@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
@@ -0,0 +1,111 @@
1
+ import * as z from 'zod';
2
+
3
+ export type JsonSchemaProperty = {
4
+ type: 'string' | 'number' | 'boolean' | 'object' | 'array';
5
+ description?: string;
6
+ minimum?: number;
7
+ maximum?: number;
8
+ minLength?: number;
9
+ maxLength?: number;
10
+ pattern?: string;
11
+ enum?: string[];
12
+ items?: JsonSchemaProperty;
13
+ };
14
+
15
+ export type JsonSchema = {
16
+ type: 'object';
17
+ properties?: Record<string, JsonSchemaProperty>;
18
+ required?: string[];
19
+ };
20
+
21
+ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
22
+ let field: z.ZodTypeAny;
23
+
24
+ switch (prop.type) {
25
+ case 'string': {
26
+ if (prop.enum) {
27
+ field = z.enum(prop.enum as [string, ...string[]]);
28
+ } else {
29
+ let str = z.string();
30
+ if (prop.minLength !== undefined) str = str.min(prop.minLength);
31
+ if (prop.maxLength !== undefined) str = str.max(prop.maxLength);
32
+ if (prop.pattern !== undefined)
33
+ str = str.regex(new RegExp(prop.pattern));
34
+ field = str;
35
+ }
36
+ break;
37
+ }
38
+ case 'number': {
39
+ let num = z.number();
40
+ if (prop.minimum !== undefined) num = num.min(prop.minimum);
41
+ if (prop.maximum !== undefined) num = num.max(prop.maximum);
42
+ field = num;
43
+ break;
44
+ }
45
+ case 'boolean':
46
+ field = z.boolean();
47
+ break;
48
+ case 'array':
49
+ field = z.array(prop.items ? propertyToZod(prop.items) : z.any());
50
+ break;
51
+ case 'object':
52
+ field = z.record(z.string(), z.any());
53
+ break;
54
+ default:
55
+ field = z.any();
56
+ }
57
+
58
+ if (prop.description) {
59
+ field = field.describe(prop.description);
60
+ }
61
+
62
+ return field;
63
+ };
64
+
65
+ export const jsonSchemaToZodShape = (
66
+ schema: JsonSchema
67
+ ): Record<string, z.ZodTypeAny> => {
68
+ const properties = schema.properties ?? {};
69
+ const required = schema.required ?? [];
70
+
71
+ const shape: Record<string, z.ZodTypeAny> = {};
72
+
73
+ for (const [key, prop] of Object.entries(properties)) {
74
+ const field = propertyToZod(prop);
75
+ shape[key] = required.includes(key) ? field : field.optional();
76
+ }
77
+
78
+ return shape;
79
+ };
80
+
81
+ export interface SchemaViolation {
82
+ // Dotted path to the offending field, empty for the value as a whole.
83
+ path: string;
84
+ message: string;
85
+ }
86
+
87
+ /**
88
+ * Check a value against a declared JSON schema, returning the violations.
89
+ *
90
+ * The same compiler the MCP boot loop registers tools with, pointed at a value
91
+ * instead of at a tool definition — so what the dashboard's Test panel calls
92
+ * invalid is exactly what an MCP client would have refused to send. Written here
93
+ * rather than in apps/api because zod lives here, and a second copy of this
94
+ * would be a second opinion about what "valid" means.
95
+ *
96
+ * Unknown keys are allowed through: a JSON Schema without
97
+ * `additionalProperties: false` does not forbid them, and refusing an extra
98
+ * field on a test input that a real client would have passed along would make
99
+ * the panel lie.
100
+ */
101
+ export const validateAgainstJsonSchema = (
102
+ schema: JsonSchema,
103
+ value: unknown
104
+ ): SchemaViolation[] => {
105
+ const result = z.object(jsonSchemaToZodShape(schema)).safeParse(value ?? {});
106
+ if (result.success) return [];
107
+ return result.error.issues.map(issue => ({
108
+ path: issue.path.join('.'),
109
+ message: issue.message
110
+ }));
111
+ };
@@ -0,0 +1,19 @@
1
+ // Scale a vector to unit length.
2
+ //
3
+ // Needed because `gemini-embedding-001` only returns a normalised vector at its
4
+ // full 3072 dimensions. Ask for fewer via `outputDimensionality` — as we do, to
5
+ // halve storage — and the values come back UNNORMALISED, because truncating a
6
+ // unit vector leaves it shorter than 1. Cosine distance in pgvector assumes
7
+ // nothing about magnitude, but our stored vectors and query vectors have to be
8
+ // treated identically, and an unnormalised mix silently skews ranking rather
9
+ // than failing. So both the ingest path and the query path normalise here.
10
+ //
11
+ // Returns the input unchanged when the magnitude is zero, which would otherwise
12
+ // divide by zero and produce NaNs that poison the whole index.
13
+ export const l2Normalize = (values: number[]): number[] => {
14
+ let sum = 0;
15
+ for (const v of values) sum += v * v;
16
+ const magnitude = Math.sqrt(sum);
17
+ if (!magnitude || !Number.isFinite(magnitude)) return values;
18
+ return values.map(v => v / magnitude);
19
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Cookie `Domain` for the remembered language, derived from the host serving
3
+ * the request.
4
+ *
5
+ * `ganju.ai` and `app.ganju.ai` are two hosts, so a host-only cookie set by one
6
+ * is invisible to the other — someone who explicitly picks English on the
7
+ * marketing site would sign in and be handed Spanish again by the country
8
+ * lookup, which defeats the point of remembering the choice. Scoping the cookie
9
+ * to `.ganju.ai` makes one click cover both.
10
+ *
11
+ * Derived from the host rather than configured, because the two surfaces deploy
12
+ * separately and a value set in one place would silently rot in the other. The
13
+ * cost is the assumption below.
14
+ *
15
+ * **Assumes a single-label public suffix** — `ganju.ai`, `vocesqueabrazan.com`.
16
+ * A deploy under a multi-part suffix (`example.co.uk`) would compute `.co.uk`,
17
+ * which browsers reject, and the cookie would quietly go nowhere. Add that
18
+ * suffix here if it ever happens.
19
+ */
20
+ export const languageCookieDomain = (hostname: string): string | undefined => {
21
+ const labels = hostname.split('.');
22
+
23
+ // `localhost`, and anything else with nothing to share with.
24
+ if (labels.length < 2) return undefined;
25
+
26
+ // A bare IP has no domain to scope to.
27
+ if (/^\d+$/.test(labels[labels.length - 1])) return undefined;
28
+
29
+ const apex = labels.slice(-2).join('.');
30
+
31
+ // Cloudflare's preview hosts are a public suffix: every Pages project on the
32
+ // planet shares them, so browsers refuse a cookie scoped there. Falling back
33
+ // to host-only keeps previews working on their own.
34
+ if (apex === 'pages.dev' || apex === 'workers.dev') return undefined;
35
+
36
+ return `.${apex}`;
37
+ };
@@ -0,0 +1,124 @@
1
+ import esLocale from 'zod/v4/locales/es.js';
2
+
3
+ import { constants } from './constants';
4
+
5
+ /**
6
+ * Re-render a Zod validation issue in the reader's language.
7
+ *
8
+ * Zod already ships a complete Spanish locale, so all ~305 built-in validators
9
+ * in `schema.ts` are covered without hand-translating anything. The trick is
10
+ * *where* to apply it. There are ~100 parse call sites across the three
11
+ * services; threading a per-parse error map through all of them is a hundred
12
+ * chances to miss one. Instead this runs once, in `handleError`'s ZodError
13
+ * branch, on the way out.
14
+ *
15
+ * That works because a Zod 4 issue keeps its structured fields — `code`,
16
+ * `origin`, `minimum`, `divisor`, `keys` — alongside the rendered `message`,
17
+ * and a locale error map is exactly a function from that structured issue to a
18
+ * string. So the English message Zod produced at parse time can be thrown away
19
+ * and rebuilt in Spanish at catch time.
20
+ *
21
+ * **Do not reach for `z.config({ localeError })` instead.** It mutates a
22
+ * module-level global (`zod/src/v4/core/config.ts`), and a Workers isolate
23
+ * serves concurrent requests in different languages — one request would set the
24
+ * locale for whatever else happened to be in flight. Nothing here holds state.
25
+ *
26
+ * What lands in `error_log` is untouched: `handleError` persists `err.message`,
27
+ * which is still Zod's own English dump from parse time. Nothing about
28
+ * persistence, and no migration, is involved.
29
+ */
30
+
31
+ /** Minimal shape of the issue we need; Zod's own type lives in `zod`. */
32
+ export interface ZodIssueLike {
33
+ code?: string;
34
+ message: string;
35
+ path?: PropertyKey[];
36
+ }
37
+
38
+ type LocaleError = (issue: never) => string | { message: string };
39
+
40
+ /**
41
+ * Messages we wrote ourselves. Zod renders a custom `message:` verbatim, so the
42
+ * locale map never sees them — they arrive here as the exact English string,
43
+ * which is what these keys are. Keying on our own text rather than on a code
44
+ * keeps `schema.ts` free of translation concerns, and keeps the English in
45
+ * `error_log` identical to what it has always been.
46
+ */
47
+ const CUSTOM_MESSAGES: Record<string, Record<string, string>> = {
48
+ 'This title is reserved as a bot command': {
49
+ es: 'Este título está reservado como comando del bot'
50
+ },
51
+ 'Enter a valid email address': {
52
+ es: 'Escribe un correo electrónico válido'
53
+ },
54
+ 'Unsupported mime type': {
55
+ es: 'Tipo de archivo no admitido'
56
+ },
57
+ 'Slug is reserved': {
58
+ es: 'Ese slug está reservado'
59
+ },
60
+ [constants.RESERVED_TOOL_NAME_MESSAGE]: {
61
+ es: 'Nombre de herramienta no válido — reservado por la plataforma'
62
+ },
63
+ [constants.CUSTOM_CODE_UNKNOWN_CONNECTION_MESSAGE]: {
64
+ es: 'Conexión no válida — no existe un proveedor gestionado con ese nombre'
65
+ },
66
+ [constants.OUTPUT_SCHEMA_NOT_OBJECT_MESSAGE]: {
67
+ es: 'Esquema de salida no válido — debe describir un objeto'
68
+ },
69
+ [constants.CUSTOM_CODE_RESOURCE_PAYLOAD_MESSAGE]: {
70
+ es: 'Envía exactamente uno de content (texto) o bytes (base64)'
71
+ },
72
+ [constants.CUSTOM_CODE_RESOURCE_TEXT_TOO_LARGE_MESSAGE]: {
73
+ es: `El contenido en línea supera el límite de ${constants.CUSTOM_CODE_MAX_RESOURCE_TEXT_BYTES / (1024 * 1024)}MB`
74
+ },
75
+ [constants.CUSTOM_CODE_RESOURCE_FILE_TOO_LARGE_MESSAGE]: {
76
+ es: `Los bytes del archivo superan el límite de ${constants.CUSTOM_CODE_MAX_RESOURCE_FILE_BYTES / (1024 * 1024)}MB`
77
+ }
78
+ };
79
+
80
+ // Built once. `esLocale()` allocates its dictionaries on every call, and this
81
+ // runs per issue, per failed request.
82
+ const LOCALE_ERRORS: Record<string, LocaleError> = {
83
+ [constants.LANGUAGE_ES]: esLocale().localeError as LocaleError
84
+ };
85
+
86
+ /**
87
+ * Narrow an `Accept-Language` header to a language we ship.
88
+ *
89
+ * `fetcher` sends the app's own locale rather than the browser's list, so this
90
+ * is normally a bare `en` or `es` — but the header is public, and a browser
91
+ * hitting the API directly will send the full `es-CO,es;q=0.9,en;q=0.8` form.
92
+ * Take the first tag and match on its primary subtag.
93
+ */
94
+ export const languageFromHeader = (header?: string | null): string => {
95
+ const tag = header?.split(',')[0]?.trim().toLowerCase() ?? '';
96
+ return tag.startsWith(constants.LANGUAGE_ES)
97
+ ? constants.LANGUAGE_ES
98
+ : constants.LANGUAGE_EN;
99
+ };
100
+
101
+ /** The issue's message in `language`, or its original English. */
102
+ export const localizeZodIssue = (
103
+ issue: ZodIssueLike,
104
+ language: string
105
+ ): string => {
106
+ if (language === constants.LANGUAGE_EN) return issue.message;
107
+
108
+ const custom = CUSTOM_MESSAGES[issue.message]?.[language];
109
+ if (custom) return custom;
110
+
111
+ const localeError = LOCALE_ERRORS[language];
112
+ if (!localeError) return issue.message;
113
+
114
+ try {
115
+ const rendered = localeError(issue as never);
116
+ const message = typeof rendered === 'string' ? rendered : rendered?.message;
117
+ // A locale map may decline an issue it does not recognise; English is a
118
+ // better answer than an empty string.
119
+ return message || issue.message;
120
+ } catch {
121
+ // Never let a formatting failure turn a 400 into a 500.
122
+ return issue.message;
123
+ }
124
+ };
@@ -0,0 +1,12 @@
1
+ import { utf8ToBase64 } from './base64';
2
+
3
+ const ASCII_ONLY = /^[\x00-\x7F]*$/;
4
+
5
+ export const sanitizeMailHeader = (value: string): string =>
6
+ value.replace(/[\r\n]+/g, ' ').trim();
7
+
8
+ export const encodeRfc2047 = (value: string): string =>
9
+ ASCII_ONLY.test(value) ? value : `=?UTF-8?B?${utf8ToBase64(value)}?=`;
10
+
11
+ export const formatMailHeader = (value: string): string =>
12
+ encodeRfc2047(sanitizeMailHeader(value));
@@ -0,0 +1,20 @@
1
+ import { constants } from './constants';
2
+
3
+ // Remote MCP tool keys are untrusted (the remote chooses them). Only allow the
4
+ // charset MCP clients accept for tool names so the composed local name can't
5
+ // break registration or downstream API calls.
6
+ const REMOTE_TOOL_NAME_RE = /^[a-zA-Z0-9_-]+$/;
7
+
8
+ // Compose the local MCP tool name for a proxied remote tool (`<prefix><sep><key>`),
9
+ // or return null when the remote key can't be safely surfaced — bad charset, or
10
+ // the composed name exceeds the tool-name length limit clients enforce. Callers
11
+ // skip-and-log nulls rather than registering an unusable tool.
12
+ export const buildProxyToolName = (
13
+ prefix: string,
14
+ remoteName: string
15
+ ): string | null => {
16
+ if (!remoteName || !REMOTE_TOOL_NAME_RE.test(remoteName)) return null;
17
+ const name = `${prefix}${constants.MCP_PROXY_TOOL_NAME_SEP}${remoteName}`;
18
+ if (name.length > constants.MCP_PROXY_TOOL_NAME_MAX) return null;
19
+ return name;
20
+ };
@@ -0,0 +1,82 @@
1
+ import { sanitizeMailHeader, formatMailHeader } from './mailHeaders';
2
+
3
+ export interface MimeAttachment {
4
+ filename: string;
5
+ mimeType: string;
6
+ base64: string;
7
+ }
8
+
9
+ export interface MimeMessageInput {
10
+ to: string;
11
+ subject: string;
12
+ body: string;
13
+ cc?: string;
14
+ bcc?: string;
15
+ inReplyTo?: string;
16
+ references?: string;
17
+ contentType?: 'text/html' | 'text/plain';
18
+ attachments?: MimeAttachment[];
19
+ }
20
+
21
+ export const sanitizeFilename = (name: string): string => {
22
+ const cleaned = name.replace(/[\r\n"]/g, '').trim();
23
+ return cleaned || 'attachment';
24
+ };
25
+
26
+ export const formatFilenameHeader = (name: string): string => {
27
+ const safe = sanitizeFilename(name);
28
+ if (/^[\x20-\x7E]+$/.test(safe)) {
29
+ return `filename="${safe.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
30
+ }
31
+ return `filename*=UTF-8''${encodeURIComponent(safe)}`;
32
+ };
33
+
34
+ export const chunkBase64 = (b64: string): string =>
35
+ b64.replace(/(.{76})/g, '$1\r\n');
36
+
37
+ export const buildMimeMessage = (input: MimeMessageInput): string => {
38
+ const headers: string[] = [];
39
+ headers.push(`To: ${formatMailHeader(input.to)}`);
40
+ if (input.cc) headers.push(`Cc: ${formatMailHeader(input.cc)}`);
41
+ if (input.bcc) headers.push(`Bcc: ${formatMailHeader(input.bcc)}`);
42
+ headers.push(`Subject: ${formatMailHeader(input.subject)}`);
43
+ if (input.inReplyTo)
44
+ headers.push(`In-Reply-To: ${sanitizeMailHeader(input.inReplyTo)}`);
45
+ if (input.references)
46
+ headers.push(`References: ${sanitizeMailHeader(input.references)}`);
47
+ headers.push('MIME-Version: 1.0');
48
+
49
+ const bodyContentType = input.contentType || 'text/html';
50
+
51
+ if (!input.attachments || input.attachments.length === 0) {
52
+ headers.push(`Content-Type: ${bodyContentType}; charset=utf-8`);
53
+ return `${headers.join('\r\n')}\r\n\r\n${input.body}`;
54
+ }
55
+
56
+ const boundary = `_ganju_${crypto.randomUUID().replace(/-/g, '')}`;
57
+ headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
58
+
59
+ const parts: string[] = [headers.join('\r\n'), ''];
60
+ parts.push(`--${boundary}`);
61
+ parts.push(`Content-Type: ${bodyContentType}; charset=utf-8`);
62
+ parts.push('Content-Transfer-Encoding: 7bit');
63
+ parts.push('');
64
+ parts.push(input.body);
65
+
66
+ for (const att of input.attachments) {
67
+ const safeName = sanitizeFilename(att.filename);
68
+ parts.push(`--${boundary}`);
69
+ parts.push(
70
+ `Content-Type: ${att.mimeType}; name="${safeName.replace(/"/g, '\\"')}"`
71
+ );
72
+ parts.push('Content-Transfer-Encoding: base64');
73
+ parts.push(
74
+ `Content-Disposition: attachment; ${formatFilenameHeader(att.filename)}`
75
+ );
76
+ parts.push('');
77
+ parts.push(chunkBase64(att.base64));
78
+ }
79
+
80
+ parts.push(`--${boundary}--`);
81
+ return parts.join('\r\n');
82
+ };
package/src/oauth.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { constants } from './constants';
2
+
3
+ export interface RefreshOAuthTokenInput {
4
+ tokenUrl: string;
5
+ clientId: string;
6
+ clientSecret: string;
7
+ refreshToken: string;
8
+ }
9
+
10
+ export interface RefreshedOAuthToken {
11
+ accessToken: string;
12
+ refreshToken?: string;
13
+ expiresIn?: number;
14
+ scope?: string;
15
+ }
16
+
17
+ export class OAuthReauthRequiredError extends Error {
18
+ readonly code: string;
19
+
20
+ constructor(code: string) {
21
+ super(`OAuth reauth required (${code})`);
22
+ this.name = 'OAuthReauthRequiredError';
23
+ this.code = code;
24
+ }
25
+ }
26
+
27
+ const parseErrorCode = (body: string): string | undefined => {
28
+ try {
29
+ return (JSON.parse(body) as { error?: string })?.error;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ };
34
+
35
+ export const refreshOAuthToken = async (
36
+ input: RefreshOAuthTokenInput
37
+ ): Promise<RefreshedOAuthToken> => {
38
+ const response = await fetch(input.tokenUrl, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
41
+ body: new URLSearchParams({
42
+ client_id: input.clientId,
43
+ client_secret: input.clientSecret,
44
+ grant_type: 'refresh_token',
45
+ refresh_token: input.refreshToken
46
+ })
47
+ });
48
+
49
+ if (!response.ok) {
50
+ const body = await response.text();
51
+ const code = parseErrorCode(body);
52
+ if (code && constants.REAUTH_ERROR_CODES.includes(code)) {
53
+ throw new OAuthReauthRequiredError(code);
54
+ }
55
+ throw new Error(`oauth refresh failed (${response.status}): ${body}`);
56
+ }
57
+
58
+ const tokens = (await response.json()) as {
59
+ access_token?: string;
60
+ refresh_token?: string;
61
+ expires_in?: number;
62
+ scope?: string;
63
+ };
64
+
65
+ if (!tokens.access_token) {
66
+ throw new Error('oauth refresh missing access_token');
67
+ }
68
+
69
+ return {
70
+ accessToken: tokens.access_token,
71
+ refreshToken: tokens.refresh_token,
72
+ expiresIn: tokens.expires_in,
73
+ scope: tokens.scope
74
+ };
75
+ };
76
+
77
+ export const buildReauthMetadata = (
78
+ previous: Record<string, unknown> | null,
79
+ reason: string
80
+ ): Record<string, unknown> => ({
81
+ ...(previous || {}),
82
+ needsReauth: true,
83
+ reauthReason: reason,
84
+ reauthAt: new Date().toISOString()
85
+ });
86
+
87
+ export const clearReauthMetadata = (
88
+ previous: Record<string, unknown> | null
89
+ ): Record<string, unknown> | null => {
90
+ if (!previous) return null;
91
+ const next: Record<string, unknown> = { ...previous };
92
+ delete next.needsReauth;
93
+ delete next.reauthReason;
94
+ delete next.reauthAt;
95
+ return Object.keys(next).length > 0 ? next : null;
96
+ };
97
+
98
+ export const isCredentialNeedingReauth = (
99
+ metadata: unknown
100
+ ): boolean => {
101
+ if (!metadata || typeof metadata !== 'object') return false;
102
+ return (metadata as Record<string, unknown>).needsReauth === true;
103
+ };
@@ -0,0 +1,118 @@
1
+ import { constants } from './constants';
2
+
3
+ export interface OAuthProviderConfig {
4
+ authUrl: string;
5
+ tokenUrl: string;
6
+ clientIdEnv: string;
7
+ clientSecretEnv: string;
8
+ defaultScopes: string[];
9
+ // Slack distinguishes the bot scope bucket (`scope=`) from the user scope
10
+ // bucket (`user_scope=`). Default is 'scope'; the slack-user provider sets
11
+ // this to 'user_scope' so its scopes are requested in the user bucket.
12
+ scopeParam?: 'scope' | 'user_scope';
13
+ // For providers (Slack user flow) whose token-exchange response carries
14
+ // the credential under `authed_user` instead of at top level. Default is
15
+ // 'top'.
16
+ tokenSource?: 'top' | 'authed_user';
17
+ // Slack's oauth.v2.access endpoint returns `internal_error` when the
18
+ // standard OAuth2 `grant_type=authorization_code` body param is included
19
+ // on the code-exchange call. Set true to omit it. Refresh-token requests
20
+ // still send `grant_type=refresh_token` (Slack does document that one).
21
+ omitGrantType?: boolean;
22
+ }
23
+
24
+ // The managed OAuth apps we run. This lives in @ganju/utils rather than in
25
+ // apps/api because three workers now need it: apps/api (the authorization flow),
26
+ // and apps/tool-broker, which mints short-lived access tokens for user code and
27
+ // must refresh with the same client credentials against the same token URLs.
28
+ // Adding a provider in one place and not the other is exactly the drift this
29
+ // prevents.
30
+ export const oauthProviders: Record<string, OAuthProviderConfig> = {
31
+ [constants.OAUTH_PROVIDER_GOOGLE_GMAIL]: {
32
+ authUrl: constants.GOOGLE_OAUTH_AUTH_URL,
33
+ tokenUrl: constants.GOOGLE_OAUTH_TOKEN_URL,
34
+ clientIdEnv: 'GOOGLE_CLIENT_ID',
35
+ clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
36
+ defaultScopes: [
37
+ 'https://www.googleapis.com/auth/gmail.readonly',
38
+ 'https://www.googleapis.com/auth/gmail.send',
39
+ 'https://www.googleapis.com/auth/gmail.modify',
40
+ 'https://www.googleapis.com/auth/gmail.compose',
41
+ 'https://www.googleapis.com/auth/gmail.labels'
42
+ ]
43
+ },
44
+ [constants.OAUTH_PROVIDER_GOOGLE_DRIVE]: {
45
+ authUrl: constants.GOOGLE_OAUTH_AUTH_URL,
46
+ tokenUrl: constants.GOOGLE_OAUTH_TOKEN_URL,
47
+ clientIdEnv: 'GOOGLE_CLIENT_ID',
48
+ clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
49
+ defaultScopes: [
50
+ 'https://www.googleapis.com/auth/drive.readonly',
51
+ 'https://www.googleapis.com/auth/drive.metadata.readonly'
52
+ ]
53
+ },
54
+ [constants.OAUTH_PROVIDER_GOOGLE_CALENDAR]: {
55
+ authUrl: constants.GOOGLE_OAUTH_AUTH_URL,
56
+ tokenUrl: constants.GOOGLE_OAUTH_TOKEN_URL,
57
+ clientIdEnv: 'GOOGLE_CLIENT_ID',
58
+ clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
59
+ defaultScopes: [
60
+ 'https://www.googleapis.com/auth/calendar.events',
61
+ 'https://www.googleapis.com/auth/calendar.readonly'
62
+ ]
63
+ },
64
+ [constants.OAUTH_PROVIDER_MICROSOFT_OUTLOOK]: {
65
+ authUrl: constants.MICROSOFT_OAUTH_AUTH_URL,
66
+ tokenUrl: constants.MICROSOFT_OAUTH_TOKEN_URL,
67
+ clientIdEnv: 'MICROSOFT_CLIENT_ID',
68
+ clientSecretEnv: 'MICROSOFT_CLIENT_SECRET',
69
+ defaultScopes: [
70
+ 'https://graph.microsoft.com/Mail.ReadWrite',
71
+ 'https://graph.microsoft.com/Mail.Send',
72
+ 'https://graph.microsoft.com/User.Read',
73
+ 'offline_access'
74
+ ]
75
+ },
76
+ [constants.OAUTH_PROVIDER_ONE_DRIVE]: {
77
+ authUrl: constants.MICROSOFT_OAUTH_AUTH_URL,
78
+ tokenUrl: constants.MICROSOFT_OAUTH_TOKEN_URL,
79
+ clientIdEnv: 'MICROSOFT_CLIENT_ID',
80
+ clientSecretEnv: 'MICROSOFT_CLIENT_SECRET',
81
+ defaultScopes: [
82
+ 'https://graph.microsoft.com/Files.Read',
83
+ 'https://graph.microsoft.com/Files.Read.All',
84
+ 'offline_access'
85
+ ]
86
+ },
87
+ [constants.OAUTH_PROVIDER_SLACK]: {
88
+ authUrl: constants.SLACK_OAUTH_AUTH_URL,
89
+ tokenUrl: constants.SLACK_OAUTH_TOKEN_URL,
90
+ clientIdEnv: 'SLACK_CLIENT_ID',
91
+ clientSecretEnv: 'SLACK_CLIENT_SECRET',
92
+ defaultScopes: [
93
+ 'chat:write',
94
+ 'channels:read',
95
+ 'groups:read',
96
+ 'mpim:read',
97
+ 'im:read',
98
+ 'users:read',
99
+ 'users:read.email',
100
+ 'files:write'
101
+ ],
102
+ omitGrantType: true
103
+ },
104
+ [constants.OAUTH_PROVIDER_SLACK_USER]: {
105
+ authUrl: constants.SLACK_OAUTH_AUTH_URL,
106
+ tokenUrl: constants.SLACK_OAUTH_TOKEN_URL,
107
+ clientIdEnv: 'SLACK_CLIENT_ID',
108
+ clientSecretEnv: 'SLACK_CLIENT_SECRET',
109
+ // search.messages is the only Slack endpoint that needs a user (xoxp)
110
+ // token. Kept as its own provider so the OAuth flow stays generic —
111
+ // scopes are requested in the user_scope bucket, and the access_token
112
+ // is read from authed_user on callback.
113
+ defaultScopes: ['search:read'],
114
+ scopeParam: 'user_scope',
115
+ tokenSource: 'authed_user',
116
+ omitGrantType: true
117
+ }
118
+ };
@@ -0,0 +1,44 @@
1
+ // Wire protocol shared between the MCP worker (which builds the request) and
2
+ // the resource-handler container (which talks to Microsoft Graph).
3
+ //
4
+ // The worker sends a multipart/form-data POST with one `metadata` field (JSON
5
+ // matching OutlookSendRequest) and zero-or-more `attachment` fields. The
6
+ // container decides per-attachment whether to inline it in the Graph message
7
+ // JSON (≤3MB) or upload it via createUploadSession with chunked PUTs.
8
+
9
+ export type OutlookOperation =
10
+ | 'send-email'
11
+ | 'reply-email'
12
+ | 'forward-email'
13
+ | 'create-draft'
14
+ | 'update-draft';
15
+
16
+ export interface OutlookSendRequest {
17
+ accessToken: string;
18
+ operation: OutlookOperation;
19
+ body: string;
20
+ // Graph terms: 'html' or 'text'. Defaults to 'html' on the container side.
21
+ contentType?: 'html' | 'text';
22
+
23
+ // send-email / forward-email / create-draft / update-draft
24
+ to?: string;
25
+ subject?: string;
26
+ cc?: string;
27
+ bcc?: string;
28
+
29
+ // reply-email / forward-email need the source message
30
+ messageId?: string;
31
+ // reply-email
32
+ replyAll?: boolean;
33
+
34
+ // update-draft target
35
+ draftId?: string;
36
+ }
37
+
38
+ export interface OutlookSendResponse {
39
+ // Resulting message ID for send / reply / forward. Draft ID for
40
+ // create-draft / update-draft. Empty string when Graph returns 202 with
41
+ // no body (sendMail without saveToSentItems).
42
+ id: string;
43
+ conversationId?: string;
44
+ }