From 4ebd7808ac8f77fecdb625d5f1ed2d4c0a5965aa Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 11:15:49 -0700 Subject: [PATCH] Let players withdraw stored fighter material from the review screen --- .../netlify/functions/oskiewar-generation.mjs | 20 +++++++++++++++++-- system/tests/oskiewar-generation.test.mjs | 14 +++++++++++++ xbox/live/oskiewar-wizard.mjs | 17 ++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/system/netlify/functions/oskiewar-generation.mjs b/system/netlify/functions/oskiewar-generation.mjs index d2d3bb2d87..44819ad609 100644 --- a/system/netlify/functions/oskiewar-generation.mjs +++ b/system/netlify/functions/oskiewar-generation.mjs @@ -1,7 +1,7 @@ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { authorize } from '../../backend/authorization.mjs'; import { connect } from '../../backend/database.mjs'; -import { pseudonym } from './oskiewar-consent.mjs'; +import { pseudonym, frozenFields } from './oskiewar-consent.mjs'; import { gateRoutes, verifyGenerationCapability, readGrantedPhoto, generateAppearance, RECIPE } from '../../backend/oskiewar-generation.mjs'; const respond = (statusCode, body) => ({ statusCode, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, body: JSON.stringify(body) }); export async function handler(event) { @@ -15,6 +15,22 @@ export async function handler(event) { // Trusted host entry point for recovering an authenticated player's own job. export async function generateForUser(input, userSub) { + if (input?.action === 'withdraw') { + const { REGARDE_GATEWAY_URL: gateway, REGARDE_SUBJECT_SALT: salt, REGARDE_GATEWAY_TOKEN: token } = process.env; + if (!gateway || !salt || !token) return respond(503, { message: 'REGARDE is unavailable.' }); + try { + const url = new URL(gateRoutes(gateway).media); url.pathname = url.pathname.replace(/media$/, 'withdraw'); + const ff = frozenFields({ source: ['appearance'], outputs: ['fighter_mesh'], distribution: ['private_preview', 'local_gameplay'], retention: 'bound_to_purpose_scope' }); + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ subject: pseudonym(userSub, salt), venue: 'oskiewar', operation_type: 'DATA_OPERATION', + idempotency_key: randomUUID(), operation_descriptor: { purpose: 'oskiewar_fighter_generation', description: 'Withdraw my Oskiewar material.' }, + frozen_fields: { ...ff, operation_kind: 'WITHDRAW_CONSENT', retention_constraint: 'propagate_to_named_processors', data_handling_chain: ['oskiewar'] } }), + signal: AbortSignal.timeout(10000) }); + const result = await response.json(); + if (!response.ok || !['withdrawn', 'nothing-to-withdraw'].includes(result.outcome)) return respond(502, { message: 'Withdrawal was not confirmed. Try again.' }); + return respond(200, { status: result.outcome }); + } catch { return respond(502, { message: 'Withdrawal was not confirmed. Try again.' }); } + } if (!['generate', 'status'].includes(input?.action) || !/^[a-f0-9]{64}$/.test(input?.hash) || typeof input.capability !== 'string' || input.capability.length > 20000) return respond(400, { message: 'A stored photo and capability are required.' }); const { REGARDE_GATEWAY_URL: gateway, REGARDE_SUBJECT_SALT: salt, REGARDE_GATEWAY_TOKEN: token, OPENAI_API_KEY: key } = process.env; diff --git a/system/tests/oskiewar-generation.test.mjs b/system/tests/oskiewar-generation.test.mjs index 58be47f327..646d7aff5a 100644 --- a/system/tests/oskiewar-generation.test.mjs +++ b/system/tests/oskiewar-generation.test.mjs @@ -101,3 +101,17 @@ test('generated appearance is local practice presentation only, and expires', as selected.__oskiewarFighterAppearance.validUntil=1; assert.equal(read(selected,null,false,()=>false,()=>false,'GAME')({pad:0}),null); }); + +test('withdrawal uses the signed-in subject and works without a generation token or model key', async () => { + const saved={...process.env}, originalFetch=globalThis.fetch; + Object.assign(process.env,{REGARDE_GATEWAY_URL:'https://gate.invalid/v0/gateway',REGARDE_SUBJECT_SALT:'fixture',REGARDE_GATEWAY_TOKEN:'deployer'}); + delete process.env.OPENAI_API_KEY; + let sent; + globalThis.fetch=async(url,options)=>{assert.equal(url,'https://gate.invalid/v0/withdraw');sent=JSON.parse(options.body);return Response.json({outcome:'withdrawn'});}; + try { + const result=await handler({httpMethod:'POST',headers:{authorization:'Bearer fixture'},body:JSON.stringify({action:'withdraw',subject:'another-person'})}); + assert.equal(result.statusCode,200); + assert.equal(sent.subject,pseudonym('auth0|fixture','fixture')); + assert.equal(sent.frozen_fields.operation_kind,'WITHDRAW_CONSENT'); + }finally{globalThis.fetch=originalFetch;for(const key of ['REGARDE_GATEWAY_URL','REGARDE_SUBJECT_SALT','REGARDE_GATEWAY_TOKEN','OPENAI_API_KEY']){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 7c6c608e10..e0cdc56800 100644 --- a/xbox/live/oskiewar-wizard.mjs +++ b/xbox/live/oskiewar-wizard.mjs @@ -355,6 +355,23 @@ export default function mountWizard({ sfx = () => {}, bearer = async () => null // Keep a granted appearance only in this page's memory. Current authority is // checked while equipped; expiry, sign-out, withdrawal or loss of contact // removes it. There is no public asset URL, localStorage copy or replay data. + const withdraw = document.createElement("button"); + withdraw.type = "button"; + withdraw.textContent = "Withdraw my material"; + withdraw.style.cssText = "flex:none;background:transparent;color:#900;border:0;padding:4px;font-size:14px;text-decoration:underline"; + card.append(withdraw); + withdraw.addEventListener("click", async () => { + if (busy) return; + const token = await bearer(); + if (!token) { say("Sign in to withdraw your material.", "trouble"); return; } + working(true); + try { + await generationRequest(token, "withdraw", {}); + clearFighter(); candidate = null; submitted = null; capability = null; + upload.replaceChildren(); working(false); go.disabled = true; + say("Withdrawn. Your stored material is removed and future use is blocked.", "settled"); + } catch (error) { working(false); say(error.message, "trouble"); } + }); let checking = false; setInterval(async () => { if (!accepted || checking) return; -- 2.51.2