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
32 changes: 32 additions & 0 deletions docs/api-reference/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8461,6 +8461,38 @@
],
"additionalProperties": false
},
"RecentSource": {
"type": "object",
"required": [
"id",
"input",
"title",
"kind",
"updatedAt"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"input": {
"type": "string"
},
"title": {
"type": "string"
},
"kind": {
"type": "string",
"enum": [
"search",
"inspection"
]
},
"updatedAt": {
"type": "integer"
}
}
},
"CompactAgentRun": {
"type": "object",
"properties": {
Expand Down
6 changes: 6 additions & 0 deletions docs/dashboard/sources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ Opening a playlist does not automatically fetch transcripts or comments for ever
</Tip>

When an upstream source is unavailable, existing results remain visible and the dashboard identifies which requested dataset could not be loaded.

## Recent sources

Sources remembers your thirty most recent successful searches and inspected links. Select an entry to reopen its saved results or datasets after reloading the page or signing in on another device. Opening recent sources reads saved data without a new YouTube request or credit charge. Use Refresh data when you want current video details or comments.

Search terms and links belong to your account. Video data stays in the shared catalog, and account deletion removes your history without deleting public assets used by other accounts.
3 changes: 3 additions & 0 deletions docs/internals/endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ Called by the video2ctx web application or an explicit signed-in account action.
| `GET` | `/v1/oauth/youtube/connect` | `createYouTubeConnectUrl` | Create a YouTube OAuth URL | `sessionCookie` or `demoUser` | Returns a state-bound OAuth URL; start it only from a user-initiated connection flow. |
| `POST` | `/v1/resolve` | `resolveInput` | Route universal UI input | `sessionCookie` or `cliSession` or `bearerApiKey` or `apiKey` or `demoUser` | First-party input router; its dispatch behavior is not a stable public API contract. |
| `POST` | `/v1/scale-inquiries` | `submitScaleInquiry` | Submit a Scale plan inquiry | Public or protocol-signed | Public lead form; validate Turnstile and rate limits, and never let the caller choose the notification recipient. |
| `GET` | `/v1/sources/recent` | `listRecentSources` | List recent Sources inputs | `sessionCookie` or `demoUser` | Browser-session only. Lists inputs belonging to the authenticated user without exposing shared asset references. |
| `POST` | `/v1/sources/recent` | `saveRecentSource` | Remember a Sources search or inspection | `sessionCookie` or `demoUser` | Browser-session only. Resolves existing provider assets server-side; never accepts client-supplied R2 keys or video payloads. |
| `GET` | `/v1/sources/recent/{id}` | `getRecentSource` | Restore saved Sources data | `sessionCookie` or `demoUser` | Browser-session only. Verifies user ownership before reading the saved shared references. |

## Callbacks and signed links

Expand Down
46 changes: 46 additions & 0 deletions platform/src/durable-objects/user-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AgentAdmissionQueue } from '../agents/runtime/admission-queue';
import type { AgentRequest, AgentAdmission } from '../agents/contracts';
import { DurableObject } from 'cloudflare:workers';
import { z } from 'zod';
import { RECENT_SOURCE_LIMIT, saveReferencedSourceSchema, sourceReferenceSchema, sourceIdentity, type RecentSource, type SaveReferencedSource, type SourceReference } from '../lib/source-history';

const MAX_SEARCH_TEXT_LENGTH = 32_000;
const MAX_TITLE_LENGTH = 80;
Expand Down Expand Up @@ -100,6 +101,7 @@ export class UserAccountDO extends DurableObject<Env> {
this.ctx.storage.sql.exec('DELETE FROM user_session_runs');
this.ctx.storage.sql.exec('DELETE FROM user_sessions');
this.ctx.storage.sql.exec('DELETE FROM agent_conversations');
this.ctx.storage.sql.exec('DELETE FROM recent_sources');
// Keep only a tombstone so already-authenticated requests cannot recreate data.
}

Expand All @@ -109,6 +111,46 @@ export class UserAccountDO extends DurableObject<Env> {
}
}

