From 3fc8a39912289778e09fa3afc4967cf2f041c29b Mon Sep 17 00:00:00 2001 From: x Date: Thu, 6 Aug 2026 20:57:46 +0800 Subject: [PATCH] =?UTF-8?q?AIT-595:=20Library=20recovery=20hardening=20?= =?UTF-8?q?=E2=80=94=20list/retry/asset=20bind=20fail-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner list/retry/asset bind residual after #434/#511: - Extract pure applyOwnerReadyAssetBindGate + collectLibraryNewAttemptAssetIds so same-photo Create handoffs are unit-testable without Supabase - gateLibrarySamePhotoHandoffs delegates to pure gate; membership stays fail-closed empty-set via listReadyOwnerAssetIds when storage is down - Image GET local-miss + durable UUID: 503 DURABLE_DETAIL_UNAVAILABLE when private verify is down (never invent process-memory-only 404) - library-owner-safe-regression locks pure bind gate + image GET 503 Closes AIT-595 No deploy. No Stripe live. No fake UGC. Co-authored-by: multica-agent --- app/api/image/[id]/route.ts | 86 +++++++++++++----- lib/privateGenerationResults.ts | 51 ++--------- lib/privateGenerationResultsPure.mjs | 91 +++++++++++++++++++ scripts/library-owner-safe-regression.mjs | 103 +++++++++++++++++++++- 4 files changed, 265 insertions(+), 66 deletions(-) diff --git a/app/api/image/[id]/route.ts b/app/api/image/[id]/route.ts index 49141c03..d8d147ed 100644 --- a/app/api/image/[id]/route.ts +++ b/app/api/image/[id]/route.ts @@ -22,33 +22,77 @@ function isUuid(value: string): boolean { * Single still poll — parity with GET /api/generations/[id]. * R1b: read-only — never extends fixed deadlineAt. * includeDataUrl: owned demo stills can recover data: bodies (list omits them). + * + * AIT-595 residual: local miss + durable UUID + auth must not invent a plain + * process-memory 404 when private verify is down (DURABLE_DETAIL_UNAVAILABLE). + * Owned durable Moments stay Library-scoped — never rehydrated as image stills. */ -export async function GET(_req: Request, { params }: Props) { +export async function GET(req: Request, { params }: Props) { const { id } = await params; const session = await ensureSession(); // Read-only poll: getImageJob may sweep TIMEOUT but does not slide deadline. const job = getImageJob(id); - if (!job || job.sessionId !== session.id) { - return NextResponse.json( - { - ok: false, - code: "NOT_FOUND", - id, - message: - "No still job in this session's local ledger. Soft-launch records jobs after POST /api/image.", - }, - { status: 404 } - ); + if (job && job.sessionId === session.id) { + return NextResponse.json({ + ok: true, + mode: "local-memory", + durable: false, + job: toPublicImageJob(job, session.id, { includeDataUrl: true }), + /** R1b: polls never extend deadlineAt. */ + touched: false, + note: "Read-only poll — fixed deadlineAt; worker heartbeat is separate.", + }); } - return NextResponse.json({ - ok: true, - mode: "local-memory", - durable: false, - job: toPublicImageJob(job, session.id, { includeDataUrl: true }), - /** R1b: polls never extend deadlineAt. */ - touched: false, - note: "Read-only poll — fixed deadlineAt; worker heartbeat is separate.", - }); + + // Durable owner path — never claim local missing when private storage is down. + if (isUuid(id)) { + const authUser = await getAuthUserFromRequest(req); + if (authUser) { + const privateLookup = await getPrivateLibraryJobForOwner({ + jobId: id, + userId: authUser.id, + }); + if (!privateLookup.ok) { + return NextResponse.json( + { + ok: false, + code: "DURABLE_DETAIL_UNAVAILABLE", + id, + message: + "Private Library could not verify this still. Retry when storage is ready — ownership is not denied.", + mode: "supabase-private", + durable: true, + }, + { status: 503 } + ); + } + // Owned durable row exists but is not a process-memory still. Fail closed + // with uniform NOT_FOUND shape (no Moment metadata leak on the image API). + if (privateLookup.job) { + return NextResponse.json( + { + ok: false, + code: "NOT_FOUND", + id, + message: + "No still job in this session's local ledger. Soft-launch records jobs after POST /api/image.", + }, + { status: 404 } + ); + } + } + } + + return NextResponse.json( + { + ok: false, + code: "NOT_FOUND", + id, + message: + "No still job in this session's local ledger. Soft-launch records jobs after POST /api/image.", + }, + { status: 404 } + ); } /** diff --git a/lib/privateGenerationResults.ts b/lib/privateGenerationResults.ts index f04955dc..b19a1d90 100644 --- a/lib/privateGenerationResults.ts +++ b/lib/privateGenerationResults.ts @@ -2,7 +2,8 @@ import { createHash } from "node:crypto"; import { getSupabaseAdmin } from "@/lib/supabase/server"; import { listReadyOwnerAssetIds } from "@/lib/privateToyAssets"; import { - acceptControlledLibraryNewAttemptUrl, + applyOwnerReadyAssetBindGate, + collectLibraryNewAttemptAssetIds, parseProviderOutputHostAllowlist, privateLibraryJobFromRow, privateResultObjectKey, @@ -13,6 +14,8 @@ import { export { acceptControlledLibraryNewAttemptUrl, + applyOwnerReadyAssetBindGate, + collectLibraryNewAttemptAssetIds, controlledLibraryNewAttemptUrl, isOwnerVisibleLibraryJob, libraryDurableTerminalFailureCopy, @@ -414,6 +417,9 @@ const LIBRARY_COLUMNS = [ * pending, rejected, or foreign assets must not mint a newAttemptUrl. * inputBound stays true when the durable row carries a UUID binding (honest * column truth); only the Create handoff is gated. + * + * AIT-595: pure gate lives in applyOwnerReadyAssetBindGate; membership is + * fail-closed via listReadyOwnerAssetIds (empty set when storage is down). */ async function gateLibrarySamePhotoHandoffs(input: { userId: string; @@ -422,22 +428,7 @@ async function gateLibrarySamePhotoHandoffs(input: { const jobs = input.jobs; if (jobs.length === 0) return jobs; - const candidateAssetIds: string[] = []; - for (const job of jobs) { - if (!job.newAttemptUrl) continue; - const accepted = acceptControlledLibraryNewAttemptUrl(job.newAttemptUrl); - if (!accepted) continue; - try { - const assetId = new URL(accepted, "https://pikbo.local").searchParams - .get("assetId") - ?.trim() - .toLowerCase(); - if (assetId) candidateAssetIds.push(assetId); - } catch { - /* ignore malformed */ - } - } - + const candidateAssetIds = collectLibraryNewAttemptAssetIds(jobs); const readyIds = candidateAssetIds.length > 0 ? await listReadyOwnerAssetIds({ @@ -446,31 +437,7 @@ async function gateLibrarySamePhotoHandoffs(input: { }) : new Set(); - return jobs.map((job) => { - if (!job.newAttemptUrl) return job; - const accepted = acceptControlledLibraryNewAttemptUrl(job.newAttemptUrl); - if (!accepted) { - const { newAttemptUrl: _drop, ...rest } = job; - void _drop; - return rest as PrivateLibraryJob; - } - let assetId = ""; - try { - assetId = - new URL(accepted, "https://pikbo.local").searchParams - .get("assetId") - ?.trim() - .toLowerCase() || ""; - } catch { - assetId = ""; - } - if (!assetId || !readyIds.has(assetId)) { - const { newAttemptUrl: _drop, ...rest } = job; - void _drop; - return rest as PrivateLibraryJob; - } - return { ...job, newAttemptUrl: accepted }; - }); + return applyOwnerReadyAssetBindGate(jobs, readyIds) as PrivateLibraryJob[]; } /** diff --git a/lib/privateGenerationResultsPure.mjs b/lib/privateGenerationResultsPure.mjs index eece14bb..dc13c1a8 100644 --- a/lib/privateGenerationResultsPure.mjs +++ b/lib/privateGenerationResultsPure.mjs @@ -232,6 +232,97 @@ export function libraryNewAttemptButtonLabel(samePhotoHandoff) { : "Create new Moment"; } +/** + * Pure same-photo Create handoff gate (AIT-595 residual). + * Strips `newAttemptUrl` unless the controlled accept gate passes AND the + * asset id is present in the owner-ready membership set. Never invents a + * handoff, never mutates inputBound (column truth stays honest), never + * rewrites capabilities. + * + * Fail-closed: empty/missing ready set → all same-photo URLs drop (generic + * Create remains available via capabilities.newAttempt). Callers prove + * membership via listReadyOwnerAssetIds (empty set on storage/query fail). + * + * @param {Array>} jobs + * @param {Iterable | Set | null | undefined} readyAssetIds + * @returns {Array>} + */ +export function applyOwnerReadyAssetBindGate(jobs, readyAssetIds) { + const list = Array.isArray(jobs) ? jobs : []; + if (list.length === 0) return list; + + /** @type {Set} */ + const ready = new Set(); + if (readyAssetIds) { + for (const raw of readyAssetIds) { + if (typeof raw !== "string") continue; + const id = raw.trim().toLowerCase(); + if (LIBRARY_INPUT_ASSET_UUID.test(id)) ready.add(id); + } + } + + return list.map((job) => { + if (!job || typeof job !== "object") return job; + if (typeof job.newAttemptUrl !== "string" || !job.newAttemptUrl) { + return job; + } + const accepted = acceptControlledLibraryNewAttemptUrl(job.newAttemptUrl); + if (!accepted) { + const { newAttemptUrl: _drop, ...rest } = job; + void _drop; + return rest; + } + let assetId = ""; + try { + assetId = + new URL(accepted, "https://pikbo.local").searchParams + .get("assetId") + ?.trim() + .toLowerCase() || ""; + } catch { + assetId = ""; + } + if (!assetId || !ready.has(assetId)) { + const { newAttemptUrl: _drop, ...rest } = job; + void _drop; + return rest; + } + return { ...job, newAttemptUrl: accepted }; + }); +} + +/** + * Collect candidate asset ids from controlled newAttemptUrl values only. + * Used before listReadyOwnerAssetIds so membership queries never see + * forged / unaccepted URLs. + * + * @param {Array>} jobs + * @returns {string[]} + */ +export function collectLibraryNewAttemptAssetIds(jobs) { + /** @type {string[]} */ + const out = []; + if (!Array.isArray(jobs)) return out; + for (const job of jobs) { + if (!job || typeof job !== "object") continue; + if (typeof job.newAttemptUrl !== "string" || !job.newAttemptUrl) continue; + const accepted = acceptControlledLibraryNewAttemptUrl(job.newAttemptUrl); + if (!accepted) continue; + try { + const assetId = new URL(accepted, "https://pikbo.local").searchParams + .get("assetId") + ?.trim() + .toLowerCase(); + if (assetId && LIBRARY_INPUT_ASSET_UUID.test(assetId)) { + out.push(assetId); + } + } catch { + /* ignore malformed */ + } + } + return out; +} + /** * True when a durable row carries a UUID-shaped input_asset_id. * Used only as a boolean Library flag — never expose the raw asset id. diff --git a/scripts/library-owner-safe-regression.mjs b/scripts/library-owner-safe-regression.mjs index 9c1c201d..b2a57b65 100644 --- a/scripts/library-owner-safe-regression.mjs +++ b/scripts/library-owner-safe-regression.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * AIT-41 / AIT-103 / AIT-148 / AIT-162 / AIT-183 / AIT-193 / AIT-254 / AIT-274 / - * AIT-477 / AIT-485 / AIT-506 / AIT-522 / AIT-523 / AIT-561: + * AIT-477 / AIT-485 / AIT-506 / AIT-522 / AIT-523 / AIT-561 / AIT-595: * Library owner-safe recovery + owner-card one-primary residual. * * Source + pure-function regression (no network, no provider, no Supabase). @@ -11,14 +11,16 @@ * guest deep-link login next, deep-link fail-closed copy, client Bearer on * retry/cancel so durable DURABLE_* codes can resolve, pure merge * dropping owned:false before page slice (AIT-274), deep-link finite resolve - * (AIT-522), and retry/cancel/download wall-clock + DURABLE_DETAIL honesty - * (AIT-523). + * (AIT-522), retry/cancel/download wall-clock + DURABLE_DETAIL honesty + * (AIT-523), pure asset-bind gate + image GET durable unavailable (AIT-595). */ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { + applyOwnerReadyAssetBindGate, + collectLibraryNewAttemptAssetIds, isOwnerVisibleLibraryJob, libraryInputBindingCopy, libraryInputBoundFromAssetId, @@ -865,4 +867,99 @@ assert.match( "asset bind membership must empty-set on query error" ); +// ─── AIT-595 residual: pure owner-ready asset bind gate + image GET 503 ───── + +assert.match(pure, /export function applyOwnerReadyAssetBindGate/); +assert.match(pure, /export function collectLibraryNewAttemptAssetIds/); +assert.match(results, /applyOwnerReadyAssetBindGate/); +assert.match(results, /collectLibraryNewAttemptAssetIds/); +assert.match( + results, + /gateLibrarySamePhotoHandoffs[\s\S]{0,500}applyOwnerReadyAssetBindGate/, + "async list/detail gate must delegate to pure applyOwnerReadyAssetBindGate" +); +assert.match( + results, + /gateLibrarySamePhotoHandoffs[\s\S]{0,400}listReadyOwnerAssetIds/, + "async gate must still prove membership via listReadyOwnerAssetIds" +); + +// Pure unit: ready membership keeps controlled same-photo URL. +const readyKeeps = applyOwnerReadyAssetBindGate( + [boundFailed], + new Set([inputAssetId]) +); +assert.equal(readyKeeps.length, 1); +assert.ok(readyKeeps[0].newAttemptUrl?.includes(inputAssetId)); +assert.equal(readyKeeps[0].inputBound, true); +assert.equal(readyKeeps[0].capabilities?.newAttempt, true); + +// Pure unit: empty ready set (storage down / not ready) strips handoff only. +const emptyStrips = applyOwnerReadyAssetBindGate([boundFailed], new Set()); +assert.equal(emptyStrips.length, 1); +assert.equal(emptyStrips[0].newAttemptUrl, undefined); +assert.equal( + emptyStrips[0].inputBound, + true, + "inputBound stays column truth when handoff is stripped" +); +assert.equal( + emptyStrips[0].capabilities?.newAttempt, + true, + "generic Create new-attempt remains after same-photo strip" +); + +// Pure unit: foreign ready id must not unlock this job's handoff. +const foreignReady = applyOwnerReadyAssetBindGate( + [boundFailed], + new Set([foreignJobId]) +); +assert.equal(foreignReady[0].newAttemptUrl, undefined); + +// Pure unit: forged / unaccepted URL never survives the gate. +const forged = applyOwnerReadyAssetBindGate( + [ + { + ...boundFailed, + newAttemptUrl: `/create?mode=moment&effect=street-power-up&source=library&assetId=${inputAssetId}&prompt=evil`, + }, + ], + new Set([inputAssetId]) +); +assert.equal(forged[0].newAttemptUrl, undefined); + +// Pure unit: candidate collector only accepts controlled URLs. +const candidates = collectLibraryNewAttemptAssetIds([ + boundFailed, + { + ...boundFailed, + id: foreignJobId, + newAttemptUrl: "https://evil.example/create?assetId=" + inputAssetId, + }, + unboundFailed, +]); +assert.deepEqual(candidates, [inputAssetId]); + +// Image GET detail: durable verify down → 503 (parity with generations detail). +assert.match( + imageDetail, + /export async function GET[\s\S]{0,1800}DURABLE_DETAIL_UNAVAILABLE/, + "image GET must surface DURABLE_DETAIL_UNAVAILABLE when durable verify is down" +); +assert.match( + imageDetail, + /export async function GET[\s\S]{0,2200}getPrivateLibraryJobForOwner/, + "image GET local-miss must consult owner durable detail" +); +assert.match( + imageDetail, + /export async function GET[\s\S]{0,2200}status:\s*503/, + "image GET must 503 when durable verify is down — never invent local-only 404" +); +assert.match( + imageDetail, + /ownership is not denied/i, + "image GET 503 copy must refuse ownership denial claim" +); + console.log("library-owner-safe-regression: ok");