@corgelabs/corge 0.0.1 → 1.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 (341) hide show
  1. package/dist/ai/index.d.ts +3 -0
  2. package/dist/ai/index.d.ts.map +1 -0
  3. package/dist/ai/index.js +3 -0
  4. package/dist/ai/index.js.map +1 -0
  5. package/dist/ai/schema.d.ts +83 -0
  6. package/dist/ai/schema.d.ts.map +1 -0
  7. package/dist/ai/schema.js +19 -0
  8. package/dist/ai/schema.js.map +1 -0
  9. package/dist/ai/service.d.ts +14 -0
  10. package/dist/ai/service.d.ts.map +1 -0
  11. package/dist/ai/service.js +266 -0
  12. package/dist/ai/service.js.map +1 -0
  13. package/dist/ai/utils.d.ts +10 -0
  14. package/dist/ai/utils.d.ts.map +1 -0
  15. package/dist/ai/utils.js +27 -0
  16. package/dist/ai/utils.js.map +1 -0
  17. package/dist/attachments/index.d.ts +4 -0
  18. package/dist/attachments/index.d.ts.map +1 -0
  19. package/dist/attachments/index.js +4 -0
  20. package/dist/attachments/index.js.map +1 -0
  21. package/dist/attachments/routes.d.ts +75 -0
  22. package/dist/attachments/routes.d.ts.map +1 -0
  23. package/dist/attachments/routes.js +18 -0
  24. package/dist/attachments/routes.js.map +1 -0
  25. package/dist/attachments/schema.d.ts +61 -0
  26. package/dist/attachments/schema.d.ts.map +1 -0
  27. package/dist/attachments/schema.js +30 -0
  28. package/dist/attachments/schema.js.map +1 -0
  29. package/dist/attachments/service.d.ts +27 -0
  30. package/dist/attachments/service.d.ts.map +1 -0
  31. package/dist/attachments/service.js +224 -0
  32. package/dist/attachments/service.js.map +1 -0
  33. package/dist/attachments/utils.d.ts +9 -0
  34. package/dist/attachments/utils.d.ts.map +1 -0
  35. package/dist/attachments/utils.js +80 -0
  36. package/dist/attachments/utils.js.map +1 -0
  37. package/dist/auth/index.d.ts +4 -0
  38. package/dist/auth/index.d.ts.map +1 -0
  39. package/dist/auth/index.js +4 -0
  40. package/dist/auth/index.js.map +1 -0
  41. package/dist/auth/oidc.d.ts +25 -0
  42. package/dist/auth/oidc.d.ts.map +1 -0
  43. package/dist/auth/oidc.js +298 -0
  44. package/dist/auth/oidc.js.map +1 -0
  45. package/dist/auth/recovery.d.ts +41 -0
  46. package/dist/auth/recovery.d.ts.map +1 -0
  47. package/dist/auth/recovery.js +250 -0
  48. package/dist/auth/recovery.js.map +1 -0
  49. package/dist/auth/routes.d.ts +226 -0
  50. package/dist/auth/routes.d.ts.map +1 -0
  51. package/dist/auth/routes.js +96 -0
  52. package/dist/auth/routes.js.map +1 -0
  53. package/dist/auth/schema.d.ts +158 -0
  54. package/dist/auth/schema.d.ts.map +1 -0
  55. package/dist/auth/schema.js +82 -0
  56. package/dist/auth/schema.js.map +1 -0
  57. package/dist/auth/service.d.ts +96 -0
  58. package/dist/auth/service.d.ts.map +1 -0
  59. package/dist/auth/service.js +129 -0
  60. package/dist/auth/service.js.map +1 -0
  61. package/dist/auth/session.d.ts +51 -0
  62. package/dist/auth/session.d.ts.map +1 -0
  63. package/dist/auth/session.js +344 -0
  64. package/dist/auth/session.js.map +1 -0
  65. package/dist/auth/utils.d.ts +17 -0
  66. package/dist/auth/utils.d.ts.map +1 -0
  67. package/dist/auth/utils.js +96 -0
  68. package/dist/auth/utils.js.map +1 -0
  69. package/dist/bin/corge.js +542 -0
  70. package/dist/chat/context.d.ts +10 -0
  71. package/dist/chat/context.d.ts.map +1 -0
  72. package/dist/chat/context.js +221 -0
  73. package/dist/chat/context.js.map +1 -0
  74. package/dist/chat/index.d.ts +4 -0
  75. package/dist/chat/index.d.ts.map +1 -0
  76. package/dist/chat/index.js +4 -0
  77. package/dist/chat/index.js.map +1 -0
  78. package/dist/chat/routes.d.ts +160 -0
  79. package/dist/chat/routes.d.ts.map +1 -0
  80. package/dist/chat/routes.js +210 -0
  81. package/dist/chat/routes.js.map +1 -0
  82. package/dist/chat/schema.d.ts +285 -0
  83. package/dist/chat/schema.d.ts.map +1 -0
  84. package/dist/chat/schema.js +74 -0
  85. package/dist/chat/schema.js.map +1 -0
  86. package/dist/chat/service.d.ts +64 -0
  87. package/dist/chat/service.d.ts.map +1 -0
  88. package/dist/chat/service.js +279 -0
  89. package/dist/chat/service.js.map +1 -0
  90. package/dist/chat/stream.d.ts +53 -0
  91. package/dist/chat/stream.d.ts.map +1 -0
  92. package/dist/chat/stream.js +339 -0
  93. package/dist/chat/stream.js.map +1 -0
  94. package/dist/chat/utils.d.ts +61 -0
  95. package/dist/chat/utils.d.ts.map +1 -0
  96. package/dist/chat/utils.js +285 -0
  97. package/dist/chat/utils.js.map +1 -0
  98. package/dist/cli/builder.d.ts +6 -0
  99. package/dist/cli/builder.d.ts.map +1 -0
  100. package/dist/cli/builder.js +91 -0
  101. package/dist/cli/builder.js.map +1 -0
  102. package/dist/cli/deployer.d.ts +2 -0
  103. package/dist/cli/deployer.d.ts.map +1 -0
  104. package/dist/cli/deployer.js +152 -0
  105. package/dist/cli/deployer.js.map +1 -0
  106. package/dist/cli/dev-server.d.ts +2 -0
  107. package/dist/cli/dev-server.d.ts.map +1 -0
  108. package/dist/cli/dev-server.js +70 -0
  109. package/dist/cli/dev-server.js.map +1 -0
  110. package/dist/cli/env.d.ts +4 -0
  111. package/dist/cli/env.d.ts.map +1 -0
  112. package/dist/cli/env.js +68 -0
  113. package/dist/cli/env.js.map +1 -0
  114. package/dist/cli/init.d.ts +6 -0
  115. package/dist/cli/init.d.ts.map +1 -0
  116. package/dist/cli/init.js +97 -0
  117. package/dist/cli/init.js.map +1 -0
  118. package/dist/config.d.ts +262 -0
  119. package/dist/config.d.ts.map +1 -0
  120. package/dist/config.js +212 -0
  121. package/dist/config.js.map +1 -0
  122. package/dist/db/index.d.ts +3 -0
  123. package/dist/db/index.d.ts.map +1 -0
  124. package/dist/db/index.js +3 -0
  125. package/dist/db/index.js.map +1 -0
  126. package/dist/db/schema.d.ts +410 -0
  127. package/dist/db/schema.d.ts.map +1 -0
  128. package/dist/db/schema.js +294 -0
  129. package/dist/db/schema.js.map +1 -0
  130. package/dist/db/service.d.ts +42 -0
  131. package/dist/db/service.d.ts.map +1 -0
  132. package/dist/db/service.js +372 -0
  133. package/dist/db/service.js.map +1 -0
  134. package/dist/db/utils.d.ts +25 -0
  135. package/dist/db/utils.d.ts.map +1 -0
  136. package/dist/db/utils.js +188 -0
  137. package/dist/db/utils.js.map +1 -0
  138. package/dist/emails/index.d.ts +4 -0
  139. package/dist/emails/index.d.ts.map +1 -0
  140. package/dist/emails/index.js +4 -0
  141. package/dist/emails/index.js.map +1 -0
  142. package/dist/emails/routes.d.ts +191 -0
  143. package/dist/emails/routes.d.ts.map +1 -0
  144. package/dist/emails/routes.js +39 -0
  145. package/dist/emails/routes.js.map +1 -0
  146. package/dist/emails/schema.d.ts +237 -0
  147. package/dist/emails/schema.d.ts.map +1 -0
  148. package/dist/emails/schema.js +98 -0
  149. package/dist/emails/schema.js.map +1 -0
  150. package/dist/emails/service.d.ts +22 -0
  151. package/dist/emails/service.d.ts.map +1 -0
  152. package/dist/emails/service.js +337 -0
  153. package/dist/emails/service.js.map +1 -0
  154. package/dist/emails/utils.d.ts +49 -0
  155. package/dist/emails/utils.d.ts.map +1 -0
  156. package/dist/emails/utils.js +341 -0
  157. package/dist/emails/utils.js.map +1 -0
  158. package/dist/files/index.d.ts +4 -0
  159. package/dist/files/index.d.ts.map +1 -0
  160. package/dist/files/index.js +4 -0
  161. package/dist/files/index.js.map +1 -0
  162. package/dist/files/routes.d.ts +72 -0
  163. package/dist/files/routes.d.ts.map +1 -0
  164. package/dist/files/routes.js +38 -0
  165. package/dist/files/routes.js.map +1 -0
  166. package/dist/files/schema.d.ts +57 -0
  167. package/dist/files/schema.d.ts.map +1 -0
  168. package/dist/files/schema.js +39 -0
  169. package/dist/files/schema.js.map +1 -0
  170. package/dist/files/service.d.ts +27 -0
  171. package/dist/files/service.d.ts.map +1 -0
  172. package/dist/files/service.js +175 -0
  173. package/dist/files/service.js.map +1 -0
  174. package/dist/files/utils.d.ts +3 -0
  175. package/dist/files/utils.d.ts.map +1 -0
  176. package/dist/files/utils.js +63 -0
  177. package/dist/files/utils.js.map +1 -0
  178. package/dist/index.d.ts +875 -0
  179. package/dist/index.d.ts.map +1 -0
  180. package/dist/index.js +9919 -0
  181. package/dist/index.js.map +7 -0
  182. package/dist/payments/billing.d.ts +29 -0
  183. package/dist/payments/billing.d.ts.map +1 -0
  184. package/dist/payments/billing.js +378 -0
  185. package/dist/payments/billing.js.map +1 -0
  186. package/dist/payments/index.d.ts +4 -0
  187. package/dist/payments/index.d.ts.map +1 -0
  188. package/dist/payments/index.js +4 -0
  189. package/dist/payments/index.js.map +1 -0
  190. package/dist/payments/routes.d.ts +340 -0
  191. package/dist/payments/routes.d.ts.map +1 -0
  192. package/dist/payments/routes.js +56 -0
  193. package/dist/payments/routes.js.map +1 -0
  194. package/dist/payments/schema.d.ts +412 -0
  195. package/dist/payments/schema.d.ts.map +1 -0
  196. package/dist/payments/schema.js +260 -0
  197. package/dist/payments/schema.js.map +1 -0
  198. package/dist/payments/service.d.ts +31 -0
  199. package/dist/payments/service.d.ts.map +1 -0
  200. package/dist/payments/service.js +322 -0
  201. package/dist/payments/service.js.map +1 -0
  202. package/dist/payments/utils.d.ts +61 -0
  203. package/dist/payments/utils.d.ts.map +1 -0
  204. package/dist/payments/utils.js +369 -0
  205. package/dist/payments/utils.js.map +1 -0
  206. package/dist/router/guard.d.ts +19 -0
  207. package/dist/router/guard.d.ts.map +1 -0
  208. package/dist/router/guard.js +243 -0
  209. package/dist/router/guard.js.map +1 -0
  210. package/dist/router/index.d.ts +3 -0
  211. package/dist/router/index.d.ts.map +1 -0
  212. package/dist/router/index.js +3 -0
  213. package/dist/router/index.js.map +1 -0
  214. package/dist/router/schema.d.ts +103 -0
  215. package/dist/router/schema.d.ts.map +1 -0
  216. package/dist/router/schema.js +282 -0
  217. package/dist/router/schema.js.map +1 -0
  218. package/dist/router/service.d.ts +25 -0
  219. package/dist/router/service.d.ts.map +1 -0
  220. package/dist/router/service.js +339 -0
  221. package/dist/router/service.js.map +1 -0
  222. package/dist/tasks/index.d.ts +4 -0
  223. package/dist/tasks/index.d.ts.map +1 -0
  224. package/dist/tasks/index.js +4 -0
  225. package/dist/tasks/index.js.map +1 -0
  226. package/dist/tasks/routes.d.ts +54 -0
  227. package/dist/tasks/routes.d.ts.map +1 -0
  228. package/dist/tasks/routes.js +195 -0
  229. package/dist/tasks/routes.js.map +1 -0
  230. package/dist/tasks/schema.d.ts +261 -0
  231. package/dist/tasks/schema.d.ts.map +1 -0
  232. package/dist/tasks/schema.js +35 -0
  233. package/dist/tasks/schema.js.map +1 -0
  234. package/dist/tasks/service.d.ts +32 -0
  235. package/dist/tasks/service.d.ts.map +1 -0
  236. package/dist/tasks/service.js +201 -0
  237. package/dist/tasks/service.js.map +1 -0
  238. package/dist/tasks/steps.d.ts +18 -0
  239. package/dist/tasks/steps.d.ts.map +1 -0
  240. package/dist/tasks/steps.js +365 -0
  241. package/dist/tasks/steps.js.map +1 -0
  242. package/dist/tasks/utils.d.ts +75 -0
  243. package/dist/tasks/utils.d.ts.map +1 -0
  244. package/dist/tasks/utils.js +311 -0
  245. package/dist/tasks/utils.js.map +1 -0
  246. package/dist/tasks/watcher.d.ts +13 -0
  247. package/dist/tasks/watcher.d.ts.map +1 -0
  248. package/dist/tasks/watcher.js +255 -0
  249. package/dist/tasks/watcher.js.map +1 -0
  250. package/dist/tasks/worker.d.ts +28 -0
  251. package/dist/tasks/worker.d.ts.map +1 -0
  252. package/dist/tasks/worker.js +326 -0
  253. package/dist/tasks/worker.js.map +1 -0
  254. package/dist/utils/index.d.ts +3 -0
  255. package/dist/utils/index.d.ts.map +1 -0
  256. package/dist/utils/index.js +3 -0
  257. package/dist/utils/index.js.map +1 -0
  258. package/dist/utils/schema.d.ts +79 -0
  259. package/dist/utils/schema.d.ts.map +1 -0
  260. package/dist/utils/schema.js +41 -0
  261. package/dist/utils/schema.js.map +1 -0
  262. package/dist/utils/service.d.ts +32 -0
  263. package/dist/utils/service.d.ts.map +1 -0
  264. package/dist/utils/service.js +384 -0
  265. package/dist/utils/service.js.map +1 -0
  266. package/docs/SDK_SPEC.md +1645 -0
  267. package/infra/firestore.base.indexes.json +138 -0
  268. package/infra/firestore.base.rules +152 -0
  269. package/infra/storage.base.rules +8 -0
  270. package/package.json +58 -13
  271. package/src/ai/index.ts +2 -0
  272. package/src/ai/schema.ts +64 -0
  273. package/src/ai/service.ts +317 -0
  274. package/src/ai/utils.ts +38 -0
  275. package/src/attachments/index.ts +3 -0
  276. package/src/attachments/routes.ts +28 -0
  277. package/src/attachments/schema.ts +45 -0
  278. package/src/attachments/service.ts +315 -0
  279. package/src/attachments/utils.ts +115 -0
  280. package/src/auth/index.ts +3 -0
  281. package/src/auth/oidc.ts +419 -0
  282. package/src/auth/recovery.ts +365 -0
  283. package/src/auth/routes.ts +142 -0
  284. package/src/auth/schema.ts +181 -0
  285. package/src/auth/service.ts +183 -0
  286. package/src/auth/session.ts +414 -0
  287. package/src/auth/utils.ts +106 -0
  288. package/src/chat/context.ts +310 -0
  289. package/src/chat/index.ts +3 -0
  290. package/src/chat/routes.ts +264 -0
  291. package/src/chat/schema.ts +295 -0
  292. package/src/chat/service.ts +360 -0
  293. package/src/chat/stream.ts +481 -0
  294. package/src/chat/utils.ts +370 -0
  295. package/src/cli/builder.ts +93 -0
  296. package/src/cli/deployer.ts +184 -0
  297. package/src/cli/dev-server.ts +77 -0
  298. package/src/cli/env.ts +89 -0
  299. package/src/cli/init.ts +121 -0
  300. package/src/config.ts +447 -0
  301. package/src/db/index.ts +2 -0
  302. package/src/db/schema.ts +409 -0
  303. package/src/db/service.ts +477 -0
  304. package/src/db/utils.ts +250 -0
  305. package/src/emails/index.ts +3 -0
  306. package/src/emails/routes.ts +64 -0
  307. package/src/emails/schema.ts +239 -0
  308. package/src/emails/service.ts +488 -0
  309. package/src/emails/utils.ts +449 -0
  310. package/src/files/index.ts +3 -0
  311. package/src/files/routes.ts +44 -0
  312. package/src/files/schema.ts +76 -0
  313. package/src/files/service.ts +238 -0
  314. package/src/files/utils.ts +77 -0
  315. package/src/index.ts +425 -0
  316. package/src/payments/billing.ts +488 -0
  317. package/src/payments/index.ts +3 -0
  318. package/src/payments/routes.ts +92 -0
  319. package/src/payments/schema.ts +401 -0
  320. package/src/payments/service.ts +445 -0
  321. package/src/payments/utils.ts +472 -0
  322. package/src/router/guard.ts +319 -0
  323. package/src/router/index.ts +2 -0
  324. package/src/router/schema.ts +458 -0
  325. package/src/router/service.ts +453 -0
  326. package/src/tasks/index.ts +11 -0
  327. package/src/tasks/routes.ts +230 -0
  328. package/src/tasks/schema.ts +310 -0
  329. package/src/tasks/service.ts +254 -0
  330. package/src/tasks/steps.ts +472 -0
  331. package/src/tasks/utils.ts +445 -0
  332. package/src/tasks/watcher.ts +307 -0
  333. package/src/tasks/worker.ts +461 -0
  334. package/src/utils/index.ts +2 -0
  335. package/src/utils/schema.ts +69 -0
  336. package/src/utils/service.ts +461 -0
  337. package/tests/e2e.test.ts +2531 -0
  338. package/tsconfig.base.json +12 -0
  339. package/README.md +0 -2
  340. package/index.d.ts +0 -1
  341. package/index.js +0 -1
