@edge-base/server 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/admin-build/.gitkeep +0 -0
  2. package/admin-build/_app/env.js +1 -0
  3. package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
  4. package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
  5. package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
  6. package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
  7. package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
  8. package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
  9. package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
  10. package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
  11. package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
  12. package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
  13. package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
  14. package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
  15. package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
  16. package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
  17. package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
  18. package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
  19. package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
  20. package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
  21. package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
  22. package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
  23. package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
  24. package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
  25. package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
  26. package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
  27. package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
  28. package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
  29. package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
  30. package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
  31. package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
  32. package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
  33. package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
  34. package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
  35. package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
  36. package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
  37. package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
  38. package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
  39. package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
  40. package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
  41. package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
  42. package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
  43. package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
  44. package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
  45. package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
  46. package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
  47. package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
  48. package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
  49. package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
  50. package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
  51. package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
  52. package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
  53. package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
  54. package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
  55. package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
  56. package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
  57. package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
  58. package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
  59. package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
  60. package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
  61. package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
  62. package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
  63. package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
  64. package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
  65. package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
  66. package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
  67. package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
  68. package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
  69. package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
  70. package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
  71. package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
  72. package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
  73. package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
  74. package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
  75. package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
  76. package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
  77. package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
  78. package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
  79. package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
  80. package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
  81. package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
  82. package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
  83. package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
  84. package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
  85. package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
  86. package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
  87. package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
  88. package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
  89. package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
  90. package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
  91. package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
  92. package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
  93. package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
  94. package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
  95. package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
  96. package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
  97. package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
  98. package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
  99. package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
  100. package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
  101. package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
  102. package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
  103. package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
  104. package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
  105. package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
  106. package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
  107. package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
  108. package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
  109. package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
  110. package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
  111. package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
  112. package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
  113. package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
  114. package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
  115. package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
  116. package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
  117. package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
  118. package/admin-build/_app/version.json +1 -0
  119. package/admin-build/favicon.svg +26 -0
  120. package/admin-build/index.html +45 -0
  121. package/openapi.json +19543 -0
  122. package/package.json +66 -0
  123. package/src/__tests__/admin-assets.test.ts +55 -0
  124. package/src/__tests__/admin-data-routes.test.ts +488 -0
  125. package/src/__tests__/admin-db-target.test.ts +103 -0
  126. package/src/__tests__/admin-routing.test.ts +31 -0
  127. package/src/__tests__/admin-user-management.test.ts +311 -0
  128. package/src/__tests__/analytics-query.test.ts +75 -0
  129. package/src/__tests__/auth-d1.test.ts +749 -0
  130. package/src/__tests__/auth-db-adapter.test.ts +73 -0
  131. package/src/__tests__/auth-jwt.test.ts +440 -0
  132. package/src/__tests__/auth-oauth.test.ts +389 -0
  133. package/src/__tests__/auth-password.test.ts +367 -0
  134. package/src/__tests__/auth-redirect.test.ts +87 -0
  135. package/src/__tests__/backup-restore.test.ts +711 -0
  136. package/src/__tests__/broadcast.test.ts +128 -0
  137. package/src/__tests__/cli.test.ts +178 -0
  138. package/src/__tests__/cloudflare-realtime.test.ts +113 -0
  139. package/src/__tests__/config.test.ts +469 -0
  140. package/src/__tests__/cors.test.ts +154 -0
  141. package/src/__tests__/cron.test.ts +302 -0
  142. package/src/__tests__/d1-handler.test.ts +402 -0
  143. package/src/__tests__/d1-sql.test.ts +120 -0
  144. package/src/__tests__/database-live-config.test.ts +42 -0
  145. package/src/__tests__/database-live-emitter.test.ts +56 -0
  146. package/src/__tests__/database-live-filters.test.ts +63 -0
  147. package/src/__tests__/database-live-route.test.ts +113 -0
  148. package/src/__tests__/db-sql.test.ts +163 -0
  149. package/src/__tests__/do-lifecycle.test.ts +263 -0
  150. package/src/__tests__/do-router.test.ts +729 -0
  151. package/src/__tests__/email-provider.test.ts +128 -0
  152. package/src/__tests__/email-templates.test.ts +528 -0
  153. package/src/__tests__/error-format.test.ts +250 -0
  154. package/src/__tests__/field-ops.test.ts +242 -0
  155. package/src/__tests__/functions-context.test.ts +334 -0
  156. package/src/__tests__/functions-d1-proxy.test.ts +229 -0
  157. package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
  158. package/src/__tests__/functions-route.test.ts +139 -0
  159. package/src/__tests__/internal-request.test.ts +77 -0
  160. package/src/__tests__/log-writer.test.ts +44 -0
  161. package/src/__tests__/logger.test.ts +58 -0
  162. package/src/__tests__/meta-admin-proxy.test.ts +48 -0
  163. package/src/__tests__/meta-export-coverage.test.ts +191 -0
  164. package/src/__tests__/meta-route-registration.test.ts +47 -0
  165. package/src/__tests__/namespace-dump.test.ts +28 -0
  166. package/src/__tests__/oauth-providers.test.ts +337 -0
  167. package/src/__tests__/openapi-coverage.test.ts +144 -0
  168. package/src/__tests__/pagination.test.ts +59 -0
  169. package/src/__tests__/password-policy.test.ts +191 -0
  170. package/src/__tests__/plugin-migrations.test.ts +379 -0
  171. package/src/__tests__/postgres-batch-compat.test.ts +133 -0
  172. package/src/__tests__/postgres-dialect.test.ts +328 -0
  173. package/src/__tests__/postgres-executor.test.ts +79 -0
  174. package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
  175. package/src/__tests__/postgres-schema-init.test.ts +105 -0
  176. package/src/__tests__/postgres-table-utils.test.ts +107 -0
  177. package/src/__tests__/presence.test.ts +199 -0
  178. package/src/__tests__/provider.test.ts +550 -0
  179. package/src/__tests__/public-user-profile.test.ts +339 -0
  180. package/src/__tests__/push-handlers.test.ts +179 -0
  181. package/src/__tests__/push-provider.test.ts +80 -0
  182. package/src/__tests__/push-token.test.ts +418 -0
  183. package/src/__tests__/query.test.ts +771 -0
  184. package/src/__tests__/rate-limit.test.ts +260 -0
  185. package/src/__tests__/room-access-policy.test.ts +101 -0
  186. package/src/__tests__/room-handler-context.test.ts +130 -0
  187. package/src/__tests__/room-monitoring.test.ts +138 -0
  188. package/src/__tests__/room-runtime-routing.test.ts +222 -0
  189. package/src/__tests__/room.test.ts +254 -0
  190. package/src/__tests__/route-parser.test.ts +490 -0
  191. package/src/__tests__/rules.test.ts +234 -0
  192. package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
  193. package/src/__tests__/scheduled.test.ts +80 -0
  194. package/src/__tests__/schema.test.ts +1273 -0
  195. package/src/__tests__/security-hardening.test.ts +312 -0
  196. package/src/__tests__/server.unit.test.ts +333 -0
  197. package/src/__tests__/service-key-db-proxy.test.ts +650 -0
  198. package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
  199. package/src/__tests__/service-key.test.ts +757 -0
  200. package/src/__tests__/smoke-skip-report.test.ts +72 -0
  201. package/src/__tests__/sms-provider.test.ts +39 -0
  202. package/src/__tests__/sql-route.test.ts +218 -0
  203. package/src/__tests__/storage-hook-context.test.ts +115 -0
  204. package/src/__tests__/totp.test.ts +200 -0
  205. package/src/__tests__/uuid.test.ts +144 -0
  206. package/src/__tests__/validation.test.ts +773 -0
  207. package/src/__tests__/websocket-pending.test.ts +163 -0
  208. package/src/_functions-registry.ts +51 -0
  209. package/src/bench-entry.ts +9 -0
  210. package/src/cloudflare-test.d.ts +1 -0
  211. package/src/durable-objects/auth-do.ts +49 -0
  212. package/src/durable-objects/database-do.ts +2240 -0
  213. package/src/durable-objects/database-live-do.ts +949 -0
  214. package/src/durable-objects/logs-do.ts +1200 -0
  215. package/src/durable-objects/room-runtime-base.ts +1604 -0
  216. package/src/durable-objects/rooms-do.ts +2191 -0
  217. package/src/generated-config.ts +6 -0
  218. package/src/index.ts +382 -0
  219. package/src/lib/admin-assets.ts +54 -0
  220. package/src/lib/admin-db-target.ts +301 -0
  221. package/src/lib/admin-routing.ts +35 -0
  222. package/src/lib/admin-user-management.ts +464 -0
  223. package/src/lib/analytics-adapter.ts +103 -0
  224. package/src/lib/analytics-query.ts +579 -0
  225. package/src/lib/auth-d1-service.ts +1193 -0
  226. package/src/lib/auth-d1.ts +1056 -0
  227. package/src/lib/auth-db-adapter.ts +289 -0
  228. package/src/lib/auth-redirect.ts +116 -0
  229. package/src/lib/cidr.ts +115 -0
  230. package/src/lib/client-ip.ts +51 -0
  231. package/src/lib/cloudflare-realtime.ts +251 -0
  232. package/src/lib/control-db.ts +36 -0
  233. package/src/lib/cron.ts +163 -0
  234. package/src/lib/d1-handler.ts +1425 -0
  235. package/src/lib/d1-schema-init.ts +255 -0
  236. package/src/lib/d1-sql.ts +33 -0
  237. package/src/lib/database-live-config.ts +24 -0
  238. package/src/lib/database-live-emitter.ts +111 -0
  239. package/src/lib/db-sql.ts +66 -0
  240. package/src/lib/do-retry.ts +36 -0
  241. package/src/lib/do-router.ts +270 -0
  242. package/src/lib/do-sql.ts +73 -0
  243. package/src/lib/email-provider.ts +379 -0
  244. package/src/lib/email-templates.ts +285 -0
  245. package/src/lib/email-translations.ts +422 -0
  246. package/src/lib/errors.ts +151 -0
  247. package/src/lib/functions.ts +2091 -0
  248. package/src/lib/hono.ts +56 -0
  249. package/src/lib/internal-request.ts +56 -0
  250. package/src/lib/jwt.ts +354 -0
  251. package/src/lib/log-writer.ts +272 -0
  252. package/src/lib/namespace-dump.ts +125 -0
  253. package/src/lib/oauth-providers.ts +1225 -0
  254. package/src/lib/op-parser.ts +99 -0
  255. package/src/lib/openapi.ts +146 -0
  256. package/src/lib/pagination.ts +19 -0
  257. package/src/lib/password-policy.ts +102 -0
  258. package/src/lib/password.ts +145 -0
  259. package/src/lib/plugin-migrations.ts +612 -0
  260. package/src/lib/postgres-executor.ts +203 -0
  261. package/src/lib/postgres-handler.ts +1102 -0
  262. package/src/lib/postgres-schema-init.ts +341 -0
  263. package/src/lib/postgres-table-utils.ts +87 -0
  264. package/src/lib/public-user-profile.ts +187 -0
  265. package/src/lib/push-provider.ts +409 -0
  266. package/src/lib/push-token.ts +294 -0
  267. package/src/lib/query-engine.ts +768 -0
  268. package/src/lib/room-monitoring.ts +97 -0
  269. package/src/lib/room-runtime.ts +14 -0
  270. package/src/lib/route-parser.ts +434 -0
  271. package/src/lib/schema.ts +538 -0
  272. package/src/lib/schemas.ts +152 -0
  273. package/src/lib/service-key.ts +419 -0
  274. package/src/lib/sms-provider.ts +230 -0
  275. package/src/lib/startup-config.ts +99 -0
  276. package/src/lib/totp.ts +242 -0
  277. package/src/lib/uuid.ts +87 -0
  278. package/src/lib/validation.ts +205 -0
  279. package/src/lib/version.ts +2 -0
  280. package/src/lib/websocket-pending.ts +40 -0
  281. package/src/middleware/auth.ts +169 -0
  282. package/src/middleware/captcha-verify.ts +217 -0
  283. package/src/middleware/cors.ts +159 -0
  284. package/src/middleware/error-handler.ts +54 -0
  285. package/src/middleware/internal-guard.ts +26 -0
  286. package/src/middleware/logger.ts +126 -0
  287. package/src/middleware/rate-limit.ts +283 -0
  288. package/src/middleware/rules.ts +475 -0
  289. package/src/routes/admin-auth.ts +447 -0
  290. package/src/routes/admin.ts +3501 -0
  291. package/src/routes/analytics-api.ts +290 -0
  292. package/src/routes/auth.ts +4222 -0
  293. package/src/routes/backup.ts +1466 -0
  294. package/src/routes/config.ts +53 -0
  295. package/src/routes/d1.ts +109 -0
  296. package/src/routes/database-live.ts +281 -0
  297. package/src/routes/functions.ts +155 -0
  298. package/src/routes/health.ts +32 -0
  299. package/src/routes/kv.ts +167 -0
  300. package/src/routes/oauth.ts +1055 -0
  301. package/src/routes/push.ts +1465 -0
  302. package/src/routes/room.ts +639 -0
  303. package/src/routes/schema-endpoint.ts +76 -0
  304. package/src/routes/sql.ts +176 -0
  305. package/src/routes/storage.ts +1674 -0
  306. package/src/routes/tables.ts +699 -0
  307. package/src/routes/users.ts +21 -0
  308. package/src/routes/vectorize.ts +372 -0
  309. package/src/types.ts +99 -0
