@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,106 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto'
2
+ import type { UserRecord } from 'firebase-admin/auth'
3
+ import { CORE_CONFIG, type DbRole } from '../config'
4
+ import type { EntryId } from '../db'
5
+ import type { AuthUser, LinkedProvider, OAuthProvider, UserAuthMetaRecord } from './schema'
6
+
7
+ export const resolveUserRole = (userId?: EntryId, explicitRole?: DbRole): DbRole => {
8
+ if (explicitRole === 'admin' || explicitRole === 'user' || explicitRole === 'anon') return explicitRole
9
+ if (userId === CORE_CONFIG.auth.adminUserId) return 'admin'
10
+ return userId ? 'user' : 'anon'
11
+ }
12
+
13
+ export const resolveTokenRole = (decoded: Record<string, unknown>, uid: EntryId): DbRole =>
14
+ uid === CORE_CONFIG.auth.adminUserId || decoded.role === 'admin' || decoded.admin === true ? 'admin' : 'user'
15
+
16
+ export const normalizeDate = (val: unknown, fallback = new Date()): Date => {
17
+ if (val instanceof Date) return val
18
+ if (
19
+ val &&
20
+ typeof val === 'object' &&
21
+ 'toDate' in val &&
22
+ typeof (val as { toDate: () => Date }).toDate === 'function'
23
+ ) {
24
+ return (val as { toDate: () => Date }).toDate()
25
+ }
26
+ if (typeof val === 'string' || typeof val === 'number') {
27
+ const d = new Date(val)
28
+ if (!Number.isNaN(d.getTime())) return d
29
+ }
30
+ return fallback
31
+ }
32
+
33
+ export const safeEqualHex = (aHex: string, bHex: string): boolean => {
34
+ if (!aHex || !bHex || aHex.length !== bHex.length) return false
35
+ try {
36
+ const a = Buffer.from(aHex, 'hex')
37
+ const b = Buffer.from(bHex, 'hex')
38
+ return a.length > 0 && a.length === b.length && timingSafeEqual(a, b)
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ export const hashToken = (raw: string): string => createHash('sha256').update(raw.trim()).digest('hex')
45
+
46
+ export const parseLinkedProviders = (rawProviders: unknown, fallbackDate = new Date()): LinkedProvider[] => {
47
+ if (!Array.isArray(rawProviders)) return []
48
+ return rawProviders
49
+ .filter((p): p is Record<string, unknown> => Boolean(p && typeof p === 'object'))
50
+ .map((p) => ({
51
+ provider: (p.provider === 'apple' ? 'apple' : 'google') as OAuthProvider,
52
+ providerUserId: String(p.providerUserId ?? ''),
53
+ ...(p.email !== undefined ? { email: (p.email as string | null) ?? null } : {}),
54
+ linkedAt: normalizeDate(p.linkedAt, fallbackDate)
55
+ }))
56
+ .filter((p) => Boolean(p.providerUserId))
57
+ }
58
+
59
+ export const mapUserRecord = (u: UserRecord, meta?: UserAuthMetaRecord): AuthUser => {
60
+ const createdAt = new Date(u.metadata.creationTime || Date.now())
61
+ const updatedAt = new Date(u.metadata.lastRefreshTime || u.metadata.creationTime || Date.now())
62
+ const fbProviders: LinkedProvider[] = (u.providerData ?? [])
63
+ .filter((p) => p.providerId === 'google.com' || p.providerId === 'apple.com')
64
+ .map((p) => ({
65
+ provider: (p.providerId === 'apple.com' ? 'apple' : 'google') as OAuthProvider,
66
+ providerUserId: p.uid,
67
+ ...(p.email ? { email: p.email } : {}),
68
+ linkedAt: createdAt
69
+ }))
70
+ const mergedProvidersMap = new Map<string, LinkedProvider>()
71
+ for (const p of fbProviders) mergedProvidersMap.set(`${p.provider}:${p.providerUserId}`, p)
72
+ for (const p of meta?.linkedProviders ?? []) mergedProvidersMap.set(`${p.provider}:${p.providerUserId}`, p)
73
+ const linkedProviders = [...mergedProvidersMap.values()]
74
+ const avatarUrl = u.photoURL ?? meta?.avatarUrl ?? undefined
75
+
76
+ return {
77
+ id: u.uid,
78
+ email: u.email ?? meta?.email ?? null,
79
+ emailVerified: Boolean(u.emailVerified || meta?.emailVerified),
80
+ name: u.displayName ?? meta?.name ?? null,
81
+ ...(avatarUrl !== undefined ? { avatarUrl } : {}),
82
+ ...(meta?.recoveryEmail !== undefined ? { recoveryEmail: meta.recoveryEmail } : {}),
83
+ ...(meta?.recoveryEmailVerified !== undefined ? { recoveryEmailVerified: meta.recoveryEmailVerified } : {}),
84
+ ...(linkedProviders.length > 0 ? { linkedProviders } : {}),
85
+ ...(u.customClaims ? { customClaims: u.customClaims } : {}),
86
+ deleted: u.disabled,
87
+ createdAt,
88
+ updatedAt
89
+ }
90
+ }
91
+
92
+ export const fetchIdentityToolkit = async <T = Record<string, unknown>>(
93
+ endpoint: string,
94
+ apiKey: string,
95
+ body: Record<string, unknown>,
96
+ timeoutMs = 10_000
97
+ ): Promise<{ ok: boolean; status: number; data: T }> => {
98
+ const res = await fetch(`https://identitytoolkit.googleapis.com/v1/${endpoint}?key=${apiKey}`, {
99
+ method: 'POST',
100
+ headers: { 'content-type': 'application/json' },
101
+ body: JSON.stringify(body),
102
+ signal: AbortSignal.timeout(timeoutMs)
103
+ })
104
+ const data = (await res.json().catch(() => ({}))) as T
105
+ return { ok: res.ok, status: res.status, data }
106
+ }
@@ -0,0 +1,310 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { ModelMessage } from 'ai'
4
+ import { FieldValue, type Query } from 'firebase-admin/firestore'
5
+ import type { JevQuestion } from '../ai'
6
+ import { CORE_CONFIG, type DbRole } from '../config'
7
+ import type { EntryId } from '../db'
8
+ import { Core } from '../index'
9
+ import type {
10
+ AgentConfig,
11
+ ChatMessagePublic,
12
+ ChatMessageRole,
13
+ ContextEntry,
14
+ ContextItemSpec,
15
+ GetContextOptions,
16
+ HybridRetrievalOptions,
17
+ HybridRetrievalResult,
18
+ RecalledMemorySnippet,
19
+ UserContext,
20
+ VectorValue
21
+ } from './schema'
22
+ import {
23
+ isContextItemVisible,
24
+ isUserContextShape,
25
+ mapFirestoreMessageDoc,
26
+ PUBLIC_MESSAGE_FIELDS,
27
+ RECALL_MESSAGE_FIELDS,
28
+ toModelMessageFromDoc
29
+ } from './utils'
30
+
31
+ let contextCatalog: readonly ContextItemSpec[] = []
32
+ const markdownCache = new Map<string, string>()
33
+
34
+ export class ChatContextEngine {
35
+ readonly #core: Core
36
+
37
+ constructor(coreClient: Core) {
38
+ this.#core = coreClient
39
+ }
40
+
41
+ registerContextCatalog = (items: readonly ContextItemSpec[] = [], reset = false): void => {
42
+ if (reset) {
43
+ contextCatalog = []
44
+ markdownCache.clear()
45
+ }
46
+ contextCatalog = [...items]
47
+ }
48
+
49
+ #readContextMarkdown = async (relPath: string): Promise<string | undefined> => {
50
+ const cached = markdownCache.get(relPath)
51
+ if (cached !== undefined) return cached
52
+
53
+ const contextRoot = path.resolve(process.cwd(), CORE_CONFIG.context.dir)
54
+ const resolved = path.resolve(contextRoot, relPath)
55
+ if (!resolved.startsWith(contextRoot + path.sep) || !relPath.endsWith('.md')) {
56
+ return undefined
57
+ }
58
+
59
+ try {
60
+ const content = await readFile(resolved, 'utf8')
61
+ markdownCache.set(relPath, content)
62
+ return content
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ getContext = async (
69
+ prompt: string,
70
+ catalog: readonly ContextItemSpec[] = contextCatalog,
71
+ options?: GetContextOptions
72
+ ): Promise<ContextEntry[]> => {
73
+ const trimmed = prompt.trim()
74
+ if (!trimmed || catalog.length === 0) return []
75
+
76
+ const validItems = catalog.filter(
77
+ (item) =>
78
+ item.path?.endsWith('.md') &&
79
+ !item.path.includes('..') &&
80
+ !path.isAbsolute(item.path) &&
81
+ isContextItemVisible(item, options?.audiences)
82
+ )
83
+ if (validItems.length === 0) return []
84
+
85
+ const maxSources = options?.maxSources ?? CORE_CONFIG.context.maxSources
86
+ const userState = options?.userState
87
+ const stateHint = userState ? ' Use the provided userState when judging relevance.' : ''
88
+ const questions: Record<string, JevQuestion> = {}
89
+ validItems.forEach((item, idx) => {
90
+ questions[`doc_${idx}`] = {
91
+ type: 'noul',
92
+ instructions: `Is documentation file "${item.path}" (${item.description}) directly relevant to answering the user prompt?${stateHint}`,
93
+ criteria: {
94
+ true: `The user prompt requires or benefits from documentation in ${item.path} (${item.description})`,
95
+ false: 'The user prompt is unrelated to this documentation file'
96
+ }
97
+ }
98
+ })
99
+
100
+ try {
101
+ const decision = await this.#core.ai.decide(
102
+ { prompt: trimmed, ...(userState ? { userState } : {}) },
103
+ questions
104
+ )
105
+ const scored = validItems
106
+ .map((item, idx) => {
107
+ const ans = decision.answers[`doc_${idx}`]
108
+ return { item, score: ans?.type === 'noul' ? ans.noul : 0 }
109
+ })
110
+ .filter((x) => x.score >= 0.5)
111
+ .sort((a, b) => b.score - a.score)
112
+ .slice(0, maxSources)
113
+
114
+ if (scored.length === 0) return []
115
+
116
+ const loaded = await Promise.all(
117
+ scored.map(async ({ item }): Promise<ContextEntry | undefined> => {
118
+ const markdown = await this.#readContextMarkdown(item.path)
119
+ return markdown ? { path: item.path, description: item.description, markdown } : undefined
120
+ })
121
+ )
122
+ return loaded.filter((entry): entry is ContextEntry => Boolean(entry))
123
+ } catch {
124
+ return []
125
+ }
126
+ }
127
+
128
+ #resolveTurnUserContext = async (opts: {
129
+ userId: EntryId
130
+ role?: DbRole
131
+ agent?: Pick<AgentConfig, 'resolveUserContext'>
132
+ abortSignal?: AbortSignal
133
+ }): Promise<UserContext | undefined> => {
134
+ const resolver = opts.agent?.resolveUserContext
135
+ const role = opts.role ?? this.#core.internals.auth.resolveUserRole(opts.userId)
136
+ if (!resolver || !opts.userId || role === 'anon') return undefined
137
+ const timeout = AbortSignal.timeout(CORE_CONFIG.context.userContextTimeoutMs)
138
+ const signal = opts.abortSignal ? AbortSignal.any([opts.abortSignal, timeout]) : timeout
139
+ let onAbort: (() => void) | undefined
140
+ try {
141
+ const state = { env: this.#core.env, userId: opts.userId, role }
142
+ const turnCore = new Core(state)
143
+ const aborted = new Promise<undefined>((resolve) => {
144
+ onAbort = () => resolve(undefined)
145
+ if (signal.aborted) return resolve(undefined)
146
+ signal.addEventListener('abort', onAbort, { once: true })
147
+ })
148
+ const result = await Promise.race([
149
+ this.#core.internals.context.run(state, () =>
150
+ Promise.resolve(resolver({ userId: opts.userId, core: turnCore, signal }))
151
+ ),
152
+ aborted
153
+ ])
154
+ return isUserContextShape(result) ? result : undefined
155
+ } catch {
156
+ return undefined
157
+ } finally {
158
+ if (onAbort) signal.removeEventListener('abort', onAbort)
159
+ }
160
+ }
161
+
162
+ #queryNearestMessages = async (
163
+ baseQuery: Query,
164
+ vectorValue: VectorValue,
165
+ limit: number,
166
+ scope: 'in_chat' | 'cross_chat',
167
+ fallbackChatId = ''
168
+ ): Promise<RecalledMemorySnippet[]> => {
169
+ try {
170
+ await this.#core.internals.resilience.acquireLimit('firestore')
171
+ const snap = await baseQuery
172
+ .select(...RECALL_MESSAGE_FIELDS)
173
+ .findNearest({
174
+ vectorField: 'embedding',
175
+ queryVector: vectorValue,
176
+ limit,
177
+ distanceMeasure: 'COSINE',
178
+ distanceThreshold: 0.35
179
+ })
180
+ .get()
181
+ return snap.docs
182
+ .map((d) => {
183
+ const data = d.data()
184
+ const content = typeof data.content === 'string' ? data.content.trim() : ''
185
+ if (!content) return undefined
186
+ return {
187
+ id: d.id,
188
+ chatId: typeof data.chatId === 'string' ? data.chatId : fallbackChatId,
189
+ role: (data.role === 'assistant' ? 'assistant' : 'user') as ChatMessageRole,
190
+ content,
191
+ scope
192
+ }
193
+ })
194
+ .filter((x): x is RecalledMemorySnippet => Boolean(x))
195
+ } catch {
196
+ return []
197
+ }
198
+ }
199
+
200
+ #vectorRecall = async (
201
+ prompt: string,
202
+ userId: EntryId,
203
+ chatId: string | undefined,
204
+ messageCount: number,
205
+ slidingWindowSize: number
206
+ ): Promise<RecalledMemorySnippet[]> => {
207
+ const queryVector = await this.#core.ai.embed(prompt)
208
+ if (!queryVector) return []
209
+ const vectorValue = FieldValue.vector(queryVector)
210
+ const db = this.#core.db.firestore()
211
+
212
+ const inChatPromise =
213
+ chatId && messageCount > slidingWindowSize
214
+ ? this.#queryNearestMessages(
215
+ db.collection('chat').doc(chatId).collection('messages'),
216
+ vectorValue,
217
+ 5,
218
+ 'in_chat',
219
+ chatId
220
+ )
221
+ : Promise.resolve([] as RecalledMemorySnippet[])
222
+
223
+ const crossChatPromise = userId
224
+ ? this.#queryNearestMessages(
225
+ db.collectionGroup('messages').where('userId', '==', userId),
226
+ vectorValue,
227
+ 3,
228
+ 'cross_chat'
229
+ )
230
+ : Promise.resolve([] as RecalledMemorySnippet[])
231
+
232
+ const [inChat, crossChat] = await Promise.all([inChatPromise, crossChatPromise])
233
+ return [...inChat, ...crossChat]
234
+ }
235
+
236
+ retrieveContext = async (opts: HybridRetrievalOptions): Promise<HybridRetrievalResult> => {
237
+ const {
238
+ userId = this.#core.userId,
239
+ chatId,
240
+ messageCount = 0,
241
+ prompt,
242
+ useContext = true,
243
+ slidingWindowSize = CORE_CONFIG.limits.chatMessagesLimit,
244
+ agent,
245
+ role,
246
+ abortSignal
247
+ } = opts
248
+ const db = this.#core.db.firestore()
249
+
250
+ const userContextPromise = this.#resolveTurnUserContext({ userId, role, agent, abortSignal })
251
+
252
+ const slidingWindowPromise = (async (): Promise<ChatMessagePublic[]> => {
253
+ if (!chatId) return []
254
+ try {
255
+ await this.#core.internals.resilience.acquireLimit('firestore')
256
+ const snap = await db
257
+ .collection('chat')
258
+ .doc(chatId)
259
+ .collection('messages')
260
+ .select(...PUBLIC_MESSAGE_FIELDS)
261
+ .orderBy('createdAt', 'desc')
262
+ .limit(slidingWindowSize)
263
+ .get()
264
+ return snap.docs.map((d) => mapFirestoreMessageDoc(d.id, d.data() ?? {})).reverse()
265
+ } catch {
266
+ return []
267
+ }
268
+ })()
269
+
270
+ const vectorRecallPromise = this.#vectorRecall(prompt, userId, chatId, messageCount, slidingWindowSize)
271
+
272
+ const infobasePromise =
273
+ useContext !== false && prompt.trim()
274
+ ? userContextPromise.then((userContext) =>
275
+ this.getContext(prompt, undefined, {
276
+ ...(agent?.contextAudiences ? { audiences: agent.contextAudiences } : {}),
277
+ ...(userContext ? { userState: userContext.state } : {})
278
+ })
279
+ )
280
+ : Promise.resolve([] as ContextEntry[])
281
+
282
+ const [slidingWindowDocs, rawRecalled, infobaseContextEntries, userContext] = await Promise.all([
283
+ slidingWindowPromise,
284
+ vectorRecallPromise,
285
+ infobasePromise,
286
+ userContextPromise
287
+ ])
288
+
289
+ const windowIds = new Set(slidingWindowDocs.map((m) => m.id))
290
+ const seenRecallIds = new Set<string>()
291
+ const recalledMemories: RecalledMemorySnippet[] = []
292
+ for (const mem of rawRecalled) {
293
+ if (windowIds.has(mem.id) || seenRecallIds.has(mem.id)) continue
294
+ seenRecallIds.add(mem.id)
295
+ recalledMemories.push(mem)
296
+ }
297
+
298
+ const slidingWindowMessages = slidingWindowDocs
299
+ .map(toModelMessageFromDoc)
300
+ .filter((m): m is ModelMessage => Boolean(m))
301
+
302
+ return {
303
+ slidingWindowDocs,
304
+ slidingWindowMessages,
305
+ recalledMemories,
306
+ infobaseContextEntries,
307
+ ...(userContext ? { userContext } : {})
308
+ }
309
+ }
310
+ }
@@ -0,0 +1,3 @@
1
+ export * from './schema'
2
+ export { ChatService } from './service'
3
+ export { coreChatRoutes, mcpRoute } from './routes'
@@ -0,0 +1,264 @@
1
+ import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'
2
+ import type { ModelMessage, streamText } from 'ai'
3
+ import type { Context } from 'hono'
4
+ import type { z } from 'zod'
5
+ import { CORE_CONFIG, type AppEnv } from '../config'
6
+ import type { EntryId } from '../db'
7
+ import { core } from '../index'
8
+ import {
9
+ listChatMessagesQuery,
10
+ openaiCompletionsBody,
11
+ saveChatBody,
12
+ streamChatBody,
13
+ type RawAttachmentPart,
14
+ type WorkflowToolSpec
15
+ } from './schema'
16
+ import { extractLatestUserPrompt, formatUsage, sanitizeToolInputSchema } from './utils'
17
+
18
+ const listOpenaiModelsService = async () => {
19
+ const agents = core.internals.registry.getRegisteredAgents()
20
+ return {
21
+ object: 'list',
22
+ data: Object.keys(agents).map((id) => ({
23
+ id,
24
+ object: 'model',
25
+ created: Math.floor(Date.now() / 1000),
26
+ owned_by: CORE_CONFIG.appName
27
+ }))
28
+ }
29
+ }
30
+
31
+ const parseOpenaiMessages = (
32
+ messages: z.infer<typeof openaiCompletionsBody>['messages']
33
+ ): { parts: RawAttachmentPart[]; modelMessages: ModelMessage[] } => {
34
+ const parts: RawAttachmentPart[] = []
35
+ const modelMessages: ModelMessage[] = []
36
+ for (const message of messages) {
37
+ if (message.role !== 'user' && message.role !== 'assistant') continue
38
+ const text =
39
+ typeof message.content === 'string'
40
+ ? message.content
41
+ : (message.content ?? [])
42
+ .filter((p) => p.type === 'text')
43
+ .map((p) => p.text ?? '')
44
+ .join('\n\n')
45
+ if (message.role === 'assistant' && text.trim()) {
46
+ modelMessages.push({ role: 'assistant', content: [{ type: 'text', text }] })
47
+ }
48
+ if (message.role !== 'user') continue
49
+ for (const p of Array.isArray(message.content) ? message.content : []) {
50
+ const dataUrl = p.image_url?.url ?? p.file?.file_data
51
+ if (dataUrl?.startsWith('data:')) parts.push({ dataUrl, filename: p.file?.filename ?? 'file' })
52
+ }
53
+ if (text.trim()) modelMessages.push({ role: 'user', content: [{ type: 'text', text }] })
54
+ }
55
+ return { parts, modelMessages }
56
+ }
57
+
58
+ const createOpenaiSseResponse = (
59
+ turn: ReturnType<typeof streamText>,
60
+ id: string,
61
+ created: number,
62
+ model: string,
63
+ includeUsage: boolean
64
+ ): Response => {
65
+ const encoder = new TextEncoder()
66
+ const stream = new ReadableStream({
67
+ async start(controller) {
68
+ const send = (payload: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`))
69
+ const sendChunk = (choices: unknown[], extra?: Record<string, unknown>) =>
70
+ send({ id, object: 'chat.completion.chunk', created, model, choices, ...extra })
71
+
72
+ sendChunk([{ index: 0, delta: { role: 'assistant' }, finish_reason: null }])
73
+ try {
74
+ for await (const delta of turn.textStream) {
75
+ if (delta) sendChunk([{ index: 0, delta: { content: delta }, finish_reason: null }])
76
+ }
77
+ sendChunk([{ index: 0, delta: {}, finish_reason: 'stop' }])
78
+ if (includeUsage) sendChunk([], { usage: formatUsage(await turn.usage) })
79
+ } catch (error) {
80
+ console.error('[openai] stream failed:', error)
81
+ send({ error: { message: 'The agent failed to produce a reply. Please retry.' } })
82
+ }
83
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
84
+ controller.close()
85
+ }
86
+ })
87
+ return new Response(stream, {
88
+ headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache' }
89
+ })
90
+ }
91
+
92
+ const openaiCompletionsService = async ({
93
+ c,
94
+ userId,
95
+ input
96
+ }: {
97
+ c: Context<AppEnv>
98
+ userId: EntryId
99
+ input?: z.infer<typeof openaiCompletionsBody>
100
+ }) => {
101
+ const body = input ?? openaiCompletionsBody.parse(await c.req.json().catch(() => ({})))
102
+ const agents = core.internals.registry.getRegisteredAgents()
103
+ const entries = Object.entries(agents)
104
+ if (entries.length === 0) return core.errors.httpError(404, 'No registered agents', 'NOT_FOUND')
105
+ const [defaultModel, defaultAgent] = entries[0]!
106
+ const modelKey = body.model && body.model in agents ? body.model : defaultModel
107
+ const agent = agents[modelKey] ?? defaultAgent
108
+ const model = agent.name ?? modelKey
109
+
110
+ await core.internals.context.enforceAgentAccess(c, agent, 'openai.completions')
111
+
112
+ const { parts, modelMessages } = parseOpenaiMessages(body.messages)
113
+ if (modelMessages.length === 0 && parts.length === 0) {
114
+ return core.errors.httpError(400, 'No supported messages', 'VALIDATION_ERROR')
115
+ }
116
+
117
+ const latestPrompt = extractLatestUserPrompt(modelMessages)
118
+ const role = core.internals.auth.resolveUserRole(userId, c.get('userRole'))
119
+ const { turn, userCreatedAtMs } = await core.internals.context.startChatStreamTurn({
120
+ userId,
121
+ role,
122
+ agent,
123
+ attachmentParts: parts,
124
+ rawUserText: latestPrompt,
125
+ signal: c.req.raw.signal,
126
+ priorMessages: modelMessages
127
+ })
128
+ const id = `chatcmpl-${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`
129
+ const created = Math.floor(userCreatedAtMs / 1000)
130
+
131
+ if (!body.stream) {
132
+ const [text, usage] = await Promise.all([turn.text, turn.usage])
133
+ return {
134
+ id,
135
+ object: 'chat.completion',
136
+ created,
137
+ model,
138
+ choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
139
+ usage: formatUsage(usage)
140
+ }
141
+ }
142
+
143
+ return createOpenaiSseResponse(turn, id, created, model, Boolean(body.stream_options?.include_usage))
144
+ }
145
+
146
+ export const mcpRoute = async (
147
+ request: Request,
148
+ userId: EntryId,
149
+ mcpTools: Map<string, WorkflowToolSpec>
150
+ ): Promise<Response> => {
151
+ const role = core.internals.auth.resolveUserRole(userId)
152
+ const server = new McpServer({ name: CORE_CONFIG.appName, version: '1.0.0' })
153
+ for (const [name, toolSpec] of mcpTools) {
154
+ if (toolSpec.mcp !== true) continue
155
+ server.registerTool(
156
+ name,
157
+ {
158
+ description: (toolSpec.description || name).slice(0, 1024),
159
+ inputSchema: sanitizeToolInputSchema(toolSpec.input)
160
+ },
161
+ async (input) => {
162
+ try {
163
+ const step = core.internals.workflows.createStepRunner('workflow')
164
+ const result = await core.internals.context.run({ env: core.env, userId, role, step }, () =>
165
+ toolSpec.execute(input, userId)
166
+ )
167
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
168
+ } catch (err) {
169
+ const msg = err instanceof Error ? err.message : String(err)
170
+ return {
171
+ content: [
172
+ {
173
+ type: 'text' as const,
174
+ text: JSON.stringify(core.errors.buildErrorBody(msg, 502, 'WORKFLOW_ERROR'))
175
+ }
176
+ ],
177
+ isError: true
178
+ }
179
+ }
180
+ }
181
+ )
182
+ }
183
+ return createMcpHandler(() => server).fetch(request)
184
+ }
185
+
186
+ export const coreChatRoutes = {
187
+ 'chat.list': {
188
+ handler: async ({ c, userId }: { c: Context<AppEnv>; userId: EntryId }) =>
189
+ core.db.list(
190
+ 'chat',
191
+ {
192
+ userId,
193
+ ...(c?.req.query('full') === 'true'
194
+ ? {}
195
+ : {
196
+ select: [
197
+ 'id',
198
+ 'userId',
199
+ 'agentId',
200
+ 'title',
201
+ 'model',
202
+ 'messageCount',
203
+ 'summary',
204
+ 'activeGenerationId',
205
+ 'deleted',
206
+ 'createdAt',
207
+ 'updatedAt'
208
+ ]
209
+ })
210
+ },
211
+ { bypassRbac: true }
212
+ )
213
+ },
214
+ 'chat.get': {
215
+ handler: async ({ c }: { c: Context<AppEnv> }) => {
216
+ const chat = await core.db.read('chat', c.req.param('chatId') ?? '', false)
217
+ return chat ?? core.errors.httpError(404, 'Chat not found', 'NOT_FOUND')
218
+ }
219
+ },
220
+ 'chat.messages': {
221
+ handler: async ({ c }: { c: Context<AppEnv> }) => {
222
+ const chatId = c.req.param('chatId') ?? ''
223
+ const parsedQuery = listChatMessagesQuery.parse(c.req.query())
224
+ return core.chat.messages(chatId, { limit: parsedQuery.limit, before: parsedQuery.before })
225
+ }
226
+ },
227
+ 'chat.save': {
228
+ input: saveChatBody,
229
+ handler: ({ userId, input }: { userId: EntryId; input: z.infer<typeof saveChatBody> }) =>
230
+ core.internals.context.saveChat({ userId, input })
231
+ },
232
+ 'chat.delete': {
233
+ handler: async ({ c }: { c: Context<AppEnv> }) => {
234
+ const chatId = c.req.param('chatId') ?? ''
235
+ await core.db.delete('chat', chatId)
236
+ return { id: chatId, deleted: true }
237
+ }
238
+ },
239
+ 'chat.stream': {
240
+ input: streamChatBody,
241
+ handler: ({
242
+ c,
243
+ userId,
244
+ input
245
+ }: {
246
+ c: Context<AppEnv>
247
+ userId: EntryId
248
+ input?: z.infer<typeof streamChatBody>
249
+ }) => core.internals.context.streamChatSse({ c, userId, input })
250
+ },
251
+ 'chat.cancel': {
252
+ handler: ({ c }: { c: Context<AppEnv> }) => {
253
+ const chatId = c.req.param('chatId') ?? ''
254
+ return core.chat.cancel(chatId)
255
+ }
256
+ },
257
+ 'openai.models': {
258
+ handler: listOpenaiModelsService
259
+ },
260
+ 'openai.completions': {
261
+ input: openaiCompletionsBody,
262
+ handler: openaiCompletionsService
263
+ }
264
+ }