diff --git a/test/admin/access.test.mjs b/test/admin/access.test.mjs index ee8296d..37ce5a9 100644 --- a/test/admin/access.test.mjs +++ b/test/admin/access.test.mjs @@ -4,19 +4,44 @@ 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); +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 } : {} }); + 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.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); + 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); }); diff --git a/test/admin/log.test.mjs b/test/admin/log.test.mjs index 7993b09..6f155bc 100644 --- a/test/admin/log.test.mjs +++ b/test/admin/log.test.mjs @@ -14,15 +14,34 @@ test('bounds retained logs, redacts secrets, and preserves normal console output assert.equal(capture.snapshot.omitted, 6); assert.equal(capture.snapshot.lines.at(-1).text, '[redacted] Bearer [redacted]'); assert.equal(consoleLog.mock.callCount(), 206); - } finally { mock.restoreAll(); } + } finally { + mock.restoreAll(); + } }); test('concurrent run contexts do not capture each other or unrelated messages', async () => { mock.method(console, 'info', () => {}); try { - const a = createRunLog(), b = createRunLog(); - await Promise.all([a.run(async () => { await Promise.resolve(); logMessage('info', 'a'); }), b.run(async () => { logMessage('info', 'b'); })]); + const a = createRunLog(), + b = createRunLog(); + await Promise.all([ + a.run(async () => { + await Promise.resolve(); + logMessage('info', 'a'); + }), + b.run(async () => { + logMessage('info', 'b'); + }), + ]); logMessage('info', 'outside'); - assert.deepEqual(a.snapshot.lines.map(l => l.text), ['a']); - assert.deepEqual(b.snapshot.lines.map(l => l.text), ['b']); - } finally { mock.restoreAll(); } + assert.deepEqual( + a.snapshot.lines.map((l) => l.text), + ['a'], + ); + assert.deepEqual( + b.snapshot.lines.map((l) => l.text), + ['b'], + ); + } finally { + mock.restoreAll(); + } }); diff --git a/workers/updater/admin.spec.mjs b/workers/updater/admin.spec.mjs index 946499e..8681ff8 100644 --- a/workers/updater/admin.spec.mjs +++ b/workers/updater/admin.spec.mjs @@ -4,12 +4,21 @@ 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 }) }); +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()]) + 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(); }); @@ -22,7 +31,10 @@ it('rejects cross-origin requests and unknown modes before scheduling work', asy }); 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' } })); + 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 }); diff --git a/workers/updater/preview/server.mjs b/workers/updater/preview/server.mjs index 0663d9d..48934aa 100644 --- a/workers/updater/preview/server.mjs +++ b/workers/updater/preview/server.mjs @@ -6,45 +6,148 @@ 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: { logs: { lines: [{ at: hour - 1800000, level: 'info', text: 'Starting social cycle' }, { at: hour - 1757700, level: 'error', text: 'Browser screenshot timed out. The post was not sent.' }], omitted: 0 }, 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 } }, + preview: true, + user: { email: 'Local preview' }, + paused: false, + busy: false, + hourlyAt: hour + 3600000 + 10000, + retryAt: null, + pendingManual: null, + lastManualRun: { + logs: { + lines: [ + { at: hour - 1800000, level: 'info', text: 'Starting social cycle' }, + { + at: hour - 1757700, + level: 'error', + text: 'Browser screenshot timed out. The post was not sent.', + }, + ], + omitted: 0, + }, + 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)); } + 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')); + 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 (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); } + 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; - const logs = { lines: [{ at: Date.now(), level: 'info', text: `Starting ${mode} run` }], omitted: 0 }; + state.busy = true; + state.pendingManual = run; + const logs = { + lines: [{ at: Date.now(), level: 'info', text: `Starting ${mode} run` }], + omitted: 0, + }; state.activeRun = { mode, startedAt: Date.now(), logs }; - setTimeout(() => logs.lines.push({ at: Date.now(), level: 'info', text: mode === 'social' ? '[Social] Checking post checkpoints…' : '[Updater] [Schedules] Updating data…' }), 1500); - setTimeout(() => logs.lines.push({ at: Date.now(), level: 'info', text: mode === 'social' ? '[Social] Preparing a due Bluesky post…' : '[Updater] [Schedules] Done.' }), 3200); - setTimeout(() => logs.lines.push({ at: Date.now(), level: 'info', text: '[Preview] Simulated work completed.' }), 5000); - setTimeout(() => { run.status = 'running'; run.startedAt = Date.now(); }, 700); + setTimeout( + () => + logs.lines.push({ + at: Date.now(), + level: 'info', + text: + mode === 'social' + ? '[Social] Checking post checkpoints…' + : '[Updater] [Schedules] Updating data…', + }), + 1500, + ); + setTimeout( + () => + logs.lines.push({ + at: Date.now(), + level: 'info', + text: + mode === 'social' + ? '[Social] Preparing a due Bluesky post…' + : '[Updater] [Schedules] Done.', + }), + 3200, + ); + setTimeout( + () => + logs.lines.push({ + at: Date.now(), + level: 'info', + text: '[Preview] Simulated work completed.', + }), + 5000, + ); setTimeout(() => { - state.lastManualRun = { ...run, logs, 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 }] }, + run.status = 'running'; + run.startedAt = Date.now(); + }, 700); + setTimeout(() => { + state.lastManualRun = { + ...run, + logs, + 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; state.activeRun = null; + state.busy = false; + state.pendingManual = null; + state.activeRun = 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)`)); + } 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)`), +); diff --git a/workers/updater/src/admin/access.mjs b/workers/updater/src/admin/access.mjs index 9b5d677..d5fc84f 100644 --- a/workers/updater/src/admin/access.mjs +++ b/workers/updater/src/admin/access.mjs @@ -7,19 +7,23 @@ export async function verifyAccess(request, env, resolveKey) { 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; + if (!token) return null; try { let issuer = new URL(`https://${domain}`); - if (issuer.hostname !== domain || !domain.endsWith('.cloudflareaccess.com')) - return null; + 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 })); + 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'], + issuer: issuer.origin, + audience: env.ACCESS_AUD, + algorithms: ['RS256'], + requiredClaims: ['exp', 'sub'], }); return { email: payload.email ?? 'Authenticated administrator' }; } catch { diff --git a/workers/updater/src/admin/page.html b/workers/updater/src/admin/page.html index cf73845..9835e07 100644 --- a/workers/updater/src/admin/page.html +++ b/workers/updater/src/admin/page.html @@ -1,121 +1,742 @@ - + - - - - -Splatoon2.ink · Administration - - - -
-
splatoon2.ink
Administration
-
Local preview · All runs are simulated. No production data or social accounts are touched.
-
Connecting…
Checking the updater
-

Start a run

One run at a time
-
-

Update game data

Refresh schedules, gear, and the rest of the SplatNet data.

-

Catch up on social

Send any due Bluesky posts using the latest published data.

-

Run the full cycle

Update game data, then send any social posts that are due.

-
-

Successful posts are remembered. Retrying a cycle skips posts already sent.

-
Sign in again - -

Loading recent runs…

- -
- - + + + + + Splatoon2.ink · Administration + + + +
+
+
splatoon2.ink
+ Administration +
+
+ Local preview · All runs are simulated. No production data or social accounts are touched. +
+
+
+
+ Connecting… +
+
Checking the updater
+
+ +
+
+

Start a run

+ One run at a time +
+
+
+ +

Update game data

+

Refresh schedules, gear, and the rest of the SplatNet data.

+ +
+
+ +

Catch up on social

+

Send any due Bluesky posts using the latest published data.

+ +
+
+ +

Run the full cycle

+

Update game data, then send any social posts that are due.

+ +
+
+

+ Successful posts are remembered. Retrying a cycle skips + posts already sent. +

+
+ Sign in again + +
+ +
+

Loading recent runs…

+
+
+ +
+ + diff --git a/workers/updater/src/admin/routes.mjs b/workers/updater/src/admin/routes.mjs index 508614d..bae4f3b 100644 --- a/workers/updater/src/admin/routes.mjs +++ b/workers/updater/src/admin/routes.mjs @@ -7,28 +7,35 @@ 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); + 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', - } }); + 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 }); + 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') + 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); + 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); }