@ganju/utils 0.0.1

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 (220) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +9 -0
  3. package/README.md +23 -0
  4. package/dist/apiError.d.ts +3 -0
  5. package/dist/apiError.d.ts.map +1 -0
  6. package/dist/apiError.js +36 -0
  7. package/dist/attachment.d.ts +33 -0
  8. package/dist/attachment.d.ts.map +1 -0
  9. package/dist/attachment.js +59 -0
  10. package/dist/base64.d.ts +10 -0
  11. package/dist/base64.d.ts.map +1 -0
  12. package/dist/base64.js +50 -0
  13. package/dist/channelDebounce.d.ts +26 -0
  14. package/dist/channelDebounce.d.ts.map +1 -0
  15. package/dist/channelDebounce.js +35 -0
  16. package/dist/channelNotifier.d.ts +9 -0
  17. package/dist/channelNotifier.d.ts.map +1 -0
  18. package/dist/channelNotifier.js +31 -0
  19. package/dist/chunking.d.ts +28 -0
  20. package/dist/chunking.d.ts.map +1 -0
  21. package/dist/chunking.js +97 -0
  22. package/dist/cliConstants.d.ts +9 -0
  23. package/dist/cliConstants.d.ts.map +1 -0
  24. package/dist/cliConstants.js +47 -0
  25. package/dist/coerce.d.ts +2 -0
  26. package/dist/coerce.d.ts.map +1 -0
  27. package/dist/coerce.js +13 -0
  28. package/dist/constants.d.ts +624 -0
  29. package/dist/constants.d.ts.map +1 -0
  30. package/dist/constants.js +2220 -0
  31. package/dist/crypto.d.ts +14 -0
  32. package/dist/crypto.d.ts.map +1 -0
  33. package/dist/crypto.js +93 -0
  34. package/dist/customCodeProject.d.ts +91 -0
  35. package/dist/customCodeProject.d.ts.map +1 -0
  36. package/dist/customCodeProject.js +154 -0
  37. package/dist/customCodeToken.d.ts +41 -0
  38. package/dist/customCodeToken.d.ts.map +1 -0
  39. package/dist/customCodeToken.js +115 -0
  40. package/dist/deadline.d.ts +6 -0
  41. package/dist/deadline.d.ts.map +1 -0
  42. package/dist/deadline.js +45 -0
  43. package/dist/discordSend.d.ts +25 -0
  44. package/dist/discordSend.d.ts.map +1 -0
  45. package/dist/discordSend.js +8 -0
  46. package/dist/embeddable.d.ts +2 -0
  47. package/dist/embeddable.d.ts.map +1 -0
  48. package/dist/embeddable.js +6 -0
  49. package/dist/exposedResource.d.ts +6 -0
  50. package/dist/exposedResource.d.ts.map +1 -0
  51. package/dist/exposedResource.js +18 -0
  52. package/dist/extractedDocument.d.ts +70 -0
  53. package/dist/extractedDocument.d.ts.map +1 -0
  54. package/dist/extractedDocument.js +2 -0
  55. package/dist/fetcher.d.ts +8 -0
  56. package/dist/fetcher.d.ts.map +1 -0
  57. package/dist/fetcher.js +68 -0
  58. package/dist/formatFilename.d.ts +2 -0
  59. package/dist/formatFilename.d.ts.map +1 -0
  60. package/dist/formatFilename.js +19 -0
  61. package/dist/formatRelative.d.ts +12 -0
  62. package/dist/formatRelative.d.ts.map +1 -0
  63. package/dist/formatRelative.js +28 -0
  64. package/dist/formatRelativeTime.d.ts +2 -0
  65. package/dist/formatRelativeTime.d.ts.map +1 -0
  66. package/dist/formatRelativeTime.js +49 -0
  67. package/dist/getEnv.d.ts +5 -0
  68. package/dist/getEnv.d.ts.map +1 -0
  69. package/dist/getEnv.js +11 -0
  70. package/dist/gmailSend.d.ts +24 -0
  71. package/dist/gmailSend.d.ts.map +1 -0
  72. package/dist/gmailSend.js +9 -0
  73. package/dist/index.d.ts +2839 -0
  74. package/dist/index.d.ts.map +1 -0
  75. package/dist/index.js +155 -0
  76. package/dist/jsonSchemaToZodShape.d.ts +38 -0
  77. package/dist/jsonSchemaToZodShape.d.ts.map +1 -0
  78. package/dist/jsonSchemaToZodShape.js +117 -0
  79. package/dist/l2Normalize.d.ts +2 -0
  80. package/dist/l2Normalize.d.ts.map +1 -0
  81. package/dist/l2Normalize.js +25 -0
  82. package/dist/languageCookieDomain.d.ts +21 -0
  83. package/dist/languageCookieDomain.d.ts.map +1 -0
  84. package/dist/languageCookieDomain.js +39 -0
  85. package/dist/localizeZodIssue.d.ts +43 -0
  86. package/dist/localizeZodIssue.d.ts.map +1 -0
  87. package/dist/localizeZodIssue.js +90 -0
  88. package/dist/mailHeaders.d.ts +4 -0
  89. package/dist/mailHeaders.d.ts.map +1 -0
  90. package/dist/mailHeaders.js +11 -0
  91. package/dist/mcpProxy.d.ts +2 -0
  92. package/dist/mcpProxy.d.ts.map +1 -0
  93. package/dist/mcpProxy.js +21 -0
  94. package/dist/mimeMessage.d.ts +21 -0
  95. package/dist/mimeMessage.d.ts.map +1 -0
  96. package/dist/mimeMessage.js +58 -0
  97. package/dist/oauth.d.ts +21 -0
  98. package/dist/oauth.d.ts.map +1 -0
  99. package/dist/oauth.js +75 -0
  100. package/dist/oauthProviders.d.ts +12 -0
  101. package/dist/oauthProviders.d.ts.map +1 -0
  102. package/dist/oauthProviders.js +99 -0
  103. package/dist/outlookSend.d.ts +19 -0
  104. package/dist/outlookSend.d.ts.map +1 -0
  105. package/dist/outlookSend.js +9 -0
  106. package/dist/parseHttpError.d.ts +2 -0
  107. package/dist/parseHttpError.d.ts.map +1 -0
  108. package/dist/parseHttpError.js +23 -0
  109. package/dist/planLimitError.d.ts +18 -0
  110. package/dist/planLimitError.d.ts.map +1 -0
  111. package/dist/planLimitError.js +35 -0
  112. package/dist/processQueueBatch.d.ts +17 -0
  113. package/dist/processQueueBatch.d.ts.map +1 -0
  114. package/dist/processQueueBatch.js +32 -0
  115. package/dist/reservedToolName.d.ts +16 -0
  116. package/dist/reservedToolName.d.ts.map +1 -0
  117. package/dist/reservedToolName.js +25 -0
  118. package/dist/resourceUri.d.ts +2 -0
  119. package/dist/resourceUri.d.ts.map +1 -0
  120. package/dist/resourceUri.js +19 -0
  121. package/dist/retry.d.ts +10 -0
  122. package/dist/retry.d.ts.map +1 -0
  123. package/dist/retry.js +37 -0
  124. package/dist/sanitize.d.ts +7 -0
  125. package/dist/sanitize.d.ts.map +1 -0
  126. package/dist/sanitize.js +69 -0
  127. package/dist/schema.d.ts +2175 -0
  128. package/dist/schema.d.ts.map +1 -0
  129. package/dist/schema.js +1382 -0
  130. package/dist/sdkConstants.d.ts +15 -0
  131. package/dist/sdkConstants.d.ts.map +1 -0
  132. package/dist/sdkConstants.js +43 -0
  133. package/dist/slackSend.d.ts +38 -0
  134. package/dist/slackSend.d.ts.map +1 -0
  135. package/dist/slackSend.js +14 -0
  136. package/dist/slug.d.ts +5 -0
  137. package/dist/slug.d.ts.map +1 -0
  138. package/dist/slug.js +34 -0
  139. package/dist/slugifyTitle.d.ts +2 -0
  140. package/dist/slugifyTitle.d.ts.map +1 -0
  141. package/dist/slugifyTitle.js +11 -0
  142. package/dist/sources.d.ts +43 -0
  143. package/dist/sources.d.ts.map +1 -0
  144. package/dist/sources.js +73 -0
  145. package/dist/ssrf.d.ts +3 -0
  146. package/dist/ssrf.d.ts.map +1 -0
  147. package/dist/ssrf.js +46 -0
  148. package/dist/tallyUsageKinds.d.ts +9 -0
  149. package/dist/tallyUsageKinds.d.ts.map +1 -0
  150. package/dist/tallyUsageKinds.js +20 -0
  151. package/dist/telegramSend.d.ts +28 -0
  152. package/dist/telegramSend.d.ts.map +1 -0
  153. package/dist/telegramSend.js +9 -0
  154. package/dist/toolCatalog.d.ts +452 -0
  155. package/dist/toolCatalog.d.ts.map +1 -0
  156. package/dist/toolCatalog.js +552 -0
  157. package/dist/validateMessageVariables.d.ts +7 -0
  158. package/dist/validateMessageVariables.d.ts.map +1 -0
  159. package/dist/validateMessageVariables.js +16 -0
  160. package/dist/vendorError.d.ts +2 -0
  161. package/dist/vendorError.d.ts.map +1 -0
  162. package/dist/vendorError.js +25 -0
  163. package/dist/whatsappSend.d.ts +31 -0
  164. package/dist/whatsappSend.d.ts.map +1 -0
  165. package/dist/whatsappSend.js +11 -0
  166. package/package.json +76 -0
  167. package/src/apiError.ts +40 -0
  168. package/src/attachment.ts +97 -0
  169. package/src/base64.ts +60 -0
  170. package/src/channelDebounce.ts +76 -0
  171. package/src/channelNotifier.ts +39 -0
  172. package/src/chunking.ts +131 -0
  173. package/src/cliConstants.ts +50 -0
  174. package/src/coerce.ts +9 -0
  175. package/src/constants.ts +2542 -0
  176. package/src/crypto.ts +108 -0
  177. package/src/customCodeProject.ts +182 -0
  178. package/src/customCodeToken.ts +198 -0
  179. package/src/deadline.ts +43 -0
  180. package/src/discordSend.ts +41 -0
  181. package/src/embeddable.ts +4 -0
  182. package/src/exposedResource.ts +40 -0
  183. package/src/extractedDocument.ts +62 -0
  184. package/src/fetcher.ts +82 -0
  185. package/src/formatFilename.ts +19 -0
  186. package/src/formatRelative.ts +36 -0
  187. package/src/formatRelativeTime.ts +45 -0
  188. package/src/getEnv.ts +13 -0
  189. package/src/gmailSend.ts +34 -0
  190. package/src/index.ts +397 -0
  191. package/src/jsonSchemaToZodShape.ts +111 -0
  192. package/src/l2Normalize.ts +19 -0
  193. package/src/languageCookieDomain.ts +37 -0
  194. package/src/localizeZodIssue.ts +124 -0
  195. package/src/mailHeaders.ts +12 -0
  196. package/src/mcpProxy.ts +20 -0
  197. package/src/mimeMessage.ts +82 -0
  198. package/src/oauth.ts +103 -0
  199. package/src/oauthProviders.ts +118 -0
  200. package/src/outlookSend.ts +44 -0
  201. package/src/parseHttpError.ts +16 -0
  202. package/src/planLimitError.ts +50 -0
  203. package/src/processQueueBatch.ts +46 -0
  204. package/src/reservedToolName.ts +27 -0
  205. package/src/resourceUri.ts +17 -0
  206. package/src/retry.ts +43 -0
  207. package/src/sanitize.ts +67 -0
  208. package/src/schema.ts +1624 -0
  209. package/src/sdkConstants.ts +44 -0
  210. package/src/slackSend.ts +65 -0
  211. package/src/slug.ts +37 -0
  212. package/src/slugifyTitle.ts +8 -0
  213. package/src/sources.ts +117 -0
  214. package/src/ssrf.ts +34 -0
  215. package/src/tallyUsageKinds.ts +20 -0
  216. package/src/telegramSend.ts +39 -0
  217. package/src/toolCatalog.ts +635 -0
  218. package/src/validateMessageVariables.ts +18 -0
  219. package/src/vendorError.ts +19 -0
  220. package/src/whatsappSend.ts +47 -0
