@agent-native/core 0.101.10 → 0.101.12
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 +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/client.ts +23 -1
- package/corpus/core/src/a2a/handlers.ts +65 -1
- package/corpus/core/src/a2a/server.ts +124 -0
- package/corpus/core/src/a2a/task-store.ts +253 -0
- package/corpus/core/src/a2a/types.ts +26 -0
- package/corpus/core/src/agent/production-agent.ts +5 -2
- package/corpus/core/src/coding-tools/run-code.ts +65 -12
- package/corpus/core/src/extensions/actions.ts +141 -7
- package/corpus/core/src/mcp/builtin-tools.ts +25 -1
- package/corpus/core/src/scripts/call-agent.ts +22 -3
- package/corpus/core/src/server/agent-chat-plugin.ts +86 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +59 -0
- package/corpus/templates/analytics/app/components/layout/NewDashboardDialog.tsx +5 -2
- package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +13 -4
- package/corpus/templates/analytics/server/lib/real-data-actions.ts +63 -1
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +30 -0
- package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +23 -0
- package/corpus/templates/content/app/components/editor/MathRenderer.tsx +43 -0
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +260 -4
- package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +78 -0
- package/corpus/templates/content/app/global.css +53 -0
- package/corpus/templates/content/app/i18n/zh-TW.ts +13 -0
- package/corpus/templates/content/app/i18n-data.ts +163 -0
- package/corpus/templates/content/app/root.tsx +2 -0
- package/corpus/templates/content/changelog/2026-07-14-latex-equations-render-in-documents-public-pages-and-exports.md +6 -0
- package/corpus/templates/content/package.json +1 -0
- package/corpus/templates/content/shared/document-export.ts +296 -25
- package/corpus/templates/content/shared/inline-math.ts +128 -0
- package/corpus/templates/content/shared/math-rendering.ts +50 -0
- package/corpus/templates/content/shared/nfm.ts +17 -14
- package/corpus/templates/content/vite.config.ts +0 -1
- package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +17 -0
- package/corpus/templates/mail/AGENTS.md +21 -19
- package/corpus/templates/mail/actions/create-attachment-upload.ts +49 -0
- package/corpus/templates/mail/changelog/2026-07-14-connected-agents-can-now-securely-upload-local-files-for-mai.md +6 -0
- package/corpus/templates/mail/server/handlers/media.ts +84 -61
- package/corpus/templates/mail/server/lib/attachment-upload-ticket.ts +234 -0
- package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -4
- package/corpus/templates/mail/server/lib/media-upload.ts +114 -0
- package/corpus/templates/mail/server/plugins/auth.ts +9 -1
- package/corpus/templates/mail/server/routes/api/media/attachment-upload/[uploadId].put.ts +1 -0
- package/dist/a2a/client.d.ts +5 -1
- package/dist/a2a/client.d.ts.map +1 -1
- package/dist/a2a/client.js +18 -1
- package/dist/a2a/client.js.map +1 -1
- package/dist/a2a/handlers.d.ts.map +1 -1
- package/dist/a2a/handlers.js +57 -8
- package/dist/a2a/handlers.js.map +1 -1
- package/dist/a2a/server.d.ts.map +1 -1
- package/dist/a2a/server.js +87 -1
- package/dist/a2a/server.js.map +1 -1
- package/dist/a2a/task-store.d.ts +27 -0
- package/dist/a2a/task-store.d.ts.map +1 -1
- package/dist/a2a/task-store.js +216 -0
- package/dist/a2a/task-store.js.map +1 -1
- package/dist/a2a/types.d.ts +24 -0
- package/dist/a2a/types.d.ts.map +1 -1
- package/dist/a2a/types.js.map +1 -1
- package/dist/agent/production-agent.d.ts +1 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +5 -2
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/coding-tools/run-code.js +64 -11
- package/dist/coding-tools/run-code.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/extensions/actions.d.ts.map +1 -1
- package/dist/extensions/actions.js +129 -7
- package/dist/extensions/actions.js.map +1 -1
- package/dist/mcp/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +22 -3
- package/dist/mcp/builtin-tools.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +5 -5
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/scripts/call-agent.d.ts +1 -1
- package/dist/scripts/call-agent.d.ts.map +1 -1
- package/dist/scripts/call-agent.js +19 -1
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +71 -2
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
|
@@ -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
|
|
64
|
-
|
|
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
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `manage-
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
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
|
+
});
|
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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:
|
|
90
|
-
|
|
91
|
-
ownerEmail: session.email,
|
|
92
|
-
recordAsset: false,
|
|
118
|
+
filename: claimed.ticket.filename,
|
|
119
|
+
originalName: claimed.ticket.originalName,
|
|
93
120
|
});
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
5
|
-
* remain available through the in-app agent,
|
|
6
|
-
* full-catalog connection; tool-search alone never
|
|
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 = [
|
|
9
|
+
export const MAIL_CONNECTOR_CATALOG = [
|
|
10
|
+
"list-emails",
|
|
11
|
+
"create-attachment-upload",
|
|
12
|
+
] as const;
|