@agent-native/core 0.101.10 → 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 (79) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/client.ts +23 -1
  5. package/corpus/core/src/a2a/handlers.ts +65 -1
  6. package/corpus/core/src/a2a/server.ts +124 -0
  7. package/corpus/core/src/a2a/task-store.ts +253 -0
  8. package/corpus/core/src/a2a/types.ts +26 -0
  9. package/corpus/core/src/agent/production-agent.ts +5 -2
  10. package/corpus/core/src/mcp/builtin-tools.ts +25 -1
  11. package/corpus/core/src/scripts/call-agent.ts +22 -3
  12. package/corpus/core/src/server/agent-chat-plugin.ts +86 -1
  13. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  14. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +23 -0
  15. package/corpus/templates/content/app/components/editor/MathRenderer.tsx +43 -0
  16. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +260 -4
  17. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +78 -0
  18. package/corpus/templates/content/app/global.css +53 -0
  19. package/corpus/templates/content/app/i18n/zh-TW.ts +13 -0
  20. package/corpus/templates/content/app/i18n-data.ts +163 -0
  21. package/corpus/templates/content/app/root.tsx +2 -0
  22. package/corpus/templates/content/changelog/2026-07-14-latex-equations-render-in-documents-public-pages-and-exports.md +6 -0
  23. package/corpus/templates/content/package.json +1 -0
  24. package/corpus/templates/content/shared/document-export.ts +296 -25
  25. package/corpus/templates/content/shared/inline-math.ts +128 -0
  26. package/corpus/templates/content/shared/math-rendering.ts +50 -0
  27. package/corpus/templates/content/shared/nfm.ts +17 -14
  28. package/corpus/templates/content/vite.config.ts +0 -1
  29. package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +17 -0
  30. package/corpus/templates/mail/AGENTS.md +21 -19
  31. package/corpus/templates/mail/actions/create-attachment-upload.ts +49 -0
  32. package/corpus/templates/mail/changelog/2026-07-14-connected-agents-can-now-securely-upload-local-files-for-mai.md +6 -0
  33. package/corpus/templates/mail/server/handlers/media.ts +84 -61
  34. package/corpus/templates/mail/server/lib/attachment-upload-ticket.ts +234 -0
  35. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -4
  36. package/corpus/templates/mail/server/lib/media-upload.ts +114 -0
  37. package/corpus/templates/mail/server/plugins/auth.ts +9 -1
  38. package/corpus/templates/mail/server/routes/api/media/attachment-upload/[uploadId].put.ts +1 -0
  39. package/dist/a2a/client.d.ts +5 -1
  40. package/dist/a2a/client.d.ts.map +1 -1
  41. package/dist/a2a/client.js +18 -1
  42. package/dist/a2a/client.js.map +1 -1
  43. package/dist/a2a/handlers.d.ts.map +1 -1
  44. package/dist/a2a/handlers.js +57 -8
  45. package/dist/a2a/handlers.js.map +1 -1
  46. package/dist/a2a/server.d.ts.map +1 -1
  47. package/dist/a2a/server.js +87 -1
  48. package/dist/a2a/server.js.map +1 -1
  49. package/dist/a2a/task-store.d.ts +27 -0
  50. package/dist/a2a/task-store.d.ts.map +1 -1
  51. package/dist/a2a/task-store.js +216 -0
  52. package/dist/a2a/task-store.js.map +1 -1
  53. package/dist/a2a/types.d.ts +24 -0
  54. package/dist/a2a/types.d.ts.map +1 -1
  55. package/dist/a2a/types.js.map +1 -1
  56. package/dist/agent/production-agent.d.ts +1 -0
  57. package/dist/agent/production-agent.d.ts.map +1 -1
  58. package/dist/agent/production-agent.js +5 -2
  59. package/dist/agent/production-agent.js.map +1 -1
  60. package/dist/collab/struct-routes.d.ts +1 -1
  61. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  62. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  63. package/dist/mcp/builtin-tools.js +22 -3
  64. package/dist/mcp/builtin-tools.js.map +1 -1
  65. package/dist/notifications/routes.d.ts +3 -3
  66. package/dist/observability/routes.d.ts +5 -5
  67. package/dist/resources/handlers.d.ts +3 -3
  68. package/dist/scripts/call-agent.d.ts +1 -1
  69. package/dist/scripts/call-agent.d.ts.map +1 -1
  70. package/dist/scripts/call-agent.js +19 -1
  71. package/dist/scripts/call-agent.js.map +1 -1
  72. package/dist/secrets/routes.d.ts +9 -9
  73. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  74. package/dist/server/agent-chat-plugin.js +71 -2
  75. package/dist/server/agent-chat-plugin.js.map +1 -1
  76. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  77. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  78. package/package.json +1 -1
  79. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
