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
44 changes: 44 additions & 0 deletions apps/web/src/app/api/v1/video/pack/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GOLDEN_IDENTITY_HASHES } from '@/lib/video-pack';

const CANON_B = 'jNQXAC9IVRw';

afterEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
});

function postRequest(body: unknown) {
return new Request('http://localhost:3000/api/v1/video/pack', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}

describe('POST /api/v1/video/pack', () => {
it('emits the same identity pack as /api/video/pack without a transcript', async () => {
const { POST } = await import('../route');
const res = await POST(
postRequest({ url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw' }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
status: string;
data: {
version: string;
video_id: string;
source_url: string;
transcript: { full_text: string; segments: unknown[] };
provenance: { source_hash: string };
};
};
expect(body.status).toBe('success');
expect(body.data.version).toBe('v0');
expect(body.data.video_id).toBe(CANON_B);
expect(body.data.source_url).toBe('https://www.youtube.com/watch?v=jNQXAC9IVRw');
expect(body.data.provenance.source_hash).toBe(GOLDEN_IDENTITY_HASHES[CANON_B]);
expect(body.data.transcript.full_text).toBe(`cite:youtube:${CANON_B}`);
expect(body.data.transcript.segments).toEqual([]);
});
});
1 change: 1 addition & 0 deletions apps/web/src/app/api/v1/video/pack/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { POST, runtime } from '../../../video/pack/route';

@vercel vercel Bot Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-exporting the runtime route segment config from another module breaks the Next.js build

Fix on Vercel

1 change: 1 addition & 0 deletions apps/web/src/app/api/video/pack/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('POST /api/video/pack', () => {
expect(a.data.version).toBe('v0');
expect(a.data.video_id).toBe(CANON_A);
expect(a.data.id).toBe(`vp:v0:${CANON_A}`);
expect(a.data.source_url).toBe('https://www.youtube.com/watch?v=auJzb1D-fag');
expect(hashA).toBe(GOLDEN_IDENTITY_HASHES[CANON_A]);
expect(hashA).toBe(identityHash(CANON_A));
expect(hashA).toBe(hashB);
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/app/api/video/pack/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,14 @@ export async function POST(request: Request) {
}

const pack = getOrCreatePack(videoId, url || undefined);
if (!pack.source_url.startsWith('http') || !pack.provenance.source_hash) {
return NextResponse.json(
{
status: 'error',
error: 'Video pack verification failed: source_url and source_hash are required.',
},
{ status: 500 },
);
}
return NextResponse.json({ status: 'success', data: pack });
}
48 changes: 45 additions & 3 deletions apps/web/src/components/OneLoopStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
startVideoToActions,
type VideoToActionsResult,
} from '@/lib/studio-workflow';
import { identityPackJson } from '@/lib/emit-video-pack';
import {
studioPackCitation,
studioPasteOutcomeMessage,
studioRunQuality,
studioStatusLabel,
studioStatusMessage,
Expand Down Expand Up @@ -196,9 +199,10 @@
const ready =
(video?.transcript?.trim().length ?? 0) >= 40 || (video?.events?.length ?? 0) > 0;
setMessage(
ready
? 'Ready — run tools, export, or save from this page.'
: 'No usable transcript. Try another public video.',
studioPasteOutcomeMessage({
hasUsableTranscript: ready,
packCitation: video?.videoPack ? studioPackCitation(video.videoPack) : null,
}),
);
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Analysis failed.');
Expand Down Expand Up @@ -254,7 +258,7 @@
const started = await startVideoToActions(payload);
if (!started.ok || !started.runId) {
if (started.status === 401 || started.status === 403) {
window.location.href = `/login?callbackUrl=${encodeURIComponent('/')}`;

Check warning on line 261 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / lint-frontend

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination

Check warning on line 261 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
return;
}
setMessage(started.error || started.message || 'Could not start Act.');
Expand Down Expand Up @@ -335,7 +339,7 @@
try {
const started = await startStudioDeploy({ url: next });
if (started.status === 401 || started.status === 403) {
window.location.href = `/login?callbackUrl=${encodeURIComponent('/')}`;

Check warning on line 342 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / lint-frontend

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination

Check warning on line 342 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
return;
}
if (!started.ok || !started.runId) {
Expand Down Expand Up @@ -416,6 +420,14 @@
<p className="font-mono text-xs text-[#e8b86d]/90" role="status">
{statusText}
</p>
{selected?.videoPack && (
<p
data-testid="video-pack-citation"
className="break-all font-mono text-[11px] text-white/55"
>
{studioPackCitation(selected.videoPack)}
</p>
)}
{(busy || selected?.status === 'processing') && (
<div className="h-1 overflow-hidden rounded-full bg-white/10">
<div
Expand Down Expand Up @@ -635,6 +647,36 @@
</section>
)}

{selected?.videoPack && (
<section
data-testid="video-pack"
className="rounded-xl border border-white/10 bg-[#11131a] p-4 lg:col-span-2"
>
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-white/45">
Video pack
</h2>
<p className="mt-3 break-all font-mono text-sm text-white/80">
{studioPackCitation(selected.videoPack)}
</p>
<dl className="mt-3 grid gap-2 font-mono text-[11px] text-white/55 sm:grid-cols-2">
<div>
<dt className="uppercase tracking-[0.16em] text-white/35">source_url</dt>
<dd className="mt-1 break-all text-white/80">{selected.videoPack.sourceUrl}</dd>
</div>
<div>
<dt className="uppercase tracking-[0.16em] text-white/35">source_hash</dt>
<dd className="mt-1 break-all text-white/80">{selected.videoPack.sourceHash}</dd>
</div>
</dl>
<pre
data-testid="video-pack-json"
className="mt-3 overflow-auto rounded-lg bg-black/40 p-3 font-mono text-[11px] leading-5 text-white/75"
>
{identityPackJson(selected.videoPack)}
</pre>
</section>
)}

{selected?.insights && (
<section className="rounded-xl border border-white/10 bg-[#11131a] p-4 lg:col-span-2">
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-white/45">
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/lib/__tests__/auth-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ describe('auth path policy', () => {
expect(isProtectedPagePath('/dashboard/agents')).toBe(true);
});

it('does not require a session for the public identity pack emit path', () => {
// Home paste is anonymous. Middleware 401 on these paths is the live
// uvai.io failure after #1609: pack never emits, UI shows no source_hash.
expect(isPublicApiPath('/api/video/pack')).toBe(true);
expect(isPublicApiPath('/api/v1/video/pack')).toBe(true);
expect(needsAuthentication('/api/video/pack')).toBe(false);
expect(needsAuthentication('/api/v1/video/pack')).toBe(false);
// Exact allowlist only — siblings stay gated.
expect(isPublicApiPath('/api/video')).toBe(false);
expect(isPublicApiPath('/api/video/generate')).toBe(false);
expect(needsAuthentication('/api/video/generate')).toBe(true);
expect(isPublicApiPath('/api/v1/video')).toBe(false);
});

it('does not gate marketing pages', () => {
expect(needsAuthentication('/')).toBe(false);
expect(needsAuthentication('/pricing')).toBe(false);
Expand Down Expand Up @@ -144,6 +158,13 @@ describe('AI route classification (rate-limit budget)', () => {
}
});

it('does not meter identity pack emit as AI work', () => {
expect(isAiRoute('/api/video/pack', 'POST')).toBe(false);
expect(isAiRoute('/api/v1/video/pack', 'POST')).toBe(false);
expect(isAiRoute('/api/video', 'POST')).toBe(true);
expect(isAiRoute('/api/video/generate', 'POST')).toBe(true);
});

it('leaves non-AI API routes on the general budget', () => {
expect(isAiRoute('/api/billing/status', 'GET')).toBe(false);
expect(isAiRoute('/api/health', 'GET')).toBe(false);
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/lib/__tests__/emit-video-pack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { GOLDEN_IDENTITY_HASHES } from '@/lib/video-pack';
import { identityPackJson, verifyIdentityPack } from '@/lib/emit-video-pack';

const CANON = 'jNQXAC9IVRw';
const SOURCE_URL = `https://www.youtube.com/watch?v=${CANON}`;
const HASH = GOLDEN_IDENTITY_HASHES[CANON];

const VALID = {
status: 'success',
data: {
version: 'v0',
id: `vp:v0:${CANON}`,
video_id: CANON,
source_url: SOURCE_URL,
transcript: { full_text: `cite:youtube:${CANON}`, segments: [] },
provenance: { source_hash: HASH },
},
};

describe('verifyIdentityPack (CoS: fail closed)', () => {
it('accepts a v0 pack with source_url and source_hash', () => {
const citation = verifyIdentityPack(VALID);
expect(citation.sourceUrl).toBe(SOURCE_URL);
expect(citation.sourceHash).toBe(HASH);
expect(citation.videoId).toBe(CANON);
expect(citation.pack.source_url).toBe(SOURCE_URL);
expect(citation.pack.provenance.source_hash).toBe(HASH);
expect(identityPackJson(citation)).toContain(SOURCE_URL);
expect(identityPackJson(citation)).toContain(HASH);
});

it('fails closed when source_url is missing', () => {
const { source_url: _omit, ...data } = VALID.data;
expect(() => verifyIdentityPack({ status: 'success', data })).toThrow(/source_url/i);
});

it('fails closed when source_hash is missing', () => {
expect(() =>
verifyIdentityPack({
status: 'success',
data: { ...VALID.data, provenance: {} },
}),
).toThrow(/source_hash/i);
});

it('fails closed on an empty or 401 payload', () => {
expect(() => verifyIdentityPack({ error: 'Authentication required' })).toThrow(/verif/i);
expect(() => verifyIdentityPack(null)).toThrow(/verif/i);
});
});
39 changes: 39 additions & 0 deletions apps/web/src/lib/__tests__/studio-pipeline-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
studioPackCitation,
studioPasteOutcomeMessage,
studioRunQuality,
studioStatusLabel,
studioStatusMessage,
Expand Down Expand Up @@ -42,6 +44,43 @@ describe('studio-pipeline-status', () => {
expect(studioStatusLabel('live', 'ready')).toBe('Analysis ready');
});

it('shows the identity pack cite when transcript evidence is missing', () => {
const citation = studioPackCitation({
version: 'v0',
videoId: 'jNQXAC9IVRw',
packId: 'vp:v0:jNQXAC9IVRw',
sourceUrl: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
sourceHash: '97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d',
pack: {
version: 'v0',
id: 'vp:v0:jNQXAC9IVRw',
video_id: 'jNQXAC9IVRw',
source_url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
provenance: {
source_hash: '97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d',
},
},
});
expect(citation).toContain('cite:youtube:jNQXAC9IVRw');
expect(citation).toContain('https://www.youtube.com/watch?v=jNQXAC9IVRw');
expect(citation).toContain('97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d');
expect(
studioPasteOutcomeMessage({
hasUsableTranscript: false,
packCitation: citation,
}),
).toContain('cite:youtube:jNQXAC9IVRw');
});

it('fails closed when paste finishes without a verified pack', () => {
const message = studioPasteOutcomeMessage({
hasUsableTranscript: false,
packCitation: null,
});
expect(message).toMatch(/pack emit failed|verification failed/i);
expect(message.toLowerCase()).not.toBe('no usable transcript. try another public video.');
});

it('does not send the user to a second product when ready', () => {
const draft = studioStatusMessage('draft', 'ready', 'App', false);
const live = studioStatusMessage('live', 'ready', 'App', false);
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/lib/__tests__/video-pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import {
GOLDEN_IDENTITY_HASHES,
buildIdentityPack,
identityHash,
identityPayload,
resolveYouTubeVideoId,
Expand Down Expand Up @@ -44,4 +45,14 @@ describe('video-pack identity', () => {
it('rejects a non-YouTube URL', () => {
expect(resolveYouTubeVideoId('https://example.com/watch')).toBeNull();
});

it('emits an identity pack without extracted speech', () => {
const pack = buildIdentityPack(CANON_B);
expect(pack.version).toBe('v0');
expect(pack.video_id).toBe(CANON_B);
expect(pack.provenance.source_hash).toBe(GOLDEN_IDENTITY_HASHES[CANON_B]);
expect(pack.source_url).toBe(`https://www.youtube.com/watch?v=${CANON_B}`);
expect(pack.transcript.full_text).toBe(`cite:youtube:${CANON_B}`);
expect(pack.transcript.segments).toEqual([]);
});
});
10 changes: 10 additions & 0 deletions apps/web/src/lib/auth-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ const PUBLIC_API_EXACT = new Set([
// Must be accessible without a session so anonymous users can run the
// pipeline; the route handler applies its own rate limiting via proxy.ts.
'/api/pipeline/stream',
// Home paste-URL identity pack. Hash is version+video_id only; no login
// and no speech evidence. Exact paths so /api/video and /api/video/generate
// stay gated (live 401 after #1609).
'/api/video/pack',
'/api/v1/video/pack',
Comment on lines +41 to +42
]);

/** App routes that require a session when NEXTAUTH_SECRET is configured. */
Expand Down Expand Up @@ -120,6 +125,9 @@ const AI_ROUTE_METHOD_EXEMPT: Record<string, ReadonlySet<string>> = {
'/api/workflows': new Set(['GET', 'HEAD']),
};

/** Identity hash only — not model work. Keep /api/video siblings on the AI budget. */
const IDENTITY_PACK_PATHS = new Set(['/api/video/pack', '/api/v1/video/pack']);

/**
* Whether a request should be metered against the AI budget rather than the
* general one.
Expand All @@ -128,6 +136,8 @@ const AI_ROUTE_METHOD_EXEMPT: Record<string, ReadonlySet<string>> = {
* limit) rather than silently widening the budget.
*/
export function isAiRoute(pathname: string, method: string = 'POST'): boolean {
if (IDENTITY_PACK_PATHS.has(pathname)) return false;

const prefix = AI_ROUTE_PREFIXES.find((candidate) =>
pathname.startsWith(candidate),
);
Expand Down
Loading
Loading