Add an Access-protected admin panel and local interactive preview

This commit is contained in:
Matt Isenhower
2026-09-07 13:37:19 -07:00
parent 01d2b87335
commit ec797c4e13
10 changed files with 330 additions and 1 deletions

10
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"delay": "^4.4.0",
"he": "^1.1.1",
"ics": "^3.12.0",
"jose": "^6.2.12",
"json-stable-stringify": "^1.3.0",
"jsonpath-plus": "^10.4.0",
"lodash": "^4.18.1",
@@ -9841,6 +9842,15 @@
"@sideway/pinpoint": "^2.0.0"
}
},
"node_modules/jose": {
"version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-message": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",

View File

@@ -38,7 +38,8 @@
"updater:deploy": "wrangler deploy --config workers/updater/wrangler.jsonc",
"updater:tail": "wrangler tail --config workers/updater/wrangler.jsonc",
"social": "node src/app/index.js social",
"social:test": "node src/app/index.js socialTest"
"social:test": "node src/app/index.js socialTest",
"admin:preview": "node workers/updater/preview/server.mjs"
},
"dependencies": {
"@atproto/api": "^0.20.42",
@@ -52,6 +53,7 @@
"delay": "^4.4.0",
"he": "^1.1.1",
"ics": "^3.12.0",
"jose": "^6.2.12",
"json-stable-stringify": "^1.3.0",
"jsonpath-plus": "^10.4.0",
"lodash": "^4.18.1",

View File

@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPair, SignJWT } from 'jose';
import { verifyAccess } from '../../workers/updater/src/admin/access.mjs';
const { publicKey, privateKey } = await generateKeyPair('RS256');
const env = { ADMIN_HOSTNAME: 'admin.example.test', ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com', ACCESS_AUD: 'admin-audience' };
async function token({ audience = env.ACCESS_AUD, issuer = 'https://' + env.ACCESS_TEAM_DOMAIN, expires = '5m' } = {}) {
return new SignJWT({ email: 'admin@example.test' }).setProtectedHeader({ alg: 'RS256' }).setSubject('user-id').setIssuer(issuer).setAudience(audience).setExpirationTime(expires).sign(privateKey);
}
function request(jwt, host = env.ADMIN_HOSTNAME) {
return new Request(`https://${host}/admin/`, { headers: jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {} });
}
test('validates Access signature, issuer, audience, expiration and configured hostname', async () => {
assert.deepEqual(await verifyAccess(request(await token()), env, publicKey), { email: 'admin@example.test' });
for (const options of [{ audience: 'different-app' }, { issuer: 'https://other.cloudflareaccess.com' }, { expires: '0s' }])
assert.equal(await verifyAccess(request(await token(options)), env, publicKey), null);
assert.equal(await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey), null);
const wrong = await generateKeyPair('RS256');
assert.equal(await verifyAccess(request(await token()), env, wrong.publicKey), null);
});
test('fails closed without configuration or a valid assertion', async () => {
assert.equal(await verifyAccess(request(), env, publicKey), null);
assert.equal(await verifyAccess(request('forged'), env, publicKey), null);
assert.equal(await verifyAccess(request(await token()), {}, publicKey), null);
});

View File

@@ -117,6 +117,39 @@ persists the pause and deletes the alarm. `/arm` resumes scheduling; a saved
overdue run executes immediately. Do not pause
in the middle of a run: a busy pause request returns 409 so the caller can retry.
## Admin panel
`/admin/` provides a mobile-friendly panel for data-only, social-only, and full
manual runs. The authenticated browser starts a persisted request and polls its
status; closing the tab does not cancel the job. One manual request can be
pending at a time, and it shares the hourly scheduler's lock. Social runs keep
normal checkpoints and the published-data check. An interrupted manual run is
reported as failed rather than automatically replaying an uncertain social send.
The hourly schedule is preserved. Paused scheduling also blocks manual runs.
Preview the actual panel with simulated results using `npm run admin:preview`,
then open `http://127.0.0.1:8788/admin/`. This standalone preview server listens
only on loopback and has no production credentials or bindings. The production
Worker has no local-authentication bypass.
Before exposing the panel in production:
1. Create a Cloudflare Access self-hosted application protecting the entire
chosen admin hostname (for example `admin.splatoon2.ink`). Allow only the
owner's identity, with email one-time codes or their preferred provider.
2. Configure the updater with `ADMIN_HOSTNAME`, `ACCESS_TEAM_DOMAIN` (the bare
`<team>.cloudflareaccess.com` hostname), and `ACCESS_AUD` (the application's
audience tag). These settings are deliberately absent until Access is ready;
all admin routes fail closed without them.
3. Attach the admin hostname to this Worker and deploy. Open `/admin/` and verify
login, status, and a deliberate test run. No Access application or production
admin domain is created by the local preview.
The Worker verifies JWT signature, issuer, audience, expiration, and hostname.
Mutating browser requests also require a matching Origin and JSON content type.
The existing bearer-token API remains available for scripts, independently of
Access. The panel does not expose force-repost, pause, or resume controls.
## Configuration and local commands
Secrets: `NINTENDO_SESSION_ID_NA`, `NINTENDO_SESSION_ID_EU`,

