@njinlabs/njin 0.10.3 → 0.11.0

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 (55) hide show
  1. package/README.md +48 -0
  2. package/package.json +19 -4
  3. package/src/cli/build-assets.ts +4 -1
  4. package/src/cli/build.ts +47 -23
  5. package/src/cli/create.ts +35 -9
  6. package/src/cli/index.ts +2 -1
  7. package/src/cli/update.ts +93 -16
  8. package/src/config/module.ts +9 -0
  9. package/src/core/adapters/bun_filesystem.ts +13 -5
  10. package/src/core/adapters/s3.ts +18 -6
  11. package/src/core/admin_schema.ts +79 -0
  12. package/src/core/banner.ts +18 -4
  13. package/src/core/config.ts +58 -11
  14. package/src/core/helper.ts +4 -1
  15. package/src/core/html_page.ts +63 -0
  16. package/src/core/model/data_type/array.ts +16 -5
  17. package/src/core/model/data_type/boolean.ts +4 -1
  18. package/src/core/model/data_type/date.ts +4 -1
  19. package/src/core/model/data_type/email.ts +4 -1
  20. package/src/core/model/data_type/file.ts +17 -4
  21. package/src/core/model/data_type/multi_file.ts +9 -4
  22. package/src/core/model/data_type/numeric.ts +4 -1
  23. package/src/core/model/data_type/object.ts +8 -2
  24. package/src/core/model/data_type/relation.ts +21 -5
  25. package/src/core/model/data_type/relation_many.ts +24 -6
  26. package/src/core/model/data_type/richtext.ts +4 -1
  27. package/src/core/model/data_type/select.ts +17 -3
  28. package/src/core/model/data_type/text.ts +4 -1
  29. package/src/core/model/hooks.ts +42 -15
  30. package/src/core/model/index.ts +101 -37
  31. package/src/core/path_guard.ts +10 -3
  32. package/src/core/plugin.ts +8 -1
  33. package/src/core/public_url.ts +24 -0
  34. package/src/core/route.ts +2 -1
  35. package/src/core/vars/index.ts +22 -8
  36. package/src/core/worker.ts +22 -6
  37. package/src/models/file.ts +1 -1
  38. package/src/models/user.ts +1 -1
  39. package/src/modules/admin.ts +2 -2
  40. package/src/modules/analytics.ts +46 -26
  41. package/src/modules/api.ts +67 -95
  42. package/src/modules/auth.ts +25 -8
  43. package/src/modules/elysia.ts +1 -1
  44. package/src/modules/file.ts +50 -33
  45. package/src/modules/img.ts +33 -14
  46. package/src/modules/logger.ts +1 -1
  47. package/src/modules/mcp.ts +607 -0
  48. package/src/modules/mcp_token.ts +295 -0
  49. package/src/modules/mcp_upload.ts +313 -0
  50. package/src/modules/oauth.ts +579 -0
  51. package/src/modules/setup.ts +13 -5
  52. package/src/modules/surreal.ts +43 -13
  53. package/src/modules/users.ts +23 -5
  54. package/src/modules/vars.ts +1 -1
  55. package/src/modules/view.ts +88 -54
