@corgelabs/corge 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +547 -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 +103 -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 +59 -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 +125 -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,2531 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createHash, generateKeyPairSync, sign as cryptoSign } from 'node:crypto'
3
+ import { EventEmitter } from 'node:events'
4
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { after, before, describe, test } from 'node:test'
8
+ import { serve, type ServerType } from '@hono/node-server'
9
+ import { z } from 'zod'
10
+ import { agents } from '@config/agents'
11
+ import { routes as customRoutes } from '@config/routes'
12
+ import { Core, core, loadGcpSecrets, useCore } from '@core'
13
+ import { devApp } from '@app-entry'
14
+
15
+ type AttachmentTypesMap = core.types.AttachmentTypesMap
16
+ type RouteConfig<I = any, O = any, P = Record<string, string>, Q = Record<string, string>> =
17
+ core.types.RouteConfig<I, O, P, Q>
18
+ type RoutesIntrospectionResponse = core.types.RoutesIntrospectionResponse
19
+ const CORE_CONFIG = core.config
20
+ const defineCollection = core.define.collection
21
+
22
+ EventEmitter.defaultMaxListeners = 100
23
+
24
+ await loadGcpSecrets()
25
+
26
+ const EXPLICIT_BASE_URL = process.env.BASE_URL
27
+ const PORT = 8791
28
+ const BASE_URL = (EXPLICIT_BASE_URL ?? `http://localhost:${PORT}`).replace(/\/+$/, '')
29
+ const SPAWN_LOCAL = !EXPLICIT_BASE_URL
30
+
31
+ const ENV = core.env
32
+ const API_KEY = ENV.SDK_API_KEY
33
+ const WEB_API_KEY = ENV.WEB_API_KEY
34
+
35
+ const AGENT_IDS = Object.keys(agents)
36
+ const DEFAULT_AGENT_ID = AGENT_IDS[0]!
37
+ const DEFAULT_AGENT_NAME = agents[DEFAULT_AGENT_ID as keyof typeof agents]?.name ?? DEFAULT_AGENT_ID
38
+
39
+ const ACTIVE_ATTACHMENT_TYPES: AttachmentTypesMap = (() => {
40
+ const fromAgents: AttachmentTypesMap = {}
41
+ for (const ag of Object.values(agents)) {
42
+ if (ag.attachmentTypes) Object.assign(fromAgents, ag.attachmentTypes)
43
+ }
44
+ return Object.keys(fromAgents).length ? fromAgents : core.internals.registry.getAttachmentTypes()
45
+ })()
46
+
47
+ interface GeneratedFixture {
48
+ type: string
49
+ filename: string
50
+ mime: 'application/pdf' | 'image/jpeg'
51
+ bytes: Uint8Array
52
+ dataUrl: string
53
+ schema?: z.ZodType
54
+ expectedKeys: string[]
55
+ }
56
+
57
+ interface DiscoveredRouteItem {
58
+ id: string
59
+ mode: 'rest' | 'workflow'
60
+ method: string
61
+ path: string
62
+ workflowName?: string
63
+ description: string
64
+ inputSchema?: {
65
+ type?: string
66
+ properties?: Record<string, Record<string, unknown>>
67
+ required?: string[]
68
+ }
69
+ }
70
+
71
+ const sanitizeAscii = (str: string): string =>
72
+ str
73
+ .replace(/[\u2013\u2014]/g, '-')
74
+ .replace(/\u2026/g, '...')
75
+ .replace(/[^\x20-\x7E]/g, ' ')
76
+
77
+ const wrapText = (text: string, maxLen = 80): string[] => {
78
+ const words = sanitizeAscii(text).split(/\s+/).filter(Boolean)
79
+ const lines: string[] = []
80
+ let current = ''
81
+ for (const word of words) {
82
+ if (current && current.length + 1 + word.length > maxLen) {
83
+ lines.push(current)
84
+ current = word
85
+ } else {
86
+ current = current ? `${current} ${word}` : word
87
+ }
88
+ }
89
+ if (current) lines.push(current)
90
+ return lines
91
+ }
92
+
93
+ const sampleValueFromJsonSchema = (key: string, prop: Record<string, unknown>): unknown => {
94
+ const desc = String(prop.description ?? '')
95
+ if (prop.type === 'boolean') return true
96
+ if (prop.type === 'number' || prop.type === 'integer') return 1
97
+ if (prop.type === 'array') {
98
+ const items = (prop.items ?? {}) as { properties?: Record<string, Record<string, unknown>>; type?: string }
99
+ if (items.properties) {
100
+ const item: Record<string, unknown> = {}
101
+ for (const [k, v] of Object.entries(items.properties)) {
102
+ item[k] = sampleValueFromJsonSchema(k, v)
103
+ }
104
+ return [item]
105
+ }
106
+ return [`SAMPLE_${key.toUpperCase()}`]
107
+ }
108
+ if (prop.type === 'object') {
109
+ const subProps = (prop.properties ?? {}) as Record<string, Record<string, unknown>>
110
+ const obj: Record<string, unknown> = {}
111
+ for (const [k, v] of Object.entries(subProps)) {
112
+ obj[k] = sampleValueFromJsonSchema(k, v)
113
+ }
114
+ return obj
115
+ }
116
+ const egMatch = /e\.g\.\s*"([^"]+)"/.exec(desc)
117
+ if (egMatch?.[1]) return egMatch[1]
118
+ const orMatch = /^([A-Za-z0-9_-]+)\s+or\s+([A-Za-z0-9_-]+)$/.exec(desc.trim())
119
+ if (orMatch?.[1]) return orMatch[1]
120
+ return `SAMPLE_${key.toUpperCase()}`
121
+ }
122
+
123
+ const formatSchemaLines = (
124
+ properties: Record<string, Record<string, unknown>>,
125
+ sampleObj: Record<string, unknown>
126
+ ): string[] => {
127
+ const lines: string[] = []
128
+ for (const [key, prop] of Object.entries(properties)) {
129
+ const val = sampleObj[key]
130
+ if (Array.isArray(val)) {
131
+ lines.push(`${key}:`)
132
+ for (const item of val) {
133
+ if (typeof item === 'object' && item !== null) {
134
+ const pairs = Object.entries(item as Record<string, unknown>)
135
+ .map(([k, v]) => `${k}: ${String(v)}`)
136
+ .join(', ')
137
+ lines.push(` - ${pairs}`)
138
+ } else {
139
+ lines.push(` - ${String(item)}`)
140
+ }
141
+ }
142
+ } else if (typeof val === 'object' && val !== null) {
143
+ const entries = Object.entries(val as Record<string, unknown>)
144
+ const summary = entries.map(([k, v]) => `${k}: ${String(v)} (granted / authorized)`).join(', ')
145
+ lines.push(`${key}: ${summary}`)
146
+ if (typeof prop.description === 'string') {
147
+ lines.push(...wrapText(` (${prop.description})`, 78))
148
+ }
149
+ } else {
150
+ lines.push(`${key}: ${String(val)}`)
151
+ }
152
+ }
153
+ return lines
154
+ }
155
+
156
+ const escapePdfLiteral = (s: string): string =>
157
+ sanitizeAscii(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
158
+
159
+ const buildSimplePdf = (lines: { text: string; bold?: boolean; size?: number }[]): Uint8Array => {
160
+ let y = 790
161
+ const ops: string[] = ['BT']
162
+ for (const line of lines) {
163
+ const size = line.size ?? 11
164
+ if (y < 40) break
165
+ ops.push(`1 0 0 1 45 ${y} Tm /F${line.bold ? 2 : 1} ${size} Tf (${escapePdfLiteral(line.text)}) Tj`)
166
+ y -= size + 6
167
+ }
168
+ ops.push('ET')
169
+ const streamContent = ops.join('\n')
170
+
171
+ const objects = [
172
+ '1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n',
173
+ '2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n',
174
+ '3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n',
175
+ '4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n',
176
+ '5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n',
177
+ `6 0 obj\n<< /Length ${Buffer.byteLength(streamContent, 'ascii')} >>\nstream\n${streamContent}\nendstream\nendobj\n`
178
+ ]
179
+
180
+ let pdf = '%PDF-1.4\n'
181
+ const offsets: number[] = [0]
182
+ for (const obj of objects) {
183
+ offsets.push(Buffer.byteLength(pdf, 'ascii'))
184
+ pdf += obj
185
+ }
186
+ const xrefStart = Buffer.byteLength(pdf, 'ascii')
187
+ pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
188
+ for (let i = 1; i <= objects.length; i++) {
189
+ pdf += `${String(offsets[i]!).padStart(10, '0')} 00000 n \n`
190
+ }
191
+ pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`
192
+ return new Uint8Array(Buffer.from(pdf, 'ascii'))
193
+ }
194
+
195
+ const buildSimpleJpeg = (quantVal = 16): Uint8Array => {
196
+ const soi = [0xff, 0xd8]
197
+ const app0 = [0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]
198
+ const dqt = [0xff, 0xdb, 0x00, 0x43, 0x00, ...Array(64).fill(quantVal)]
199
+ const sof0 = [0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x40, 0x00, 0x40, 0x01, 0x01, 0x11, 0x00]
200
+ const dhtDc = [0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, ...Array(15).fill(0), 0x00]
201
+ const dhtAc = [0xff, 0xc4, 0x00, 0x14, 0x10, 0x01, ...Array(15).fill(0), 0x00]
202
+ const sos = [0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00]
203
+ const scanData = Array(16).fill(0x00)
204
+ const eoi = [0xff, 0xd9]
205
+ return new Uint8Array([...soi, ...app0, ...dqt, ...sof0, ...dhtDc, ...dhtAc, ...sos, ...scanData, ...eoi])
206
+ }
207
+
208
+ const generatePdfForAttachmentType = async (
209
+ type: string,
210
+ cfg: { description: string; schema: z.ZodType },
211
+ outDir: string
212
+ ): Promise<GeneratedFixture> => {
213
+ const jsonSchema = z.toJSONSchema(cfg.schema, { io: 'output' }) as {
214
+ properties?: Record<string, Record<string, unknown>>
215
+ }
216
+ const properties = jsonSchema.properties ?? {}
217
+ const sampleObj: Record<string, unknown> = {}
218
+ for (const [k, prop] of Object.entries(properties)) {
219
+ sampleObj[k] = sampleValueFromJsonSchema(k, prop)
220
+ }
221
+ cfg.schema.parse(sampleObj)
222
+
223
+ const pdfLines: { text: string; bold?: boolean; size?: number }[] = [
224
+ { text: `OFFICIAL DOCUMENT - ${type.toUpperCase().replaceAll('_', ' ')}`, bold: true, size: 15 },
225
+ { text: `Document Classification Type: ${type}`, bold: true, size: 12 },
226
+ ...wrapText(`Document Description: ${cfg.description}`, 80).map((t) => ({ text: t, bold: false, size: 10 })),
227
+ { text: 'EXTRACTED DOCUMENT FIELDS:', bold: true, size: 12 },
228
+ ...formatSchemaLines(properties, sampleObj).flatMap((fieldLine) =>
229
+ wrapText(fieldLine, 82).map((t) => ({ text: t, bold: false, size: 11 }))
230
+ )
231
+ ]
232
+
233
+ const bytes = buildSimplePdf(pdfLines)
234
+ const filename = `${type}.pdf`
235
+ writeFileSync(join(outDir, filename), bytes)
236
+ return {
237
+ type,
238
+ filename,
239
+ mime: 'application/pdf',
240
+ bytes,
241
+ dataUrl: `data:application/pdf;base64,${Buffer.from(bytes).toString('base64')}`,
242
+ schema: cfg.schema,
243
+ expectedKeys: Object.keys(properties).sort()
244
+ }
245
+ }
246
+
247
+ const generateOtherJpeg = async (outDir: string): Promise<GeneratedFixture> => {
248
+ const bytes = buildSimpleJpeg(16)
249
+ const filename = 'other.jpeg'
250
+ writeFileSync(join(outDir, filename), bytes)
251
+ return {
252
+ type: 'other',
253
+ filename,
254
+ mime: 'image/jpeg',
255
+ bytes,
256
+ dataUrl: `data:image/jpeg;base64,${Buffer.from(bytes).toString('base64')}`,
257
+ expectedKeys: []
258
+ }
259
+ }
260
+
261
+ let tempDir = ''
262
+ let localServer: ServerType | null = null
263
+ const fixtures: GeneratedFixture[] = []
264
+ const uploadedRefs = new Map<string, string>()
265
+ let discoveredDomainRoutes: DiscoveredRouteItem[] = []
266
+ let testAuthUserId = ''
267
+ let testAuthCookie = ''
268
+ let initialEmailCookie = ''
269
+ let asyncTaskId = ''
270
+ let chatId1 = ''
271
+ let userErased = false
272
+
273
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
274
+
275
+ const bearerHeaders = (extra: Record<string, string> = {}) => ({
276
+ authorization: `Bearer ${API_KEY}`,
277
+ ...extra
278
+ })
279
+
280
+ const userHeaders = (extra: Record<string, string> = {}) => ({
281
+ cookie: testAuthCookie,
282
+ origin: BASE_URL,
283
+ ...extra
284
+ })
285
+
286
+ const apiFetch = async (path: string, init: RequestInit = {}, label?: string): Promise<Response> => {
287
+ const method = (init.method ?? 'GET').toUpperCase()
288
+ const t0 = performance.now()
289
+ const res = await fetch(`${BASE_URL}${path}`, {
290
+ ...init,
291
+ signal: init.signal ?? AbortSignal.timeout(240_000)
292
+ })
293
+ const rttMs = performance.now() - t0
294
+ const authHeader = res.headers.get('x-auth-time-ms')
295
+ const authSuffix = authHeader !== null ? ` | auth: ${Number(authHeader).toFixed(3)}ms` : ''
296
+ const tag = label ? `${method} ${path} (${label})` : `${method} ${path}`
297
+ console.log(`[http] ${tag.padEnd(54)} -> ${res.status} | ${rttMs.toFixed(1).padStart(7)}ms${authSuffix}`)
298
+ return res
299
+ }
300
+
301
+ const apiJson = async <T = unknown>(
302
+ path: string,
303
+ init: RequestInit = {},
304
+ useBearer = false,
305
+ label?: string
306
+ ): Promise<{ status: number; data: T; headers: Headers }> => {
307
+ const baseHeaders = useBearer ? bearerHeaders() : userHeaders()
308
+ const res = await apiFetch(
309
+ path,
310
+ {
311
+ ...init,
312
+ headers: {
313
+ ...baseHeaders,
314
+ ...(init.headers as Record<string, string> | undefined)
315
+ }
316
+ },
317
+ label
318
+ )
319
+ const text = await res.text()
320
+ const data = (text ? JSON.parse(text) : null) as T
321
+ return { status: res.status, data, headers: res.headers }
322
+ }
323
+
324
+ const mcpCall = async <T = unknown>(method: string, params: Record<string, unknown> = {}, id = 1): Promise<T> => {
325
+ const label = params.name ? `mcp:${method}:${String(params.name)}` : `mcp:${method}`
326
+ const res = await apiFetch(
327
+ '/api/mcp',
328
+ {
329
+ method: 'POST',
330
+ headers: userHeaders({
331
+ 'content-type': 'application/json',
332
+ accept: 'application/json, text/event-stream'
333
+ }),
334
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params })
335
+ },
336
+ label
337
+ )
338
+ assert.equal(res.status, 200)
339
+ const raw = await res.text()
340
+ const contentType = res.headers.get('content-type') ?? ''
341
+ if (contentType.includes('text/event-stream')) {
342
+ const dataLine = raw
343
+ .split('\n')
344
+ .map((l) => l.trim())
345
+ .find((l) => l.startsWith('data: '))
346
+ if (!dataLine) throw new Error(`No SSE data line in MCP response: ${raw}`)
347
+ const parsed = JSON.parse(dataLine.slice(6)) as { result?: T; error?: unknown }
348
+ if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
349
+ return parsed.result as T
350
+ }
351
+ const parsed = JSON.parse(raw) as { result?: T; error?: unknown }
352
+ if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
353
+ return parsed.result as T
354
+ }
355
+
356
+ const synthesizeDomainInput = (route: DiscoveredRouteItem): Record<string, unknown> => {
357
+ const props = route.inputSchema?.properties ?? {}
358
+ const payload: Record<string, unknown> = {}
359
+ const sampleRefs = [
360
+ uploadedRefs.get('title_deed') ?? [...uploadedRefs.values()][0]!,
361
+ uploadedRefs.get('passport') ?? [...uploadedRefs.values()][1] ?? [...uploadedRefs.values()][0]!
362
+ ]
363
+ for (const [k, prop] of Object.entries(props)) {
364
+ if (k === 'refs') {
365
+ payload[k] = sampleRefs
366
+ } else if (k === 'ref') {
367
+ payload[k] = sampleRefs[0]
368
+ } else {
369
+ payload[k] = sampleValueFromJsonSchema(k, prop)
370
+ }
371
+ }
372
+ return payload
373
+ }
374
+
375
+ before(async () => {
376
+ if (!API_KEY || !WEB_API_KEY) {
377
+ throw new Error('SDK_API_KEY and WEB_API_KEY are required from GCP Secret Manager')
378
+ }
379
+
380
+ tempDir = mkdtempSync(join(tmpdir(), 'ai-sdk-e2e-'))
381
+ const pdfEntries = await Promise.all(
382
+ Object.entries(ACTIVE_ATTACHMENT_TYPES).map(([type, cfg]) => generatePdfForAttachmentType(type, cfg, tempDir))
383
+ )
384
+ const otherJpeg = await generateOtherJpeg(tempDir)
385
+ fixtures.push(...pdfEntries, otherJpeg)
386
+
387
+ if (SPAWN_LOCAL) {
388
+ localServer = serve({ fetch: devApp.fetch, port: PORT })
389
+ let ready = false
390
+ for (let i = 0; i < 40; i++) {
391
+ try {
392
+ const res = await fetch(`${BASE_URL}/api/routes`, { headers: bearerHeaders() })
393
+ if (res.ok) {
394
+ ready = true
395
+ break
396
+ }
397
+ } catch {}
398
+ await sleep(250)
399
+ }
400
+ if (!ready) throw new Error('Local server failed to become ready in time')
401
+ }
402
+
403
+ const existingUsers = await core.auth.listUsers(true).catch(() => [])
404
+ for (const u of existingUsers) {
405
+ if (u.email?.endsWith('@test.local')) {
406
+ await fetch(`${BASE_URL}/api/erase-user`, {
407
+ method: 'POST',
408
+ headers: bearerHeaders({ 'content-type': 'application/json' }),
409
+ body: JSON.stringify({ userId: u.id, backupBeforeErase: false })
410
+ }).catch(() => {})
411
+ }
412
+ }
413
+ })
414
+
415
+ after(async () => {
416
+ if (!userErased && testAuthUserId) {
417
+ await fetch(`${BASE_URL}/api/erase-user`, {
418
+ method: 'POST',
419
+ headers: bearerHeaders({ 'content-type': 'application/json' }),
420
+ body: JSON.stringify({ userId: testAuthUserId, backupBeforeErase: false })
421
+ }).catch(() => {})
422
+ }
423
+ if (localServer) {
424
+ localServer.close()
425
+ }
426
+ if (tempDir) {
427
+ rmSync(tempDir, { recursive: true, force: true })
428
+ }
429
+ })
430
+
431
+ describe('1. Auth & RBAC (/api/auth/*, /api/routes)', () => {
432
+ const testEmail = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@test.local`
433
+ const testPassword = 'TestPassword123!'
434
+
435
+ test('GET /api/health, GET /api/routes & GET /api/auth/session — health check (200) & reject unauthenticated (401)', async () => {
436
+ const healthRes = await apiFetch('/api/health', {}, 'health')
437
+ assert.equal(healthRes.status, 200)
438
+ const healthData = (await healthRes.json()) as { ok: boolean; status: string; service: string }
439
+ assert.equal(healthData.ok, true)
440
+ assert.equal(healthData.status, 'healthy')
441
+ assert.equal(healthData.service, CORE_CONFIG.appName)
442
+
443
+ const unauthRoutesRes = await apiFetch('/api/routes', {}, 'unauth')
444
+ assert.equal(unauthRoutesRes.status, 401)
445
+
446
+ const badBearerRes = await apiFetch(
447
+ '/api/routes',
448
+ { headers: { authorization: 'Bearer invalid-token' } },
449
+ 'invalid-bearer'
450
+ )
451
+ assert.equal(badBearerRes.status, 401)
452
+
453
+ const unauthSessionRes = await apiFetch('/api/auth/session', {}, 'unauth')
454
+ assert.equal(unauthSessionRes.status, 401)
455
+ })
456
+
457
+ test('GET /api/auth/session & GET /api/routes — Admin Bearer cold & warm cache', async () => {
458
+ const adminSessionRes = await apiFetch('/api/auth/session', { headers: bearerHeaders() }, 'admin-cold')
459
+ assert.equal(adminSessionRes.status, 200)
460
+ assert.notEqual(adminSessionRes.headers.get('x-auth-time-ms'), null)
461
+ assert.ok((adminSessionRes.headers.get('server-timing') ?? '').includes('auth;dur='))
462
+ assert.deepEqual(await adminSessionRes.json(), { userId: CORE_CONFIG.auth.adminUserId })
463
+
464
+ const adminWarmRes = await apiFetch('/api/routes', { headers: bearerHeaders() }, 'admin-warm')
465
+ assert.equal(adminWarmRes.status, 200)
466
+ assert.ok(Number(adminWarmRes.headers.get('x-auth-time-ms')) < 5)
467
+ })
468
+
469
+ test('POST /api/auth/sign-up/email — create test user', async () => {
470
+ const signUpRes = await apiFetch('/api/auth/sign-up/email', {
471
+ method: 'POST',
472
+ headers: { 'content-type': 'application/json', origin: BASE_URL },
473
+ body: JSON.stringify({ email: testEmail, password: testPassword, name: 'E2E User' })
474
+ })
475
+ assert.equal(signUpRes.status, 200)
476
+ const signedUpUser = (await signUpRes.json()) as { id: string; email: string; name: string }
477
+ assert.equal(signedUpUser.email, testEmail)
478
+ assert.equal(signedUpUser.name, 'E2E User')
479
+ testAuthUserId = signedUpUser.id
480
+ })
481
+
482
+ test('POST /api/auth/sign-in/email — sign in with email & password', async () => {
483
+ const signInRes = await apiFetch('/api/auth/sign-in/email', {
484
+ method: 'POST',
485
+ headers: { 'content-type': 'application/json', origin: BASE_URL },
486
+ body: JSON.stringify({ email: testEmail, password: testPassword })
487
+ })
488
+ assert.equal(signInRes.status, 200)
489
+ const signedInUser = (await signInRes.json()) as { id: string }
490
+ assert.equal(signedInUser.id, testAuthUserId)
491
+ const signInCookies = signInRes.headers.getSetCookie?.() ?? [signInRes.headers.get('set-cookie') ?? '']
492
+ initialEmailCookie = signInCookies.map((c) => c.split(';')[0]).filter(Boolean).join('; ')
493
+ assert.ok(initialEmailCookie.includes(`${core.internals.auth.sessionCookie}=`))
494
+ })
495
+
496
+ test('POST /api/auth/users/block — block user, verify 401 on session, then unblock', async () => {
497
+ const blockRes = await apiJson<{ userId: string; blocked: boolean; deleted: boolean }>(
498
+ '/api/auth/users/block',
499
+ {
500
+ method: 'POST',
501
+ headers: { 'content-type': 'application/json' },
502
+ body: JSON.stringify({ userId: testAuthUserId, blocked: true })
503
+ },
504
+ true,
505
+ 'block'
506
+ )
507
+ assert.equal(blockRes.status, 200)
508
+ assert.deepEqual(blockRes.data, { userId: testAuthUserId, blocked: true, deleted: true })
509
+
510
+ const blockedSessionRes = await apiFetch(
511
+ '/api/auth/session',
512
+ { headers: { cookie: initialEmailCookie, origin: BASE_URL } },
513
+ 'blocked-cookie'
514
+ )
515
+ assert.equal(blockedSessionRes.status, 401)
516
+
517
+ const unblockRes = await apiJson<{ userId: string; blocked: boolean; deleted: boolean }>(
518
+ '/api/auth/users/block',
519
+ {
520
+ method: 'POST',
521
+ headers: { 'content-type': 'application/json' },
522
+ body: JSON.stringify({ userId: testAuthUserId, blocked: false })
523
+ },
524
+ true,
525
+ 'unblock'
526
+ )
527
+ assert.equal(unblockRes.status, 200)
528
+ assert.deepEqual(unblockRes.data, { userId: testAuthUserId, blocked: false, deleted: false })
529
+ })
530
+
531
+ test('POST /api/auth/custom-token, POST /api/auth/session & GET /api/auth/session — custom token RBAC, ID token & session cookie', async () => {
532
+ const unauthCustomTokenRes = await apiFetch(
533
+ '/api/auth/custom-token',
534
+ {
535
+ method: 'POST',
536
+ headers: { 'content-type': 'application/json' },
537
+ body: JSON.stringify({ uid: testAuthUserId })
538
+ },
539
+ 'unauth-custom-token'
540
+ )
541
+ assert.equal(unauthCustomTokenRes.status, 401)
542
+
543
+ const nonAdminCustomTokenRes = await apiFetch(
544
+ '/api/auth/custom-token',
545
+ {
546
+ method: 'POST',
547
+ headers: { 'content-type': 'application/json', cookie: initialEmailCookie, origin: BASE_URL },
548
+ body: JSON.stringify({ uid: testAuthUserId })
549
+ },
550
+ 'user-custom-token-403'
551
+ )
552
+ assert.equal(nonAdminCustomTokenRes.status, 403)
553
+
554
+ const customTokenRes = await apiFetch('/api/auth/custom-token', {
555
+ method: 'POST',
556
+ headers: bearerHeaders({ 'content-type': 'application/json' }),
557
+ body: JSON.stringify({ uid: testAuthUserId })
558
+ })
559
+ assert.equal(customTokenRes.status, 200)
560
+ const { uid, customToken } = (await customTokenRes.json()) as { uid: string; customToken: string }
561
+ assert.equal(uid, testAuthUserId)
562
+ assert.ok(customToken.length > 20)
563
+
564
+ const exchangeRes = await fetch(
565
+ `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${WEB_API_KEY}`,
566
+ {
567
+ method: 'POST',
568
+ headers: { 'content-type': 'application/json' },
569
+ body: JSON.stringify({ token: customToken, returnSecureToken: true })
570
+ }
571
+ )
572
+ assert.equal(exchangeRes.status, 200)
573
+ const { idToken } = (await exchangeRes.json()) as { idToken: string }
574
+ assert.ok(idToken.length > 20)
575
+
576
+ const idTokenColdRes = await apiFetch(
577
+ '/api/auth/session',
578
+ { headers: { authorization: `Bearer ${idToken}` } },
579
+ 'id-token-cold'
580
+ )
581
+ assert.equal(idTokenColdRes.status, 200)
582
+ assert.deepEqual(await idTokenColdRes.json(), { userId: testAuthUserId })
583
+
584
+ const idTokenWarmRes = await apiFetch(
585
+ '/api/files',
586
+ { headers: { authorization: `Bearer ${idToken}` } },
587
+ 'id-token-warm'
588
+ )
589
+ assert.equal(idTokenWarmRes.status, 200)
590
+ assert.ok(Number(idTokenWarmRes.headers.get('x-auth-time-ms')) < 5)
591
+
592
+ const badSessionRes = await apiFetch(
593
+ '/api/auth/session',
594
+ {
595
+ method: 'POST',
596
+ headers: { 'content-type': 'application/json' },
597
+ body: JSON.stringify({ idToken: 'invalid-id-token' })
598
+ },
599
+ 'invalid-id-token'
600
+ )
601
+ assert.equal(badSessionRes.status, 401)
602
+
603
+ const sessionRes = await apiFetch(
604
+ '/api/auth/session',
605
+ {
606
+ method: 'POST',
607
+ headers: { 'content-type': 'application/json', origin: BASE_URL },
608
+ body: JSON.stringify({ idToken })
609
+ },
610
+ 'mint-cookie'
611
+ )
612
+ assert.equal(sessionRes.status, 200)
613
+ const sessionUser = (await sessionRes.json()) as { id: string }
614
+ assert.equal(sessionUser.id, testAuthUserId)
615
+
616
+ const setCookies = sessionRes.headers.getSetCookie?.() ?? [sessionRes.headers.get('set-cookie') ?? '']
617
+ testAuthCookie = setCookies.map((c) => c.split(';')[0]).filter(Boolean).join('; ')
618
+ assert.ok(testAuthCookie.includes(`${core.internals.auth.sessionCookie}=`))
619
+
620
+ const getSessionRes = await apiFetch('/api/auth/session', { headers: userHeaders() }, 'cookie-strict')
621
+ assert.equal(getSessionRes.status, 200)
622
+ assert.deepEqual(await getSessionRes.json(), { userId: testAuthUserId })
623
+ assert.ok(Number(getSessionRes.headers.get('x-auth-time-ms')) < 5)
624
+
625
+ const cookieWarmRes = await apiFetch('/api/files', { headers: userHeaders() }, 'cookie-warm')
626
+ assert.equal(cookieWarmRes.status, 200)
627
+ assert.ok(Number(cookieWarmRes.headers.get('x-auth-time-ms')) < 5)
628
+ })
629
+
630
+ test('GET /api/auth/users — enforce admin RBAC (403 for user, 200 for admin)', async () => {
631
+ const nonAdminUsersRes = await apiFetch('/api/auth/users', { headers: userHeaders() }, 'user-rbac')
632
+ assert.equal(nonAdminUsersRes.status, 403)
633
+
634
+ const adminUsersRes = await apiFetch('/api/auth/users', { headers: bearerHeaders() }, 'admin-rbac')
635
+ assert.equal(adminUsersRes.status, 200)
636
+ const usersList = (await adminUsersRes.json()) as { id: string }[]
637
+ assert.ok(usersList.some((u) => u.id === testAuthUserId))
638
+ })
639
+
640
+ test('Better-Auth Suite — Email Verification (SHA-256 OTP + 5-attempt limit), Social OAuth (Google/Apple), Recovery Email, Password Reset & Full Account Recovery', async () => {
641
+ // 1. Send email verification & verify SHA-256 hash storage + 5-attempt brute-force invalidation
642
+ const sendVerifyRes = await apiJson<{ sent: boolean; expiresInMinutes: number }>(
643
+ '/api/auth/email/send-verification',
644
+ {
645
+ method: 'POST',
646
+ headers: { 'content-type': 'application/json' },
647
+ body: JSON.stringify({})
648
+ },
649
+ false,
650
+ 'send-verify-1'
651
+ )
652
+ assert.equal(sendVerifyRes.status, 200)
653
+ assert.equal(sendVerifyRes.data.sent, true)
654
+
655
+ const verifyPayload1 = core.internals.auth.getLastDevEmail(testEmail, 'verify_email')
656
+ assert.ok(verifyPayload1, 'Expected dev email outbox to capture verify_email payload')
657
+ assert.match(verifyPayload1.code, /^\d{6}$/)
658
+
659
+ const rawTokenSnap = await core.db.firestore(true).collection('auth_tokens').doc(`${testAuthUserId}_verify_email`).get()
660
+ assert.equal(rawTokenSnap.exists, true)
661
+ const rawTokenData = rawTokenSnap.data()!
662
+ assert.equal(rawTokenData.tokenHash, core.internals.auth.hashToken(verifyPayload1.code))
663
+ assert.notEqual(rawTokenData.tokenHash, verifyPayload1.code)
664
+ assert.equal('code' in rawTokenData, false)
665
+
666
+ for (let attempt = 1; attempt <= 5; attempt++) {
667
+ const badVerifyRes = await apiJson<{ code?: string }>(
668
+ '/api/auth/email/verify',
669
+ {
670
+ method: 'POST',
671
+ headers: { 'content-type': 'application/json' },
672
+ body: JSON.stringify({ email: testEmail, codeOrToken: '000000' })
673
+ },
674
+ false,
675
+ `bad-verify-${attempt}`
676
+ )
677
+ assert.equal(badVerifyRes.status, 400)
678
+ if (attempt === 5) {
679
+ assert.equal(badVerifyRes.data.code, 'TOKEN_MAX_ATTEMPTS_EXCEEDED')
680
+ }
681
+ }
682
+
683
+ // Even the right code must now be rejected after 5 failed attempts
684
+ const exhaustedRes = await apiFetch(
685
+ '/api/auth/email/verify',
686
+ {
687
+ method: 'POST',
688
+ headers: { 'content-type': 'application/json' },
689
+ body: JSON.stringify({ email: testEmail, codeOrToken: verifyPayload1.code })
690
+ },
691
+ 'exhausted-verify'
692
+ )
693
+ assert.equal(exhaustedRes.status, 400)
694
+
695
+ // Re-issue verification OTP and verify with valid 6-digit code
696
+ const resendVerifyRes = await apiJson<{ sent: boolean }>(
697
+ '/api/auth/email/send-verification',
698
+ {
699
+ method: 'POST',
700
+ headers: { 'content-type': 'application/json' },
701
+ body: JSON.stringify({})
702
+ },
703
+ false,
704
+ 'send-verify-2'
705
+ )
706
+ assert.equal(resendVerifyRes.status, 200)
707
+ const verifyPayload2 = core.internals.auth.getLastDevEmail(testEmail, 'verify_email')!
708
+
709
+ const okVerifyRes = await apiJson<{ verified: boolean; userId: string }>(
710
+ '/api/auth/email/verify',
711
+ {
712
+ method: 'POST',
713
+ headers: { 'content-type': 'application/json' },
714
+ body: JSON.stringify({ email: testEmail, codeOrToken: verifyPayload2.code })
715
+ },
716
+ false,
717
+ 'ok-verify'
718
+ )
719
+ assert.equal(okVerifyRes.status, 200)
720
+ assert.equal(okVerifyRes.data.verified, true)
721
+ assert.equal(okVerifyRes.data.userId, testAuthUserId)
722
+
723
+ const verifiedUser = await core.auth.getUser(testAuthUserId)
724
+ assert.equal(verifiedUser?.emailVerified, true)
725
+
726
+ // 2. Social OAuth (Google & Apple) — OIDC signature verification, nonce check, account linking & Apple first-time name
727
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
728
+ const pubJwk = { ...(publicKey.export({ format: 'jwk' }) as JsonWebKey), kid: 'test-oidc-key' }
729
+ core.internals.auth.registerTestOidcPublicKey(pubJwk)
730
+
731
+ const signTestJwt = (claims: Record<string, unknown>) => {
732
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: 'test-oidc-key' })).toString(
733
+ 'base64url'
734
+ )
735
+ const payload = Buffer.from(
736
+ JSON.stringify({
737
+ iat: Math.floor(Date.now() / 1000) - 10,
738
+ exp: Math.floor(Date.now() / 1000) + 3600,
739
+ ...claims
740
+ })
741
+ ).toString('base64url')
742
+ const sig = cryptoSign('RSA-SHA256', Buffer.from(`${header}.${payload}`, 'utf8'), privateKey).toString(
743
+ 'base64url'
744
+ )
745
+ return `${header}.${payload}.${sig}`
746
+ }
747
+
748
+ let appleCreatedUserId = ''
749
+ try {
750
+ // 2a. Google OAuth linking to existing verified email user
751
+ const googleSub = `google_sub_${Date.now()}`
752
+ const googleIdToken = signTestJwt({
753
+ iss: 'https://accounts.google.com',
754
+ sub: googleSub,
755
+ aud: 'test-google-client',
756
+ email: testEmail,
757
+ email_verified: true,
758
+ name: 'E2E User',
759
+ picture: 'https://example.com/avatar.png'
760
+ })
761
+
762
+ const googleSignInRes = await apiFetch(
763
+ '/api/auth/sign-in/social',
764
+ {
765
+ method: 'POST',
766
+ headers: { 'content-type': 'application/json', origin: BASE_URL },
767
+ body: JSON.stringify({ provider: 'google', idToken: googleIdToken })
768
+ },
769
+ 'social-google-link'
770
+ )
771
+ assert.equal(googleSignInRes.status, 200)
772
+ const googleUser = (await googleSignInRes.json()) as {
773
+ id: string
774
+ email: string
775
+ emailVerified: boolean
776
+ linkedProviders?: { provider: string; providerUserId: string }[]
777
+ }
778
+ assert.equal(googleUser.id, testAuthUserId)
779
+ assert.equal(googleUser.emailVerified, true)
780
+ assert.ok(googleUser.linkedProviders?.some((p) => p.provider === 'google' && p.providerUserId === googleSub))
781
+
782
+ // 2b. Apple OAuth with raw nonce + first-time Capacitor profile.name persistence
783
+ const rawNonce = `nonce_${Date.now()}`
784
+ const nonceSha256 = createHash('sha256').update(rawNonce).digest('hex')
785
+ const appleSub = `apple_sub_${Date.now()}`
786
+ const appleEmail = `apple-${Date.now()}@test.local`
787
+ const appleIdToken = signTestJwt({
788
+ iss: 'https://appleid.apple.com',
789
+ sub: appleSub,
790
+ aud: 'com.example.capacitor',
791
+ email: appleEmail,
792
+ email_verified: 'true',
793
+ nonce: nonceSha256
794
+ })
795
+
796
+ // Nonce mismatch must fail with 401
797
+ const badNonceRes = await apiFetch(
798
+ '/api/auth/sign-in/social',
799
+ {
800
+ method: 'POST',
801
+ headers: { 'content-type': 'application/json', origin: BASE_URL },
802
+ body: JSON.stringify({
803
+ provider: 'apple',
804
+ idToken: appleIdToken,
805
+ nonce: 'wrong-nonce'
806
+ })
807
+ },
808
+ 'social-apple-bad-nonce'
809
+ )
810
+ assert.equal(badNonceRes.status, 401)
811
+
812
+ const appleSignInRes = await apiFetch(
813
+ '/api/auth/sign-in/social',
814
+ {
815
+ method: 'POST',
816
+ headers: { 'content-type': 'application/json', origin: BASE_URL },
817
+ body: JSON.stringify({
818
+ provider: 'apple',
819
+ idToken: appleIdToken,
820
+ nonce: rawNonce,
821
+ profile: { name: 'Apple First-Auth Name' }
822
+ })
823
+ },
824
+ 'social-apple-first-auth'
825
+ )
826
+ assert.equal(appleSignInRes.status, 200)
827
+ const appleUser = (await appleSignInRes.json()) as { id: string; name: string; email: string }
828
+ appleCreatedUserId = appleUser.id
829
+ assert.equal(appleUser.name, 'Apple First-Auth Name')
830
+ assert.equal(appleUser.email, appleEmail)
831
+
832
+ // Subsequent Apple sign-in (without profile.name or email in token) resolves by (provider, sub) and keeps name
833
+ const appleSubsequentToken = signTestJwt({
834
+ iss: 'https://appleid.apple.com',
835
+ sub: appleSub,
836
+ aud: 'com.example.capacitor'
837
+ })
838
+ const appleSubsequentRes = await apiFetch(
839
+ '/api/auth/sign-in/social',
840
+ {
841
+ method: 'POST',
842
+ headers: { 'content-type': 'application/json', origin: BASE_URL },
843
+ body: JSON.stringify({ provider: 'apple', idToken: appleSubsequentToken })
844
+ },
845
+ 'social-apple-subsequent'
846
+ )
847
+ assert.equal(appleSubsequentRes.status, 200)
848
+ const appleUser2 = (await appleSubsequentRes.json()) as { id: string; name: string }
849
+ assert.equal(appleUser2.id, appleCreatedUserId)
850
+ assert.equal(appleUser2.name, 'Apple First-Auth Name')
851
+ } finally {
852
+ core.internals.auth.registerTestOidcPublicKey(null)
853
+ if (appleCreatedUserId) {
854
+ await apiFetch(
855
+ '/api/erase-user',
856
+ {
857
+ method: 'POST',
858
+ headers: bearerHeaders({ 'content-type': 'application/json' }),
859
+ body: JSON.stringify({ userId: appleCreatedUserId, backupBeforeErase: false })
860
+ },
861
+ 'cleanup-apple-user'
862
+ ).catch(() => {})
863
+ }
864
+ }
865
+
866
+ // 3. Secondary Recovery Email Binding
867
+ const recoveryEmail = `recovery-${Date.now()}@test.local`
868
+ const sameEmailRes = await apiFetch(
869
+ '/api/auth/recovery-email/set',
870
+ {
871
+ method: 'POST',
872
+ headers: userHeaders({ 'content-type': 'application/json' }),
873
+ body: JSON.stringify({ recoveryEmail: testEmail })
874
+ },
875
+ 'recovery-same-as-primary'
876
+ )
877
+ assert.equal(sameEmailRes.status, 400)
878
+
879
+ const setRecoveryRes = await apiJson<{
880
+ recoveryEmail: string
881
+ recoveryEmailVerified: boolean
882
+ sent: boolean
883
+ }>(
884
+ '/api/auth/recovery-email/set',
885
+ {
886
+ method: 'POST',
887
+ headers: { 'content-type': 'application/json' },
888
+ body: JSON.stringify({ recoveryEmail })
889
+ },
890
+ false,
891
+ 'set-recovery-email'
892
+ )
893
+ assert.equal(setRecoveryRes.status, 200)
894
+ assert.equal(setRecoveryRes.data.recoveryEmail, recoveryEmail)
895
+ assert.equal(setRecoveryRes.data.recoveryEmailVerified, false)
896
+
897
+ const recoveryVerifyPayload = core.internals.auth.getLastDevEmail(recoveryEmail, 'verify_recovery_email')
898
+ assert.ok(recoveryVerifyPayload)
899
+
900
+ const verifyRecoveryRes = await apiJson<{
901
+ recoveryEmail: string
902
+ recoveryEmailVerified: boolean
903
+ verified: boolean
904
+ }>(
905
+ '/api/auth/recovery-email/verify',
906
+ {
907
+ method: 'POST',
908
+ headers: { 'content-type': 'application/json' },
909
+ body: JSON.stringify({ codeOrToken: recoveryVerifyPayload.code })
910
+ },
911
+ false,
912
+ 'verify-recovery-email'
913
+ )
914
+ assert.equal(verifyRecoveryRes.status, 200)
915
+ assert.equal(verifyRecoveryRes.data.recoveryEmailVerified, true)
916
+
917
+ // 4. Password Forgot & Reset (with anti-enumeration & global session revocation)
918
+ const unknownForgotRes = await apiJson<{ sent: boolean }>(
919
+ '/api/auth/password/forgot',
920
+ {
921
+ method: 'POST',
922
+ headers: { 'content-type': 'application/json' },
923
+ body: JSON.stringify({ email: 'nonexistent-user@test.local' })
924
+ },
925
+ false,
926
+ 'forgot-nonexistent'
927
+ )
928
+ assert.equal(unknownForgotRes.status, 200)
929
+ assert.equal(unknownForgotRes.data.sent, true)
930
+
931
+ const forgotRes = await apiJson<{ sent: boolean }>(
932
+ '/api/auth/password/forgot',
933
+ {
934
+ method: 'POST',
935
+ headers: { 'content-type': 'application/json' },
936
+ body: JSON.stringify({ email: testEmail })
937
+ },
938
+ false,
939
+ 'forgot-password'
940
+ )
941
+ assert.equal(forgotRes.status, 200)
942
+ const resetPayload = core.internals.auth.getLastDevEmail(testEmail, 'reset_password')
943
+ assert.ok(resetPayload)
944
+
945
+ const resetPasswordVal = 'ResetPassword456!'
946
+ const resetRes = await apiJson<{ reset: boolean }>(
947
+ '/api/auth/password/reset',
948
+ {
949
+ method: 'POST',
950
+ headers: { 'content-type': 'application/json' },
951
+ body: JSON.stringify({
952
+ email: testEmail,
953
+ codeOrToken: resetPayload.token,
954
+ newPassword: resetPasswordVal
955
+ })
956
+ },
957
+ false,
958
+ 'reset-password'
959
+ )
960
+ assert.equal(resetRes.status, 200)
961
+ assert.equal(resetRes.data.reset, true)
962
+
963
+ // Previous session cookie must be revoked after password reset
964
+ const revokedAfterResetRes = await apiFetch(
965
+ '/api/auth/session',
966
+ { headers: { cookie: testAuthCookie, origin: BASE_URL } },
967
+ 'session-revoked-after-reset'
968
+ )
969
+ assert.equal(revokedAfterResetRes.status, 401)
970
+
971
+ // 5. Full Account Recovery via Verified Secondary Recovery Email
972
+ const initiateRecoveryRes = await apiJson<{ initiated: boolean }>(
973
+ '/api/auth/account-recovery/initiate',
974
+ {
975
+ method: 'POST',
976
+ headers: { 'content-type': 'application/json' },
977
+ body: JSON.stringify({ primaryEmail: testEmail, recoveryEmail })
978
+ },
979
+ false,
980
+ 'initiate-account-recovery'
981
+ )
982
+ assert.equal(initiateRecoveryRes.status, 200)
983
+ assert.equal(initiateRecoveryRes.data.initiated, true)
984
+
985
+ const accountRecoveryPayload = core.internals.auth.getLastDevEmail(recoveryEmail, 'account_recovery')
986
+ assert.ok(accountRecoveryPayload)
987
+
988
+ const recoveredPrimaryEmail = `recovered-${Date.now()}@test.local`
989
+ const completeRecoveryRes = await apiFetch(
990
+ '/api/auth/account-recovery/complete',
991
+ {
992
+ method: 'POST',
993
+ headers: { 'content-type': 'application/json', origin: BASE_URL },
994
+ body: JSON.stringify({
995
+ primaryEmail: testEmail,
996
+ recoveryEmail,
997
+ codeOrToken: accountRecoveryPayload.code,
998
+ newPrimaryEmail: recoveredPrimaryEmail,
999
+ newPassword: testPassword
1000
+ })
1001
+ },
1002
+ 'complete-account-recovery'
1003
+ )
1004
+ assert.equal(completeRecoveryRes.status, 200)
1005
+ const recoveredUser = (await completeRecoveryRes.json()) as {
1006
+ id: string
1007
+ email: string
1008
+ emailVerified: boolean
1009
+ }
1010
+ assert.equal(recoveredUser.id, testAuthUserId)
1011
+ assert.equal(recoveredUser.email, recoveredPrimaryEmail)
1012
+ assert.equal(recoveredUser.emailVerified, true)
1013
+
1014
+ const recoveryCookies =
1015
+ completeRecoveryRes.headers.getSetCookie?.() ?? [completeRecoveryRes.headers.get('set-cookie') ?? '']
1016
+ testAuthCookie = recoveryCookies.map((c) => c.split(';')[0]).filter(Boolean).join('; ')
1017
+ assert.ok(testAuthCookie.includes(`${core.internals.auth.sessionCookie}=`))
1018
+
1019
+ const postRecoverySessionRes = await apiFetch(
1020
+ '/api/auth/session',
1021
+ { headers: userHeaders() },
1022
+ 'post-recovery-session'
1023
+ )
1024
+ assert.equal(postRecoverySessionRes.status, 200)
1025
+ })
1026
+ })
1027
+
1028
+ describe('2. Files CRUD (/api/files, /api/files/:ref)', () => {
1029
+ test('POST /api/files & GET /api/files/:ref — validation (400), missing (404) & cross-tenant guard (403)', async () => {
1030
+ const badForm = new FormData()
1031
+ badForm.append('other', 'not-a-file')
1032
+ const badRes = await apiFetch('/api/files', { method: 'POST', headers: userHeaders(), body: badForm }, 'missing-file')
1033
+ assert.equal(badRes.status, 400)
1034
+
1035
+ const badMimeForm = new FormData()
1036
+ badMimeForm.append('file', new File([new TextEncoder().encode('hello')], 'note.txt', { type: 'text/plain' }))
1037
+ const badMimeRes = await apiFetch(
1038
+ '/api/files',
1039
+ { method: 'POST', headers: userHeaders(), body: badMimeForm },
1040
+ 'bad-mime'
1041
+ )
1042
+ assert.equal(badMimeRes.status, 400)
1043
+
1044
+ const missingFileRes = await apiFetch(
1045
+ `/api/files/${testAuthUserId}_000000000000000000000000`,
1046
+ { headers: userHeaders() },
1047
+ 'missing-ref'
1048
+ )
1049
+ assert.equal(missingFileRes.status, 404)
1050
+
1051
+ const crossTenantRes = await apiFetch(
1052
+ '/api/files/otheruser_000000000000000000000000',
1053
+ { headers: userHeaders() },
1054
+ 'cross-tenant'
1055
+ )
1056
+ assert.equal(crossTenantRes.status, 403)
1057
+ })
1058
+
1059
+ test('POST /api/files, GET /api/files/:ref & GET /api/files/:ref?meta=true — upload, download & metadata across all fixtures', async () => {
1060
+ await Promise.all(
1061
+ fixtures.map(async (fixture) => {
1062
+ const form = new FormData()
1063
+ form.append('file', new File([Buffer.from(fixture.bytes)], fixture.filename, { type: fixture.mime }))
1064
+ const res = await apiFetch(
1065
+ '/api/files',
1066
+ { method: 'POST', headers: userHeaders(), body: form },
1067
+ fixture.filename
1068
+ )
1069
+ assert.equal(res.status, 200)
1070
+ const { ref } = (await res.json()) as { ref: string }
1071
+ assert.match(ref, new RegExp(`^${testAuthUserId}_[a-f0-9]{24}$`))
1072
+ uploadedRefs.set(fixture.type, ref)
1073
+
1074
+ const getRes = await apiFetch(`/api/files/${ref}`, { headers: userHeaders() }, fixture.type)
1075
+ assert.equal(getRes.status, 200)
1076
+ assert.equal(getRes.headers.get('content-type'), fixture.mime)
1077
+ assert.ok((getRes.headers.get('cache-control') ?? '').includes('immutable'))
1078
+ const downloaded = new Uint8Array(await getRes.arrayBuffer())
1079
+ assert.equal(downloaded.byteLength, fixture.bytes.byteLength)
1080
+
1081
+ const metaRes = await apiFetch(`/api/files/${ref}?meta=true`, { headers: userHeaders() }, `${fixture.type}:meta`)
1082
+ assert.equal(metaRes.status, 200)
1083
+ const meta = (await metaRes.json()) as { id: string; fileName: string; deleted: boolean; userId: string }
1084
+ assert.equal(meta.id, ref)
1085
+ assert.equal(meta.userId, testAuthUserId)
1086
+ assert.equal(meta.deleted, false)
1087
+ })
1088
+ )
1089
+
1090
+ const sampleRef = uploadedRefs.get(fixtures[0]!.type)!
1091
+ const signedRes = await apiFetch(`/api/files/${sampleRef}?signed=true`, { headers: userHeaders() }, 'signed-download')
1092
+ assert.equal(signedRes.status, 200)
1093
+ const signedData = (await signedRes.json()) as { ref: string; url: string; expiresAt: string }
1094
+ assert.equal(signedData.ref, sampleRef)
1095
+ assert.ok(signedData.url.startsWith('https://storage.googleapis.com/'))
1096
+ assert.ok(Date.parse(signedData.expiresAt) > Date.now())
1097
+
1098
+ const userCore = new Core({ env: ENV, userId: testAuthUserId, role: 'user' })
1099
+ const signedUp = await userCore.files.getSignedUploadUrl({
1100
+ name: 'direct-upload.pdf',
1101
+ type: 'application/pdf',
1102
+ sha256: 'a'.repeat(64)
1103
+ })
1104
+ assert.equal(signedUp.ref, `${testAuthUserId}_${'a'.repeat(24)}`)
1105
+ assert.equal(signedUp.deduplicated, false)
1106
+ assert.ok(signedUp.url.startsWith('https://storage.googleapis.com/'))
1107
+ assert.ok(Date.parse(signedUp.expiresAt) > Date.now())
1108
+ })
1109
+
1110
+ test('GET /api/files & POST /api/files — list files & verify SHA-256 deduplication', async () => {
1111
+ const listFilesRes = await apiFetch('/api/files', { headers: userHeaders() })
1112
+ assert.equal(listFilesRes.status, 200)
1113
+ const listedFiles = (await listFilesRes.json()) as { id: string }[]
1114
+ assert.ok(listedFiles.length >= fixtures.length)
1115
+
1116
+ const firstFixture = fixtures[0]!
1117
+ const dupForm = new FormData()
1118
+ dupForm.append('file', new File([Buffer.from(firstFixture.bytes)], firstFixture.filename, { type: firstFixture.mime }))
1119
+ const dupRes = await apiFetch('/api/files', { method: 'POST', headers: userHeaders(), body: dupForm }, 'dedupe')
1120
+ assert.equal(dupRes.status, 200)
1121
+ const { ref: dupRef } = (await dupRes.json()) as { ref: string }
1122
+ assert.equal(dupRef, uploadedRefs.get(firstFixture.type)!)
1123
+ })
1124
+
1125
+ test('DELETE /api/files/:ref & POST /api/files — soft-delete file, verify 404 & restore on re-upload', async () => {
1126
+ const tempBytes = buildSimpleJpeg(24)
1127
+ const tempForm = new FormData()
1128
+ tempForm.append('file', new File([Buffer.from(tempBytes)], 'disposable.jpeg', { type: 'image/jpeg' }))
1129
+ const tempUp = await apiFetch('/api/files', { method: 'POST', headers: userHeaders(), body: tempForm }, 'disposable')
1130
+ assert.equal(tempUp.status, 200)
1131
+ const { ref: disposableRef } = (await tempUp.json()) as { ref: string }
1132
+
1133
+ const delRes = await apiFetch(`/api/files/${disposableRef}`, { method: 'DELETE', headers: userHeaders() }, 'soft-del')
1134
+ assert.equal(delRes.status, 200)
1135
+ assert.deepEqual(await delRes.json(), { ref: disposableRef, deleted: true })
1136
+
1137
+ const afterDelRes = await apiFetch(`/api/files/${disposableRef}`, { headers: userHeaders() }, 'after-del')
1138
+ assert.equal(afterDelRes.status, 404)
1139
+
1140
+ const restoreForm = new FormData()
1141
+ restoreForm.append('file', new File([Buffer.from(tempBytes)], 'disposable.jpeg', { type: 'image/jpeg' }))
1142
+ const restoreRes = await apiFetch(
1143
+ '/api/files',
1144
+ { method: 'POST', headers: userHeaders(), body: restoreForm },
1145
+ 'restore'
1146
+ )
1147
+ assert.equal(restoreRes.status, 200)
1148
+ const afterRestoreRes = await apiFetch(`/api/files/${disposableRef}`, { headers: userHeaders() }, 'after-restore')
1149
+ assert.equal(afterRestoreRes.status, 200)
1150
+ await apiFetch(`/api/files/${disposableRef}`, { method: 'DELETE', headers: userHeaders() }, 'cleanup-disposable')
1151
+ })
1152
+ })
1153
+
1154
+ describe('3. Route Catalog & Attachment Scanning (/api/routes, /api/scan-attachment)', () => {
1155
+ test('GET /api/routes & GET /api/routes?format=md — verify all core/domain routes & deterministic API_CONTRACT.md introspection', async () => {
1156
+ const catalogRes = await apiJson<RoutesIntrospectionResponse | DiscoveredRouteItem[]>('/api/routes', {}, true)
1157
+ assert.equal(catalogRes.status, 200)
1158
+
1159
+ const routeList: DiscoveredRouteItem[] = Array.isArray(catalogRes.data)
1160
+ ? catalogRes.data
1161
+ : (catalogRes.data.routes as DiscoveredRouteItem[])
1162
+ if (!Array.isArray(catalogRes.data)) {
1163
+ assert.equal(catalogRes.data.appName, CORE_CONFIG.appName)
1164
+ assert.ok(catalogRes.data.collections && 'chat' in catalogRes.data.collections)
1165
+ assert.ok('user_auth_meta' in catalogRes.data.collections!)
1166
+ assert.ok('auth_tokens' in catalogRes.data.collections!)
1167
+ assert.ok('email_log' in catalogRes.data.collections!)
1168
+ assert.ok('email_suppression' in catalogRes.data.collections!)
1169
+ }
1170
+
1171
+ const registeredIds = new Set(routeList.map((r) => r.id))
1172
+ for (const coreId of Object.keys(core.internals.context.routeDefaults)) {
1173
+ assert.ok(registeredIds.has(coreId), `Expected core route ${coreId} in GET /api/routes`)
1174
+ }
1175
+
1176
+ discoveredDomainRoutes = routeList.filter((r) => !(r.id in core.internals.context.routeDefaults))
1177
+ assert.equal(discoveredDomainRoutes.length, Object.keys(customRoutes).length)
1178
+
1179
+ // Verify deterministic Markdown rendering (GET /api/routes?format=md)
1180
+ const mdRes = await apiFetch('/api/routes?format=md', { headers: bearerHeaders() }, 'routes-md')
1181
+ assert.equal(mdRes.status, 200)
1182
+ assert.ok((mdRes.headers.get('content-type') ?? '').includes('text/markdown'))
1183
+ const mdText = await mdRes.text()
1184
+ assert.ok(mdText.includes(`# API Contract & Schema Reference — ${CORE_CONFIG.appName}`))
1185
+ assert.ok(mdText.includes('POST /api/auth/sign-in/social'))
1186
+ assert.ok(mdText.includes('POST /api/auth/email/send-verification'))
1187
+ assert.ok(mdText.includes('POST /api/auth/email/verify'))
1188
+ assert.ok(mdText.includes('POST /api/auth/password/forgot'))
1189
+ assert.ok(mdText.includes('POST /api/auth/password/reset'))
1190
+ assert.ok(mdText.includes('POST /api/auth/recovery-email/set'))
1191
+ assert.ok(mdText.includes('POST /api/auth/recovery-email/verify'))
1192
+ assert.ok(mdText.includes('POST /api/auth/account-recovery/initiate'))
1193
+ assert.ok(mdText.includes('POST /api/auth/account-recovery/complete'))
1194
+ assert.ok(mdText.includes('POST /api/emails/send'))
1195
+ assert.ok(mdText.includes('POST /api/emails/batch'))
1196
+ assert.ok(mdText.includes('GET /api/emails/logs'))
1197
+ assert.ok(mdText.includes('POST /api/emails/webhooks/:provider'))
1198
+ })
1199
+
1200
+ test('POST /api/scan-attachment — OCR, classify & extract structured fields for 1 single attachment', async () => {
1201
+ const singleFixture = fixtures[0]!
1202
+ const ref = uploadedRefs.get(singleFixture.type)!
1203
+ const { status, data: scan } = await apiJson<{
1204
+ type: string
1205
+ confidence: number
1206
+ fields: Record<string, unknown>
1207
+ markdown: string
1208
+ }>(
1209
+ '/api/scan-attachment',
1210
+ {
1211
+ method: 'POST',
1212
+ headers: { 'content-type': 'application/json' },
1213
+ body: JSON.stringify({ ref })
1214
+ },
1215
+ false,
1216
+ singleFixture.type
1217
+ )
1218
+ assert.equal(status, 200)
1219
+ assert.equal(scan.type, singleFixture.type)
1220
+ assert.ok(scan.confidence > 0)
1221
+ assert.ok(scan.markdown.length > 0)
1222
+ assert.deepEqual(Object.keys(scan.fields).sort(), singleFixture.expectedKeys)
1223
+ if (singleFixture.schema) {
1224
+ assert.equal(singleFixture.schema.safeParse(scan.fields).success, true)
1225
+ }
1226
+ })
1227
+
1228
+ test('POST /api/scan-attachment — verify Firestore scan cache short-circuit & negative caching (scanError)', async () => {
1229
+ const sampleFixture = fixtures[0]!
1230
+ const sampleRef = uploadedRefs.get(sampleFixture.type)!
1231
+ const cachedRes = await apiJson<{ type: string; fields: Record<string, unknown>; markdown: string }>(
1232
+ '/api/scan-attachment',
1233
+ {
1234
+ method: 'POST',
1235
+ headers: { 'content-type': 'application/json' },
1236
+ body: JSON.stringify({ ref: sampleRef })
1237
+ },
1238
+ false,
1239
+ 'cached'
1240
+ )
1241
+ assert.equal(cachedRes.status, 200)
1242
+ assert.equal(cachedRes.data.type, sampleFixture.type)
1243
+ assert.deepEqual(Object.keys(cachedRes.data.fields).sort(), sampleFixture.expectedKeys)
1244
+
1245
+ const corruptedBytes = new TextEncoder().encode('%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\n%%EOF-CORRUPTED')
1246
+ const badForm = new FormData()
1247
+ badForm.append('file', new File([Buffer.from(corruptedBytes)], 'corrupted.pdf', { type: 'application/pdf' }))
1248
+ const upBadRes = await apiFetch('/api/files', { method: 'POST', headers: userHeaders(), body: badForm }, 'corrupted-pdf')
1249
+ assert.equal(upBadRes.status, 200)
1250
+ const { ref: corruptedRef } = (await upBadRes.json()) as { ref: string }
1251
+
1252
+ const scanBadRes = await apiJson<{ error: string; code: string }>(
1253
+ '/api/scan-attachment',
1254
+ {
1255
+ method: 'POST',
1256
+ headers: { 'content-type': 'application/json' },
1257
+ body: JSON.stringify({ ref: corruptedRef })
1258
+ },
1259
+ false,
1260
+ 'scan-corrupted'
1261
+ )
1262
+ assert.equal(scanBadRes.status, 400)
1263
+ assert.ok(scanBadRes.data.error.length > 10)
1264
+
1265
+ const badMetaRes = await apiJson<{
1266
+ id: string
1267
+ scanError?: { status: string; reason: string; agentHint: string }
1268
+ }>(`/api/files/${corruptedRef}?meta=true`, {}, false, 'corrupted-meta')
1269
+ assert.equal(badMetaRes.status, 200)
1270
+ assert.equal(badMetaRes.data.scanError?.status, 'unparseable')
1271
+ assert.ok((badMetaRes.data.scanError?.agentHint ?? '').length > 10)
1272
+
1273
+ const t0 = performance.now()
1274
+ const shortCircuitBatch = await apiJson<{
1275
+ attachments: { ref: string; scan?: { type: string }; error?: string }[]
1276
+ }>(
1277
+ '/api/process-attachments',
1278
+ {
1279
+ method: 'POST',
1280
+ headers: { 'content-type': 'application/json' },
1281
+ body: JSON.stringify({ refs: [sampleRef, corruptedRef] })
1282
+ },
1283
+ false,
1284
+ 'negative-cache-short-circuit'
1285
+ )
1286
+ const elapsedMs = performance.now() - t0
1287
+ assert.equal(shortCircuitBatch.status, 200)
1288
+ assert.equal(shortCircuitBatch.data.attachments[0]?.scan?.type, sampleFixture.type)
1289
+ assert.equal(shortCircuitBatch.data.attachments[1]?.error, badMetaRes.data.scanError?.agentHint)
1290
+ assert.ok(elapsedMs < 1500)
1291
+
1292
+ await apiFetch(`/api/files/${corruptedRef}`, { method: 'DELETE', headers: userHeaders() }, 'cleanup-corrupted')
1293
+ })
1294
+ })
1295
+
1296
+ describe('4. Batch Attachment Processing & Async Task Lifecycle (/api/process-attachments, /api/tasks/:taskId)', () => {
1297
+ test('POST /api/process-attachments — 20-shard parallel fan-out across all attachment types + other.jpeg', async () => {
1298
+ const allRefs = fixtures.map((f) => uploadedRefs.get(f.type)!)
1299
+ assert.equal(allRefs.length, Object.keys(ACTIVE_ATTACHMENT_TYPES).length + 1)
1300
+
1301
+ const procRes = await apiJson<{
1302
+ attachments: {
1303
+ ref: string
1304
+ scan?: { type: string; confidence: number; fields: Record<string, unknown>; markdown: string }
1305
+ error?: string
1306
+ }[]
1307
+ }>(
1308
+ '/api/process-attachments',
1309
+ {
1310
+ method: 'POST',
1311
+ headers: { 'content-type': 'application/json' },
1312
+ body: JSON.stringify({ refs: allRefs })
1313
+ },
1314
+ false,
1315
+ `all-${allRefs.length}-attachments`
1316
+ )
1317
+ assert.equal(procRes.status, 200)
1318
+ assert.equal(procRes.data.attachments.length, allRefs.length)
1319
+ procRes.data.attachments.forEach((item, idx) => {
1320
+ const fixture = fixtures[idx]!
1321
+ assert.equal(item.ref, allRefs[idx]!)
1322
+ assert.equal(item.error, undefined)
1323
+ assert.ok(item.scan)
1324
+ assert.equal(item.scan.type, fixture.type)
1325
+ assert.ok(item.scan.confidence > 0)
1326
+ assert.ok(item.scan.markdown.length > 0)
1327
+ assert.deepEqual(Object.keys(item.scan.fields).sort(), fixture.expectedKeys)
1328
+ if (fixture.schema) {
1329
+ assert.equal(fixture.schema.safeParse(item.scan.fields).success, true)
1330
+ }
1331
+ })
1332
+ })
1333
+
1334
+ test('POST /api/process-attachments — partial error isolation with missing ref', async () => {
1335
+ const firstRef = uploadedRefs.get(fixtures[0]!.type)!
1336
+ const missingRef = `${testAuthUserId}_000000000000000000000000`
1337
+ const partialErrRes = await apiJson<{
1338
+ attachments: { ref: string; scan?: { type: string }; error?: string }[]
1339
+ }>(
1340
+ '/api/process-attachments',
1341
+ {
1342
+ method: 'POST',
1343
+ headers: { 'content-type': 'application/json' },
1344
+ body: JSON.stringify({ refs: [firstRef, missingRef] })
1345
+ },
1346
+ false,
1347
+ 'partial-error'
1348
+ )
1349
+ assert.equal(partialErrRes.status, 200)
1350
+ assert.equal(partialErrRes.data.attachments[0]?.scan?.type, fixtures[0]!.type)
1351
+ assert.equal(typeof partialErrRes.data.attachments[1]?.error, 'string')
1352
+ })
1353
+
1354
+ test('POST /api/process-attachments?async=true — async dispatch (202 Accepted)', async () => {
1355
+ const allRefs = fixtures.map((f) => uploadedRefs.get(f.type)!)
1356
+ const asyncRes = await apiJson<{ taskId: string; status: string; pollUrl: string }>(
1357
+ '/api/process-attachments?async=true',
1358
+ {
1359
+ method: 'POST',
1360
+ headers: { 'content-type': 'application/json', prefer: 'respond-async' },
1361
+ body: JSON.stringify({ refs: allRefs.slice(0, 2) })
1362
+ },
1363
+ false,
1364
+ 'respond-async'
1365
+ )
1366
+ assert.equal(asyncRes.status, 202)
1367
+ assert.equal(asyncRes.headers.get('preference-applied'), 'respond-async')
1368
+ assert.ok(asyncRes.data.taskId.length > 5)
1369
+ assert.equal(asyncRes.data.pollUrl, `/api/tasks/${asyncRes.data.taskId}`)
1370
+ asyncTaskId = asyncRes.data.taskId
1371
+ })
1372
+
1373
+ test('POST /api/tasks/:taskId/signal — send signal to task', async () => {
1374
+ const sigRes = await apiJson<{ taskId: string; signaled: boolean }>(
1375
+ `/api/tasks/${asyncTaskId}/signal`,
1376
+ {
1377
+ method: 'POST',
1378
+ headers: { 'content-type': 'application/json' },
1379
+ body: JSON.stringify({ signal: 'ping', payload: { ok: true } })
1380
+ },
1381
+ false,
1382
+ 'signal'
1383
+ )
1384
+ assert.equal(sigRes.status, 200)
1385
+ assert.deepEqual(sigRes.data, { taskId: asyncTaskId, signaled: true })
1386
+ })
1387
+
1388
+ test('GET /api/tasks/:taskId?envelope=true — poll task status until completed', async () => {
1389
+ let taskCompleted = false
1390
+ for (let i = 0; i < 40; i++) {
1391
+ const pollRes = await apiJson<{ status?: string; response?: { attachments: unknown[] } }>(
1392
+ `/api/tasks/${asyncTaskId}?envelope=true`,
1393
+ {},
1394
+ false,
1395
+ `poll-${i + 1}`
1396
+ )
1397
+ if (pollRes.status === 200 && pollRes.data.status === 'completed') {
1398
+ assert.equal(pollRes.data.response?.attachments.length, 2)
1399
+ taskCompleted = true
1400
+ break
1401
+ }
1402
+ await sleep(100)
1403
+ }
1404
+ assert.equal(taskCompleted, true)
1405
+ })
1406
+
1407
+ test('DELETE /api/tasks/:taskId — cancel task endpoint', async () => {
1408
+ const cancelRes = await apiJson<{ taskId: string; status: string }>(
1409
+ `/api/tasks/${asyncTaskId}`,
1410
+ { method: 'DELETE' },
1411
+ false,
1412
+ 'cancel'
1413
+ )
1414
+ assert.equal(cancelRes.status, 200)
1415
+ assert.deepEqual(cancelRes.data, { taskId: asyncTaskId, status: 'cancelled' })
1416
+ })
1417
+ })
1418
+
1419
+ describe('5. Dynamic Custom Domain Endpoints (/api/*)', () => {
1420
+ test('Verify & execute all dynamically discovered custom domain routes (401 auth, 403 guard & 200 schema-validated execution)', async () => {
1421
+ assert.ok(discoveredDomainRoutes.length > 0, 'Expected at least 1 custom domain route from GET /api/routes')
1422
+
1423
+ for (const route of discoveredDomainRoutes) {
1424
+ const localCfg = (customRoutes as Record<string, RouteConfig>)[route.id]
1425
+ assert.ok(localCfg, `Discovered route ${route.id} should exist in @config/routes`)
1426
+ assert.ok(route.path.startsWith('/api/'))
1427
+
1428
+ if (localCfg.auth !== 'public' && localCfg.auth !== 'none' && localCfg.auth !== false) {
1429
+ const unauthRes = await apiFetch(route.path, { method: route.method }, `${route.id}:unauth`)
1430
+ assert.equal(unauthRes.status, 401)
1431
+ }
1432
+
1433
+ const props = route.inputSchema?.properties ?? {}
1434
+ if ('refs' in props || 'ref' in props) {
1435
+ const badPayload =
1436
+ 'refs' in props
1437
+ ? { refs: ['otheruser_000000000000000000000000'] }
1438
+ : { ref: 'otheruser_000000000000000000000000' }
1439
+ const guardRes = await apiJson<{ error: string; code: string; status: number }>(
1440
+ route.path,
1441
+ {
1442
+ method: route.method,
1443
+ headers: { 'content-type': 'application/json' },
1444
+ body: JSON.stringify(badPayload)
1445
+ },
1446
+ false,
1447
+ `${route.id}:guard`
1448
+ )
1449
+ assert.equal(guardRes.status, 403)
1450
+ assert.equal(guardRes.data.code, 'FORBIDDEN')
1451
+ }
1452
+
1453
+ const validPayload = synthesizeDomainInput(route)
1454
+ const execRes = await apiJson<unknown>(
1455
+ route.path,
1456
+ {
1457
+ method: route.method,
1458
+ headers: { 'content-type': 'application/json' },
1459
+ ...(route.method !== 'GET' ? { body: JSON.stringify(validPayload) } : {})
1460
+ },
1461
+ false,
1462
+ `${route.id}:exec`
1463
+ )
1464
+ assert.equal(execRes.status, 200, `Custom domain route ${route.method} ${route.path} failed`)
1465
+ if (localCfg.output) {
1466
+ const parsed = localCfg.output.safeParse(execRes.data)
1467
+ assert.equal(parsed.success, true, `Output validation failed for ${route.id}`)
1468
+ }
1469
+ }
1470
+ })
1471
+ })
1472
+
1473
+ describe('6. MCP Server (/api/mcp Streamable HTTP JSON-RPC)', () => {
1474
+ let toolNames = new Set<string>()
1475
+
1476
+ test('POST /api/mcp (initialize & tools/list) — handshake & tool discovery', async () => {
1477
+ const initResult = await mcpCall<{
1478
+ serverInfo: { name: string; version: string }
1479
+ capabilities: { tools?: Record<string, unknown> }
1480
+ }>('initialize', {
1481
+ protocolVersion: '2025-03-26',
1482
+ capabilities: {},
1483
+ clientInfo: { name: 'ai-sdk-e2e', version: '1.0.0' }
1484
+ })
1485
+ assert.equal(initResult.serverInfo.name, CORE_CONFIG.appName)
1486
+ assert.notEqual(initResult.capabilities.tools, undefined)
1487
+
1488
+ const toolsList = await mcpCall<{ tools: { name: string; description: string; inputSchema: unknown }[] }>(
1489
+ 'tools/list'
1490
+ )
1491
+ toolNames = new Set(toolsList.tools.map((t) => t.name))
1492
+ assert.ok(toolNames.has('scan-attachment'))
1493
+ assert.ok(toolNames.has('process-attachments'))
1494
+ })
1495
+
1496
+ test('POST /api/mcp (tools/call) — execute scan-attachment & process-attachments', async () => {
1497
+ const targetFixture = fixtures[0]!
1498
+ const targetRef = uploadedRefs.get(targetFixture.type)!
1499
+ const mcpScan = await mcpCall<{ content: { type: string; text: string }[]; isError?: boolean }>('tools/call', {
1500
+ name: 'scan-attachment',
1501
+ arguments: { ref: targetRef }
1502
+ })
1503
+ assert.equal(mcpScan.isError, undefined)
1504
+ const mcpScanOut = JSON.parse(mcpScan.content[0]!.text) as { type: string }
1505
+ assert.equal(mcpScanOut.type, targetFixture.type)
1506
+
1507
+ const mcpProc = await mcpCall<{ content: { type: string; text: string }[]; isError?: boolean }>('tools/call', {
1508
+ name: 'process-attachments',
1509
+ arguments: { refs: [targetRef, uploadedRefs.get('other')!] }
1510
+ })
1511
+ assert.equal(mcpProc.isError, undefined)
1512
+ const mcpProcOut = JSON.parse(mcpProc.content[0]!.text) as { attachments: { ref: string }[] }
1513
+ assert.equal(mcpProcOut.attachments.length, 2)
1514
+ })
1515
+
1516
+ test('POST /api/mcp (tools/call) — dynamically execute all custom domain MCP tools', async () => {
1517
+ for (const route of discoveredDomainRoutes) {
1518
+ const localCfg = (customRoutes as Record<string, RouteConfig>)[route.id]
1519
+ if (!localCfg?.mcp) continue
1520
+ const toolName = route.workflowName ?? route.id
1521
+ assert.ok(toolNames.has(toolName), `Expected custom domain MCP tool ${toolName} in tools/list`)
1522
+
1523
+ const mcpDomainRes = await mcpCall<{ content: { type: string; text: string }[]; isError?: boolean }>(
1524
+ 'tools/call',
1525
+ {
1526
+ name: toolName,
1527
+ arguments: synthesizeDomainInput(route)
1528
+ }
1529
+ )
1530
+ assert.equal(mcpDomainRes.isError, undefined)
1531
+ const parsedOut = JSON.parse(mcpDomainRes.content[0]!.text)
1532
+ if (localCfg.output) {
1533
+ assert.equal(localCfg.output.safeParse(parsedOut).success, true)
1534
+ }
1535
+ }
1536
+ })
1537
+ })
1538
+
1539
+ describe('7. Chat Streaming & Chat CRUD (/api/chats/:chatId, /api/chats)', () => {
1540
+ test('POST /api/chats/:chatId & validation — reject unknown agent (404), legacy messages array (400), empty message (400) & bad data URL (400)', async () => {
1541
+ const tempChatId = `chat_${Date.now().toString(36)}`
1542
+ const unknownRes = await apiFetch(`/api/chats/${tempChatId}`, {
1543
+ method: 'POST',
1544
+ headers: userHeaders({ 'content-type': 'application/json' }),
1545
+ body: JSON.stringify({
1546
+ agentId: 'unknown-agent',
1547
+ message: { role: 'user', content: 'Hello' }
1548
+ })
1549
+ })
1550
+ assert.equal(unknownRes.status, 404)
1551
+
1552
+ const legacyArrayRes = await apiFetch(`/api/chats/${tempChatId}`, {
1553
+ method: 'POST',
1554
+ headers: userHeaders({ 'content-type': 'application/json' }),
1555
+ body: JSON.stringify({
1556
+ messages: [{ role: 'user', content: 'Legacy history array must be rejected' }]
1557
+ })
1558
+ })
1559
+ assert.equal(legacyArrayRes.status, 400)
1560
+
1561
+ const emptyMsgRes = await apiFetch(`/api/chats/${tempChatId}`, {
1562
+ method: 'POST',
1563
+ headers: userHeaders({ 'content-type': 'application/json' }),
1564
+ body: JSON.stringify({
1565
+ message: { role: 'user', content: ' ' }
1566
+ })
1567
+ })
1568
+ assert.equal(emptyMsgRes.status, 400)
1569
+
1570
+ const badDataUrlRes = await apiFetch(`/api/chats/${tempChatId}`, {
1571
+ method: 'POST',
1572
+ headers: userHeaders({ 'content-type': 'application/json' }),
1573
+ body: JSON.stringify({
1574
+ message: {
1575
+ role: 'user',
1576
+ parts: [
1577
+ { type: 'text', text: 'Check this file' },
1578
+ {
1579
+ type: 'file',
1580
+ mediaType: 'text/plain',
1581
+ filename: 'bad.txt',
1582
+ url: 'data:text/plain;base64,SGVsbG8='
1583
+ }
1584
+ ]
1585
+ }
1586
+ })
1587
+ })
1588
+ assert.equal(badDataUrlRes.status, 400)
1589
+ })
1590
+
1591
+ test('POST /api/chats/:chatId — stream single-attachment chat & auto-persist to subcollection', async () => {
1592
+ const pdfFixture = fixtures[0]!
1593
+ const targetChatId = `chat_${Date.now().toString(36)}_1`
1594
+ const singleChatRes = await apiFetch(
1595
+ `/api/chats/${targetChatId}`,
1596
+ {
1597
+ method: 'POST',
1598
+ headers: userHeaders({ 'content-type': 'application/json' }),
1599
+ body: JSON.stringify({
1600
+ agentId: DEFAULT_AGENT_ID,
1601
+ message: {
1602
+ role: 'user',
1603
+ parts: [
1604
+ { type: 'text', text: 'What type of document is attached? Reply briefly in one sentence.' },
1605
+ {
1606
+ type: 'file',
1607
+ mediaType: pdfFixture.mime,
1608
+ filename: pdfFixture.filename,
1609
+ url: pdfFixture.dataUrl
1610
+ }
1611
+ ]
1612
+ }
1613
+ }),
1614
+ signal: AbortSignal.timeout(180_000)
1615
+ },
1616
+ 'single-attachment'
1617
+ )
1618
+ assert.equal(singleChatRes.status, 200)
1619
+ assert.ok((singleChatRes.headers.get('content-type') ?? '').includes('text/event-stream'))
1620
+ chatId1 = singleChatRes.headers.get('x-chat-id') ?? ''
1621
+ assert.equal(chatId1, targetChatId)
1622
+ const singleStreamText = await singleChatRes.text()
1623
+ assert.ok(singleStreamText.includes('data: '))
1624
+ assert.ok(singleStreamText.length > 20)
1625
+ await sleep(600)
1626
+ })
1627
+
1628
+ test('GET /api/chats/:chatId, GET /api/chats/:chatId/messages & GET /api/chats — read metadata & subcollection messages', async () => {
1629
+ const getChat1 = await apiJson<{
1630
+ id: string
1631
+ title: string
1632
+ agentId: string
1633
+ model: string
1634
+ messageCount: number
1635
+ activeGenerationId: string | null
1636
+ messages?: unknown
1637
+ deleted: boolean
1638
+ userId: string
1639
+ }>(`/api/chats/${chatId1}`)
1640
+ assert.equal(getChat1.status, 200)
1641
+ assert.equal(getChat1.data.id, chatId1)
1642
+ assert.equal(getChat1.data.userId, testAuthUserId)
1643
+ assert.equal(getChat1.data.deleted, false)
1644
+ assert.equal(getChat1.data.messages, undefined)
1645
+ assert.equal(getChat1.data.messageCount, 2)
1646
+ assert.equal(getChat1.data.activeGenerationId, null)
1647
+
1648
+ const getMessages1 = await apiJson<
1649
+ {
1650
+ id: string
1651
+ chatId: string
1652
+ userId: string
1653
+ role: string
1654
+ content: string
1655
+ parts: unknown[]
1656
+ embedding?: unknown
1657
+ }[]
1658
+ >(`/api/chats/${chatId1}/messages`)
1659
+ assert.equal(getMessages1.status, 200)
1660
+ assert.equal(getMessages1.data.length, 2)
1661
+ assert.equal(getMessages1.data[0]?.role, 'user')
1662
+ assert.equal(getMessages1.data[1]?.role, 'assistant')
1663
+ assert.equal(getMessages1.data[0]?.embedding, undefined)
1664
+ assert.equal(getMessages1.data[1]?.embedding, undefined)
1665
+ assert.ok((getMessages1.data[1]?.content ?? '').length > 0)
1666
+
1667
+ const listChatsRes = await apiJson<{ id: string; messageCount: number }[]>('/api/chats')
1668
+ assert.equal(listChatsRes.status, 200)
1669
+ assert.ok(listChatsRes.data.some((c) => c.id === chatId1 && c.messageCount === 2))
1670
+ })
1671
+
1672
+ test('POST /api/chats & POST /api/chats/:chatId/cancel — rename chat metadata & cancel endpoint', async () => {
1673
+ const updateChatRes = await apiJson<{ id: string; title: string }>('/api/chats', {
1674
+ method: 'POST',
1675
+ headers: { 'content-type': 'application/json' },
1676
+ body: JSON.stringify({ id: chatId1, title: 'Renamed E2E Chat' })
1677
+ })
1678
+ assert.equal(updateChatRes.status, 200)
1679
+ assert.equal(updateChatRes.data.title, 'Renamed E2E Chat')
1680
+
1681
+ const cancelChatRes = await apiJson<{ chatId: string; cancelled: boolean }>(
1682
+ `/api/chats/${chatId1}/cancel`,
1683
+ { method: 'POST' },
1684
+ false,
1685
+ 'cancel-chat'
1686
+ )
1687
+ assert.equal(cancelChatRes.status, 200)
1688
+ assert.deepEqual(cancelChatRes.data, { chatId: chatId1, cancelled: true })
1689
+ })
1690
+
1691
+ test('DELETE /api/chats/:chatId — soft-delete chat & verify 404', async () => {
1692
+ const delChatRes = await apiJson<{ id: string; deleted: boolean }>(`/api/chats/${chatId1}`, {
1693
+ method: 'DELETE'
1694
+ })
1695
+ assert.equal(delChatRes.status, 200)
1696
+ assert.deepEqual(delChatRes.data, { id: chatId1, deleted: true })
1697
+
1698
+ const getDeletedChat = await apiFetch(`/api/chats/${chatId1}`, { headers: userHeaders() }, 'deleted-chat')
1699
+ assert.equal(getDeletedChat.status, 404)
1700
+ })
1701
+
1702
+ test('POST /api/chats/:chatId — stream multi-attachment chat (inline dataUrl + /api/files/:ref)', async () => {
1703
+ const secondPdf = fixtures[1]!
1704
+ const otherFixture = fixtures.find((f) => f.type === 'other')!
1705
+ const existingRef = uploadedRefs.get(otherFixture.type)!
1706
+ const multiChatId = `chat_${Date.now().toString(36)}_2`
1707
+ const multiChatRes = await apiFetch(
1708
+ `/api/chats/${multiChatId}`,
1709
+ {
1710
+ method: 'POST',
1711
+ headers: userHeaders({ 'content-type': 'application/json' }),
1712
+ body: JSON.stringify({
1713
+ agentId: DEFAULT_AGENT_ID,
1714
+ message: {
1715
+ role: 'user',
1716
+ parts: [
1717
+ { type: 'text', text: 'How many attachments did I include? Reply in one short sentence.' },
1718
+ {
1719
+ type: 'file',
1720
+ mediaType: secondPdf.mime,
1721
+ filename: secondPdf.filename,
1722
+ url: secondPdf.dataUrl
1723
+ },
1724
+ {
1725
+ type: 'file',
1726
+ mediaType: otherFixture.mime,
1727
+ filename: otherFixture.filename,
1728
+ url: `/api/files/${existingRef}`
1729
+ }
1730
+ ]
1731
+ }
1732
+ }),
1733
+ signal: AbortSignal.timeout(180_000)
1734
+ },
1735
+ 'multi-attachment'
1736
+ )
1737
+ assert.equal(multiChatRes.status, 200)
1738
+ assert.ok((multiChatRes.headers.get('content-type') ?? '').includes('text/event-stream'))
1739
+ const multiStreamText = await multiChatRes.text()
1740
+ assert.ok(multiStreamText.includes('data: '))
1741
+ assert.ok(multiStreamText.length > 20)
1742
+ await sleep(600)
1743
+ })
1744
+ })
1745
+
1746
+ describe('8. OpenAI-Compatible Endpoints (/api/v1/models, /api/v1/chat/completions)', () => {
1747
+ test('GET /api/v1/models — list registered agent models', async () => {
1748
+ const modelsRes = await apiJson<{ object: string; data: { id: string; object: string; owned_by: string }[] }>(
1749
+ '/api/v1/models'
1750
+ )
1751
+ assert.equal(modelsRes.status, 200)
1752
+ assert.equal(modelsRes.data.object, 'list')
1753
+ for (const agentId of AGENT_IDS) {
1754
+ assert.ok(modelsRes.data.data.some((m) => m.id === agentId && m.object === 'model'))
1755
+ }
1756
+
1757
+ const badCompletionRes = await apiFetch('/api/v1/chat/completions', {
1758
+ method: 'POST',
1759
+ headers: userHeaders({ 'content-type': 'application/json' }),
1760
+ body: JSON.stringify({ model: DEFAULT_AGENT_ID, messages: [] })
1761
+ })
1762
+ assert.equal(badCompletionRes.status, 400)
1763
+ })
1764
+
1765
+ test('POST /api/v1/chat/completions (stream: false) — non-streaming completion with attached PDF', async () => {
1766
+ const pdfFixture = fixtures[0]!
1767
+ const completionRes = await apiJson<{
1768
+ id: string
1769
+ object: string
1770
+ model: string
1771
+ choices: { index: number; message: { role: string; content: string }; finish_reason: string }[]
1772
+ usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }
1773
+ }>(
1774
+ '/api/v1/chat/completions',
1775
+ {
1776
+ method: 'POST',
1777
+ headers: { 'content-type': 'application/json' },
1778
+ body: JSON.stringify({
1779
+ model: DEFAULT_AGENT_ID,
1780
+ stream: false,
1781
+ messages: [
1782
+ {
1783
+ role: 'user',
1784
+ content: [
1785
+ { type: 'text', text: 'What document type is attached? Answer in a few words.' },
1786
+ {
1787
+ type: 'file',
1788
+ file: {
1789
+ file_data: pdfFixture.dataUrl,
1790
+ filename: pdfFixture.filename
1791
+ }
1792
+ }
1793
+ ]
1794
+ }
1795
+ ]
1796
+ })
1797
+ },
1798
+ false,
1799
+ 'json'
1800
+ )
1801
+ assert.equal(completionRes.status, 200)
1802
+ assert.ok(completionRes.data.id.startsWith('chatcmpl-'))
1803
+ assert.equal(completionRes.data.object, 'chat.completion')
1804
+ assert.equal(completionRes.data.model, DEFAULT_AGENT_NAME)
1805
+ assert.equal(completionRes.data.choices[0]?.message.role, 'assistant')
1806
+ assert.ok((completionRes.data.choices[0]?.message.content ?? '').length > 0)
1807
+ assert.equal(completionRes.data.choices[0]?.finish_reason, 'stop')
1808
+ assert.equal(typeof completionRes.data.usage.total_tokens, 'number')
1809
+ })
1810
+
1811
+ test('POST /api/v1/chat/completions (stream: true) — streaming SSE completion with PDF + JPEG attachments', async () => {
1812
+ const pdfFixture = fixtures[0]!
1813
+ const otherFixture = fixtures.find((f) => f.type === 'other')!
1814
+ const streamRes = await apiFetch(
1815
+ '/api/v1/chat/completions',
1816
+ {
1817
+ method: 'POST',
1818
+ headers: userHeaders({ 'content-type': 'application/json' }),
1819
+ body: JSON.stringify({
1820
+ model: DEFAULT_AGENT_ID,
1821
+ stream: true,
1822
+ stream_options: { include_usage: true },
1823
+ messages: [
1824
+ {
1825
+ role: 'user',
1826
+ content: [
1827
+ { type: 'text', text: 'How many attachments did I include? Reply in one short sentence.' },
1828
+ {
1829
+ type: 'file',
1830
+ file: {
1831
+ file_data: pdfFixture.dataUrl,
1832
+ filename: pdfFixture.filename
1833
+ }
1834
+ },
1835
+ {
1836
+ type: 'image_url',
1837
+ image_url: {
1838
+ url: otherFixture.dataUrl
1839
+ }
1840
+ }
1841
+ ]
1842
+ }
1843
+ ]
1844
+ }),
1845
+ signal: AbortSignal.timeout(180_000)
1846
+ },
1847
+ 'sse'
1848
+ )
1849
+ assert.equal(streamRes.status, 200)
1850
+ assert.ok((streamRes.headers.get('content-type') ?? '').startsWith('text/event-stream'))
1851
+ const rawSse = await streamRes.text()
1852
+ const frames = rawSse
1853
+ .split('\n\n')
1854
+ .map((f) => f.trim())
1855
+ .filter(Boolean)
1856
+ assert.equal(frames.at(-1), 'data: [DONE]')
1857
+
1858
+ const chunks = frames.slice(0, -1).map(
1859
+ (f) =>
1860
+ JSON.parse(f.replace(/^data:\s*/, '')) as {
1861
+ object?: string
1862
+ choices?: { index: number; delta: { role?: string; content?: string }; finish_reason: string | null }[]
1863
+ usage?: { total_tokens: number }
1864
+ }
1865
+ )
1866
+ assert.equal(chunks[0]?.object, 'chat.completion.chunk')
1867
+ assert.equal(chunks[0]?.choices?.[0]?.delta.role, 'assistant')
1868
+ const streamedContent = chunks.map((c) => c.choices?.[0]?.delta.content ?? '').join('')
1869
+ assert.ok(streamedContent.length > 0)
1870
+ assert.ok(chunks.some((c) => c.choices?.[0]?.finish_reason === 'stop'))
1871
+ assert.ok(chunks.some((c) => c.usage !== undefined))
1872
+ })
1873
+ })
1874
+
1875
+ describe('9. Core SDK Primitives, Sign-Out, GDPR Erase & Zero-Leftover Verification', () => {
1876
+ test('Step runner durable replay checkpointing & per-step rollback', async () => {
1877
+ const steps: Record<string, unknown> = {}
1878
+ let ocrRuns = 0
1879
+ let classifyRuns = 0
1880
+ let extractRuns = 0
1881
+ let rolledBackStep = ''
1882
+ const runPipeline = async (failExtract: boolean) => {
1883
+ const step = core.internals.workflows.createStepRunner('rest', undefined, steps)
1884
+ try {
1885
+ const ocr = await step('ocr', async () => (++ocrRuns, 'OCR_TEXT'))
1886
+ const classify = await step('classify', async () => (++classifyRuns, { type: 'passport', ocr }))
1887
+ await step('temp-write', {
1888
+ fn: async () => 'temp-val',
1889
+ rollback: async (v) => {
1890
+ rolledBackStep = v
1891
+ }
1892
+ })
1893
+ return await step('extract', async () => {
1894
+ ++extractRuns
1895
+ if (failExtract) throw new Error('Transient extract failure')
1896
+ return { passportNumber: 'P123', classifyType: classify.type }
1897
+ })
1898
+ } catch (e) {
1899
+ await step.onError()
1900
+ throw e
1901
+ }
1902
+ }
1903
+ await assert.rejects(() => runPipeline(true), /Transient extract failure/)
1904
+ assert.equal(ocrRuns, 1)
1905
+ assert.equal(classifyRuns, 1)
1906
+ assert.equal(extractRuns, 1)
1907
+ assert.equal(rolledBackStep, 'temp-val')
1908
+ assert.equal('temp-write' in steps, false)
1909
+
1910
+ const replayOut = await runPipeline(false)
1911
+ assert.deepEqual(replayOut, { passportNumber: 'P123', classifyType: 'passport' })
1912
+ assert.equal(ocrRuns, 1)
1913
+ assert.equal(classifyRuns, 1)
1914
+ assert.equal(extractRuns, 2)
1915
+ })
1916
+
1917
+ test('allWithRollback parallel sibling compensation', async () => {
1918
+ let rolledBackLegA = ''
1919
+ let rolledBackLegB = ''
1920
+ await assert.rejects(
1921
+ () =>
1922
+ core.internals.workflows.allWithRollback([
1923
+ {
1924
+ fn: async () => 'legA-committed',
1925
+ rollback: async (val) => {
1926
+ rolledBackLegA = val
1927
+ }
1928
+ },
1929
+ {
1930
+ fn: async () => 'legB-committed',
1931
+ rollback: async (val) => {
1932
+ rolledBackLegB = val
1933
+ }
1934
+ },
1935
+ {
1936
+ fn: async () => {
1937
+ throw new Error('legC-failed')
1938
+ }
1939
+ }
1940
+ ]),
1941
+ /legC-failed/
1942
+ )
1943
+ assert.equal(rolledBackLegA, 'legA-committed')
1944
+ assert.equal(rolledBackLegB, 'legB-committed')
1945
+ })
1946
+
1947
+ test('Universal Auth-User Collection Connector (1:1 & M:N), updateEntry validation, cursor pagination & cascadeEraseUserCollections', async () => {
1948
+ core.internals.registry.registerCollections({
1949
+ test_profile: defineCollection({
1950
+ name: 'test_profile',
1951
+ schema: z.object({
1952
+ bio: z.string(),
1953
+ age: z.number().optional()
1954
+ }),
1955
+ userRelation: { type: '1:1' }
1956
+ }),
1957
+ test_shared_org: defineCollection({
1958
+ name: 'test_shared_org',
1959
+ schema: z.object({
1960
+ name: z.string(),
1961
+ userIds: z.array(z.string())
1962
+ }),
1963
+ userRelation: { type: 'M:N', userIdsField: 'userIds', deleteWhenEmpty: true }
1964
+ })
1965
+ })
1966
+ const connectorUid = `conn_${Date.now().toString(36).padEnd(15, 'x').slice(0, 15)}`
1967
+ try {
1968
+ const profileDoc = await core.db.create('test_profile', { id: connectorUid, bio: 'E2E Profile', age: 30 })
1969
+
1970
+ // Verify core.db.update validates against def.schema.partial() and strips immutable fields (id, createdAt)
1971
+ await assert.rejects(
1972
+ () => core.db.update('test_profile', connectorUid, { age: 'not-a-number' as unknown as number }),
1973
+ /ZodError|expected number/i
1974
+ )
1975
+ const originalCreatedAt = profileDoc.createdAt
1976
+ await core.db.update('test_profile', connectorUid, {
1977
+ id: 'hacked_id',
1978
+ createdAt: new Date(0),
1979
+ bio: 'Updated Bio'
1980
+ } as Record<string, unknown>)
1981
+ const updatedProfile = await core.db.read('test_profile', connectorUid)
1982
+ assert.equal(updatedProfile?.id, connectorUid)
1983
+ assert.equal(updatedProfile?.bio, 'Updated Bio')
1984
+ assert.equal(updatedProfile?.age, 30)
1985
+ assert.equal(updatedProfile?.createdAt.getTime(), originalCreatedAt.getTime())
1986
+
1987
+ const sharedOrg = await core.db.create('test_shared_org', {
1988
+ name: 'Shared Org 1',
1989
+ userIds: [connectorUid]
1990
+ })
1991
+ const sharedOrg2 = await core.db.create('test_shared_org', {
1992
+ name: 'Shared Org 2',
1993
+ userIds: [connectorUid]
1994
+ })
1995
+ const deletedOrg = await core.db.create('test_shared_org', {
1996
+ name: 'Deleted Org',
1997
+ userIds: [connectorUid]
1998
+ })
1999
+ await core.db.delete('test_shared_org', deletedOrg.id)
2000
+
2001
+ assert.equal(await core.db.canAccess('test_profile', profileDoc, connectorUid), true)
2002
+ assert.equal(await core.db.canAccess('test_shared_org', sharedOrg, connectorUid), true)
2003
+
2004
+ // Verify cursor pagination (core.db.list & core.db.listPage) and soft-delete exclusion
2005
+ const page1 = await core.db.listPage('test_shared_org', { userId: connectorUid, limit: 1 })
2006
+ assert.equal(page1.items.length, 1)
2007
+ assert.ok(page1.nextCursor)
2008
+ const page2 = await core.db.list('test_shared_org', {
2009
+ userId: connectorUid,
2010
+ limit: 1,
2011
+ cursor: page1.nextCursor
2012
+ })
2013
+ assert.equal(page2.length, 1)
2014
+ assert.notEqual(page1.items[0]?.id, page2[0]?.id)
2015
+
2016
+ const allActiveOrgs = await core.db.list('test_shared_org', { userId: connectorUid })
2017
+ assert.equal(allActiveOrgs.length, 2)
2018
+
2019
+ const cascadeRes = await core.internals.registry.cascadeEraseUserCollections(
2020
+ connectorUid,
2021
+ core.internals.workflows.createStepRunner('rest')
2022
+ )
2023
+ assert.equal(cascadeRes.erasedCollections.test_profile, 1)
2024
+ assert.equal(cascadeRes.erasedCollections.test_shared_org, 3)
2025
+ assert.equal(await core.db.read('test_profile', connectorUid, true), undefined)
2026
+ assert.equal(await core.db.read('test_shared_org', sharedOrg.id, true), undefined)
2027
+ assert.equal(await core.db.read('test_shared_org', sharedOrg2.id, true), undefined)
2028
+ assert.equal(await core.db.read('test_shared_org', deletedOrg.id, true), undefined)
2029
+ } finally {
2030
+ await core.internals.registry
2031
+ .cascadeEraseUserCollections(connectorUid, core.internals.workflows.createStepRunner('rest'))
2032
+ .catch(() => {})
2033
+ core.internals.registry.unregisterCollections(['test_profile', 'test_shared_org'])
2034
+ }
2035
+ })
2036
+
2037
+ test('Workflow NOT_FOUND error propagation & child workflow context inheritance', async () => {
2038
+ const prevRuntime = core.internals.workflows.getRuntime()
2039
+ const tempRegistry = {
2040
+ ...prevRuntime.registry,
2041
+ 'child-echo-context': {
2042
+ mode: 'workflow' as const,
2043
+ deadlineSeconds: 60,
2044
+ runTask: async (_input: unknown) => {
2045
+ const active = useCore()
2046
+ return {
2047
+ userId: active.userId,
2048
+ userRole: active.role,
2049
+ isDev: active.env.isDev
2050
+ }
2051
+ }
2052
+ },
2053
+ 'fail-not-found-doc': {
2054
+ mode: 'workflow' as const,
2055
+ deadlineSeconds: 60,
2056
+ runTask: async () => {
2057
+ throw new Error('5 NOT_FOUND: Document projects/test/databases/default/documents/missing/123 does not exist')
2058
+ }
2059
+ }
2060
+ }
2061
+ core.internals.workflows.registerRuntime(tempRegistry)
2062
+
2063
+ try {
2064
+ const parentStep = core.internals.workflows.createStepRunner('rest')
2065
+ const childResult = await core.internals.context.run(
2066
+ { env: ENV, userId: testAuthUserId, role: 'user', step: parentStep },
2067
+ () =>
2068
+ parentStep.invoke<{ ping: string }, { userId: string; userRole: string; isDev: boolean }>(
2069
+ 'call-child',
2070
+ 'child-echo-context',
2071
+ { ping: 'ok' }
2072
+ )
2073
+ )
2074
+ assert.equal(childResult.userId, testAuthUserId)
2075
+ assert.equal(childResult.userRole, 'user')
2076
+ assert.equal(childResult.isDev, true)
2077
+
2078
+ const nfDocRef = core.db.firestore(true).collection('_tasks').doc()
2079
+ const nfTaskId = nfDocRef.id
2080
+ await nfDocRef.set({
2081
+ taskId: nfTaskId,
2082
+ workflowName: 'fail-not-found-doc',
2083
+ status: 'running',
2084
+ params: {},
2085
+ steps: {},
2086
+ userId: testAuthUserId,
2087
+ userRole: 'user',
2088
+ keepAlive: true,
2089
+ createdAt: new Date().toISOString(),
2090
+ updatedAt: new Date().toISOString()
2091
+ })
2092
+ await core.internals.workflows.execute(
2093
+ {
2094
+ taskId: nfTaskId,
2095
+ workflowName: 'fail-not-found-doc',
2096
+ params: {},
2097
+ isDev: true
2098
+ },
2099
+ tempRegistry
2100
+ )
2101
+ const nfTaskDoc = await nfDocRef.get()
2102
+ assert.equal(nfTaskDoc.data()?.status, 'errored')
2103
+ assert.ok(String(nfTaskDoc.data()?.error ?? '').includes('NOT_FOUND'))
2104
+ await nfDocRef.delete()
2105
+ } finally {
2106
+ core.internals.workflows.registerRuntime(prevRuntime.registry)
2107
+ }
2108
+ })
2109
+
2110
+ test('singleflight tenant-scoped isolation, global coalescing & acquireHttpLimit (429 Retry-After)', async () => {
2111
+ let sfRuns = 0
2112
+ const sfResults = await Promise.all(
2113
+ Array.from({ length: 5 }, () =>
2114
+ core.internals.resilience.singleflight('test-sf-key', async () => {
2115
+ ++sfRuns
2116
+ await sleep(20)
2117
+ return 'coalesced-value'
2118
+ })
2119
+ )
2120
+ )
2121
+ assert.equal(sfRuns, 1)
2122
+ assert.deepEqual(sfResults, Array(5).fill('coalesced-value'))
2123
+
2124
+ let tenantRuns = 0
2125
+ const [tenantA, tenantB] = await Promise.all([
2126
+ core.internals.context.run({ env: ENV, userId: 'tenant_user_a', role: 'user' }, () =>
2127
+ useCore().internals.resilience.singleflight('shared-op', async () => {
2128
+ ++tenantRuns
2129
+ await sleep(20)
2130
+ return useCore().userId
2131
+ })
2132
+ ),
2133
+ core.internals.context.run({ env: ENV, userId: 'tenant_user_b', role: 'user' }, () =>
2134
+ useCore().internals.resilience.singleflight('shared-op', async () => {
2135
+ ++tenantRuns
2136
+ await sleep(20)
2137
+ return useCore().userId
2138
+ })
2139
+ )
2140
+ ])
2141
+ assert.equal(tenantRuns, 2)
2142
+ assert.equal(tenantA, 'tenant_user_a')
2143
+ assert.equal(tenantB, 'tenant_user_b')
2144
+
2145
+ let globalRuns = 0
2146
+ const [globalA, globalB] = await Promise.all([
2147
+ core.internals.context.run({ env: ENV, userId: 'tenant_user_a', role: 'user' }, () =>
2148
+ useCore().internals.resilience.singleflight(
2149
+ 'global-op',
2150
+ async () => {
2151
+ ++globalRuns
2152
+ await sleep(20)
2153
+ return useCore().userId
2154
+ },
2155
+ { scope: 'global' }
2156
+ )
2157
+ ),
2158
+ core.internals.context.run({ env: ENV, userId: 'tenant_user_b', role: 'user' }, () =>
2159
+ useCore().internals.resilience.singleflight(
2160
+ 'global-op',
2161
+ async () => {
2162
+ ++globalRuns
2163
+ await sleep(20)
2164
+ return useCore().userId
2165
+ },
2166
+ { scope: 'global' }
2167
+ )
2168
+ )
2169
+ ])
2170
+ assert.equal(globalRuns, 1)
2171
+ assert.equal(globalA, CORE_CONFIG.auth.adminUserId)
2172
+ assert.equal(globalB, CORE_CONFIG.auth.adminUserId)
2173
+
2174
+ const rateKey = `test-limit-${Date.now()}`
2175
+ core.internals.resilience.acquireHttpLimit(rateKey, 2, 60_000)
2176
+ core.internals.resilience.acquireHttpLimit(rateKey, 2, 60_000)
2177
+ try {
2178
+ core.internals.resilience.acquireHttpLimit(rateKey, 2, 60_000)
2179
+ assert.fail('Should have thrown 429')
2180
+ } catch (err: any) {
2181
+ const res = err.getResponse() as Response
2182
+ assert.equal(res.status, 429)
2183
+ assert.ok(Number(res.headers.get('retry-after')) > 0)
2184
+ const body = (await res.json()) as { code: string; retryAfterMs: number }
2185
+ assert.equal(body.code, 'RATE_LIMITED')
2186
+ assert.ok(body.retryAfterMs > 0)
2187
+ }
2188
+
2189
+ const coreBarrel = (await import('@core')) as Record<string, unknown>
2190
+ for (const internalSymbol of [
2191
+ 'decideService',
2192
+ 'visionService',
2193
+ 'processAttachments',
2194
+ 'createEntry',
2195
+ 'readEntry',
2196
+ 'listEntries',
2197
+ 'withCircuitBreaker',
2198
+ 'getDb',
2199
+ 'getBucket',
2200
+ 'runWithCore'
2201
+ ]) {
2202
+ assert.equal(internalSymbol in coreBarrel, false, `@core must not leak internal symbol ${internalSymbol}`)
2203
+ }
2204
+ assert.equal(Object.keys(core).includes('internals'), false, 'core.internals must be non-enumerable')
2205
+ assert.deepEqual(
2206
+ Object.keys(core.internals).sort(),
2207
+ ['auth', 'context', 'emails', 'payments', 'registry', 'resilience', 'workflows']
2208
+ )
2209
+ })
2210
+
2211
+ test('POST /api/emails/send, POST /api/emails/batch, GET /api/emails/logs & POST /api/emails/webhooks/:provider — E2E email routes, templates, attachments & suppression', async () => {
2212
+ core.internals.registry.registerEmailTemplates(
2213
+ core.define.emailTemplates({
2214
+ e2e_summary: {
2215
+ description: 'E2E summary notification',
2216
+ variables: z.object({ title: z.string(), count: z.number() }),
2217
+ subject: ({ title }) => `[E2E] ${title}`,
2218
+ html: ({ title, count }) => `<p>${title}: ${count} items processed.</p>`
2219
+ }
2220
+ })
2221
+ )
2222
+
2223
+ const firstRef = uploadedRefs.get(fixtures[0]!.type)!
2224
+ const idemKey = `e2e_email_${Date.now()}`
2225
+ const sendRes1 = await apiJson<{
2226
+ emailId: string
2227
+ status: string
2228
+ provider: string
2229
+ deduplicated?: boolean
2230
+ }>(
2231
+ '/api/emails/send',
2232
+ {
2233
+ method: 'POST',
2234
+ headers: { 'content-type': 'application/json' },
2235
+ body: JSON.stringify({
2236
+ to: 'e2e-recipient@test.local',
2237
+ template: 'e2e_summary',
2238
+ variables: { title: 'Batch Scan', count: fixtures.length },
2239
+ attachments: [{ filename: 'fixture.pdf', fileRef: firstRef }],
2240
+ idempotencyKey: idemKey
2241
+ })
2242
+ },
2243
+ true,
2244
+ 'emails-send-1'
2245
+ )
2246
+ assert.equal(sendRes1.status, 200)
2247
+ assert.equal(sendRes1.data.status, 'sent')
2248
+ assert.equal(sendRes1.data.deduplicated, undefined)
2249
+
2250
+ // Verify core.emails.send idempotency deduplication in email_log
2251
+ const directDedup = await core.emails.send(
2252
+ {
2253
+ to: 'e2e-recipient@test.local',
2254
+ template: 'e2e_summary',
2255
+ variables: { title: 'Batch Scan', count: fixtures.length },
2256
+ idempotencyKey: idemKey
2257
+ },
2258
+ { userId: testAuthUserId }
2259
+ )
2260
+ assert.equal(directDedup.emailId, sendRes1.data.emailId)
2261
+ assert.equal(directDedup.deduplicated, true)
2262
+
2263
+ // Clean up admin-owned sendRes1 log so only testAuthUserId logs remain
2264
+ await core.db.hardDelete('email_log', sendRes1.data.emailId, { bypassRbac: true })
2265
+
2266
+ // Create a user-scoped email log for testAuthUserId to verify GET /api/emails/logs & cascadeEraseUserCollections
2267
+ const userScopedSend = await core.emails.send(
2268
+ {
2269
+ to: 'user-scoped@test.local',
2270
+ template: 'e2e_summary',
2271
+ variables: { title: 'User Scoped', count: 1 }
2272
+ },
2273
+ { userId: testAuthUserId }
2274
+ )
2275
+
2276
+ // Verify POST /api/emails/batch
2277
+ const batchRes = await apiJson<{
2278
+ total: number
2279
+ sent: number
2280
+ failed: number
2281
+ results: { emailId: string; status: string }[]
2282
+ }>(
2283
+ '/api/emails/batch',
2284
+ {
2285
+ method: 'POST',
2286
+ headers: { 'content-type': 'application/json' },
2287
+ body: JSON.stringify({
2288
+ emails: [
2289
+ { to: 'batch-a@test.local', subject: 'Batch A', text: 'Hello A' },
2290
+ { to: 'batch-b@test.local', subject: 'Batch B', text: 'Hello B' }
2291
+ ]
2292
+ })
2293
+ },
2294
+ true,
2295
+ 'emails-batch'
2296
+ )
2297
+ assert.equal(batchRes.status, 200)
2298
+ assert.equal(batchRes.data.total, 2)
2299
+ assert.equal(batchRes.data.sent, 2)
2300
+ await Promise.all(
2301
+ batchRes.data.results.map((r) => core.db.hardDelete('email_log', r.emailId, { bypassRbac: true }))
2302
+ )
2303
+
2304
+ // Verify GET /api/emails/logs filtered by userId
2305
+ const logsRes = await apiJson<{ id: string; to: string; userId: string }[]>(
2306
+ `/api/emails/logs?userId=${testAuthUserId}&limit=20`,
2307
+ {},
2308
+ true,
2309
+ 'emails-logs'
2310
+ )
2311
+ assert.equal(logsRes.status, 200)
2312
+ assert.ok(logsRes.data.some((l) => l.id === userScopedSend.emailId))
2313
+
2314
+ // Verify POST /api/emails/webhooks/resend rejects unsigned/invalid signature with 401
2315
+ const badWebhookRes = await apiFetch(
2316
+ '/api/emails/webhooks/resend',
2317
+ {
2318
+ method: 'POST',
2319
+ headers: {
2320
+ 'content-type': 'application/json',
2321
+ 'svix-id': 'msg_bad',
2322
+ 'svix-timestamp': String(Math.floor(Date.now() / 1000)),
2323
+ 'svix-signature': 'v1,bad_sig'
2324
+ },
2325
+ body: JSON.stringify({ type: 'email.delivered', data: { email_id: 'dev_123' } })
2326
+ },
2327
+ 'emails-webhook-invalid'
2328
+ )
2329
+ assert.equal(badWebhookRes.status, 401)
2330
+ })
2331
+
2332
+ test('GET /api/payments/status, POST /api/payments/subscribe, POST /api/payments/checkout, POST /api/payments/charge & POST /api/payments/webhooks/:provider — E2E payment routes & usage metering', async () => {
2333
+ core.internals.registry.registerPlans(
2334
+ core.define.plans({
2335
+ free: {
2336
+ name: 'Free Plan',
2337
+ interval: 'free',
2338
+ amount: 0,
2339
+ quotas: { aiTokens: 100_000 }
2340
+ },
2341
+ pro: {
2342
+ name: 'Pro Plan',
2343
+ interval: 'month',
2344
+ amount: 2_900,
2345
+ currency: 'usd',
2346
+ quotas: { aiTokens: 1_000_000, overage: { enabled: true } }
2347
+ }
2348
+ })
2349
+ )
2350
+ core.internals.registry.registerProducts(
2351
+ core.define.products({
2352
+ e2e_token_pack: {
2353
+ name: 'E2E 5,000 Token Pack',
2354
+ amount: 500,
2355
+ currency: 'usd',
2356
+ grants: { aiTokens: 5_000, credits: 5 }
2357
+ }
2358
+ })
2359
+ )
2360
+
2361
+ const subRes = await apiJson<{ subscriptionId: string; planId: string; status: string }>(
2362
+ '/api/payments/subscribe',
2363
+ {
2364
+ method: 'POST',
2365
+ headers: userHeaders({ 'content-type': 'application/json' }),
2366
+ body: JSON.stringify({ plan: 'free' })
2367
+ },
2368
+ false,
2369
+ 'payments-subscribe'
2370
+ )
2371
+ assert.equal(subRes.status, 200)
2372
+ assert.equal(subRes.data.planId, 'free')
2373
+ assert.equal(subRes.data.status, 'active')
2374
+
2375
+ const chkRes = await apiJson<{ checkoutId: string; url: string; provider: string }>(
2376
+ '/api/payments/checkout',
2377
+ {
2378
+ method: 'POST',
2379
+ headers: userHeaders({ 'content-type': 'application/json' }),
2380
+ body: JSON.stringify({ custom: { name: 'E2E Custom Add-on', amount: 1_500, currency: 'usd' } })
2381
+ },
2382
+ false,
2383
+ 'payments-checkout'
2384
+ )
2385
+ assert.equal(chkRes.status, 200)
2386
+ assert.ok(chkRes.data.checkoutId)
2387
+ assert.ok(chkRes.data.url.startsWith('http'))
2388
+
2389
+ const chargeRes = await apiJson<{ chargeId: string; status: string; amount: number }>(
2390
+ '/api/payments/charge',
2391
+ {
2392
+ method: 'POST',
2393
+ headers: userHeaders({ 'content-type': 'application/json' }),
2394
+ body: JSON.stringify({ product: 'e2e_token_pack', idempotencyKey: 'e2e-charge-idem-1' })
2395
+ },
2396
+ false,
2397
+ 'payments-charge'
2398
+ )
2399
+ assert.equal(chargeRes.status, 200)
2400
+ assert.equal(chargeRes.data.amount, 500)
2401
+
2402
+ const retryChargeRes = await apiJson<{ chargeId: string; status: string; amount: number }>(
2403
+ '/api/payments/charge',
2404
+ {
2405
+ method: 'POST',
2406
+ headers: userHeaders({ 'content-type': 'application/json' }),
2407
+ body: JSON.stringify({ product: 'e2e_token_pack', idempotencyKey: 'e2e-charge-idem-1' })
2408
+ },
2409
+ false,
2410
+ 'payments-charge-retry'
2411
+ )
2412
+ assert.equal(retryChargeRes.status, 200)
2413
+ assert.equal(retryChargeRes.data.chargeId, chargeRes.data.chargeId)
2414
+
2415
+ await core.payments.recordUsage(
2416
+ { metric: 'ai_tokens', amount: 250, model: 'e2e-test-model' },
2417
+ { userId: testAuthUserId }
2418
+ )
2419
+
2420
+ const statusRes = await apiJson<{
2421
+ subscription: { userId: string; planId: string; status: string; bonusAiTokens: number; credits: number }
2422
+ usage: { userId: string; aiTokensUsed: number; aiTokensLimit: number | null }
2423
+ }>('/api/payments/status', { headers: userHeaders() }, false, 'payments-status')
2424
+ assert.equal(statusRes.status, 200)
2425
+ assert.equal(statusRes.data.subscription.userId, testAuthUserId)
2426
+ assert.equal(statusRes.data.subscription.planId, 'free')
2427
+ assert.equal(statusRes.data.subscription.credits, 5, 'Expected idempotent charge retry to grant credits only once')
2428
+ assert.ok(statusRes.data.usage.aiTokensUsed >= 0)
2429
+
2430
+ const badPayWhRes = await apiFetch(
2431
+ '/api/payments/webhooks/stripe',
2432
+ {
2433
+ method: 'POST',
2434
+ headers: {
2435
+ 'content-type': 'application/json',
2436
+ 'stripe-signature': `t=${Math.floor(Date.now() / 1000)},v1=invalid`
2437
+ },
2438
+ body: JSON.stringify({ id: 'evt_bad_e2e', type: 'checkout.session.completed' })
2439
+ },
2440
+ 'payments-webhook-invalid'
2441
+ )
2442
+ assert.equal(badPayWhRes.status, 401)
2443
+ })
2444
+
2445
+ test('DELETE /api/files/:ref & POST /api/auth/sign-out — soft-delete all files & revoke session', async () => {
2446
+ await Promise.all(
2447
+ [...uploadedRefs.values()].map(async (ref) => {
2448
+ const delRes = await apiFetch(`/api/files/${ref}`, { method: 'DELETE', headers: userHeaders() }, 'soft-del')
2449
+ assert.equal(delRes.status, 200)
2450
+ })
2451
+ )
2452
+
2453
+ const firstRef = uploadedRefs.get(fixtures[0]!.type)!
2454
+ const afterDelRes = await apiFetch(`/api/files/${firstRef}`, { headers: bearerHeaders() }, 'verify-404')
2455
+ assert.equal(afterDelRes.status, 404)
2456
+
2457
+ const signOutRes = await apiFetch('/api/auth/sign-out', {
2458
+ method: 'POST',
2459
+ headers: userHeaders({ 'content-type': 'application/json' }),
2460
+ body: JSON.stringify({ deleteUser: true })
2461
+ })
2462
+ assert.equal(signOutRes.status, 200)
2463
+
2464
+ const revokedCookieRes = await apiFetch('/api/auth/session', { headers: userHeaders() }, 'revoked-cookie')
2465
+ assert.equal(revokedCookieRes.status, 401)
2466
+ })
2467
+
2468
+ test('POST /api/erase-user & Final Zero-Leftover Verification (Auth, Firestore file/chat/_tasks & Cloud Storage)', async () => {
2469
+ const eraseRes = await apiJson<{
2470
+ userId: string
2471
+ erasedFiles: number
2472
+ erasedChats: number
2473
+ erasedUser: boolean
2474
+ }>(
2475
+ '/api/erase-user',
2476
+ {
2477
+ method: 'POST',
2478
+ headers: { 'content-type': 'application/json' },
2479
+ body: JSON.stringify({ userId: testAuthUserId, backupBeforeErase: false })
2480
+ },
2481
+ true
2482
+ )
2483
+ assert.equal(eraseRes.status, 200)
2484
+ assert.equal(eraseRes.data.userId, testAuthUserId)
2485
+ assert.ok(eraseRes.data.erasedFiles >= fixtures.length)
2486
+ assert.ok(eraseRes.data.erasedChats >= 2)
2487
+ assert.equal(eraseRes.data.erasedUser, true)
2488
+ userErased = true
2489
+
2490
+ const db = core.db.firestore(true)
2491
+ const [
2492
+ leftoverAuthUser,
2493
+ leftoverAuthMetaDoc,
2494
+ leftoverAuthTokenDocs,
2495
+ leftoverFileDocs,
2496
+ leftoverChatDocs,
2497
+ leftoverChatMsgDocs,
2498
+ leftoverTaskDocs,
2499
+ leftoverEmailLogDocs,
2500
+ leftoverPaymentCustomerDoc,
2501
+ leftoverPaymentUsageDocs,
2502
+ leftoverPaymentTxDocs,
2503
+ [leftoverStorageBlobs]
2504
+ ] = await Promise.all([
2505
+ core.auth.getUser(testAuthUserId),
2506
+ db.collection('user_auth_meta').doc(testAuthUserId).get(),
2507
+ db.collection('auth_tokens').where('userId', '==', testAuthUserId).get(),
2508
+ db.collection('file').where('userId', '==', testAuthUserId).get(),
2509
+ db.collection('chat').where('userId', '==', testAuthUserId).get(),
2510
+ db.collectionGroup('messages').where('userId', '==', testAuthUserId).get(),
2511
+ db.collection('_tasks').where('userId', '==', testAuthUserId).get(),
2512
+ db.collection('email_log').where('userId', '==', testAuthUserId).get(),
2513
+ db.collection('payment_customer').doc(testAuthUserId).get(),
2514
+ db.collection('payment_usage').where('userId', '==', testAuthUserId).get(),
2515
+ db.collection('payment_transaction').where('userId', '==', testAuthUserId).get(),
2516
+ core.files.bucket(true).getFiles({ prefix: `${testAuthUserId}_` })
2517
+ ])
2518
+ assert.equal(leftoverAuthUser, undefined, 'Expected user to be completely deleted from Firebase Auth')
2519
+ assert.equal(leftoverAuthMetaDoc.exists, false, 'Expected 0 leftover user_auth_meta doc in Firestore')
2520
+ assert.equal(leftoverAuthTokenDocs.size, 0, 'Expected 0 leftover auth_tokens docs in Firestore')
2521
+ assert.equal(leftoverFileDocs.size, 0, 'Expected 0 leftover file docs in Firestore')
2522
+ assert.equal(leftoverChatDocs.size, 0, 'Expected 0 leftover chat docs in Firestore')
2523
+ assert.equal(leftoverChatMsgDocs.size, 0, 'Expected 0 leftover chat message subcollection docs in Firestore')
2524
+ assert.equal(leftoverTaskDocs.size, 0, 'Expected 0 leftover _tasks docs in Firestore')
2525
+ assert.equal(leftoverEmailLogDocs.size, 0, 'Expected 0 leftover email_log docs in Firestore')
2526
+ assert.equal(leftoverPaymentCustomerDoc.exists, false, 'Expected 0 leftover payment_customer doc in Firestore')
2527
+ assert.equal(leftoverPaymentUsageDocs.size, 0, 'Expected 0 leftover payment_usage docs in Firestore')
2528
+ assert.equal(leftoverPaymentTxDocs.size, 0, 'Expected 0 leftover payment_transaction docs in Firestore')
2529
+ assert.equal(leftoverStorageBlobs.length, 0, 'Expected 0 leftover blobs in Cloud Storage')
2530
+ })
2531
+ })