View File

@@ -0,0 +1,37 @@
import { expect, it, vi, afterEach } from 'vitest';
import { adminRequest } from './src/admin/routes.mjs';
import { verifyAccess } from './src/admin/access.mjs';
vi.mock('./src/admin/access.mjs', () => ({ verifyAccess: vi.fn() }));
afterEach(() => vi.resetAllMocks());
const url = 'https://admin.example.test';
const post = (mode = 'both', origin = url) => new Request(url + '/admin/api/run', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }) });
it('requires Access before exposing the panel, status or actions', async () => {
verifyAccess.mockResolvedValue(null);
const get = vi.fn();
for (const request of [new Request(url + '/admin/'), new Request(url + '/admin/api/status'), post()])
expect((await adminRequest(request, {}, get)).status).toBe(401);
expect(get).not.toHaveBeenCalled();
});
it('rejects cross-origin requests and unknown modes before scheduling work', async () => {
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
const get = vi.fn();
expect((await adminRequest(post('both', 'https://other.test'), {}, get)).status).toBe(403);
expect((await adminRequest(post('force'), {}, get)).status).toBe(400);
expect(get).not.toHaveBeenCalled();
});
it('returns an accepted run immediately and reports overlap without starting another', async () => {
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
const startManual = vi.fn(async mode => ({ ok: true, run: { id: 'one', mode, status: 'queued' } }));
expect((await adminRequest(post('social'), {}, () => ({ startManual }))).status).toBe(202);
expect(startManual).toHaveBeenCalledWith('social');
startManual.mockResolvedValue({ ok: false, busy: true });
expect((await adminRequest(post(), {}, () => ({ startManual }))).status).toBe(409);
});
it('serves the mobile panel with no-store and a nonce-based content policy', async () => {
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
const response = await adminRequest(new Request(url + '/admin/'), {}, vi.fn());
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('content-security-policy')).toContain('frame-ancestors \'none\'');
expect(await response.text()).not.toContain('__NONCE__');
});

View File

@@ -0,0 +1,45 @@
// UI preview only. No Worker bindings, credentials, or production network requests.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
const port = Number(process.env.ADMIN_PREVIEW_PORT || 8788);
const hour = Math.floor(Date.now() / 3600000) * 3600000;
const state = {
preview: true, user: { email: 'Local preview' }, paused: false, busy: false,
hourlyAt: hour + 3600000 + 10000, retryAt: null, pendingManual: null,
lastManualRun: { id: 'previous', mode: 'social', ok: false, status: 'failed', startedAt: hour - 1800000, runMs: 42300, error: 'Browser screenshot timed out. The post was not sent.' },
lastRun: { mode: 'both', ok: true, startedAt: hour + 10000, runMs: 18200, updaters: { ok: true }, social: { ok: true } },
};
createServer(async (request, response) => {
response.setHeader('Cache-Control', 'no-store');
const url = new URL(request.url, `http://127.0.0.1:${port}`);
function json(body, status = 200) { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); }
try {
if (request.method === 'GET' && ['/', '/admin', '/admin/'].includes(url.pathname)) {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return response.end((await readFile(new URL('../src/admin/page.html', import.meta.url), 'utf8')).replaceAll('__NONCE__', 'preview'));
}
if (request.method === 'GET' && url.pathname === '/admin/api/status') return json(state);
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
if (request.headers.origin !== `http://${request.headers.host}`) return json({ error: 'Invalid origin.' }, 403);
if (state.busy) return json({ error: 'A run is already active.' }, 409);
let body = '';
for await (const chunk of request) { body += chunk; if (body.length > 1024) return json({ error: 'Request too large.' }, 413); }
const { mode } = JSON.parse(body);
if (!['data', 'social', 'both'].includes(mode)) return json({ error: 'Unknown mode.' }, 400);
const run = { id: randomUUID(), mode, status: 'queued', requestedAt: Date.now() };
state.busy = true; state.pendingManual = run;
setTimeout(() => { run.status = 'running'; run.startedAt = Date.now(); }, 700);
setTimeout(() => {
state.lastManualRun = { ...run, ok: true, status: 'succeeded', finishedAt: Date.now(), runMs: Date.now() - run.startedAt,
updaters: mode === 'social' ? { ok: true, skipped: true } : { ok: true, updaters: ['Schedules', 'Timeline', 'CoopSchedules', 'Merchandises'].map(name => ({ name, ok: true })) },
social: mode === 'data' ? { ok: true, skipped: true } : { ok: true, posts: [{ name: 'Schedule', ok: true, simulated: true }] },
};
state.busy = false; state.pendingManual = null;
}, 6000);
return json({ ok: true, run }, 202);
}
json({ error: 'Not found.' }, 404);
} catch { json({ error: 'Invalid preview request.' }, 400); }
}).listen(port, '127.0.0.1', () => console.log(`Admin preview: http://127.0.0.1:${port}/admin/ (simulated runs only)`));