saveSource(value: SaveReferencedSource): RecentSource {
this.assertActive();
const input = saveReferencedSourceSchema.parse(value);
const snapshot = JSON.stringify(input.snapshot);
const key = sourceIdentity(input);
const existing = this.ctx.storage.sql.exec<{ id: string }>('SELECT id FROM recent_sources WHERE source_key = ?', key).toArray()[0];
const entry: RecentSource = { id: existing?.id ?? crypto.randomUUID(), input: input.input,
title: input.title,
kind: input.snapshot.kind, updatedAt: this.nextSourceUpdate() };
this.ctx.storage.transactionSync(() => {
this.ctx.storage.sql.exec(`INSERT INTO recent_sources (id, source_key, input, title, kind, updated_at, snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(source_key) DO UPDATE SET
input=excluded.input, title=excluded.title, kind=excluded.kind, updated_at=excluded.updated_at, snapshot=excluded.snapshot`,
entry.id, key, entry.input, entry.title, entry.kind, entry.updatedAt, snapshot);
this.ctx.storage.sql.exec(`DELETE FROM recent_sources WHERE id NOT IN
(SELECT id FROM recent_sources ORDER BY updated_at DESC, rowid DESC LIMIT ?)`, RECENT_SOURCE_LIMIT);
});
return entry;
}

listSources(): RecentSource[] {
this.assertActive();
return this.ctx.storage.sql.exec<{ id: string; input: string; title: string; kind: RecentSource['kind']; updated_at: number }>(
'SELECT id, input, title, kind, updated_at FROM recent_sources ORDER BY updated_at DESC, rowid DESC LIMIT ?', RECENT_SOURCE_LIMIT,
).toArray().map(({ updated_at, ...row }) => ({ ...row, updatedAt: updated_at }));
}

getSource(id: string): { source: RecentSource; snapshot: SourceReference } | null {
this.assertActive();
const row = this.ctx.storage.sql.exec<{ snapshot: string }>('SELECT snapshot FROM recent_sources WHERE id = ?', z.string().uuid().parse(id)).toArray()[0];
if (!row) return null;
this.ctx.storage.sql.exec('UPDATE recent_sources SET updated_at = ? WHERE id = ?', this.nextSourceUpdate(), id);
return { source: this.listSources().find(source => source.id === id)!, snapshot: sourceReferenceSchema.parse(JSON.parse(row.snapshot)) };
}

private nextSourceUpdate(): number {
const latest = this.ctx.storage.sql.exec<{ latest: number | null }>('SELECT MAX(updated_at) AS latest FROM recent_sources').one().latest;
return Math.max(Date.now(), (latest ?? 0) + 1);
}

