Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 65 additions & 21 deletions app/api/image/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
}

/**
Expand Down
51 changes: 9 additions & 42 deletions lib/privateGenerationResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,6 +14,8 @@ import {

export {
acceptControlledLibraryNewAttemptUrl,
applyOwnerReadyAssetBindGate,
collectLibraryNewAttemptAssetIds,
controlledLibraryNewAttemptUrl,
isOwnerVisibleLibraryJob,
libraryDurableTerminalFailureCopy,
Expand Down Expand Up @@ -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;
Expand All @@ -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({
Expand All @@ -446,31 +437,7 @@ async function gateLibrarySamePhotoHandoffs(input: {
})
: new Set<string>();

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[];
}

/**
Expand Down
91 changes: 91 additions & 0 deletions lib/privateGenerationResultsPure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>} jobs
* @param {Iterable<string> | Set<string> | null | undefined} readyAssetIds
* @returns {Array<Record<string, unknown>>}
*/
export function applyOwnerReadyAssetBindGate(jobs, readyAssetIds) {
const list = Array.isArray(jobs) ? jobs : [];
if (list.length === 0) return list;

/** @type {Set<string>} */
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<Record<string, unknown>>} 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.
Expand Down
Loading
Loading