@@ -0,0 +1,449 @@
1
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
2
+ import type { z } from 'zod'
3
+ import type {
4
+ AuthEmailVariables,
5
+ EmailLogStatus,
6
+ EmailProvider,
7
+ EmailsConfig,
8
+ EmailTemplateDefinition,
9
+ NormalizedEmailRequest,
10
+ ResolvedAttachment,
11
+ ResendWebhookEvent,
12
+ SendBatchEmailResult,
13
+ SendEmailResult,
14
+ SendGridWebhookEvent,
15
+ TransactionalEmailPayload,
16
+ sendEmailInputSchema
17
+ } from './schema'
18
+
19
+ export const normalizeEmail = (email: string): string => email.trim().toLowerCase()
20
+
21
+ export const suppressionDocId = (email: string): string =>
22
+ createHash('sha256').update(normalizeEmail(email)).digest('hex')
23
+
24
+ export const idempotencyEmailDocId = (idempotencyKey: string): string =>
25
+ `em_${createHash('sha256').update(idempotencyKey.trim()).digest('hex').slice(0, 40)}`
26
+
27
+ export const summarizeBatchEmailResults = (results: SendEmailResult[]): SendBatchEmailResult => ({
28
+ total: results.length,
29
+ sent: results.filter((r) => r.status === 'sent').length,
30
+ suppressed: results.filter((r) => r.status === 'suppressed').length,
31
+ failed: results.filter((r) => r.status === 'failed').length,
32
+ results
33
+ })
34
+
35
+ export const buildNormalizedEmailRequest = (
36
+ emailId: string,
37
+ toList: string[],
38
+ parsed: z.infer<typeof sendEmailInputSchema>,
39
+ tmpl: EmailTemplateDefinition<any> | undefined,
40
+ provider: EmailProvider | 'dev',
41
+ vars: Record<string, unknown>,
42
+ emailsCfg: EmailsConfig,
43
+ attachments: ResolvedAttachment[],
44
+ userId?: string
45
+ ): NormalizedEmailRequest => {
46
+ const f = resolveTemplateFields(parsed, tmpl, provider, vars, emailsCfg)
47
+ return {
48
+ emailId,
49
+ to: toList,
50
+ fromEmail: f.fromEmail,
51
+ fromName: f.fromName,
52
+ ...(f.replyTo ? { replyTo: f.replyTo } : {}),
53
+ ...(parsed.cc?.length ? { cc: parsed.cc.map(normalizeEmail) } : {}),
54
+ ...(parsed.bcc?.length ? { bcc: parsed.bcc.map(normalizeEmail) } : {}),
55
+ ...(f.subject ? { subject: f.subject } : {}),
56
+ ...(f.html ? { html: f.html } : {}),
57
+ ...(f.text ? { text: f.text } : {}),
58
+ ...(parsed.template ? { template: parsed.template } : {}),
59
+ ...(f.providerTemplateId ? { providerTemplateId: f.providerTemplateId } : {}),
60
+ variables: vars,
61
+ attachments,
62
+ ...(parsed.tags ? { tags: parsed.tags } : {}),
63
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
64
+ ...(parsed.purpose ? { purpose: parsed.purpose } : {}),
65
+ ...(userId ? { userId } : {})
66
+ }
67
+ }
68
+
69
+ const constantTimeEqual = (a: string, b: string): boolean => {
70
+ if (!a || !b) return false
71
+ const bufA = createHash('sha256').update(a).digest()
72
+ const bufB = createHash('sha256').update(b).digest()
73
+ return timingSafeEqual(bufA, bufB)
74
+ }
75
+
76
+ const getHeader = (headers: Record<string, string | undefined>, name: string): string | undefined => {
77
+ const lower = name.toLowerCase()
78
+ for (const [k, v] of Object.entries(headers)) {
79
+ if (k.toLowerCase() === lower && v) return v.trim()
80
+ }
81
+ return undefined
82
+ }
83
+
84
+ export const RESEND_STATUS_MAP: Record<string, EmailLogStatus> = {
85
+ 'email.sent': 'sent',
86
+ 'email.delivered': 'delivered',
87
+ 'email.opened': 'opened',
88
+ 'email.clicked': 'clicked',
89
+ 'email.bounced': 'bounced',
90
+ 'email.complained': 'complained'
91
+ }
92
+
93
+ export const SENDGRID_STATUS_MAP: Record<string, EmailLogStatus> = {
94
+ processed: 'sent',
95
+ delivered: 'delivered',
96
+ open: 'opened',
97
+ click: 'clicked',
98
+ bounce: 'bounced',
99
+ dropped: 'bounced',
100
+ spamreport: 'complained'
101
+ }
102
+
103
+ export const resolveTemplateFields = (
104
+ parsed: z.infer<typeof sendEmailInputSchema>,
105
+ tmpl: EmailTemplateDefinition<any> | undefined,
106
+ provider: EmailProvider | 'dev',
107
+ vars: Record<string, unknown>,
108
+ emailsCfg: EmailsConfig
109
+ ) => {
110
+ const rSpec = tmpl?.providers?.resend
111
+ const sgSpec = tmpl?.providers?.sendgrid
112
+ const providerTemplateId =
113
+ parsed.providerTemplateId ??
114
+ (provider === 'sendgrid'
115
+ ? sgSpec?.templateId
116
+ : provider === 'resend'
117
+ ? rSpec?.templateId
118
+ : rSpec?.templateId ?? sgSpec?.templateId)
119
+
120
+ const evalField = (val: string | ((v: Record<string, unknown>) => string) | undefined): string | undefined =>
121
+ typeof val === 'function' ? val(vars) : val
122
+
123
+ const subject = parsed.subject ?? evalField(rSpec?.subject) ?? evalField(tmpl?.subject)
124
+ const html = parsed.html ?? evalField(rSpec?.html) ?? evalField(tmpl?.html)
125
+ const text = parsed.text ?? evalField(rSpec?.text) ?? evalField(tmpl?.text)
126
+ const fromEmail = parsed.fromEmail ?? tmpl?.fromEmail ?? emailsCfg.fromEmail
127
+ const fromName = parsed.fromName ?? tmpl?.fromName ?? emailsCfg.fromName
128
+ const replyTo = parsed.replyTo ?? tmpl?.replyTo ?? emailsCfg.replyTo
129
+
130
+ return { providerTemplateId, subject, html, text, fromEmail, fromName, replyTo }
131
+ }
132
+
133
+ export const buildAuthActionUrl = (
134
+ baseUrl: string,
135
+ actionPath: string,
136
+ payload: TransactionalEmailPayload
137
+ ): string => {
138
+ const cleanBase = baseUrl.replace(/\/+$/, '')
139
+ const cleanPath = actionPath.startsWith('/') ? actionPath : `/${actionPath}`
140
+ const url = new URL(`${cleanBase}${cleanPath}`)
141
+ if (payload.token) url.searchParams.set('token', payload.token)
142
+ if (payload.code) url.searchParams.set('code', payload.code)
143
+ url.searchParams.set('email', payload.to)
144
+ return url.toString()
145
+ }
146
+
147
+ export const renderDefaultAuthEmail = (
148
+ vars: AuthEmailVariables,
149
+ customSubject?: string
150
+ ): { subject: string; html: string; text: string } => {
151
+ const subject = customSubject || `${vars.appName} — ${vars.purpose.replace(/_/g, ' ')}`
152
+ if (vars.purpose === 'welcome') {
153
+ return {
154
+ subject,
155
+ html: `<p>Welcome to <strong>${vars.appName}</strong>!</p><p><a href="${vars.actionUrl}">Open ${vars.appName}</a></p>`,
156
+ text: `Welcome to ${vars.appName}! Open: ${vars.actionUrl}`
157
+ }
158
+ }
159
+ return {
160
+ subject,
161
+ html: [
162
+ `<p>Your <strong>${vars.appName}</strong> verification code is:</p>`,
163
+ `<p style="font-size:24px;font-weight:bold;letter-spacing:2px">${vars.code}</p>`,
164
+ `<p>Or click the link below (expires in ${vars.expiresInMinutes} minutes):</p>`,
165
+ `<p><a href="${vars.actionUrl}">${vars.actionUrl}</a></p>`
166
+ ].join('\n'),
167
+ text: `Your ${vars.appName} code is ${vars.code} (expires in ${vars.expiresInMinutes} minutes).\nLink: ${vars.actionUrl}`
168
+ }
169
+ }
170
+
171
+ export const buildSendGridPayload = (req: NormalizedEmailRequest): Record<string, unknown> => {
172
+ const personalization: Record<string, unknown> = {
173
+ to: req.to.map((email) => ({ email })),
174
+ ...(req.cc?.length ? { cc: req.cc.map((email) => ({ email })) } : {}),
175
+ ...(req.bcc?.length ? { bcc: req.bcc.map((email) => ({ email })) } : {}),
176
+ ...(req.providerTemplateId
177
+ ? { dynamic_template_data: req.variables }
178
+ : req.subject
179
+ ? { subject: req.subject }
180
+ : {})
181
+ }
182
+ const content: { type: string; value: string }[] = []
183
+ if (!req.providerTemplateId) {
184
+ if (req.text) content.push({ type: 'text/plain', value: req.text })
185
+ if (req.html) content.push({ type: 'text/html', value: req.html })
186
+ if (!content.length) content.push({ type: 'text/plain', value: req.subject ?? '' })
187
+ }
188
+ return {
189
+ personalizations: [personalization],
190
+ from: { email: req.fromEmail, ...(req.fromName ? { name: req.fromName } : {}) },
191
+ ...(req.replyTo ? { reply_to: { email: req.replyTo } } : {}),
192
+ ...(req.subject ? { subject: req.subject } : {}),
193
+ ...(req.providerTemplateId ? { template_id: req.providerTemplateId } : { content }),
194
+ ...(req.attachments.length
195
+ ? {
196
+ attachments: req.attachments.map((a) => ({
197
+ content: a.contentBase64,
198
+ filename: a.filename,
199
+ type: a.contentType,
200
+ disposition: 'attachment'
201
+ }))
202
+ }
203
+ : {}),
204
+ ...(req.tags ? { custom_args: req.tags } : {})
205
+ }
206
+ }
207
+
208
+ export const buildResendPayload = (req: NormalizedEmailRequest): Record<string, unknown> => ({
209
+ from: req.fromName ? `${req.fromName} <${req.fromEmail}>` : req.fromEmail,
210
+ to: req.to,
211
+ ...(req.cc?.length ? { cc: req.cc } : {}),
212
+ ...(req.bcc?.length ? { bcc: req.bcc } : {}),
213
+ ...(req.replyTo ? { reply_to: req.replyTo } : {}),
214
+ subject: req.subject ?? req.template ?? 'Notification',
215
+ ...(req.providerTemplateId
216
+ ? { template_id: req.providerTemplateId, variables: req.variables }
217
+ : {}),
218
+ ...(req.html ? { html: req.html } : {}),
219
+ ...(req.text ? { text: req.text } : {}),
220
+ ...(!req.html && !req.text && !req.providerTemplateId
221
+ ? { text: req.subject ?? 'Notification' }
222
+ : {}),
223
+ ...(req.attachments.length
224
+ ? {
225
+ attachments: req.attachments.map((a) => ({
226
+ filename: a.filename,
227
+ content: a.contentBase64,
228
+ content_type: a.contentType
229
+ }))
230
+ }
231
+ : {}),
232
+ ...(req.tags
233
+ ? { tags: Object.entries(req.tags).map(([name, value]) => ({ name, value })) }
234
+ : {})
235
+ })
236
+
237
+ export const postSendGridMail = async (
238
+ baseUrl: string,
239
+ apiKey: string,
240
+ req: NormalizedEmailRequest
241
+ ): Promise<{ externalId: string }> => {
242
+ const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/mail/send`, {
243
+ method: 'POST',
244
+ headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
245
+ body: JSON.stringify(buildSendGridPayload(req)),
246
+ signal: AbortSignal.timeout(15_000)
247
+ })
248
+ if (!res.ok) {
249
+ const err = new Error(`SendGrid HTTP ${res.status}: ${await res.text().catch(() => '')}`)
250
+ ;(err as { status?: number }).status = res.status
251
+ throw err
252
+ }
253
+ return { externalId: res.headers.get('x-message-id')?.trim() || `sg_${req.emailId}` }
254
+ }
255
+
256
+ export const postResendEmail = async (
257
+ baseUrl: string,
258
+ apiKey: string,
259
+ req: NormalizedEmailRequest
260
+ ): Promise<{ externalId: string }> => {
261
+ const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/emails`, {
262
+ method: 'POST',
263
+ headers: {
264
+ Authorization: `Bearer ${apiKey}`,
265
+ 'content-type': 'application/json',
266
+ ...(req.idempotencyKey ? { 'Idempotency-Key': req.idempotencyKey } : {})
267
+ },
268
+ body: JSON.stringify(buildResendPayload(req)),
269
+ signal: AbortSignal.timeout(15_000)
270
+ })
271
+ if (!res.ok) {
272
+ const err = new Error(`Resend HTTP ${res.status}: ${await res.text().catch(() => '')}`)
273
+ ;(err as { status?: number }).status = res.status
274
+ throw err
275
+ }
276
+ const data = (await res.json().catch(() => ({}))) as { id?: string }
277
+ return { externalId: data.id || `re_${req.emailId}` }
278
+ }
279
+
280
+ export const postResendBatch = async (
281
+ baseUrl: string,
282
+ apiKey: string,
283
+ reqs: NormalizedEmailRequest[]
284
+ ): Promise<{ externalIds: string[] }> => {
285
+ const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/emails/batch`, {
286
+ method: 'POST',
287
+ headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
288
+ body: JSON.stringify(reqs.map(buildResendPayload)),
289
+ signal: AbortSignal.timeout(20_000)
290
+ })
291
+ if (!res.ok) {
292
+ const err = new Error(`Resend Batch HTTP ${res.status}: ${await res.text().catch(() => '')}`)
293
+ ;(err as { status?: number }).status = res.status
294
+ throw err
295
+ }
296
+ const raw = (await res.json().catch(() => ({}))) as { data?: { id?: string }[] } | { id?: string }[]
297
+ const items = Array.isArray(raw) ? raw : (raw.data ?? [])
298
+ return { externalIds: reqs.map((r, idx) => items[idx]?.id || `re_${r.emailId}`) }
299
+ }
300
+
301
+ export const postFirebaseOobCode = async (
302
+ apiKey: string,
303
+ customToken: string | undefined,
304
+ payload: TransactionalEmailPayload,
305
+ continueUrl: string
306
+ ): Promise<void> => {
307
+ const postToolkit = async (endpoint: string, body: Record<string, unknown>) => {
308
+ const res = await fetch(`https://identitytoolkit.googleapis.com/v1/${endpoint}?key=${apiKey}`, {
309
+ method: 'POST',
310
+ headers: { 'content-type': 'application/json' },
311
+ body: JSON.stringify(body),
312
+ signal: AbortSignal.timeout(10_000)
313
+ })
314
+ return { ok: res.ok, data: (await res.json().catch(() => ({}))) as Record<string, unknown> }
315
+ }
316
+ let body: Record<string, unknown>
317
+ if (payload.purpose === 'verify_email') {
318
+ if (!customToken) return
319
+ const { ok, data } = await postToolkit('accounts:signInWithCustomToken', {
320
+ token: customToken,
321
+ returnSecureToken: true
322
+ })
323
+ if (!ok || typeof data.idToken !== 'string') return
324
+ body = { requestType: 'VERIFY_EMAIL', idToken: data.idToken }
325
+ } else if (payload.purpose === 'reset_password') {
326
+ body = { requestType: 'PASSWORD_RESET', email: payload.to }
327
+ } else {
328
+ body = { requestType: 'EMAIL_SIGNIN', email: payload.to, continueUrl, canHandleCodeInApp: true }
329
+ }
330
+ await postToolkit('accounts:sendOobCode', body)
331
+ }
332
+
333
+ export const verifyResendWebhookHmac = (
334
+ rawBody: string,
335
+ headers: Record<string, string | undefined>,
336
+ secret: string,
337
+ toleranceSeconds: number
338
+ ): ResendWebhookEvent => {
339
+ const msgId = getHeader(headers, 'svix-id') ?? getHeader(headers, 'webhook-id')
340
+ const msgTimestamp = getHeader(headers, 'svix-timestamp') ?? getHeader(headers, 'webhook-timestamp')
341
+ const msgSignature = getHeader(headers, 'svix-signature') ?? getHeader(headers, 'webhook-signature')
342
+ if (!msgId || !msgTimestamp || !msgSignature || !secret) {
343
+ throw new Error('Missing Resend/Svix webhook signature headers or secret')
344
+ }
345
+
346
+ const tsSeconds = Number(msgTimestamp)
347
+ const nowSeconds = Math.floor(Date.now() / 1000)
348
+ if (!Number.isFinite(tsSeconds) || Math.abs(nowSeconds - tsSeconds) > toleranceSeconds) {
349
+ throw new Error('Resend webhook timestamp outside tolerance window')
350
+ }
351
+
352
+ const signedContent = `${msgId}.${msgTimestamp}.${rawBody}`
353
+ const rawSecret = secret.startsWith('whsec_') ? secret.slice(6) : secret
354
+ const expectedBase64FromDecoded = createHmac('sha256', Buffer.from(rawSecret, 'base64'))
355
+ .update(signedContent, 'utf8')
356
+ .digest('base64')
357
+ const expectedBase64FromUtf8 = createHmac('sha256', Buffer.from(secret, 'utf8'))
358
+ .update(signedContent, 'utf8')
359
+ .digest('base64')
360
+
361
+ const candidates = msgSignature
362
+ .split(/\s+/)
363
+ .map((part) => (part.startsWith('v1,') ? part.slice(3) : part.trim()))
364
+ .filter(Boolean)
365
+
366
+ const verified = candidates.some(
367
+ (sig) => constantTimeEqual(sig, expectedBase64FromDecoded) || constantTimeEqual(sig, expectedBase64FromUtf8)
368
+ )
369
+ if (!verified) {
370
+ throw new Error('Invalid Resend webhook HMAC signature')
371
+ }
372
+
373
+ return JSON.parse(rawBody) as ResendWebhookEvent
374
+ }
375
+
376
+ export const verifySendGridWebhookHmac = (
377
+ rawBody: string,
378
+ headers: Record<string, string | undefined>,
379
+ secret: string,
380
+ toleranceSeconds: number
381
+ ): SendGridWebhookEvent[] => {
382
+ const timestamp =
383
+ getHeader(headers, 'x-twilio-email-event-webhook-timestamp') ?? getHeader(headers, 'x-sendgrid-timestamp')
384
+ const signature =
385
+ getHeader(headers, 'x-twilio-email-event-webhook-signature') ?? getHeader(headers, 'x-sendgrid-signature')
386
+ if (!timestamp || !signature || !secret) {
387
+ throw new Error('Missing SendGrid webhook signature headers or secret')
388
+ }
389
+
390
+ const tsSeconds = Number(timestamp)
391
+ const nowSeconds = Math.floor(Date.now() / 1000)
392
+ if (!Number.isFinite(tsSeconds) || Math.abs(nowSeconds - tsSeconds) > toleranceSeconds) {
393
+ throw new Error('SendGrid webhook timestamp outside tolerance window')
394
+ }
395
+
396
+ const signedPayload = `${timestamp}${rawBody}`
397
+ const expectedBase64 = createHmac('sha256', secret).update(signedPayload, 'utf8').digest('base64')
398
+ const expectedHex = createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex')
399
+ const cleanSig = signature.startsWith('v1,') ? signature.slice(3) : signature
400
+
401
+ if (!constantTimeEqual(cleanSig, expectedBase64) && !constantTimeEqual(cleanSig, expectedHex)) {
402
+ throw new Error('Invalid SendGrid webhook HMAC signature')
403
+ }
404
+
405
+ const parsed = JSON.parse(rawBody) as unknown
406
+ return Array.isArray(parsed) ? (parsed as SendGridWebhookEvent[]) : [parsed as SendGridWebhookEvent]
407
+ }
408
+
409
+ export interface NormalizedWebhookUpdate {
410
+ externalId?: string
411
+ recipient?: string
412
+ status: EmailLogStatus
413
+ rawEvent: string
414
+ }
415
+
416
+ export const parseAndVerifyEmailWebhook = (
417
+ provider: 'sendgrid' | 'resend',
418
+ rawBody: string,
419
+ headers: Record<string, string | undefined>,
420
+ secret: string,
421
+ toleranceSeconds: number
422
+ ): { processed: number; updates: NormalizedWebhookUpdate[] } => {
423
+ if (provider === 'resend') {
424
+ const event = verifyResendWebhookHmac(rawBody, headers, secret, toleranceSeconds)
425
+ const status = RESEND_STATUS_MAP[event.type]
426
+ const recipient = Array.isArray(event.data?.to) ? event.data.to[0] : event.data?.to
427
+ return {
428
+ processed: 1,
429
+ updates: status
430
+ ? [{ ...(event.data?.email_id ? { externalId: event.data.email_id } : {}), ...(recipient ? { recipient } : {}), status, rawEvent: rawBody }]
431
+ : []
432
+ }
433
+ }
434
+
435
+ const events = verifySendGridWebhookHmac(rawBody, headers, secret, toleranceSeconds)
436
+ const updates: NormalizedWebhookUpdate[] = []
437
+ for (const ev of events) {
438
+ const status = SENDGRID_STATUS_MAP[ev.event]
439
+ if (!status) continue
440
+ const cleanMsgId = ev.sg_message_id?.split('.')[0]
441
+ updates.push({
442
+ ...(cleanMsgId ? { externalId: cleanMsgId } : {}),
443
+ ...(ev.email ? { recipient: ev.email } : {}),
444
+ status,
445
+ rawEvent: JSON.stringify(ev)
446
+ })
447
+ }
448
+ return { processed: events.length, updates }
449
+ }
@@ -0,0 +1,3 @@
1
+ export * from './schema'
2
+ export { FilesService } from './service'
3
+ export { coreFilesRoutes } from './routes'
@@ -0,0 +1,44 @@
1
+ import type { Context } from 'hono'
2
+ import type { AppEnv } from '../config'
3
+ import type { EntryId } from '../db'
4
+ import { core } from '../index'
5
+ import type { FileType } from './schema'
6
+
7
+ export const coreFilesRoutes = {
8
+ 'files.list': {
9
+ handler: async ({ userId }: { userId: EntryId }) => core.db.list('file', { userId }, { bypassRbac: true })
10
+ },
11
+ 'files.upload': {
12
+ handler: async ({ c, userId }: { c: Context<AppEnv>; userId: EntryId }) => {
13
+ const file = (await c!.req.formData()).get('file')
14
+ if (!(file instanceof File)) return core.errors.httpError(400, 'Missing "file" field', 'INVALID_FILE')
15
+ return core.files.upload(
16
+ {
17
+ bytes: new Uint8Array(await file.arrayBuffer()),
18
+ name: file.name || 'upload',
19
+ type: file.type as FileType
20
+ },
21
+ userId
22
+ )
23
+ }
24
+ },
25
+ 'files.get': {
26
+ handler: async ({ c }: { c: Context<AppEnv> }) => {
27
+ const ref = c!.req.param('ref') ?? ''
28
+ if (c!.req.query('meta') === 'true') {
29
+ const entry = await core.db.read('file', ref, false, { bypassRbac: true })
30
+ return entry ?? core.errors.httpError(404, 'File not found', 'NOT_FOUND')
31
+ }
32
+ if (c!.req.query('signed') === 'true') {
33
+ return core.files.getSignedDownloadUrl(ref)
34
+ }
35
+ const { bytes, type } = await core.files.get(ref)
36
+ return new Response(Buffer.from(bytes), {
37
+ headers: { 'content-type': type, 'cache-control': 'private, max-age=3600, immutable' }
38
+ })
39
+ }
40
+ },
41
+ 'files.delete': {
42
+ handler: async ({ c }: { c: Context<AppEnv> }) => core.files.delete(c!.req.param('ref') ?? '')
43
+ }
44
+ }
@@ -0,0 +1,76 @@
1
+ import { z } from 'zod'
2
+ import { CORE_CONFIG } from '../config'
3
+ import {
4
+ fileRef,
5
+ fileScan,
6
+ fileScanError,
7
+ unparseableReason,
8
+ type FileRef,
9
+ type FileScan,
10
+ type FileScanError,
11
+ type UnparseableReason
12
+ } from '../db'
13
+ import { hasValidMagicBytes } from './utils'
14
+
15
+ export {
16
+ fileRef,
17
+ fileScan,
18
+ fileScanError,
19
+ unparseableReason,
20
+ type FileRef,
21
+ type FileScan,
22
+ type FileScanError,
23
+ type UnparseableReason
24
+ }
25
+
26
+ export const fileType = z.enum([
27
+ 'application/pdf',
28
+ 'image/png',
29
+ 'image/jpeg',
30
+ 'image/webp',
31
+ 'image/gif',
32
+ 'image/bmp',
33
+ 'image/tiff',
34
+ 'image/avif'
35
+ ])
36
+ export type FileType = z.infer<typeof fileType>
37
+
38
+ export const fileBody = z
39
+ .object({
40
+ bytes: z
41
+ .instanceof(Uint8Array)
42
+ .refine((b) => b.byteLength > 0 && b.byteLength <= CORE_CONFIG.limits.maxFileBytes, 'File size must be 1B..20MiB'),
43
+ name: z.string().min(1).max(256),
44
+ type: fileType
45
+ })
46
+ .refine((f) => hasValidMagicBytes(f.type, f.bytes), {
47
+ message: 'File binary header does not match declared MIME type',
48
+ path: ['bytes']
49
+ })
50
+ export type FileBody = z.infer<typeof fileBody>
51
+
52
+ export const signedUploadInput = z.object({
53
+ sha256: z.string().regex(/^[a-fA-F0-9]{24,64}$/, 'Invalid SHA-256 hex digest'),
54
+ name: z.string().min(1).max(256),
55
+ type: fileType,
56
+ size: z
57
+ .number()
58
+ .int()
59
+ .positive()
60
+ .refine((s) => s <= CORE_CONFIG.limits.maxFileBytes, 'File size exceeds maxFileBytes')
61
+ .optional()
62
+ })
63
+ export type SignedUploadInput = z.infer<typeof signedUploadInput>
64
+
65
+ export interface SignedUploadUrlResult {
66
+ ref: FileRef
67
+ url: string
68
+ expiresAt: string
69
+ deduplicated: boolean
70
+ }
71
+
72
+ export interface SignedDownloadUrlResult {
73
+ ref: FileRef
74
+ url: string
75
+ expiresAt: string
76
+ }