@@ -0,0 +1,295 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import bearer from "@elysia/bearer";
3
+ import Elysia, { status } from "elysia";
4
+ import moment from "moment";
5
+ import { RecordId, Table } from "surrealdb";
6
+ import z from "zod";
7
+ import { makeModule } from "../core/module";
8
+ import { publicBase } from "../core/public_url";
9
+ import auth from "./auth";
10
+ import elysia from "./elysia";
11
+ import surreal from "./surreal";
12
+
13
+ const table = new Table("mcp_token");
14
+
15
+ // Bearer prefixes, not just cosmetic — a session token ("token:<id>:<plain>") fed to an
16
+ // MCP-only route (or the reverse) fails the prefix check before any DB lookup happens, and an
17
+ // access token can't be replayed as a refresh token (or vice versa).
18
+ const ACCESS_PREFIX = "mcp";
19
+ const REFRESH_PREFIX = "mcpr";
20
+
21
+ const ACCESS_TTL_SECONDS = 60 * 60;
22
+ const REFRESH_TTL_DAYS = 30;
23
+
24
+ // One record per agent connection. "manual" tokens are minted by an admin through
25
+ // POST /api/mcp-token (no expiry); "oauth" ones are issued by the OAuth flow in oauth.ts to a
26
+ // specific user + client, expire quickly, and renew through a rotating refresh token that
27
+ // lives on the same record — so listing/revoking connections is one table for both kinds.
28
+ type McpToken = {
29
+ id: RecordId;
30
+ name: string;
31
+ hash: string;
32
+ createdAt: string;
33
+ createdBy: RecordId;
34
+ kind?: "manual" | "oauth";
35
+ expiresAt?: string;
36
+ refreshHash?: string;
37
+ refreshExpiresAt?: string;
38
+ clientId?: string;
39
+ };
40
+
41
+ const newSecret = () => randomBytes(32).toString("base64url");
42
+
43
+ const hashSecret = (plain: string) =>
44
+ new Bun.CryptoHasher("sha256").update(plain).digest("hex");
45
+
46
+ const safeEqual = (a: string, b: string) => {
47
+ const left = Buffer.from(a);
48
+ const right = Buffer.from(b);
49
+
50
+ return left.length === right.length && timingSafeEqual(left, right);
51
+ };
52
+
53
+ const parseBearer = (value: string | undefined, prefix: string) => {
54
+ if (!value) return null;
55
+
56
+ const [head, id, secret, ...rest] = value.split(":");
57
+ if (head !== prefix || !id || !secret || rest.length > 0) return null;
58
+
59
+ return { id, secret };
60
+ };
61
+
62
+ const isPast = (iso: string | undefined) =>
63
+ iso !== undefined && moment().isAfter(iso);
64
+
65
+ // Never leaks `hash`/`refreshHash` — they are the only thing standing between a DB read and a
66
+ // usable token.
67
+ const toPublic = ({
68
+ id,
69
+ name,
70
+ createdAt,
71
+ createdBy,
72
+ kind,
73
+ expiresAt,
74
+ clientId,
75
+ }: McpToken) => ({
76
+ id: id.id.toString(),
77
+ name,
78
+ kind: kind ?? "manual",
79
+ createdAt,
80
+ createdBy: createdBy?.id?.toString() ?? null,
81
+ expiresAt: expiresAt ?? null,
82
+ clientId: clientId ?? null,
83
+ });
84
+
85
+ const verifyToken = async (bearerValue: string | undefined) => {
86
+ const parsed = parseBearer(bearerValue, ACCESS_PREFIX);
87
+ if (!parsed) return null;
88
+
89
+ const token = await surreal().select<McpToken>(
90
+ new RecordId(table, parsed.id),
91
+ );
92
+ if (!token) return null;
93
+
94
+ if (!safeEqual(token.hash, hashSecret(parsed.secret))) return null;
95
+ if (isPast(token.expiresAt)) return null;
96
+
97
+ return token;
98
+ };
99
+
100
+ const tokenResponse = (id: string, access: string, refresh: string) => ({
101
+ access_token: `${ACCESS_PREFIX}:${id}:${access}`,
102
+ token_type: "Bearer" as const,
103
+ expires_in: ACCESS_TTL_SECONDS,
104
+ refresh_token: `${REFRESH_PREFIX}:${id}:${refresh}`,
105
+ });
106
+
107
+ const accessExpiry = () =>
108
+ moment().add(ACCESS_TTL_SECONDS, "seconds").toISOString();
109
+ const refreshExpiry = () =>
110
+ moment().add(REFRESH_TTL_DAYS, "days").toISOString();
111
+
112
+ // Called by oauth.ts once an authorization code has been redeemed.
113
+ export const issueOAuthTokens = async (input: {
114
+ name: string;
115
+ clientId: string;
116
+ userId: RecordId;
117
+ }) => {
118
+ const access = newSecret();
119
+ const refresh = newSecret();
120
+
121
+ const [created] = await surreal()
122
+ .create<McpToken>(table)
123
+ .content({
124
+ name: input.name,
125
+ kind: "oauth",
126
+ clientId: input.clientId,
127
+ createdBy: input.userId as unknown as McpToken["createdBy"],
128
+ createdAt: moment().toISOString(),
129
+ hash: hashSecret(access),
130
+ expiresAt: accessExpiry(),
131
+ refreshHash: hashSecret(refresh),
132
+ refreshExpiresAt: refreshExpiry(),
133
+ });
134
+
135
+ return tokenResponse(created!.id.id.toString(), access, refresh);
136
+ };
137
+
138
+ // Refresh-token rotation: the presented token is replaced in place, so it can never be used
139
+ // twice. Returns null for any reason the grant is unusable (unknown, wrong client, expired,
140
+ // already rotated) — the caller reports all of them as OAuth `invalid_grant`.
141
+ export const rotateOAuthTokens = async (
142
+ refreshBearer: string,
143
+ clientId: string,
144
+ ) => {
145
+ const parsed = parseBearer(refreshBearer, REFRESH_PREFIX);
146
+ if (!parsed) return null;
147
+
148
+ const id = new RecordId(table, parsed.id);
149
+ const token = await surreal().select<McpToken>(id);
150
+
151
+ if (
152
+ token?.kind !== "oauth" ||
153
+ token.clientId !== clientId ||
154
+ !token.refreshHash ||
155
+ !safeEqual(token.refreshHash, hashSecret(parsed.secret)) ||
156
+ isPast(token.refreshExpiresAt)
157
+ ) {
158
+ return null;
159
+ }
160
+
161
+ const access = newSecret();
162
+ const refresh = newSecret();
163
+
164
+ await surreal()
165
+ .update<McpToken>(id)
166
+ .merge({
167
+ hash: hashSecret(access),
168
+ expiresAt: accessExpiry(),
169
+ refreshHash: hashSecret(refresh),
170
+ refreshExpiresAt: refreshExpiry(),
171
+ });
172
+
173
+ return tokenResponse(parsed.id, access, refresh);
174
+ };
175
+
176
+ // RFC 7009 — accepts either token type; deleting the record kills both halves at once.
177
+ export const revokeOAuthToken = async (bearerValue: string) => {
178
+ for (const prefix of [ACCESS_PREFIX, REFRESH_PREFIX]) {
179
+ const parsed = parseBearer(bearerValue, prefix);
180
+ if (!parsed) continue;
181
+
182
+ const id = new RecordId(table, parsed.id);
183
+ const token = await surreal().select<McpToken>(id);
184
+ if (!token) return;
185
+
186
+ const expected = prefix === ACCESS_PREFIX ? token.hash : token.refreshHash;
187
+ if (expected && safeEqual(expected, hashSecret(parsed.secret))) {
188
+ await surreal().delete(id);
189
+ }
190
+ return;
191
+ }
192
+ };
193
+
194
+ // Dedicated credential for AI agents (MCP) — deliberately a separate table and bearer
195
+ // format from the admin session token in auth.ts, so an MCP token can never satisfy the
196
+ // `auth: true` macro (and with it the whole /api/* admin surface), and a leaked MCP token
197
+ // is revoked by deleting one record without touching anybody's login session.
198
+ const mcpToken = makeModule(() => {
199
+ const fn = async () => {
200
+ const plugin = new Elysia({ name: "mcp-auth" }).use(bearer()).macro({
201
+ mcpAuth: {
202
+ resolve: async ({ bearer, request, set }) => {
203
+ const token = await verifyToken(bearer);
204
+
205
+ if (!token) {
206
+ // RFC 9728 — this header is how an MCP client discovers the OAuth flow; without
207
+ // it (and a real 401 status) Claude never starts sign-in.
208
+ set.headers["www-authenticate"] =
209
+ `Bearer resource_metadata="${publicBase(request)}/.well-known/oauth-protected-resource"`;
210
+
211
+ return status(401, { message: "Unauthorized" });
212
+ }
213
+
214
+ return { mcpToken: toPublic(token) };
215
+ },
216
+ },
217
+ });
218
+
219
+ return { plugin };
220
+ };
221
+
222
+ fn.init = async () => {
223
+ const authPlugin = (await auth()).plugin;
224
+
225
+ // Managed with a regular admin session (`auth: true`), never with an MCP token itself —
226
+ // otherwise a leaked agent token could mint itself a replacement after being revoked.
227
+ const controller = new Elysia({ prefix: "/api/mcp-token" })
228
+ .use(authPlugin)
229
+ .get(
230
+ "/",
231
+ async () => {
232
+ const tokens = await surreal().select<McpToken>(table);
233
+
234
+ return {
235
+ data: tokens
236
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
237
+ .map(toPublic),
238
+ };
239
+ },
240
+ { auth: true },
241
+ )
242
+ .post(
243
+ "/",
244
+ async ({ body, user }) => {
245
+ const plain = newSecret();
246
+
247
+ const [created] = await surreal()
248
+ .create<McpToken>(table)
249
+ .content({
250
+ name: body.name,
251
+ kind: "manual",
252
+ hash: hashSecret(plain),
253
+ createdAt: moment().toISOString(),
254
+ createdBy: user.id as unknown as McpToken["createdBy"],
255
+ });
256
+
257
+ return {
258
+ data: {
259
+ ...toPublic(created!),
260
+ // Returned exactly once — only the hash is stored, so it can't be shown again.
261
+ token: `${ACCESS_PREFIX}:${created!.id.id}:${plain}`,
262
+ },
263
+ };
264
+ },
265
+ {
266
+ auth: true,
267
+ body: z.object({ name: z.string().trim().min(1).max(100) }),
268
+ },
269
+ )
270
+ .delete(
271
+ "/:id",
272
+ async ({ params }) => {
273
+ const id = new RecordId(table, params.id);
274
+ const existing = await surreal().select<McpToken>(id);
275
+
276
+ if (!existing) {
277
+ return status(404, { message: "Token not found" });
278
+ }
279
+
280
+ await surreal().delete(id);
281
+
282
+ return { data: toPublic(existing) };
283
+ },
284
+ { auth: true, params: z.object({ id: z.coerce.string() }) },
285
+ );
286
+
287
+ elysia().use(controller);
288
+
289
+ return {};
290
+ };
291
+
292
+ return fn;
293
+ });
294
+
295
+ export default mcpToken;
@@ -0,0 +1,313 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import Elysia from "elysia";
3
+ import moment from "moment";
4
+ import { RecordId, Table } from "surrealdb";
5
+ import { escapeHtml, noStore, page } from "../core/html_page";
6
+ import { makeModule } from "../core/module";
7
+ import elysia from "./elysia";
8
+ import fileModule from "./file";
9
+ import surreal from "./surreal";
10
+
11
+ // How an agent gets a file *into* the site. MCP is JSON-RPC, and a file the user dropped into
12
+ // the chat can't be passed through a tool argument (base64 floods the context and models mangle
13
+ // it), so the tool only hands out a short-lived upload URL: the agent `curl`s the file to it from
14
+ // its sandbox, or — when it has no shell/network — gives the URL to the user to open and drop
15
+ // the file on. The bytes never pass through the model either way.
16
+
17
+ const table = new Table("mcp_upload");
18
+
19
+ export const UPLOAD_TTL_MINUTES = 10;
20
+ export const MAX_FILES_PER_TICKET = 10;
21
+ export const MAX_FILE_BYTES = 10 * 1024 * 1024;
22
+
23
+ // Allowlist, not a denylist: /uploads/* is served from the site's own origin by file extension,
24
+ // so an uploaded .html/.svg/.js would run with the same origin as the admin panel. The declared
25
+ // MIME type is ignored (the client picks it) and replaced with the one mapped here — object
26
+ // stores that serve by stored Content-Type would otherwise be told whatever the uploader said.
27
+ export const ALLOWED_TYPES: Record<string, string> = {
28
+ jpg: "image/jpeg",
29
+ jpeg: "image/jpeg",
30
+ png: "image/png",
31
+ gif: "image/gif",
32
+ webp: "image/webp",
33
+ avif: "image/avif",
34
+ bmp: "image/bmp",
35
+ ico: "image/x-icon",
36
+ pdf: "application/pdf",
37
+ txt: "text/plain",
38
+ csv: "text/csv",
39
+ md: "text/markdown",
40
+ doc: "application/msword",
41
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
42
+ xls: "application/vnd.ms-excel",
43
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
44
+ ppt: "application/vnd.ms-powerpoint",
45
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
46
+ zip: "application/zip",
47
+ mp3: "audio/mpeg",
48
+ wav: "audio/wav",
49
+ ogg: "audio/ogg",
50
+ mp4: "video/mp4",
51
+ webm: "video/webm",
52
+ mov: "video/quicktime",
53
+ woff: "font/woff",
54
+ woff2: "font/woff2",
55
+ };
56
+
57
+ type McpUpload = {
58
+ id: RecordId;
59
+ hash: string;
60
+ createdBy: string | null;
61
+ tokenId: string;
62
+ expiresAt: string;
63
+ remaining: number;
64
+ files: string[];
65
+ };
66
+
67
+ type FileRecord = {
68
+ id: RecordId;
69
+ name: string;
70
+ url: string;
71
+ type: string;
72
+ size: number;
73
+ };
74
+
75
+ const hashSecret = (plain: string) =>
76
+ new Bun.CryptoHasher("sha256").update(plain).digest("hex");
77
+
78
+ const safeEqual = (a: string, b: string) => {
79
+ const left = Buffer.from(a);
80
+ const right = Buffer.from(b);
81
+
82
+ return left.length === right.length && timingSafeEqual(left, right);
83
+ };
84
+
85
+ const isPast = (iso: string) => moment().isAfter(iso);
86
+
87
+ const publicFile = ({ id, name, url, type, size }: FileRecord) => ({
88
+ id: id.id.toString(),
89
+ name,
90
+ url,
91
+ type,
92
+ size,
93
+ });
94
+
95
+ // Called by the create_upload_url tool.
96
+ export const createUploadTicket = async (input: {
97
+ tokenId: string;
98
+ userId: string | null;
99
+ base: string;
100
+ }) => {
101
+ // Tickets are short-lived; anything an hour past expiry is only clutter (the grace period
102
+ // lets check_upload still answer shortly after a ticket lapses).
103
+ await surreal().query("DELETE mcp_upload WHERE expiresAt < $cutoff;", {
104
+ cutoff: moment().subtract(1, "hour").toISOString(),
105
+ });
106
+
107
+ const secret = randomBytes(24).toString("base64url");
108
+ const expiresAt = moment().add(UPLOAD_TTL_MINUTES, "minutes").toISOString();
109
+
110
+ const [created] = await surreal()
111
+ .create<McpUpload>(table)
112
+ .content({
113
+ hash: hashSecret(secret),
114
+ createdBy: input.userId,
115
+ tokenId: input.tokenId,
116
+ expiresAt,
117
+ remaining: MAX_FILES_PER_TICKET,
118
+ files: [],
119
+ });
120
+
121
+ const id = created!.id.id.toString();
122
+
123
+ return {
124
+ upload_id: id,
125
+ url: `${input.base}/mcp/upload/${id}.${secret}`,
126
+ expires_at: expiresAt,
127
+ max_files: MAX_FILES_PER_TICKET,
128
+ max_file_bytes: MAX_FILE_BYTES,
129
+ allowed_extensions: Object.keys(ALLOWED_TYPES),
130
+ };
131
+ };
132
+
133
+ // Called by the check_upload tool: what has landed on a ticket so far.
134
+ export const getUploadStatus = async (uploadId: string) => {
135
+ const ticket = await surreal().select<McpUpload>(
136
+ new RecordId(table, uploadId),
137
+ );
138
+ if (!ticket) return null;
139
+
140
+ const files = (
141
+ await Promise.all(
142
+ ticket.files.map((id) => fileModule().model.show(id) as Promise<unknown>),
143
+ )
144
+ )
145
+ .filter(Boolean)
146
+ .map((record) => publicFile(record as FileRecord));
147
+
148
+ return {
149
+ files,
150
+ remaining: ticket.remaining,
151
+ expired: isPast(ticket.expiresAt),
152
+ };
153
+ };
154
+
155
+ const mcpUpload = makeModule(() => {
156
+ const fn = () => {};
157
+
158
+ fn.init = async () => {
159
+ const wantsHtml = (request: Request) =>
160
+ request.headers.get("accept")?.includes("text/html") ?? false;
161
+
162
+ const fail = (request: Request, status: number, message: string) =>
163
+ wantsHtml(request)
164
+ ? page(
165
+ "Upload failed",
166
+ `<h1>Upload failed</h1><p>${escapeHtml(message)}</p>`,
167
+ status,
168
+ )
169
+ : new Response(JSON.stringify({ error: message }), {
170
+ status,
171
+ headers: { "Content-Type": "application/json", ...noStore },
172
+ });
173
+
174
+ // The URL is the credential: "<ticket id>.<secret>", the secret stored only as a hash.
175
+ // Every failure reads the same to the caller, so a guessed id reveals nothing.
176
+ const loadTicket = async (param: string) => {
177
+ const dot = param.lastIndexOf(".");
178
+ const id = param.slice(0, dot);
179
+ const secret = param.slice(dot + 1);
180
+
181
+ const ticket =
182
+ dot > 0 && secret
183
+ ? await surreal().select<McpUpload>(new RecordId(table, id))
184
+ : null;
185
+
186
+ if (!ticket || !safeEqual(ticket.hash, hashSecret(secret))) return null;
187
+ if (isPast(ticket.expiresAt) || ticket.remaining <= 0) return null;
188
+
189
+ return ticket;
190
+ };
191
+
192
+ const invalid = (request: Request) =>
193
+ fail(request, 404, "This upload link is invalid, expired or used up.");
194
+
195
+ const controller = new Elysia()
196
+ .get("/mcp/upload/:ticket", async ({ request, params }) => {
197
+ const ticket = await loadTicket(params.ticket);
198
+ if (!ticket) return invalid(request);
199
+
200
+ const maxMb = MAX_FILE_BYTES / 1024 / 1024;
201
+
202
+ return page(
203
+ "Upload files",
204
+ `<h1>Upload files</h1>
205
+ <p>Choose or drop up to ${ticket.remaining} files (${maxMb} MB each). The assistant will pick them up from here.</p>
206
+ <form method="post" enctype="multipart/form-data">
207
+ <input type="file" name="file" multiple required>
208
+ <div class="row"><button type="submit" class="primary">Upload</button></div>
209
+ </form>`,
210
+ );
211
+ })
212
+ .post(
213
+ "/mcp/upload/:ticket",
214
+ async ({ request, params }) => {
215
+ const ticket = await loadTicket(params.ticket);
216
+ if (!ticket) return invalid(request);
217
+
218
+ // Checked before the body is buffered — formData() reads it all into memory.
219
+ const declared = Number(request.headers.get("content-length") ?? 0);
220
+ if (declared > ticket.remaining * MAX_FILE_BYTES + 64 * 1024) {
221
+ return fail(request, 413, "That upload is too large.");
222
+ }
223
+
224
+ let incoming: FormDataEntryValue[];
225
+ try {
226
+ incoming = (await request.formData()).getAll("file");
227
+ } catch {
228
+ return fail(request, 400, "Send the file as multipart form data.");
229
+ }
230
+
231
+ const files = incoming.filter(
232
+ (entry): entry is File => entry instanceof File,
233
+ );
234
+ if (files.length === 0) {
235
+ return fail(request, 400, 'No file found in the "file" field.');
236
+ }
237
+ if (files.length > ticket.remaining) {
238
+ return fail(
239
+ request,
240
+ 400,
241
+ `This link accepts ${ticket.remaining} more file(s).`,
242
+ );
243
+ }
244
+
245
+ // Everything is validated before anything is written, so a bad file in the batch
246
+ // doesn't leave the others half-uploaded.
247
+ const normalized: File[] = [];
248
+ for (const file of files) {
249
+ const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
250
+ const type = ALLOWED_TYPES[extension];
251
+
252
+ if (!file.name.includes(".") || !type) {
253
+ return fail(
254
+ request,
255
+ 415,
256
+ `"${file.name}": this file type is not allowed.`,
257
+ );
258
+ }
259
+ if (file.size === 0 || file.size > MAX_FILE_BYTES) {
260
+ return fail(
261
+ request,
262
+ 413,
263
+ `"${file.name}" must be between 1 byte and ${MAX_FILE_BYTES / 1024 / 1024} MB.`,
264
+ );
265
+ }
266
+
267
+ normalized.push(new File([file], file.name, { type }));
268
+ }
269
+
270
+ const created: FileRecord[] = [];
271
+ for (const file of normalized) {
272
+ const [record] = (await fileModule().upload(file)) as FileRecord[];
273
+ created.push(record!);
274
+ }
275
+
276
+ await surreal()
277
+ .update(new RecordId(table, ticket.id.id))
278
+ .merge({
279
+ remaining: ticket.remaining - created.length,
280
+ files: [
281
+ ...ticket.files,
282
+ ...created.map((f) => f.id.id.toString()),
283
+ ],
284
+ });
285
+
286
+ if (wantsHtml(request)) {
287
+ return page(
288
+ "Uploaded",
289
+ `<h1>Uploaded</h1><p>You can close this tab and go back to the chat.</p><ul>${created
290
+ .map((f) => `<li>${escapeHtml(f.name)}</li>`)
291
+ .join("")}</ul>`,
292
+ );
293
+ }
294
+
295
+ return new Response(
296
+ JSON.stringify({ data: created.map(publicFile) }),
297
+ {
298
+ headers: { "Content-Type": "application/json", ...noStore },
299
+ },
300
+ );
301
+ },
302
+ { parse: "none" },
303
+ );
304
+
305
+ elysia().use(controller);
306
+
307
+ return {};
308
+ };
309
+
310
+ return fn;
311
+ });
312
+
313
+ export default mcpUpload;