@@ -0,0 +1,128 @@
1
+ export const MAX_INLINE_MATH_LENGTH = 1_000;
2
+
3
+ export type InlineMathSyntax = "plain" | "github";
4
+
5
+ export interface InlineMathMatch {
6
+ from: number;
7
+ to: number;
8
+ latex: string;
9
+ syntax: InlineMathSyntax;
10
+ }
11
+
12
+ export function isEscapedInlineDelimiter(text: string, index: number): boolean {
13
+ let backslashes = 0;
14
+ for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor--) {
15
+ backslashes += 1;
16
+ }
17
+ return backslashes % 2 === 1;
18
+ }
19
+
20
+ function matchGithubInlineMath(
21
+ text: string,
22
+ start: number,
23
+ ): InlineMathMatch | null {
24
+ if (!text.startsWith("$`", start)) return null;
25
+
26
+ const close = text.indexOf("`$", start + 2);
27
+ if (close === -1 || close - (start + 2) > MAX_INLINE_MATH_LENGTH) {
28
+ return null;
29
+ }
30
+
31
+ return {
32
+ from: start,
33
+ to: close + 2,
34
+ latex: text.slice(start + 2, close),
35
+ syntax: "github",
36
+ };
37
+ }
38
+
39
+ function matchPlainInlineMath(
40
+ text: string,
41
+ start: number,
42
+ ): InlineMathMatch | null {
43
+ if (
44
+ text[start] !== "$" ||
45
+ text[start + 1] === "$" ||
46
+ text[start + 1] === "`" ||
47
+ !text[start + 1] ||
48
+ /\s/.test(text[start + 1])
49
+ ) {
50
+ return null;
51
+ }
52
+
53
+ const searchLimit = Math.min(text.length, start + MAX_INLINE_MATH_LENGTH + 2);
54
+ for (let close = start + 1; close < searchLimit; close++) {
55
+ const char = text[close];
56
+ if (char === "\n" || char === "\r") return null;
57
+ if (
58
+ char !== "$" ||
59
+ isEscapedInlineDelimiter(text, close) ||
60
+ text[close - 1] === "$" ||
61
+ text[close + 1] === "$" ||
62
+ /\s/.test(text[close - 1]) ||
63
+ /\d/.test(text[close + 1] || "")
64
+ ) {
65
+ continue;
66
+ }
67
+
68
+ return {
69
+ from: start,
70
+ to: close + 1,
71
+ latex: text.slice(start + 1, close),
72
+ syntax: "plain",
73
+ };
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ export function matchInlineMathAt(
80
+ text: string,
81
+ start: number,
82
+ ): InlineMathMatch | null {
83
+ if (text[start] !== "$" || isEscapedInlineDelimiter(text, start)) {
84
+ return null;
85
+ }
86
+
87
+ return (
88
+ matchGithubInlineMath(text, start) ?? matchPlainInlineMath(text, start)
89
+ );
90
+ }
91
+
92
+ export function findTrailingPlainInlineMath(
93
+ text: string,
94
+ ): InlineMathMatch | null {
95
+ if (!text.endsWith("$") || text.endsWith("$$")) return null;
96
+
97
+ for (let start = text.length - 2; start >= 0; start--) {
98
+ if (text[start] !== "$") continue;
99
+ const match = matchInlineMathAt(text, start);
100
+ if (
101
+ match?.syntax === "plain" &&
102
+ match.to === text.length &&
103
+ !hasUnclosedCodeSpan(text.slice(0, start))
104
+ ) {
105
+ return match;
106
+ }
107
+ }
108
+
109
+ return null;
110
+ }
111
+
112
+ function hasUnclosedCodeSpan(text: string): boolean {
113
+ let openRun = 0;
114
+
115
+ for (let cursor = 0; cursor < text.length; cursor++) {
116
+ if (text[cursor] !== "`" || isEscapedInlineDelimiter(text, cursor)) {
117
+ continue;
118
+ }
119
+
120
+ let run = 1;
121
+ while (text[cursor + run] === "`") run += 1;
122
+ if (openRun === 0) openRun = run;
123
+ else if (openRun === run) openRun = 0;
124
+ cursor += run - 1;
125
+ }
126
+
127
+ return openRun !== 0;
128
+ }
@@ -0,0 +1,50 @@
1
+ import katex, { type KatexOptions } from "katex";
2
+
3
+ export const KATEX_VERSION = "0.17.0";
4
+ export const KATEX_STYLESHEET_URL = `https://cdn.jsdelivr.net/npm/katex@${KATEX_VERSION}/dist/katex.min.css`;
5
+
6
+ export interface MathRenderSuccess {
7
+ ok: true;
8
+ html: string;
9
+ }
10
+
11
+ export interface MathRenderFailure {
12
+ ok: false;
13
+ error: string;
14
+ }
15
+
16
+ export type MathRenderResult = MathRenderSuccess | MathRenderFailure;
17
+
18
+ export function mathRenderOptions(displayMode: boolean): KatexOptions {
19
+ return {
20
+ displayMode,
21
+ maxExpand: 1_000,
22
+ output: "htmlAndMathml",
23
+ strict: "warn",
24
+ throwOnError: true,
25
+ trust: false,
26
+ };
27
+ }
28
+
29
+ export function mathRenderErrorMessage(error: unknown): string {
30
+ if (error instanceof Error && error.message.trim()) return error.message;
31
+ return "This equation could not be rendered.";
32
+ }
33
+
34
+ export function renderMathToHtml(
35
+ latex: string,
36
+ displayMode: boolean,
37
+ ): MathRenderResult {
38
+ if (!latex.trim()) {
39
+ return { ok: false, error: "This equation is empty." };
40
+ }
41
+
42
+ try {
43
+ return {
44
+ ok: true,
45
+ html: katex.renderToString(latex, mathRenderOptions(displayMode)),
46
+ };
47
+ } catch (error) {
48
+ return { ok: false, error: mathRenderErrorMessage(error) };
49
+ }
50
+ }
@@ -41,6 +41,7 @@
41
41
  * exposing simple string props for the editor preview layer.
42
42
  */
43
43
 
44
+ import { matchInlineMathAt } from "./inline-math.js";
44
45
  import { isRegistryBlockTag, registryBlockSpecByTag } from "./nfm-registry.js";
45
46
 
46
47
  // ── Shared PM JSON types ────────────────────────────────────────────
@@ -279,7 +280,7 @@ function serializeInlineAtom(node: PMNode): string {
279
280
  attrs = {};
280
281
  }
281
282
  if (tagName === "math") {
282
- return "$`" + (label || attrs.latex || "") + "`$";
283
+ return "$" + (label || attrs.latex || "") + "$";
283
284
  }
284
285
  const attrEntries = Object.entries(attrs).filter(([k]) => k !== "latex");
285
286
  const attrStr = serializeAttrs(attrEntries);
@@ -446,19 +447,21 @@ function parseInline(input: string): PMNode[] {
446
447
  continue;
447
448
  }
448
449
 
449
- // Inline math $`...`$
450
- if (ch === "$" && input[i + 1] === "`") {
451
- const close = input.indexOf("`$", i + 2);
452
- if (close !== -1) {
453
- flush();
454
- const latex = input.slice(i + 2, close);
455
- out.push({
456
- type: "notionInlineAtom",
457
- attrs: { tagName: "math", attrsJson: "{}", label: latex },
458
- });
459
- i = close + 2;
460
- continue;
461
- }
450
+ // Canonical inline math is $...$; GitHub's $`...`$ form remains a
451
+ // backwards-compatible input alias and canonicalizes on the next write.
452
+ const inlineMath = ch === "$" ? matchInlineMathAt(input, i) : null;
453
+ if (inlineMath) {
454
+ flush();
455
+ out.push({
456
+ type: "notionInlineAtom",
457
+ attrs: {
458
+ tagName: "math",
459
+ attrsJson: "{}",
460
+ label: inlineMath.latex,
461
+ },
462
+ });
463
+ i = inlineMath.to;
464
+ continue;
462
465
  }
463
466
 
464
467
  // Inline code `...` — CommonMark-style variable-length delimiter: the
@@ -413,7 +413,6 @@ const cloudflareSsrStubs =
413
413
  "@xterm/addon-fit",
414
414
  "@xterm/addon-web-links",
415
415
  "@xterm/xterm",
416
- "katex",
417
416
  "lowlight",
418
417
  "prettier",
419
418
  "react-markdown",
@@ -121,6 +121,23 @@ const drafts = await listAppState("compose-");
121
121
 
122
122
  The `send-email` action accepts an optional `attachments` array. Each entry must reference a file that was previously uploaded via the media-upload endpoint (`/api/media/upload`). Pass the server-side `filename` (the key returned by the upload endpoint, e.g. `abc123.pdf`), and optionally `originalName` (display name for the recipient) and `mimeType`. The attachment plumbing resolves the file from `data/uploads/` or from the configured file-storage URL recorded by the upload endpoint, then includes it as a MIME multipart attachment in the outgoing Gmail message. Do not store or paste attachment bytes, base64, or `data:` URLs in draft state/settings; files are never sent speculatively — only attach what the user has explicitly provided and confirmed.
123
123
 
124
+ ### Local files from an MCP host
125
+
126
+ A hosted Mail app cannot read a path on the MCP host's local filesystem. Use
127
+ `create-attachment-upload` with the file's display name. It returns a
128
+ five-minute, owner-bound `uploadUrl`, a `PUT` method, and the attachment handle
129
+ Mail expects. From the local host, upload the raw bytes directly:
130
+
131
+ ```bash
132
+ curl --fail-with-body --request PUT --header '<Authorization header>' --data-binary @/absolute/path/to/report.pdf '<uploadUrl>'
133
+ ```
134
+
135
+ Use the response's `attachment` object in the draft/send request. The upload
136
+ Authorization header is a short-lived bearer credential: do not log, persist,
137
+ reuse, or share it. Creating or consuming it does not send mail. Keep `send-email` behind its
138
+ normal explicit-intent and human-approval gate; for drafting, pass the handle
139
+ through `ask_app` or open the returned compose deep link.
140
+
124
141
  Example:
125
142
  ```json
126
143
  {
@@ -60,8 +60,9 @@ needed.
60
60
  - Never send mail unless the user explicitly asks to send. Draft or queue
61
61
  review by default. `send-email` has `needsApproval: true` — it is the
62
62
  canonical, intentionally rare use of the human-in-the-loop gate in this
63
- framework; the loop pauses for approval on every real send. Drafting and
64
- queueing are unaffected.
63
+ framework. An authenticated A2A caller may carry the user's exact chat
64
+ authorization for one matching send; otherwise the loop pauses for approval.
65
+ Drafting and queueing are unaffected.
65
66
  - When drafting, first read `get-mail-settings` for signature and writing
66
67
  style. Use `signature` exactly when present — draft-writing paths that build
67
68
  a `compose-*` entry (`manage-draft`, `open-queued-draft`) call
@@ -93,23 +94,24 @@ needed.
93
94
 
94
95
  ## Action Map
95
96
 
96
- | Action | Purpose |
97
- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
98
- | `search-emails` / `list-emails` | Query across Gmail or local-fallback data by view/query. |
99
- | `get-email` / `get-thread` | Full body/metadata for one message or thread. |
100
- | `find-contact` | Resolve a name/partial address to a real email. |
101
- | `get-hubspot-contact` | CRM contact + deals + tickets by email (HubSpot only). |
102
- | `manage-draft` | Create/update/delete a `compose-{id}` draft (signature-aware). |
103
- | `send-email` | Real send. `needsApproval: true` always pauses for human approval. |
104
- | `queue-email-draft` / `list-queued-drafts` / `update-queued-draft` / `open-queued-draft` / `send-queued-drafts` | Teammate/Slack draft-review workflow see `draft-queue`. |
105
- | `mark-read` / `mark-thread-read` / `star-email` / `archive-email` / `unarchive-email` / `trash-email` / `untrash-email` / `move-email` | Per-message or per-thread state changes; most call `refresh-list` internally. |
106
- | `manage-gmail-filters` | Provider-native Gmail filters (create/replace/delete). |
107
- | `manage-automations` / `trigger-automations` | Natural-language inbox automation rules. |
108
- | `respond-calendar-invite` | Accept/decline/tentative an invite found in Mail. |
109
- | `get-mail-settings` / `update-mail-settings` / `import-gmail-signature` | Signature and writing-style settings. |
110
- | `manage-snippets` | List/create/update/delete saved reply snippets insertable from the compose slash menu. |
111
- | `get-tracking` | Open/click stats for a previously sent, tracked message. |
112
- | `provider-api-catalog` / `provider-api-docs` / `provider-api-request` | Raw Gmail, Calendar, or HubSpot API calls beyond the canned actions. |
97
+ | Action | Purpose |
98
+ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
99
+ | `search-emails` / `list-emails` | Query across Gmail or local-fallback data by view/query. |
100
+ | `get-email` / `get-thread` | Full body/metadata for one message or thread. |
101
+ | `find-contact` | Resolve a name/partial address to a real email. |
102
+ | `get-hubspot-contact` | CRM contact + deals + tickets by email (HubSpot only). |
103
+ | `create-attachment-upload` | Mint a five-minute, owner-bound raw-byte upload URL for one local attachment. |
104
+ | `manage-draft` | Create/update/delete a `compose-{id}` draft (signature-aware). |
105
+ | `send-email` | Real send. `needsApproval: true`; exact authenticated chat grants run once, otherwise it pauses for approval. |
106
+ | `queue-email-draft` / `list-queued-drafts` / `update-queued-draft` / `open-queued-draft` / `send-queued-drafts` | Teammate/Slack draft-review workflow see `draft-queue`. |
107
+ | `mark-read` / `mark-thread-read` / `star-email` / `archive-email` / `unarchive-email` / `trash-email` / `untrash-email` / `move-email` | Per-message or per-thread state changes; most call `refresh-list` internally. |
108
+ | `manage-gmail-filters` | Provider-native Gmail filters (create/replace/delete). |
109
+ | `manage-automations` / `trigger-automations` | Natural-language inbox automation rules. |
110
+ | `respond-calendar-invite` | Accept/decline/tentative an invite found in Mail. |
111
+ | `get-mail-settings` / `update-mail-settings` / `import-gmail-signature` | Signature and writing-style settings. |
112
+ | `manage-snippets` | List/create/update/delete saved reply snippets insertable from the compose slash menu. |
113
+ | `get-tracking` | Open/click stats for a previously sent, tracked message. |
114
+ | `provider-api-catalog` / `provider-api-docs` / `provider-api-request` | Raw Gmail, Calendar, or HubSpot API calls beyond the canned actions. |
113
115
 
114
116
  ## Application State
115
117
 
@@ -0,0 +1,49 @@
1
+ import { defineAction } from "@agent-native/core/action";
2
+ import {
3
+ getAppProductionUrl,
4
+ getRequestUserEmail,
5
+ withConfiguredAppBasePath,
6
+ } from "@agent-native/core/server";
7
+ import { z } from "zod";
8
+
9
+ import { createAttachmentUploadTicket } from "../server/lib/attachment-upload-ticket.js";
10
+
11
+ const originalNameSchema = z
12
+ .string()
13
+ .trim()
14
+ .min(1)
15
+ .max(255)
16
+ .refine(
17
+ (value) => !/[\\/\0\r\n]/.test(value),
18
+ "Filename must not contain path separators or control characters",
19
+ );
20
+
21
+ export default defineAction({
22
+ description:
23
+ "Create a short-lived authenticated Mail upload URL for one local attachment. Upload the file bytes with HTTP PUT, then pass the returned attachment handle to ask_app so Mail can include it in a draft or approved send. Creating the URL does not send email.",
24
+ schema: z.object({
25
+ originalName: originalNameSchema.describe(
26
+ "Display filename, including its extension (for example, report.png)",
27
+ ),
28
+ }),
29
+ run: async ({ originalName }) => {
30
+ const ownerEmail = getRequestUserEmail();
31
+ if (!ownerEmail) throw new Error("Authentication required");
32
+ const ticket = await createAttachmentUploadTicket(ownerEmail, originalName);
33
+ const appUrl = withConfiguredAppBasePath(getAppProductionUrl());
34
+ const uploadUrl = `${appUrl}/api/media/attachment-upload/${encodeURIComponent(ticket.uploadId)}`;
35
+ return {
36
+ method: "PUT" as const,
37
+ uploadUrl,
38
+ headers: { Authorization: `Bearer ${ticket.token}` },
39
+ expiresAt: new Date(ticket.expiresAt).toISOString(),
40
+ maxBytes: 10 * 1024 * 1024,
41
+ attachment: {
42
+ filename: ticket.filename,
43
+ originalName: ticket.originalName,
44
+ mimeType: ticket.mimeType,
45
+ },
46
+ next: "PUT the raw local file bytes to uploadUrl with the returned Authorization header before passing attachment to ask_app. Do not paste base64 into chat or action arguments.",
47
+ };
48
+ },
49
+ });
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-07-14
4
+ ---
5
+
6
+ Connected agents can now securely upload local attachments, honor an exact send you explicitly authorized in chat, and otherwise pause real sends for browser approval.
@@ -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(