@@ -0,0 +1,3501 @@
1
+ /**
2
+ * Admin Dashboard routes — M12
3
+ *
4
+ * Two sections:
5
+ * 1. Auth routes (no JWT required): setup status, setup, login, refresh
6
+ * 2. Internal route (Service Key required): reset-password
7
+ * 3. Admin API routes (Admin JWT required): tables, users, storage, schema, logs, monitoring
8
+ *
9
+ * Admin accounts are managed via D1 Control Plane.
10
+ */
11
+ import { OpenAPIHono, createRoute, z, type HonoEnv } from '../lib/hono.js';
12
+ import type { Env } from '../types.js';
13
+ import { EdgeBaseError, getDbAccess, getTableAccess } from '@edge-base/shared';
14
+ import type { AuthContext } from '@edge-base/shared';
15
+ import {
16
+ signAdminAccessToken,
17
+ signAdminRefreshToken,
18
+ TokenExpiredError,
19
+ TokenInvalidError,
20
+ verifyAdminRefreshTokenWithFallback,
21
+ verifyAdminTokenWithFallback,
22
+ } from '../lib/jwt.js';
23
+ import { hashPassword, verifyPassword } from '../lib/password.js';
24
+ import { generateId } from '../lib/uuid.js';
25
+ import { validateKey, buildConstraintCtx, extractBearerToken, resolveServiceKeyCandidate } from '../lib/service-key.js';
26
+ import { parseConfig, getDbDoName, getD1BindingName, shouldRouteToD1 } from '../lib/do-router.js';
27
+ import { handleD1Request, d1BatchImport } from '../lib/d1-handler.js';
28
+ import { fetchDOWithRetry } from '../lib/do-retry.js';
29
+ import { dumpNamespaceTables } from '../lib/namespace-dump.js';
30
+ import { ensureD1Schema } from '../lib/d1-schema-init.js';
31
+ import { QUERY_PARAM_KEYS } from '../lib/query-engine.js';
32
+ import { parsePagination } from '../lib/pagination.js';
33
+ import { handlePgRequest } from '../lib/postgres-handler.js';
34
+ import { ensurePgSchema } from '../lib/postgres-schema-init.js';
35
+ import {
36
+ ensureLocalDevPostgresSchema,
37
+ getLocalDevPostgresExecOptions,
38
+ getProviderBindingName,
39
+ withPostgresConnection,
40
+ } from '../lib/postgres-executor.js';
41
+ import {
42
+ zodDefaultHook,
43
+ jsonResponseSchema,
44
+ errorResponseSchema,
45
+ } from '../lib/schemas.js';
46
+ import {
47
+ ensureAuthSchema,
48
+ adminExists,
49
+ createAdmin,
50
+ getAdminByEmail,
51
+ getAdminById,
52
+ getAdminSession,
53
+ createAdminSession,
54
+ deleteAdminSession,
55
+ listAdmins,
56
+ deleteAdmin,
57
+ updateAdminPassword,
58
+ listUserMappings,
59
+ searchUserMappingsByEmail,
60
+ countUsers,
61
+ deleteAnon,
62
+ } from '../lib/auth-d1.js';
63
+ import * as authService from '../lib/auth-d1-service.js';
64
+ import { resolveAuthDb, type AuthDb } from '../lib/auth-db-adapter.js';
65
+ import { getPublicProfileWithCache } from './users.js';
66
+ import { createSignedToken, parseDuration } from './storage.js';
67
+ import { getDevicesForUser, getPushLogs } from '../lib/push-token.js';
68
+ import { RATE_LIMIT_DEFAULTS } from '../middleware/rate-limit.js';
69
+ import {
70
+ createManagedAdminUser,
71
+ deleteManagedAdminUser,
72
+ normalizeAdminUserUpdates,
73
+ updateManagedAdminUser,
74
+ } from '../lib/admin-user-management.js';
75
+ import { DATABASE_LIVE_HUB_DO_NAME } from '../lib/database-live-emitter.js';
76
+ import { fetchRoomMonitoringStatsFromKv } from '../lib/room-monitoring.js';
77
+ import {
78
+ executeAdminDbQuery,
79
+ resolveAdminInstanceOptions,
80
+ serializeAdminInstanceDiscovery,
81
+ } from '../lib/admin-db-target.js';
82
+
83
+ const BUILT_IN_RATE_LIMIT_GROUPS = [
84
+ 'global',
85
+ 'db',
86
+ 'storage',
87
+ 'functions',
88
+ 'auth',
89
+ 'authSignin',
90
+ 'authSignup',
91
+ 'events',
92
+ ] as const;
93
+
94
+ const AUTH_BACKUP_TABLES = [
95
+ '_email_index',
96
+ '_oauth_index',
97
+ '_anon_index',
98
+ '_phone_index',
99
+ '_passkey_index',
100
+ '_admins',
101
+ '_admin_sessions',
102
+ '_users_public',
103
+ '_meta',
104
+ '_users',
105
+ '_sessions',
106
+ '_oauth_accounts',
107
+ '_email_tokens',
108
+ '_mfa_factors',
109
+ '_mfa_recovery_codes',
110
+ '_webauthn_credentials',
111
+ ] as const;
112
+
113
+ const AUTH_BACKUP_TABLE_SET = new Set<string>(AUTH_BACKUP_TABLES);
114
+
115
+ function quoteSqlIdentifier(identifier: string): string {
116
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
117
+ throw new EdgeBaseError(400, `Invalid SQL identifier: ${identifier}`, undefined, 'validation-failed');
118
+ }
119
+ return `"${identifier}"`;
120
+ }
121
+
122
+ interface MonitoringStats {
123
+ subsystem?: string;
124
+ activeConnections: number;
125
+ authenticatedConnections?: number;
126
+ channels: number;
127
+ channelDetails?: Array<{ channel: string; subscribers: number }>;
128
+ }
129
+
130
+ function emptyMonitoringStats(): MonitoringStats {
131
+ return {
132
+ activeConnections: 0,
133
+ authenticatedConnections: 0,
134
+ channels: 0,
135
+ channelDetails: [],
136
+ };
137
+ }
138
+
139
+ async function fetchMonitoringStatsFromNamespace(
140
+ namespace: DurableObjectNamespace | undefined,
141
+ hubName: string,
142
+ ): Promise<MonitoringStats> {
143
+ if (!namespace) return emptyMonitoringStats();
144
+
145
+ try {
146
+ const stub = namespace.get(namespace.idFromName(hubName));
147
+ const resp = await stub.fetch(new Request('http://internal/internal/stats', {
148
+ headers: { 'X-DO-Name': hubName },
149
+ }));
150
+ if (!resp.ok) return emptyMonitoringStats();
151
+ const stats = await resp.json() as MonitoringStats;
152
+ return {
153
+ ...emptyMonitoringStats(),
154
+ ...stats,
155
+ channelDetails: Array.isArray(stats.channelDetails) ? stats.channelDetails : [],
156
+ };
157
+ } catch {
158
+ return emptyMonitoringStats();
159
+ }
160
+ }
161
+
162
+ async function fetchUnifiedMonitoringStats(env: Env): Promise<MonitoringStats & {
163
+ databaseLive: MonitoringStats;
164
+ rooms: MonitoringStats;
165
+ }> {
166
+ const [databaseLive, rooms] = await Promise.all([
167
+ fetchMonitoringStatsFromNamespace(env.DATABASE_LIVE, DATABASE_LIVE_HUB_DO_NAME),
168
+ fetchRoomMonitoringStatsFromKv(env.KV),
169
+ ]);
170
+
171
+ const channelDetails = [
172
+ ...(databaseLive.channelDetails ?? []),
173
+ ...(rooms.channelDetails ?? []),
174
+ ].sort((a, b) => b.subscribers - a.subscribers);
175
+
176
+ return {
177
+ activeConnections: databaseLive.activeConnections + rooms.activeConnections,
178
+ authenticatedConnections:
179
+ (databaseLive.authenticatedConnections ?? 0) + (rooms.authenticatedConnections ?? 0),
180
+ channels: new Set(channelDetails.map((detail) => detail.channel)).size,
181
+ channelDetails,
182
+ databaseLive,
183
+ rooms,
184
+ };
185
+ }
186
+
187
+ async function fetchRecentLogsFromDo(
188
+ env: Env,
189
+ options: {
190
+ limit: number;
191
+ level?: string;
192
+ pathFilter?: string;
193
+ category?: string;
194
+ },
195
+ ): Promise<Array<Record<string, unknown>> | null> {
196
+ if (!env.LOGS) return null;
197
+
198
+ try {
199
+ const logsId = env.LOGS.idFromName('logs:main');
200
+ const logsDO = env.LOGS.get(logsId);
201
+ const params = new URLSearchParams({
202
+ limit: String(Math.max(1, Math.min(options.limit, 200))),
203
+ });
204
+
205
+ if (options.level) params.set('level', options.level);
206
+ if (options.pathFilter) params.set('path', options.pathFilter);
207
+ if (options.category) params.set('category', options.category);
208
+
209
+ const resp = await logsDO.fetch(
210
+ new Request(`http://internal/internal/logs/recent?${params.toString()}`),
211
+ );
212
+ if (!resp.ok) return [];
213
+
214
+ const data = await resp.json<{ logs?: Array<Record<string, unknown>> }>();
215
+ return data.logs ?? [];
216
+ } catch {
217
+ return [];
218
+ }
219
+ }
220
+
221
+ function getLogStatusCode(log: Record<string, unknown>): number {
222
+ const status = log.status;
223
+ if (typeof status === 'number') return status;
224
+ if (typeof status === 'string') {
225
+ const parsed = Number.parseInt(status, 10);
226
+ return Number.isFinite(parsed) ? parsed : 0;
227
+ }
228
+ return 0;
229
+ }
230
+
231
+ function matchesLogLevel(status: number, level: string): boolean {
232
+ const normalized = level.toLowerCase();
233
+ if (normalized === 'error') return status >= 500;
234
+ if (normalized === 'warn') return status >= 300 && status < 500;
235
+ if (normalized === 'info') return status >= 200 && status < 300;
236
+ return true;
237
+ }
238
+
239
+ const DEFAULT_RATE_LIMIT_BINDING = {
240
+ limit: 10_000_000,
241
+ period: 60 as const,
242
+ };
243
+
244
+ function normalizeOptionalRole(role: unknown): string | undefined {
245
+ if (role === undefined) {
246
+ return undefined;
247
+ }
248
+ if (typeof role !== 'string') {
249
+ throw new EdgeBaseError(400, 'Role must be a non-empty string.', undefined, 'validation-failed');
250
+ }
251
+ const normalized = role.trim();
252
+ if (!normalized) {
253
+ throw new EdgeBaseError(400, 'Role must be a non-empty string.', undefined, 'validation-failed');
254
+ }
255
+ if (normalized.length > 100) {
256
+ throw new EdgeBaseError(400, 'Role must not exceed 100 characters.', undefined, 'validation-failed');
257
+ }
258
+ return normalized;
259
+ }
260
+
261
+ function buildRateLimitSummary(config: ReturnType<typeof parseConfig>) {
262
+ const configured = config.rateLimiting ?? {};
263
+ const entries: Array<{
264
+ group: string;
265
+ requests: number;
266
+ window: string;
267
+ binding: {
268
+ enabled: boolean;
269
+ limit?: number;
270
+ period?: number;
271
+ source: 'default' | 'override' | 'disabled' | 'custom';
272
+ } | null;
273
+ }> = [];
274
+ const seen = new Set<string>();
275
+ const formatWindow = (window: string | number | undefined, fallbackSec: number) => {
276
+ if (typeof window === 'number') return `${window}s`;
277
+ return window ?? `${fallbackSec}s`;
278
+ };
279
+
280
+ for (const group of BUILT_IN_RATE_LIMIT_GROUPS) {
281
+ const groupConfig = configured[group];
282
+ const fallback = RATE_LIMIT_DEFAULTS[group] ?? RATE_LIMIT_DEFAULTS.global;
283
+ const bindingConfig = groupConfig?.binding;
284
+
285
+ entries.push({
286
+ group,
287
+ requests: groupConfig?.requests ?? fallback.requests,
288
+ window: formatWindow(groupConfig?.window, fallback.windowSec),
289
+ binding: bindingConfig?.enabled === false
290
+ ? { enabled: false, source: 'disabled' }
291
+ : {
292
+ enabled: true,
293
+ limit: bindingConfig?.limit ?? DEFAULT_RATE_LIMIT_BINDING.limit,
294
+ period: bindingConfig?.period ?? DEFAULT_RATE_LIMIT_BINDING.period,
295
+ source: bindingConfig ? 'override' : 'default',
296
+ },
297
+ });
298
+ seen.add(group);
299
+ }
300
+
301
+ for (const [group, groupConfig] of Object.entries(configured)) {
302
+ if (seen.has(group) || !groupConfig) continue;
303
+ const bindingConfig = groupConfig.binding;
304
+
305
+ entries.push({
306
+ group,
307
+ requests: groupConfig.requests,
308
+ window: formatWindow(groupConfig.window, RATE_LIMIT_DEFAULTS.global.windowSec),
309
+ binding: bindingConfig
310
+ ? (
311
+ bindingConfig.enabled === false
312
+ ? { enabled: false, source: 'disabled' as const }
313
+ : {
314
+ enabled: true,
315
+ limit: bindingConfig.limit,
316
+ period: bindingConfig.period ?? DEFAULT_RATE_LIMIT_BINDING.period,
317
+ source: 'custom' as const,
318
+ }
319
+ )
320
+ : null,
321
+ });
322
+ }
323
+
324
+ return entries;
325
+ }
326
+
327
+
328
+ /** Resolve AuthDb from Hono context. Defaults to D1 (AUTH_DB binding). */
329
+ function getAuthDb(c: { env: Env }): AuthDb {
330
+ return resolveAuthDb(c.env as unknown as Record<string, unknown>);
331
+ }
332
+
333
+ export const adminRoute = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
334
+
335
+ // Error handler
336
+ adminRoute.onError((err, c) => {
337
+ if (err instanceof EdgeBaseError) {
338
+ return c.json(err.toJSON(), err.code as 400);
339
+ }
340
+ console.error('Admin Dashboard unhandled error:', err);
341
+ return c.json({ code: 500, message: 'Internal server error.' }, 500);
342
+ });
343
+
344
+ // ─────────────────────────────────────────────
345
+ // 1. Auth Routes — No JWT required
346
+ // Admin accounts stored in D1
347
+ // ─────────────────────────────────────────────
348
+
349
+ // GET /admin/api/setup/status — check if admin setup is needed
350
+ const adminSetupStatus = createRoute({
351
+ operationId: 'adminSetupStatus',
352
+ method: 'get',
353
+ path: '/setup/status',
354
+ tags: ['admin'],
355
+ summary: 'Check if admin setup is needed',
356
+ responses: {
357
+ 200: { description: 'Setup status', content: { 'application/json': { schema: jsonResponseSchema } } },
358
+ },
359
+ });
360
+
361
+ adminRoute.openapi(adminSetupStatus, async (c) => {
362
+ await ensureAuthSchema(getAuthDb(c));
363
+ const exists = await adminExists(getAuthDb(c));
364
+ return c.json({ needsSetup: !exists });
365
+ });
366
+
367
+ // POST /admin/api/setup — create the first admin account
368
+ const adminSetup = createRoute({
369
+ operationId: 'adminSetup',
370
+ method: 'post',
371
+ path: '/setup',
372
+ tags: ['admin'],
373
+ summary: 'Create the first admin account',
374
+ request: {
375
+ body: {
376
+ content: {
377
+ 'application/json': {
378
+ schema: z.object({
379
+ email: z.string(),
380
+ password: z.string(),
381
+ }).passthrough(),
382
+ },
383
+ },
384
+ required: true,
385
+ },
386
+ },
387
+ responses: {
388
+ 201: { description: 'Admin created', content: { 'application/json': { schema: jsonResponseSchema } } },
389
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
390
+ },
391
+ });
392
+
393
+ adminRoute.openapi(adminSetup, async (c) => {
394
+ await ensureAuthSchema(getAuthDb(c));
395
+ const exists = await adminExists(getAuthDb(c));
396
+ if (exists) throw new EdgeBaseError(400, 'Admin account already exists. Use login instead.', undefined, 'already-exists');
397
+
398
+ const body = await c.req.json<{ email: string; password: string }>();
399
+ if (!body.email || !body.password) throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'validation-failed');
400
+ body.email = body.email.trim().toLowerCase();
401
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
402
+ if (body.password.length < 8) throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
403
+ if (body.password.length > 256) throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
404
+
405
+ const adminSecret = c.env.JWT_ADMIN_SECRET;
406
+ if (!adminSecret) throw new EdgeBaseError(500, 'JWT_ADMIN_SECRET not configured.', undefined, 'internal-error');
407
+
408
+ const adminId = generateId();
409
+ const passwordHash = await hashPassword(body.password);
410
+ await createAdmin(getAuthDb(c), adminId, body.email, passwordHash);
411
+
412
+ const accessToken = await signAdminAccessToken({ sub: adminId }, adminSecret, '1h');
413
+ const refreshToken = await signAdminRefreshToken({ sub: adminId }, adminSecret, '28d');
414
+
415
+ const sessionId = generateId();
416
+ const expiresAt = new Date(Date.now() + 28 * 24 * 60 * 60 * 1000).toISOString();
417
+ await createAdminSession(getAuthDb(c), sessionId, adminId, refreshToken, expiresAt);
418
+
419
+ return c.json({
420
+ accessToken,
421
+ refreshToken,
422
+ admin: { id: adminId, email: body.email },
423
+ }, 201);
424
+ });
425
+
426
+ // POST /admin/api/auth/login — admin login
427
+ const adminLogin = createRoute({
428
+ operationId: 'adminLogin',
429
+ method: 'post',
430
+ path: '/auth/login',
431
+ tags: ['admin'],
432
+ summary: 'Admin login',
433
+ request: {
434
+ body: {
435
+ content: {
436
+ 'application/json': {
437
+ schema: z.object({
438
+ email: z.string(),
439
+ password: z.string(),
440
+ }).passthrough(),
441
+ },
442
+ },
443
+ required: true,
444
+ },
445
+ },
446
+ responses: {
447
+ 200: { description: 'Login successful', content: { 'application/json': { schema: jsonResponseSchema } } },
448
+ 401: { description: 'Invalid credentials', content: { 'application/json': { schema: errorResponseSchema } } },
449
+ },
450
+ });
451
+
452
+ adminRoute.openapi(adminLogin, async (c) => {
453
+ await ensureAuthSchema(getAuthDb(c));
454
+ const body = await c.req.json<{ email: string; password: string }>();
455
+ if (!body.email || !body.password) throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'validation-failed');
456
+ body.email = body.email.trim().toLowerCase();
457
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
458
+
459
+ const admin = await getAdminByEmail(getAuthDb(c), body.email);
460
+ if (!admin) throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
461
+
462
+ const valid = await verifyPassword(body.password, admin.passwordHash);
463
+ if (!valid) throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
464
+
465
+ const adminSecret = c.env.JWT_ADMIN_SECRET;
466
+ if (!adminSecret) throw new EdgeBaseError(500, 'JWT_ADMIN_SECRET not configured.', undefined, 'internal-error');
467
+
468
+ const accessToken = await signAdminAccessToken({ sub: admin.id }, adminSecret, '1h');
469
+ const refreshToken = await signAdminRefreshToken({ sub: admin.id }, adminSecret, '28d');
470
+
471
+ const sessionId = generateId();
472
+ const expiresAt = new Date(Date.now() + 28 * 24 * 60 * 60 * 1000).toISOString();
473
+ await createAdminSession(getAuthDb(c), sessionId, admin.id, refreshToken, expiresAt);
474
+
475
+ return c.json({
476
+ accessToken,
477
+ refreshToken,
478
+ admin: { id: admin.id, email: admin.email },
479
+ });
480
+ });
481
+
482
+ // POST /admin/api/auth/refresh — rotate admin token
483
+ const adminRefresh = createRoute({
484
+ operationId: 'adminRefresh',
485
+ method: 'post',
486
+ path: '/auth/refresh',
487
+ tags: ['admin'],
488
+ summary: 'Rotate admin token',
489
+ request: {
490
+ body: {
491
+ content: {
492
+ 'application/json': {
493
+ schema: z.object({
494
+ refreshToken: z.string(),
495
+ }).passthrough(),
496
+ },
497
+ },
498
+ required: true,
499
+ },
500
+ },
501
+ responses: {
502
+ 200: { description: 'Token rotated', content: { 'application/json': { schema: jsonResponseSchema } } },
503
+ 401: { description: 'Invalid token', content: { 'application/json': { schema: errorResponseSchema } } },
504
+ },
505
+ });
506
+
507
+ adminRoute.openapi(adminRefresh, async (c) => {
508
+ await ensureAuthSchema(getAuthDb(c));
509
+ const body = await c.req.json<{ refreshToken: string }>();
510
+ if (!body.refreshToken) throw new EdgeBaseError(400, 'Refresh token is required.', undefined, 'validation-failed');
511
+
512
+ const adminSecret = c.env.JWT_ADMIN_SECRET;
513
+ if (!adminSecret) throw new EdgeBaseError(500, 'JWT_ADMIN_SECRET not configured.', undefined, 'internal-error');
514
+
515
+ let tokenPayload: { sub: string };
516
+ try {
517
+ tokenPayload = await verifyAdminRefreshTokenWithFallback(
518
+ body.refreshToken,
519
+ adminSecret,
520
+ c.env.JWT_ADMIN_SECRET_OLD,
521
+ c.env.JWT_ADMIN_SECRET_OLD_AT,
522
+ ) as { sub: string };
523
+ } catch (err) {
524
+ if (err instanceof TokenExpiredError || err instanceof TokenInvalidError) {
525
+ throw new EdgeBaseError(401, 'Invalid or expired refresh token.', undefined, 'invalid-refresh-token');
526
+ }
527
+ throw err;
528
+ }
529
+
530
+ const session = await getAdminSession(getAuthDb(c), body.refreshToken);
531
+ if (!session) throw new EdgeBaseError(401, 'Invalid or expired refresh token.', undefined, 'invalid-refresh-token');
532
+ if (session.adminId !== tokenPayload.sub) {
533
+ throw new EdgeBaseError(401, 'Invalid or expired refresh token.', undefined, 'invalid-refresh-token');
534
+ }
535
+
536
+ const admin = await getAdminById(getAuthDb(c), session.adminId);
537
+ if (!admin) throw new EdgeBaseError(401, 'Admin not found.', undefined, 'user-not-found');
538
+
539
+ // Rotate: delete old session, create new
540
+ await deleteAdminSession(getAuthDb(c), session.id);
541
+
542
+ const newAccessToken = await signAdminAccessToken({ sub: admin.id }, adminSecret, '1h');
543
+ const newRefreshToken = await signAdminRefreshToken({ sub: admin.id }, adminSecret, '28d');
544
+
545
+ const newSessionId = generateId();
546
+ const expiresAt = new Date(Date.now() + 28 * 24 * 60 * 60 * 1000).toISOString();
547
+ await createAdminSession(getAuthDb(c), newSessionId, admin.id, newRefreshToken, expiresAt);
548
+
549
+ return c.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
550
+ });
551
+
552
+ // ─────────────────────────────────────────────
553
+ // 2. Internal Route — Service Key required
554
+ // ─────────────────────────────────────────────
555
+
556
+ // POST /admin/api/internal/reset-password — CLI reset-password endpoint
557
+ const adminResetPassword = createRoute({
558
+ operationId: 'adminResetPassword',
559
+ method: 'post',
560
+ path: '/internal/reset-password',
561
+ tags: ['admin'],
562
+ summary: 'Reset admin password (Service Key required)',
563
+ request: {
564
+ body: {
565
+ content: {
566
+ 'application/json': {
567
+ schema: z.object({
568
+ email: z.string(),
569
+ newPassword: z.string(),
570
+ }).passthrough(),
571
+ },
572
+ },
573
+ required: true,
574
+ },
575
+ },
576
+ responses: {
577
+ 200: { description: 'Password reset', content: { 'application/json': { schema: jsonResponseSchema } } },
578
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
579
+ 401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
580
+ 403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
581
+ },
582
+ });
583
+
584
+ adminRoute.openapi(adminResetPassword, async (c) => {
585
+ const config = parseConfig(c.env);
586
+ const provided = resolveServiceKeyCandidate(c.req);
587
+ const { result } = validateKey(provided, 'auth:admin:*:*', config, c.env, undefined, buildConstraintCtx(c.env, c.req));
588
+ if (result === 'missing') {
589
+ throw new EdgeBaseError(403, 'Service Key required for admin operations.', undefined, 'forbidden');
590
+ }
591
+ if (result === 'invalid') {
592
+ throw new EdgeBaseError(401, 'Invalid or missing Service Key.', undefined, 'unauthenticated');
593
+ }
594
+
595
+ await ensureAuthSchema(getAuthDb(c));
596
+ const body = await c.req.json<{ email: string; newPassword: string }>();
597
+ if (!body.email || !body.newPassword) throw new EdgeBaseError(400, 'Email and newPassword are required.', undefined, 'validation-failed');
598
+ body.email = body.email.trim().toLowerCase();
599
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
600
+ if (body.newPassword.length < 8) throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
601
+ if (body.newPassword.length > 256) throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
602
+
603
+ const admin = await getAdminByEmail(getAuthDb(c), body.email);
604
+ if (!admin) throw new EdgeBaseError(404, 'Admin not found.', undefined, 'user-not-found');
605
+
606
+ const newHash = await hashPassword(body.newPassword);
607
+ await updateAdminPassword(getAuthDb(c), admin.id, newHash);
608
+
609
+ return c.json({ ok: true, message: 'Admin password reset successfully.' });
610
+ });
611
+
612
+ // ─────────────────────────────────────────────
613
+ // 3. Admin API Routes — Admin JWT required
614
+ // ─────────────────────────────────────────────
615
+
616
+ // Sub-app for JWT-protected routes
617
+ const api = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
618
+
619
+ // Admin JWT or Service Key middleware — verifies Admin JWT with separate signing key,
620
+ // OR accepts service key for programmatic/server admin access
621
+ api.use('*', async (c, next) => {
622
+ const config = parseConfig(c.env);
623
+
624
+ // 1. Try service key first (allows admin SDK to access /admin/api/data/*)
625
+ const explicitServiceKey =
626
+ c.req.header('X-EdgeBase-Service-Key') ??
627
+ c.req.header('x-edgebase-service-key') ??
628
+ c.req.raw.headers.get('X-EdgeBase-Service-Key') ??
629
+ c.req.raw.headers.get('x-edgebase-service-key');
630
+ const provided = explicitServiceKey ?? resolveServiceKeyCandidate(c.req, extractBearerToken(c.req));
631
+
632
+ if (provided !== undefined) {
633
+ const { result } = validateKey(provided, 'auth:admin:*:*', config, c.env, undefined, buildConstraintCtx(c.env, c.req));
634
+ if (result === 'valid') {
635
+ // Valid service key — allow access
636
+ await next();
637
+ return;
638
+ }
639
+
640
+ // 2. Try Admin JWT
641
+ const authHeader = c.req.header('authorization');
642
+ if (authHeader?.startsWith('Bearer ')) {
643
+ const token = authHeader.slice(7);
644
+ const secret = c.env.JWT_ADMIN_SECRET;
645
+ if (secret) {
646
+ try {
647
+ const payload = await verifyAdminTokenWithFallback(
648
+ token,
649
+ secret,
650
+ c.env.JWT_ADMIN_SECRET_OLD,
651
+ c.env.JWT_ADMIN_SECRET_OLD_AT,
652
+ );
653
+ c.set('adminId' as never, payload.sub);
654
+ await next();
655
+ return;
656
+ } catch {
657
+ // fall through to 401
658
+ }
659
+ }
660
+ }
661
+ }
662
+
663
+ throw new EdgeBaseError(401, 'Admin authentication required. Provide Admin JWT or Service Key.', undefined, 'unauthenticated');
664
+ });
665
+
666
+ // ─── Tables API ───
667
+
668
+ /** Parse config to get tables from databases block (§1). */
669
+ function getTables(env: Env): Array<{ name: string; namespace: string; fields: Record<string, unknown> }> {
670
+ try {
671
+ const config = parseConfig(env);
672
+ const result: Array<{ name: string; namespace: string; fields: Record<string, unknown> }> = [];
673
+ for (const [namespace, dbBlock] of Object.entries(config.databases ?? {})) {
674
+ for (const [tableName, tableConfig] of Object.entries(dbBlock.tables ?? {})) {
675
+ result.push({
676
+ name: tableName,
677
+ namespace,
678
+ fields: tableConfig.schema ?? {},
679
+ });
680
+ }
681
+ }
682
+ return result;
683
+ } catch {
684
+ return [];
685
+ }
686
+ }
687
+
688
+ /** Get the Database DO stub for a table (§1/§2). */
689
+ function findNamespaceForTable(tableName: string, config: ReturnType<typeof parseConfig>): string {
690
+ for (const [ns, dbBlock] of Object.entries(config.databases ?? {})) {
691
+ if (dbBlock.tables?.[tableName]) {
692
+ return ns;
693
+ }
694
+ }
695
+ return 'shared';
696
+ }
697
+
698
+ function getTableDO(env: Env, tableName: string, config: ReturnType<typeof parseConfig>, instanceId?: string) {
699
+ const namespace = findNamespaceForTable(tableName, config);
700
+ const doName = getDbDoName(namespace, instanceId);
701
+ return { stub: env.DATABASE.get(env.DATABASE.idFromName(doName)), doName };
702
+ }
703
+
704
+ function isDynamicDbBlock(
705
+ dbBlock: {
706
+ instance?: boolean;
707
+ access?: {
708
+ canCreate?: unknown;
709
+ access?: unknown;
710
+ };
711
+ } | undefined,
712
+ ): boolean {
713
+ if (!dbBlock) return false;
714
+ return !!(dbBlock.instance || dbBlock.access?.canCreate || dbBlock.access?.access);
715
+ }
716
+
717
+ function getEffectiveDbProvider(namespace: string, config: ReturnType<typeof parseConfig>): 'do' | 'd1' | 'postgres' | 'neon' {
718
+ const dbBlock = config.databases?.[namespace];
719
+ if (!dbBlock) return 'do';
720
+ if (dbBlock.provider === 'neon') {
721
+ return 'neon';
722
+ }
723
+ if (dbBlock.provider === 'postgres') {
724
+ return 'postgres';
725
+ }
726
+ if (dbBlock.provider === 'do' || dbBlock.provider === 'd1') {
727
+ return dbBlock.provider;
728
+ }
729
+ return shouldRouteToD1(namespace, config) ? 'd1' : 'do';
730
+ }
731
+
732
+ function getRequestedInstanceId(c: { req: { query: (name: string) => string | undefined } }): string | undefined {
733
+ const raw = c.req.query('instanceId');
734
+ if (!raw) return undefined;
735
+ const trimmed = raw.trim();
736
+ return trimmed.length > 0 ? trimmed : undefined;
737
+ }
738
+
739
+ function validateAdminTableInstanceId(
740
+ namespace: string,
741
+ config: ReturnType<typeof parseConfig>,
742
+ instanceId: string | undefined,
743
+ ): Response | null {
744
+ const dynamic = isDynamicDbBlock(config.databases?.[namespace]);
745
+ if (!instanceId) {
746
+ if (dynamic) {
747
+ return new Response(
748
+ JSON.stringify({
749
+ code: 400,
750
+ message: `instanceId is required for dynamic namespace '${namespace}'`,
751
+ }),
752
+ {
753
+ status: 400,
754
+ headers: { 'Content-Type': 'application/json' },
755
+ },
756
+ );
757
+ }
758
+ return null;
759
+ }
760
+
761
+ if (instanceId.includes(':')) {
762
+ return new Response(
763
+ JSON.stringify({
764
+ code: 400,
765
+ message: 'instanceId must not contain \':\'',
766
+ }),
767
+ {
768
+ status: 400,
769
+ headers: { 'Content-Type': 'application/json' },
770
+ },
771
+ );
772
+ }
773
+
774
+ return null;
775
+ }
776
+
777
+ async function restoreAdminNamespaceTables(
778
+ env: Env,
779
+ config: ReturnType<typeof parseConfig>,
780
+ body: {
781
+ namespace: string;
782
+ tables: Record<string, Array<Record<string, unknown>>>;
783
+ skipWipe?: boolean;
784
+ },
785
+ ): Promise<void> {
786
+ const dbBlock = config.databases?.[body.namespace];
787
+ if (!dbBlock) throw new EdgeBaseError(404, `Namespace '${body.namespace}' not found in config.`, undefined, 'not-found');
788
+
789
+ const userTableNames = Object.keys(dbBlock.tables ?? {});
790
+ const provider = dbBlock.provider;
791
+ const batchSize = 100;
792
+
793
+ if (provider === 'neon' || provider === 'postgres') {
794
+ const bindingName = getProviderBindingName(body.namespace);
795
+ const envRecord = env as unknown as Record<string, unknown>;
796
+ const hyperdrive = envRecord[bindingName] as { connectionString: string } | undefined;
797
+ const envKey = dbBlock.connectionString ?? `${bindingName}_URL`;
798
+ const connStr = hyperdrive?.connectionString ?? (envRecord[envKey] as string | undefined);
799
+ if (!connStr) {
800
+ throw new EdgeBaseError(500, `PostgreSQL connection not available for '${body.namespace}'.`, undefined, 'internal-error');
801
+ }
802
+
803
+ const localDevOptions = getLocalDevPostgresExecOptions(env as unknown as Record<string, unknown>, body.namespace);
804
+ if (localDevOptions) {
805
+ await ensureLocalDevPostgresSchema(localDevOptions);
806
+ }
807
+ await withPostgresConnection(connStr, async (query) => {
808
+ if (!localDevOptions) {
809
+ await ensurePgSchema(connStr, body.namespace, dbBlock.tables ?? {}, query);
810
+ }
811
+
812
+ if (!body.skipWipe) {
813
+ for (const tableName of [...userTableNames, '_meta']) {
814
+ try {
815
+ await query(`DELETE FROM "${tableName}"`, []);
816
+ } catch {
817
+ // Table may not exist yet.
818
+ }
819
+ }
820
+ }
821
+
822
+ for (const tableName of [...userTableNames, '_meta']) {
823
+ const rows = body.tables[tableName];
824
+ if (!rows || rows.length === 0) continue;
825
+
826
+ const escId = (name: string) => `"${name.replace(/"/g, '""')}"`;
827
+ for (const row of rows) {
828
+ const columns = Object.keys(row);
829
+ const columnList = columns.map((col) => escId(col)).join(', ');
830
+ const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
831
+ const values = columns.map((col) => row[col]);
832
+ await query(
833
+ `INSERT INTO ${escId(tableName)} (${columnList}) VALUES (${placeholders})`,
834
+ values,
835
+ );
836
+ }
837
+ }
838
+
839
+ for (const tableName of userTableNames) {
840
+ try {
841
+ await query(
842
+ `SELECT setval(pg_get_serial_sequence('"${tableName}"', 'id'), COALESCE((SELECT MAX(CAST(id AS BIGINT)) FROM "${tableName}"), 0) + 1, false)`,
843
+ [],
844
+ );
845
+ } catch {
846
+ // Sequence may not exist for this table.
847
+ }
848
+ }
849
+ }, localDevOptions);
850
+ return;
851
+ }
852
+
853
+ if (!shouldRouteToD1(body.namespace, config)) {
854
+ throw new EdgeBaseError(400, `Namespace '${body.namespace}' is not restorable via the admin data backup API.`, undefined, 'validation-failed');
855
+ }
856
+
857
+ const bindingName = getD1BindingName(body.namespace);
858
+ const db = (env as unknown as Record<string, unknown>)[bindingName] as D1Database | undefined;
859
+ if (!db) {
860
+ throw new EdgeBaseError(500, `D1 binding '${bindingName}' not available for '${body.namespace}'.`, undefined, 'internal-error');
861
+ }
862
+
863
+ await ensureD1Schema(db, body.namespace, dbBlock.tables ?? {});
864
+
865
+ if (!body.skipWipe) {
866
+ const wipeStmts = [...userTableNames, '_meta'].map((tableName) => db.prepare(`DELETE FROM "${tableName}"`));
867
+ if (wipeStmts.length > 0) {
868
+ await db.batch(wipeStmts);
869
+ }
870
+ }
871
+
872
+ for (const tableName of [...userTableNames, '_meta']) {
873
+ const rows = body.tables[tableName];
874
+ if (!rows || rows.length === 0) continue;
875
+
876
+ const escId = (name: string) => `"${name.replace(/"/g, '""')}"`;
877
+ const insertStmts: D1PreparedStatement[] = [];
878
+ for (const row of rows) {
879
+ const columns = Object.keys(row);
880
+ const columnList = columns.map((col) => escId(col)).join(', ');
881
+ const placeholders = columns.map(() => '?').join(', ');
882
+ const values = columns.map((col) => row[col]);
883
+ insertStmts.push(
884
+ db.prepare(
885
+ `INSERT OR REPLACE INTO ${escId(tableName)} (${columnList}) VALUES (${placeholders})`,
886
+ ).bind(...values),
887
+ );
888
+ }
889
+
890
+ for (let index = 0; index < insertStmts.length; index += batchSize) {
891
+ await db.batch(insertStmts.slice(index, index + batchSize));
892
+ }
893
+ }
894
+ }
895
+
896
+ // GET /admin/api/data/tables — list all tables from config
897
+ const adminListTables = createRoute({
898
+ operationId: 'adminListTables',
899
+ method: 'get',
900
+ path: '/tables',
901
+ tags: ['admin'],
902
+ summary: 'List all tables from config',
903
+ responses: {
904
+ 200: { description: 'Tables list', content: { 'application/json': { schema: jsonResponseSchema } } },
905
+ },
906
+ });
907
+
908
+ api.openapi(adminListTables, (c) => {
909
+ const tables = getTables(c.env);
910
+ return c.json({ tables: tables.map((col) => ({ name: col.name, namespace: col.namespace, fieldCount: Object.keys(col.fields).length })) });
911
+ });
912
+
913
+ /** Build DO URL with whitelisted query params passthrough.
914
+ * Uses QUERY_PARAM_KEYS — adding a key there auto-forwards it here. */
915
+ function buildDoUrl(basePath: string, incomingUrl: string): URL {
916
+ const incoming = new URL(incomingUrl).searchParams;
917
+ const url = new URL(`http://internal${basePath}`);
918
+ for (const key of QUERY_PARAM_KEYS) {
919
+ const val = incoming.get(key);
920
+ if (val) url.searchParams.set(key, val);
921
+ }
922
+ return url;
923
+ }
924
+
925
+ // GET /admin/api/data/tables/:name/records — list records with pagination (#133 §32)
926
+ const adminGetTableRecords = createRoute({
927
+ operationId: 'adminGetTableRecords',
928
+ method: 'get',
929
+ path: '/tables/{name}/records',
930
+ tags: ['admin'],
931
+ summary: 'List table records with pagination',
932
+ request: {
933
+ params: z.object({ name: z.string() }),
934
+ },
935
+ responses: {
936
+ 200: { description: 'Records list', content: { 'application/json': { schema: jsonResponseSchema } } },
937
+ },
938
+ });
939
+
940
+ api.openapi(adminGetTableRecords, async (c) => {
941
+ const name = c.req.param('name')!;
942
+ const config = parseConfig(c.env);
943
+ const namespace = findNamespaceForTable(name, config);
944
+ const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
945
+ const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
946
+ if (instanceError) return instanceError;
947
+
948
+ // D1 route: handle directly in Worker context
949
+ if (!instanceId && shouldRouteToD1(namespace, config)) {
950
+ // Inject service-key header for admin bypass
951
+ c.set('isServiceKey' as never, true);
952
+ return handleD1Request(c, namespace, name, `/tables/${name}`);
953
+ }
954
+
955
+ const provider = config.databases?.[namespace]?.provider;
956
+ if (provider === 'neon' || provider === 'postgres') {
957
+ c.set('isServiceKey' as never, true);
958
+ return handlePgRequest(c, namespace, name, `/tables/${name}`);
959
+ }
960
+
961
+ const { stub, doName } = getTableDO(c.env, name, config, instanceId);
962
+ const url = buildDoUrl(`/tables/${name}`, c.req.url);
963
+
964
+ const resp = await fetchDOWithRetry(stub, url.toString(), {
965
+ method: 'GET',
966
+ headers: { 'X-DO-Name': doName, 'x-internal': 'true' },
967
+ }, { safeToRetry: true });
968
+ const data = await resp.json();
969
+ return c.json(data, resp.status as 200);
970
+ });
971
+
972
+
973
+ // POST /admin/api/data/tables/:name/records — create record (#133 §32)
974
+ // Admin requests are already authenticated — bypass row-level rules via X-Is-Service-Key.
975
+ const adminCreateTableRecord = createRoute({
976
+ operationId: 'adminCreateTableRecord',
977
+ method: 'post',
978
+ path: '/tables/{name}/records',
979
+ tags: ['admin'],
980
+ summary: 'Create a table record',
981
+ request: {
982
+ params: z.object({ name: z.string() }),
983
+ body: {
984
+ content: { 'application/json': { schema: z.record(z.string(), z.unknown()) } },
985
+ required: true,
986
+ },
987
+ },
988
+ responses: {
989
+ 200: { description: 'Record created', content: { 'application/json': { schema: jsonResponseSchema } } },
990
+ },
991
+ });
992
+
993
+ api.openapi(adminCreateTableRecord, async (c) => {
994
+ const name = c.req.param('name')!;
995
+ const config = parseConfig(c.env);
996
+ const namespace = findNamespaceForTable(name, config);
997
+ const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
998
+ const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
999
+ if (instanceError) return instanceError;
1000
+
1001
+ if (!instanceId && shouldRouteToD1(namespace, config)) {
1002
+ c.set('isServiceKey' as never, true);
1003
+ return handleD1Request(c, namespace, name, `/tables/${name}`);
1004
+ }
1005
+
1006
+ const provider = config.databases?.[namespace]?.provider;
1007
+ if (provider === 'neon' || provider === 'postgres') {
1008
+ c.set('isServiceKey' as never, true);
1009
+ return handlePgRequest(c, namespace, name, `/tables/${name}`);
1010
+ }
1011
+
1012
+ const body = await c.req.json();
1013
+ const { stub, doName } = getTableDO(c.env, name, config, instanceId);
1014
+
1015
+ const resp = await fetchDOWithRetry(stub, `http://internal/tables/${name}`, {
1016
+ method: 'POST',
1017
+ headers: { 'Content-Type': 'application/json', 'X-DO-Name': doName, 'x-internal': 'true', 'X-Is-Service-Key': 'true' },
1018
+ body: JSON.stringify(body),
1019
+ }, { safeToRetry: false });
1020
+ const data = await resp.json();
1021
+ return c.json(data, resp.status as 200);
1022
+ });
1023
+
1024
+
1025
+ // PUT /admin/api/data/tables/:name/records/:id — update record (#133 §32)
1026
+ // Admin dashboard sends PUT, but DO only has PATCH handler (database-do.ts:683).
1027
+ // We accept PUT from the client and forward as PATCH to the DO.
1028
+ const adminUpdateTableRecord = createRoute({
1029
+ operationId: 'adminUpdateTableRecord',
1030
+ method: 'put',
1031
+ path: '/tables/{name}/records/{id}',
1032
+ tags: ['admin'],
1033
+ summary: 'Update a table record',
1034
+ request: {
1035
+ params: z.object({ name: z.string(), id: z.string() }),
1036
+ body: {
1037
+ content: { 'application/json': { schema: z.record(z.string(), z.unknown()) } },
1038
+ required: true,
1039
+ },
1040
+ },
1041
+ responses: {
1042
+ 200: { description: 'Record updated', content: { 'application/json': { schema: jsonResponseSchema } } },
1043
+ },
1044
+ });
1045
+
1046
+ api.openapi(adminUpdateTableRecord, async (c) => {
1047
+ const name = c.req.param('name')!;
1048
+ const id = c.req.param('id')!;
1049
+ const config = parseConfig(c.env);
1050
+ const namespace = findNamespaceForTable(name, config);
1051
+ const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
1052
+ const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
1053
+ if (instanceError) return instanceError;
1054
+
1055
+ if (!instanceId && shouldRouteToD1(namespace, config)) {
1056
+ c.set('isServiceKey' as never, true);
1057
+ return handleD1Request(c, namespace, name, `/tables/${name}/${id}`);
1058
+ }
1059
+
1060
+ const provider = config.databases?.[namespace]?.provider;
1061
+ if (provider === 'neon' || provider === 'postgres') {
1062
+ c.set('isServiceKey' as never, true);
1063
+ return handlePgRequest(c, namespace, name, `/tables/${name}/${id}`);
1064
+ }
1065
+
1066
+ const body = await c.req.json();
1067
+ const { stub, doName } = getTableDO(c.env, name, config, instanceId);
1068
+
1069
+ const resp = await fetchDOWithRetry(stub, `http://internal/tables/${name}/${id}`, {
1070
+ method: 'PATCH',
1071
+ headers: { 'Content-Type': 'application/json', 'X-DO-Name': doName, 'x-internal': 'true', 'X-Is-Service-Key': 'true' },
1072
+ body: JSON.stringify(body),
1073
+ }, { safeToRetry: false });
1074
+ const data = await resp.json();
1075
+ return c.json(data, resp.status as 200);
1076
+ });
1077
+
1078
+
1079
+ // DELETE /admin/api/data/tables/:name/records/:id — delete record (#133 §32)
1080
+ const adminDeleteTableRecord = createRoute({
1081
+ operationId: 'adminDeleteTableRecord',
1082
+ method: 'delete',
1083
+ path: '/tables/{name}/records/{id}',
1084
+ tags: ['admin'],
1085
+ summary: 'Delete a table record',
1086
+ request: {
1087
+ params: z.object({ name: z.string(), id: z.string() }),
1088
+ },
1089
+ responses: {
1090
+ 200: { description: 'Record deleted', content: { 'application/json': { schema: jsonResponseSchema } } },
1091
+ },
1092
+ });
1093
+
1094
+ api.openapi(adminDeleteTableRecord, async (c) => {
1095
+ const name = c.req.param('name')!;
1096
+ const id = c.req.param('id')!;
1097
+ const config = parseConfig(c.env);
1098
+ const namespace = findNamespaceForTable(name, config);
1099
+ const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
1100
+ const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
1101
+ if (instanceError) return instanceError;
1102
+
1103
+ if (!instanceId && shouldRouteToD1(namespace, config)) {
1104
+ c.set('isServiceKey' as never, true);
1105
+ return handleD1Request(c, namespace, name, `/tables/${name}/${id}`);
1106
+ }
1107
+
1108
+ const provider = config.databases?.[namespace]?.provider;
1109
+ if (provider === 'neon' || provider === 'postgres') {
1110
+ c.set('isServiceKey' as never, true);
1111
+ return handlePgRequest(c, namespace, name, `/tables/${name}/${id}`);
1112
+ }
1113
+
1114
+ const { stub, doName } = getTableDO(c.env, name, config, instanceId);
1115
+
1116
+ const resp = await fetchDOWithRetry(stub, `http://internal/tables/${name}/${id}`, {
1117
+ method: 'DELETE',
1118
+ headers: { 'X-DO-Name': doName, 'x-internal': 'true', 'X-Is-Service-Key': 'true' },
1119
+ }, { safeToRetry: false });
1120
+ const data = await resp.json();
1121
+ return c.json(data, resp.status as 200);
1122
+ });
1123
+
1124
+
1125
+ // ─── Users API ───
1126
+
1127
+ // GET /admin/api/data/users — list users via D1 index
1128
+ const adminListUsers = createRoute({
1129
+ operationId: 'adminListUsers',
1130
+ method: 'get',
1131
+ path: '/users',
1132
+ tags: ['admin'],
1133
+ summary: 'List users via D1 index',
1134
+ responses: {
1135
+ 200: { description: 'Users list', content: { 'application/json': { schema: jsonResponseSchema } } },
1136
+ },
1137
+ });
1138
+
1139
+ api.openapi(adminListUsers, async (c) => {
1140
+ await ensureAuthSchema(getAuthDb(c));
1141
+ const { limit, offset } = parsePagination(c.req.query('limit'), c.req.query('cursor'));
1142
+ const emailQuery = c.req.query('email') || '';
1143
+
1144
+ let result;
1145
+ if (emailQuery) {
1146
+ result = await searchUserMappingsByEmail(getAuthDb(c), emailQuery, limit, offset);
1147
+ } else {
1148
+ result = await listUserMappings(getAuthDb(c), limit, offset);
1149
+ }
1150
+ const { mappings, total } = result;
1151
+
1152
+ if (mappings.length === 0) return c.json({ users: [], cursor: null, total: 0 });
1153
+
1154
+ // Fetch full user details from D1 directly
1155
+ const userIds = mappings.map(m => m.userId);
1156
+ const users = await authService.batchGetUsers(getAuthDb(c), userIds);
1157
+ const usersById = new Map(users.map((user) => [String(user.id ?? ''), user]));
1158
+ const sanitized = userIds
1159
+ .map((userId) => usersById.get(userId))
1160
+ .filter((user): user is Record<string, unknown> => !!user)
1161
+ .map((user) => authService.sanitizeUser(user, { includeAppMetadata: true }));
1162
+
1163
+ const hasMore = total > offset + limit;
1164
+ return c.json({ users: sanitized, cursor: hasMore ? String(offset + limit) : null, total });
1165
+ });
1166
+
1167
+ // GET /admin/api/data/users/:id — fetch a single user's auth info
1168
+ const adminGetUser = createRoute({
1169
+ operationId: 'adminGetUser',
1170
+ method: 'get',
1171
+ path: '/users/{id}',
1172
+ tags: ['admin'],
1173
+ summary: 'Fetch a single user by ID',
1174
+ request: {
1175
+ params: z.object({ id: z.string() }),
1176
+ },
1177
+ responses: {
1178
+ 200: { description: 'User data', content: { 'application/json': { schema: jsonResponseSchema } } },
1179
+ 404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
1180
+ },
1181
+ });
1182
+
1183
+ api.openapi(adminGetUser, async (c) => {
1184
+ const userId = c.req.param('id')!;
1185
+ const user = await authService.getUserById(getAuthDb(c), userId);
1186
+ if (!user) return c.json({ code: 404, message: 'User not found.' }, 404);
1187
+ return c.json({ user: authService.sanitizeUser(user, { includeAppMetadata: true }) });
1188
+ });
1189
+
1190
+ // PUT /admin/api/data/users/:id — update user status/role
1191
+ const adminUpdateUser = createRoute({
1192
+ operationId: 'adminUpdateUser',
1193
+ method: 'put',
1194
+ path: '/users/{id}',
1195
+ tags: ['admin'],
1196
+ summary: 'Update user status or role',
1197
+ request: {
1198
+ params: z.object({ id: z.string() }),
1199
+ body: {
1200
+ content: { 'application/json': { schema: z.record(z.string(), z.unknown()) } },
1201
+ required: true,
1202
+ },
1203
+ },
1204
+ responses: {
1205
+ 200: { description: 'User updated', content: { 'application/json': { schema: jsonResponseSchema } } },
1206
+ },
1207
+ });
1208
+
1209
+ api.openapi(adminUpdateUser, async (c) => {
1210
+ const userId = c.req.param('id')!;
1211
+ const body = await c.req.json() as Record<string, unknown>;
1212
+ const normalized = await normalizeAdminUserUpdates(body);
1213
+ const user = await updateManagedAdminUser(getAuthDb(c), userId, normalized as Record<string, unknown>, {
1214
+ executionCtx: c.executionCtx,
1215
+ kv: c.env.KV,
1216
+ });
1217
+ if (!user) return c.json({ code: 404, message: 'User not found.' }, 404);
1218
+ return c.json({ user: authService.sanitizeUser(user, { includeAppMetadata: true }) });
1219
+ });
1220
+
1221
+ // GET /admin/api/data/users/:id/profile — fetch any user profile with 3-tier cache
1222
+ const adminGetUserProfile = createRoute({
1223
+ operationId: 'adminGetUserProfile',
1224
+ method: 'get',
1225
+ path: '/users/{id}/profile',
1226
+ tags: ['admin'],
1227
+ summary: 'Fetch user profile with cache',
1228
+ request: {
1229
+ params: z.object({ id: z.string() }),
1230
+ },
1231
+ responses: {
1232
+ 200: { description: 'User profile', content: { 'application/json': { schema: jsonResponseSchema } } },
1233
+ 404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
1234
+ },
1235
+ });
1236
+
1237
+ api.openapi(adminGetUserProfile, async (c) => {
1238
+ const userId = c.req.param('id');
1239
+ if (!userId) {
1240
+ throw new EdgeBaseError(400, 'User ID is required.', undefined, 'validation-failed');
1241
+ }
1242
+
1243
+ const profile = await getPublicProfileWithCache(userId, c.env, c.executionCtx);
1244
+
1245
+ if (!profile) {
1246
+ throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
1247
+ }
1248
+
1249
+ return c.json(profile);
1250
+ });
1251
+
1252
+ // DELETE /admin/api/data/users/:id/sessions — revoke all user sessions
1253
+ const adminDeleteUserSessions = createRoute({
1254
+ operationId: 'adminDeleteUserSessions',
1255
+ method: 'delete',
1256
+ path: '/users/{id}/sessions',
1257
+ tags: ['admin'],
1258
+ summary: 'Revoke all user sessions',
1259
+ request: {
1260
+ params: z.object({ id: z.string() }),
1261
+ },
1262
+ responses: {
1263
+ 200: { description: 'Sessions revoked', content: { 'application/json': { schema: jsonResponseSchema } } },
1264
+ },
1265
+ });
1266
+
1267
+ api.openapi(adminDeleteUserSessions, async (c) => {
1268
+ const userId = c.req.param('id')!;
1269
+ await authService.deleteAllUserSessions(getAuthDb(c), userId);
1270
+ return c.json({ ok: true, message: 'All sessions revoked.' });
1271
+ });
1272
+
1273
+ // ─── Anon Index D1 Cleanup ───
1274
+
1275
+ // POST /admin/api/data/cleanup-anon — process KV-signaled _anon_index D1 cleanup
1276
+ const adminCleanupAnon = createRoute({
1277
+ operationId: 'adminCleanupAnon',
1278
+ method: 'post',
1279
+ path: '/cleanup-anon',
1280
+ tags: ['admin'],
1281
+ summary: 'Cleanup anonymous user index',
1282
+ responses: {
1283
+ 200: { description: 'Cleanup result', content: { 'application/json': { schema: jsonResponseSchema } } },
1284
+ },
1285
+ });
1286
+
1287
+ api.openapi(adminCleanupAnon, async (c) => {
1288
+ await ensureAuthSchema(getAuthDb(c));
1289
+ const config = parseConfig(c.env);
1290
+ const retentionDays = config?.auth?.anonymousRetentionDays ?? 30;
1291
+
1292
+ const deletedIds = await authService.cleanStaleAnonymousAccounts(getAuthDb(c), retentionDays);
1293
+
1294
+ // Clean D1 indexes
1295
+ for (const id of deletedIds) {
1296
+ await deleteAnon(getAuthDb(c), id).catch(() => {});
1297
+ }
1298
+
1299
+ return c.json({ ok: true, cleaned: deletedIds.length });
1300
+ });
1301
+
1302
+ // ─── Storage API ───
1303
+
1304
+ // GET /admin/api/data/storage/buckets — list configured buckets
1305
+ const adminListBuckets = createRoute({
1306
+ operationId: 'adminListBuckets',
1307
+ method: 'get',
1308
+ path: '/storage/buckets',
1309
+ tags: ['admin'],
1310
+ summary: 'List configured storage buckets',
1311
+ responses: {
1312
+ 200: { description: 'Buckets list', content: { 'application/json': { schema: jsonResponseSchema } } },
1313
+ },
1314
+ });
1315
+
1316
+ api.openapi(adminListBuckets, (c) => {
1317
+ try {
1318
+ const config = parseConfig(c.env);
1319
+ const buckets = Object.keys(config?.storage?.buckets || {});
1320
+ return c.json({ buckets });
1321
+ } catch {
1322
+ return c.json({ buckets: [] });
1323
+ }
1324
+ });
1325
+
1326
+ // GET /admin/api/data/storage/buckets/:name/objects — list objects in a bucket
1327
+ const adminListBucketObjects = createRoute({
1328
+ operationId: 'adminListBucketObjects',
1329
+ method: 'get',
1330
+ path: '/storage/buckets/{name}/objects',
1331
+ tags: ['admin'],
1332
+ summary: 'List objects in a storage bucket',
1333
+ request: {
1334
+ params: z.object({ name: z.string() }),
1335
+ },
1336
+ responses: {
1337
+ 200: { description: 'Objects list', content: { 'application/json': { schema: jsonResponseSchema } } },
1338
+ },
1339
+ });
1340
+
1341
+ api.openapi(adminListBucketObjects, async (c) => {
1342
+ const bucketName = c.req.param('name');
1343
+ const userPrefix = c.req.query('prefix') || '';
1344
+ const delimiter = c.req.query('delimiter') || '';
1345
+ const prefix = `${bucketName}/${userPrefix}`;
1346
+ const cursor = c.req.query('cursor');
1347
+ const limit = parseInt(c.req.query('limit') || '50', 10);
1348
+
1349
+ const listOptions: R2ListOptions = { prefix, limit, include: ['httpMetadata'] };
1350
+ if (cursor) listOptions.cursor = cursor;
1351
+ if (delimiter) listOptions.delimiter = delimiter;
1352
+
1353
+ const result = await c.env.STORAGE.list(listOptions);
1354
+ const objects = result.objects.map((obj) => ({
1355
+ key: obj.key.replace(`${bucketName}/`, ''),
1356
+ size: obj.size,
1357
+ uploaded: obj.uploaded.toISOString(),
1358
+ httpMetadata: obj.httpMetadata,
1359
+ }));
1360
+
1361
+ // Extract folder names from delimited prefixes
1362
+ const folders = (result.delimitedPrefixes || []).map((p: string) => p.replace(`${bucketName}/`, ''));
1363
+
1364
+ return c.json({
1365
+ objects,
1366
+ folders,
1367
+ cursor: result.truncated ? result.cursor : null,
1368
+ });
1369
+ });
1370
+
1371
+ // GET /admin/api/data/storage/buckets/:name/objects/:key — get object content (for preview)
1372
+ const adminGetBucketObject = createRoute({
1373
+ operationId: 'adminGetBucketObject',
1374
+ method: 'get',
1375
+ path: '/storage/buckets/{name}/objects/{key}',
1376
+ tags: ['admin'],
1377
+ summary: 'Get a storage object content',
1378
+ request: {
1379
+ params: z.object({ name: z.string(), key: z.string() }),
1380
+ },
1381
+ responses: {
1382
+ 200: { description: 'Object content' },
1383
+ 404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
1384
+ },
1385
+ });
1386
+
1387
+ api.openapi(adminGetBucketObject, async (c) => {
1388
+ const bucketName = c.req.param('name')!;
1389
+ const key = decodeURIComponent(c.req.param('key')!);
1390
+ const fullKey = `${bucketName}/${key}`;
1391
+ const obj = await c.env.STORAGE.get(fullKey);
1392
+ if (!obj) throw new EdgeBaseError(404, 'Object not found.', undefined, 'not-found');
1393
+ const headers = new Headers();
1394
+ if (obj.httpMetadata?.contentType) headers.set('Content-Type', obj.httpMetadata.contentType);
1395
+ headers.set('Cache-Control', 'private, max-age=3600');
1396
+ return new Response(obj.body, { headers });
1397
+ });
1398
+
1399
+ // DELETE /admin/api/data/storage/buckets/:name/objects/:key+ — delete an object
1400
+ const adminDeleteBucketObject = createRoute({
1401
+ operationId: 'adminDeleteBucketObject',
1402
+ method: 'delete',
1403
+ path: '/storage/buckets/{name}/objects/{key}',
1404
+ tags: ['admin'],
1405
+ summary: 'Delete a storage object',
1406
+ request: {
1407
+ params: z.object({ name: z.string(), key: z.string() }),
1408
+ },
1409
+ responses: {
1410
+ 200: { description: 'Object deleted', content: { 'application/json': { schema: jsonResponseSchema } } },
1411
+ },
1412
+ });
1413
+
1414
+ api.openapi(adminDeleteBucketObject, async (c) => {
1415
+ const bucketName = c.req.param('name');
1416
+ const key = c.req.param('key');
1417
+ const fullKey = `${bucketName}/${key}`;
1418
+
1419
+ await c.env.STORAGE.delete(fullKey);
1420
+ return c.json({ ok: true, deleted: fullKey });
1421
+ });
1422
+
1423
+ // GET /admin/api/data/storage/buckets/:name/stats — bucket statistics
1424
+ const adminGetBucketStats = createRoute({
1425
+ operationId: 'adminGetBucketStats',
1426
+ method: 'get',
1427
+ path: '/storage/buckets/{name}/stats',
1428
+ tags: ['admin'],
1429
+ summary: 'Get bucket statistics (total objects and size)',
1430
+ request: {
1431
+ params: z.object({ name: z.string() }),
1432
+ },
1433
+ responses: {
1434
+ 200: { description: 'Bucket stats', content: { 'application/json': { schema: jsonResponseSchema } } },
1435
+ },
1436
+ });
1437
+
1438
+ api.openapi(adminGetBucketStats, async (c) => {
1439
+ const bucketName = c.req.param('name');
1440
+ let totalObjects = 0;
1441
+ let totalSize = 0;
1442
+ let listCursor: string | undefined;
1443
+
1444
+ do {
1445
+ const listOptions: R2ListOptions = { prefix: `${bucketName}/`, limit: 1000 };
1446
+ if (listCursor) listOptions.cursor = listCursor;
1447
+ const result = await c.env.STORAGE.list(listOptions);
1448
+ for (const obj of result.objects) {
1449
+ totalObjects++;
1450
+ totalSize += obj.size;
1451
+ }
1452
+ listCursor = result.truncated ? result.cursor : undefined;
1453
+ } while (listCursor);
1454
+
1455
+ return c.json({ totalObjects, totalSize });
1456
+ });
1457
+
1458
+ // POST /admin/api/data/storage/buckets/:name/signed-url — create signed download URL
1459
+ const adminCreateSignedUrl = createRoute({
1460
+ operationId: 'adminCreateSignedUrl',
1461
+ method: 'post',
1462
+ path: '/storage/buckets/{name}/signed-url',
1463
+ tags: ['admin'],
1464
+ summary: 'Create a signed download URL for a storage object',
1465
+ request: {
1466
+ params: z.object({ name: z.string() }),
1467
+ body: {
1468
+ content: {
1469
+ 'application/json': {
1470
+ schema: z.object({
1471
+ key: z.string(),
1472
+ expiresIn: z.string().optional(),
1473
+ }),
1474
+ },
1475
+ },
1476
+ required: true,
1477
+ },
1478
+ },
1479
+ responses: {
1480
+ 200: { description: 'Signed URL created', content: { 'application/json': { schema: jsonResponseSchema } } },
1481
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
1482
+ 404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
1483
+ 500: { description: 'Server error', content: { 'application/json': { schema: errorResponseSchema } } },
1484
+ },
1485
+ });
1486
+
1487
+ api.openapi(adminCreateSignedUrl, async (c) => {
1488
+ const bucketName = c.req.param('name')!;
1489
+ const body = await c.req.json<{ key: string; expiresIn?: string }>();
1490
+
1491
+ if (!body.key) {
1492
+ throw new EdgeBaseError(400, 'Missing required field: key.', undefined, 'validation-failed');
1493
+ }
1494
+
1495
+ // Check file exists
1496
+ const fullKey = `${bucketName}/${body.key}`;
1497
+ const obj = await c.env.STORAGE.head(fullKey);
1498
+ if (!obj) {
1499
+ throw new EdgeBaseError(404, 'File not found.', undefined, 'not-found');
1500
+ }
1501
+
1502
+ const secret = c.env.JWT_USER_SECRET;
1503
+ if (!secret) {
1504
+ throw new EdgeBaseError(500, 'Signed URLs require JWT_USER_SECRET to be configured.', undefined, 'internal-error');
1505
+ }
1506
+
1507
+ const expiresInMs = parseDuration(body.expiresIn || '1h');
1508
+ const expiresAt = Date.now() + expiresInMs;
1509
+ const token = await createSignedToken(body.key, bucketName, expiresAt, secret);
1510
+
1511
+ // Build signed URL using the public storage endpoint
1512
+ const url = new URL(c.req.url);
1513
+ const signedUrl = `${url.protocol}//${url.host}/api/storage/${bucketName}/${body.key}?token=${token}`;
1514
+
1515
+ return c.json({
1516
+ url: signedUrl,
1517
+ expiresAt: new Date(expiresAt).toISOString(),
1518
+ });
1519
+ });
1520
+
1521
+ // ─── Schema API ───
1522
+
1523
+ // GET /admin/api/data/schema — full schema structure from config
1524
+ const adminGetSchema = createRoute({
1525
+ operationId: 'adminGetSchema',
1526
+ method: 'get',
1527
+ path: '/schema',
1528
+ tags: ['admin'],
1529
+ summary: 'Get full schema structure from config',
1530
+ responses: {
1531
+ 200: { description: 'Schema', content: { 'application/json': { schema: jsonResponseSchema } } },
1532
+ },
1533
+ });
1534
+
1535
+ api.openapi(adminGetSchema, (c) => {
1536
+ try {
1537
+ const config = parseConfig(c.env);
1538
+ const schema: Record<string, unknown> = {};
1539
+ const namespaces: Record<string, unknown> = {};
1540
+
1541
+ for (const [namespace, dbBlock] of Object.entries(config.databases ?? {})) {
1542
+ const provider = getEffectiveDbProvider(namespace, config);
1543
+ const dynamic = isDynamicDbBlock(dbBlock);
1544
+ const instanceDiscovery = serializeAdminInstanceDiscovery(dbBlock.admin?.instances, {
1545
+ fallbackManual: dynamic,
1546
+ });
1547
+ namespaces[namespace] = {
1548
+ provider,
1549
+ dynamic,
1550
+ instanceDiscovery,
1551
+ };
1552
+ for (const [tableName, tableConfig] of Object.entries(dbBlock.tables ?? {})) {
1553
+ schema[tableName] = {
1554
+ namespace,
1555
+ provider,
1556
+ dynamic,
1557
+ instanceDiscovery,
1558
+ fields: tableConfig.schema || {},
1559
+ indexes: tableConfig.indexes || [],
1560
+ fts: tableConfig.fts || [],
1561
+ };
1562
+ }
1563
+ }
1564
+
1565
+ return c.json({ schema, namespaces });
1566
+ } catch {
1567
+ return c.json({ schema: {}, namespaces: {} });
1568
+ }
1569
+ });
1570
+
1571
+ const adminListNamespaceInstances = createRoute({
1572
+ operationId: 'adminListNamespaceInstances',
1573
+ method: 'get',
1574
+ path: '/namespaces/{namespace}/instances',
1575
+ tags: ['admin'],
1576
+ summary: 'List instance suggestions for a dynamic namespace',
1577
+ request: {
1578
+ params: z.object({ namespace: z.string() }),
1579
+ query: z.object({
1580
+ q: z.string().optional(),
1581
+ limit: z.coerce.number().int().min(1).max(100).optional(),
1582
+ }),
1583
+ },
1584
+ responses: {
1585
+ 200: { description: 'Instance suggestions', content: { 'application/json': { schema: jsonResponseSchema } } },
1586
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
1587
+ 404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
1588
+ },
1589
+ });
1590
+
1591
+ api.openapi(adminListNamespaceInstances, async (c) => {
1592
+ const namespace = c.req.param('namespace')!;
1593
+ const config = parseConfig(c.env);
1594
+ if (!config.databases?.[namespace]) {
1595
+ throw new EdgeBaseError(404, `Namespace not found: ${namespace}`, undefined, 'not-found');
1596
+ }
1597
+
1598
+ try {
1599
+ const resolved = await resolveAdminInstanceOptions({
1600
+ env: c.env,
1601
+ config,
1602
+ namespace,
1603
+ query: c.req.query('q') ?? '',
1604
+ limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
1605
+ });
1606
+ return c.json(resolved);
1607
+ } catch (err) {
1608
+ const message = err instanceof Error ? err.message : 'Failed to resolve instance suggestions';
1609
+ if (message.includes('not dynamic')) {
1610
+ throw new EdgeBaseError(400, message, undefined, 'validation-failed');
1611
+ }
1612
+ throw new EdgeBaseError(400, message, undefined, 'validation-failed');
1613
+ }
1614
+ });
1615
+
1616
+ // GET /admin/api/data/tables/:name/export?format=json (#133 §32)
1617
+ // Exports table data as JSON array.
1618
+ // In DB-block architecture (#133 §1), all tables share namespace-level DO isolation.
1619
+ const adminExportTable = createRoute({
1620
+ operationId: 'adminExportTable',
1621
+ method: 'get',
1622
+ path: '/tables/{name}/export',
1623
+ tags: ['admin'],
1624
+ summary: 'Export table data as JSON',
1625
+ request: {
1626
+ params: z.object({ name: z.string() }),
1627
+ },
1628
+ responses: {
1629
+ 200: { description: 'Exported data', content: { 'application/json': { schema: jsonResponseSchema } } },
1630
+ 404: { description: 'Table not found', content: { 'application/json': { schema: errorResponseSchema } } },
1631
+ },
1632
+ });
1633
+
1634
+ api.openapi(adminExportTable, async (c) => {
1635
+ const name = c.req.param('name')!;
1636
+ const format = c.req.query('format') || 'json';
1637
+
1638
+ if (format !== 'json') {
1639
+ throw new EdgeBaseError(400, `Unsupported export format: ${format}. Only "json" is supported.`, undefined, 'validation-failed');
1640
+ }
1641
+
1642
+ // Validate table exists in config
1643
+ const allTables = getTables(c.env);
1644
+ const tableInfo = allTables.find((col) => col.name === name);
1645
+ if (!tableInfo) {
1646
+ throw new EdgeBaseError(404, `Table not found: ${name}`, undefined, 'not-found');
1647
+ }
1648
+
1649
+ const config = parseConfig(c.env);
1650
+
1651
+ const responseHeaders: Record<string, string> = {
1652
+ 'Content-Type': 'application/json',
1653
+ 'Content-Disposition': `attachment; filename="${name}-export.json"`,
1654
+ };
1655
+
1656
+ const records = (await dumpNamespaceTables(c.env, config, tableInfo.namespace, {
1657
+ includeMeta: false,
1658
+ tableNames: [name],
1659
+ }))[name] || [];
1660
+
1661
+ return new Response(JSON.stringify(records, null, 2), { headers: responseHeaders });
1662
+ });
1663
+
1664
+
1665
+ // ─── Logs API ───
1666
+
1667
+ // GET /admin/api/data/logs — request logs (from KV, M10 logger)
1668
+ const adminGetLogs = createRoute({
1669
+ operationId: 'adminGetLogs',
1670
+ method: 'get',
1671
+ path: '/logs',
1672
+ tags: ['admin'],
1673
+ summary: 'Get request logs',
1674
+ responses: {
1675
+ 200: { description: 'Logs', content: { 'application/json': { schema: jsonResponseSchema } } },
1676
+ },
1677
+ });
1678
+
1679
+ api.openapi(adminGetLogs, async (c) => {
1680
+ const limit = parseInt(c.req.query('limit') || '50', 10);
1681
+ const prefix = c.req.query('prefix') || 'log:';
1682
+ const level = c.req.query('level') || '';
1683
+ const pathFilter = c.req.query('path') || '';
1684
+ const category = c.req.query('category') || '';
1685
+
1686
+ try {
1687
+ const doLogs = await fetchRecentLogsFromDo(c.env, { limit, level, pathFilter, category });
1688
+ if (doLogs !== null) {
1689
+ return c.json({ logs: doLogs, cursor: null });
1690
+ }
1691
+
1692
+ const list = await c.env.KV.list({ prefix, limit });
1693
+ let logs: Array<Record<string, unknown>> = [];
1694
+
1695
+ for (const key of list.keys) {
1696
+ const value = await c.env.KV.get(key.name, 'json');
1697
+ if (value) logs.push(value as Record<string, unknown>);
1698
+ }
1699
+
1700
+ if (level) {
1701
+ logs = logs.filter((log) => matchesLogLevel(getLogStatusCode(log), level));
1702
+ }
1703
+
1704
+ if (pathFilter) {
1705
+ logs = logs.filter((log) => String(log.path ?? '').includes(pathFilter));
1706
+ }
1707
+
1708
+ if (category) {
1709
+ logs = logs.filter((log) => String(log.category ?? '').toLowerCase() === category.toLowerCase());
1710
+ }
1711
+
1712
+ return c.json({ logs, cursor: list.list_complete ? null : list.cursor });
1713
+ } catch {
1714
+ return c.json({ logs: [], cursor: null });
1715
+ }
1716
+ });
1717
+
1718
+ // ─── Monitoring API ───
1719
+
1720
+ // GET /admin/api/data/monitoring — live monitoring stats
1721
+ const adminGetMonitoring = createRoute({
1722
+ operationId: 'adminGetMonitoring',
1723
+ method: 'get',
1724
+ path: '/monitoring',
1725
+ tags: ['admin'],
1726
+ summary: 'Get live monitoring stats',
1727
+ responses: {
1728
+ 200: { description: 'Monitoring stats', content: { 'application/json': { schema: jsonResponseSchema } } },
1729
+ },
1730
+ });
1731
+
1732
+ api.openapi(adminGetMonitoring, async (c) => {
1733
+ return c.json(await fetchUnifiedMonitoringStats(c.env));
1734
+ });
1735
+
1736
+ // ─── Analytics API ───
1737
+
1738
+ // GET /admin/api/data/analytics — analytics dashboard data
1739
+ const adminGetAnalytics = createRoute({
1740
+ operationId: 'adminGetAnalytics',
1741
+ method: 'get',
1742
+ path: '/analytics',
1743
+ tags: ['admin'],
1744
+ summary: 'Get analytics dashboard data',
1745
+ request: {
1746
+ query: z.object({
1747
+ range: z.string().optional().openapi({ description: 'Time range (e.g. 1h, 6h, 24h, 7d, 30d)', example: '24h' }),
1748
+ category: z.string().optional().openapi({ description: 'Filter by category', example: 'db' }),
1749
+ metric: z.string().optional().openapi({ description: 'Metric type (overview, timeSeries, breakdown, topEndpoints)', example: 'overview' }),
1750
+ groupBy: z.string().optional().openapi({ description: 'Optional group-by override (minute, tenMinute, hour, day)', example: 'hour' }),
1751
+ start: z.string().optional().openapi({ description: 'Custom ISO start time' }),
1752
+ end: z.string().optional().openapi({ description: 'Custom ISO end time' }),
1753
+ excludeCategory: z.string().optional().openapi({ description: 'Exclude a category from the result set', example: 'admin' }),
1754
+ }),
1755
+ },
1756
+ responses: {
1757
+ 200: { description: 'Analytics data', content: { 'application/json': { schema: jsonResponseSchema } } },
1758
+ },
1759
+ });
1760
+
1761
+ api.openapi(adminGetAnalytics, async (c) => {
1762
+ const range = c.req.query('range') || '24h';
1763
+ const category = c.req.query('category') || '';
1764
+ const metric = c.req.query('metric') || 'overview';
1765
+ const start = c.req.query('start') || undefined;
1766
+ const end = c.req.query('end') || undefined;
1767
+ const excludeCategory = c.req.query('excludeCategory') || undefined;
1768
+
1769
+ const { executeAnalyticsQuery, resolveAnalyticsGroupBy } = await import('../lib/analytics-query.js');
1770
+ const groupBy = resolveAnalyticsGroupBy(range, start, end, c.req.query('groupBy') || undefined);
1771
+
1772
+ const result = await executeAnalyticsQuery(c.env, { range, category, metric, groupBy, start, end, excludeCategory });
1773
+ return c.json(result);
1774
+ });
1775
+
1776
+ // GET /admin/api/data/analytics/events — proxy custom events query for admin dashboard
1777
+ const adminGetAnalyticsEvents = createRoute({
1778
+ operationId: 'adminGetAnalyticsEvents',
1779
+ method: 'get',
1780
+ path: '/analytics/events',
1781
+ tags: ['admin'],
1782
+ summary: 'Query analytics events for admin dashboard',
1783
+ responses: {
1784
+ 200: { description: 'Events data', content: { 'application/json': { schema: jsonResponseSchema } } },
1785
+ },
1786
+ });
1787
+
1788
+ api.openapi(adminGetAnalyticsEvents, async (c) => {
1789
+ const range = c.req.query('range') || '24h';
1790
+ const type = c.req.query('type') || '';
1791
+ const userId = c.req.query('userId') || '';
1792
+ const metric = c.req.query('metric') || 'list';
1793
+ const groupBy = c.req.query('groupBy') || 'hour';
1794
+ const limit = c.req.query('limit') || '100';
1795
+ const cursor = c.req.query('cursor') || '';
1796
+
1797
+ if (!c.env.LOGS) {
1798
+ if (metric === 'list') return c.json({ events: [], cursor: undefined, hasMore: false });
1799
+ if (metric === 'count') return c.json({ totalEvents: 0, uniqueUsers: 0 });
1800
+ if (metric === 'timeSeries') return c.json({ timeSeries: [] });
1801
+ if (metric === 'topEvents') return c.json({ topEvents: [] });
1802
+ return c.json({ events: [] });
1803
+ }
1804
+
1805
+ const logsDO = c.env.LOGS.get(c.env.LOGS.idFromName('logs:main'));
1806
+ const params = new URLSearchParams({ range, metric, groupBy, limit });
1807
+ if (type && type !== 'all') params.set('event', type);
1808
+ if (userId) params.set('userId', userId);
1809
+ if (cursor) params.set('cursor', cursor);
1810
+
1811
+ const resp = await logsDO.fetch(
1812
+ new Request(`http://internal/internal/events/query?${params}`),
1813
+ );
1814
+ const data = await resp.json();
1815
+ return c.json(data);
1816
+ });
1817
+
1818
+ // ─── Overview API ───
1819
+
1820
+ // GET /admin/api/data/overview — project overview for dashboard home
1821
+ const adminGetOverview = createRoute({
1822
+ operationId: 'adminGetOverview',
1823
+ method: 'get',
1824
+ path: '/overview',
1825
+ tags: ['admin'],
1826
+ summary: 'Get project overview for dashboard home',
1827
+ request: {
1828
+ query: z.object({
1829
+ range: z.string().optional().openapi({ description: 'Time range (e.g. 1h, 6h, 24h, 7d, 30d)', example: '24h' }),
1830
+ groupBy: z.string().optional().openapi({ description: 'Optional group-by override (minute, tenMinute, hour, day)', example: 'hour' }),
1831
+ }),
1832
+ },
1833
+ responses: {
1834
+ 200: { description: 'Overview data', content: { 'application/json': { schema: jsonResponseSchema } } },
1835
+ },
1836
+ });
1837
+
1838
+ api.openapi(adminGetOverview, async (c) => {
1839
+ const { executeAnalyticsQuery, resolveAnalyticsGroupBy, resolveOverviewAutoRange } = await import('../lib/analytics-query.js');
1840
+ const requestedRange = c.req.query('range');
1841
+ const effectiveRange =
1842
+ requestedRange === '1h' || requestedRange === '6h' || requestedRange === '24h'
1843
+ ? requestedRange
1844
+ : await resolveOverviewAutoRange(c.env);
1845
+ const groupBy = resolveAnalyticsGroupBy(effectiveRange, undefined, undefined, c.req.query('groupBy') || undefined);
1846
+
1847
+ const [userCountResult, configResult, analyticsResult, liveStatsResult] = await Promise.allSettled([
1848
+ // User count from D1
1849
+ (async () => {
1850
+ await ensureAuthSchema(getAuthDb(c));
1851
+ return countUsers(getAuthDb(c));
1852
+ })(),
1853
+ // Config info (tables, buckets, auth providers)
1854
+ (async () => {
1855
+ const config = parseConfig(c.env);
1856
+ const databases = Object.entries(config.databases ?? {}).map(([name, db]) => ({
1857
+ name,
1858
+ tableCount: Object.keys(db.tables ?? {}).length,
1859
+ }));
1860
+ const totalTables = databases.reduce((sum, db) => sum + db.tableCount, 0);
1861
+ const storageConfig = config.storage ?? {} as Record<string, unknown>;
1862
+ const buckets = Object.keys((storageConfig as Record<string, unknown>).buckets ?? {});
1863
+ const serviceKeys = config.serviceKeys ?? [];
1864
+ const serviceKeyCount = Array.isArray(serviceKeys) ? serviceKeys.length : 0;
1865
+ const authProviders = config.auth?.allowedOAuthProviders ?? [];
1866
+ const sidecarPort = c.env.EDGEBASE_DEV_SIDECAR_PORT;
1867
+ return {
1868
+ databases,
1869
+ totalTables,
1870
+ storageBuckets: buckets,
1871
+ serviceKeyCount,
1872
+ authProviders,
1873
+ devMode: !!sidecarPort,
1874
+ };
1875
+ })(),
1876
+ // Analytics summary
1877
+ executeAnalyticsQuery(c.env, { range: effectiveRange, category: '', metric: 'overview', groupBy }),
1878
+ fetchUnifiedMonitoringStats(c.env),
1879
+ ]);
1880
+
1881
+ const totalUsers = userCountResult.status === 'fulfilled' ? userCountResult.value : 0;
1882
+ const config = configResult.status === 'fulfilled' ? configResult.value : {
1883
+ databases: [], totalTables: 0, storageBuckets: [], serviceKeyCount: 0, authProviders: [], devMode: false,
1884
+ };
1885
+ const analytics = analyticsResult.status === 'fulfilled' ? analyticsResult.value : {
1886
+ summary: { totalRequests: 0, totalErrors: 0, avgLatency: 0, uniqueUsers: 0 },
1887
+ timeSeries: [],
1888
+ breakdown: [],
1889
+ topItems: [],
1890
+ };
1891
+ const live = liveStatsResult.status === 'fulfilled'
1892
+ ? liveStatsResult.value as { activeConnections: number; channels: number }
1893
+ : { activeConnections: 0, channels: 0 };
1894
+
1895
+ return c.json({
1896
+ project: {
1897
+ totalUsers,
1898
+ totalTables: config.totalTables,
1899
+ databases: config.databases,
1900
+ storageBuckets: config.storageBuckets,
1901
+ serviceKeyCount: config.serviceKeyCount,
1902
+ authProviders: config.authProviders,
1903
+ liveConnections: live.activeConnections,
1904
+ liveChannels: live.channels,
1905
+ devMode: config.devMode,
1906
+ },
1907
+ traffic: {
1908
+ appliedRange: effectiveRange,
1909
+ summary: analytics.summary,
1910
+ timeSeries: analytics.timeSeries,
1911
+ breakdown: analytics.breakdown,
1912
+ topItems: analytics.topItems,
1913
+ },
1914
+ });
1915
+ });
1916
+
1917
+ // ─── Dev Info API ───
1918
+
1919
+ // GET /admin/api/data/dev-info — returns dev mode status and sidecar port
1920
+ const adminGetDevInfo = createRoute({
1921
+ operationId: 'adminGetDevInfo',
1922
+ method: 'get',
1923
+ path: '/dev-info',
1924
+ tags: ['admin'],
1925
+ summary: 'Get dev mode status and sidecar port',
1926
+ responses: {
1927
+ 200: { description: 'Dev info', content: { 'application/json': { schema: jsonResponseSchema } } },
1928
+ },
1929
+ });
1930
+
1931
+ api.openapi(adminGetDevInfo, (c) => {
1932
+ const sidecarPort = c.env.EDGEBASE_DEV_SIDECAR_PORT;
1933
+ return c.json({
1934
+ devMode: !!sidecarPort,
1935
+ sidecarPort: sidecarPort ? parseInt(sidecarPort, 10) : null,
1936
+ });
1937
+ });
1938
+
1939
+ // ─── SQL Console API ───
1940
+
1941
+ // POST /admin/api/data/sql — execute raw SQL via admin JWT (proxies to DatabaseDO)
1942
+ const adminExecuteSql = createRoute({
1943
+ operationId: 'adminExecuteSql',
1944
+ method: 'post',
1945
+ path: '/sql',
1946
+ tags: ['admin'],
1947
+ summary: 'Execute raw SQL query',
1948
+ request: {
1949
+ body: {
1950
+ content: {
1951
+ 'application/json': {
1952
+ schema: z.object({
1953
+ namespace: z.string(),
1954
+ id: z.string().optional(),
1955
+ sql: z.string(),
1956
+ params: z.array(z.unknown()).optional(),
1957
+ }).passthrough(),
1958
+ },
1959
+ },
1960
+ required: true,
1961
+ },
1962
+ },
1963
+ responses: {
1964
+ 200: { description: 'SQL result', content: { 'application/json': { schema: jsonResponseSchema } } },
1965
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
1966
+ 404: { description: 'Namespace not found', content: { 'application/json': { schema: errorResponseSchema } } },
1967
+ },
1968
+ });
1969
+
1970
+ api.openapi(adminExecuteSql, async (c) => {
1971
+ const body = await c.req.json<{ namespace: string; id?: string; sql: string; params?: unknown[] }>();
1972
+ if (!body.namespace || !body.sql) {
1973
+ throw new EdgeBaseError(400, 'namespace and sql are required.', undefined, 'validation-failed');
1974
+ }
1975
+
1976
+ // ── Block destructive DDL statements in admin SQL Console ──
1977
+ const sqlUpper = body.sql.trim().replace(/\s+/g, ' ').toUpperCase();
1978
+ const destructivePatterns = [
1979
+ /^DROP\s+TABLE/,
1980
+ /^DROP\s+INDEX/,
1981
+ /^DROP\s+TRIGGER/,
1982
+ /^DROP\s+VIEW/,
1983
+ /^ALTER\s+TABLE\s+\S+\s+DROP/,
1984
+ /^TRUNCATE/,
1985
+ /^DELETE\s+FROM\s+\S+\s*$/, // DELETE without WHERE clause
1986
+ /^DELETE\s+FROM\s+\S+\s*;?\s*$/, // DELETE without WHERE (with optional semicolon)
1987
+ ];
1988
+ for (const pat of destructivePatterns) {
1989
+ if (pat.test(sqlUpper)) {
1990
+ throw new EdgeBaseError(400, `Destructive SQL blocked: "${body.sql.trim().split(/\s+/).slice(0, 3).join(' ')}..." is not allowed in the admin SQL Console. Use the Schema editor or CLI for DDL operations.`, undefined, 'forbidden');
1991
+ }
1992
+ }
1993
+
1994
+ const config = parseConfig(c.env);
1995
+ const databases = config.databases ?? {};
1996
+ if (!databases[body.namespace]) {
1997
+ throw new EdgeBaseError(404, `Namespace not found: ${body.namespace}`, undefined, 'not-found');
1998
+ }
1999
+
2000
+ // Validate id (no ':' allowed per)
2001
+ if (body.id && body.id.includes(':')) {
2002
+ throw new EdgeBaseError(400, 'Instance ID must not contain ":".', undefined, 'validation-failed');
2003
+ }
2004
+
2005
+ const start = Date.now();
2006
+ try {
2007
+ const result = await executeAdminDbQuery({
2008
+ env: c.env,
2009
+ config,
2010
+ namespace: body.namespace,
2011
+ id: body.id,
2012
+ sql: body.sql,
2013
+ params: body.params ?? [],
2014
+ });
2015
+ const elapsed = Date.now() - start;
2016
+ return c.json({ ...result, time: elapsed });
2017
+ } catch (err) {
2018
+ const elapsed = Date.now() - start;
2019
+ return c.json({ code: 400, message: err instanceof Error ? err.message : 'SQL execution failed', time: elapsed }, 400);
2020
+ }
2021
+ });
2022
+
2023
+ // ─── Batch Import API ───
2024
+
2025
+ // POST /admin/api/data/tables/:name/import — batch import records
2026
+ const adminImportTable = createRoute({
2027
+ operationId: 'adminImportTable',
2028
+ method: 'post',
2029
+ path: '/tables/{name}/import',
2030
+ tags: ['admin'],
2031
+ summary: 'Batch import records into a table',
2032
+ request: {
2033
+ params: z.object({ name: z.string() }),
2034
+ body: {
2035
+ content: {
2036
+ 'application/json': {
2037
+ schema: z.object({
2038
+ records: z.array(z.record(z.string(), z.unknown())),
2039
+ mode: z.enum(['create', 'upsert']).optional(),
2040
+ }).passthrough(),
2041
+ },
2042
+ },
2043
+ required: true,
2044
+ },
2045
+ },
2046
+ responses: {
2047
+ 200: { description: 'Import result', content: { 'application/json': { schema: jsonResponseSchema } } },
2048
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2049
+ },
2050
+ });
2051
+
2052
+ api.openapi(adminImportTable, async (c) => {
2053
+ const name = c.req.param('name')!;
2054
+ const body = await c.req.json<{ records: Record<string, unknown>[]; mode?: 'create' | 'upsert' }>();
2055
+
2056
+ if (!Array.isArray(body.records) || body.records.length === 0) {
2057
+ throw new EdgeBaseError(400, 'records array is required and must not be empty.', undefined, 'validation-failed');
2058
+ }
2059
+ if (body.records.length > 1000) {
2060
+ throw new EdgeBaseError(400, 'Maximum 1000 records per import.', undefined, 'validation-failed');
2061
+ }
2062
+
2063
+ const config = parseConfig(c.env);
2064
+ const namespace = findNamespaceForTable(name, config);
2065
+ const mode = body.mode ?? 'create';
2066
+ const upsert = mode === 'upsert' ? '?upsert=true' : '';
2067
+
2068
+ // D1 route: insert directly via D1 batch API
2069
+ if (shouldRouteToD1(namespace, config)) {
2070
+ const result = await d1BatchImport(c.env, namespace, name, body.records, {
2071
+ upsert: mode === 'upsert',
2072
+ });
2073
+ return c.json({ imported: result.imported, errors: result.errors, total: body.records.length });
2074
+ }
2075
+
2076
+ const { stub, doName } = getTableDO(c.env, name, config);
2077
+
2078
+ let imported = 0;
2079
+ const errors: Array<{ row: number; message: string }> = [];
2080
+
2081
+ // Batch create via DO (process in chunks of 50)
2082
+ const chunkSize = 50;
2083
+ for (let i = 0; i < body.records.length; i += chunkSize) {
2084
+ const chunk = body.records.slice(i, i + chunkSize);
2085
+
2086
+ try {
2087
+ const resp = await stub.fetch(new Request(`http://internal/tables/${name}/batch${upsert}`, {
2088
+ method: 'POST',
2089
+ headers: { 'Content-Type': 'application/json', 'X-DO-Name': doName, 'x-internal': 'true' },
2090
+ body: JSON.stringify({ inserts: chunk }),
2091
+ }));
2092
+
2093
+ if (resp.ok) {
2094
+ imported += chunk.length;
2095
+ } else {
2096
+ const errData = await resp.json() as { message?: string };
2097
+ for (let j = 0; j < chunk.length; j++) {
2098
+ errors.push({ row: i + j, message: errData.message ?? 'Batch insert failed' });
2099
+ }
2100
+ }
2101
+ } catch (err) {
2102
+ for (let j = 0; j < chunk.length; j++) {
2103
+ errors.push({ row: i + j, message: err instanceof Error ? err.message : 'Unknown error' });
2104
+ }
2105
+ }
2106
+ }
2107
+
2108
+ return c.json({ imported, errors, total: body.records.length });
2109
+ });
2110
+
2111
+ // ─── Rules Test API ───
2112
+
2113
+ // POST /admin/api/data/rules-test — evaluate access rules with simulated auth context
2114
+ const adminRulesTest = createRoute({
2115
+ operationId: 'adminRulesTest',
2116
+ method: 'post',
2117
+ path: '/rules-test',
2118
+ tags: ['admin'],
2119
+ summary: 'Evaluate access rules with simulated auth context',
2120
+ request: {
2121
+ body: {
2122
+ content: {
2123
+ 'application/json': {
2124
+ schema: z.object({
2125
+ namespace: z.string(),
2126
+ table: z.string(),
2127
+ auth: z.record(z.string(), z.unknown()).nullable(),
2128
+ record: z.record(z.string(), z.unknown()).optional(),
2129
+ operations: z.array(z.string()),
2130
+ }).passthrough(),
2131
+ },
2132
+ },
2133
+ required: true,
2134
+ },
2135
+ },
2136
+ responses: {
2137
+ 200: { description: 'Rules test results', content: { 'application/json': { schema: jsonResponseSchema } } },
2138
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2139
+ 404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
2140
+ },
2141
+ });
2142
+
2143
+ api.openapi(adminRulesTest, async (c) => {
2144
+ const body = await c.req.json<{
2145
+ namespace: string;
2146
+ table: string;
2147
+ auth: Record<string, unknown> | null;
2148
+ record?: Record<string, unknown>;
2149
+ operations: string[];
2150
+ }>();
2151
+
2152
+ if (!body.namespace || !body.table || !body.operations?.length) {
2153
+ throw new EdgeBaseError(400, 'namespace, table, and operations are required.', undefined, 'validation-failed');
2154
+ }
2155
+
2156
+ const config = parseConfig(c.env);
2157
+ const dbBlock = (config.databases ?? {})[body.namespace];
2158
+ if (!dbBlock) {
2159
+ throw new EdgeBaseError(404, `Namespace not found: ${body.namespace}`, undefined, 'not-found');
2160
+ }
2161
+
2162
+ const tableConfig = dbBlock.tables?.[body.table];
2163
+ if (!tableConfig) {
2164
+ throw new EdgeBaseError(404, `Table not found: ${body.table}`, undefined, 'not-found');
2165
+ }
2166
+
2167
+ const rules = getTableAccess(tableConfig) ?? {};
2168
+ const dbAccess = getDbAccess(dbBlock)?.access;
2169
+ const results: Array<{ operation: string; allowed: boolean; rule: string; error?: string }> = [];
2170
+
2171
+ for (const op of body.operations) {
2172
+ try {
2173
+ if (op === 'access' && typeof dbAccess === 'function') {
2174
+ const stubCtx = { db: { get: async () => null, exists: async () => false } };
2175
+ const allowed = await dbAccess(body.auth as AuthContext | null, 'test', stubCtx);
2176
+ results.push({ operation: op, allowed: !!allowed, rule: 'databases.' + body.namespace + '.access.access()' });
2177
+ } else if (op === 'access') {
2178
+ results.push({ operation: op, allowed: true, rule: '(no access rule defined — allowed by default)' });
2179
+ } else {
2180
+ const ruleFn = (rules as Record<string, unknown>)[op];
2181
+ if (typeof ruleFn === 'function') {
2182
+ const allowed = await ruleFn(body.auth, body.record ?? {});
2183
+ results.push({ operation: op, allowed: !!allowed, rule: `rules.${op}()` });
2184
+ } else if (ruleFn === undefined) {
2185
+ // No rule defined — check release mode
2186
+ const release = config.release ?? false;
2187
+ results.push({
2188
+ operation: op,
2189
+ allowed: !release,
2190
+ rule: release ? '(no access rule defined — denied in release mode)' : '(no access rule defined — allowed in dev mode)',
2191
+ });
2192
+ } else {
2193
+ results.push({ operation: op, allowed: !!ruleFn, rule: `rules.${op} = ${String(ruleFn)}` });
2194
+ }
2195
+ }
2196
+ } catch (err) {
2197
+ results.push({
2198
+ operation: op,
2199
+ allowed: false,
2200
+ rule: `access.${op}()`,
2201
+ error: err instanceof Error ? err.message : 'Evaluation error',
2202
+ });
2203
+ }
2204
+ }
2205
+
2206
+ return c.json({ results });
2207
+ });
2208
+
2209
+ // ─── Functions List API ───
2210
+
2211
+ // GET /admin/api/data/functions — list registered functions from config
2212
+ const adminListFunctions = createRoute({
2213
+ operationId: 'adminListFunctions',
2214
+ method: 'get',
2215
+ path: '/functions',
2216
+ tags: ['admin'],
2217
+ summary: 'List registered functions from config',
2218
+ responses: {
2219
+ 200: { description: 'Functions list', content: { 'application/json': { schema: jsonResponseSchema } } },
2220
+ },
2221
+ });
2222
+
2223
+ api.openapi(adminListFunctions, (c) => {
2224
+ try {
2225
+ const config = parseConfig(c.env) as Record<string, unknown>;
2226
+ const functionsConfig = config.functions as Record<string, unknown> | undefined;
2227
+ const functions: Array<{ path: string; methods: string[]; type: string }> = [];
2228
+
2229
+ if (functionsConfig && typeof functionsConfig === 'object') {
2230
+ // Extract function routes from config
2231
+ for (const [path, fn] of Object.entries(functionsConfig)) {
2232
+ if (typeof fn === 'object' && fn !== null) {
2233
+ const fnObj = fn as Record<string, unknown>;
2234
+ const methods = Array.isArray(fnObj.methods) ? fnObj.methods as string[] : ['POST'];
2235
+ const type = (fnObj.type as string) ?? 'endpoint';
2236
+ functions.push({ path, methods, type });
2237
+ }
2238
+ }
2239
+ }
2240
+
2241
+ return c.json({ functions });
2242
+ } catch {
2243
+ return c.json({ functions: [] });
2244
+ }
2245
+ });
2246
+
2247
+ // ─── Config Info API ───
2248
+
2249
+ // GET /admin/api/data/config-info — environment and config overview
2250
+ const adminGetConfigInfo = createRoute({
2251
+ operationId: 'adminGetConfigInfo',
2252
+ method: 'get',
2253
+ path: '/config-info',
2254
+ tags: ['admin'],
2255
+ summary: 'Get environment and config overview',
2256
+ responses: {
2257
+ 200: { description: 'Config info', content: { 'application/json': { schema: jsonResponseSchema } } },
2258
+ },
2259
+ });
2260
+
2261
+ api.openapi(adminGetConfigInfo, (c) => {
2262
+ try {
2263
+ const config = parseConfig(c.env);
2264
+ const sidecarPort = c.env.EDGEBASE_DEV_SIDECAR_PORT;
2265
+ const devMode = !!sidecarPort;
2266
+
2267
+ const databases = Object.entries(config.databases ?? {}).map(([name, db]) => ({
2268
+ name,
2269
+ tableCount: Object.keys(db.tables ?? {}).length,
2270
+ hasAccess: !!db.access?.access,
2271
+ }));
2272
+
2273
+ const storageConfig = config.storage ?? {} as Record<string, unknown>;
2274
+ const buckets = Object.keys((storageConfig as Record<string, unknown>).buckets ?? {});
2275
+
2276
+ // Service keys — show masked preview for admin display
2277
+ const rawServiceKeys = config.serviceKeys ?? [];
2278
+ const serviceKeyList: string[] = Array.isArray(rawServiceKeys)
2279
+ ? rawServiceKeys.map((k: string) => {
2280
+ if (typeof k !== 'string' || k.length < 8) return '****';
2281
+ return k.slice(0, 8) + '•'.repeat(Math.min(k.length - 8, 24));
2282
+ })
2283
+ : [];
2284
+ const serviceKeyCount = serviceKeyList.length;
2285
+
2286
+ // Native resources
2287
+ const kvNamespaces = Object.keys(config.kv ?? {});
2288
+ const d1Databases = Object.keys(config.d1 ?? {});
2289
+ const vectorizeIndexes = Object.keys(config.vectorize ?? {});
2290
+ const rateLimiting = buildRateLimitSummary(config);
2291
+
2292
+ return c.json({
2293
+ devMode,
2294
+ release: config.release ?? false,
2295
+ databases,
2296
+ storageBuckets: buckets,
2297
+ serviceKeyCount,
2298
+ serviceKeys: serviceKeyList,
2299
+ bindings: {
2300
+ kv: kvNamespaces,
2301
+ d1: d1Databases,
2302
+ vectorize: vectorizeIndexes,
2303
+ },
2304
+ auth: {
2305
+ providers: config.auth?.allowedOAuthProviders ?? [],
2306
+ anonymousAuth: config.auth?.anonymousAuth ?? false,
2307
+ },
2308
+ rateLimiting,
2309
+ });
2310
+ } catch {
2311
+ return c.json({
2312
+ devMode: false,
2313
+ release: false,
2314
+ databases: [],
2315
+ storageBuckets: [],
2316
+ serviceKeyCount: 0,
2317
+ bindings: { kv: [], d1: [], vectorize: [] },
2318
+ auth: { providers: [], anonymousAuth: false },
2319
+ rateLimiting: [],
2320
+ });
2321
+ }
2322
+ });
2323
+
2324
+ // ─── Recent Logs API (enhanced polling for real-time) ───
2325
+
2326
+ // GET /admin/api/data/logs/recent — recent request logs with filtering
2327
+ const adminGetRecentLogs = createRoute({
2328
+ operationId: 'adminGetRecentLogs',
2329
+ method: 'get',
2330
+ path: '/logs/recent',
2331
+ tags: ['admin'],
2332
+ summary: 'Get recent request logs with filtering',
2333
+ responses: {
2334
+ 200: { description: 'Recent logs', content: { 'application/json': { schema: jsonResponseSchema } } },
2335
+ },
2336
+ });
2337
+
2338
+ api.openapi(adminGetRecentLogs, async (c) => {
2339
+ const limit = parseInt(c.req.query('limit') || '100', 10);
2340
+ const level = c.req.query('level') || '';
2341
+ const pathFilter = c.req.query('path') || '';
2342
+ const category = c.req.query('category') || '';
2343
+
2344
+ try {
2345
+ const doLogs = await fetchRecentLogsFromDo(c.env, { limit, level, pathFilter, category });
2346
+ if (doLogs !== null) {
2347
+ return c.json({ logs: doLogs, total: doLogs.length });
2348
+ }
2349
+
2350
+ const list = await c.env.KV.list({ prefix: 'log:', limit: Math.min(limit, 200) });
2351
+ let logs: Array<Record<string, unknown>> = [];
2352
+
2353
+ for (const key of list.keys) {
2354
+ const value = await c.env.KV.get(key.name, 'json');
2355
+ if (value) logs.push(value as Record<string, unknown>);
2356
+ }
2357
+
2358
+ // Apply filters
2359
+ if (level) {
2360
+ logs = logs.filter((log) => {
2361
+ return matchesLogLevel(getLogStatusCode(log), level);
2362
+ });
2363
+ }
2364
+
2365
+ if (pathFilter) {
2366
+ logs = logs.filter((log) => String(log.path ?? '').includes(pathFilter));
2367
+ }
2368
+
2369
+ if (category) {
2370
+ logs = logs.filter((log) => String(log.category ?? '').toLowerCase() === category.toLowerCase());
2371
+ }
2372
+
2373
+ return c.json({ logs, total: logs.length });
2374
+ } catch {
2375
+ return c.json({ logs: [], total: 0 });
2376
+ }
2377
+ });
2378
+
2379
+ // ─── Auth Settings API ───
2380
+
2381
+ // GET /admin/api/data/auth/settings — OAuth provider config
2382
+ const adminGetAuthSettings = createRoute({
2383
+ operationId: 'adminGetAuthSettings',
2384
+ method: 'get',
2385
+ path: '/auth/settings',
2386
+ tags: ['admin'],
2387
+ summary: 'Get OAuth provider config',
2388
+ responses: {
2389
+ 200: { description: 'Auth settings', content: { 'application/json': { schema: jsonResponseSchema } } },
2390
+ },
2391
+ });
2392
+
2393
+ api.openapi(adminGetAuthSettings, (c) => {
2394
+ try {
2395
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2396
+ const config = parseConfig(c.env) as any;
2397
+ const authConfig = config?.auth || {};
2398
+ return c.json({
2399
+ providers: Array.isArray(authConfig.allowedOAuthProviders) ? authConfig.allowedOAuthProviders : [],
2400
+ emailAuth: authConfig.emailAuth !== false,
2401
+ anonymousAuth: !!authConfig.anonymousAuth,
2402
+ allowedRedirectUrls: Array.isArray(authConfig.allowedRedirectUrls) ? authConfig.allowedRedirectUrls : [],
2403
+ session: {
2404
+ accessTokenTTL: authConfig.session?.accessTokenTTL ?? null,
2405
+ refreshTokenTTL: authConfig.session?.refreshTokenTTL ?? null,
2406
+ maxActiveSessions: typeof authConfig.session?.maxActiveSessions === 'number'
2407
+ ? authConfig.session.maxActiveSessions
2408
+ : null,
2409
+ },
2410
+ magicLink: {
2411
+ enabled: !!authConfig.magicLink?.enabled,
2412
+ autoCreate: authConfig.magicLink?.autoCreate !== false,
2413
+ tokenTTL: authConfig.magicLink?.tokenTTL ?? null,
2414
+ },
2415
+ emailOtp: {
2416
+ enabled: !!authConfig.emailOtp?.enabled,
2417
+ autoCreate: authConfig.emailOtp?.autoCreate !== false,
2418
+ },
2419
+ passkeys: {
2420
+ enabled: !!authConfig.passkeys?.enabled,
2421
+ rpName: authConfig.passkeys?.rpName ?? null,
2422
+ rpID: authConfig.passkeys?.rpID ?? null,
2423
+ origin: Array.isArray(authConfig.passkeys?.origin)
2424
+ ? authConfig.passkeys.origin
2425
+ : authConfig.passkeys?.origin
2426
+ ? [authConfig.passkeys.origin]
2427
+ : [],
2428
+ },
2429
+ });
2430
+ } catch {
2431
+ return c.json({
2432
+ providers: [],
2433
+ emailAuth: true,
2434
+ anonymousAuth: false,
2435
+ allowedRedirectUrls: [],
2436
+ session: {
2437
+ accessTokenTTL: null,
2438
+ refreshTokenTTL: null,
2439
+ maxActiveSessions: null,
2440
+ },
2441
+ magicLink: {
2442
+ enabled: false,
2443
+ autoCreate: true,
2444
+ tokenTTL: null,
2445
+ },
2446
+ emailOtp: {
2447
+ enabled: false,
2448
+ autoCreate: true,
2449
+ },
2450
+ passkeys: {
2451
+ enabled: false,
2452
+ rpName: null,
2453
+ rpID: null,
2454
+ origin: [],
2455
+ },
2456
+ });
2457
+ }
2458
+ });
2459
+
2460
+ // GET /admin/api/data/email/templates — read email template/subject config
2461
+ const adminGetEmailTemplates = createRoute({
2462
+ operationId: 'adminGetEmailTemplates',
2463
+ method: 'get',
2464
+ path: '/email/templates',
2465
+ tags: ['admin'],
2466
+ summary: 'Get email template and subject config',
2467
+ responses: {
2468
+ 200: { description: 'Email template config', content: { 'application/json': { schema: jsonResponseSchema } } },
2469
+ },
2470
+ });
2471
+
2472
+ api.openapi(adminGetEmailTemplates, (c) => {
2473
+ try {
2474
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2475
+ const config = parseConfig(c.env) as any;
2476
+ const emailConfig = config?.email || {};
2477
+ return c.json({
2478
+ appName: emailConfig.appName || 'EdgeBase',
2479
+ configured: !!emailConfig.provider,
2480
+ subjects: {
2481
+ verification: emailConfig.subjects?.verification || null,
2482
+ passwordReset: emailConfig.subjects?.passwordReset || null,
2483
+ magicLink: emailConfig.subjects?.magicLink || null,
2484
+ emailOtp: emailConfig.subjects?.emailOtp || null,
2485
+ emailChange: emailConfig.subjects?.emailChange || null,
2486
+ },
2487
+ templates: {
2488
+ verification: emailConfig.templates?.verification || null,
2489
+ passwordReset: emailConfig.templates?.passwordReset || null,
2490
+ magicLink: emailConfig.templates?.magicLink || null,
2491
+ emailOtp: emailConfig.templates?.emailOtp || null,
2492
+ emailChange: emailConfig.templates?.emailChange || null,
2493
+ },
2494
+ });
2495
+ } catch {
2496
+ return c.json({
2497
+ appName: 'EdgeBase',
2498
+ configured: false,
2499
+ subjects: { verification: null, passwordReset: null, magicLink: null, emailOtp: null, emailChange: null },
2500
+ templates: { verification: null, passwordReset: null, magicLink: null, emailOtp: null, emailChange: null },
2501
+ });
2502
+ }
2503
+ });
2504
+
2505
+ // ─── User Create / Delete / MFA (Admin JWT proxied from admin-auth) ───
2506
+
2507
+ // POST /admin/api/data/users — create a new user
2508
+ const adminCreateUser = createRoute({
2509
+ operationId: 'adminCreateUser',
2510
+ method: 'post',
2511
+ path: '/users',
2512
+ tags: ['admin'],
2513
+ summary: 'Create a new user',
2514
+ request: {
2515
+ body: {
2516
+ content: {
2517
+ 'application/json': {
2518
+ schema: z.object({
2519
+ email: z.string(),
2520
+ password: z.string(),
2521
+ displayName: z.string().optional(),
2522
+ role: z.string().optional(),
2523
+ }).passthrough(),
2524
+ },
2525
+ },
2526
+ required: true,
2527
+ },
2528
+ },
2529
+ responses: {
2530
+ 201: { description: 'User created', content: { 'application/json': { schema: jsonResponseSchema } } },
2531
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2532
+ 409: { description: 'Conflict', content: { 'application/json': { schema: errorResponseSchema } } },
2533
+ },
2534
+ });
2535
+
2536
+ api.openapi(adminCreateUser, async (c) => {
2537
+ const body = await c.req.json<{ email: string; password: string; displayName?: string; role?: string }>();
2538
+ if (!body.email || !body.password) throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'validation-failed');
2539
+ body.email = body.email.trim().toLowerCase();
2540
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
2541
+ if (body.password.length < 8) throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
2542
+ if (body.password.length > 256) throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
2543
+ body.role = normalizeOptionalRole(body.role);
2544
+ if (body.displayName && body.displayName.length > 200) throw new EdgeBaseError(400, 'Display name must not exceed 200 characters.', undefined, 'display-name-too-long');
2545
+
2546
+ await ensureAuthSchema(getAuthDb(c));
2547
+ const user = await createManagedAdminUser(
2548
+ getAuthDb(c),
2549
+ {
2550
+ userId: generateId(),
2551
+ email: body.email,
2552
+ passwordHash: await hashPassword(body.password),
2553
+ displayName: body.displayName,
2554
+ role: body.role || 'user',
2555
+ verified: true,
2556
+ },
2557
+ {
2558
+ executionCtx: c.executionCtx,
2559
+ kv: c.env.KV,
2560
+ },
2561
+ );
2562
+
2563
+ return c.json({ user: authService.sanitizeUser(user, { includeAppMetadata: true }) }, 201);
2564
+ });
2565
+
2566
+ // DELETE /admin/api/data/users/:id — delete a user completely
2567
+ const adminDeleteUser = createRoute({
2568
+ operationId: 'adminDeleteUser',
2569
+ method: 'delete',
2570
+ path: '/users/{id}',
2571
+ tags: ['admin'],
2572
+ summary: 'Delete a user completely',
2573
+ request: {
2574
+ params: z.object({ id: z.string() }),
2575
+ },
2576
+ responses: {
2577
+ 200: { description: 'User deleted', content: { 'application/json': { schema: jsonResponseSchema } } },
2578
+ 404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
2579
+ },
2580
+ });
2581
+
2582
+ api.openapi(adminDeleteUser, async (c) => {
2583
+ const userId = c.req.param('id')!;
2584
+ await ensureAuthSchema(getAuthDb(c));
2585
+ const deleted = await deleteManagedAdminUser(getAuthDb(c), userId, {
2586
+ executionCtx: c.executionCtx,
2587
+ kv: c.env.KV,
2588
+ });
2589
+ if (!deleted) {
2590
+ return c.json({ code: 404, message: 'User not found.' }, 404);
2591
+ }
2592
+
2593
+ return c.json({ ok: true });
2594
+ });
2595
+
2596
+ // DELETE /admin/api/data/users/:id/mfa — disable MFA for a user
2597
+ const adminDeleteUserMfa = createRoute({
2598
+ operationId: 'adminDeleteUserMfa',
2599
+ method: 'delete',
2600
+ path: '/users/{id}/mfa',
2601
+ tags: ['admin'],
2602
+ summary: 'Disable MFA for a user',
2603
+ request: {
2604
+ params: z.object({ id: z.string() }),
2605
+ },
2606
+ responses: {
2607
+ 200: { description: 'MFA disabled', content: { 'application/json': { schema: jsonResponseSchema } } },
2608
+ },
2609
+ });
2610
+
2611
+ api.openapi(adminDeleteUserMfa, async (c) => {
2612
+ const userId = c.req.param('id')!;
2613
+ await authService.disableMfa(getAuthDb(c), userId);
2614
+ return c.json({ ok: true, message: 'MFA disabled.' });
2615
+ });
2616
+
2617
+ // POST /admin/api/data/users/:id/send-password-reset — send password reset email for a user
2618
+ const adminSendPasswordReset = createRoute({
2619
+ operationId: 'adminSendPasswordReset',
2620
+ method: 'post',
2621
+ path: '/users/{id}/send-password-reset',
2622
+ tags: ['admin'],
2623
+ summary: 'Send password reset email for a user',
2624
+ request: {
2625
+ params: z.object({ id: z.string() }),
2626
+ },
2627
+ responses: {
2628
+ 200: { description: 'Reset email sent', content: { 'application/json': { schema: jsonResponseSchema } } },
2629
+ 404: { description: 'User not found', content: { 'application/json': { schema: errorResponseSchema } } },
2630
+ },
2631
+ });
2632
+
2633
+ api.openapi(adminSendPasswordReset, async (c) => {
2634
+ const userId = c.req.param('id')!;
2635
+ const user = await authService.getUserById(getAuthDb(c), userId);
2636
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2637
+ if (!user.email) throw new EdgeBaseError(400, 'User has no email address.', undefined, 'validation-failed');
2638
+
2639
+ // Create email token in D1
2640
+ const token = generateId();
2641
+ const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); // 1 hour
2642
+ await authService.createEmailToken(getAuthDb(c), {
2643
+ token,
2644
+ userId,
2645
+ type: 'password_reset',
2646
+ expiresAt,
2647
+ });
2648
+
2649
+ return c.json({ ok: true, token, message: 'Password reset token created.' });
2650
+ });
2651
+
2652
+ // ─── Storage Upload (Admin JWT) ───
2653
+
2654
+ // POST /admin/api/data/storage/buckets/:name/upload — upload file to R2
2655
+ const adminUploadFile = createRoute({
2656
+ operationId: 'adminUploadFile',
2657
+ method: 'post',
2658
+ path: '/storage/buckets/{name}/upload',
2659
+ tags: ['admin'],
2660
+ summary: 'Upload file to R2 storage',
2661
+ request: {
2662
+ params: z.object({ name: z.string() }),
2663
+ body: {
2664
+ content: { 'multipart/form-data': { schema: z.object({}).passthrough() } },
2665
+ required: true,
2666
+ },
2667
+ },
2668
+ responses: {
2669
+ 201: { description: 'File uploaded', content: { 'application/json': { schema: jsonResponseSchema } } },
2670
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2671
+ 404: { description: 'Bucket not found', content: { 'application/json': { schema: errorResponseSchema } } },
2672
+ },
2673
+ });
2674
+
2675
+ api.openapi(adminUploadFile, async (c) => {
2676
+ const bucketName = c.req.param('name')!;
2677
+
2678
+ // Validate bucket exists
2679
+ const config = parseConfig(c.env);
2680
+ const buckets = Object.keys(config?.storage?.buckets || {});
2681
+ if (!buckets.includes(bucketName)) {
2682
+ throw new EdgeBaseError(404, `Bucket "${bucketName}" not found.`, undefined, 'not-found');
2683
+ }
2684
+
2685
+ let formData: FormData;
2686
+ try {
2687
+ formData = await c.req.formData();
2688
+ } catch {
2689
+ throw new EdgeBaseError(400, 'Expected multipart/form-data request body.', undefined, 'validation-failed');
2690
+ }
2691
+ const file = formData.get('file') as File | null;
2692
+ if (!file) throw new EdgeBaseError(400, 'No file provided. Use "file" form field.', undefined, 'validation-failed');
2693
+
2694
+ const key = (formData.get('key') as string) || file.name;
2695
+ if (!key) throw new EdgeBaseError(400, 'File key is required.', undefined, 'validation-failed');
2696
+
2697
+ const fullKey = `${bucketName}/${key}`;
2698
+ const result = await c.env.STORAGE.put(fullKey, file.stream(), {
2699
+ httpMetadata: { contentType: file.type || 'application/octet-stream' },
2700
+ customMetadata: { uploadedBy: 'admin', originalName: file.name },
2701
+ });
2702
+
2703
+ if (!result) throw new EdgeBaseError(500, 'Failed to upload file to R2.', undefined, 'internal-error');
2704
+
2705
+ return c.json({
2706
+ ok: true,
2707
+ key,
2708
+ size: file.size,
2709
+ contentType: file.type || 'application/octet-stream',
2710
+ }, 201);
2711
+ });
2712
+
2713
+ // ─── Push Management (Admin JWT) ───
2714
+
2715
+ // GET /admin/api/data/push/tokens — list push tokens for a user
2716
+ const adminGetPushTokens = createRoute({
2717
+ operationId: 'adminGetPushTokens',
2718
+ method: 'get',
2719
+ path: '/push/tokens',
2720
+ tags: ['admin'],
2721
+ summary: 'List push tokens for a user',
2722
+ responses: {
2723
+ 200: { description: 'Push tokens', content: { 'application/json': { schema: jsonResponseSchema } } },
2724
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2725
+ },
2726
+ });
2727
+
2728
+ api.openapi(adminGetPushTokens, async (c) => {
2729
+ await ensureAuthSchema(getAuthDb(c));
2730
+ const userId = c.req.query('userId');
2731
+ if (!userId) throw new EdgeBaseError(400, 'userId query parameter is required.', undefined, 'validation-failed');
2732
+ const devices = await getDevicesForUser({ kv: c.env.KV, authDb: getAuthDb(c) }, userId);
2733
+ return c.json({ items: devices });
2734
+ });
2735
+
2736
+ // GET /admin/api/data/push/logs — get push notification logs
2737
+ const adminGetPushLogs = createRoute({
2738
+ operationId: 'adminGetPushLogs',
2739
+ method: 'get',
2740
+ path: '/push/logs',
2741
+ tags: ['admin'],
2742
+ summary: 'Get push notification logs',
2743
+ responses: {
2744
+ 200: { description: 'Push logs', content: { 'application/json': { schema: jsonResponseSchema } } },
2745
+ },
2746
+ });
2747
+
2748
+ api.openapi(adminGetPushLogs, async (c) => {
2749
+ const userId = c.req.query('userId');
2750
+ const limit = parseInt(c.req.query('limit') || '50', 10);
2751
+
2752
+ if (userId) {
2753
+ const logs = await getPushLogs(c.env.KV, userId, limit);
2754
+ return c.json({ items: logs });
2755
+ }
2756
+
2757
+ // List all recent push logs across all users
2758
+ const result = await c.env.KV.list({ prefix: 'push:log:', limit: Math.min(limit, 200) });
2759
+ const items: Array<Record<string, unknown>> = [];
2760
+ for (const key of result.keys) {
2761
+ const raw = await c.env.KV.get(key.name, 'json');
2762
+ if (raw) items.push(raw as Record<string, unknown>);
2763
+ }
2764
+ return c.json({ items });
2765
+ });
2766
+
2767
+ // POST /admin/api/data/push/test-send — test send push notification
2768
+ const adminTestPushSend = createRoute({
2769
+ operationId: 'adminTestPushSend',
2770
+ method: 'post',
2771
+ path: '/push/test-send',
2772
+ tags: ['admin'],
2773
+ summary: 'Test send push notification',
2774
+ request: {
2775
+ body: {
2776
+ content: {
2777
+ 'application/json': {
2778
+ schema: z.object({
2779
+ userId: z.string(),
2780
+ title: z.string(),
2781
+ body: z.string(),
2782
+ data: z.record(z.string(), z.string()).optional(),
2783
+ }).passthrough(),
2784
+ },
2785
+ },
2786
+ required: true,
2787
+ },
2788
+ },
2789
+ responses: {
2790
+ 200: { description: 'Push sent', content: { 'application/json': { schema: jsonResponseSchema } } },
2791
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2792
+ 404: { description: 'No tokens', content: { 'application/json': { schema: errorResponseSchema } } },
2793
+ },
2794
+ });
2795
+
2796
+ api.openapi(adminTestPushSend, async (c) => {
2797
+ await ensureAuthSchema(getAuthDb(c));
2798
+ const body = await c.req.json<{ userId: string; title: string; body: string; data?: Record<string, string> }>();
2799
+ if (!body.userId || !body.title) throw new EdgeBaseError(400, 'userId and title are required.', undefined, 'validation-failed');
2800
+
2801
+ const devices = await getDevicesForUser({ kv: c.env.KV, authDb: getAuthDb(c) }, body.userId);
2802
+ if (devices.length === 0) throw new EdgeBaseError(404, 'No push tokens registered for this user.', undefined, 'not-found');
2803
+
2804
+ // Forward to internal push send logic via the push route
2805
+ const config = parseConfig(c.env);
2806
+ if (!config.push?.fcm) throw new EdgeBaseError(400, 'Push notifications not configured. Set push.fcm in config.', undefined, 'feature-not-enabled');
2807
+
2808
+ // Use dynamic import to avoid circular dependency
2809
+ const { createPushProvider } = await import('../lib/push-provider.js');
2810
+ const { storePushLog } = await import('../lib/push-token.js');
2811
+ const provider = createPushProvider(config.push, c.env);
2812
+ if (!provider) throw new EdgeBaseError(400, 'Push provider could not be initialized. Check push.fcm config and PUSH_FCM_SERVICE_ACCOUNT env.', undefined, 'internal-error');
2813
+
2814
+ let sent = 0;
2815
+ let failed = 0;
2816
+
2817
+ for (const device of devices) {
2818
+ try {
2819
+ await provider.send({
2820
+ token: device.token,
2821
+ platform: device.platform,
2822
+ payload: {
2823
+ title: body.title,
2824
+ body: body.body,
2825
+ data: body.data,
2826
+ },
2827
+ });
2828
+ sent++;
2829
+ } catch {
2830
+ failed++;
2831
+ }
2832
+ }
2833
+
2834
+ await storePushLog(c.env.KV, body.userId, {
2835
+ sentAt: new Date().toISOString(),
2836
+ userId: body.userId,
2837
+ platform: 'admin-test',
2838
+ status: failed === 0 ? 'sent' : 'failed',
2839
+ });
2840
+
2841
+ return c.json({ ok: true, sent, failed, total: devices.length });
2842
+ });
2843
+
2844
+ // ─── Backup Proxy (Admin JWT) ───
2845
+
2846
+ // POST /admin/api/data/backup/list-dos
2847
+ const adminBackupListDOs = createRoute({
2848
+ operationId: 'adminBackupListDOs',
2849
+ method: 'post',
2850
+ path: '/backup/list-dos',
2851
+ tags: ['admin'],
2852
+ summary: 'List Durable Objects for backup',
2853
+ responses: {
2854
+ 200: { description: 'DO list', content: { 'application/json': { schema: jsonResponseSchema } } },
2855
+ },
2856
+ });
2857
+
2858
+ api.openapi(adminBackupListDOs, async (c) => {
2859
+ const config = parseConfig(c.env);
2860
+ const dos: Array<{ doName: string; type: string; namespace: string }> = [];
2861
+
2862
+ // Database DOs
2863
+ for (const [namespace, _dbBlock] of Object.entries(config.databases ?? {})) {
2864
+ const doName = getDbDoName(namespace);
2865
+ dos.push({ doName, type: 'database', namespace });
2866
+ }
2867
+
2868
+ return c.json({ dos, total: dos.length });
2869
+ });
2870
+
2871
+ // POST /admin/api/data/backup/dump-do
2872
+ const adminBackupDumpDO = createRoute({
2873
+ operationId: 'adminBackupDumpDO',
2874
+ method: 'post',
2875
+ path: '/backup/dump-do',
2876
+ tags: ['admin'],
2877
+ summary: 'Dump a Durable Object for backup',
2878
+ request: {
2879
+ body: {
2880
+ content: {
2881
+ 'application/json': {
2882
+ schema: z.object({
2883
+ doName: z.string(),
2884
+ type: z.string(),
2885
+ }).passthrough(),
2886
+ },
2887
+ },
2888
+ required: true,
2889
+ },
2890
+ },
2891
+ responses: {
2892
+ 200: { description: 'DO dump', content: { 'application/json': { schema: jsonResponseSchema } } },
2893
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2894
+ },
2895
+ });
2896
+
2897
+ api.openapi(adminBackupDumpDO, async (c) => {
2898
+ const body = await c.req.json<{ doName: string; type: string }>();
2899
+ if (!body.doName || !body.type) throw new EdgeBaseError(400, 'doName and type are required.', undefined, 'validation-failed');
2900
+
2901
+ const binding = body.type === 'auth' ? c.env.AUTH : c.env.DATABASE;
2902
+ const stub = binding.get(binding.idFromName(body.doName));
2903
+
2904
+ const resp = await stub.fetch(new Request('http://internal/internal/backup/dump', {
2905
+ method: 'GET',
2906
+ headers: { 'X-DO-Name': body.doName },
2907
+ }));
2908
+ if (!resp.ok) throw new EdgeBaseError(resp.status, `Failed to dump DO: ${body.doName}`, undefined, 'internal-error');
2909
+
2910
+ const data = await resp.json();
2911
+ return c.json({ ...data as Record<string, unknown>, doName: body.doName, type: body.type });
2912
+ });
2913
+
2914
+ // POST /admin/api/data/backup/restore-do
2915
+ const adminBackupRestoreDO = createRoute({
2916
+ operationId: 'adminBackupRestoreDO',
2917
+ method: 'post',
2918
+ path: '/backup/restore-do',
2919
+ tags: ['admin'],
2920
+ summary: 'Restore a Durable Object from backup',
2921
+ request: {
2922
+ body: {
2923
+ content: {
2924
+ 'application/json': {
2925
+ schema: z.object({
2926
+ doName: z.string(),
2927
+ type: z.string(),
2928
+ tables: z.record(z.string(), z.unknown()),
2929
+ }).passthrough(),
2930
+ },
2931
+ },
2932
+ required: true,
2933
+ },
2934
+ },
2935
+ responses: {
2936
+ 200: { description: 'DO restored', content: { 'application/json': { schema: jsonResponseSchema } } },
2937
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2938
+ },
2939
+ });
2940
+
2941
+ api.openapi(adminBackupRestoreDO, async (c) => {
2942
+ const body = await c.req.json<{ doName: string; type: string; tables: Record<string, unknown> }>();
2943
+ if (!body.doName || !body.type || !body.tables) throw new EdgeBaseError(400, 'doName, type and tables are required.', undefined, 'validation-failed');
2944
+
2945
+ const binding = body.type === 'auth' ? c.env.AUTH : c.env.DATABASE;
2946
+ const stub = binding.get(binding.idFromName(body.doName));
2947
+
2948
+ const resp = await stub.fetch(new Request('http://internal/internal/backup/restore', {
2949
+ method: 'POST',
2950
+ headers: { 'Content-Type': 'application/json', 'X-DO-Name': body.doName },
2951
+ body: JSON.stringify({ tables: body.tables }),
2952
+ }));
2953
+ if (!resp.ok) throw new EdgeBaseError(resp.status, `Failed to restore DO: ${body.doName}`, undefined, 'internal-error');
2954
+
2955
+ const data = await resp.json();
2956
+ return c.json(data);
2957
+ });
2958
+
2959
+ // POST /admin/api/data/backup/dump-d1
2960
+ const adminBackupDumpD1 = createRoute({
2961
+ operationId: 'adminBackupDumpD1',
2962
+ method: 'post',
2963
+ path: '/backup/dump-d1',
2964
+ tags: ['admin'],
2965
+ summary: 'Dump D1 database for backup',
2966
+ responses: {
2967
+ 200: { description: 'D1 dump', content: { 'application/json': { schema: jsonResponseSchema } } },
2968
+ },
2969
+ });
2970
+
2971
+ api.openapi(adminBackupDumpD1, async (c) => {
2972
+ await ensureAuthSchema(getAuthDb(c));
2973
+ const tables: Record<string, unknown[]> = {};
2974
+
2975
+ for (const tbl of AUTH_BACKUP_TABLES) {
2976
+ try {
2977
+ tables[tbl] = await getAuthDb(c).query(`SELECT * FROM ${quoteSqlIdentifier(tbl)}`);
2978
+ } catch {
2979
+ tables[tbl] = [];
2980
+ }
2981
+ }
2982
+
2983
+ return c.json({ type: 'd1', tables, timestamp: new Date().toISOString() });
2984
+ });
2985
+
2986
+ // POST /admin/api/data/backup/restore-d1
2987
+ const adminBackupRestoreD1 = createRoute({
2988
+ operationId: 'adminBackupRestoreD1',
2989
+ method: 'post',
2990
+ path: '/backup/restore-d1',
2991
+ tags: ['admin'],
2992
+ summary: 'Restore D1 database from backup',
2993
+ request: {
2994
+ body: {
2995
+ content: {
2996
+ 'application/json': {
2997
+ schema: z.object({
2998
+ tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))),
2999
+ }).passthrough(),
3000
+ },
3001
+ },
3002
+ required: true,
3003
+ },
3004
+ },
3005
+ responses: {
3006
+ 200: { description: 'D1 restored', content: { 'application/json': { schema: jsonResponseSchema } } },
3007
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3008
+ },
3009
+ });
3010
+
3011
+ api.openapi(adminBackupRestoreD1, async (c) => {
3012
+ const body = await c.req.json<{ tables: Record<string, unknown[]> }>();
3013
+ if (!body.tables) throw new EdgeBaseError(400, 'tables object is required.', undefined, 'validation-failed');
3014
+
3015
+ await ensureAuthSchema(getAuthDb(c));
3016
+
3017
+ const statements: Array<{ sql: string; params?: unknown[] }> = [];
3018
+ let restored = 0;
3019
+ for (const [tableName, rows] of Object.entries(body.tables)) {
3020
+ if (!AUTH_BACKUP_TABLE_SET.has(tableName)) {
3021
+ throw new EdgeBaseError(400, `Unsupported backup table: ${tableName}`, undefined, 'validation-failed');
3022
+ }
3023
+ if (!Array.isArray(rows)) {
3024
+ throw new EdgeBaseError(400, `Backup rows for ${tableName} must be an array.`, undefined, 'validation-failed');
3025
+ }
3026
+
3027
+ statements.push({ sql: `DELETE FROM ${quoteSqlIdentifier(tableName)}` });
3028
+
3029
+ for (const row of rows) {
3030
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
3031
+ throw new EdgeBaseError(400, `Backup rows for ${tableName} must be objects.`, undefined, 'validation-failed');
3032
+ }
3033
+ const cols = Object.keys(row as Record<string, unknown>);
3034
+ if (cols.length === 0) continue;
3035
+ const vals = Object.values(row as Record<string, unknown>);
3036
+ const placeholders = cols.map(() => '?').join(',');
3037
+ const quotedCols = cols.map(quoteSqlIdentifier).join(', ');
3038
+ statements.push({
3039
+ sql: `INSERT INTO ${quoteSqlIdentifier(tableName)} (${quotedCols}) VALUES (${placeholders})`,
3040
+ params: vals,
3041
+ });
3042
+ restored++;
3043
+ }
3044
+ }
3045
+
3046
+ await getAuthDb(c).batch(statements);
3047
+
3048
+ return c.json({ ok: true, restored });
3049
+ });
3050
+
3051
+ // POST /admin/api/data/backup/dump-data
3052
+ const adminBackupDumpData = createRoute({
3053
+ operationId: 'adminBackupDumpData',
3054
+ method: 'post',
3055
+ path: '/backup/dump-data',
3056
+ tags: ['admin'],
3057
+ summary: 'Dump data namespace tables for admin-side migrations',
3058
+ request: {
3059
+ body: {
3060
+ content: {
3061
+ 'application/json': {
3062
+ schema: z.object({
3063
+ namespace: z.string(),
3064
+ }).passthrough(),
3065
+ },
3066
+ },
3067
+ required: true,
3068
+ },
3069
+ },
3070
+ responses: {
3071
+ 200: { description: 'Namespace dump', content: { 'application/json': { schema: jsonResponseSchema } } },
3072
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3073
+ 404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
3074
+ },
3075
+ });
3076
+
3077
+ api.openapi(adminBackupDumpData, async (c) => {
3078
+ const { namespace } = await c.req.json<{ namespace: string }>();
3079
+ if (!namespace) throw new EdgeBaseError(400, 'namespace is required.', undefined, 'validation-failed');
3080
+
3081
+ const config = parseConfig(c.env);
3082
+ const dbBlock = config.databases?.[namespace];
3083
+ if (!dbBlock) throw new EdgeBaseError(404, `Namespace '${namespace}' not found in config.`, undefined, 'not-found');
3084
+
3085
+ const tableNames = Object.keys(dbBlock.tables ?? {});
3086
+ const tables = await dumpNamespaceTables(c.env, config, namespace, {
3087
+ includeMeta: true,
3088
+ tableNames,
3089
+ });
3090
+
3091
+ return c.json({
3092
+ type: 'data',
3093
+ namespace,
3094
+ tables,
3095
+ tableOrder: tableNames,
3096
+ timestamp: new Date().toISOString(),
3097
+ });
3098
+ });
3099
+
3100
+ // POST /admin/api/data/backup/restore-data
3101
+ const adminBackupRestoreData = createRoute({
3102
+ operationId: 'adminBackupRestoreData',
3103
+ method: 'post',
3104
+ path: '/backup/restore-data',
3105
+ tags: ['admin'],
3106
+ summary: 'Restore data namespace tables for admin-side migrations',
3107
+ request: {
3108
+ body: {
3109
+ content: {
3110
+ 'application/json': {
3111
+ schema: z.object({
3112
+ namespace: z.string(),
3113
+ tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))),
3114
+ skipWipe: z.boolean().optional(),
3115
+ }).passthrough(),
3116
+ },
3117
+ },
3118
+ required: true,
3119
+ },
3120
+ },
3121
+ responses: {
3122
+ 200: { description: 'Namespace restored', content: { 'application/json': { schema: jsonResponseSchema } } },
3123
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3124
+ 404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
3125
+ },
3126
+ });
3127
+
3128
+ api.openapi(adminBackupRestoreData, async (c) => {
3129
+ const body = await c.req.json<{
3130
+ namespace: string;
3131
+ tables: Record<string, Array<Record<string, unknown>>>;
3132
+ skipWipe?: boolean;
3133
+ }>();
3134
+ if (!body.namespace) throw new EdgeBaseError(400, 'namespace is required.', undefined, 'validation-failed');
3135
+ if (!body.tables) throw new EdgeBaseError(400, 'tables data is required.', undefined, 'validation-failed');
3136
+
3137
+ const config = parseConfig(c.env);
3138
+ await restoreAdminNamespaceTables(c.env, config, body);
3139
+
3140
+ return c.json({
3141
+ ok: true,
3142
+ namespace: body.namespace,
3143
+ restored: Object.keys(body.tables).length,
3144
+ });
3145
+ });
3146
+
3147
+ // GET /admin/api/data/backup/config
3148
+ const adminBackupGetConfig = createRoute({
3149
+ operationId: 'adminBackupGetConfig',
3150
+ method: 'get',
3151
+ path: '/backup/config',
3152
+ tags: ['admin'],
3153
+ summary: 'Get backup config',
3154
+ responses: {
3155
+ 200: { description: 'Backup config', content: { 'application/json': { schema: jsonResponseSchema } } },
3156
+ },
3157
+ });
3158
+
3159
+ api.openapi(adminBackupGetConfig, (c) => {
3160
+ try {
3161
+ const config = parseConfig(c.env);
3162
+ return c.json(config);
3163
+ } catch {
3164
+ return c.json({});
3165
+ }
3166
+ });
3167
+
3168
+ // ─── Admin Account Management ───
3169
+
3170
+ // GET /admin/api/data/admins — list all admin accounts
3171
+ const adminListAdmins = createRoute({
3172
+ operationId: 'adminListAdmins',
3173
+ method: 'get',
3174
+ path: '/admins',
3175
+ tags: ['admin'],
3176
+ summary: 'List admin accounts',
3177
+ responses: {
3178
+ 200: { description: 'Admin list', content: { 'application/json': { schema: jsonResponseSchema } } },
3179
+ },
3180
+ });
3181
+
3182
+ api.openapi(adminListAdmins, async (c) => {
3183
+ await ensureAuthSchema(getAuthDb(c));
3184
+ const admins = await listAdmins(getAuthDb(c));
3185
+ return c.json({ admins });
3186
+ });
3187
+
3188
+ // POST /admin/api/data/admins — create a new admin account
3189
+ const adminCreateAdmin = createRoute({
3190
+ operationId: 'adminCreateAdmin',
3191
+ method: 'post',
3192
+ path: '/admins',
3193
+ tags: ['admin'],
3194
+ summary: 'Create an admin account',
3195
+ request: {
3196
+ body: {
3197
+ content: { 'application/json': { schema: z.object({ email: z.string().email(), password: z.string().min(8) }) } },
3198
+ required: true,
3199
+ },
3200
+ },
3201
+ responses: {
3202
+ 200: { description: 'Admin created', content: { 'application/json': { schema: jsonResponseSchema } } },
3203
+ },
3204
+ });
3205
+
3206
+ api.openapi(adminCreateAdmin, async (c) => {
3207
+ await ensureAuthSchema(getAuthDb(c));
3208
+ const body = await c.req.json<{ email: string; password: string }>();
3209
+
3210
+ const existing = await getAdminByEmail(getAuthDb(c), body.email);
3211
+ if (existing) {
3212
+ return c.json({ code: 409, message: 'An admin with this email already exists' }, 409);
3213
+ }
3214
+
3215
+ const id = generateId();
3216
+ const hash = await hashPassword(body.password);
3217
+ await createAdmin(getAuthDb(c), id, body.email, hash);
3218
+ return c.json({ id, email: body.email });
3219
+ });
3220
+
3221
+ // DELETE /admin/api/data/admins/:id — delete an admin account
3222
+ const adminDeleteAdmin = createRoute({
3223
+ operationId: 'adminDeleteAdmin',
3224
+ method: 'delete',
3225
+ path: '/admins/{id}',
3226
+ tags: ['admin'],
3227
+ summary: 'Delete an admin account',
3228
+ request: { params: z.object({ id: z.string() }) },
3229
+ responses: {
3230
+ 200: { description: 'Admin deleted', content: { 'application/json': { schema: jsonResponseSchema } } },
3231
+ },
3232
+ });
3233
+
3234
+ api.openapi(adminDeleteAdmin, async (c) => {
3235
+ await ensureAuthSchema(getAuthDb(c));
3236
+ const id = c.req.param('id')!;
3237
+ const currentAdminId = (c as unknown as { get(key: string): string }).get('adminId');
3238
+
3239
+ if (id === currentAdminId) {
3240
+ return c.json({ code: 403, message: 'Cannot delete your own admin account' }, 403);
3241
+ }
3242
+
3243
+ const admins = await listAdmins(getAuthDb(c));
3244
+ if (admins.length <= 1) {
3245
+ return c.json({ code: 403, message: 'Cannot delete the last admin account' }, 403);
3246
+ }
3247
+
3248
+ await deleteAdmin(getAuthDb(c), id);
3249
+ return c.json({ success: true });
3250
+ });
3251
+
3252
+ // PUT /admin/api/data/admins/:id/password — change admin password
3253
+ const adminChangePassword = createRoute({
3254
+ operationId: 'adminChangePassword',
3255
+ method: 'put',
3256
+ path: '/admins/{id}/password',
3257
+ tags: ['admin'],
3258
+ summary: 'Change admin password',
3259
+ request: {
3260
+ params: z.object({ id: z.string() }),
3261
+ body: {
3262
+ content: { 'application/json': { schema: z.object({ password: z.string().min(8) }) } },
3263
+ required: true,
3264
+ },
3265
+ },
3266
+ responses: {
3267
+ 200: { description: 'Password updated', content: { 'application/json': { schema: jsonResponseSchema } } },
3268
+ },
3269
+ });
3270
+
3271
+ api.openapi(adminChangePassword, async (c) => {
3272
+ await ensureAuthSchema(getAuthDb(c));
3273
+ const id = c.req.param('id')!;
3274
+ const body = await c.req.json<{ password: string }>();
3275
+ const hash = await hashPassword(body.password);
3276
+ await updateAdminPassword(getAuthDb(c), id, hash);
3277
+ return c.json({ success: true });
3278
+ });
3279
+
3280
+ // ─── Destroy App (Self-Destruct) ───
3281
+
3282
+ const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
3283
+
3284
+ interface DeployManifest {
3285
+ version: number;
3286
+ accountId: string;
3287
+ worker: { name: string; url: string };
3288
+ resources: Array<{
3289
+ type: string;
3290
+ name: string;
3291
+ binding?: string;
3292
+ id?: string;
3293
+ managed?: boolean;
3294
+ }>;
3295
+ }
3296
+
3297
+ async function cfApi(
3298
+ accountId: string,
3299
+ apiToken: string,
3300
+ method: string,
3301
+ path: string,
3302
+ ): Promise<{ ok: boolean; status: number; error?: string }> {
3303
+ try {
3304
+ const res = await fetch(`${CF_API_BASE}/accounts/${accountId}${path}`, {
3305
+ method,
3306
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
3307
+ });
3308
+ // 2xx = success, 404 = not found (already gone), 410 = gone (Vectorize soft-delete)
3309
+ if (res.ok || res.status === 404 || res.status === 410) return { ok: true, status: res.status };
3310
+ const body = await res.json().catch(() => ({})) as { errors?: Array<{ message?: string }> };
3311
+ const msg = (body.errors ?? []).map((e: { message?: string }) => e.message).filter(Boolean).join(', ');
3312
+ return { ok: false, status: res.status, error: msg || `HTTP ${res.status}` };
3313
+ } catch (err) {
3314
+ return { ok: false, status: 0, error: err instanceof Error ? err.message : 'Network error' };
3315
+ }
3316
+ }
3317
+
3318
+ const adminDestroyApp = createRoute({
3319
+ operationId: 'adminDestroyApp',
3320
+ method: 'post',
3321
+ path: '/destroy-app',
3322
+ tags: ['admin'],
3323
+ summary: 'Delete all Cloudflare resources and the Worker itself (self-destruct)',
3324
+ request: {
3325
+ body: {
3326
+ content: {
3327
+ 'application/json': {
3328
+ schema: z.object({
3329
+ confirm: z.literal('DELETE_ALL_RESOURCES'),
3330
+ }),
3331
+ },
3332
+ },
3333
+ required: true,
3334
+ },
3335
+ },
3336
+ responses: {
3337
+ 200: {
3338
+ description: 'Destruction result',
3339
+ content: {
3340
+ 'application/json': {
3341
+ schema: z.object({
3342
+ success: z.boolean(),
3343
+ deleted: z.array(z.string()),
3344
+ failed: z.array(z.object({ resource: z.string(), error: z.string() })),
3345
+ message: z.string(),
3346
+ }),
3347
+ },
3348
+ },
3349
+ },
3350
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3351
+ 503: { description: 'Not available', content: { 'application/json': { schema: errorResponseSchema } } },
3352
+ },
3353
+ });
3354
+
3355
+ api.openapi(adminDestroyApp, async (c) => {
3356
+ const env = c.env as Env;
3357
+ const body = await c.req.json<{ confirm: string }>();
3358
+
3359
+ if (body.confirm !== 'DELETE_ALL_RESOURCES') {
3360
+ throw new EdgeBaseError(400, 'Confirmation string must be "DELETE_ALL_RESOURCES"', undefined, 'bad_request');
3361
+ }
3362
+
3363
+ const apiToken = env.CF_API_TOKEN;
3364
+ const accountId = env.CF_ACCOUNT_ID;
3365
+
3366
+ if (!apiToken || !accountId) {
3367
+ throw new EdgeBaseError(
3368
+ 503,
3369
+ 'Self-destruct is not available. CF_API_TOKEN and CF_ACCOUNT_ID must be set as Worker secrets during deploy.',
3370
+ undefined,
3371
+ 'unavailable',
3372
+ );
3373
+ }
3374
+
3375
+ // Read manifest from KV
3376
+ const manifestRaw = await env.KV.get('__edgebase_deploy_manifest', 'text');
3377
+ if (!manifestRaw) {
3378
+ throw new EdgeBaseError(
3379
+ 503,
3380
+ 'Deploy manifest not found in KV. Redeploy or use CLI `edgebase destroy` instead.',
3381
+ undefined,
3382
+ 'unavailable',
3383
+ );
3384
+ }
3385
+
3386
+ let manifest: DeployManifest;
3387
+ try {
3388
+ manifest = JSON.parse(manifestRaw);
3389
+ } catch {
3390
+ throw new EdgeBaseError(503, 'Deploy manifest is corrupted.', undefined, 'unavailable');
3391
+ }
3392
+
3393
+ const deleted: string[] = [];
3394
+ const failed: Array<{ resource: string; error: string }> = [];
3395
+ const resources = manifest.resources.filter((r) => r.managed !== false);
3396
+
3397
+ // Delete order: D1 → Vectorize → Hyperdrive → R2 (empty first) → Turnstile → Worker → KV (last)
3398
+ // KV is deleted last so the manifest remains available for retry on partial failure.
3399
+ // Worker is deleted second-to-last (it takes down DOs, secrets, crons automatically).
3400
+
3401
+ for (const r of resources) {
3402
+ if (r.type === 'd1_database' && r.id) {
3403
+ const label = `D1 ${r.name}`;
3404
+ const result = await cfApi(accountId, apiToken, 'DELETE', `/d1/database/${r.id}`);
3405
+ if (result.ok) deleted.push(label);
3406
+ else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
3407
+ }
3408
+ }
3409
+
3410
+ for (const r of resources) {
3411
+ if (r.type === 'vectorize' && (r.id || r.name)) {
3412
+ const indexName = r.id ?? r.name;
3413
+ const label = `Vectorize ${indexName}`;
3414
+ const result = await cfApi(accountId, apiToken, 'DELETE', `/vectorize/v2/indexes/${indexName}`);
3415
+ if (result.ok) deleted.push(label);
3416
+ else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
3417
+ }
3418
+ }
3419
+
3420
+ for (const r of resources) {
3421
+ if (r.type === 'hyperdrive' && r.id) {
3422
+ const label = `Hyperdrive ${r.name}`;
3423
+ const result = await cfApi(accountId, apiToken, 'DELETE', `/hyperdrive/configs/${r.id}`);
3424
+ if (result.ok) deleted.push(label);
3425
+ else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
3426
+ }
3427
+ }
3428
+
3429
+ // R2: empty bucket contents via the Worker's R2 binding, then delete via CF API
3430
+ for (const r of resources) {
3431
+ if (r.type === 'r2_bucket' && r.name) {
3432
+ const label = `R2 ${r.name}`;
3433
+
3434
+ // Use the STORAGE binding to empty the bucket (works regardless of bucket name)
3435
+ try {
3436
+ let truncated = true;
3437
+ let cursor: string | undefined;
3438
+ while (truncated) {
3439
+ const list = await env.STORAGE.list({ limit: 1000, cursor });
3440
+ if (list.objects.length > 0) {
3441
+ await Promise.all(list.objects.map((obj) => env.STORAGE.delete(obj.key)));
3442
+ }
3443
+ truncated = list.truncated;
3444
+ cursor = truncated ? (list as unknown as { cursor: string }).cursor : undefined;
3445
+ }
3446
+ } catch {
3447
+ // Non-fatal: attempt deletion anyway — CF API will reject if not empty
3448
+ }
3449
+
3450
+ const result = await cfApi(accountId, apiToken, 'DELETE', `/r2/buckets/${r.name}`);
3451
+ if (result.ok) deleted.push(label);
3452
+ else failed.push({ resource: label, error: result.error ?? 'Bucket may not be empty' });
3453
+ }
3454
+ }
3455
+
3456
+ for (const r of resources) {
3457
+ if (r.type === 'turnstile_widget' && r.id) {
3458
+ const label = `Turnstile ${r.name}`;
3459
+ // Turnstile uses zone-level API, not account
3460
+ const result = await cfApi(accountId, apiToken, 'DELETE', `/challenges/widgets/${r.id}`);
3461
+ if (result.ok) deleted.push(label);
3462
+ else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
3463
+ }
3464
+ }
3465
+
3466
+ // Worker is deleted before KV (takes down DOs, secrets, crons automatically)
3467
+ if (manifest.worker.name) {
3468
+ const label = `Worker ${manifest.worker.name}`;
3469
+ const result = await cfApi(accountId, apiToken, 'DELETE', `/workers/scripts/${manifest.worker.name}`);
3470
+ if (result.ok) {
3471
+ deleted.push(label);
3472
+ } else {
3473
+ failed.push({ resource: label, error: result.error ?? 'Unknown error' });
3474
+ }
3475
+ }
3476
+
3477
+ // KV deleted last — manifest stays available for retry on partial failure
3478
+ for (const r of resources) {
3479
+ if (r.type === 'kv_namespace' && r.id) {
3480
+ const label = `KV ${r.name}`;
3481
+ const result = await cfApi(accountId, apiToken, 'DELETE', `/storage/kv/namespaces/${r.id}`);
3482
+ if (result.ok) deleted.push(label);
3483
+ else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
3484
+ }
3485
+ }
3486
+
3487
+ const allOk = failed.length === 0;
3488
+ const message = allOk
3489
+ ? `All resources destroyed. ${deleted.length} resources deleted.`
3490
+ : `Partial destruction: ${deleted.length} deleted, ${failed.length} failed.`;
3491
+
3492
+ return c.json({
3493
+ success: allOk,
3494
+ deleted,
3495
+ failed,
3496
+ message,
3497
+ });
3498
+ });
3499
+
3500
+ // Mount JWT-protected sub-app under /data/
3501
+ adminRoute.route('/data', api);