@agent-native/core 0.168.5 → 0.168.6

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.
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8492
34
+ - template files: 8496
@@ -14,7 +14,10 @@ import { useState, useEffect, useCallback, useRef } from "react";
14
14
  import { createPortal } from "react-dom";
15
15
  import { toast } from "sonner";
16
16
 
17
- import { MAX_REFERENCE_FILE_BYTES } from "../../../shared/upload-types";
17
+ import {
18
+ MAX_REFERENCE_FILE_BYTES,
19
+ MAX_REFERENCE_FILES,
20
+ } from "../../../shared/upload-types";
18
21
  import { Button } from "../ui/button";
19
22
  import { Input } from "../ui/input";
20
23
  import { GoogleDocImportHint } from "./GoogleDocImportHint";
@@ -29,39 +32,159 @@ export interface UploadedFile {
29
32
  size: number;
30
33
  }
31
34
 
32
- export async function uploadPromptFiles(
33
- files: File[],
34
- ): Promise<UploadedFile[]> {
35
- if (files.length === 0) return [];
35
+ // Netlify functions cap request bodies well under what a real PPTX/PDF
36
+ // needs, so any file above this size streams through the chunked upload
37
+ // endpoints (sub-4 MB slices, reassembled server-side) instead of one
38
+ // multipart POST.
39
+ const CHUNK_UPLOAD_THRESHOLD_BYTES = 4 * 1024 * 1024;
40
+ const CHUNK_SIZE_BYTES = 4 * 1024 * 1024;
41
+
42
+ async function readUploadJson(response: Response): Promise<unknown> {
43
+ try {
44
+ return await response.json();
45
+ } catch (error) {
46
+ throw new Error(`Upload returned invalid JSON (${response.status})`, {
47
+ cause: error,
48
+ });
49
+ }
50
+ }
51
+
52
+ function extractErrorMessage(data: unknown): string | null {
53
+ if (
54
+ data &&
55
+ typeof data === "object" &&
56
+ "error" in data &&
57
+ typeof (data as { error: unknown }).error === "string" &&
58
+ (data as { error: string }).error.trim()
59
+ ) {
60
+ return (data as { error: string }).error;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ async function uploadFilesMultipart(files: File[]): Promise<UploadedFile[]> {
36
66
  const formData = new FormData();
37
67
  files.forEach((file) => formData.append("files", file));
38
- ensureEmbedAuthFetchInterceptor();
39
68
  const response = await fetch(`${appBasePath()}/api/uploads`, {
40
69
  method: "POST",
41
70
  body: formData,
42
71
  credentials: "include",
43
72
  });
73
+ const data = await readUploadJson(response);
44
74
  if (!response.ok) {
45
- let message = "Upload failed";
46
- try {
47
- const data: unknown = await response.json();
48
- if (
49
- data &&
50
- typeof data === "object" &&
51
- "error" in data &&
52
- typeof data.error === "string" &&
53
- data.error.trim()
54
- ) {
55
- message = data.error;
56
- }
57
- } catch (error) {
58
- const uploadError = new Error(`Upload failed (${response.status})`);
59
- (uploadError as Error & { cause?: unknown }).cause = error;
60
- throw uploadError;
75
+ throw new Error(
76
+ extractErrorMessage(data) || `Upload failed (${response.status})`,
77
+ );
78
+ }
79
+ if (!Array.isArray(data)) {
80
+ throw new Error("Upload failed: invalid response");
81
+ }
82
+ return data as UploadedFile[];
83
+ }
84
+
85
+ async function uploadFileChunked(file: File): Promise<UploadedFile> {
86
+ const startResponse = await fetch(
87
+ `${appBasePath()}/api/uploads-chunked/start`,
88
+ {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ credentials: "include",
92
+ body: JSON.stringify({
93
+ filename: file.name,
94
+ mimetype: file.type || "application/octet-stream",
95
+ declaredSize: file.size,
96
+ }),
97
+ },
98
+ );
99
+ const startData = await readUploadJson(startResponse);
100
+ if (!startResponse.ok) {
101
+ throw new Error(
102
+ extractErrorMessage(startData) ||
103
+ `Upload failed (${startResponse.status})`,
104
+ );
105
+ }
106
+ if (
107
+ startData &&
108
+ typeof startData === "object" &&
109
+ (startData as { uploadMode?: unknown }).uploadMode === "multipart"
110
+ ) {
111
+ const [uploaded] = await uploadFilesMultipart([file]);
112
+ if (!uploaded) throw new Error("Upload failed: no file returned");
113
+ return uploaded;
114
+ }
115
+ const sessionId =
116
+ startData && typeof startData === "object"
117
+ ? (startData as { sessionId?: unknown }).sessionId
118
+ : undefined;
119
+ if (typeof sessionId !== "string" || !sessionId) {
120
+ throw new Error("Upload failed: session ID missing");
121
+ }
122
+
123
+ const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE_BYTES));
124
+ for (let index = 0; index < totalChunks; index++) {
125
+ const start = index * CHUNK_SIZE_BYTES;
126
+ const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
127
+ const isFinal = index === totalChunks - 1;
128
+ const chunkResponse = await fetch(
129
+ `${appBasePath()}/api/uploads-chunked/${sessionId}/chunk?index=${index}&isFinal=${
130
+ isFinal ? "1" : "0"
131
+ }`,
132
+ {
133
+ method: "POST",
134
+ credentials: "include",
135
+ headers: { "Content-Type": "application/octet-stream" },
136
+ body: file.slice(start, end),
137
+ },
138
+ );
139
+ const chunkData = await readUploadJson(chunkResponse);
140
+ if (!chunkResponse.ok) {
141
+ throw new Error(
142
+ extractErrorMessage(chunkData) ||
143
+ `Upload failed (${chunkResponse.status})`,
144
+ );
145
+ }
146
+ if (isFinal) {
147
+ const result = Array.isArray(chunkData)
148
+ ? (chunkData[0] as UploadedFile)
149
+ : undefined;
150
+ if (!result) throw new Error("Upload failed: no file returned");
151
+ return result;
61
152
  }
62
- throw new Error(message);
63
153
  }
64
- return (await response.json()) as UploadedFile[];
154
+ throw new Error("Upload failed: no final chunk response");
155
+ }
156
+
157
+ export async function uploadPromptFiles(
158
+ files: File[],
159
+ ): Promise<UploadedFile[]> {
160
+ if (files.length === 0) return [];
161
+ if (files.length > MAX_REFERENCE_FILES) {
162
+ throw new Error(`Too many files (max ${MAX_REFERENCE_FILES})`);
163
+ }
164
+ ensureEmbedAuthFetchInterceptor();
165
+ const smallIndices = files.flatMap((file, index) =>
166
+ file.size <= CHUNK_UPLOAD_THRESHOLD_BYTES ? [index] : [],
167
+ );
168
+ const largeIndices = files.flatMap((file, index) =>
169
+ file.size > CHUNK_UPLOAD_THRESHOLD_BYTES ? [index] : [],
170
+ );
171
+ const [smallUploads, largeUploads] = await Promise.all([
172
+ smallIndices.length > 0
173
+ ? uploadFilesMultipart(smallIndices.map((index) => files[index]))
174
+ : [],
175
+ Promise.all(largeIndices.map((index) => uploadFileChunked(files[index]))),
176
+ ]);
177
+ if (smallUploads.length !== smallIndices.length) {
178
+ throw new Error("Upload failed: response file count did not match request");
179
+ }
180
+ const uploads = new Array<UploadedFile>(files.length);
181
+ smallIndices.forEach((fileIndex, resultIndex) => {
182
+ uploads[fileIndex] = smallUploads[resultIndex];
183
+ });
184
+ largeIndices.forEach((fileIndex, resultIndex) => {
185
+ uploads[fileIndex] = largeUploads[resultIndex];
186
+ });
187
+ return uploads;
65
188
  }
66
189
 
67
190
  /**
@@ -0,0 +1,317 @@
1
+ import {
2
+ deletePrivateBlob,
3
+ putPrivateBlob,
4
+ readPrivateBlob,
5
+ } from "@agent-native/core/private-blob";
6
+ import {
7
+ defineEventHandler,
8
+ getHeader,
9
+ getQuery,
10
+ getRouterParam,
11
+ readBody,
12
+ readRawBody,
13
+ setResponseStatus,
14
+ } from "h3";
15
+ import { nanoid } from "nanoid";
16
+
17
+ import {
18
+ createChunkedUploadSession,
19
+ deleteChunkedUploadSession,
20
+ getChunkedUploadSession,
21
+ listChunkedUploadSessions,
22
+ type ChunkedUploadSession,
23
+ } from "../lib/chunked-upload-session.js";
24
+ import { isHostedSlidesRuntime } from "../lib/tenant-files.js";
25
+ import {
26
+ resolveSlidesRequestAuth,
27
+ withSlidesRequestContext,
28
+ } from "./request-auth-context.js";
29
+ import { maxReferenceFileBytes, saveUploadedReferenceFile } from "./uploads.js";
30
+
31
+ const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
32
+ const MAX_CHUNKS = 128;
33
+ const SESSION_TTL_MS = 60 * 60 * 1000;
34
+
35
+ interface StartBody {
36
+ filename?: unknown;
37
+ mimetype?: unknown;
38
+ declaredSize?: unknown;
39
+ }
40
+
41
+ async function deleteChunk(handle: ChunkedUploadSession["chunks"][string]) {
42
+ return (await deletePrivateBlob(handle)).deleted;
43
+ }
44
+
45
+ async function cleanupChunks(session: ChunkedUploadSession): Promise<boolean> {
46
+ const results = await Promise.all(
47
+ Object.values(session.chunks).map(deleteChunk),
48
+ );
49
+ return results.every(Boolean);
50
+ }
51
+
52
+ async function discardSession(
53
+ sessionId: string,
54
+ session: ChunkedUploadSession,
55
+ ): Promise<boolean> {
56
+ const cleaned = await cleanupChunks(session);
57
+ if (cleaned) await deleteChunkedUploadSession(sessionId);
58
+ return cleaned;
59
+ }
60
+
61
+ async function reapExpiredChunkedUploads(): Promise<void> {
62
+ const now = Date.now();
63
+ const sessions = await listChunkedUploadSessions();
64
+ await Promise.all(
65
+ sessions.map(async ({ sessionId, session }) => {
66
+ const expiresAt = Date.parse(session.expiresAt);
67
+ if (Number.isFinite(expiresAt) && expiresAt > now) return;
68
+ try {
69
+ const cleaned = await discardSession(sessionId, session);
70
+ if (!cleaned) {
71
+ console.warn("[slides-upload] expired session cleanup incomplete", {
72
+ sessionId,
73
+ });
74
+ }
75
+ } catch (error) {
76
+ console.warn("[slides-upload] expired session cleanup failed", {
77
+ sessionId,
78
+ error: error instanceof Error ? error.message : String(error),
79
+ });
80
+ }
81
+ }),
82
+ );
83
+ }
84
+
85
+ async function cleanupCommittedSession(
86
+ sessionId: string,
87
+ session: ChunkedUploadSession,
88
+ ): Promise<void> {
89
+ try {
90
+ const cleaned = await discardSession(sessionId, session);
91
+ if (!cleaned) {
92
+ console.warn("[slides-upload] committed session cleanup incomplete", {
93
+ sessionId,
94
+ });
95
+ }
96
+ } catch (error) {
97
+ console.warn("[slides-upload] committed session cleanup failed", {
98
+ sessionId,
99
+ error: error instanceof Error ? error.message : String(error),
100
+ });
101
+ }
102
+ }
103
+
104
+ export const startChunkedUpload = defineEventHandler(async (event) => {
105
+ const auth = await resolveSlidesRequestAuth(event);
106
+ if (!auth.ok) {
107
+ setResponseStatus(event, auth.statusCode);
108
+ return { error: auth.error };
109
+ }
110
+ const authContext = auth.context;
111
+ if (!authContext.email) {
112
+ setResponseStatus(event, 401);
113
+ return { error: "Unauthorized" };
114
+ }
115
+
116
+ return withSlidesRequestContext(
117
+ event,
118
+ async () => {
119
+ if (!isHostedSlidesRuntime()) {
120
+ return { uploadMode: "multipart" as const };
121
+ }
122
+ await reapExpiredChunkedUploads();
123
+ const body = (await readBody(event).catch(
124
+ () => null,
125
+ )) as StartBody | null;
126
+ const filename =
127
+ typeof body?.filename === "string" ? body.filename.trim() : "";
128
+ const mimetype =
129
+ typeof body?.mimetype === "string" && body.mimetype.trim()
130
+ ? body.mimetype.trim()
131
+ : "application/octet-stream";
132
+ const declaredSize = Number(body?.declaredSize);
133
+ if (!filename) {
134
+ setResponseStatus(event, 400);
135
+ return { error: "filename is required" };
136
+ }
137
+ if (!Number.isSafeInteger(declaredSize) || declaredSize <= 0) {
138
+ setResponseStatus(event, 400);
139
+ return { error: "declaredSize must be a positive integer" };
140
+ }
141
+ const limit = maxReferenceFileBytes(filename);
142
+ if (declaredSize > limit) {
143
+ setResponseStatus(event, 413);
144
+ return {
145
+ error: `File too large (max ${Math.round(limit / 1024 / 1024)} MB)`,
146
+ };
147
+ }
148
+
149
+ const sessionId = nanoid();
150
+ const now = Date.now();
151
+ await createChunkedUploadSession(sessionId, {
152
+ filename,
153
+ mimeType: mimetype,
154
+ declaredSize,
155
+ chunks: {},
156
+ chunkSizes: {},
157
+ createdAt: new Date(now).toISOString(),
158
+ expiresAt: new Date(now + SESSION_TTL_MS).toISOString(),
159
+ });
160
+ return { sessionId, maxChunkBytes: MAX_CHUNK_BYTES };
161
+ },
162
+ authContext,
163
+ );
164
+ });
165
+
166
+ export const uploadChunkedChunk = defineEventHandler(async (event) => {
167
+ const auth = await resolveSlidesRequestAuth(event);
168
+ if (!auth.ok) {
169
+ setResponseStatus(event, auth.statusCode);
170
+ return { error: auth.error };
171
+ }
172
+ const authContext = auth.context;
173
+ const email = authContext.email;
174
+ if (!email) {
175
+ setResponseStatus(event, 401);
176
+ return { error: "Unauthorized" };
177
+ }
178
+
179
+ return withSlidesRequestContext(
180
+ event,
181
+ async ({ orgId }) => {
182
+ const sessionId = getRouterParam(event, "sessionId");
183
+ if (!sessionId) {
184
+ setResponseStatus(event, 400);
185
+ return { error: "Missing sessionId" };
186
+ }
187
+ const session = await getChunkedUploadSession(sessionId);
188
+ if (!session) {
189
+ setResponseStatus(event, 404);
190
+ return { error: "Upload session not found or expired" };
191
+ }
192
+ if (Date.parse(session.expiresAt) <= Date.now()) {
193
+ await discardSession(sessionId, session);
194
+ setResponseStatus(event, 410);
195
+ return { error: "Upload session expired" };
196
+ }
197
+
198
+ const query = getQuery(event);
199
+ const index = Number(query.index ?? 0);
200
+ const isFinal = query.isFinal === "1" || query.isFinal === "true";
201
+ if (!Number.isInteger(index) || index < 0 || index >= MAX_CHUNKS) {
202
+ setResponseStatus(event, 400);
203
+ return { error: "Invalid chunk index" };
204
+ }
205
+
206
+ const contentLengthHeader = getHeader(event, "content-length");
207
+ if (!contentLengthHeader || !/^\d+$/.test(contentLengthHeader)) {
208
+ setResponseStatus(event, 411);
209
+ return { error: "Valid Content-Length header required" };
210
+ }
211
+ const contentLength = Number(contentLengthHeader);
212
+ if (contentLength <= 0) {
213
+ setResponseStatus(event, 400);
214
+ return { error: "Empty chunk body" };
215
+ }
216
+ if (contentLength > MAX_CHUNK_BYTES) {
217
+ setResponseStatus(event, 413);
218
+ return { error: "Chunk too large" };
219
+ }
220
+
221
+ const chunkKey = String(index);
222
+ const previousSize = session.chunkSizes[chunkKey] ?? 0;
223
+ const receivedBefore = Object.values(session.chunkSizes).reduce(
224
+ (total, size) => total + size,
225
+ 0,
226
+ );
227
+ const nextSize = receivedBefore - previousSize + contentLength;
228
+ const fileLimit = maxReferenceFileBytes(session.filename);
229
+ if (nextSize > session.declaredSize || nextSize > fileLimit) {
230
+ await discardSession(sessionId, session);
231
+ setResponseStatus(event, 413);
232
+ return { error: "Uploaded bytes exceed the declared file size" };
233
+ }
234
+
235
+ const raw = await readRawBody(event, false);
236
+ const bytes = raw ?? new Uint8Array(0);
237
+ if (bytes.byteLength !== contentLength) {
238
+ setResponseStatus(event, 400);
239
+ return { error: "Chunk size does not match Content-Length" };
240
+ }
241
+
242
+ const previousHandle = session.chunks[chunkKey];
243
+ if (previousHandle && !(await deleteChunk(previousHandle))) {
244
+ setResponseStatus(event, 503);
245
+ return { error: "Could not replace the previously uploaded chunk" };
246
+ }
247
+
248
+ const handle = await putPrivateBlob({
249
+ data: bytes,
250
+ filename: `${sessionId}-${index}`,
251
+ mimeType: "application/octet-stream",
252
+ ownerEmail: email,
253
+ });
254
+ if (!handle) {
255
+ setResponseStatus(event, 503);
256
+ return { error: "Upload storage is not available" };
257
+ }
258
+ session.chunks[chunkKey] = handle;
259
+ session.chunkSizes[chunkKey] = bytes.byteLength;
260
+ await createChunkedUploadSession(sessionId, session);
261
+
262
+ if (!isFinal) return { ok: true };
263
+
264
+ const orderedIndices = Object.keys(session.chunks)
265
+ .map(Number)
266
+ .sort((a, b) => a - b);
267
+ const missing = orderedIndices.some((value, i) => value !== i);
268
+ const receivedSize = Object.values(session.chunkSizes).reduce(
269
+ (total, size) => total + size,
270
+ 0,
271
+ );
272
+ if (
273
+ missing ||
274
+ orderedIndices.length === 0 ||
275
+ receivedSize !== session.declaredSize
276
+ ) {
277
+ await discardSession(sessionId, session);
278
+ setResponseStatus(event, 400);
279
+ return { error: "Upload is incomplete or has an invalid size" };
280
+ }
281
+
282
+ let result;
283
+ try {
284
+ const parts = await Promise.all(
285
+ orderedIndices.map(async (chunkIndex) => {
286
+ const chunkHandle = session.chunks[String(chunkIndex)];
287
+ const read = await readPrivateBlob(chunkHandle);
288
+ return Buffer.from(read.data);
289
+ }),
290
+ );
291
+ const combined = Buffer.concat(parts);
292
+ if (combined.byteLength !== session.declaredSize) {
293
+ throw new Error("Assembled upload size does not match declaredSize");
294
+ }
295
+ result = await saveUploadedReferenceFile({
296
+ email,
297
+ orgId,
298
+ originalName: session.filename,
299
+ data: combined,
300
+ type: session.mimeType,
301
+ });
302
+ } catch (err) {
303
+ await discardSession(sessionId, session);
304
+ const statusCode =
305
+ typeof (err as { statusCode?: unknown })?.statusCode === "number"
306
+ ? (err as { statusCode: number }).statusCode
307
+ : 400;
308
+ setResponseStatus(event, statusCode);
309
+ return { error: err instanceof Error ? err.message : "Invalid upload" };
310
+ }
311
+
312
+ await cleanupCommittedSession(sessionId, session);
313
+ return [result];
314
+ },
315
+ authContext,
316
+ );
317
+ });
@@ -11,6 +11,7 @@ import { nanoid } from "nanoid";
11
11
  import {
12
12
  MAX_FIG_REFERENCE_FILE_BYTES,
13
13
  MAX_REFERENCE_FILE_BYTES,
14
+ MAX_REFERENCE_FILES,
14
15
  SLIDES_REFERENCE_FILE_ERROR_LABEL,
15
16
  isSlidesReferenceFileExtension,
16
17
  } from "../../shared/upload-types.js";
@@ -237,11 +238,9 @@ export const uploadFiles = defineEventHandler(async (event) => {
237
238
  return { error: "No files uploaded" };
238
239
  }
239
240
 
240
- const MAX_FILES = 20;
241
-
242
- if (fileParts.length > MAX_FILES) {
241
+ if (fileParts.length > MAX_REFERENCE_FILES) {
243
242
  setResponseStatus(event, 413);
244
- return { error: `Too many files (max ${MAX_FILES})` };
243
+ return { error: `Too many files (max ${MAX_REFERENCE_FILES})` };
245
244
  }
246
245
 
247
246
  const oversized = fileParts.find(
@@ -0,0 +1,54 @@
1
+ import {
2
+ deleteAppState,
3
+ listAppState,
4
+ readAppState,
5
+ writeAppState,
6
+ } from "@agent-native/core/application-state";
7
+ import type { PrivateBlobHandle } from "@agent-native/core/private-blob";
8
+
9
+ export interface ChunkedUploadSession {
10
+ filename: string;
11
+ mimeType: string;
12
+ declaredSize: number;
13
+ chunks: Record<string, PrivateBlobHandle>;
14
+ chunkSizes: Record<string, number>;
15
+ createdAt: string;
16
+ expiresAt: string;
17
+ }
18
+
19
+ const PREFIX = "slides-upload-chunks-";
20
+ const key = (sessionId: string) => `${PREFIX}${sessionId}`;
21
+
22
+ export async function createChunkedUploadSession(
23
+ sessionId: string,
24
+ session: ChunkedUploadSession,
25
+ ): Promise<void> {
26
+ await writeAppState(
27
+ key(sessionId),
28
+ session as unknown as Record<string, unknown>,
29
+ );
30
+ }
31
+
32
+ export async function getChunkedUploadSession(
33
+ sessionId: string,
34
+ ): Promise<ChunkedUploadSession | null> {
35
+ const raw = await readAppState(key(sessionId));
36
+ if (!raw || typeof raw !== "object") return null;
37
+ return raw as unknown as ChunkedUploadSession;
38
+ }
39
+
40
+ export async function listChunkedUploadSessions(): Promise<
41
+ Array<{ sessionId: string; session: ChunkedUploadSession }>
42
+ > {
43
+ const entries = await listAppState(PREFIX);
44
+ return entries.map(({ key: entryKey, value }) => ({
45
+ sessionId: entryKey.slice(PREFIX.length),
46
+ session: value as unknown as ChunkedUploadSession,
47
+ }));
48
+ }
49
+
50
+ export async function deleteChunkedUploadSession(
51
+ sessionId: string,
52
+ ): Promise<void> {
53
+ await deleteAppState(key(sessionId));
54
+ }
@@ -0,0 +1 @@
1
+ export { uploadChunkedChunk as default } from "../../../../handlers/uploads-chunked";
@@ -0,0 +1 @@
1
+ export { startChunkedUpload as default } from "../../../handlers/uploads-chunked";
@@ -25,6 +25,7 @@ export const SLIDES_REFERENCE_FILE_LABEL =
25
25
  export const SLIDES_REFERENCE_FILE_ERROR_LABEL =
26
26
  "pptx, docx, pdf, fig, text, Markdown, JSON, CSV, and images including SVG";
27
27
 
28
+ export const MAX_REFERENCE_FILES = 20;
28
29
  export const MAX_REFERENCE_FILE_BYTES = 50 * 1024 * 1024;
29
30
  export const MAX_FIG_REFERENCE_FILE_BYTES = 200 * 1024 * 1024;
30
31
 
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
- error?: undefined;
66
65
  states: {
67
66
  clientId: number;
68
67
  state: string;
69
68
  }[];
69
+ error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
- error?: undefined;
81
80
  users: {
82
81
  clientId: number;
83
82
  lastSeen: number;
84
83
  }[];
84
+ error?: undefined;
85
85
  }>>;
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- ok?: undefined;
30
29
  error: string;
30
+ ok?: undefined;
31
31
  } | {
32
32
  error?: undefined;
33
33
  ok: boolean;
@@ -199,6 +199,30 @@ export const builderFileUploadProvider = {
199
199
  console.log(`[builder-upload] done: ${json.url}`);
200
200
  return { url: json.url, id: json.id, provider: "builder" };
201
201
  },
202
+ delete: async ({ url }) => {
203
+ const assetUrl = new URL(url);
204
+ if (assetUrl.hostname !== "cdn.builder.io")
205
+ return false;
206
+ assetUrl.search = "";
207
+ assetUrl.hash = "";
208
+ const { resolveBuilderCredentials } = await import("../server/credential-provider.js");
209
+ const credentials = await resolveBuilderCredentials();
210
+ if (!credentials.privateKey || !credentials.publicKey)
211
+ return false;
212
+ const deleteUrl = new URL("/api/v1/assets/by-url", "https://cdn.builder.io");
213
+ deleteUrl.searchParams.set("url", assetUrl.toString());
214
+ deleteUrl.searchParams.set("apiKey", credentials.publicKey);
215
+ const response = await fetchWithTimeout(deleteUrl.toString(), {
216
+ method: "DELETE",
217
+ headers: { Authorization: `Bearer ${credentials.privateKey}` },
218
+ });
219
+ if (response.ok)
220
+ return true;
221
+ if (response.status === 404)
222
+ return false;
223
+ await assertOk(response, "Builder.io asset delete failed");
224
+ return false;
225
+ },
202
226
  resumable: {
203
227
  async startSession(filename, mimeType, maxBytes) {
204
228
  const { resolveBuilderPrivateKey } = await import("../server/credential-provider.js");
@@ -1,4 +1,4 @@
1
- export type { FileUploadInput, FileUploadProvider, FileUploadResult, ResumableUploadSession, ResumableChunkResult, } from "./types.js";
2
- export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, uploadFile, } from "./registry.js";
1
+ export type { FileUploadDeleteInput, FileUploadInput, FileUploadProvider, FileUploadResult, ResumableUploadSession, ResumableChunkResult, } from "./types.js";
2
+ export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, deleteUploadedFile, uploadFile, } from "./registry.js";
3
3
  export { builderFileUploadProvider } from "./builder.js";
4
4
  export { preUploadImageAttachments, preUploadAttachments, isFileUploadProviderConfigured, type PreUploadAttachmentsResult, type PreUploadedImageAttachment, type PreUploadedFileAttachment, } from "./pre-upload-attachments.js";
@@ -1,3 +1,3 @@
1
- export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, uploadFile, } from "./registry.js";
1
+ export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, deleteUploadedFile, uploadFile, } from "./registry.js";
2
2
  export { builderFileUploadProvider } from "./builder.js";
3
3
  export { preUploadImageAttachments, preUploadAttachments, isFileUploadProviderConfigured, } from "./pre-upload-attachments.js";
@@ -1,4 +1,4 @@
1
- import type { FileUploadInput, FileUploadProvider, FileUploadResult } from "./types.js";
1
+ import type { FileUploadDeleteInput, FileUploadInput, FileUploadProvider, FileUploadResult } from "./types.js";
2
2
  /**
3
3
  * Register a file upload provider. Call from a server plugin or app
4
4
  * bootstrap. Idempotent per id — later calls with the same id replace.
@@ -19,4 +19,5 @@ export declare function getActiveFileUploadProviderForRequest(): Promise<FileUpl
19
19
  * configured. `null` is an explicit storage-setup state: callers must not
20
20
  * turn the input into a base64 SQL fallback.
21
21
  */
22
+ export declare function deleteUploadedFile(providerId: string, input: FileUploadDeleteInput): Promise<boolean>;
22
23
  export declare function uploadFile(input: FileUploadInput): Promise<FileUploadResult | null>;
@@ -59,6 +59,14 @@ export async function getActiveFileUploadProviderForRequest() {
59
59
  * configured. `null` is an explicit storage-setup state: callers must not
60
60
  * turn the input into a base64 SQL fallback.
61
61
  */
62
+ export async function deleteUploadedFile(providerId, input) {
63
+ const provider = providerId === builderFileUploadProvider.id
64
+ ? builderFileUploadProvider
65
+ : providers.get(providerId);
66
+ if (!provider?.delete)
67
+ return false;
68
+ return provider.delete(input);
69
+ }
62
70
  export async function uploadFile(input) {
63
71
  const provider = await getActiveFileUploadProviderForRequest();
64
72
  // User-registered providers (S3, etc.) may be configured by sync runtime
@@ -146,6 +146,56 @@ async function putObject(config, key, data, contentType) {
146
146
  }
147
147
  return `${config.publicBaseUrl}/${key.split("/").map(encodePathSegment).join("/")}`;
148
148
  }
149
+ async function deleteObject(config, key) {
150
+ const now = new Date();
151
+ const amzDate = now
152
+ .toISOString()
153
+ .replace(/[:-]|\.\d{3}/g, "")
154
+ .slice(0, 15) + "Z";
155
+ const dateStamp = amzDate.slice(0, 8);
156
+ const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;
157
+ const host = new URL(config.endpoint).host;
158
+ const canonicalUri = objectPath(config, key);
159
+ const payloadHash = await sha256(new Uint8Array(0));
160
+ const headers = {
161
+ host,
162
+ "x-amz-content-sha256": payloadHash,
163
+ "x-amz-date": amzDate,
164
+ };
165
+ const signedHeaderKeys = Object.keys(headers).sort();
166
+ const signedHeaders = signedHeaderKeys.join(";");
167
+ const canonicalHeaders = signedHeaderKeys
168
+ .map((header) => `${header}:${headers[header]}`)
169
+ .join("\n") + "\n";
170
+ const canonicalRequest = [
171
+ "DELETE",
172
+ canonicalUri,
173
+ "",
174
+ canonicalHeaders,
175
+ signedHeaders,
176
+ payloadHash,
177
+ ].join("\n");
178
+ const requestHash = await sha256(new TextEncoder().encode(canonicalRequest));
179
+ const stringToSign = [
180
+ "AWS4-HMAC-SHA256",
181
+ amzDate,
182
+ credentialScope,
183
+ requestHash,
184
+ ].join("\n");
185
+ const signature = toHex(await hmac(await signingKey(config.secretAccessKey, dateStamp, config.region), stringToSign));
186
+ const authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, ` +
187
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
188
+ const response = await fetch(`${config.endpoint}${canonicalUri}`, {
189
+ method: "DELETE",
190
+ headers: { ...headers, Authorization: authorization },
191
+ });
192
+ if (response.ok)
193
+ return true;
194
+ if (response.status === 404)
195
+ return false;
196
+ const detail = await response.text();
197
+ throw new Error(`S3 DeleteObject failed (${response.status}): ${detail || response.statusText}`);
198
+ }
149
199
  function safeFilename(filename) {
150
200
  const basename = filename?.split(/[\\/]/).pop()?.trim() || "attachment";
151
201
  return (basename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "attachment");
@@ -163,6 +213,14 @@ export const s3FileUploadProvider = {
163
213
  const key = `uploads/${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${safeFilename(filename)}`;
164
214
  const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
165
215
  const url = await putObject(config, key, bytes, mimeType || "application/octet-stream");
166
- return { url, provider: "s3" };
216
+ return { url, id: key, provider: "s3" };
217
+ },
218
+ delete: async ({ id }) => {
219
+ if (!id)
220
+ return false;
221
+ const config = await readRequestConfig();
222
+ if (!config)
223
+ return false;
224
+ return deleteObject(config, id);
167
225
  },
168
226
  };
@@ -28,6 +28,10 @@ export interface FileUploadResult {
28
28
  /** The provider that handled the upload. */
29
29
  provider: string;
30
30
  }
31
+ export interface FileUploadDeleteInput {
32
+ url: string;
33
+ id?: string;
34
+ }
31
35
  /** Opaque session handle returned by {@link FileUploadProvider.resumable.startSession}.
32
36
  * `sessionId` is provider-specific (GCS Location URI, S3 UploadId, etc.).
33
37
  * `meta` holds any provider state needed for subsequent relay and complete calls. */
@@ -56,6 +60,8 @@ export interface FileUploadProvider {
56
60
  isConfiguredForRequest?: () => Promise<boolean>;
57
61
  /** Upload a file and return a URL. Throw on failure. */
58
62
  upload: (input: FileUploadInput) => Promise<FileUploadResult>;
63
+ /** Delete a previously uploaded file when the provider supports it. */
64
+ delete?: (input: FileUploadDeleteInput) => Promise<boolean>;
59
65
  /**
60
66
  * Optional resumable/streaming upload capability.
61
67
  * When present, create-recording will initialise a session and stream chunks
@@ -11,14 +11,14 @@
11
11
  * DELETE /_agent-native/notifications/:id — delete
12
12
  */
13
13
  export declare function createNotificationsHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<"" | import("./types.js").Notification[] | {
14
- error?: undefined;
15
14
  count: number;
16
15
  updated?: undefined;
16
+ error?: undefined;
17
17
  ok?: undefined;
18
18
  } | {
19
- error?: undefined;
20
19
  count?: undefined;
21
20
  updated: number;
21
+ error?: undefined;
22
22
  ok?: undefined;
23
23
  } | {
24
24
  count?: undefined;
@@ -26,8 +26,8 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
26
26
  error: string;
27
27
  ok?: undefined;
28
28
  } | {
29
- error?: undefined;
30
29
  count?: undefined;
31
30
  updated?: undefined;
32
31
  ok: boolean;
32
+ error?: undefined;
33
33
  }>>;
@@ -3,11 +3,6 @@ export declare function registerPrivateBlobProvider(provider: PrivateBlobProvide
3
3
  export declare function unregisterPrivateBlobProvider(id: string): void;
4
4
  export declare function listPrivateBlobProviders(): PrivateBlobProvider[];
5
5
  export declare function getActivePrivateBlobProvider(): PrivateBlobProvider | null;
6
- /**
7
- * @deprecated Use `defineAppConfig({ privateBlob: { publicUploadFallback } })`
8
- * instead. This writes the same value into the deprecated layer of the config
9
- * ladder, so an explicit `defineAppConfig` call now wins over it.
10
- */
11
6
  export declare function setPrivateBlobPublicUploadFallbackEnabled(enabled: boolean): void;
12
7
  export declare function putPrivateBlob(input: PrivateBlobPutInput): Promise<PrivateBlobHandle | null>;
13
8
  export declare function readPrivateBlob(handle: PrivateBlobHandle): Promise<PrivateBlobReadResult>;
@@ -1,12 +1,14 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
2
  import { getAppConfig } from "../app-config/index.js";
3
- import { setAppConfigLayer } from "../app-config/store.js";
4
- import { uploadFile } from "../file-upload/index.js";
3
+ import { deleteUploadedFile, uploadFile } from "../file-upload/index.js";
5
4
  import { decryptSecretValue, encryptSecretValue, getSecretEncryptionKey, } from "../secrets/crypto.js";
6
5
  const PUBLIC_UPLOAD_HANDLE_PREFIX = "public-upload:v1:";
7
6
  const PUBLIC_UPLOAD_READ_RETRY_DELAYS_MS = [100, 250, 500];
8
7
  const globals = globalThis;
9
8
  const providers = (globals.__agentNativePrivateBlobProviders ??= new Map());
9
+ const publicUploadFallbackRef = (globals.__agentNativePrivateBlobPublicUploadFallback ??= {
10
+ enabled: true,
11
+ });
10
12
  function toBytes(data) {
11
13
  return data instanceof Uint8Array ? data : new Uint8Array(data);
12
14
  }
@@ -170,13 +172,16 @@ export function listPrivateBlobProviders() {
170
172
  return [...providers.values()];
171
173
  }
172
174
  export function getActivePrivateBlobProvider() {
173
- const selected = getAppConfig().privateBlob.provider;
174
- if (selected) {
175
- const provider = providers.get(selected);
176
- if (!provider) {
177
- throw new Error(`Private blob provider "${selected}" is selected in app config but no provider with that id is registered`);
175
+ const selectedId = getAppConfig().privateBlob.provider;
176
+ if (selectedId) {
177
+ const selected = providers.get(selectedId);
178
+ if (!selected) {
179
+ throw new Error(`Private blob config selects '${selectedId}', but no provider with that id is registered`);
178
180
  }
179
- return provider;
181
+ if (!selected.isConfigured()) {
182
+ throw new Error(`Private blob provider '${selectedId}' is selected but not configured`);
183
+ }
184
+ return selected;
180
185
  }
181
186
  for (const provider of providers.values()) {
182
187
  if (provider.isConfigured())
@@ -184,20 +189,15 @@ export function getActivePrivateBlobProvider() {
184
189
  }
185
190
  return null;
186
191
  }
187
- /**
188
- * @deprecated Use `defineAppConfig({ privateBlob: { publicUploadFallback } })`
189
- * instead. This writes the same value into the deprecated layer of the config
190
- * ladder, so an explicit `defineAppConfig` call now wins over it.
191
- */
192
192
  export function setPrivateBlobPublicUploadFallbackEnabled(enabled) {
193
- setAppConfigLayer("legacy", {
194
- privateBlob: { publicUploadFallback: enabled },
195
- });
193
+ publicUploadFallbackRef.enabled = enabled;
196
194
  }
197
195
  export async function putPrivateBlob(input) {
198
196
  const provider = getActivePrivateBlobProvider();
199
197
  if (provider)
200
198
  return provider.put(input);
199
+ if (!publicUploadFallbackRef.enabled)
200
+ return null;
201
201
  if (!getAppConfig().privateBlob.publicUploadFallback)
202
202
  return null;
203
203
  return putViaEncryptedPublicUpload(input);
@@ -216,10 +216,17 @@ export async function deletePrivateBlob(handle) {
216
216
  if (provider)
217
217
  return provider.delete(handle);
218
218
  if (isPublicUploadFallbackHandle(handle)) {
219
+ const descriptor = decodePublicUploadDescriptor(handle.id);
220
+ const deleted = await deleteUploadedFile(descriptor.uploadProvider, {
221
+ url: descriptor.url,
222
+ id: descriptor.uploadId,
223
+ });
219
224
  return {
220
- deleted: false,
225
+ deleted,
221
226
  provider: handle.provider,
222
- reason: "delete is not supported by the encrypted public-upload fallback provider",
227
+ ...(deleted
228
+ ? {}
229
+ : { reason: "backing upload provider could not delete the asset" }),
223
230
  };
224
231
  }
225
232
  throw new Error(`No private blob provider registered for ${handle.provider}`);
@@ -15,6 +15,6 @@ export declare function createProgressHandler(): import("h3").EventHandlerWithFe
15
15
  error: string;
16
16
  ok?: undefined;
17
17
  } | {
18
- error?: undefined;
19
18
  ok: boolean;
19
+ error?: undefined;
20
20
  }>>;
@@ -51,8 +51,8 @@ export declare function handleDeleteResource(event: any): Promise<{
51
51
  error: string;
52
52
  ok?: undefined;
53
53
  } | {
54
- error?: undefined;
55
54
  ok: boolean;
55
+ error?: undefined;
56
56
  }>;
57
57
  /** POST /_agent-native/resources/upload — upload a file as a resource */
58
58
  export declare function handleUploadResource(event: any): Promise<import("./store.js").Resource | {
@@ -73,10 +73,10 @@ export declare function handleUploadResource(event: any): Promise<import("./stor
73
73
  runId: string | null;
74
74
  expiresAt: number | null;
75
75
  metadata: string | null;
76
- error?: undefined;
77
76
  url: string;
78
77
  provider: string;
79
78
  storageSetupRequired?: undefined;
79
+ error?: undefined;
80
80
  } | {
81
81
  error: string;
82
82
  storageSetupRequired: boolean;
@@ -37,17 +37,17 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
37
37
  ok?: undefined;
38
38
  status?: undefined;
39
39
  } | {
40
+ error?: undefined;
40
41
  ok: boolean;
41
42
  status: string;
42
- error?: undefined;
43
43
  } | {
44
44
  ok?: undefined;
45
45
  error: string;
46
46
  removed?: undefined;
47
47
  } | {
48
+ error?: undefined;
48
49
  ok: boolean;
49
50
  removed: boolean;
50
- error?: undefined;
51
51
  }>>;
52
52
  /**
53
53
  * POST /_agent-native/secrets/:key/test — validate an optional candidate value
@@ -58,13 +58,13 @@ export declare function createTestSecretHandler(): import("h3").EventHandlerWith
58
58
  error: string;
59
59
  note?: undefined;
60
60
  } | {
61
+ error?: undefined;
61
62
  ok: boolean;
62
63
  note?: undefined;
63
- error?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  ok: boolean;
66
67
  note: string;
67
- error?: undefined;
68
68
  } | {
69
69
  note?: undefined;
70
70
  ok: boolean;
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
95
95
  export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
96
96
  error: string;
97
97
  } | {
98
+ error?: undefined;
98
99
  ok: boolean;
99
100
  key: string;
100
- error?: undefined;
101
101
  } | {
102
+ error?: undefined;
102
103
  ok: boolean;
103
104
  removed: boolean;
104
- error?: undefined;
105
105
  }>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.168.5",
3
+ "version": "0.168.6",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {