auramaxx 0.0.17 → 0.0.19

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 (296) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +70 -70
  3. package/.next/app-path-routes-manifest.json +2 -2
  4. package/.next/build-manifest.json +5 -5
  5. package/.next/prerender-manifest.json +30 -30
  6. package/.next/react-loadable-manifest.json +16 -16
  7. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  8. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_not-found.html +1 -1
  10. package/.next/server/app/_not-found.rsc +5 -5
  11. package/.next/server/app/api/[...doc]/page.js.nft.json +1 -1
  12. package/.next/server/app/api/[...doc]/page_client-reference-manifest.js +1 -1
  13. package/.next/server/app/api/agent-requests/route_client-reference-manifest.js +1 -1
  14. package/.next/server/app/api/apps/install/route.js.nft.json +1 -1
  15. package/.next/server/app/api/apps/install/route_client-reference-manifest.js +1 -1
  16. package/.next/server/app/api/apps/manifests/route_client-reference-manifest.js +1 -1
  17. package/.next/server/app/api/apps/static/[...path]/route_client-reference-manifest.js +1 -1
  18. package/.next/server/app/api/docs/plain/route.js.nft.json +1 -1
  19. package/.next/server/app/api/docs/plain/route_client-reference-manifest.js +1 -1
  20. package/.next/server/app/api/events/route_client-reference-manifest.js +1 -1
  21. package/.next/server/app/api/import-from-openclaw/[channel]/route.js.nft.json +1 -1
  22. package/.next/server/app/api/import-from-openclaw/[channel]/route_client-reference-manifest.js +1 -1
  23. package/.next/server/app/api/import-from-openclaw/route.js.nft.json +1 -1
  24. package/.next/server/app/api/import-from-openclaw/route_client-reference-manifest.js +1 -1
  25. package/.next/server/app/api/import-from-openclaw/validate/[channel]/route.js.nft.json +1 -1
  26. package/.next/server/app/api/import-from-openclaw/validate/[channel]/route_client-reference-manifest.js +1 -1
  27. package/.next/server/app/api/page.js.nft.json +1 -1
  28. package/.next/server/app/api/page_client-reference-manifest.js +1 -1
  29. package/.next/server/app/api/restart/route_client-reference-manifest.js +1 -1
  30. package/.next/server/app/api/update/route_client-reference-manifest.js +1 -1
  31. package/.next/server/app/api/version/route_client-reference-manifest.js +1 -1
  32. package/.next/server/app/api/workspace/[id]/apps/[wid]/route_client-reference-manifest.js +1 -1
  33. package/.next/server/app/api/workspace/[id]/apps/route_client-reference-manifest.js +1 -1
  34. package/.next/server/app/api/workspace/[id]/export/route_client-reference-manifest.js +1 -1
  35. package/.next/server/app/api/workspace/[id]/route_client-reference-manifest.js +1 -1
  36. package/.next/server/app/api/workspace/config/route_client-reference-manifest.js +1 -1
  37. package/.next/server/app/api/workspace/import/route_client-reference-manifest.js +1 -1
  38. package/.next/server/app/api/workspace/route_client-reference-manifest.js +1 -1
  39. package/.next/server/app/app-legacy-do-not-use/page.js +3 -3
  40. package/.next/server/app/app-legacy-do-not-use/page.js.nft.json +1 -1
  41. package/.next/server/app/app-legacy-do-not-use/page_client-reference-manifest.js +1 -1
  42. package/.next/server/app/app-legacy-do-not-use.html +1 -1
  43. package/.next/server/app/app-legacy-do-not-use.rsc +6 -6
  44. package/.next/server/app/approve/[actionId]/page.js.nft.json +1 -1
  45. package/.next/server/app/approve/[actionId]/page_client-reference-manifest.js +1 -1
  46. package/.next/server/app/docs/[...doc]/page.js.nft.json +1 -1
  47. package/.next/server/app/docs/[...doc]/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/docs/page.js.nft.json +1 -1
  49. package/.next/server/app/docs/page_client-reference-manifest.js +1 -1
  50. package/.next/server/app/health/page.js.nft.json +1 -1
  51. package/.next/server/app/health/page_client-reference-manifest.js +1 -1
  52. package/.next/server/app/health.html +1 -1
  53. package/.next/server/app/health.rsc +5 -5
  54. package/.next/server/app/hello/page.js.nft.json +1 -1
  55. package/.next/server/app/hello/page_client-reference-manifest.js +1 -1
  56. package/.next/server/app/hello.html +1 -1
  57. package/.next/server/app/hello.rsc +5 -5
  58. package/.next/server/app/index.html +1 -1
  59. package/.next/server/app/index.rsc +6 -6
  60. package/.next/server/app/page.js +9 -3
  61. package/.next/server/app/page.js.nft.json +1 -1
  62. package/.next/server/app/page_client-reference-manifest.js +1 -1
  63. package/.next/server/app/privacy/page.js.nft.json +1 -1
  64. package/.next/server/app/privacy/page_client-reference-manifest.js +1 -1
  65. package/.next/server/app/privacy.html +1 -1
  66. package/.next/server/app/privacy.rsc +5 -5
  67. package/.next/server/app/share/[token]/page.js.nft.json +1 -1
  68. package/.next/server/app/share/[token]/page_client-reference-manifest.js +1 -1
  69. package/.next/server/app/terms/page.js.nft.json +1 -1
  70. package/.next/server/app/terms/page_client-reference-manifest.js +1 -1
  71. package/.next/server/app/terms.html +1 -1
  72. package/.next/server/app/terms.rsc +5 -5
  73. package/.next/server/app/yo/page.js +1 -1
  74. package/.next/server/app/yo/page.js.nft.json +1 -1
  75. package/.next/server/app/yo/page_client-reference-manifest.js +1 -1
  76. package/.next/server/app/yo.html +1 -1
  77. package/.next/server/app/yo.rsc +6 -6
  78. package/.next/server/app-paths-manifest.json +2 -2
  79. package/.next/server/chunks/1196.js +1 -1
  80. package/.next/server/chunks/{6415.js → 2123.js} +2 -2
  81. package/.next/server/chunks/2234.js +1 -1
  82. package/.next/server/chunks/4224.js +1 -0
  83. package/.next/server/chunks/4600.js +1 -1
  84. package/.next/server/chunks/5678.js +1 -1
  85. package/.next/server/chunks/5991.js +7 -0
  86. package/.next/server/chunks/6470.js +1 -1
  87. package/.next/server/chunks/7585.js +1 -1
  88. package/.next/server/chunks/7935.js +19 -11
  89. package/.next/server/chunks/8468.js +1 -1
  90. package/.next/server/chunks/{1350.js → 9323.js} +3 -3
  91. package/.next/server/chunks/9971.js +1 -1
  92. package/.next/server/functions-config-manifest.json +1 -1
  93. package/.next/server/middleware-build-manifest.js +1 -1
  94. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  95. package/.next/server/pages/404.html +1 -1
  96. package/.next/server/pages/500.html +1 -1
  97. package/.next/server/server-reference-manifest.json +1 -1
  98. package/.next/static/chunks/{1168.aaac1edbb597fe5a.js → 1168.bb1c93965872e980.js} +1 -1
  99. package/.next/static/chunks/2891-bf010357841c9ce9.js +1 -0
  100. package/.next/static/chunks/{2927.7e00cc878d9a3f52.js → 2927.3976471e61df966e.js} +1 -1
  101. package/.next/static/chunks/{3573-1b41d7b8a000d015.js → 3573-667bc4c8f56afcff.js} +1 -1
  102. package/.next/static/chunks/{3a91511d-ba215c0b5dc21ba9.js → 3a91511d-da2c74cad6c6fc33.js} +2 -2
  103. package/.next/static/chunks/4114-a375178fd94c4d8a.js +1 -0
  104. package/.next/static/chunks/{4256.48407d9abad5ea33.js → 4256.aa831e4d8c3f65bc.js} +1 -1
  105. package/.next/static/chunks/{4919-7e7cdd5efc9f2110.js → 4919-0e698cd95504e355.js} +1 -1
  106. package/.next/static/chunks/{5336-233ec7ab3807267c.js → 5336-b775f449d7433604.js} +1 -1
  107. package/.next/static/chunks/{7338-3ce17a93614f1d77.js → 5362-2e8fcdd0bc6273ca.js} +4 -4
  108. package/.next/static/chunks/67-cb9bbb430499e803.js +1 -0
  109. package/.next/static/chunks/{8273-8e92d34180669ca9.js → 8273-3b9d882c5dd6a9a8.js} +1 -1
  110. package/.next/static/chunks/8291-8f6fb6554e692552.js +1 -0
  111. package/.next/static/chunks/8370-6537b6b27df58b23.js +1 -0
  112. package/.next/static/chunks/app/app-legacy-do-not-use/page-efa348da049c5873.js +1 -0
  113. package/.next/static/chunks/app/{error-63cbe72522239cff.js → error-04a18fd49a89ee17.js} +1 -1
  114. package/.next/static/chunks/app/layout-7093dda0d106f34d.js +1 -0
  115. package/.next/static/chunks/app/page-0ec7cf48550f71e6.js +1 -0
  116. package/.next/static/chunks/app/yo/{page-cf5d6540ea31c31c.js → page-48930746b135037e.js} +1 -1
  117. package/.next/static/chunks/{webpack-79ad58260e9b10b4.js → webpack-15ec0114c9d658c2.js} +1 -1
  118. package/.next/static/css/35991998d1b5eff0.css +3 -0
  119. package/.next/trace +28 -28
  120. package/README.md +2 -2
  121. package/bin/auramaxx.js +87 -3
  122. package/package.json +8 -4
  123. package/prisma/migrations/20260301225214_social_tables/migration.sql +81 -0
  124. package/prisma/migrations/20260302120000_add_notification_hash/migration.sql +8 -0
  125. package/prisma/migrations/20260302160000_add_public_key_hex/migration.sql +5 -0
  126. package/prisma/migrations/20260302170000_add_inbound_author_public_key/migration.sql +5 -0
  127. package/prisma/schema.prisma +272 -177
  128. package/shared/credential-field-schema.ts +10 -0
  129. package/skills/auramaxx/SKILL.md +33 -4
  130. package/skills/auramaxx/docs/AGENT_SETUP.md +7 -2
  131. package/src/app/UnlockPageClient.tsx +448 -43
  132. package/src/app/app-legacy-do-not-use/page.tsx +5 -3
  133. package/src/app/globals.css +1 -1
  134. package/src/components/AgentPicker.tsx +278 -0
  135. package/src/components/CreateAgentModal.tsx +192 -0
  136. package/src/components/NotificationDrawer.tsx +15 -13
  137. package/src/components/agent/AgentSidebar.tsx +2 -19
  138. package/src/components/agent/CredentialAgent.tsx +70 -37
  139. package/src/components/agent/CredentialForm.tsx +32 -7
  140. package/src/components/agent/CredentialList.tsx +149 -72
  141. package/src/components/agents/AgentsView.tsx +1035 -0
  142. package/src/components/design-system/TextAreaInput.tsx +78 -0
  143. package/src/components/design-system/index.ts +1 -0
  144. package/src/components/hub/HubView.tsx +58 -0
  145. package/src/components/layout/LeftRail.tsx +333 -40
  146. package/src/components/layout/ViewShell.tsx +125 -0
  147. package/src/components/layout/index.ts +1 -0
  148. package/src/components/social/PrimaryHubRegistrationGate.tsx +260 -0
  149. package/src/components/social/SocialView.tsx +904 -0
  150. package/src/hooks/useSocialNotifications.ts +123 -0
  151. package/src/lib/events.ts +10 -1
  152. package/src/lib/social-client.ts +319 -0
  153. package/src/lib/view-registry.ts +22 -4
  154. package/src/server/cli/commands/agent.ts +33 -7
  155. package/src/server/cli/commands/create.ts +209 -0
  156. package/src/server/cli/commands/onboard.ts +39 -0
  157. package/src/server/cli/commands/play.ts +68 -0
  158. package/src/server/cli/commands/register-agent.ts +181 -0
  159. package/src/server/cli/commands/social.ts +648 -0
  160. package/src/server/cli/commands/start.ts +4 -3
  161. package/src/server/cron/index.ts +5 -9
  162. package/src/server/cron/jobs/social-inbound.ts +596 -0
  163. package/src/server/cron/jobs/social-outbound.ts +70 -0
  164. package/src/server/index.ts +14 -0
  165. package/src/server/lib/agent-profiles.ts +10 -1
  166. package/src/server/lib/cold.ts +0 -45
  167. package/src/server/lib/credential-import.ts +53 -47
  168. package/src/server/lib/defaults.ts +11 -0
  169. package/src/server/lib/feature-flags.ts +3 -0
  170. package/src/server/lib/hub-auth.ts +159 -0
  171. package/src/server/lib/migrate-agent-profiles.ts +96 -0
  172. package/src/server/lib/notifications.ts +12 -1
  173. package/src/server/lib/permissions.ts +10 -0
  174. package/src/server/lib/project-scope.ts +33 -5
  175. package/src/server/lib/social/create.ts +176 -0
  176. package/src/server/lib/social/global-aura-id.ts +115 -0
  177. package/src/server/lib/social/hub-rpc-client.ts +99 -0
  178. package/src/server/lib/social/hub-session.ts +6 -0
  179. package/src/server/lib/social/public-key.ts +32 -0
  180. package/src/server/lib/social/register.ts +162 -0
  181. package/src/server/lib/social/sign.ts +95 -0
  182. package/src/server/lib/social/sync.ts +301 -0
  183. package/src/server/mcp/server.ts +142 -18
  184. package/src/server/mcp/tools.ts +552 -0
  185. package/src/server/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  186. package/src/server/routes/agent-hub.ts +276 -0
  187. package/src/server/routes/agent-profiles.ts +145 -19
  188. package/src/server/routes/credentials.ts +49 -15
  189. package/src/server/routes/heartbeat.ts +3 -7
  190. package/src/server/routes/passkey.ts +7 -1
  191. package/src/server/routes/social.ts +869 -0
  192. package/src/server/routes/unlock.ts +17 -1
  193. package/src/server/routes/verified-credentials.ts +312 -0
  194. package/src/server/test-data/template.db +0 -0
  195. package/src/server/test-data/worker-1/agent-primary.json +12 -0
  196. package/src/server/test-data/worker-1/auramaxx.db +0 -0
  197. package/src/server/test-data/worker-1/backups/auramaxx.db.20260305_011404.bak +0 -0
  198. package/src/server/test-data/worker-1/backups/credentials.20260305_011404.cred-00ntf5h3.json +24 -0
  199. package/src/server/test-data/worker-1/backups/credentials.20260305_011404.cred-dh8fa9k3.json +24 -0
  200. package/src/server/test-data/worker-1/backups/credentials.20260305_011404.cred-prfhlyow.json +25 -0
  201. package/src/server/test-data/worker-1/backups/credentials.20260305_011404.cred-w26jd6zi.json +22 -0
  202. package/src/server/test-data/worker-1/config.json +33 -0
  203. package/src/server/test-data/worker-1/test.db +0 -0
  204. package/src/server/test-data/worker-10/agent-primary.json +12 -0
  205. package/src/server/test-data/worker-10/config.json +33 -0
  206. package/src/server/test-data/worker-10/test.db +0 -0
  207. package/src/server/test-data/worker-11/agent-primary.json +12 -0
  208. package/src/server/test-data/worker-11/config.json +33 -0
  209. package/src/server/test-data/worker-11/test.db +0 -0
  210. package/src/server/test-data/worker-12/agent-primary.json +12 -0
  211. package/src/server/test-data/worker-12/config.json +33 -0
  212. package/src/server/test-data/worker-12/credentials/cred-hboagned.json +25 -0
  213. package/src/server/test-data/worker-12/credentials/cred-nt14xd9t.json +24 -0
  214. package/src/server/test-data/worker-12/credentials/cred-qn59xefc.json +24 -0
  215. package/src/server/test-data/worker-12/test.db +0 -0
  216. package/src/server/test-data/worker-13/agent-primary.json +12 -0
  217. package/src/server/test-data/worker-13/approval-context.json +56 -0
  218. package/src/server/test-data/worker-13/approval-context.key +1 -0
  219. package/src/server/test-data/worker-13/config.json +33 -0
  220. package/src/server/test-data/worker-13/credentials-recently-deleted/cred-jn9yqy2m.json +20 -0
  221. package/src/server/test-data/worker-13/test.db +0 -0
  222. package/src/server/test-data/worker-14/agent-primary.json +12 -0
  223. package/src/server/test-data/worker-14/config.json +33 -0
  224. package/src/server/test-data/worker-14/test.db +0 -0
  225. package/src/server/test-data/worker-15/agent-primary.json +12 -0
  226. package/src/server/test-data/worker-15/config.json +33 -0
  227. package/src/server/test-data/worker-15/test.db +0 -0
  228. package/src/server/test-data/worker-15/update-check-4b305e76.json +5 -0
  229. package/src/server/test-data/worker-2/agent-primary.json +12 -0
  230. package/src/server/test-data/worker-2/config.json +33 -0
  231. package/src/server/test-data/worker-2/test.db +0 -0
  232. package/src/server/test-data/worker-3/agent-primary.json +12 -0
  233. package/src/server/test-data/worker-3/config.json +33 -0
  234. package/src/server/test-data/worker-3/test.db +0 -0
  235. package/src/server/test-data/worker-4/agent-primary.json +12 -0
  236. package/src/server/test-data/worker-4/config.json +33 -0
  237. package/src/server/test-data/worker-4/credentials/cred-bm6p74u5.json +24 -0
  238. package/src/server/test-data/worker-4/credentials/cred-r8eqb07i.json +25 -0
  239. package/src/server/test-data/worker-4/credentials/cred-ziv49y9h.json +24 -0
  240. package/src/server/test-data/worker-4/test.db +0 -0
  241. package/src/server/test-data/worker-5/agent-primary.json +12 -0
  242. package/src/server/test-data/worker-5/config.json +33 -0
  243. package/src/server/test-data/worker-5/credentials/cred-15gyfeht.json +25 -0
  244. package/src/server/test-data/worker-5/credentials/cred-lp0ocnfc.json +24 -0
  245. package/src/server/test-data/worker-5/credentials/cred-zfbwfxw0.json +24 -0
  246. package/src/server/test-data/worker-5/test.db +0 -0
  247. package/src/server/test-data/worker-6/agent-primary.json +12 -0
  248. package/src/server/test-data/worker-6/config.json +33 -0
  249. package/src/server/test-data/worker-6/credentials/cred-dx8li3ue.json +24 -0
  250. package/src/server/test-data/worker-6/credentials/cred-myeskqky.json +24 -0
  251. package/src/server/test-data/worker-6/credentials/cred-tam01qdt.json +25 -0
  252. package/src/server/test-data/worker-6/credentials/cred-wdh6rslw.json +22 -0
  253. package/src/server/test-data/worker-6/credentials-archive/cred-4wg53v1y.json +25 -0
  254. package/src/server/test-data/worker-6/test.db +0 -0
  255. package/src/server/test-data/worker-7/agent-primary.json +12 -0
  256. package/src/server/test-data/worker-7/config.json +33 -0
  257. package/src/server/test-data/worker-7/credentials/cred-ji7ec57d.json +25 -0
  258. package/src/server/test-data/worker-7/credentials/cred-mmgj0ypo.json +24 -0
  259. package/src/server/test-data/worker-7/credentials/cred-nb0ic93x.json +24 -0
  260. package/src/server/test-data/worker-7/test.db +0 -0
  261. package/src/server/test-data/worker-8/agent-primary.json +12 -0
  262. package/src/server/test-data/worker-8/config.json +33 -0
  263. package/src/server/test-data/worker-8/test.db +0 -0
  264. package/src/server/test-data/worker-9/agent-primary.json +12 -0
  265. package/src/server/test-data/worker-9/test.db +0 -0
  266. package/src/server/tests/cli/bin-entrypoint.test.ts +99 -0
  267. package/src/server/tests/cli/social.test.ts +83 -0
  268. package/src/server/tests/cron/social-inbound-followers.unit.test.ts +164 -0
  269. package/src/server/tests/cron/social-inbound-staleness.test.ts +193 -0
  270. package/src/server/tests/cron/social-outbound.test.ts +252 -0
  271. package/src/server/tests/endpoints/agent.test.ts +0 -16
  272. package/src/server/tests/lib/credential-field-schema.test.ts +448 -0
  273. package/src/server/tests/lib/project-scope.test.ts +49 -0
  274. package/src/server/tests/lib/social/create.test.ts +207 -0
  275. package/src/server/tests/lib/social/sign.test.ts +163 -0
  276. package/src/server/tests/lib/social/sync.test.ts +198 -0
  277. package/src/server/tests/mcp/server.test.ts +1 -3
  278. package/src/server/tests/mcp/tools.test.ts +219 -7
  279. package/src/server/tsconfig.tsbuildinfo +1 -1
  280. package/tsconfig.json +1 -0
  281. package/.next/server/chunks/2460.js +0 -7
  282. package/.next/server/chunks/5246.js +0 -1
  283. package/.next/static/chunks/3667-1db7bd03948e60df.js +0 -1
  284. package/.next/static/chunks/5442-b5bb869e832e8967.js +0 -1
  285. package/.next/static/chunks/6872-a8f1ad5fc35a1c35.js +0 -1
  286. package/.next/static/chunks/7394-4bdb1feefad1a74a.js +0 -1
  287. package/.next/static/chunks/7587-8542eae6e5139d27.js +0 -1
  288. package/.next/static/chunks/app/app-legacy-do-not-use/page-569fed5d1a2bfe79.js +0 -1
  289. package/.next/static/chunks/app/layout-af8d9969c7aeb758.js +0 -1
  290. package/.next/static/chunks/app/page-9bd0ec3ba4aa6cea.js +0 -1
  291. package/.next/static/css/88f92e1578b1e5d9.css +0 -3
  292. package/shared/agent-profile-schema.ts +0 -81
  293. package/src/internal/security/frontend-api-authorization-audit.md +0 -206
  294. package/src/server/lib/agent-profile-records.ts +0 -72
  295. /package/.next/static/{QPzVnl7GToqgYBq14V_iC → yEruVJH_cuht5hu5_1R00}/_buildManifest.js +0 -0
  296. /package/.next/static/{QPzVnl7GToqgYBq14V_iC → yEruVJH_cuht5hu5_1R00}/_ssgManifest.js +0 -0
@@ -0,0 +1,869 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { prisma } from '../lib/db';
3
+ import { requireWalletAuth } from '../middleware/auth';
4
+ import { requirePermission } from '../lib/permissions';
5
+ import { isEnabled } from '../lib/feature-flags';
6
+ import { getDefaultSync, getHubUrl } from '../lib/defaults';
7
+ import { getErrorMessage } from '../lib/error';
8
+ import { getAgentMnemonic, isAgentUnlocked } from '../lib/cold';
9
+ import { blake3 } from '@noble/hashes/blake3.js';
10
+ import { resolveHubAuthIdentity, tryCallHubWithSessionAuth } from '../lib/hub-auth';
11
+ import { normalizeHubPublicKey } from '../lib/social/public-key';
12
+ import {
13
+ createPost,
14
+ createPostRemove,
15
+ createReaction,
16
+ createReactionRemove,
17
+ createFollow,
18
+ createUnfollow,
19
+ createProfileUpdate,
20
+ } from '../lib/social/create';
21
+ import { submitSocialMessagesAsync } from '../lib/social/sync';
22
+
23
+ const router = Router();
24
+
25
+ // ─── Credential gate for social writes ──────────────────────────────
26
+ // Configurable via `social.required_credentials` default (array of credential
27
+ // type slugs). Empty array = no requirement. Checks locally synced
28
+ // credential_add messages in InboundMessage.
29
+
30
+ /**
31
+ * Middleware: reject social writes if agent lacks required verified credentials.
32
+ * Reads `social.required_credentials` from defaults — empty = no gate.
33
+ * Checks locally synced credential_add messages (via inbound sync).
34
+ */
35
+ async function requireVerifiedCredentials(req: Request, res: Response, next: NextFunction): Promise<void> {
36
+ const required = getDefaultSync<string[]>('social.required_credentials', []);
37
+ if (required.length === 0) {
38
+ next();
39
+ return;
40
+ }
41
+
42
+ const agentId = typeof req.body?.agentId === 'string' ? req.body.agentId.trim() : '';
43
+ if (!agentId) {
44
+ // Let the route handler deal with missing agentId
45
+ next();
46
+ return;
47
+ }
48
+
49
+ // Find all credential_add messages synced for this agent
50
+ const credentials = await prisma.inboundMessage.findMany({
51
+ where: { agentId, type: 'credential_add' },
52
+ select: { body: true },
53
+ });
54
+
55
+ // Extract credential type slugs from synced messages
56
+ const verified = new Set<string>();
57
+ for (const cred of credentials) {
58
+ try {
59
+ const parsed = JSON.parse(cred.body) as { credentialType?: string };
60
+ if (parsed.credentialType) verified.add(parsed.credentialType);
61
+ } catch { /* skip malformed */ }
62
+ }
63
+
64
+ const missing = required.filter(slug => !verified.has(slug));
65
+
66
+ if (missing.length > 0) {
67
+ res.status(403).json({
68
+ error: 'credential_required',
69
+ detail: `Agent must have verified credentials before posting: ${missing.join(', ')}`,
70
+ required,
71
+ missing,
72
+ });
73
+ return;
74
+ }
75
+
76
+ next();
77
+ }
78
+
79
+ // All social routes require auth + SOCIAL flag
80
+ router.use(requireWalletAuth);
81
+ router.use((_req: Request, res: Response, next) => {
82
+ if (!isEnabled('SOCIAL')) {
83
+ res.status(404).json({ error: 'social_not_enabled' });
84
+ return;
85
+ }
86
+ next();
87
+ });
88
+
89
+ // Permission middleware — reads vs writes
90
+ const requireSocialRead = requirePermission('social:read', 'social:write');
91
+ const requireSocialWrite = requirePermission('social:write');
92
+
93
+ // ─── Helpers ─────────────────────────────────────────────────────────
94
+
95
+ function parseIntParam(value: unknown, fallback: number): number {
96
+ const n = Number(value);
97
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
98
+ }
99
+
100
+ function parseJsonObject(raw: string): Record<string, unknown> {
101
+ try {
102
+ const parsed = JSON.parse(raw) as unknown;
103
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
104
+ return parsed as Record<string, unknown>;
105
+ }
106
+ } catch {
107
+ // no-op
108
+ }
109
+ return {};
110
+ }
111
+
112
+ function extractFolloweePublicKey(body: string): string | null {
113
+ const parsed = parseJsonObject(body);
114
+ const key = parsed.followeePublicKey;
115
+ if (typeof key !== 'string') return null;
116
+ const trimmed = key.trim();
117
+ return trimmed || null;
118
+ }
119
+
120
+ function extractRemovedPostHash(body: string): string | null {
121
+ const parsed = parseJsonObject(body);
122
+ const raw = parsed.targetPostHash ?? parsed.postHash ?? parsed.targetHash;
123
+ if (typeof raw !== 'string') return null;
124
+ const trimmed = raw.trim();
125
+ return trimmed || null;
126
+ }
127
+
128
+ function syntheticFollowerHash(agentId: string, followerPublicKey: string): string {
129
+ const input = `follower:${agentId}:${followerPublicKey}`;
130
+ return Buffer.from(blake3(new TextEncoder().encode(input))).toString('hex');
131
+ }
132
+
133
+ interface SnapshotFollowEntry {
134
+ auraId: number;
135
+ publicKey: string;
136
+ timestamp: number;
137
+ }
138
+
139
+ interface FollowersSnapshotResponse {
140
+ followers?: SnapshotFollowEntry[];
141
+ }
142
+
143
+ function requireAgentId(body: Record<string, unknown>, res: Response): string | null {
144
+ const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
145
+ if (!agentId) {
146
+ res.status(400).json({ error: 'agentId is required' });
147
+ return null;
148
+ }
149
+ return agentId;
150
+ }
151
+
152
+ function requireMnemonic(agentId: string, res: Response): string | null {
153
+ if (!isAgentUnlocked(agentId)) {
154
+ res.status(400).json({ error: `Agent '${agentId}' is not unlocked` });
155
+ return null;
156
+ }
157
+ const mnemonic = getAgentMnemonic(agentId);
158
+ if (!mnemonic) {
159
+ res.status(400).json({ error: `No mnemonic available for agent '${agentId}'` });
160
+ return null;
161
+ }
162
+ return mnemonic;
163
+ }
164
+
165
+ // ─── POST /social/post ───────────────────────────────────────────────
166
+
167
+ router.post('/post', requireSocialWrite, requireVerifiedCredentials, async (req: Request, res: Response) => {
168
+ try {
169
+ const agentId = requireAgentId(req.body, res);
170
+ if (!agentId) return;
171
+
172
+ const mnemonic = requireMnemonic(agentId, res);
173
+ if (!mnemonic) return;
174
+
175
+ const { text, embeds, parentPostHash, mentions } = req.body;
176
+
177
+ if (typeof text !== 'string' || !text.trim()) {
178
+ res.status(400).json({ error: 'text is required and must be a non-empty string' });
179
+ return;
180
+ }
181
+
182
+ if (embeds !== undefined && !Array.isArray(embeds)) {
183
+ res.status(400).json({ error: 'embeds must be an array of strings' });
184
+ return;
185
+ }
186
+
187
+ if (parentPostHash !== undefined && typeof parentPostHash !== 'string') {
188
+ res.status(400).json({ error: 'parentPostHash must be a string' });
189
+ return;
190
+ }
191
+
192
+ if (mentions !== undefined && !Array.isArray(mentions)) {
193
+ res.status(400).json({ error: 'mentions must be an array of numbers' });
194
+ return;
195
+ }
196
+
197
+ const hubUrl = typeof req.body.hubUrl === 'string' ? req.body.hubUrl.trim() : '';
198
+
199
+ const message = await createPost(agentId, mnemonic, text.trim(), {
200
+ embeds,
201
+ parentPostHash,
202
+ mentions,
203
+ hubUrl,
204
+ });
205
+
206
+ submitSocialMessagesAsync({
207
+ messages: [message],
208
+ transientErrorMode: 'retry',
209
+ hubUrl: hubUrl || undefined,
210
+ });
211
+
212
+ res.json({ success: true, message });
213
+ } catch (error) {
214
+ res.status(400).json({ error: getErrorMessage(error) });
215
+ }
216
+ });
217
+
218
+ // ─── POST /social/react ──────────────────────────────────────────────
219
+
220
+ router.post('/react', requireSocialWrite, requireVerifiedCredentials, async (req: Request, res: Response) => {
221
+ try {
222
+ const agentId = requireAgentId(req.body, res);
223
+ if (!agentId) return;
224
+
225
+ const mnemonic = requireMnemonic(agentId, res);
226
+ if (!mnemonic) return;
227
+
228
+ const { postHash, reactionType } = req.body;
229
+
230
+ if (typeof postHash !== 'string' || !postHash.trim()) {
231
+ res.status(400).json({ error: 'postHash is required' });
232
+ return;
233
+ }
234
+
235
+ if (typeof reactionType !== 'string' || !reactionType.trim()) {
236
+ res.status(400).json({ error: 'reactionType is required' });
237
+ return;
238
+ }
239
+
240
+ const reactHubUrl = typeof req.body.hubUrl === 'string' ? req.body.hubUrl.trim() : '';
241
+ const message = await createReaction(agentId, mnemonic, postHash.trim(), reactionType.trim(), reactHubUrl);
242
+
243
+ submitSocialMessagesAsync({
244
+ messages: [message],
245
+ transientErrorMode: 'retry',
246
+ hubUrl: reactHubUrl || undefined,
247
+ });
248
+
249
+ res.json({ success: true, message });
250
+ } catch (error) {
251
+ res.status(400).json({ error: getErrorMessage(error) });
252
+ }
253
+ });
254
+
255
+ // ─── POST /social/follow ─────────────────────────────────────────────
256
+
257
+ router.post('/follow', requireSocialWrite, requireVerifiedCredentials, async (req: Request, res: Response) => {
258
+ try {
259
+ const agentId = requireAgentId(req.body, res);
260
+ if (!agentId) return;
261
+
262
+ const mnemonic = requireMnemonic(agentId, res);
263
+ if (!mnemonic) return;
264
+
265
+ const { targetPublicKey } = req.body;
266
+
267
+ if (typeof targetPublicKey !== 'string' || !targetPublicKey.trim()) {
268
+ res.status(400).json({ error: 'targetPublicKey is required' });
269
+ return;
270
+ }
271
+
272
+ const followHubUrl = typeof req.body.hubUrl === 'string' ? req.body.hubUrl.trim() : '';
273
+ const message = await createFollow(agentId, mnemonic, targetPublicKey.trim(), followHubUrl);
274
+
275
+ submitSocialMessagesAsync({
276
+ messages: [message],
277
+ transientErrorMode: 'retry',
278
+ hubUrl: followHubUrl || undefined,
279
+ });
280
+
281
+ res.json({ success: true, message });
282
+ } catch (error) {
283
+ res.status(400).json({ error: getErrorMessage(error) });
284
+ }
285
+ });
286
+
287
+ // ─── POST /social/unfollow ───────────────────────────────────────────
288
+
289
+ router.post('/unfollow', requireSocialWrite, requireVerifiedCredentials, async (req: Request, res: Response) => {
290
+ try {
291
+ const agentId = requireAgentId(req.body, res);
292
+ if (!agentId) return;
293
+
294
+ const mnemonic = requireMnemonic(agentId, res);
295
+ if (!mnemonic) return;
296
+
297
+ const { targetPublicKey } = req.body;
298
+
299
+ if (typeof targetPublicKey !== 'string' || !targetPublicKey.trim()) {
300
+ res.status(400).json({ error: 'targetPublicKey is required' });
301
+ return;
302
+ }
303
+
304
+ const unfollowHubUrl = typeof req.body.hubUrl === 'string' ? req.body.hubUrl.trim() : '';
305
+ const message = await createUnfollow(agentId, mnemonic, targetPublicKey.trim(), unfollowHubUrl);
306
+
307
+ submitSocialMessagesAsync({
308
+ messages: [message],
309
+ transientErrorMode: 'retry',
310
+ hubUrl: unfollowHubUrl || undefined,
311
+ });
312
+
313
+ res.json({ success: true, message });
314
+ } catch (error) {
315
+ res.status(400).json({ error: getErrorMessage(error) });
316
+ }
317
+ });
318
+
319
+ // ─── POST /social/profile ────────────────────────────────────────────
320
+
321
+ router.post('/profile', requireSocialWrite, requireVerifiedCredentials, async (req: Request, res: Response) => {
322
+ try {
323
+ const agentId = requireAgentId(req.body, res);
324
+ if (!agentId) return;
325
+
326
+ const mnemonic = requireMnemonic(agentId, res);
327
+ if (!mnemonic) return;
328
+
329
+ const { field, value } = req.body;
330
+
331
+ if (typeof field !== 'string' || !field.trim()) {
332
+ res.status(400).json({ error: 'field is required' });
333
+ return;
334
+ }
335
+
336
+ if (typeof value !== 'string') {
337
+ res.status(400).json({ error: 'value must be a string' });
338
+ return;
339
+ }
340
+
341
+ const profileHubUrl = typeof req.body.hubUrl === 'string' ? req.body.hubUrl.trim() : '';
342
+ const message = await createProfileUpdate(agentId, mnemonic, field.trim(), value, profileHubUrl);
343
+
344
+ submitSocialMessagesAsync({
345
+ messages: [message],
346
+ transientErrorMode: 'retry',
347
+ hubUrl: profileHubUrl || undefined,
348
+ });
349
+
350
+ res.json({ success: true, message });
351
+ } catch (error) {
352
+ res.status(400).json({ error: getErrorMessage(error) });
353
+ }
354
+ });
355
+
356
+ // ─── POST /social/post/remove ────────────────────────────────────────
357
+
358
+ router.post('/post/remove', requireSocialWrite, requireVerifiedCredentials, async (req: Request, res: Response) => {
359
+ try {
360
+ const agentId = requireAgentId(req.body, res);
361
+ if (!agentId) return;
362
+
363
+ const mnemonic = requireMnemonic(agentId, res);
364
+ if (!mnemonic) return;
365
+
366
+ const { targetPostHash } = req.body;
367
+
368
+ if (typeof targetPostHash !== 'string' || !targetPostHash.trim()) {
369
+ res.status(400).json({ error: 'targetPostHash is required' });
370
+ return;
371
+ }
372
+
373
+ const removeHubUrl = typeof req.body.hubUrl === 'string' ? req.body.hubUrl.trim() : '';
374
+ const message = await createPostRemove(agentId, mnemonic, targetPostHash.trim(), removeHubUrl);
375
+
376
+ submitSocialMessagesAsync({
377
+ messages: [message],
378
+ transientErrorMode: 'retry',
379
+ hubUrl: removeHubUrl || undefined,
380
+ });
381
+
382
+ res.json({ success: true, message });
383
+ } catch (error) {
384
+ res.status(400).json({ error: getErrorMessage(error) });
385
+ }
386
+ });
387
+
388
+ // ─── POST /social/react/remove ──────────────────────────────────────
389
+
390
+ router.post('/react/remove', requireSocialWrite, requireVerifiedCredentials, async (req: Request, res: Response) => {
391
+ try {
392
+ const agentId = requireAgentId(req.body, res);
393
+ if (!agentId) return;
394
+
395
+ const mnemonic = requireMnemonic(agentId, res);
396
+ if (!mnemonic) return;
397
+
398
+ const { postHash, reactionType } = req.body;
399
+
400
+ if (typeof postHash !== 'string' || !postHash.trim()) {
401
+ res.status(400).json({ error: 'postHash is required' });
402
+ return;
403
+ }
404
+
405
+ if (typeof reactionType !== 'string' || !reactionType.trim()) {
406
+ res.status(400).json({ error: 'reactionType is required' });
407
+ return;
408
+ }
409
+
410
+ const reactRemoveHubUrl = typeof req.body.hubUrl === 'string' ? req.body.hubUrl.trim() : '';
411
+ const message = await createReactionRemove(agentId, mnemonic, postHash.trim(), reactionType.trim(), reactRemoveHubUrl);
412
+
413
+ submitSocialMessagesAsync({
414
+ messages: [message],
415
+ transientErrorMode: 'retry',
416
+ hubUrl: reactRemoveHubUrl || undefined,
417
+ });
418
+
419
+ res.json({ success: true, message });
420
+ } catch (error) {
421
+ res.status(400).json({ error: getErrorMessage(error) });
422
+ }
423
+ });
424
+
425
+ // ─── GET /social/following ──────────────────────────────────────────
426
+
427
+ router.get('/following', requireSocialRead, async (req: Request, res: Response) => {
428
+ try {
429
+ const agentId = typeof req.query.agentId === 'string' ? req.query.agentId.trim() : '';
430
+ if (!agentId) {
431
+ res.status(400).json({ error: 'agentId query param is required' });
432
+ return;
433
+ }
434
+
435
+ const hubUrl = typeof req.query.hubUrl === 'string' ? req.query.hubUrl.trim() : '';
436
+
437
+ // Get all link_add and link_remove messages for this agent (optionally filtered by hub)
438
+ const linkWhere: Record<string, unknown> = { agentId, type: { in: ['link_add', 'link_remove'] } };
439
+ if (hubUrl) linkWhere.hubUrl = hubUrl;
440
+ const links = await prisma.socialMessage.findMany({
441
+ where: linkWhere,
442
+ orderBy: { createdAt: 'asc' },
443
+ });
444
+
445
+ // Build net following set: add on link_add, remove on link_remove
446
+ const followingMap = new Map<string, { publicKey: string; timestamp: number }>();
447
+ for (const link of links) {
448
+ const body = JSON.parse(link.body) as { followeePublicKey?: string; followeeAuraId?: number };
449
+ const targetKey = body.followeePublicKey ?? String(body.followeeAuraId ?? '');
450
+ if (!targetKey) continue;
451
+
452
+ if (link.type === 'link_add') {
453
+ followingMap.set(targetKey, { publicKey: targetKey, timestamp: link.timestamp });
454
+ } else {
455
+ followingMap.delete(targetKey);
456
+ }
457
+ }
458
+
459
+ const following = Array.from(followingMap.values());
460
+
461
+ res.json({ success: true, following, count: following.length });
462
+ } catch (error) {
463
+ res.status(400).json({ error: getErrorMessage(error) });
464
+ }
465
+ });
466
+
467
+ // ─── GET /social/messages ────────────────────────────────────────────
468
+
469
+ router.get('/messages', requireSocialRead, async (req: Request, res: Response) => {
470
+ try {
471
+ const agentId = typeof req.query.agentId === 'string' ? req.query.agentId.trim() : '';
472
+ if (!agentId) {
473
+ res.status(400).json({ error: 'agentId query param is required' });
474
+ return;
475
+ }
476
+
477
+ const type = typeof req.query.type === 'string' ? req.query.type.trim() : undefined;
478
+ const hubUrl = typeof req.query.hubUrl === 'string' ? req.query.hubUrl.trim() : '';
479
+ const limit = parseIntParam(req.query.limit, 50);
480
+ const offset = parseIntParam(req.query.offset, 0);
481
+
482
+ const where: Record<string, unknown> = { agentId };
483
+ if (type) where.type = type;
484
+ if (hubUrl) where.hubUrl = hubUrl;
485
+
486
+ const [messages, total] = await Promise.all([
487
+ prisma.socialMessage.findMany({
488
+ where,
489
+ orderBy: { createdAt: 'desc' },
490
+ take: Math.min(limit, 200),
491
+ skip: offset,
492
+ }),
493
+ prisma.socialMessage.count({ where }),
494
+ ]);
495
+
496
+ res.json({ success: true, messages, total });
497
+ } catch (error) {
498
+ res.status(400).json({ error: getErrorMessage(error) });
499
+ }
500
+ });
501
+
502
+ // ─── GET /social/feed ────────────────────────────────────────────────
503
+
504
+ router.get('/feed', requireSocialRead, async (req: Request, res: Response) => {
505
+ try {
506
+ const agentId = typeof req.query.agentId === 'string' ? req.query.agentId.trim() : '';
507
+ if (!agentId) {
508
+ res.status(400).json({ error: 'agentId query param is required' });
509
+ return;
510
+ }
511
+
512
+ const type = typeof req.query.type === 'string' ? req.query.type.trim() : undefined;
513
+ const hubUrl = typeof req.query.hubUrl === 'string' ? req.query.hubUrl.trim() : '';
514
+ const limit = parseIntParam(req.query.limit, 50);
515
+ const offset = parseIntParam(req.query.offset, 0);
516
+
517
+ if (type && type !== 'post_add') {
518
+ res.json({ success: true, messages: [], total: 0 });
519
+ return;
520
+ }
521
+
522
+ // Build hub-aware where clauses for outbound messages
523
+ const outboundHubWhere: Record<string, unknown> = hubUrl ? { hubUrl } : {};
524
+ const effectiveHubUrl = hubUrl || getHubUrl();
525
+
526
+ const [
527
+ selectedHubSubscription,
528
+ inboundOwnerProfile,
529
+ outboundLinks,
530
+ ownPostAdds,
531
+ ownPostRemoves,
532
+ ] = await Promise.all([
533
+ prisma.hubSubscription.findUnique({
534
+ where: {
535
+ agentId_hubUrl: {
536
+ agentId,
537
+ hubUrl: effectiveHubUrl,
538
+ },
539
+ },
540
+ select: { auraId: true },
541
+ }),
542
+ prisma.agentProfile.findFirst({
543
+ where: { publicKeyHex: { not: null } },
544
+ orderBy: { createdAt: 'asc' },
545
+ select: { agentId: true },
546
+ }),
547
+ prisma.socialMessage.findMany({
548
+ where: {
549
+ agentId,
550
+ type: { in: ['link_add', 'link_remove'] },
551
+ syncStatus: { notIn: ['failed', 'rejected'] },
552
+ ...outboundHubWhere,
553
+ },
554
+ orderBy: { createdAt: 'asc' },
555
+ select: { type: true, body: true },
556
+ }),
557
+ prisma.socialMessage.findMany({
558
+ where: {
559
+ agentId,
560
+ type: 'post_add',
561
+ syncStatus: { notIn: ['failed', 'rejected'] },
562
+ ...outboundHubWhere,
563
+ },
564
+ orderBy: { timestamp: 'desc' },
565
+ select: {
566
+ id: true,
567
+ hash: true,
568
+ type: true,
569
+ body: true,
570
+ timestamp: true,
571
+ },
572
+ }),
573
+ prisma.socialMessage.findMany({
574
+ where: {
575
+ agentId,
576
+ type: 'post_remove',
577
+ syncStatus: { notIn: ['failed', 'rejected'] },
578
+ ...outboundHubWhere,
579
+ },
580
+ select: { body: true },
581
+ }),
582
+ ]);
583
+
584
+ const followingPublicKeys = new Set<string>();
585
+ for (const link of outboundLinks) {
586
+ const key = extractFolloweePublicKey(link.body);
587
+ if (!key) continue;
588
+ if (link.type === 'link_add') {
589
+ followingPublicKeys.add(key);
590
+ } else {
591
+ followingPublicKeys.delete(key);
592
+ }
593
+ }
594
+
595
+ const removedPostHashes = new Set<string>();
596
+ for (const removeMessage of ownPostRemoves) {
597
+ const targetHash = extractRemovedPostHash(removeMessage.body);
598
+ if (targetHash) removedPostHashes.add(targetHash);
599
+ }
600
+
601
+ const ownPosts = ownPostAdds
602
+ .filter((post) => !removedPostHashes.has(post.hash))
603
+ .map((post) => ({
604
+ id: post.id,
605
+ agentId,
606
+ hash: post.hash,
607
+ authorAuraId: selectedHubSubscription?.auraId ?? 0,
608
+ type: post.type,
609
+ body: post.body,
610
+ timestamp: post.timestamp,
611
+ }));
612
+
613
+ const inboundAgentId = inboundOwnerProfile?.agentId ?? agentId;
614
+ const inboundHubWhere: Record<string, unknown> = hubUrl ? { hubUrl } : {};
615
+ const followedPosts = followingPublicKeys.size > 0
616
+ ? await prisma.inboundMessage.findMany({
617
+ where: {
618
+ agentId: inboundAgentId,
619
+ type: 'post_add',
620
+ authorPublicKey: { in: Array.from(followingPublicKeys) },
621
+ ...inboundHubWhere,
622
+ },
623
+ orderBy: { timestamp: 'desc' },
624
+ select: {
625
+ id: true,
626
+ agentId: true,
627
+ hash: true,
628
+ authorAuraId: true,
629
+ type: true,
630
+ body: true,
631
+ timestamp: true,
632
+ },
633
+ })
634
+ : [];
635
+
636
+ const byHash = new Map<string, {
637
+ id: string;
638
+ agentId: string;
639
+ hash: string;
640
+ authorAuraId: number;
641
+ type: string;
642
+ body: string;
643
+ timestamp: number;
644
+ }>();
645
+
646
+ for (const msg of followedPosts) {
647
+ byHash.set(msg.hash, msg);
648
+ }
649
+ for (const msg of ownPosts) {
650
+ byHash.set(msg.hash, msg);
651
+ }
652
+
653
+ const merged = Array.from(byHash.values()).sort((a, b) => b.timestamp - a.timestamp);
654
+ const cappedLimit = Math.min(limit, 200);
655
+ const messages = merged.slice(offset, offset + cappedLimit);
656
+
657
+ res.json({ success: true, messages, total: merged.length });
658
+ } catch (error) {
659
+ res.status(400).json({ error: getErrorMessage(error) });
660
+ }
661
+ });
662
+
663
+ // ─── GET /social/followers ───────────────────────────────────────────
664
+
665
+ router.get('/followers', requireSocialRead, async (req: Request, res: Response) => {
666
+ try {
667
+ const agentId = typeof req.query.agentId === 'string' ? req.query.agentId.trim() : '';
668
+ if (!agentId) {
669
+ res.status(400).json({ error: 'agentId query param is required' });
670
+ return;
671
+ }
672
+
673
+ const hubUrl = typeof req.query.hubUrl === 'string' ? req.query.hubUrl.trim() : '';
674
+ const followerWhere: Record<string, unknown> = { agentId, type: 'link_add' };
675
+ if (hubUrl) followerWhere.hubUrl = hubUrl;
676
+
677
+ const [cachedFollowers, cachedCount] = await Promise.all([
678
+ prisma.inboundMessage.findMany({
679
+ where: followerWhere,
680
+ orderBy: { timestamp: 'desc' },
681
+ }),
682
+ prisma.inboundMessage.count({
683
+ where: followerWhere,
684
+ }),
685
+ ]);
686
+
687
+ if (cachedCount > 0) {
688
+ res.json({ success: true, followers: cachedFollowers, count: cachedCount });
689
+ return;
690
+ }
691
+
692
+ // Fallback: if cron cache is empty, read a fresh snapshot from hub.
693
+ // This keeps followers usable even when inbound cron has not populated yet.
694
+ const effectiveHubUrl = hubUrl || getHubUrl();
695
+ const profile = await prisma.agentProfile.findUnique({
696
+ where: { agentId },
697
+ select: { publicKeyHex: true },
698
+ });
699
+
700
+ if (!profile?.publicKeyHex) {
701
+ res.json({ success: true, followers: cachedFollowers, count: cachedCount });
702
+ return;
703
+ }
704
+
705
+ const authIdentity = resolveHubAuthIdentity(agentId);
706
+ if (!authIdentity) {
707
+ res.json({ success: true, followers: cachedFollowers, count: cachedCount });
708
+ return;
709
+ }
710
+
711
+ const snapshot = await tryCallHubWithSessionAuth<FollowersSnapshotResponse>(
712
+ effectiveHubUrl,
713
+ 'sync.snapshot',
714
+ { publicKey: normalizeHubPublicKey(profile.publicKeyHex) },
715
+ authIdentity.mnemonic,
716
+ );
717
+
718
+ const snapshotFollowers = (snapshot?.followers ?? [])
719
+ .filter((entry) => typeof entry?.publicKey === 'string' && entry.publicKey.trim().length > 0)
720
+ .map((entry) => {
721
+ const followerPublicKey = entry.publicKey.trim();
722
+ return {
723
+ id: `snapshot:${agentId}:${followerPublicKey}`,
724
+ agentId,
725
+ hash: syntheticFollowerHash(agentId, followerPublicKey),
726
+ hubUrl: effectiveHubUrl,
727
+ authorAuraId: Number.isFinite(entry.auraId) ? entry.auraId : 0,
728
+ authorPublicKey: followerPublicKey,
729
+ type: 'link_add',
730
+ body: JSON.stringify({
731
+ followeePublicKey: profile.publicKeyHex,
732
+ followerPublicKey,
733
+ }),
734
+ timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
735
+ };
736
+ });
737
+
738
+ res.json({ success: true, followers: snapshotFollowers, count: snapshotFollowers.length });
739
+ } catch (error) {
740
+ res.status(400).json({ error: getErrorMessage(error) });
741
+ }
742
+ });
743
+
744
+ // ─── GET /social/notifications ───────────────────────────────────────
745
+
746
+ router.get('/notifications', requireSocialRead, async (req: Request, res: Response) => {
747
+ try {
748
+ const agentId = typeof req.query.agentId === 'string' ? req.query.agentId.trim() : '';
749
+
750
+ const limit = parseIntParam(req.query.limit, 50);
751
+ const unreadOnly = req.query.unreadOnly === 'true';
752
+
753
+ const where: Record<string, unknown> = { category: 'social' };
754
+ if (agentId) where.agentId = agentId;
755
+ if (unreadOnly) {
756
+ where.read = false;
757
+ where.dismissed = false;
758
+ }
759
+
760
+ const [notifications, total] = await Promise.all([
761
+ prisma.notification.findMany({
762
+ where,
763
+ orderBy: { createdAt: 'desc' },
764
+ take: Math.min(limit, 200),
765
+ }),
766
+ prisma.notification.count({ where }),
767
+ ]);
768
+
769
+ res.json({ success: true, notifications, total });
770
+ } catch (error) {
771
+ res.status(400).json({ error: getErrorMessage(error) });
772
+ }
773
+ });
774
+
775
+ // ─── POST /social/notifications/read ────────────────────────────────
776
+
777
+ router.post('/notifications/read', requireSocialWrite, async (req: Request, res: Response) => {
778
+ try {
779
+ const { ids } = req.body;
780
+ if (!Array.isArray(ids) || ids.length === 0) {
781
+ res.status(400).json({ error: 'ids array is required' });
782
+ return;
783
+ }
784
+
785
+ const updated = await prisma.notification.updateMany({
786
+ where: { id: { in: ids }, category: 'social' },
787
+ data: { read: true },
788
+ });
789
+
790
+ res.json({ success: true, updated: updated.count });
791
+ } catch (error) {
792
+ res.status(400).json({ error: getErrorMessage(error) });
793
+ }
794
+ });
795
+
796
+ // ─── POST /social/notifications/dismiss ─────────────────────────────
797
+
798
+ router.post('/notifications/dismiss', requireSocialWrite, async (req: Request, res: Response) => {
799
+ try {
800
+ const { ids } = req.body;
801
+ if (!Array.isArray(ids) || ids.length === 0) {
802
+ res.status(400).json({ error: 'ids array is required' });
803
+ return;
804
+ }
805
+
806
+ const updated = await prisma.notification.updateMany({
807
+ where: { id: { in: ids }, category: 'social' },
808
+ data: { dismissed: true, read: true },
809
+ });
810
+
811
+ res.json({ success: true, updated: updated.count });
812
+ } catch (error) {
813
+ res.status(400).json({ error: getErrorMessage(error) });
814
+ }
815
+ });
816
+
817
+ // ─── GET /social/global-feed ─────────────────────────────────────────
818
+
819
+ router.get('/global-feed', requireSocialRead, async (req: Request, res: Response) => {
820
+ try {
821
+ const limit = parseIntParam(req.query.limit, 50);
822
+ const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : undefined;
823
+ const hubUrl = typeof req.query.hubUrl === 'string' && req.query.hubUrl.trim()
824
+ ? req.query.hubUrl.trim()
825
+ : getHubUrl();
826
+
827
+ const url = new URL(`${hubUrl}/v1/social/global`);
828
+ url.searchParams.set('limit', String(Math.min(limit, 200)));
829
+ if (cursor) url.searchParams.set('cursor', cursor);
830
+
831
+ const hubRes = await fetch(url.toString(), { signal: AbortSignal.timeout(10_000) });
832
+ const data = await hubRes.json();
833
+ res.status(hubRes.status).json(data);
834
+ } catch (error) {
835
+ res.status(502).json({ error: 'hub_unreachable', detail: getErrorMessage(error) });
836
+ }
837
+ });
838
+
839
+ // ─── GET /social/status ──────────────────────────────────────────────
840
+
841
+ router.get('/status', requireSocialRead, async (req: Request, res: Response) => {
842
+ try {
843
+ const agentId = typeof req.query.agentId === 'string' ? req.query.agentId.trim() : '';
844
+ if (!agentId) {
845
+ res.status(400).json({ error: 'agentId query param is required' });
846
+ return;
847
+ }
848
+
849
+ const hubUrl = typeof req.query.hubUrl === 'string' ? req.query.hubUrl.trim() : '';
850
+ const statusHubWhere: Record<string, unknown> = hubUrl ? { hubUrl } : {};
851
+
852
+ const [pending, accepted, rejected, failed, total] = await Promise.all([
853
+ prisma.socialMessage.count({ where: { agentId, syncStatus: 'pending', ...statusHubWhere } }),
854
+ prisma.socialMessage.count({ where: { agentId, syncStatus: 'accepted', ...statusHubWhere } }),
855
+ prisma.socialMessage.count({ where: { agentId, syncStatus: 'rejected', ...statusHubWhere } }),
856
+ prisma.socialMessage.count({ where: { agentId, syncStatus: 'failed', ...statusHubWhere } }),
857
+ prisma.socialMessage.count({ where: { agentId, ...statusHubWhere } }),
858
+ ]);
859
+
860
+ res.json({
861
+ success: true,
862
+ status: { pending, accepted, rejected, failed, total },
863
+ });
864
+ } catch (error) {
865
+ res.status(400).json({ error: getErrorMessage(error) });
866
+ }
867
+ });
868
+
869
+ export default router;