@agent-native/core 0.178.1-nightly-20260910175201 → 0.178.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/CHANGELOG.md +1 -0
  3. package/corpus/templates/calendar/CHANGELOG.md +7 -0
  4. package/corpus/templates/clips/CHANGELOG.md +22 -0
  5. package/corpus/templates/clips/actions/create-intake-agent-link.ts +114 -0
  6. package/corpus/templates/clips/actions/create-intake-recording.ts +208 -0
  7. package/corpus/templates/clips/actions/create-recording-intake-link.ts +85 -0
  8. package/corpus/templates/clips/app/components/bug-report/bug-report-form.tsx +7 -0
  9. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +22 -5
  10. package/corpus/templates/clips/app/hooks/use-video-storage-status.ts +2 -1
  11. package/corpus/templates/clips/app/routes/bug-report.done.tsx +12 -10
  12. package/corpus/templates/clips/app/routes/bug-report.tsx +6 -0
  13. package/corpus/templates/clips/app/routes/record.tsx +269 -100
  14. package/corpus/templates/clips/server/db/schema.ts +24 -0
  15. package/corpus/templates/clips/server/lib/clip-intake.ts +247 -0
  16. package/corpus/templates/clips/server/plugins/auth.ts +8 -0
  17. package/corpus/templates/clips/server/plugins/db.ts +16 -0
  18. package/corpus/templates/clips/server/routes/api/clip-intake.get.ts +61 -0
  19. package/corpus/templates/clips/server/routes/api/clip-intake.post.ts +90 -0
  20. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +19 -4
  21. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +25 -10
  22. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +21 -4
  23. package/corpus/templates/clips/shared/clip-intake.ts +41 -0
  24. package/corpus/templates/clips/shared/finalize-recovery.ts +27 -5
  25. package/corpus/templates/clips/shared/recording-core.ts +4 -4
  26. package/corpus/templates/content/CHANGELOG.md +1 -0
  27. package/corpus/templates/design/CHANGELOG.md +9 -4
  28. package/corpus/templates/factory/CHANGELOG.md +11 -0
  29. package/dist/collab/struct-routes.d.ts +1 -1
  30. package/dist/observability/routes.d.ts +3 -3
  31. package/dist/progress/routes.d.ts +1 -1
  32. package/dist/resources/handlers.d.ts +1 -1
  33. package/dist/secrets/routes.d.ts +3 -3
  34. package/docs/content/template-clips-anonymous-intake.mdx +148 -0
  35. package/docs/content/template-clips-developers.mdx +4 -3
  36. package/docs/content/template-clips.mdx +1 -0
  37. package/package.json +3 -3