recordSession(value: RecordSessionInput): UserSessionSummary {
this.assertActive();
const input = recordSessionInputSchema.parse(value);
Expand Down Expand Up @@ -283,6 +325,10 @@ export class UserAccountDO extends DurableObject<Env> {
}

private ensureSchema(): void {
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS recent_sources (
id TEXT PRIMARY KEY, source_key TEXT NOT NULL UNIQUE, input TEXT NOT NULL, title TEXT NOT NULL,
kind TEXT NOT NULL, updated_at INTEGER NOT NULL, snapshot TEXT NOT NULL
)`);
this.ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS account_deletion (id INTEGER PRIMARY KEY)');
this.ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS agent_conversations (conversation_id TEXT PRIMARY KEY)');
this.ctx.storage.sql.exec(`
Expand Down
88 changes: 88 additions & 0 deletions platform/src/lib/source-history-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { ApiError, sha256 } from './http';
import { videoCatalog, type VideoAssetReference } from './video-catalog';
import { videoResourceKey } from './video-resources';
import { readYouTubeCacheEntry } from './youtube-cache-coordinator';
import { routeInput, withYouTubeMetadata } from './youtube';
import { sourceSnapshotSchema, type SaveSourceInput, type SaveReferencedSource, type SourceReference, type SourceSnapshot } from './source-history';

// Only public provider payloads enter this bucket. Inputs and user selections stay in the user DO.
async function saveShared(env: Env, value: unknown): Promise<string> {
const payload = JSON.stringify(value);
const key = `youtube/source-history/${await sha256(payload)}.json`;
await env.VIDEO_ASSETS.put(key, payload, { httpMetadata: { contentType: 'application/json' } });
return key;
}

export async function referenceSource(env: Env, value: SaveSourceInput): Promise<SaveReferencedSource> {
if (!env.VIDEO_ASSETS || !videoCatalog(env)) throw new ApiError(503, 'SOURCE_STORAGE_UNAVAILABLE', 'Recent sources storage is unavailable.');
const { input, snapshot } = value;
const cachedPublicData = async (type: string, id: string): Promise<Record<string, unknown>> => {
const key = `youtube:v1:${await sha256(JSON.stringify([type, id]))}`;
const cached = await readYouTubeCacheEntry<Record<string, unknown>>(env, key, type);
if (!cached) throw new ApiError(409, 'SOURCE_ASSET_NOT_SAVED', 'Source data has not finished saving. Inspect the source again.');
return withYouTubeMetadata(cached.value);
};
if (snapshot.kind === 'search') {
const resolved = routeInput(input);
if (resolved.kind !== 'search') throw new ApiError(422, 'INVALID_SOURCE', 'Expected search terms.');
const key = await sha256(JSON.stringify({ query: resolved.query, filters: { type: 'video' } }));
const search = await cachedPublicData('search-v3', key);
const items = (search.results as Array<{ type: string }>).filter(item => item.type === 'video');
return { input, title: input, snapshot: {
kind: 'search', selectedData: snapshot.selectedData, results: await saveShared(env, items),
} };
}
const source = snapshot.inspector;
const assets: Partial<Record<'metadata' | 'transcript' | 'comments', VideoAssetReference>> = {};
let data: Record<string, unknown> = {};
if (source.type === 'video') {
const operations = [
{ field: 'metadata' as const, op: { kind: 'video' as const, id: source.id } },
...(source.loadedData.includes('transcript') ? [{ field: 'transcript' as const, op: { kind: 'transcript' as const, id: source.id, granularity: 'word' as const } }] : []),
...(source.loadedData.includes('comments') ? [{ field: 'comments' as const, op: { kind: 'comments' as const, id: source.id } }] : []),
];
await Promise.all(operations.map(async ({ field, op }) => {
if (source.dataErrors[field]) return;
const saved = await videoCatalog(env)!.readSaved(videoResourceKey(op)!);
const reference = saved?.catalogVersions?.[0];
if (reference) { assets[field] = reference; if (field === 'metadata') data = saved!.value as Record<string, unknown>; }
else throw new ApiError(409, 'SOURCE_ASSET_NOT_SAVED', 'Source data has not finished saving. Retry the source.');
}));
} else data = await cachedPublicData(source.type === 'playlist' ? 'playlist-v2' : 'channel-v5', source.id);
const channelId = (data.channel as { id?: string } | undefined)?.id;
const channel = source.loadedData.includes('channel') && !source.dataErrors.channel && channelId
? await cachedPublicData('channel-v5', channelId) : undefined;
return { input, title: String(data.title ?? data.name ?? input).slice(0, 300), snapshot: {
kind: 'inspection', inspector: { provider: source.provider, type: source.type, id: source.id,
requestedData: source.requestedData, dataErrors: source.dataErrors, assets,
entity: source.type !== 'video' ? await saveShared(env, data) : undefined,
channel: channel ? await saveShared(env, channel) : undefined,
},
} };
}

export async function restoreSource(env: Env, reference: SourceReference): Promise<SourceSnapshot> {
const readShared = async (key: string) => {
const object = await env.VIDEO_ASSETS.get(key);
if (!object) throw new ApiError(404, 'SOURCE_ASSET_MISSING', 'Saved source data is unavailable. Inspect this source again.');
const payload = await object.text();
if (`youtube/source-history/${await sha256(payload)}.json` !== key) throw new ApiError(500, 'SOURCE_ASSET_INVALID', 'Saved source data could not be verified.');
return JSON.parse(payload);
};
if (reference.kind === 'search') return sourceSnapshotSchema.parse({
kind: 'search', selectedData: reference.selectedData, items: await readShared(reference.results),
});
const { assets, entity, channel, ...source } = reference.inspector;
const restored: Record<string, unknown> = { ...source,
data: entity ? await readShared(entity) : { id: source.id, url: `https://youtube.com/watch?v=${encodeURIComponent(source.id)}` },
channel: channel ? await readShared(channel) : undefined,
};
await Promise.all(Object.entries(assets).map(async ([field, asset]) => {
if (!asset) return;
const stored = await videoCatalog(env)?.readVersion(asset);
if (!stored) throw new ApiError(404, 'SOURCE_ASSET_MISSING', 'Saved source data is unavailable. Inspect this source again.');
const value = { ...withYouTubeMetadata(stored.value as Record<string, unknown>), freshness: { state: 'stored', fetchedAt: new Date(stored.fetchedAt).toISOString() } };
restored[field === 'metadata' ? 'data' : field] = value;
}));
return sourceSnapshotSchema.parse({ kind: 'inspection', inspector: restored });
}
66 changes: 66 additions & 0 deletions platform/src/lib/source-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { z } from 'zod';

export const RECENT_SOURCE_LIMIT = 30;
export const MAX_SOURCE_SNAPSHOT_BYTES = 16_000;
export const sourceIdSchema = z.string().uuid();
const dataset = z.enum(['transcript', 'comments', 'channel']);
const metadata = z.object({ source: z.string(), fetchedAt: z.string(), partial: z.boolean(), warnings: z.array(z.string()) }).passthrough();
const thumbnails = z.array(z.object({ url: z.string(), width: z.number().optional(), height: z.number().optional() }));
const record = z.record(z.string(), z.unknown());
const inspector = z.object({
provider: z.literal('youtube'), type: z.enum(['video', 'playlist', 'channel']), id: z.string().min(1).max(200),
data: record, requestedData: z.array(dataset),
dataErrors: z.partialRecord(z.enum(['metadata', 'transcript', 'comments', 'channel']), z.string()),
refreshData: z.array(z.enum(['metadata', 'transcript', 'comments', 'channel'])).optional(),
transcript: z.object({
videoId: z.string(), text: z.string(), granularity: z.enum(['segment', 'word']).optional(), meta: metadata,
track: z.object({ name: z.string(), kind: z.string(), languageCode: z.string() }).passthrough(),
segments: z.array(z.object({ text: z.string(), startMs: z.number(), endMs: z.number(), durationMs: z.number() }).passthrough()),
}).passthrough().optional(),
comments: z.object({ videoId: z.string(), comments: z.array(record), meta: metadata,
totalCount: z.number().optional(), continuation: z.string().optional() }).passthrough().optional(),
channel: z.object({ id: z.string(), name: z.string(), thumbnails, url: z.string(), meta: metadata,
about: z.object({ description: z.string().optional(), links: z.array(z.object({ title: z.string(), displayUrl: z.string(), url: z.string() })),
moreInfo: record }) }).passthrough().optional(),
});

export const sourceSnapshotSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('search'), selectedData: z.array(dataset).min(1), items: z.array(z.object({
provider: z.literal('youtube').optional(), type: z.literal('video'), id: z.string(), thumbnails,
title: z.string().optional(), name: z.string().optional(), description: z.string().optional(),
channel: z.object({ id: z.string(), name: z.string() }).optional(),
durationText: z.string().optional(), viewCountText: z.string().optional(), publishedTimeText: z.string().optional(),
isLive: z.boolean().optional(), videoCountText: z.string().optional(),
})) }),
z.object({ kind: z.literal('inspection'), inspector }),
]);
export const saveSourceSchema = z.object({ input: z.string().trim().min(1).max(500), snapshot: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('search'), selectedData: z.array(dataset).min(1) }),
z.object({ kind: z.literal('inspection'), inspector: inspector.pick({ provider: true, type: true, id: true, requestedData: true, dataErrors: true })
.extend({ loadedData: z.array(z.enum(['metadata', 'transcript', 'comments', 'channel'])) }) }),
]) });
export type SourceSnapshot = z.infer<typeof sourceSnapshotSchema>;
export type SaveSourceInput = z.infer<typeof saveSourceSchema>;
const assetReference = z.object({ videoId: z.string(), kind: z.string(), variant: z.string(), contentHash: z.string() });
const sharedReference = z.string().regex(/^youtube\/source-history\/[a-f0-9]{64}\.json$/);
export const sourceReferenceSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('search'), selectedData: z.array(dataset), results: sharedReference }),
z.object({ kind: z.literal('inspection'), inspector: z.object({
provider: z.literal('youtube'), type: z.enum(['video', 'playlist', 'channel']), id: z.string(),
requestedData: z.array(dataset), dataErrors: inspector.shape.dataErrors,
assets: z.partialRecord(z.enum(['metadata', 'transcript', 'comments']), assetReference),
entity: sharedReference.optional(), channel: sharedReference.optional(),
}) }),
]);
export const saveReferencedSourceSchema = z.object({ input: z.string().trim().min(1).max(500), title: z.string().max(300), snapshot: sourceReferenceSchema });
export type SourceReference = z.infer<typeof sourceReferenceSchema>;
export type SaveReferencedSource = z.infer<typeof saveReferencedSourceSchema>;
export interface RecentSource {
id: string; input: string; title: string; kind: SourceSnapshot['kind']; updatedAt: number;
}