package/src/crypto.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { Context } from 'hono';
2
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
3
+ import { getEnv } from './getEnv';
4
+ import { base64ToBytes, bytesToBase64 } from './base64';
5
+
6
+ const PREFIX = 'enc:v1:';
7
+ const NONCE_BYTES = 24;
8
+ const KEY_BYTES = 32;
9
+ const KEY_ENV = 'CRYPTO_SECRET';
10
+
11
+ export const getCredentialEncryptionKey = (c: Context) => {
12
+ const rawKey = getEnv(c, KEY_ENV);
13
+ if (!rawKey) {
14
+ throw new Error(`Missing env: ${KEY_ENV}`);
15
+ }
16
+ return rawKey;
17
+ };
18
+
19
+ const loadKey = (rawKey: string) => {
20
+ const bytes = base64ToBytes(rawKey);
21
+ if (bytes.length !== KEY_BYTES) {
22
+ throw new Error(
23
+ `CRYPTO_SECRET must decode to ${KEY_BYTES} bytes (got ${bytes.length})`
24
+ );
25
+ }
26
+ return bytes;
27
+ };
28
+
29
+ export const encryptString = (plaintext: string, rawKey: string) => {
30
+ const key = loadKey(rawKey);
31
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
32
+ const cipher = xchacha20poly1305(key, nonce);
33
+ const ciphertext = cipher.encrypt(new TextEncoder().encode(plaintext));
34
+ const bundle = new Uint8Array(nonce.length + ciphertext.length);
35
+ bundle.set(nonce, 0);
36
+ bundle.set(ciphertext, nonce.length);
37
+ return `${PREFIX}${bytesToBase64(bundle)}`;
38
+ };
39
+
40
+ export const decryptString = (value: string, rawKey: string) => {
41
+ if (!value.startsWith(PREFIX)) return value;
42
+ const bundle = base64ToBytes(value.slice(PREFIX.length));
43
+ const nonce = bundle.slice(0, NONCE_BYTES);
44
+ const ciphertext = bundle.slice(NONCE_BYTES);
45
+ const key = loadKey(rawKey);
46
+ const cipher = xchacha20poly1305(key, nonce);
47
+ const plaintext = cipher.decrypt(ciphertext);
48
+ return new TextDecoder().decode(plaintext);
49
+ };
50
+
51
+ export const sha256Hex = async (input: string) => {
52
+ const digest = await crypto.subtle.digest(
53
+ 'SHA-256',
54
+ new TextEncoder().encode(input)
55
+ );
56
+ const bytes = new Uint8Array(digest);
57
+ let hex = '';
58
+ for (let i = 0; i < bytes.length; i++) {
59
+ hex += bytes[i].toString(16).padStart(2, '0');
60
+ }
61
+ return hex;
62
+ };
63
+
64
+ /**
65
+ * SHA-256 as unpadded base64url — the format `@better-auth/oauth-provider`
66
+ * stores hashed client secrets in. Anything comparing against a stored
67
+ * `oauth_client.client_secret` has to hash the candidate exactly this way.
68
+ */
69
+ export const sha256Base64Url = async (input: string) => {
70
+ const digest = await crypto.subtle.digest(
71
+ 'SHA-256',
72
+ new TextEncoder().encode(input)
73
+ );
74
+ const bytes = new Uint8Array(digest);
75
+ let binary = '';
76
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
77
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
78
+ };
79
+
80
+ export const hmacSha256Hex = async (key: string, message: string) => {
81
+ const cryptoKey = await crypto.subtle.importKey(
82
+ 'raw',
83
+ new TextEncoder().encode(key),
84
+ { name: 'HMAC', hash: 'SHA-256' },
85
+ false,
86
+ ['sign']
87
+ );
88
+ const signature = await crypto.subtle.sign(
89
+ 'HMAC',
90
+ cryptoKey,
91
+ new TextEncoder().encode(message)
92
+ );
93
+ const bytes = new Uint8Array(signature);
94
+ let hex = '';
95
+ for (let i = 0; i < bytes.length; i++) {
96
+ hex += bytes[i].toString(16).padStart(2, '0');
97
+ }
98
+ return hex;
99
+ };
100
+
101
+ export const timingSafeEqual = (a: string, b: string) => {
102
+ if (a.length !== b.length) return false;
103
+ let diff = 0;
104
+ for (let i = 0; i < a.length; i++) {
105
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
106
+ }
107
+ return diff === 0;
108
+ };
@@ -0,0 +1,182 @@
1
+ import { constants } from './constants';
2
+
3
+ /**
4
+ * A dashboard-authored script is a set of files, not one file.
5
+ *
6
+ * The deploy path already uploads more than one module — the SDK travels beside
7
+ * every script as `ganju-sdk.js` — so the runtime has never been the thing
8
+ * holding this to a single file. What was single was the storage: one R2 object
9
+ * holding one module's text.
10
+ *
11
+ * So the object becomes an envelope. `sourceKind: 'editor'` bytes are JSON of
12
+ * this shape; `'bundle'` bytes stay exactly what the CLI compiled, because a
13
+ * bundle IS one module and wrapping it would mean the stored bytes are no longer
14
+ * the thing that runs. `decodeProject` tells the two apart by reading the marker,
15
+ * which is why there is a marker rather than a bare `{ files }` — arbitrary
16
+ * minified JS never parses as JSON, but a bundle that happens to be a JSON
17
+ * module would, and guessing is not a thing to do with someone's deploy.
18
+ */
19
+ export const CUSTOM_CODE_PROJECT_MARKER = 'ganjuProject';
20
+
21
+ export interface CustomCodeProject {
22
+ [CUSTOM_CODE_PROJECT_MARKER]: 1;
23
+ files: Record<string, string>;
24
+ }
25
+
26
+ export const encodeProject = (files: Record<string, string>): string =>
27
+ JSON.stringify({ [CUSTOM_CODE_PROJECT_MARKER]: 1, files });
28
+
29
+ /**
30
+ * Read stored bytes back as a set of files, or `null` when they are a single
31
+ * module — the CLI's bundle, and every editor version written before folders
32
+ * existed.
33
+ */
34
+ export const decodeProject = (text: string): Record<string, string> | null => {
35
+ if (!text.trimStart().startsWith('{')) return null;
36
+ let parsed: unknown;
37
+ try {
38
+ parsed = JSON.parse(text);
39
+ } catch {
40
+ return null;
41
+ }
42
+ if (!parsed || typeof parsed !== 'object') return null;
43
+ const envelope = parsed as Partial<CustomCodeProject>;
44
+ if (envelope[CUSTOM_CODE_PROJECT_MARKER] !== 1) return null;
45
+ if (!envelope.files || typeof envelope.files !== 'object') return null;
46
+ const files: Record<string, string> = {};
47
+ for (const [path, content] of Object.entries(envelope.files)) {
48
+ if (typeof content === 'string') files[path] = content;
49
+ }
50
+ return files;
51
+ };
52
+
53
+ /**
54
+ * Why one path is not a legal file in a script — the rule that refused it, and
55
+ * what it needs to say so.
56
+ *
57
+ * A code rather than a sentence, because there are two readers. The upload path
58
+ * throws it as English, which is what `error_log` has always held; the
59
+ * dashboard's file explorer renders it in whatever language the person is
60
+ * reading, at the keystroke. Returning prose would have forced the explorer to
61
+ * either show English or keep a second copy of these rules — and a second copy
62
+ * is how the two surfaces come to disagree about what a legal name is.
63
+ */
64
+ export type ProjectPathIssue =
65
+ | { code: 'required' }
66
+ | { code: 'tooLong'; path: string; max: number }
67
+ | { code: 'charset'; path: string }
68
+ | { code: 'dots'; path: string }
69
+ | { code: 'extension'; path: string }
70
+ | { code: 'reserved'; path: string }
71
+ | { code: 'taken'; path: string };
72
+
73
+ /**
74
+ * Check ONE path against every rule a stored file has to satisfy.
75
+ *
76
+ * Split out of the set-level check below so the dashboard's file explorer can
77
+ * apply the identical rules as someone types a name, rather than letting them
78
+ * finish, click deploy, and read the same sentence back as a 400. There is one
79
+ * definition of what a legal path is, and both surfaces call it.
80
+ *
81
+ * `taken` is the paths already in the project, compared case-insensitively —
82
+ * pass the file being renamed's own path in it and the rename reports a
83
+ * collision with itself, so callers exclude it.
84
+ */
85
+ export const projectPathIssue = (
86
+ path: string,
87
+ taken: Iterable<string> = []
88
+ ): ProjectPathIssue | null => {
89
+ if (!path) return { code: 'required' };
90
+ if (path.length > constants.CUSTOM_CODE_MAX_FILE_PATH) {
91
+ return {
92
+ code: 'tooLong',
93
+ path,
94
+ max: constants.CUSTOM_CODE_MAX_FILE_PATH
95
+ };
96
+ }
97
+ if (!/^[a-zA-Z0-9._-]+(\/[a-zA-Z0-9._-]+)*$/.test(path)) {
98
+ return { code: 'charset', path };
99
+ }
100
+ if (path.split('/').some(segment => segment === '.' || segment === '..')) {
101
+ return { code: 'dots', path };
102
+ }
103
+ if (!path.endsWith('.js')) {
104
+ return { code: 'extension', path };
105
+ }
106
+ if (path === constants.CUSTOM_CODE_SDK_MODULE) {
107
+ return { code: 'reserved', path };
108
+ }
109
+ // Case-insensitively, because two files differing only in case are one file
110
+ // on the machine of whoever downloads them next.
111
+ const lower = path.toLowerCase();
112
+ for (const other of taken) {
113
+ if (other.toLowerCase() === lower) {
114
+ return { code: 'taken', path };
115
+ }
116
+ }
117
+ return null;
118
+ };
119
+
120
+ /**
121
+ * The same check, rendered in English — what the upload path throws and what
122
+ * lands in `error_log`. Messages carry a word `matchStatus` recognises, so they
123
+ * land as 400s rather than opaque 500s.
124
+ */
125
+ export const validateProjectPath = (
126
+ path: string,
127
+ taken: Iterable<string> = []
128
+ ): string | null => {
129
+ const issue = projectPathIssue(path, taken);
130
+ if (!issue) return null;
131
+
132
+ switch (issue.code) {
133
+ case 'required':
134
+ return 'A name is required';
135
+ case 'tooLong':
136
+ return `The path "${issue.path}" exceeds ${issue.max} characters`;
137
+ case 'charset':
138
+ return `Invalid file path "${issue.path}" — letters, digits, dot, dash, underscore and / only, with no leading or trailing slash`;
139
+ case 'dots':
140
+ return `Invalid file path "${issue.path}" — . and .. are not allowed`;
141
+ case 'extension':
142
+ return `Invalid file path "${issue.path}" — every file must end in .js, since it is deployed as a module exactly as written`;
143
+ case 'reserved':
144
+ return `Invalid file path "${issue.path}" — that name belongs to the SDK, which is attached to every deploy`;
145
+ case 'taken':
146
+ return `Invalid file path "${issue.path}" — it is already in use`;
147
+ }
148
+ };
149
+
150
+ /**
151
+ * Check a set of files before anything is stored or deployed.
152
+ *
153
+ * Paths are the module names the Workers upload API receives, and a script's
154
+ * imports resolve against them — so a path that escapes the root, collides with
155
+ * the SDK's own module, or arrives twice under different spellings is a deploy
156
+ * that fails in the runtime rather than here. Messages carry a word `matchStatus`
157
+ * recognises, so these land as 400s rather than opaque 500s.
158
+ */
159
+ export const validateProjectFiles = (files: Record<string, string>): void => {
160
+ const paths = Object.keys(files);
161
+
162
+ if (paths.length === 0) {
163
+ throw new Error('At least one file is required');
164
+ }
165
+ if (paths.length > constants.CUSTOM_CODE_MAX_FILES) {
166
+ throw new Error(
167
+ `The project exceeds the ${constants.CUSTOM_CODE_MAX_FILES}-file limit`
168
+ );
169
+ }
170
+ if (!files[constants.CUSTOM_CODE_MAIN_MODULE]) {
171
+ throw new Error(
172
+ `A file named ${constants.CUSTOM_CODE_MAIN_MODULE} is required — it is the module the dispatcher calls`
173
+ );
174
+ }
175
+
176
+ const seen = new Set<string>();
177
+ for (const path of paths) {
178
+ const problem = validateProjectPath(path, seen);
179
+ if (problem) throw new Error(problem);
180
+ seen.add(path.toLowerCase());
181
+ }
182
+ };
@@ -0,0 +1,198 @@
1
+ import {
2
+ bytesToBase64,
3
+ toBase64Url,
4
+ fromBase64Url,
5
+ base64ToBytes
6
+ } from './base64';
7
+ import { constants } from './constants';
8
+
9
+ // The credential a deployed user script uses to call the broker.
10
+ //
11
+ // A service binding carries no caller identity — the broker sees a request from
12
+ // "some script in the namespace" and nothing more — so the script has to present
13
+ // something that names it. That something is minted here at publish time,
14
+ // injected as the script's `GANJU_TOOL_TOKEN` secret binding, and verified by the
15
+ // broker on every call.
16
+ //
17
+ // It is a signed value rather than a random string checked against a table for
18
+ // one reason: revocation is already free. The token names the version it was
19
+ // minted for, and the broker refuses any token whose version is no longer the
20
+ // artifact's active one — a check it performs against a row it has to read
21
+ // anyway (the tool config, for the connection allow-list). Publishing a new
22
+ // version therefore invalidates every older token without a second write, and
23
+ // there is no token table to keep in step with the version table.
24
+ export interface CustomCodeTokenPayload {
25
+ // Token format version, so a future change to the payload can be rejected
26
+ // rather than misread.
27
+ v: string;
28
+ artifactId: string;
29
+ // The artifact_tool_version this token was minted for. Checked against
30
+ // config.activeVersionId by the broker, which is what makes rotation real.
31
+ versionId: string;
32
+ // Issued-at, seconds. Not used as an expiry — the version check is the
33
+ // lifetime — but it makes two tokens for the same version distinguishable in
34
+ // logs, and gives an operator something to reason about.
35
+ iat: number;
36
+ // Minted for a test run rather than for a published version.
37
+ //
38
+ // The distinction matters to the broker and nowhere else: a preview token
39
+ // names a version that is deliberately NOT the active one, so the check that
40
+ // gives a live token its lifetime cannot apply to it. In exchange it carries
41
+ // an expiry, which a live token does not need.
42
+ preview?: boolean;
43
+ // Expiry, seconds. Present on preview tokens, absent on live ones. Checked
44
+ // whenever present, so adding it to live tokens later needs no broker change.
45
+ exp?: number;
46
+ }
47
+
48
+ const encoder = new TextEncoder();
49
+
50
+ const importKey = (secret: string): Promise<CryptoKey> =>
51
+ crypto.subtle.importKey(
52
+ 'raw',
53
+ encoder.encode(secret),
54
+ { name: 'HMAC', hash: 'SHA-256' },
55
+ false,
56
+ ['sign', 'verify']
57
+ );
58
+
59
+ const signPayload = async (
60
+ encodedPayload: string,
61
+ secret: string
62
+ ): Promise<string> => {
63
+ const key = await importKey(secret);
64
+ const signature = await crypto.subtle.sign(
65
+ 'HMAC',
66
+ key,
67
+ encoder.encode(encodedPayload)
68
+ );
69
+ return toBase64Url(bytesToBase64(new Uint8Array(signature)));
70
+ };
71
+
72
+ /**
73
+ * Mint a tool token for one artifact + version. Called by the publish pipeline
74
+ * immediately before upload; the result is written as a `secret_text` binding so
75
+ * it never appears in the bundle the user can read back.
76
+ */
77
+ export const mintCustomCodeToken = async (
78
+ payload: Omit<CustomCodeTokenPayload, 'v' | 'iat' | 'exp'> & {
79
+ ttlMs?: number;
80
+ },
81
+ secret: string,
82
+ issuedAt: number = Date.now()
83
+ ): Promise<string> => {
84
+ const body: CustomCodeTokenPayload = {
85
+ v: constants.CUSTOM_CODE_TOKEN_VERSION,
86
+ artifactId: payload.artifactId,
87
+ versionId: payload.versionId,
88
+ iat: Math.floor(issuedAt / 1000),
89
+ ...(payload.preview ? { preview: true } : {}),
90
+ // Rounded up, not down: `exp` is in seconds, and truncating would make a
91
+ // stated ten-minute lifetime end up to a second early. A ceiling means the
92
+ // TTL is a floor, which is what the caller asked for.
93
+ ...(payload.ttlMs
94
+ ? { exp: Math.ceil((issuedAt + payload.ttlMs) / 1000) }
95
+ : {})
96
+ };
97
+ const encodedPayload = toBase64Url(
98
+ bytesToBase64(encoder.encode(JSON.stringify(body)))
99
+ );
100
+ const signature = await signPayload(encodedPayload, secret);
101
+ return `${encodedPayload}.${signature}`;
102
+ };
103
+
104
+ /**
105
+ * Verify a tool token and return its payload, or null when it is malformed, of
106
+ * an unknown format version, or not signed by this deployment's secret.
107
+ *
108
+ * Returning null rather than throwing keeps the broker's failure path uniform:
109
+ * every rejection is the same 401, so a caller learns nothing about which check
110
+ * failed.
111
+ */
112
+ export const verifyCustomCodeToken = async (
113
+ token: string,
114
+ secret: string,
115
+ now?: number
116
+ ): Promise<CustomCodeTokenPayload | null> => {
117
+ const separator = token.lastIndexOf('.');
118
+ if (separator <= 0) return null;
119
+
120
+ const encodedPayload = token.slice(0, separator);
121
+ const signature = token.slice(separator + 1);
122
+
123
+ const key = await importKey(secret);
124
+ let valid: boolean;
125
+ try {
126
+ // Copied into a fresh view so its backing store is a plain ArrayBuffer —
127
+ // base64ToBytes is typed against ArrayBufferLike, which subtle.verify's
128
+ // BufferSource parameter does not accept.
129
+ const signatureBytes = new Uint8Array(
130
+ base64ToBytes(fromBase64Url(signature))
131
+ );
132
+ valid = await crypto.subtle.verify(
133
+ 'HMAC',
134
+ key,
135
+ signatureBytes,
136
+ encoder.encode(encodedPayload)
137
+ );
138
+ } catch {
139
+ return null;
140
+ }
141
+ if (!valid) return null;
142
+
143
+ let parsed: unknown;
144
+ try {
145
+ parsed = JSON.parse(
146
+ new TextDecoder().decode(base64ToBytes(fromBase64Url(encodedPayload)))
147
+ );
148
+ } catch {
149
+ return null;
150
+ }
151
+
152
+ if (!parsed || typeof parsed !== 'object') return null;
153
+ const candidate = parsed as Partial<CustomCodeTokenPayload>;
154
+ if (candidate.v !== constants.CUSTOM_CODE_TOKEN_VERSION) return null;
155
+ if (typeof candidate.artifactId !== 'string' || !candidate.artifactId) {
156
+ return null;
157
+ }
158
+ if (typeof candidate.versionId !== 'string' || !candidate.versionId) {
159
+ return null;
160
+ }
161
+
162
+ // Checked here rather than in the broker so every caller gets it: a token past
163
+ // its expiry is as good as unsigned.
164
+ if (
165
+ typeof candidate.exp === 'number' &&
166
+ candidate.exp * 1000 <= (now ?? Date.now())
167
+ ) {
168
+ return null;
169
+ }
170
+
171
+ return {
172
+ v: candidate.v,
173
+ artifactId: candidate.artifactId,
174
+ versionId: candidate.versionId,
175
+ iat: typeof candidate.iat === 'number' ? candidate.iat : 0,
176
+ ...(candidate.preview === true ? { preview: true } : {}),
177
+ ...(typeof candidate.exp === 'number' ? { exp: candidate.exp } : {})
178
+ };
179
+ };
180
+
181
+ /**
182
+ * The dispatch-namespace script name for an artifact: `artifact_<id>`.
183
+ *
184
+ * The id, never the slug — slugs are user-editable and a rename would orphan the
185
+ * deployed script while the database still pointed at a live version.
186
+ */
187
+ export const customCodeScriptName = (artifactId: string): string =>
188
+ `${constants.CUSTOM_CODE_SCRIPT_NAME_PREFIX}${artifactId}`;
189
+
190
+ /**
191
+ * The script name a test run deploys into: `artifact_<id>_preview`.
192
+ *
193
+ * A second script rather than a second version of the live one, because a test
194
+ * must not be able to disturb what MCP clients are being served — and the only
195
+ * way to be certain of that is for it to run under a name nothing dispatches to.
196
+ */
197
+ export const customCodePreviewScriptName = (artifactId: string): string =>
198
+ `${customCodeScriptName(artifactId)}${constants.CUSTOM_CODE_PREVIEW_SCRIPT_SUFFIX}`;
@@ -0,0 +1,43 @@
1
+ // Bound how long we wait for a dispatched user script, without handing the
2
+ // binding an AbortSignal.
3
+ //
4
+ // The obvious version — `fetch(url, { signal })` on a Fetcher from a dispatch
5
+ // namespace — works when the binding is in the same process and fails when it is
6
+ // not: `AbortSignal serialization is not enabled`. A local `wrangler dev` proxies
7
+ // the dispatch namespace to the remote account, so every test run and every tool
8
+ // call raised that instead of running, while the deployed Worker was fine. A
9
+ // timeout that only works in production is worse than no timeout, because it
10
+ // breaks the environment where people are actually writing the code.
11
+ //
12
+ // What is lost by racing instead of aborting: the isolate keeps running after we
13
+ // stop waiting for it. That is what the per-script `limits.cpu_ms` ceiling is
14
+ // for, and this deadline was never a cost control — it bounds how long a person
15
+ // watches a spinner.
16
+ export class DeadlineError extends Error {
17
+ constructor(ms: number) {
18
+ super(`Timed out after ${ms}ms`);
19
+ // Named the way an aborted fetch is, so a call site that already branches on
20
+ // the name keeps reading the same way.
21
+ this.name = 'TimeoutError';
22
+ }
23
+ }
24
+
25
+ export const withDeadline = async <T>(
26
+ work: Promise<T>,
27
+ ms: number
28
+ ): Promise<T> => {
29
+ let timer: ReturnType<typeof setTimeout> | undefined;
30
+ try {
31
+ return await Promise.race([
32
+ work,
33
+ new Promise<never>((_, reject) => {
34
+ timer = setTimeout(() => reject(new DeadlineError(ms)), ms);
35
+ })
36
+ ]);
37
+ } finally {
38
+ if (timer !== undefined) clearTimeout(timer);
39
+ }
40
+ };
41
+
42
+ export const isDeadlineError = (error: unknown): boolean =>
43
+ error instanceof Error && error.name === 'TimeoutError';
@@ -0,0 +1,41 @@
1
+ // Wire protocol shared between the worker (which constructs the request) and the
2
+ // resource-handler container (which parses it and calls the Discord API). The
3
+ // worker sends a multipart/form-data POST with one `metadata` field (JSON
4
+ // matching DiscordSendRequest) and exactly one `file` field (binary). Discord
5
+ // uploads a file by posting a `payload_json` + `files[0]` multipart to
6
+ // POST /channels/{channelId}/messages.
7
+
8
+ export interface DiscordSendRequest {
9
+ botToken: string;
10
+ // Discord channel id (a DM channel or a guild text channel) — Discord replies
11
+ // are posted into the same channel the message arrived in.
12
+ channelId: string;
13
+ // Optional message id to reply to (Discord message_reference).
14
+ replyToMessageId?: string;
15
+ // Text posted alongside the file (Discord has no separate "caption" — it's the
16
+ // message content, capped at 2000 chars by the caller).
17
+ content?: string;
18
+ }
19
+
20
+ export interface DiscordSendResponse {
21
+ ok: boolean;
22
+ id?: string;
23
+ error?: string;
24
+ }
25
+
26
+ // Wire protocol for sending a PROXIED (remote MCP) resource as a Discord file.
27
+ // Mirrors TelegramSendRemoteResourceRequest / SlackSendRemoteResourceRequest:
28
+ // the worker sends only the remote connection details + resolved auth header as
29
+ // JSON, and the resource-handler container does the remote read + decode + upload
30
+ // itself — so a large file's bytes never transit the 128 MiB worker.
31
+ export interface DiscordSendRemoteResourceRequest {
32
+ discord: DiscordSendRequest;
33
+ remote: {
34
+ url: string;
35
+ transport: string;
36
+ // Single header injected on the remote MCP connection (e.g. Authorization).
37
+ authHeader?: { name: string; value: string } | null;
38
+ uri: string;
39
+ timeoutMs: number;
40
+ };
41
+ }
@@ -0,0 +1,4 @@
1
+ import { constants } from './constants';
2
+
3
+ export const isEmbeddableMimeType = (mimeType: string): boolean =>
4
+ (constants.EMBEDDABLE_MIME_TYPES as readonly string[]).includes(mimeType);
@@ -0,0 +1,40 @@
1
+ import { constants } from './constants';
2
+
3
+ // Which of an artifact's resources are actually addressable.
4
+ //
5
+ // Two source types produce rows that exist to hold structure rather than
6
+ // content, and neither should ever be handed to a caller:
7
+ //
8
+ // - a WEBSITE seed (the row with no parent) is the crawl's starting point. The
9
+ // page at that URL is indexed separately as its child, so the seed and the
10
+ // page carry the SAME uri while only the child has content.
11
+ // - a Drive/OneDrive folder is a pure reference whose content lives in its
12
+ // children.
13
+ //
14
+ // The seed case is why this has to be shared rather than reimplemented. Applied
15
+ // in one place and not another, a lookup by uri can return the seed in one code
16
+ // path and the page in another — the same uri on the same artifact answering
17
+ // "146 characters of text" and "no inline content and no file in storage"
18
+ // depending on which surface asked.
19
+ export interface ExposableResource {
20
+ sourceType?: string | null;
21
+ parentResourceId?: string | null;
22
+ }
23
+
24
+ export const isExposedResource = (resource: ExposableResource): boolean => {
25
+ if (
26
+ resource.sourceType === constants.RESOURCE_SOURCE_TYPE_WEBSITE &&
27
+ !resource.parentResourceId
28
+ ) {
29
+ return false;
30
+ }
31
+ if (
32
+ resource.sourceType === constants.RESOURCE_SOURCE_TYPE_GOOGLE_DRIVE_FOLDER
33
+ ) {
34
+ return false;
35
+ }
36
+ if (resource.sourceType === constants.RESOURCE_SOURCE_TYPE_ONE_DRIVE_FOLDER) {
37
+ return false;
38
+ }
39
+ return true;
40
+ };
@@ -0,0 +1,62 @@
1
+ export interface ExtractedDocumentSource {
2
+ fileName?: string;
3
+ mimeType: string;
4
+ sizeBytes: number;
5
+ }
6
+
7
+ export interface ExtractedDocumentMetadata {
8
+ loc: {
9
+ pageNumber: number;
10
+ totalPages: number;
11
+ sheetName?: string;
12
+ slideTitle?: string;
13
+ };
14
+ source: ExtractedDocumentSource;
15
+ pdf?: {
16
+ info?: Record<string, unknown>;
17
+ pdfMetadata?: Record<string, unknown> | null;
18
+ };
19
+ docx?: {
20
+ coreProps?: Record<string, string>;
21
+ appProps?: Record<string, string>;
22
+ };
23
+ xlsx?: {
24
+ sheetIndex: number;
25
+ range?: string;
26
+ rowCount?: number;
27
+ columnCount?: number;
28
+ workbookProps?: Record<string, unknown>;
29
+ };
30
+ pptx?: {
31
+ coreProps?: Record<string, string>;
32
+ appProps?: Record<string, string>;
33
+ notes?: string;
34
+ };
35
+ web?: {
36
+ url: string;
37
+ canonicalUrl?: string;
38
+ title?: string;
39
+ description?: string;
40
+ siteName?: string;
41
+ author?: string;
42
+ keywords?: string[];
43
+ language?: string;
44
+ publishedAt?: string;
45
+ modifiedAt?: string;
46
+ openGraph?: Record<string, string>;
47
+ twitter?: Record<string, string>;
48
+ jsonLd?: unknown[];
49
+ headings?: { tag: string; text: string }[];
50
+ images?: { src: string; alt?: string }[];
51
+ links?: { url: string; text?: string }[];
52
+ favicon?: string;
53
+ httpStatus?: number;
54
+ contentType?: string;
55
+ renderer: 'cheerio' | 'playwright';
56
+ };
57
+ }
58
+
59
+ export interface ExtractedDocument {
60
+ pageContent: string;
61
+ metadata: ExtractedDocumentMetadata;
62
+ }