View File

@@ -0,0 +1,28 @@
import { createRemoteJWKSet, jwtVerify } from 'jose';
let keySets = new Map();
export async function verifyAccess(request, env, resolveKey) {
let domain = env.ACCESS_TEAM_DOMAIN;
if (!domain || !env.ACCESS_AUD || new URL(request.url).hostname !== env.ADMIN_HOSTNAME)
return null;
let token = request.headers.get('Cf-Access-Jwt-Assertion');
if (!token)
return null;
try {
let issuer = new URL(`https://${domain}`);
if (issuer.hostname !== domain || !domain.endsWith('.cloudflareaccess.com'))
return null;
if (!resolveKey) {
if (!keySets.has(domain))
keySets.set(domain, createRemoteJWKSet(new URL('/cdn-cgi/access/certs', issuer), { timeoutDuration: 5000 }));
resolveKey = keySets.get(domain);
}
let { payload } = await jwtVerify(token, resolveKey, {
issuer: issuer.origin, audience: env.ACCESS_AUD, algorithms: ['RS256'], requiredClaims: ['exp', 'sub'],
});
return { email: payload.email ?? 'Authenticated administrator' };
} catch {
return null;
}
}

View File

@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Splatoon2.ink · Control room</title>
<style nonce="__NONCE__">
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#f3f2fa;background:#101116;font-synthesis:none;--muted:#aaaaba;--line:#30313d;--purple:#b1a0ff;--green:#91ddb6}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 85% 0%,#242037 0,transparent 48%);min-height:100vh}button,a{-webkit-tap-highlight-color:transparent}button{font:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.45}a{color:var(--purple)}button:focus-visible,a:focus-visible{outline:3px solid var(--purple);outline-offset:4px}.shell{width:min(920px,100% - 48px);margin:auto;padding:38px 0 30px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:30px;border-bottom:1px solid var(--line)}.brand{font-weight:750;font-size:18px;letter-spacing:-.5px}.brand span{color:var(--purple)}.tag{font-size:11px;letter-spacing:1.4px;text-transform:uppercase;color:var(--muted);padding:6px 10px;border:1px solid var(--line);border-radius:5px}.intro{padding:38px 0 26px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:2px;color:var(--purple);text-transform:uppercase}h1{font-size:38px;letter-spacing:-1.5px;line-height:1.15;margin:10px 0 12px}p{color:var(--muted);font-size:14px;line-height:1.65;margin:0}.preview{display:none;padding:12px 16px;border:1px solid #54476b;background:#251f32;border-radius:9px;color:#d0bee9;margin-bottom:20px;font-size:13px}.status{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:20px 23px;border:1px solid var(--line);border-radius:12px;background:#1b1c25;margin-bottom:30px}.state-line{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:650}.dot{height:8px;width:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px #91ddb612}.dot.busy{background:var(--purple)}.dot.warning{background:#edb685}.sub{font-size:12px;color:var(--muted);margin-top:7px}.next{text-align:right}.next strong{font-size:14px;font-weight:500}.next small{display:block;color:var(--muted);font-size:11px;margin-bottom:7px}.section-label{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}h2{font-size:15px;margin:0;font-weight:650}.hint{font-size:12px;color:var(--muted)}.actions{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:17px}.action{display:flex;flex-direction:column;background:#191a23;border:1px solid var(--line);border-radius:12px;padding:23px 21px}.icon{width:38px;height:38px;display:grid;place-items:center;border-radius:10px;background:#2c2640;color:var(--purple);margin-bottom:22px;font-size:19px}.action:nth-child(2) .icon{background:#20352e;color:#91ddb6}.action:nth-child(3) .icon{background:#333024;color:#e6d595}h3{font-size:16px;margin:0 0 9px;font-weight:650}.action p{font-size:13px;line-height:1.65;flex:1;margin-bottom:24px}.run{width:100%;border:1px solid #454050;border-radius:7px;background:#272431;color:#e6defc;padding:11px 8px;font-size:13px;font-weight:650;min-height:44px}.run:hover:not(:disabled){background:#373046;border-color:#8c75b7}.action:first-child .run{background:#b6a4fa;color:#191226;border-color:#b6a4fa}.action:first-child .run:hover:not(:disabled){background:#cbbbff}.note{font-size:12px;color:var(--muted);margin-bottom:32px;display:flex;gap:8px}.notice{border-radius:8px;background:#272333;border:1px solid #615277;padding:13px 16px;font-size:13px;margin:0 0 20px;line-height:1.6}.notice:empty{display:none}.history{border:1px solid var(--line);border-radius:12px;overflow:hidden}.record{padding:20px 23px;background:#191a23}.record+.record{border-top:1px solid var(--line)}.record-top{display:flex;justify-content:space-between;align-items:center;gap:16px}.record-name{font-weight:550;font-size:14px}.badge{font-size:11px;padding:4px 8px;border-radius:5px;color:var(--green);background:#20352b;white-space:nowrap}.badge.failed{color:#edbd9b;background:#392b27}.badge.neutral{color:var(--muted);background:#292a35}.detail{font-size:12px;color:var(--muted);line-height:1.6;margin-top:8px}.error{color:#edbd9b;margin-top:8px;font-size:12px;overflow-wrap:anywhere}details{margin-top:12px;font-size:12px;color:var(--muted)}summary{cursor:pointer;min-height:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere;padding:12px;background:#111218;border-radius:7px;font-size:11px;line-height:1.6}.footer{display:flex;justify-content:space-between;gap:16px;color:#898a9d;font-size:11px;padding-top:26px;line-height:1.7}.refresh{background:transparent;border:0;color:var(--purple);font-size:12px;padding:8px;min-height:36px}#sign-in{display:none;margin-left:8px}.identity{overflow-wrap:anywhere}
@media(max-width:640px){.shell{width:calc(100% - 32px);padding-top:22px}.top{padding-bottom:22px}.intro{padding:28px 0 24px}h1{font-size:32px}.actions{grid-template-columns:1fr}.action{padding:20px;display:grid;grid-template-columns:40px 1fr;column-gap:16px}.icon{grid-row:1/3;margin:0}.action p{margin:0 0 16px}.run{grid-column:2}.status{padding:18px 17px;align-items:flex-start}.next strong{font-size:12px}.sub{max-width:180px;line-height:1.5}.record{padding:18px 17px}.footer{flex-direction:column;gap:5px}.hint{font-size:11px}.tag{font-size:10px}h3{margin-top:2px}.note{line-height:1.6}}
</style>
</head>
<body>
<main class="shell">
<header class="top"><div class="brand">splatoon<span>2</span>.ink</div><span class="tag">Administration</span></header>
<section class="intro"><div class="eyebrow">Control room</div><h1>A little maintenance.</h1><p>Check the latest runs, refresh the data, or catch up on social posts.</p></section>
<div class="preview" id="preview">Local preview · All runs are simulated. No production data or social accounts are touched.</div>
<section class="status" aria-label="Scheduler status"><div><div class="state-line"><span class="dot" id="dot"></span><span id="state">Connecting…</span></div><div class="sub" id="state-detail">Checking the updater</div></div><div class="next"><small>Next scheduled run</small><strong id="next">—</strong></div></section>
<div class="section-label"><h2>Start a run</h2><span class="hint">One run at a time</span></div>
<section class="actions" aria-label="Manual actions">
<article class="action"><div class="icon" aria-hidden="true">↻</div><h3>Update game data</h3><p>Refresh schedules, gear, and the rest of the SplatNet data.</p><button class="run" data-mode="data" disabled>Update data <span aria-hidden="true">→</span></button></article>
<article class="action"><div class="icon" aria-hidden="true">↗</div><h3>Catch up on social</h3><p>Send any due Bluesky posts using the latest published data.</p><button class="run" data-mode="social" disabled>Run social cycle <span aria-hidden="true">→</span></button></article>
<article class="action"><div class="icon" aria-hidden="true">⇄</div><h3>Run the full cycle</h3><p>Update game data, then send any social posts that are due.</p><button class="run" data-mode="both" disabled>Update &amp; post <span aria-hidden="true">→</span></button></article>
</section>
<p class="note"><span aria-hidden="true">✓</span> Successful posts are remembered. Retrying a cycle skips posts already sent.</p>
<div id="notice" class="notice" role="status" aria-live="polite"></div><a id="sign-in" href="/admin/">Sign in again</a>
<section><div class="section-label"><h2>Recent activity</h2><button class="refresh" id="refresh">Refresh status ↻</button></div><div class="history" id="history"><div class="record"><p>Loading recent runs…</p></div></div></section>
<footer class="footer"><div class="identity" id="identity">Protected by Cloudflare Access</div><div id="checked">Times shown in your local timezone.</div></footer>
</main>
<script nonce="__NONCE__">
const names = { data: 'Data update', social: 'Social cycle', both: 'Full cycle' };
const buttons = [...document.querySelectorAll('[data-mode]')];
const $ = id => document.getElementById(id);
let state, sending = false, polling = false, timer, lastRunId;
function date(value) { return value ? new Date(value).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—'; }
function controls() { for (const button of buttons) button.disabled = sending || !state || state.busy || state.paused; }
function record(label, run) {
const row = document.createElement('div'); row.className = 'record';
const top = document.createElement('div'); top.className = 'record-top';
const title = document.createElement('span'); title.className = 'record-name'; title.textContent = label;
const badge = document.createElement('span'); badge.className = 'badge' + (!run ? ' neutral' : run.ok ? '' : ' failed'); badge.textContent = !run ? 'No runs yet' : run.ok ? 'Completed' : 'Needs attention';
top.append(title, badge); row.append(top);
const detail = document.createElement('div'); detail.className = 'detail';
detail.textContent = run ? `${names[run.mode] || 'Full cycle'} · ${date(run.startedAt)} · ${Math.max(1, Math.round((run.runMs || 0) / 1000))}s` : 'Results will appear here after a run.'; row.append(detail);
if (run) {
const messages = [run.error, ...(run.updaters?.updaters || []).filter(item => !item.ok).map(item => item.error || `${item.name} failed`), ...(run.social?.posts || []).filter(item => item && !item.ok).map(item => item.error || 'A social post failed')].filter(Boolean);
if (messages.length) { const error = document.createElement('div'); error.className = 'error'; error.textContent = messages.join(' · '); row.append(error); }
const details = document.createElement('details'), summary = document.createElement('summary'), pre = document.createElement('pre'); summary.textContent = 'View run details'; pre.textContent = JSON.stringify(run, null, 2); details.append(summary, pre); row.append(details);
}
return row;
}
function render() {
$('preview').style.display = state.preview ? 'block' : 'none';
$('state').textContent = state.busy ? 'Run in progress' : state.paused ? 'Scheduler paused' : 'Ready when you are';
$('dot').className = 'dot' + (state.busy ? ' busy' : state.paused ? ' warning' : '');
$('state-detail').textContent = state.pendingManual ? `${names[state.pendingManual.mode]} · ${state.pendingManual.status === 'queued' ? 'waiting to start' : 'running'}` : state.busy ? 'A scheduled or API run is active.' : state.paused ? 'Manual runs are disabled while paused.' : 'The hourly schedule is running automatically.';
$('next').textContent = state.paused ? 'Paused' : date(state.retryAt || state.hourlyAt);
// Keep expanded details open during polling when the records have not changed.
const signature = JSON.stringify([state.lastManualRun, state.lastRun]);
if ($('history').dataset.signature !== signature) {
$('history').replaceChildren(record('Last manual run', state.lastManualRun), record('Last scheduled run', state.lastRun));
$('history').dataset.signature = signature;
}
if (lastRunId && state.lastManualRun?.id === lastRunId) { $('notice').textContent = state.lastManualRun.ok ? 'Your run finished successfully.' : 'Your run needs attention. See the result below before retrying.'; lastRunId = null; }
$('identity').textContent = state.preview ? 'Preview session · Simulated results' : `Signed in as ${state.user.email}`;
$('checked').textContent = `Checked ${new Date().toLocaleTimeString([], {hour: 'numeric', minute: '2-digit', second: '2-digit'})} · Local time`;
controls();
}
async function api(path, options) {
const response = await fetch('/admin/api/' + path, { ...options, redirect: 'error', signal: AbortSignal.timeout(15000) });
if (response.status === 401 || response.status === 403) { $('sign-in').style.display = 'inline-block'; throw new Error('Your session may have expired. Sign in again to continue.'); }
if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Could not read status. Reload to sign in again.');
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'The request failed.');
return result;
}
async function poll() {
clearTimeout(timer);
if (polling) return;
polling = true;
try { state = await api('status'); render(); }
catch (error) { state = null; controls(); $('state').textContent = 'Connection unavailable'; $('state-detail').textContent = 'Reconnect before starting a run.'; $('dot').className = 'dot warning'; $('notice').textContent = error.message; }
finally { polling = false; if (!document.hidden) timer = setTimeout(poll, state?.busy ? 2000 : 15000); }
}
for (const button of buttons) button.addEventListener('click', async () => {
sending = true; controls(); $('notice').textContent = 'Starting your run…';
try {
const result = await api('run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: button.dataset.mode }) });
lastRunId = result.run.id;
if (state) { state.busy = true; state.pendingManual = result.run; render(); }
$('notice').textContent = 'Run accepted. You can close this page and check back later.';
} catch (error) { $('notice').textContent = `${error.message} Check status before trying again; the run may already have started.`; }
finally { sending = false; await poll(); controls(); }
});
$('refresh').addEventListener('click', poll);
document.addEventListener('visibilitychange', () => { if (document.hidden) clearTimeout(timer); else poll(); });
poll();
</script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
import page from './page.html';
import { verifyAccess } from './access.mjs';
import { MANUAL_MODES } from '../Scheduler.mjs';
const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
const json = (body, status = 200) => Response.json(body, { status, headers });
export async function adminRequest(request, env, getScheduler) {
let user = await verifyAccess(request, env);
if (!user)
return json({ error: 'Sign in through Cloudflare Access to continue.' }, 401);
let url = new URL(request.url);
if (request.method === 'GET' && ['/admin', '/admin/'].includes(url.pathname)) {
let nonce = crypto.randomUUID();
return new Response(page.replaceAll('__NONCE__', nonce), { headers: {
...headers,
'Content-Type': 'text/html; charset=utf-8',
'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'`,
'Referrer-Policy': 'no-referrer',
} });
}
if (request.method === 'GET' && url.pathname === '/admin/api/status')
return json({ ...await getScheduler().status(), user, preview: false });
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
// Access cookies authenticate the user; require a same-origin JSON request as well.
if (request.headers.get('Origin') !== url.origin || request.headers.get('Content-Type') !== 'application/json')
return json({ error: 'A same-origin JSON request is required.' }, 403);
let mode;
try { ({ mode } = await request.json()); } catch { return json({ error: 'Invalid request.' }, 400); }
if (!MANUAL_MODES.includes(mode))
return json({ error: 'Unknown run mode.' }, 400);
let result = await getScheduler().startManual(mode);
return json(result, result.ok ? 202 : result.busy || result.paused ? 409 : 400);
}
return json({ error: 'Not found.' }, 404);
}

View File

@@ -1,3 +1,4 @@
import { adminRequest } from './admin/routes.mjs';
import { withSentry, instrumentDurableObjectWithSentry } from '@sentry/cloudflare';
import { Scheduler as SchedulerClass } from './Scheduler.mjs';
import { createLogger, describeError } from './log.mjs';
@@ -44,6 +45,14 @@ export default withSentry(sentryOptions, {
// Manual runs use the same owner as alarms. Targeted runs refresh data only.
async fetch(request, env, ctx) {
let url = new URL(request.url);
if (url.pathname === '/admin' || url.pathname.startsWith('/admin/')) {
try {
return await adminRequest(request, env, () => scheduler(env));
} catch (error) {
createLogger('admin').error('Admin request failed', describeError(error));
return Response.json({ error: 'The request failed. Refresh status before retrying.' }, { status: 500, headers: { 'Cache-Control': 'no-store' } });
}
}
let route = `${request.method} ${url.pathname}`;
if (!['POST /run', 'POST /arm', 'POST /pause', 'GET /status', 'GET /list'].includes(route))
return new Response('Not found', { status: 404 });