export function sourceIdentity(input: SaveReferencedSource): string {
return input.snapshot.kind === 'search'
? `search:${input.input.replace(/\s+/g, ' ').toLowerCase()}`
: `${input.snapshot.inspector.provider}:${input.snapshot.inspector.type}:${input.snapshot.inspector.id}`;
}
6 changes: 6 additions & 0 deletions platform/src/openapi-audience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export const OPENAPI_OPERATION_AUDIENCE: Readonly<Record<string, OpenApiAudience
listApiKeys: 'first-party',
deleteApiKey: 'first-party',
resolveInput: 'first-party',
listRecentSources: 'first-party',
saveRecentSource: 'first-party',
getRecentSource: 'first-party',
startAgentRun: 'consumer',
getAgentAccess: 'consumer',
listAgentSessions: 'consumer',
Expand Down Expand Up @@ -98,6 +101,9 @@ export const OPENAPI_OPERATION_AUDIENCE: Readonly<Record<string, OpenApiAudience
* Tests keep this inventory aligned with the non-consumer audience map.
*/
export const OPENAPI_INTERNAL_SAFETY: Readonly<Record<string, string>> = {
listRecentSources: 'Browser-session only. Lists inputs belonging to the authenticated user without exposing shared asset references.',
saveRecentSource: 'Browser-session only. Resolves existing provider assets server-side; never accepts client-supplied R2 keys or video payloads.',
getRecentSource: 'Browser-session only. Verifies user ownership before reading the saved shared references.',
inspectLandingYouTubeVideo: 'Public, rate-limited demo route; do not use it as a credentialed bulk-data API.',
submitScaleInquiry: 'Public lead form; validate Turnstile and rate limits, and never let the caller choose the notification recipient.',
signInWithMagicLink: 'Sends account email; rate-limit callers and never disclose whether an address is registered.',
Expand Down
Loading
Loading