diff --git a/system/netlify/functions/oskiewar-consent.mjs b/system/netlify/functions/oskiewar-consent.mjs index a43b68625e..24b71c1722 100644 --- a/system/netlify/functions/oskiewar-consent.mjs +++ b/system/netlify/functions/oskiewar-consent.mjs @@ -76,7 +76,7 @@ function subsetOf(values, vocabulary) { // different purpose — an oskiewar grant cannot be correlated against any other // REGARDE operation AC might one day run — and hashed so this side of the wire // is the last place the Auth0 subject exists. -function pseudonym(sub, salt) { +export function pseudonym(sub, salt) { return "sub_" + createHash("sha256").update(`${salt}:${PURPOSE}:${sub}`) .digest("hex").slice(0, 32); } @@ -233,11 +233,8 @@ export async function handler(event) { // Those govern a finished bundle, not what a worker may touch. It // expires in minutes, because a capability is for one job run. // - // No worker consumes it yet. It is passed through rather than withheld - // because the next stage binds to it, and because a player who has just - // been told "allowed, and recorded" should be able to see the shape of - // what they permitted. - capability: answer?.capability ?? null, + // Submission consumes this authority before storing any player media. + capability: outcome === "allow" ? answer?.capability ?? null : null, }), }; } diff --git a/system/netlify/functions/oskiewar-submission.mjs b/system/netlify/functions/oskiewar-submission.mjs new file mode 100644 index 0000000000..77ea0f8078 --- /dev/null +++ b/system/netlify/functions/oskiewar-submission.mjs @@ -0,0 +1,34 @@ +// Authenticated transport only. REGARDE owns capability and retention checks. +import { authorize } from '../../backend/authorization.mjs'; +import { pseudonym } from './oskiewar-consent.mjs'; + +const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }; +const fail = (statusCode, message) => ({ statusCode, headers, body: JSON.stringify({ message }) }); + +export async function handler(event) { + if (event.httpMethod !== 'POST') return fail(405, 'POST only.'); + const user = await authorize(event.headers); + if (!user?.sub) return fail(401, 'Sign in before submitting material.'); + if (typeof event.body !== 'string' || Buffer.byteLength(event.body) > 1500000) return fail(413, 'Upload at most 1 MiB total.'); + let body; + try { body = JSON.parse(event.body); } catch { return fail(400, 'Unreadable submission.'); } + if (!body || typeof body.capability !== 'string' || !Array.isArray(body.files)) return fail(400, 'A capability and files are required.'); + const { REGARDE_GATEWAY_URL: gateway, REGARDE_SUBJECT_SALT: salt, + REGARDE_GATEWAY_TOKEN: token } = process.env; + if (!gateway || !salt || !token) return fail(503, 'The submission desk is unavailable.'); + try { + const url = new URL(gateway); + url.pathname = url.pathname.replace(/\/gateway\/?$/, '/submission'); + if (!url.pathname.endsWith('/submission')) return fail(503, 'The submission desk is unconfigured.'); + const upstream = await fetch(url, { + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ capability: body.capability, subject: pseudonym(user.sub, salt), + files: body.files.map(file => ({ source: file?.source, base64: file?.base64 })) }), + signal: AbortSignal.timeout(10000), + }); + const result = await upstream.json(); + if (!upstream.ok) return fail(upstream.status, result.error || 'Submission refused.'); + return { statusCode: 201, headers, body: JSON.stringify({ manifest: result.manifest, + retention: result.retention, purge_at: result.purge_at }) }; + } catch { return fail(502, 'Submission did not complete. Check your connection before trying again.'); } +} diff --git a/system/tests/oskiewar-submission.test.mjs b/system/tests/oskiewar-submission.test.mjs new file mode 100644 index 0000000000..d15b001dd6 --- /dev/null +++ b/system/tests/oskiewar-submission.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test, { mock } from 'node:test'; +mock.module('../backend/authorization.mjs', { exports: { authorize: async headers => headers?.authorization ? { sub: 'auth0|fixture' } : null } }); +const { handler } = await import('../netlify/functions/oskiewar-submission.mjs'); +const { pseudonym } = await import('../netlify/functions/oskiewar-consent.mjs'); +test('submission bridge authenticates, strips filenames/identity/retention, forwards capability, fails closed', async () => { + const saved = { ...process.env }; const originalFetch = globalThis.fetch; + try { + process.env.REGARDE_GATEWAY_URL = 'https://gate.invalid/v0/gateway'; + process.env.REGARDE_SUBJECT_SALT = 'fixture'; process.env.REGARDE_GATEWAY_TOKEN = 'deployer'; + let seen; + globalThis.fetch = async (url, options) => { seen = { url: String(url), ...options }; return Response.json({ manifest: [{ hash: 'sha256:fixture' }], retention: 'pilot_deadline', purge_at: '2030-01-01T00:00:00.000Z' }, { status: 201 }); }; + const event = { httpMethod: 'POST', headers: { authorization: 'Bearer fixture' }, body: JSON.stringify({ capability: 'signed-capability', subject: 'imposter', retention: 'forever', files: [{source:'appearance', base64: btoa('fixture'), filename: 'person.jpg'}] }) }; + assert.equal((await handler({ ...event, headers: {} })).statusCode, 401); + assert.equal(seen, undefined); + const result = await handler(event); assert.equal(result.statusCode, 201); + assert.equal(seen.url, 'https://gate.invalid/v0/submission'); + assert.deepEqual(JSON.parse(seen.body), { subject: pseudonym('auth0|fixture', 'fixture'), capability: 'signed-capability', files: [{source:'appearance', base64:btoa('fixture')}] }); + assert.equal(seen.headers.Authorization, 'Bearer deployer'); + globalThis.fetch = async () => Response.json({error:'expired'}, {status:403}); + assert.equal((await handler(event)).statusCode, 403); + globalThis.fetch = async () => { throw Error('offline'); }; + assert.equal((await handler(event)).statusCode, 502); + delete process.env.REGARDE_GATEWAY_TOKEN; + assert.equal((await handler(event)).statusCode, 503); + } finally { globalThis.fetch = originalFetch; for (const key of ['REGARDE_GATEWAY_URL','REGARDE_SUBJECT_SALT','REGARDE_GATEWAY_TOKEN']) { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } } +}); diff --git a/xbox/live/oskiewar-wizard.mjs b/xbox/live/oskiewar-wizard.mjs index 24c8e82d1f..f275ae1f6e 100644 --- a/xbox/live/oskiewar-wizard.mjs +++ b/xbox/live/oskiewar-wizard.mjs @@ -136,6 +136,9 @@ export default function mountWizard({ sfx = () => {}, bearer = async () => null const go = panel.querySelector("#wizard-go"); const back = panel.querySelector("#wizard-back"); let busy = false; + let capability = null; + const upload = document.createElement("div"); + sections.after(upload); const row = (name, kind, { value, label, note: hint, on }) => { const wrap = document.createElement("label"); @@ -184,6 +187,11 @@ export default function mountWizard({ sfx = () => {}, bearer = async () => null function open() { if (!panel.hidden) return; + capability = null; + upload.replaceChildren(); + sections.hidden = false; + go.textContent = "allow this much"; + go.disabled = false; panel.hidden = false; globalThis.__oskiewarWizardOpen = true; say(""); @@ -218,6 +226,37 @@ export default function mountWizard({ sfx = () => {}, bearer = async () => null "trouble"); return; } + if (capability) { + const selected = [...upload.querySelectorAll('input[type="file"]')] + .filter(input => input.files.length); + if (!selected.length) { say("Choose material to submit.", "trouble"); return; } + if (selected.reduce((sum, input) => sum + input.files[0].size, 0) > 1024 * 1024) { + say("Choose files totaling at most 1 MiB.", "trouble"); return; + } + working(true); + say("Submitting…"); + try { + const files = await Promise.all(selected.map(async input => { + const bytes = new Uint8Array(await input.files[0].arrayBuffer()); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return { source: input.name, base64: btoa(binary) }; + })); + const response = await fetch("/api/oskiewar-submission", { + method: "POST", headers: { "Content-Type": "application/json", authorization: "Bearer " + token }, + body: JSON.stringify({ capability: capability.jws, files }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.message || "Submission refused."); + upload.replaceChildren(); + capability = null; + working(false); + go.disabled = true; + say(result.purge_at ? `Submitted. Purge scheduled for ${new Date(result.purge_at).toLocaleString()}. Nothing generated yet.` + : "Submitted. Kept while your grant stands. Nothing generated yet.", "settled"); + } catch (error) { working(false); say(error.message, "trouble"); } + return; + } const answer = { source: picked("source"), outputs: picked("outputs"), @@ -245,6 +284,27 @@ export default function mountWizard({ sfx = () => {}, bearer = async () => null working(false); if (result?.outcome === "allow") { + if (result.capability?.jws && Array.isArray(result.capability.sources)) { + capability = result.capability; + sections.hidden = true; + upload.replaceChildren(); + const hint = document.createElement("p"); + hint.textContent = "Choose your own material. At most 1 MiB total."; + upload.append(hint); + for (const source of capability.sources) { + const label = document.createElement("label"); + label.style.display = "block"; + label.textContent = SECTIONS[0].rows.find(row => row.value === source)?.label ?? source; + const input = document.createElement("input"); + input.type = "file"; + input.style.display = "block"; + input.style.margin = "6px 0 12px"; + input.name = source; + label.append(input); + upload.append(label); + } + go.textContent = "submit material"; + } sfx("hit", .9, 0); say("Allowed, and recorded. Nothing has been generated yet.", "settled"); receiptLine.textContent = result.receipt?.hash