@agent-native/core 0.101.7 → 0.101.11

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 (219) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +28 -0
  3. package/corpus/core/docs/content/deployment.mdx +9 -7
  4. package/corpus/core/docs/content/locales/ar-SA/deployment.mdx +9 -7
  5. package/corpus/core/docs/content/locales/de-DE/deployment.mdx +9 -7
  6. package/corpus/core/docs/content/locales/es-ES/deployment.mdx +9 -7
  7. package/corpus/core/docs/content/locales/fr-FR/deployment.mdx +9 -7
  8. package/corpus/core/docs/content/locales/hi-IN/deployment.mdx +9 -7
  9. package/corpus/core/docs/content/locales/ja-JP/deployment.mdx +9 -7
  10. package/corpus/core/docs/content/locales/ko-KR/deployment.mdx +9 -7
  11. package/corpus/core/docs/content/locales/pt-BR/deployment.mdx +9 -7
  12. package/corpus/core/docs/content/locales/zh-CN/deployment.mdx +9 -7
  13. package/corpus/core/docs/content/locales/zh-TW/deployment.mdx +9 -7
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/a2a/client.ts +23 -1
  16. package/corpus/core/src/a2a/handlers.ts +65 -1
  17. package/corpus/core/src/a2a/server.ts +124 -0
  18. package/corpus/core/src/a2a/task-store.ts +253 -0
  19. package/corpus/core/src/a2a/types.ts +26 -0
  20. package/corpus/core/src/agent/production-agent.ts +5 -2
  21. package/corpus/core/src/client/review/ReviewCommentComposer.tsx +9 -1
  22. package/corpus/core/src/client/review/ReviewThreadPanel.tsx +17 -2
  23. package/corpus/core/src/client/settings/SettingsPanel.tsx +41 -21
  24. package/corpus/core/src/client/settings/useBuilderStatus.ts +28 -9
  25. package/corpus/core/src/client/setup-connections/BuilderConnectCard.tsx +2 -7
  26. package/corpus/core/src/mcp/builtin-tools.ts +25 -1
  27. package/corpus/core/src/scripts/call-agent.ts +22 -3
  28. package/corpus/core/src/server/agent-chat-plugin.ts +86 -1
  29. package/corpus/core/src/server/auth.ts +25 -1
  30. package/corpus/core/src/server/builder-browser.ts +353 -1
  31. package/corpus/core/src/server/core-routes-plugin.ts +601 -223
  32. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  33. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +80 -1
  34. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +28 -3
  35. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +5 -1
  36. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +30 -3
  37. package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +196 -0
  38. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +115 -1
  39. package/corpus/templates/calendar/app/lib/out-of-office.ts +45 -0
  40. package/corpus/templates/calendar/changelog/2026-07-14-out-of-office-blocks-now-sit-behind-meetings-and-event-stack.md +6 -0
  41. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +23 -0
  42. package/corpus/templates/content/AGENTS.md +3 -2
  43. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +87 -0
  44. package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -25
  45. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  46. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +427 -18
  47. package/corpus/templates/content/actions/_builder-cms-write-client.ts +17 -73
  48. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +27 -7
  49. package/corpus/templates/content/actions/_builder-source-execution-claim.ts +124 -0
  50. package/corpus/templates/content/actions/_builder-source-execution-preservation.ts +21 -0
  51. package/corpus/templates/content/actions/_builder-source-timings.ts +57 -0
  52. package/corpus/templates/content/actions/_database-source-utils.ts +1041 -154
  53. package/corpus/templates/content/actions/_database-utils.ts +89 -12
  54. package/corpus/templates/content/actions/_preview-document-draft.ts +31 -0
  55. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +309 -0
  56. package/corpus/templates/content/actions/execute-builder-source-batch.ts +120 -49
  57. package/corpus/templates/content/actions/execute-builder-source-execution.ts +608 -264
  58. package/corpus/templates/content/actions/get-preview-document-draft.ts +26 -0
  59. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +550 -0
  60. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +112 -65
  61. package/corpus/templates/content/actions/prepare-builder-source-review.ts +463 -170
  62. package/corpus/templates/content/actions/preview-builder-source-review.ts +99 -0
  63. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +2 -2
  64. package/corpus/templates/content/actions/update-preview-document-draft.ts +153 -0
  65. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +27 -15
  66. package/corpus/templates/content/app/components/editor/MathRenderer.tsx +43 -0
  67. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +260 -4
  68. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +57 -5
  69. package/corpus/templates/content/app/components/editor/body-hydration.ts +27 -0
  70. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2353 -576
  71. package/corpus/templates/content/app/components/editor/database/settings.tsx +13 -10
  72. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +358 -75
  73. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +78 -0
  74. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +124 -29
  75. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +2 -0
  76. package/corpus/templates/content/app/global.css +71 -1
  77. package/corpus/templates/content/app/hooks/use-content-database.ts +68 -0
  78. package/corpus/templates/content/app/hooks/use-documents.ts +53 -4
  79. package/corpus/templates/content/app/i18n/zh-TW.ts +38 -0
  80. package/corpus/templates/content/app/i18n-data.ts +450 -0
  81. package/corpus/templates/content/app/root.tsx +2 -0
  82. package/corpus/templates/content/changelog/2026-07-10-builder-backed-preview-edits-are-preserved-when-article-bodi.md +6 -0
  83. package/corpus/templates/content/changelog/2026-07-10-builder-cms-sources-can-now-be-enabled-for-guarded-staged-or.md +6 -0
  84. package/corpus/templates/content/changelog/2026-07-13-builder-connection-setup-is-now-available-directly-from-conn.md +6 -0
  85. package/corpus/templates/content/changelog/2026-07-13-builder-reviews-now-hide-already-applied-writes-detect-nativ.md +6 -0
  86. package/corpus/templates/content/changelog/2026-07-13-required-builder-publishing-fields-can-now-be-added-directly.md +6 -0
  87. package/corpus/templates/content/changelog/2026-07-13-you-can-now-cancel-a-prepared-builder-update-safely-before-i.md +6 -0
  88. package/corpus/templates/content/changelog/2026-07-14-builder-account-connection-now-remains-available-when-branch.md +6 -0
  89. package/corpus/templates/content/changelog/2026-07-14-builder-backed-articles-now-preserve-fifth-and-sixth-level-h.md +6 -0
  90. package/corpus/templates/content/changelog/2026-07-14-builder-sources-now-review-and-publish-rich-article-updates-.md +6 -0
  91. package/corpus/templates/content/changelog/2026-07-14-builder-writes-now-stop-retries-when-reconciliation-is-needed.md +6 -0
  92. package/corpus/templates/content/changelog/2026-07-14-file-and-media-links-now-validate-before-saving.md +6 -0
  93. package/corpus/templates/content/changelog/2026-07-14-latex-equations-render-in-documents-public-pages-and-exports.md +6 -0
  94. package/corpus/templates/content/package.json +1 -0
  95. package/corpus/templates/content/parity/matrix.md +27 -25
  96. package/corpus/templates/content/parity/matrix.ts +50 -2
  97. package/corpus/templates/content/server/db/schema.ts +47 -0
  98. package/corpus/templates/content/server/plugins/auth.ts +1 -0
  99. package/corpus/templates/content/server/plugins/db.ts +53 -0
  100. package/corpus/templates/content/shared/api.ts +70 -1
  101. package/corpus/templates/content/shared/builder-mdx.ts +705 -15
  102. package/corpus/templates/content/shared/document-export.ts +296 -25
  103. package/corpus/templates/content/shared/inline-math.ts +128 -0
  104. package/corpus/templates/content/shared/math-rendering.ts +50 -0
  105. package/corpus/templates/content/shared/nfm.ts +20 -17
  106. package/corpus/templates/content/vite.config.ts +0 -1
  107. package/corpus/templates/design/app/components/design/ReadOnlyDesignBanner.tsx +52 -7
  108. package/corpus/templates/design/app/components/design/ReviewCommentsPanel.tsx +36 -4
  109. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  110. package/corpus/templates/design/app/i18n/ar-SA.ts +1 -0
  111. package/corpus/templates/design/app/i18n/de-DE.ts +1 -0
  112. package/corpus/templates/design/app/i18n/en-US.ts +1 -0
  113. package/corpus/templates/design/app/i18n/es-ES.ts +1 -0
  114. package/corpus/templates/design/app/i18n/fr-FR.ts +1 -0
  115. package/corpus/templates/design/app/i18n/hi-IN.ts +1 -0
  116. package/corpus/templates/design/app/i18n/ja-JP.ts +1 -0
  117. package/corpus/templates/design/app/i18n/ko-KR.ts +1 -0
  118. package/corpus/templates/design/app/i18n/pt-BR.ts +1 -0
  119. package/corpus/templates/design/app/i18n/zh-CN.ts +1 -0
  120. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  121. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +8 -0
  122. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +11 -0
  123. package/corpus/templates/design/changelog/2026-07-14-reviewers-can-now-pin-comments-from-the-canvas-toolbar-targe.md +6 -0
  124. package/corpus/templates/design/changelog/2026-07-15-viewer-comment-controls-now-sit-inside-the-read-only-notice-.md +6 -0
  125. package/corpus/templates/design/shared/review-anchor.ts +48 -0
  126. package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +17 -0
  127. package/corpus/templates/mail/AGENTS.md +21 -19
  128. package/corpus/templates/mail/actions/create-attachment-upload.ts +49 -0
  129. package/corpus/templates/mail/changelog/2026-07-14-connected-agents-can-now-securely-upload-local-files-for-mai.md +6 -0
  130. package/corpus/templates/mail/server/handlers/media.ts +84 -61
  131. package/corpus/templates/mail/server/lib/attachment-upload-ticket.ts +234 -0
  132. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -4
  133. package/corpus/templates/mail/server/lib/media-upload.ts +114 -0
  134. package/corpus/templates/mail/server/plugins/auth.ts +9 -1
  135. package/corpus/templates/mail/server/routes/api/media/attachment-upload/[uploadId].put.ts +1 -0
  136. package/corpus/templates/plan/server/plan-mdx.ts +53 -7
  137. package/dist/a2a/client.d.ts +5 -1
  138. package/dist/a2a/client.d.ts.map +1 -1
  139. package/dist/a2a/client.js +18 -1
  140. package/dist/a2a/client.js.map +1 -1
  141. package/dist/a2a/handlers.d.ts.map +1 -1
  142. package/dist/a2a/handlers.js +57 -8
  143. package/dist/a2a/handlers.js.map +1 -1
  144. package/dist/a2a/server.d.ts.map +1 -1
  145. package/dist/a2a/server.js +87 -1
  146. package/dist/a2a/server.js.map +1 -1
  147. package/dist/a2a/task-store.d.ts +27 -0
  148. package/dist/a2a/task-store.d.ts.map +1 -1
  149. package/dist/a2a/task-store.js +216 -0
  150. package/dist/a2a/task-store.js.map +1 -1
  151. package/dist/a2a/types.d.ts +24 -0
  152. package/dist/a2a/types.d.ts.map +1 -1
  153. package/dist/a2a/types.js.map +1 -1
  154. package/dist/agent/production-agent.d.ts +1 -0
  155. package/dist/agent/production-agent.d.ts.map +1 -1
  156. package/dist/agent/production-agent.js +5 -2
  157. package/dist/agent/production-agent.js.map +1 -1
  158. package/dist/client/review/ReviewCommentComposer.d.ts +2 -1
  159. package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -1
  160. package/dist/client/review/ReviewCommentComposer.js +3 -3
  161. package/dist/client/review/ReviewCommentComposer.js.map +1 -1
  162. package/dist/client/review/ReviewThreadPanel.d.ts +9 -1
  163. package/dist/client/review/ReviewThreadPanel.d.ts.map +1 -1
  164. package/dist/client/review/ReviewThreadPanel.js +6 -4
  165. package/dist/client/review/ReviewThreadPanel.js.map +1 -1
  166. package/dist/client/settings/SettingsPanel.d.ts +3 -0
  167. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  168. package/dist/client/settings/SettingsPanel.js +11 -4
  169. package/dist/client/settings/SettingsPanel.js.map +1 -1
  170. package/dist/client/settings/useBuilderStatus.d.ts +8 -4
  171. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  172. package/dist/client/settings/useBuilderStatus.js +19 -6
  173. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  174. package/dist/client/setup-connections/BuilderConnectCard.d.ts.map +1 -1
  175. package/dist/client/setup-connections/BuilderConnectCard.js +2 -5
  176. package/dist/client/setup-connections/BuilderConnectCard.js.map +1 -1
  177. package/dist/collab/awareness.d.ts +2 -2
  178. package/dist/collab/awareness.d.ts.map +1 -1
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  181. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  182. package/dist/mcp/builtin-tools.js +22 -3
  183. package/dist/mcp/builtin-tools.js.map +1 -1
  184. package/dist/notifications/routes.d.ts +3 -3
  185. package/dist/resources/handlers.d.ts +2 -2
  186. package/dist/scripts/call-agent.d.ts +1 -1
  187. package/dist/scripts/call-agent.d.ts.map +1 -1
  188. package/dist/scripts/call-agent.js +19 -1
  189. package/dist/scripts/call-agent.js.map +1 -1
  190. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  191. package/dist/server/agent-chat-plugin.js +71 -2
  192. package/dist/server/agent-chat-plugin.js.map +1 -1
  193. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  194. package/dist/server/auth.d.ts +1 -0
  195. package/dist/server/auth.d.ts.map +1 -1
  196. package/dist/server/auth.js +19 -2
  197. package/dist/server/auth.js.map +1 -1
  198. package/dist/server/builder-browser.d.ts +78 -0
  199. package/dist/server/builder-browser.d.ts.map +1 -1
  200. package/dist/server/builder-browser.js +246 -1
  201. package/dist/server/builder-browser.js.map +1 -1
  202. package/dist/server/core-routes-plugin.d.ts +41 -0
  203. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  204. package/dist/server/core-routes-plugin.js +260 -14
  205. package/dist/server/core-routes-plugin.js.map +1 -1
  206. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  207. package/docs/content/deployment.mdx +9 -7
  208. package/docs/content/locales/ar-SA/deployment.mdx +9 -7
  209. package/docs/content/locales/de-DE/deployment.mdx +9 -7
  210. package/docs/content/locales/es-ES/deployment.mdx +9 -7
  211. package/docs/content/locales/fr-FR/deployment.mdx +9 -7
  212. package/docs/content/locales/hi-IN/deployment.mdx +9 -7
  213. package/docs/content/locales/ja-JP/deployment.mdx +9 -7
  214. package/docs/content/locales/ko-KR/deployment.mdx +9 -7
  215. package/docs/content/locales/pt-BR/deployment.mdx +9 -7
  216. package/docs/content/locales/zh-CN/deployment.mdx +9 -7
  217. package/docs/content/locales/zh-TW/deployment.mdx +9 -7
  218. package/package.json +2 -2
  219. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
- import { uploadFile } from "@agent-native/core/file-upload";
5
4
  import { streamFile, getSession } from "@agent-native/core/server";
6
5
  import {
7
6
  defineEventHandler,
7
+ getHeader,
8
8
  getQuery,
9
9
  readRawBody,
10
10
  getRouterParam,
@@ -15,9 +15,20 @@ import {
15
15
  } from "h3";
16
16
  import { nanoid } from "nanoid";
17
17
 
18
- import { getStoredUpload, putStoredUpload } from "../lib/upload-store.js";
18
+ import {
19
+ claimAttachmentUploadTicket,
20
+ verifyAttachmentUploadTicket,
21
+ } from "../lib/attachment-upload-ticket.js";
22
+ import {
23
+ MAX_UPLOAD_BYTES,
24
+ MediaStorageSetupError,
25
+ mimeTypeForUpload,
26
+ storeMediaUpload,
27
+ uploadsDirectory,
28
+ } from "../lib/media-upload.js";
29
+ import { getStoredUpload } from "../lib/upload-store.js";
19
30
 
20
- const UPLOADS_DIR = path.resolve("data/uploads");
31
+ const UPLOADS_DIR = uploadsDirectory();
21
32
 
22
33
  // Ensure uploads directory exists (guarded for edge runtimes without filesystem)
23
34
  try {
@@ -28,26 +39,6 @@ try {
28
39
  // Edge runtime (e.g. Cloudflare Workers) — no local filesystem
29
40
  }
30
41
 
31
- const MIME_MAP: Record<string, string> = {
32
- ".png": "image/png",
33
- ".jpg": "image/jpeg",
34
- ".jpeg": "image/jpeg",
35
- ".gif": "image/gif",
36
- ".webp": "image/webp",
37
- ".svg": "image/svg+xml",
38
- ".pdf": "application/pdf",
39
- ".txt": "text/plain",
40
- ".csv": "text/csv",
41
- ".doc": "application/msword",
42
- ".docx":
43
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
44
- ".xls": "application/vnd.ms-excel",
45
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
46
- ".zip": "application/zip",
47
- };
48
-
49
- const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB
50
-
51
42
  export const uploadMedia = defineEventHandler(async (event: H3Event) => {
52
43
  const session = await getSession(event).catch(() => null);
53
44
  if (!session?.email) {
@@ -69,51 +60,83 @@ export const uploadMedia = defineEventHandler(async (event: H3Event) => {
69
60
 
70
61
  const originalName = (getQuery(event).filename as string) || "upload";
71
62
  const ext = path.extname(originalName).toLowerCase() || ".bin";
72
- const id = nanoid(12) + ext;
73
- const filePath = path.join(UPLOADS_DIR, id);
74
-
75
- const mimeType = MIME_MAP[ext] || "application/octet-stream";
76
- const payload = {
77
- url: `/api/media/${id}`,
78
- filename: id,
63
+ return await storeMediaUpload({
64
+ ownerEmail: session.email,
65
+ data: body instanceof Uint8Array ? body : new Uint8Array(body),
66
+ filename: nanoid(12) + ext,
79
67
  originalName,
80
- mimeType,
81
- size: body.byteLength,
82
- };
68
+ });
69
+ } catch (err) {
70
+ if (err instanceof MediaStorageSetupError) {
71
+ setResponseStatus(event, 503);
72
+ return {
73
+ error: err.message,
74
+ storageSetupRequired: true,
75
+ };
76
+ }
77
+ console.error("[media] Upload failed:", err);
78
+ setResponseStatus(event, 500);
79
+ return { error: "Upload failed" };
80
+ }
81
+ });
82
+
83
+ export const uploadAttachmentWithTicket = defineEventHandler(
84
+ async (event: H3Event) => {
85
+ const uploadId = getRouterParam(event, "uploadId") as string;
86
+ const authorization = getHeader(event, "authorization") || "";
87
+ const token = authorization.startsWith("Bearer ")
88
+ ? authorization.slice("Bearer ".length).trim()
89
+ : "";
90
+ // Reject an invalid bearer before buffering request bytes. The later
91
+ // atomic claim remains the concurrency boundary immediately before the
92
+ // storage side effect.
93
+ const verified = await verifyAttachmentUploadTicket(uploadId, token);
94
+ if (!verified) {
95
+ setResponseStatus(event, 401);
96
+ return { error: "Invalid or expired attachment upload URL" };
97
+ }
98
+ const body = await readRawBody(event, false);
99
+ if (!body || !body.byteLength) {
100
+ setResponseStatus(event, 400);
101
+ return { error: "No file data" };
102
+ }
103
+ if (body.byteLength > MAX_UPLOAD_BYTES) {
104
+ setResponseStatus(event, 413);
105
+ return { error: "File too large (max 10 MB)" };
106
+ }
107
+
108
+ const claimed = await claimAttachmentUploadTicket(uploadId, token);
109
+ if (!claimed) {
110
+ setResponseStatus(event, 401);
111
+ return { error: "Invalid or expired attachment upload URL" };
112
+ }
83
113
 
84
114
  try {
85
- fs.writeFileSync(filePath, body);
86
- } catch {
87
- const uploaded = await uploadFile({
115
+ const uploaded = await storeMediaUpload({
116
+ ownerEmail: claimed.ownerEmail,
88
117
  data: body instanceof Uint8Array ? body : new Uint8Array(body),
89
- filename: originalName,
90
- mimeType,
91
- ownerEmail: session.email,
92
- recordAsset: false,
118
+ filename: claimed.ticket.filename,
119
+ originalName: claimed.ticket.originalName,
93
120
  });
94
- if (!uploaded?.url) {
121
+ return {
122
+ ...uploaded,
123
+ attachment: {
124
+ filename: uploaded.filename,
125
+ originalName: uploaded.originalName,
126
+ mimeType: uploaded.mimeType,
127
+ },
128
+ };
129
+ } catch (err) {
130
+ if (err instanceof MediaStorageSetupError) {
95
131
  setResponseStatus(event, 503);
96
- return {
97
- error:
98
- "File storage is not configured. Connect Builder.io or another upload provider before attaching files in hosted environments.",
99
- storageSetupRequired: true,
100
- };
132
+ return { error: err.message, storageSetupRequired: true };
101
133
  }
102
- await putStoredUpload(session.email, {
103
- ...payload,
104
- url: uploaded.url,
105
- createdAt: Date.now(),
106
- });
107
- return { ...payload, url: uploaded.url, provider: uploaded.provider };
134
+ console.error("[media] Ticketed attachment upload failed:", err);
135
+ setResponseStatus(event, 500);
136
+ return { error: "Upload failed" };
108
137
  }
109
-
110
- return payload;
111
- } catch (err) {
112
- console.error("[media] Upload failed:", err);
113
- setResponseStatus(event, 500);
114
- return { error: "Upload failed" };
115
- }
116
- });
138
+ },
139
+ );
117
140
 
118
141
  export const serveMedia = defineEventHandler(async (event: H3Event) => {
119
142
  const filename = getRouterParam(event, "filename") as string;
@@ -161,7 +184,7 @@ export const serveMedia = defineEventHandler(async (event: H3Event) => {
161
184
  return Buffer.from(stored.dataBase64, "base64");
162
185
  }
163
186
 
164
- const mimeType = MIME_MAP[ext] || "application/octet-stream";
187
+ const mimeType = mimeTypeForUpload(filename);
165
188
 
166
189
  setResponseHeader(event, "Content-Type", mimeType);
167
190
  setResponseHeader(
@@ -0,0 +1,234 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import { getDbExec, isPostgres } from "@agent-native/core/db";
4
+ import { getUserSetting } from "@agent-native/core/settings";
5
+ import { nanoid } from "nanoid";
6
+
7
+ import { extensionForUpload, mimeTypeForUpload } from "./media-upload.js";
8
+
9
+ const SETTING_KEY = "mail-attachment-upload-ticket";
10
+ const TICKET_TTL_MS = 5 * 60 * 1000;
11
+ const MAX_PENDING_TICKETS = 20;
12
+ const MAX_CAS_ATTEMPTS = 8;
13
+
14
+ export interface AttachmentUploadTicket extends Record<string, unknown> {
15
+ uploadId: string;
16
+ filename: string;
17
+ originalName: string;
18
+ mimeType: string;
19
+ tokenHash: string;
20
+ expiresAt: number;
21
+ }
22
+
23
+ interface AttachmentUploadTickets extends Record<string, unknown> {
24
+ tickets: Record<string, AttachmentUploadTicket>;
25
+ }
26
+
27
+ function tokenHash(token: string): string {
28
+ return crypto.createHash("sha256").update(token).digest("hex");
29
+ }
30
+
31
+ function encodeOwner(ownerEmail: string): string {
32
+ return Buffer.from(ownerEmail, "utf8").toString("base64url");
33
+ }
34
+
35
+ function decodeOwner(token: string): string | null {
36
+ const ownerPart = token.split(".", 1)[0];
37
+ if (!ownerPart) return null;
38
+ try {
39
+ const owner = Buffer.from(ownerPart, "base64url").toString("utf8");
40
+ return owner.includes("@") ? owner : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function hashesMatch(left: string, right: string): boolean {
47
+ const leftBuffer = Buffer.from(left, "utf8");
48
+ const rightBuffer = Buffer.from(right, "utf8");
49
+ if (leftBuffer.length !== rightBuffer.length) return false;
50
+ return crypto.timingSafeEqual(leftBuffer, rightBuffer);
51
+ }
52
+
53
+ function settingStorageKey(ownerEmail: string): string {
54
+ return `u:${ownerEmail}:${SETTING_KEY}`;
55
+ }
56
+
57
+ function settingsTable(): string {
58
+ return isPostgres() ? "public.settings" : "settings";
59
+ }
60
+
61
+ function parseTickets(raw: string | null): AttachmentUploadTickets {
62
+ if (!raw) return { tickets: {} };
63
+ try {
64
+ const parsed = JSON.parse(raw) as Partial<AttachmentUploadTickets>;
65
+ if (!parsed.tickets || typeof parsed.tickets !== "object") {
66
+ return { tickets: {} };
67
+ }
68
+ return {
69
+ tickets: Object.fromEntries(
70
+ Object.entries(parsed.tickets).filter(
71
+ ([uploadId, ticket]) =>
72
+ uploadId &&
73
+ ticket &&
74
+ typeof ticket === "object" &&
75
+ ticket.uploadId === uploadId,
76
+ ),
77
+ ),
78
+ };
79
+ } catch {
80
+ return { tickets: {} };
81
+ }
82
+ }
83
+
84
+ function pruneTickets(
85
+ tickets: Record<string, AttachmentUploadTicket>,
86
+ now: number,
87
+ ): Record<string, AttachmentUploadTicket> {
88
+ return Object.fromEntries(
89
+ Object.entries(tickets)
90
+ .filter(
91
+ ([, ticket]) =>
92
+ Number.isFinite(ticket.expiresAt) && ticket.expiresAt >= now,
93
+ )
94
+ .sort(([, left], [, right]) => right.expiresAt - left.expiresAt)
95
+ .slice(0, MAX_PENDING_TICKETS),
96
+ );
97
+ }
98
+
99
+ async function readTicketsRow(
100
+ ownerEmail: string,
101
+ ): Promise<{ raw: string | null; collection: AttachmentUploadTickets }> {
102
+ // This initializes the framework-owned settings table without duplicating
103
+ // its schema/startup logic. The raw read below deliberately bypasses the
104
+ // per-request settings cache because compare-and-swap retries need fresh data.
105
+ await getUserSetting(ownerEmail, SETTING_KEY);
106
+ const { rows } = await getDbExec().execute({
107
+ sql: `SELECT value FROM ${settingsTable()} WHERE key = ?`,
108
+ args: [settingStorageKey(ownerEmail)],
109
+ });
110
+ const raw = rows.length ? String(rows[0].value ?? rows[0][0]) : null;
111
+ return { raw, collection: parseTickets(raw) };
112
+ }
113
+
114
+ async function compareAndSwapTickets(
115
+ ownerEmail: string,
116
+ expectedRaw: string | null,
117
+ collection: AttachmentUploadTickets,
118
+ ): Promise<boolean> {
119
+ const value = JSON.stringify(collection);
120
+ const key = settingStorageKey(ownerEmail);
121
+ const client = getDbExec();
122
+ if (expectedRaw === null) {
123
+ const result = await client.execute({
124
+ sql: isPostgres()
125
+ ? `INSERT INTO ${settingsTable()} (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING`
126
+ : `INSERT OR IGNORE INTO ${settingsTable()} (key, value, updated_at) VALUES (?, ?, ?)`,
127
+ args: [key, value, Date.now()],
128
+ });
129
+ return result.rowsAffected === 1;
130
+ }
131
+ const result = await client.execute({
132
+ sql: `UPDATE ${settingsTable()} SET value = ?, updated_at = ? WHERE key = ? AND value = ?`,
133
+ args: [value, Date.now(), key, expectedRaw],
134
+ });
135
+ return result.rowsAffected === 1;
136
+ }
137
+
138
+ export async function createAttachmentUploadTicket(
139
+ ownerEmail: string,
140
+ originalName: string,
141
+ ): Promise<AttachmentUploadTicket & { token: string }> {
142
+ const uploadId = nanoid(12);
143
+ const filename = `${uploadId}${extensionForUpload(originalName)}`;
144
+ const token = `${encodeOwner(ownerEmail)}.${crypto.randomBytes(32).toString("base64url")}`;
145
+ const ticket: AttachmentUploadTicket = {
146
+ uploadId,
147
+ filename,
148
+ originalName,
149
+ mimeType: mimeTypeForUpload(originalName),
150
+ tokenHash: tokenHash(token),
151
+ expiresAt: Date.now() + TICKET_TTL_MS,
152
+ };
153
+
154
+ for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) {
155
+ const { raw, collection } = await readTicketsRow(ownerEmail);
156
+ const tickets = pruneTickets(
157
+ // Put the new ticket first so stable sorting keeps it when many tickets
158
+ // share the same millisecond expiry at the collection limit.
159
+ { [uploadId]: ticket, ...collection.tickets },
160
+ Date.now(),
161
+ );
162
+ if (await compareAndSwapTickets(ownerEmail, raw, { tickets })) {
163
+ return { ...ticket, token };
164
+ }
165
+ }
166
+ throw new Error("Could not reserve an attachment upload ticket");
167
+ }
168
+
169
+ export async function verifyAttachmentUploadTicket(
170
+ uploadId: string,
171
+ token: string,
172
+ ): Promise<{ ownerEmail: string; ticket: AttachmentUploadTicket } | null> {
173
+ const ownerEmail = decodeOwner(token);
174
+ if (!ownerEmail) return null;
175
+
176
+ for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) {
177
+ const { raw, collection } = await readTicketsRow(ownerEmail);
178
+ const tickets = pruneTickets(collection.tickets, Date.now());
179
+ const ticket = tickets[uploadId];
180
+ if (JSON.stringify(tickets) !== JSON.stringify(collection.tickets)) {
181
+ if (!(await compareAndSwapTickets(ownerEmail, raw, { tickets })))
182
+ continue;
183
+ }
184
+ if (
185
+ !ticket ||
186
+ typeof ticket.tokenHash !== "string" ||
187
+ !hashesMatch(ticket.tokenHash, tokenHash(token))
188
+ ) {
189
+ return null;
190
+ }
191
+ return { ownerEmail, ticket };
192
+ }
193
+ return null;
194
+ }
195
+
196
+ /**
197
+ * Atomically validates and removes a one-time upload capability.
198
+ *
199
+ * The caller must claim immediately before the storage side effect. Once this
200
+ * returns a ticket it cannot be reclaimed, even if storage later fails. That
201
+ * fail-closed behavior prevents retries from turning an ambiguous write into a
202
+ * replay vulnerability.
203
+ */
204
+ export async function claimAttachmentUploadTicket(
205
+ uploadId: string,
206
+ token: string,
207
+ ): Promise<{ ownerEmail: string; ticket: AttachmentUploadTicket } | null> {
208
+ const ownerEmail = decodeOwner(token);
209
+ if (!ownerEmail) return null;
210
+
211
+ for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) {
212
+ const { raw, collection } = await readTicketsRow(ownerEmail);
213
+ const tickets = pruneTickets(collection.tickets, Date.now());
214
+ const ticket = tickets[uploadId];
215
+ if (
216
+ !ticket ||
217
+ typeof ticket.tokenHash !== "string" ||
218
+ !hashesMatch(ticket.tokenHash, tokenHash(token))
219
+ ) {
220
+ if (JSON.stringify(tickets) !== JSON.stringify(collection.tickets)) {
221
+ if (!(await compareAndSwapTickets(ownerEmail, raw, { tickets }))) {
222
+ continue;
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+
228
+ delete tickets[uploadId];
229
+ if (await compareAndSwapTickets(ownerEmail, raw, { tickets })) {
230
+ return { ownerEmail, ticket };
231
+ }
232
+ }
233
+ return null;
234
+ }
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Deliberately narrow authenticated MCP surface for Mail.
3
3
  *
4
- * External callers may read inbox coverage through list-emails. Other actions
5
- * remain available through the in-app agent, ask_app, or an explicit
6
- * full-catalog connection; tool-search alone never makes them callable.
4
+ * External callers may read inbox coverage and mint a short-lived attachment
5
+ * upload capability. Other actions remain available through the in-app agent,
6
+ * ask_app, or an explicit full-catalog connection; tool-search alone never
7
+ * makes them callable.
7
8
  */
8
- export const MAIL_CONNECTOR_CATALOG = ["list-emails"] as const;
9
+ export const MAIL_CONNECTOR_CATALOG = [
10
+ "list-emails",
11
+ "create-attachment-upload",
12
+ ] as const;
@@ -0,0 +1,114 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { uploadFile } from "@agent-native/core/file-upload";
5
+
6
+ import { getStoredUpload, putStoredUpload } from "./upload-store.js";
7
+
8
+ const UPLOADS_DIR = path.resolve("data/uploads");
9
+
10
+ const MIME_MAP: Record<string, string> = {
11
+ ".png": "image/png",
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".gif": "image/gif",
15
+ ".webp": "image/webp",
16
+ ".svg": "image/svg+xml",
17
+ ".pdf": "application/pdf",
18
+ ".txt": "text/plain",
19
+ ".csv": "text/csv",
20
+ ".doc": "application/msword",
21
+ ".docx":
22
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
23
+ ".xls": "application/vnd.ms-excel",
24
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
25
+ ".zip": "application/zip",
26
+ };
27
+
28
+ export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
29
+
30
+ export interface StoredMediaUpload {
31
+ url: string;
32
+ filename: string;
33
+ originalName: string;
34
+ mimeType: string;
35
+ size: number;
36
+ provider?: string;
37
+ }
38
+
39
+ export class MediaStorageSetupError extends Error {
40
+ readonly storageSetupRequired = true;
41
+ }
42
+
43
+ export function extensionForUpload(originalName: string): string {
44
+ return path.extname(originalName).toLowerCase() || ".bin";
45
+ }
46
+
47
+ export function mimeTypeForUpload(originalName: string): string {
48
+ return (
49
+ MIME_MAP[extensionForUpload(originalName)] || "application/octet-stream"
50
+ );
51
+ }
52
+
53
+ export function uploadsDirectory(): string {
54
+ return UPLOADS_DIR;
55
+ }
56
+
57
+ export async function storeMediaUpload(input: {
58
+ ownerEmail: string;
59
+ data: Uint8Array;
60
+ filename: string;
61
+ originalName: string;
62
+ }): Promise<StoredMediaUpload> {
63
+ if (input.data.byteLength > MAX_UPLOAD_BYTES) {
64
+ throw new Error("File too large (max 10 MB)");
65
+ }
66
+
67
+ const mimeType = mimeTypeForUpload(input.originalName);
68
+ const payload = {
69
+ url: `/api/media/${input.filename}`,
70
+ filename: input.filename,
71
+ originalName: input.originalName,
72
+ mimeType,
73
+ size: input.data.byteLength,
74
+ };
75
+
76
+ try {
77
+ const uploaded = await uploadFile({
78
+ data: input.data,
79
+ filename: input.originalName,
80
+ mimeType,
81
+ ownerEmail: input.ownerEmail,
82
+ recordAsset: false,
83
+ });
84
+ if (uploaded?.url) {
85
+ await putStoredUpload(input.ownerEmail, {
86
+ ...payload,
87
+ url: uploaded.url,
88
+ createdAt: Date.now(),
89
+ });
90
+ const persisted = await getStoredUpload(input.ownerEmail, input.filename);
91
+ if (persisted?.url !== uploaded.url) {
92
+ throw new Error("Uploaded file metadata did not persist");
93
+ }
94
+ return { ...payload, url: uploaded.url, provider: uploaded.provider };
95
+ }
96
+ } catch (error) {
97
+ if (process.env.NODE_ENV === "production") throw error;
98
+ }
99
+
100
+ if (process.env.NODE_ENV === "production") {
101
+ throw new MediaStorageSetupError(
102
+ "File storage is not configured. Connect Builder.io or another upload provider before attaching files in hosted environments.",
103
+ );
104
+ }
105
+
106
+ fs.mkdirSync(UPLOADS_DIR, { recursive: true });
107
+ const filePath = path.join(UPLOADS_DIR, input.filename);
108
+ fs.writeFileSync(filePath, input.data);
109
+ const storedSize = fs.statSync(filePath).size;
110
+ if (storedSize !== input.data.byteLength) {
111
+ throw new Error("Uploaded file did not persist completely");
112
+ }
113
+ return payload;
114
+ }
@@ -51,5 +51,13 @@ export default createAuthPlugin({
51
51
  // GMAIL_PUSH_AUDIENCE is configured.
52
52
  // Cloud Scheduler POSTs to /api/gmail/watch/renew every 6h for watch
53
53
  // lifecycle; same OIDC-verification pattern.
54
- publicPaths: ["/api/gmail/push", "/api/gmail/watch/renew", "/api/tracking"],
54
+ // Attachment upload capabilities carry their own short-lived, owner-bound
55
+ // bearer credential because a local MCP caller cannot attach the browser's
56
+ // session cookie to the subsequent raw-byte PUT.
57
+ publicPaths: [
58
+ "/api/gmail/push",
59
+ "/api/gmail/watch/renew",
60
+ "/api/tracking",
61
+ "/api/media/attachment-upload",
62
+ ],
55
63
  });
@@ -0,0 +1 @@
1
+ export { uploadAttachmentWithTicket as default } from "../../../../handlers/media.js";
@@ -1015,6 +1015,50 @@ function parseMdx(source: string): MdxNode {
1015
1015
  return tree;
1016
1016
  }
1017
1017
 
1018
+ type MdxParseError = {
1019
+ column?: unknown;
1020
+ line?: unknown;
1021
+ position?: { start?: { column?: unknown; line?: unknown } };
1022
+ reason?: unknown;
1023
+ };
1024
+
1025
+ function parsePlanMdxFile(
1026
+ filename: "plan.mdx" | "canvas.mdx" | "prototype.mdx",
1027
+ source: string,
1028
+ lineOffset = 0,
1029
+ ): MdxNode {
1030
+ try {
1031
+ return parseMdx(source);
1032
+ } catch (error) {
1033
+ const mdxError = error as MdxParseError;
1034
+ const line = numericMdxPosition(
1035
+ mdxError.line ?? mdxError.position?.start?.line,
1036
+ );
1037
+ const column = numericMdxPosition(
1038
+ mdxError.column ?? mdxError.position?.start?.column,
1039
+ );
1040
+ const reason =
1041
+ typeof mdxError.reason === "string"
1042
+ ? mdxError.reason
1043
+ : error instanceof Error
1044
+ ? error.message
1045
+ : String(error);
1046
+ const location = line && column ? `:${line + lineOffset}:${column}` : "";
1047
+ throw new Error(`${filename}${location}: ${reason}`);
1048
+ }
1049
+ }
1050
+
1051
+ function numericMdxPosition(value: unknown): number | undefined {
1052
+ return typeof value === "number" && Number.isFinite(value)
1053
+ ? value
1054
+ : undefined;
1055
+ }
1056
+
1057
+ function lineCountBeforeContent(source: string, content: string): number {
1058
+ const prefix = source.slice(0, Math.max(0, source.length - content.length));
1059
+ return (prefix.match(/\r?\n/g) ?? []).length;
1060
+ }
1061
+
1018
1062
  function restoreStaticTemplateLiteralAttributes(tree: MdxNode, source: string) {
1019
1063
  const walk = (node: MdxNode | undefined) => {
1020
1064
  if (!node) return;
@@ -1718,7 +1762,11 @@ export async function parsePlanMdxFolder(
1718
1762
  ): Promise<PlanContent> {
1719
1763
  const files = planMdxFileSchema.parse(folder);
1720
1764
  const parsedMatter = parseSimpleFrontmatter(files["plan.mdx"]);
1721
- const planTree = parseMdx(parsedMatter.content);
1765
+ const planTree = parsePlanMdxFile(
1766
+ "plan.mdx",
1767
+ parsedMatter.content,
1768
+ lineCountBeforeContent(files["plan.mdx"], parsedMatter.content),
1769
+ );
1722
1770
  const state = parsePlanState(files[".plan-state.json"]);
1723
1771
  const blocks = parseBlocksFromNodes(planTree.children, "plan-block", {
1724
1772
  markdownBlockIds: state?.markdownBlockIds,
@@ -1726,10 +1774,10 @@ export async function parsePlanMdxFolder(
1726
1774
  });
1727
1775
 
1728
1776
  const canvas = files["canvas.mdx"]
1729
- ? parseCanvas(files["canvas.mdx"])
1777
+ ? parseCanvas(parsePlanMdxFile("canvas.mdx", files["canvas.mdx"]))
1730
1778
  : undefined;
1731
1779
  const prototype = files["prototype.mdx"]
1732
- ? parsePrototype(files["prototype.mdx"])
1780
+ ? parsePrototype(parsePlanMdxFile("prototype.mdx", files["prototype.mdx"]))
1733
1781
  : undefined;
1734
1782
  if (canvas && state?.canvas) canvas.viewport = state.canvas;
1735
1783
 
@@ -1757,8 +1805,7 @@ export async function parsePlanMdxFolder(
1757
1805
  return normalized;
1758
1806
  }
1759
1807
 
1760
- function parsePrototype(source: string): PlanPrototype | undefined {
1761
- const tree = parseMdx(source);
1808
+ function parsePrototype(tree: MdxNode): PlanPrototype | undefined {
1762
1809
  const node = (tree.children ?? []).find(
1763
1810
  (child) => elementName(child) === "Prototype",
1764
1811
  );
@@ -1822,8 +1869,7 @@ function parsePlanState(source: string | undefined) {
1822
1869
  }
1823
1870
  }
1824
1871
 
1825
- function parseCanvas(source: string): PlanContent["canvas"] {
1826
- const tree = parseMdx(source);
1872
+ function parseCanvas(tree: MdxNode): PlanContent["canvas"] {
1827
1873
  const board = (tree.children ?? []).find(
1828
1874
  (child) => elementName(child) === "DesignBoard",
1829
1875
  );