@@ -0,0 +1,247 @@
1
+ import { verifyScopedAgentAccessToken } from "@agent-native/core/server";
2
+ import { and, eq, gt, inArray } from "drizzle-orm";
3
+ import { createError, getQuery, type H3Event } from "h3";
4
+
5
+ import {
6
+ CLIP_INTAKE_RESOURCE_KIND,
7
+ parseClipIntakeParams,
8
+ } from "../../shared/clip-intake.js";
9
+ import { getDb, schema } from "../db/index.js";
10
+ import { ownerEmailMatches } from "./recordings.js";
11
+
12
+ export type ClipIntakeSession = typeof schema.clipIntakeSessions.$inferSelect;
13
+
14
+ export interface ClipIntakeRequestContext {
15
+ recordingId: string;
16
+ ownerEmail: string;
17
+ orgId: string;
18
+ }
19
+
20
+ function queryParams(event: H3Event): URLSearchParams {
21
+ const query = getQuery(event);
22
+ const params = new URLSearchParams();
23
+ for (const [key, value] of Object.entries(query)) {
24
+ const first = Array.isArray(value) ? value[0] : value;
25
+ if (first !== undefined && first !== null) params.set(key, String(first));
26
+ }
27
+ return params;
28
+ }
29
+
30
+ function intakeIsExpired(
31
+ session: ClipIntakeSession,
32
+ now = Date.now(),
33
+ ): boolean {
34
+ const expiresAt = Date.parse(session.expiresAt);
35
+ return !Number.isFinite(expiresAt) || expiresAt <= now;
36
+ }
37
+
38
+ export async function findClipIntakeSession(
39
+ intakeId: string,
40
+ token: string,
41
+ ): Promise<ClipIntakeSession | null> {
42
+ const verified = verifyScopedAgentAccessToken(token, {
43
+ resourceKind: CLIP_INTAKE_RESOURCE_KIND,
44
+ resourceId: intakeId,
45
+ });
46
+ if (!verified.ok) return null;
47
+
48
+ const [session] = await getDb()
49
+ .select()
50
+ .from(schema.clipIntakeSessions)
51
+ .where(eq(schema.clipIntakeSessions.id, intakeId))
52
+ .limit(1);
53
+ if (!session || intakeIsExpired(session)) return null;
54
+ return session;
55
+ }
56
+
57
+ export async function findOwnedClipIntakeSession(
58
+ intakeId: string,
59
+ ownerEmail: string,
60
+ organizationId: string,
61
+ ): Promise<ClipIntakeSession | null> {
62
+ const [session] = await getDb()
63
+ .select()
64
+ .from(schema.clipIntakeSessions)
65
+ .where(
66
+ and(
67
+ eq(schema.clipIntakeSessions.id, intakeId),
68
+ ownerEmailMatches(schema.clipIntakeSessions.ownerEmail, ownerEmail),
69
+ eq(schema.clipIntakeSessions.organizationId, organizationId),
70
+ ),
71
+ )
72
+ .limit(1);
73
+ if (!session || intakeIsExpired(session)) return null;
74
+ return session;
75
+ }
76
+
77
+ export async function requireClipIntakeSession(
78
+ intakeId: string,
79
+ token: string,
80
+ ): Promise<ClipIntakeSession> {
81
+ const session = await findClipIntakeSession(intakeId, token);
82
+ if (!session) {
83
+ throw createError({
84
+ statusCode: 401,
85
+ statusMessage: "Invalid or expired intake link",
86
+ });
87
+ }
88
+ return session;
89
+ }
90
+
91
+ export async function resolveClipIntakeRequest(
92
+ event: H3Event,
93
+ recordingId: string,
94
+ ): Promise<ClipIntakeRequestContext> {
95
+ const params = parseClipIntakeParams(queryParams(event));
96
+ if (!params) {
97
+ throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
98
+ }
99
+ const session = await requireClipIntakeSession(params.intakeId, params.token);
100
+ if (
101
+ !session.recordingId ||
102
+ session.recordingId !== recordingId ||
103
+ session.status !== "recording"
104
+ ) {
105
+ throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
106
+ }
107
+ return {
108
+ recordingId,
109
+ ownerEmail: session.ownerEmail,
110
+ orgId: session.organizationId,
111
+ };
112
+ }
113
+
114
+ export async function resolveClipIntakeStatusRequest(
115
+ event: H3Event,
116
+ recordingId: string,
117
+ ): Promise<ClipIntakeSession> {
118
+ const params = parseClipIntakeParams(queryParams(event));
119
+ if (!params) {
120
+ throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
121
+ }
122
+ const session = await requireClipIntakeSession(params.intakeId, params.token);
123
+ if (
124
+ !session.recordingId ||
125
+ session.recordingId !== recordingId ||
126
+ !["recording", "completed"].includes(session.status)
127
+ ) {
128
+ throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
129
+ }
130
+ return session;
131
+ }
132
+
133
+ export async function claimClipIntakeRecording(
134
+ intakeId: string,
135
+ token: string,
136
+ ): Promise<ClipIntakeSession | null> {
137
+ const session = await findClipIntakeSession(intakeId, token);
138
+ if (!session || session.status !== "open") return null;
139
+ const [claimed] = await getDb()
140
+ .update(schema.clipIntakeSessions)
141
+ .set({ status: "creating", updatedAt: new Date().toISOString() })
142
+ .where(
143
+ and(
144
+ eq(schema.clipIntakeSessions.id, intakeId),
145
+ eq(schema.clipIntakeSessions.status, "open"),
146
+ eq(schema.clipIntakeSessions.expiresAt, session.expiresAt),
147
+ gt(schema.clipIntakeSessions.expiresAt, new Date().toISOString()),
148
+ ),
149
+ )
150
+ .returning();
151
+ return claimed ?? null;
152
+ }
153
+
154
+ export async function findRecoverableClipIntakeRecording(
155
+ session: ClipIntakeSession,
156
+ ): Promise<{ id: string; organizationId: string } | null> {
157
+ if (session.status !== "recording" || !session.recordingId) return null;
158
+
159
+ const [recording] = await getDb()
160
+ .select({
161
+ id: schema.recordings.id,
162
+ organizationId: schema.recordings.organizationId,
163
+ status: schema.recordings.status,
164
+ })
165
+ .from(schema.recordings)
166
+ .where(
167
+ and(
168
+ eq(schema.recordings.id, session.recordingId),
169
+ ownerEmailMatches(schema.recordings.ownerEmail, session.ownerEmail),
170
+ eq(schema.recordings.organizationId, session.organizationId),
171
+ ),
172
+ )
173
+ .limit(1);
174
+
175
+ return recording?.status === "uploading"
176
+ ? { id: recording.id, organizationId: recording.organizationId }
177
+ : null;
178
+ }
179
+
180
+ export async function releaseClipIntakeCreation(
181
+ intakeId: string,
182
+ ): Promise<void> {
183
+ await getDb()
184
+ .update(schema.clipIntakeSessions)
185
+ .set({ status: "open", updatedAt: new Date().toISOString() })
186
+ .where(
187
+ and(
188
+ eq(schema.clipIntakeSessions.id, intakeId),
189
+ eq(schema.clipIntakeSessions.status, "creating"),
190
+ ),
191
+ );
192
+ }
193
+
194
+ export async function attachClipIntakeRecording(
195
+ intakeId: string,
196
+ recordingId: string,
197
+ ): Promise<void> {
198
+ const [updated] = await getDb()
199
+ .update(schema.clipIntakeSessions)
200
+ .set({
201
+ recordingId,
202
+ status: "recording",
203
+ updatedAt: new Date().toISOString(),
204
+ })
205
+ .where(
206
+ and(
207
+ eq(schema.clipIntakeSessions.id, intakeId),
208
+ eq(schema.clipIntakeSessions.status, "creating"),
209
+ ),
210
+ )
211
+ .returning({ id: schema.clipIntakeSessions.id });
212
+ if (!updated) throw new Error("Could not attach recording to intake session");
213
+ }
214
+
215
+ export async function completeClipIntake(
216
+ intakeId: string,
217
+ recordingId: string,
218
+ ): Promise<void> {
219
+ await getDb()
220
+ .update(schema.clipIntakeSessions)
221
+ .set({ status: "completed", updatedAt: new Date().toISOString() })
222
+ .where(
223
+ and(
224
+ eq(schema.clipIntakeSessions.id, intakeId),
225
+ eq(schema.clipIntakeSessions.recordingId, recordingId),
226
+ inArray(schema.clipIntakeSessions.status, ["recording", "completed"]),
227
+ ),
228
+ );
229
+ }
230
+
231
+ export async function abandonClipIntake(
232
+ intakeId: string,
233
+ recordingId?: string,
234
+ ): Promise<void> {
235
+ await getDb()
236
+ .update(schema.clipIntakeSessions)
237
+ .set({ status: "aborted", updatedAt: new Date().toISOString() })
238
+ .where(
239
+ and(
240
+ eq(schema.clipIntakeSessions.id, intakeId),
241
+ inArray(schema.clipIntakeSessions.status, ["creating", "recording"]),
242
+ ...(recordingId
243
+ ? [eq(schema.clipIntakeSessions.recordingId, recordingId)]
244
+ : []),
245
+ ),
246
+ );
247
+ }
@@ -38,6 +38,14 @@ export default createAuthPlugin({
38
38
  // for signed-in viewers and redirects anonymous viewers to /share/:id.
39
39
  "/r",
40
40
  "/bug-report",
41
+ // The recorder is public only so a signed intake URL can reach the
42
+ // browser capture UI. Recording creation and upload remain token-scoped.
43
+ "/record",
44
+ // Anonymous intake is a signed, write-only capability. The recording
45
+ // action and transport handlers perform their own token and recording-
46
+ // scope checks. Agent-link exchange stays behind normal auth.
47
+ "/_agent-native/actions/create-intake-recording",
48
+ "/api/clip-intake",
41
49
  // React Router's lazy route-discovery endpoint. If this is gated by
42
50
  // auth it returns an HTML login page; the client tries to parse it
43
51
  // as JSON, fails, and can't resolve any public route the user lands
@@ -1229,6 +1229,22 @@ export const migrations = runMigrations(
1229
1229
  ALTER TABLE IF EXISTS calendar_account_shares ADD COLUMN IF NOT EXISTS notified_at TEXT
1230
1230
  `,
1231
1231
  },
1232
+ {
1233
+ version: 72,
1234
+ name: "clips-intake-sessions",
1235
+ sql: `CREATE TABLE IF NOT EXISTS clips_intake_sessions (
1236
+ id TEXT PRIMARY KEY,
1237
+ owner_email TEXT NOT NULL,
1238
+ organization_id TEXT NOT NULL,
1239
+ recording_id TEXT,
1240
+ status TEXT NOT NULL DEFAULT 'open',
1241
+ expires_at TEXT NOT NULL,
1242
+ created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
1243
+ updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
1244
+ );
1245
+ CREATE INDEX IF NOT EXISTS clips_intake_sessions_expires_idx
1246
+ ON clips_intake_sessions (status, expires_at)`,
1247
+ },
1232
1248
  ],
1233
1249
  { table: "clips_migrations" },
1234
1250
  );
@@ -0,0 +1,61 @@
1
+ // guard:allow-api-route - anonymous intake status is a signed recovery transport, not a CRUD API.
2
+
3
+ import { and, eq } from "drizzle-orm";
4
+ import { defineEventHandler, getQuery, type H3Event } from "h3";
5
+
6
+ import { getDb, schema } from "../../db/index.js";
7
+ import {
8
+ completeClipIntake,
9
+ resolveClipIntakeStatusRequest,
10
+ } from "../../lib/clip-intake.js";
11
+ import { ownerEmailMatches } from "../../lib/recordings.js";
12
+
13
+ function queryString(event: H3Event, key: string): string | null {
14
+ const value = getQuery(event)[key];
15
+ const first = Array.isArray(value) ? value[0] : value;
16
+ return typeof first === "string" && first.trim() ? first.trim() : null;
17
+ }
18
+
19
+ export default defineEventHandler(async (event: H3Event) => {
20
+ if (queryString(event, "operation") !== "status") {
21
+ return { recording: null };
22
+ }
23
+
24
+ const recordingId = queryString(event, "recordingId");
25
+ if (!recordingId || recordingId.length > 200 || /[\r\n]/.test(recordingId)) {
26
+ return { recording: null };
27
+ }
28
+
29
+ const session = await resolveClipIntakeStatusRequest(event, recordingId);
30
+ const [recording] = await getDb()
31
+ .select({
32
+ id: schema.recordings.id,
33
+ status: schema.recordings.status,
34
+ videoUrl: schema.recordings.videoUrl,
35
+ })
36
+ .from(schema.recordings)
37
+ .where(
38
+ and(
39
+ eq(schema.recordings.id, recordingId),
40
+ ownerEmailMatches(schema.recordings.ownerEmail, session.ownerEmail),
41
+ eq(schema.recordings.organizationId, session.organizationId),
42
+ ),
43
+ )
44
+ .limit(1);
45
+
46
+ if (!recording) return { recording: null };
47
+
48
+ if (recording.status === "ready" && recording.videoUrl) {
49
+ await completeClipIntake(session.id, recordingId);
50
+ }
51
+
52
+ return {
53
+ recording: {
54
+ id: recording.id,
55
+ status: recording.status,
56
+ ...(recording.status === "processing" && recording.videoUrl
57
+ ? { verificationPending: true }
58
+ : {}),
59
+ },
60
+ };
61
+ });
@@ -0,0 +1,90 @@
1
+ // guard:allow-api-route - anonymous intake is a signed upload transport, not a CRUD API.
2
+
3
+ import { createError, defineEventHandler, getQuery, type H3Event } from "h3";
4
+
5
+ import {
6
+ abandonClipIntake,
7
+ completeClipIntake,
8
+ resolveClipIntakeRequest,
9
+ } from "../../lib/clip-intake.js";
10
+ import { handleAbortRecordingUpload } from "./uploads/[recordingId]/abort.post.js";
11
+ import { handleRecordingChunk } from "./uploads/[recordingId]/chunk.post.js";
12
+ import { handleResetRecordingChunks } from "./uploads/[recordingId]/reset-chunks.post.js";
13
+
14
+ function queryString(event: H3Event, key: string): string | null {
15
+ const value = getQuery(event)[key];
16
+ const first = Array.isArray(value) ? value[0] : value;
17
+ return typeof first === "string" && first.trim() ? first.trim() : null;
18
+ }
19
+
20
+ export default defineEventHandler(async (event: H3Event) => {
21
+ const recordingId = queryString(event, "recordingId");
22
+ if (!recordingId || recordingId.length > 200 || /[\r\n]/.test(recordingId)) {
23
+ throw createError({
24
+ statusCode: 400,
25
+ statusMessage: "Missing recordingId",
26
+ });
27
+ }
28
+
29
+ const operation = queryString(event, "operation");
30
+ if (operation !== "chunk" && operation !== "abort" && operation !== "reset") {
31
+ throw createError({
32
+ statusCode: 405,
33
+ statusMessage: "Unsupported operation",
34
+ });
35
+ }
36
+
37
+ const access = await resolveClipIntakeRequest(event, recordingId);
38
+ const intakeId = queryString(event, "clip_intake_id");
39
+ const override = {
40
+ recordingId,
41
+ ownerEmail: access.ownerEmail,
42
+ orgId: access.orgId,
43
+ };
44
+
45
+ if (operation === "reset") {
46
+ return handleResetRecordingChunks(event, override);
47
+ }
48
+
49
+ if (operation === "abort") {
50
+ const result = await handleAbortRecordingUpload(event, override);
51
+ const body =
52
+ result && typeof result === "object"
53
+ ? (result as Record<string, unknown>)
54
+ : null;
55
+ if (body?.ok === true) {
56
+ if (body.alreadyReady === true || body.verificationPending === true) {
57
+ if (intakeId) await completeClipIntake(intakeId, recordingId);
58
+ } else if (intakeId) {
59
+ await abandonClipIntake(intakeId, recordingId);
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+
65
+ const result = await handleRecordingChunk(event, override);
66
+ const body =
67
+ result && typeof result === "object"
68
+ ? (result as Record<string, unknown>)
69
+ : null;
70
+ if (body?.status === "failed" || body?.aborted === true) {
71
+ if (intakeId) await abandonClipIntake(intakeId, recordingId);
72
+ } else if (
73
+ body &&
74
+ (body.finalized === true ||
75
+ body.verificationPending === true ||
76
+ body.waitingForStorage === true)
77
+ ) {
78
+ if (intakeId) await completeClipIntake(intakeId, recordingId);
79
+ }
80
+
81
+ if (!body) return result;
82
+ const {
83
+ videoUrl: _videoUrl,
84
+ thumbnailUrl: _thumbnailUrl,
85
+ animatedThumbnailUrl: _animatedThumbnailUrl,
86
+ filmstripUrl: _filmstripUrl,
87
+ ...safeResult
88
+ } = body;
89
+ return safeResult;
90
+ });
@@ -34,14 +34,27 @@ import {
34
34
  } from "../../../../lib/resumable-session.js";
35
35
  import { resolveResumableUploadProvider } from "../../../../lib/resumable-upload-provider.js";
36
36
 
37
- export default defineEventHandler(async (event: H3Event) => {
38
- const recordingId = getRouterParam(event, "recordingId");
37
+ export async function handleAbortRecordingUpload(
38
+ event: H3Event,
39
+ override?: {
40
+ recordingId?: string;
41
+ ownerEmail?: string;
42
+ orgId?: string;
43
+ },
44
+ ) {
45
+ const recordingId =
46
+ override?.recordingId ?? getRouterParam(event, "recordingId");
39
47
  if (!recordingId) {
40
48
  setResponseStatus(event, 400);
41
49
  return { error: "Missing recordingId" };
42
50
  }
43
51
 
44
- const { userEmail: ownerEmail, orgId } = await getEventOwnerContext(event);
52
+ const { ownerEmail, orgId } = override?.ownerEmail
53
+ ? { ownerEmail: override.ownerEmail, orgId: override.orgId }
54
+ : await getEventOwnerContext(event).then((context) => ({
55
+ ownerEmail: context.userEmail,
56
+ orgId: context.orgId,
57
+ }));
45
58
  const body = (await readBody(event).catch(() => null)) as {
46
59
  reason?: unknown;
47
60
  attemptId?: unknown;
@@ -263,4 +276,6 @@ export default defineEventHandler(async (event: H3Event) => {
263
276
 
264
277
  return { ok: true, recordingId, chunksCleared: cleared };
265
278
  });
266
- });
279
+ }
280
+
281
+ export default defineEventHandler((event) => handleAbortRecordingUpload(event));
@@ -198,8 +198,16 @@ function trackUploadBlockingFailure(
198
198
  }
199
199
  }
200
200
 
201
- export default defineEventHandler(async (event: H3Event) => {
202
- const recordingId = getRouterParam(event, "recordingId");
201
+ export async function handleRecordingChunk(
202
+ event: H3Event,
203
+ override?: {
204
+ recordingId?: string;
205
+ ownerEmail?: string;
206
+ orgId?: string;
207
+ },
208
+ ) {
209
+ const recordingId =
210
+ override?.recordingId ?? getRouterParam(event, "recordingId");
203
211
  if (!recordingId) {
204
212
  throw createError({ statusCode: 400, message: "Missing recordingId" });
205
213
  }
@@ -264,13 +272,18 @@ export default defineEventHandler(async (event: H3Event) => {
264
272
 
265
273
  let ownerEmail: string;
266
274
  let orgId: string | undefined;
267
- try {
268
- const context = await getEventOwnerContext(event);
269
- ownerEmail = context.userEmail;
270
- orgId = context.orgId;
271
- } catch (err) {
272
- console.error("[chunk] getEventOwnerContext threw:", err);
273
- throw createError({ statusCode: 401, message: "Unauthorized" });
275
+ if (override?.ownerEmail) {
276
+ ownerEmail = override.ownerEmail;
277
+ orgId = override.orgId;
278
+ } else {
279
+ try {
280
+ const context = await getEventOwnerContext(event);
281
+ ownerEmail = context.userEmail;
282
+ orgId = context.orgId;
283
+ } catch (err) {
284
+ console.error("[chunk] getEventOwnerContext threw:", err);
285
+ throw createError({ statusCode: 401, message: "Unauthorized" });
286
+ }
274
287
  }
275
288
  debugLog("[chunk] resolved owner:", ownerEmail);
276
289
 
@@ -821,7 +834,9 @@ export default defineEventHandler(async (event: H3Event) => {
821
834
 
822
835
  return { ok: true, finalized: false, index, bytes: bytes.byteLength };
823
836
  });
824
- });
837
+ }
838
+
839
+ export default defineEventHandler((event) => handleRecordingChunk(event));
825
840
 
826
841
  function buildFinalizeArgs(
827
842
  recordingId: string,
@@ -158,14 +158,27 @@ function pickString(value: unknown, max: number): string | undefined {
158
158
  return trimmed.slice(0, max);
159
159
  }
160
160
 
161
- export default defineEventHandler(async (event: H3Event) => {
162
- const recordingId = getRouterParam(event, "recordingId");
161
+ export async function handleResetRecordingChunks(
162
+ event: H3Event,
163
+ override?: {
164
+ recordingId?: string;
165
+ ownerEmail?: string;
166
+ orgId?: string;
167
+ },
168
+ ) {
169
+ const recordingId =
170
+ override?.recordingId ?? getRouterParam(event, "recordingId");
163
171
  if (!recordingId) {
164
172
  setResponseStatus(event, 400);
165
173
  return { error: "Missing recordingId" };
166
174
  }
167
175
 
168
- const { userEmail: ownerEmail, orgId } = await getEventOwnerContext(event);
176
+ const { ownerEmail, orgId } = override?.ownerEmail
177
+ ? { ownerEmail: override.ownerEmail, orgId: override.orgId }
178
+ : await getEventOwnerContext(event).then(({ userEmail, orgId }) => ({
179
+ ownerEmail: userEmail,
180
+ orgId,
181
+ }));
169
182
  const body = (await readBody(event).catch(() => null)) as {
170
183
  compression?: CompressionMeta | null;
171
184
  requestStreaming?: boolean;
@@ -593,4 +606,8 @@ export default defineEventHandler(async (event: H3Event) => {
593
606
  uploadGenerationId: nextGenerationId,
594
607
  };
595
608
  });
596
- });
609
+ }
610
+
611
+ export default defineEventHandler((event: H3Event) =>
612
+ handleResetRecordingChunks(event),
613
+ );
@@ -0,0 +1,41 @@
1
+ export const CLIP_INTAKE_RESOURCE_KIND = "clip-intake";
2
+ export const CLIP_INTAKE_TOKEN_PARAM = "clip_intake";
3
+ export const CLIP_INTAKE_ID_PARAM = "clip_intake_id";
4
+ export const CLIP_INTAKE_DEFAULT_TTL_SECONDS = 60 * 60;
5
+ export const CLIP_INTAKE_MAX_TTL_SECONDS = 4 * 60 * 60;
6
+
7
+ export interface ClipIntakeParams {
8
+ intakeId: string;
9
+ token: string;
10
+ }
11
+
12
+ function queryValue(params: URLSearchParams, key: string): string | null {
13
+ const value = params.get(key)?.trim() ?? "";
14
+ return value || null;
15
+ }
16
+
17
+ export function parseClipIntakeParams(
18
+ params: URLSearchParams,
19
+ ): ClipIntakeParams | null {
20
+ const intakeId = queryValue(params, CLIP_INTAKE_ID_PARAM);
21
+ const token = queryValue(params, CLIP_INTAKE_TOKEN_PARAM);
22
+ if (!intakeId || !token) return null;
23
+ if (!/^[A-Za-z0-9_-]{16,80}$/.test(intakeId)) return null;
24
+ if (token.length > 4096 || /[\r\n]/.test(token)) return null;
25
+ return { intakeId, token };
26
+ }
27
+
28
+ export function buildClipIntakeUrl(
29
+ path: string,
30
+ params: ClipIntakeParams & {
31
+ recordingId: string;
32
+ operation: "chunk" | "abort" | "reset";
33
+ },
34
+ ): string {
35
+ const url = new URL(path, "https://clips-intake.invalid");
36
+ url.searchParams.set("recordingId", params.recordingId);
37
+ url.searchParams.set("operation", params.operation);
38
+ url.searchParams.set(CLIP_INTAKE_ID_PARAM, params.intakeId);
39
+ url.searchParams.set(CLIP_INTAKE_TOKEN_PARAM, params.token);
40
+ return `${url.pathname}${url.search}`;
41
+ }