@taskforcehq/taskforce 0.3.305 → 0.3.307

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 (440) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +603 -107
  3. package/dist/TaskforceCore.js +216 -43
  4. package/dist/TaskforceCore.test.js +611 -28
  5. package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
  6. package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  7. package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
  8. package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
  9. package/dist/assets/fonts/Noir_medium.woff2 +0 -0
  10. package/dist/assets/fonts/Noir_regular.woff2 +0 -0
  11. package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
  12. package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
  13. package/dist/compat/workspaceSyncCompat.js +46 -2
  14. package/dist/compat/workspaceSyncCompat.test.js +38 -1
  15. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  16. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  17. package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
  18. package/dist/components/context/ContextAttachmentManager.js +561 -0
  19. package/dist/components/features/AgentsModule.d.ts +14 -1
  20. package/dist/components/features/AgentsModule.js +366 -18
  21. package/dist/components/features/AgentsModule.test.js +507 -8
  22. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  23. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  24. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  25. package/dist/components/features/DocumentIndex.d.ts +2 -2
  26. package/dist/components/features/DocumentIndex.js +3 -4
  27. package/dist/components/features/DocumentViewer.d.ts +7 -1
  28. package/dist/components/features/DocumentViewer.js +62 -4
  29. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  30. package/dist/components/features/DocumentWorkspace.js +15 -4
  31. package/dist/components/features/DocumentWorkspace.test.js +51 -1
  32. package/dist/components/features/TaskSettings.d.ts +2 -0
  33. package/dist/components/features/TaskSettings.js +83 -78
  34. package/dist/components/features/TaskSettings.test.js +171 -82
  35. package/dist/components/features/documentWorkspaceModel.d.ts +1 -1
  36. package/dist/components/task/TaskActionHeader.js +11 -5
  37. package/dist/components/task/TaskActionHeader.test.js +10 -0
  38. package/dist/components/task/TaskCard.js +24 -29
  39. package/dist/components/task/TaskCard.test.js +38 -3
  40. package/dist/components/task/TaskContextUpload.js +4 -526
  41. package/dist/components/task/TaskContextUpload.test.js +3 -3
  42. package/dist/components/task/TaskForm.js +61 -116
  43. package/dist/components/task/TaskForm.test.js +110 -8
  44. package/dist/components/task/TaskKanban.d.ts +2 -1
  45. package/dist/components/task/TaskKanban.js +94 -21
  46. package/dist/components/task/TaskKanban.test.js +36 -0
  47. package/dist/components/task/TaskList.js +5 -1
  48. package/dist/components/task/TaskList.test.js +4 -0
  49. package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
  50. package/dist/components/ui/EditableAvatarButton.js +18 -0
  51. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  52. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  53. package/dist/components/views/AppShellHeader.d.ts +3 -1
  54. package/dist/components/views/AppShellHeader.js +27 -11
  55. package/dist/components/views/AppShellHeader.test.js +18 -1
  56. package/dist/components/views/PlanComparisonPage.d.ts +9 -1
  57. package/dist/components/views/PlanComparisonPage.js +78 -14
  58. package/dist/components/views/PlanComparisonPage.test.js +257 -0
  59. package/dist/components/views/PlansPage.js +127 -36
  60. package/dist/components/views/PlansPage.test.js +294 -12
  61. package/dist/components/views/StandaloneLayout.js +529 -592
  62. package/dist/components/views/WidgetView.js +10 -8
  63. package/dist/components/views/panels/PlanningDrawer.d.ts +25 -5
  64. package/dist/components/views/panels/PlanningDrawer.js +270 -55
  65. package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
  66. package/dist/components/views/panels/PlanningDrawer.test.js +529 -0
  67. package/dist/components/views/planningScope.d.ts +20 -0
  68. package/dist/components/views/planningScope.js +25 -0
  69. package/dist/components/views/planningScope.test.d.ts +1 -0
  70. package/dist/components/views/planningScope.test.js +60 -0
  71. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +26 -1
  72. package/dist/components/views/standalone/modals/AccountHubModal.js +76 -2
  73. package/dist/components/views/standalone/modals/AccountHubModal.test.js +20 -3
  74. package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
  75. package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
  76. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
  77. package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
  78. package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
  79. package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
  80. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
  81. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
  82. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
  83. package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
  84. package/dist/config/envSchema.js +1 -0
  85. package/dist/core/AiProfileService.d.ts +46 -2
  86. package/dist/core/AiProfileService.js +1073 -82
  87. package/dist/core/AiProfileService.test.js +19 -1
  88. package/dist/core/AiProfiles.test.js +1249 -44
  89. package/dist/core/AttachmentLinkService.d.ts +3 -0
  90. package/dist/core/AttachmentLinkService.js +110 -23
  91. package/dist/core/AuthIdentityService.d.ts +107 -0
  92. package/dist/core/AuthIdentityService.js +284 -0
  93. package/dist/core/AuthTokenService.d.ts +4 -0
  94. package/dist/core/AuthTokenService.js +34 -6
  95. package/dist/core/AuthTokenService.test.js +3 -3
  96. package/dist/core/EntitlementsPolicy.test.js +2 -2
  97. package/dist/core/GlobalSettingsService.js +127 -6
  98. package/dist/core/McpTokenService.d.ts +12 -0
  99. package/dist/core/McpTokenService.js +44 -1
  100. package/dist/core/PlanEntitlementService.d.ts +23 -2
  101. package/dist/core/PlanEntitlementService.js +343 -57
  102. package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
  103. package/dist/core/PlanFeatureCatalog.test.js +84 -0
  104. package/dist/core/PlanVersionPolicy.test.js +35 -2
  105. package/dist/core/PlanningEntities.test.js +418 -4
  106. package/dist/core/SignupMonetizationSettings.test.js +199 -27
  107. package/dist/core/SyncReconciliationService.js +2 -65
  108. package/dist/core/SystemAdmin.test.js +42 -42
  109. package/dist/core/TaskActivityService.d.ts +28 -1
  110. package/dist/core/TaskActivityService.js +227 -46
  111. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  112. package/dist/core/TaskActivitySync.test.js +90 -0
  113. package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
  114. package/dist/core/TaskAuditTimeline.test.js +96 -0
  115. package/dist/core/Taskforce.d.ts +415 -40
  116. package/dist/core/Taskforce.ids.test.js +58 -0
  117. package/dist/core/Taskforce.js +1543 -266
  118. package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
  119. package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
  120. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  121. package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
  122. package/dist/core/WorkspaceLifecycleService.js +0 -5
  123. package/dist/core/WorkspacePermissions.test.js +25 -1
  124. package/dist/core/WorkspacePlanMode.test.js +117 -16
  125. package/dist/core/shared.d.ts +1 -1
  126. package/dist/core/shared.js +5 -1
  127. package/dist/core/types.d.ts +75 -2
  128. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +28 -2
  129. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  130. package/dist/hooks/bootstrapTimeouts.js +8 -0
  131. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  132. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  133. package/dist/hooks/sync/bootstrap.js +2 -0
  134. package/dist/hooks/sync/controlPlane.d.ts +37 -0
  135. package/dist/hooks/sync/controlPlane.js +78 -0
  136. package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
  137. package/dist/hooks/sync/controlPlane.test.js +121 -0
  138. package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
  139. package/dist/hooks/sync/lifecyclePolicy.js +93 -0
  140. package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
  141. package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
  142. package/dist/hooks/sync/orchestratorShared.d.ts +13 -4
  143. package/dist/hooks/sync/orchestratorShared.js +129 -48
  144. package/dist/hooks/sync/orchestratorShared.test.js +50 -1
  145. package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
  146. package/dist/hooks/sync/pullLifecycle.js +24 -0
  147. package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
  148. package/dist/hooks/sync/pullLifecycle.test.js +80 -0
  149. package/dist/hooks/sync/recovery.d.ts +16 -2
  150. package/dist/hooks/sync/recovery.js +179 -53
  151. package/dist/hooks/sync/recovery.test.d.ts +1 -0
  152. package/dist/hooks/sync/recovery.test.js +292 -0
  153. package/dist/hooks/sync/transfers.d.ts +16 -3
  154. package/dist/hooks/sync/transfers.js +210 -66
  155. package/dist/hooks/sync/transfers.test.d.ts +1 -0
  156. package/dist/hooks/sync/transfers.test.js +928 -0
  157. package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
  158. package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
  159. package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
  160. package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
  161. package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
  162. package/dist/hooks/sync/useSyncStatusActions.js +88 -0
  163. package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
  164. package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
  165. package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
  166. package/dist/hooks/sync/useSyncStatusControls.js +60 -0
  167. package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
  168. package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
  169. package/dist/hooks/useFilterSort.js +13 -13
  170. package/dist/hooks/useFilterSort.test.js +87 -1
  171. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
  172. package/dist/hooks/useSyncOrchestrator.d.ts +9 -2
  173. package/dist/hooks/useSyncOrchestrator.js +409 -183
  174. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +407 -0
  175. package/dist/hooks/useTaskData.js +8 -3
  176. package/dist/hooks/useTaskData.test.js +45 -0
  177. package/dist/hooks/useTaskforce.d.ts +145 -52
  178. package/dist/hooks/useTaskforce.js +592 -253
  179. package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
  180. package/dist/hooks/useTaskforce.runtime-routing.test.js +246 -0
  181. package/dist/hooks/useTaskforce.sync-behavior.test.js +2468 -201
  182. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  183. package/dist/hooks/useWorkspaceSyncController.js +13 -2
  184. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  185. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
  186. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +328 -40
  187. package/dist/localization/locales/en-US.d.ts +0 -1
  188. package/dist/localization/locales/en-US.js +0 -1
  189. package/dist/localization/locales/es-419.js +0 -1
  190. package/dist/localization/locales/pt-BR.js +0 -1
  191. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  192. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  193. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  194. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  195. package/dist/mcp/clientIntegrations.d.ts +1 -0
  196. package/dist/mcp/clientIntegrations.js +25 -0
  197. package/dist/mcp/clientIntegrations.test.js +14 -3
  198. package/dist/mcp/clientProfileDefaults.js +3 -3
  199. package/dist/mcp/collaborationRegistrar.js +7 -7
  200. package/dist/mcp/documentAssetRegistrar.js +116 -10
  201. package/dist/mcp/runtime.d.ts +3 -0
  202. package/dist/mcp/runtime.js +1099 -185
  203. package/dist/mcp/runtime.test.js +1491 -140
  204. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  205. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  206. package/dist/mcp/toolRegistry.d.ts +56 -0
  207. package/dist/mcp/toolRegistry.js +8 -0
  208. package/dist/mcp/toolRegistry.test.js +4 -0
  209. package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
  210. package/dist/migrations/taskSchemaMigrations.js +192 -61
  211. package/dist/migrations/taskSchemaMigrations.test.js +15 -0
  212. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  213. package/dist/resources/templates/workflows/collaborate.yaml +1 -1
  214. package/dist/resources/templates/workflows/evaluate.yaml +2 -2
  215. package/dist/resources/templates/workflows/plan.yaml +1 -1
  216. package/dist/resources/templates/workflows/review.yaml +2 -2
  217. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  218. package/dist/server/aiProfileAvatarAssets.js +319 -0
  219. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  220. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  221. package/dist/server/annotatedAttachmentsRoutes.test.js +69 -10
  222. package/dist/server/auth/providers/apple.d.ts +33 -0
  223. package/dist/server/auth/providers/apple.js +82 -0
  224. package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
  225. package/dist/server/auth/providers/appleClientSecret.js +65 -0
  226. package/dist/server/auth/providers/github.d.ts +26 -0
  227. package/dist/server/auth/providers/github.js +86 -0
  228. package/dist/server/auth/providers/google.d.ts +21 -0
  229. package/dist/server/auth/providers/google.js +56 -0
  230. package/dist/server/auth/providers/types.d.ts +21 -0
  231. package/dist/server/auth/providers/types.js +1 -0
  232. package/dist/server/auth.d.ts +2 -0
  233. package/dist/server/auth.js +6 -3
  234. package/dist/server/documentReviewRoutes.test.js +36 -3
  235. package/dist/server/index.cookieProxy.test.js +1 -0
  236. package/dist/server/index.d.ts +12 -0
  237. package/dist/server/index.js +124 -9
  238. package/dist/server/index.rateLimit.test.js +3 -0
  239. package/dist/server/index.test.js +111 -3
  240. package/dist/server/routes/admin.d.ts +3 -0
  241. package/dist/server/routes/admin.js +459 -28
  242. package/dist/server/routes/annotatedAttachments.js +51 -0
  243. package/dist/server/routes/auth.d.ts +26 -2
  244. package/dist/server/routes/auth.js +1170 -71
  245. package/dist/server/routes/authSupport.d.ts +5 -1
  246. package/dist/server/routes/authSupport.js +13 -2
  247. package/dist/server/routes/billing.js +372 -35
  248. package/dist/server/routes/billing.test.js +595 -12
  249. package/dist/server/routes/documents.d.ts +1 -0
  250. package/dist/server/routes/documents.js +182 -70
  251. package/dist/server/routes/shared.d.ts +28 -2
  252. package/dist/server/routes/shared.js +69 -14
  253. package/dist/server/routes/sync.integration.test.js +658 -39
  254. package/dist/server/routes/sync.js +74 -608
  255. package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
  256. package/dist/server/routes/syncAuxRoutes.js +91 -0
  257. package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
  258. package/dist/server/routes/syncAuxRoutes.test.js +158 -0
  259. package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
  260. package/dist/server/routes/syncPullApplyRoutes.js +204 -0
  261. package/dist/server/routes/syncPushRoutes.d.ts +24 -0
  262. package/dist/server/routes/syncPushRoutes.js +212 -0
  263. package/dist/server/routes/syncRouteGuards.d.ts +36 -0
  264. package/dist/server/routes/syncRouteGuards.js +67 -0
  265. package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
  266. package/dist/server/routes/syncRouteGuards.test.js +94 -0
  267. package/dist/server/routes/syncRouteTypes.d.ts +13 -0
  268. package/dist/server/routes/syncRouteTypes.js +1 -0
  269. package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
  270. package/dist/server/routes/syncSnapshotRoutes.js +182 -0
  271. package/dist/server/routes/tasks.js +55 -10
  272. package/dist/server/routes/workspaces.js +36 -5
  273. package/dist/server/routes.js +147 -11
  274. package/dist/server/routes.test.js +2704 -183
  275. package/dist/services/workflowExportSnapshots.test.js +2 -0
  276. package/dist/shared/aiProfileSeatScope.d.ts +5 -0
  277. package/dist/shared/aiProfileSeatScope.js +21 -0
  278. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  279. package/dist/shared/aiProfileSurfaceType.js +10 -8
  280. package/dist/shared/passwordPolicy.d.ts +13 -0
  281. package/dist/shared/passwordPolicy.js +84 -0
  282. package/dist/shared/passwordPolicy.test.d.ts +1 -0
  283. package/dist/shared/passwordPolicy.test.js +34 -0
  284. package/dist/storage/objectStorage.d.ts +1 -0
  285. package/dist/storage/objectStorage.js +2 -1
  286. package/dist/storage/objectStorageClient.d.ts +1 -0
  287. package/dist/storage/objectStorageClient.js +41 -8
  288. package/dist/storage/objectStorageClient.test.js +26 -1
  289. package/dist/storage/objectStorageConfig.js +3 -1
  290. package/dist/storage/objectStorageConfig.test.js +13 -1
  291. package/dist/storage/workspaceAssetStore.d.ts +15 -3
  292. package/dist/storage/workspaceAssetStore.js +132 -44
  293. package/dist/storage/workspaceAssetStore.test.js +93 -6
  294. package/dist/styles/fonts.css +70 -0
  295. package/dist/sync/collaborationSyncPayload.d.ts +6 -1
  296. package/dist/sync/collaborationSyncPayload.js +19 -11
  297. package/dist/sync/collaborationSyncPayload.test.js +14 -1
  298. package/dist/sync/collaborationSyncState.d.ts +3 -3
  299. package/dist/sync/collaborationSyncState.js +19 -12
  300. package/dist/sync/contentSyncPayload.d.ts +5 -1
  301. package/dist/sync/contentSyncPayload.js +9 -0
  302. package/dist/sync/contentSyncPayload.test.js +39 -0
  303. package/dist/sync/contentSyncState.d.ts +9 -9
  304. package/dist/sync/contentSyncState.js +248 -107
  305. package/dist/sync/planningSyncPayload.js +5 -0
  306. package/dist/sync/planningSyncPayload.test.js +24 -0
  307. package/dist/sync/syncApplyHandlers.d.ts +11 -4
  308. package/dist/sync/syncApplyHandlers.js +115 -42
  309. package/dist/sync/syncApplyHandlers.test.js +251 -4
  310. package/dist/sync/syncFieldSemantics.d.ts +12 -0
  311. package/dist/sync/syncFieldSemantics.js +55 -0
  312. package/dist/sync/syncResourceAdapters.d.ts +46 -0
  313. package/dist/sync/syncResourceAdapters.js +79 -0
  314. package/dist/sync/syncService.d.ts +6 -0
  315. package/dist/sync/syncService.js +65 -1
  316. package/dist/sync/syncService.test.js +96 -3
  317. package/dist/sync/taskSyncChangeKey.d.ts +2 -0
  318. package/dist/sync/taskSyncChangeKey.js +143 -0
  319. package/dist/sync/taskSyncPayload.js +3 -8
  320. package/dist/sync/workspacePullFeed.d.ts +4 -0
  321. package/dist/sync/workspacePullFeed.js +132 -72
  322. package/dist/sync/workspacePullFeed.test.js +92 -0
  323. package/dist/sync/workspaceRepair.js +5 -2
  324. package/dist/sync/workspaceSyncModel.d.ts +73 -6
  325. package/dist/sync/workspaceSyncModel.js +342 -73
  326. package/dist/sync/workspaceSyncModel.test.js +559 -14
  327. package/dist/sync/workspaceSyncState.d.ts +17 -0
  328. package/dist/sync/workspaceSyncState.js +2 -1
  329. package/dist/sync/workspaceSyncSurface.js +3 -8
  330. package/dist/types.d.ts +50 -2
  331. package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
  332. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
  333. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  334. package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
  335. package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
  336. package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
  337. package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
  338. package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
  339. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  340. package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
  341. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  342. package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
  343. package/dist/ui/assets/{InitiativesModule-Dhm7M8e7.js → InitiativesModule-D4XseTwW.js} +1 -1
  344. package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
  345. package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
  346. package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
  347. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
  348. package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
  349. package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
  350. package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
  351. package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
  352. package/dist/ui/assets/{WorkflowsModule-BcS4afRn.js → WorkflowsModule-oOA9bcdK.js} +4 -4
  353. package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
  354. package/dist/ui/assets/index-607WPo_X.js +4 -0
  355. package/dist/ui/assets/index-CYyodXsg.css +1 -0
  356. package/dist/ui/assets/{vendor-icons-BSUaRwF8.js → vendor-icons-B32hGuF2.js} +1 -1
  357. package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
  358. package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
  359. package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
  360. package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  361. package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
  362. package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
  363. package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
  364. package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
  365. package/dist/ui/fonts/Noir_medium.otf +0 -0
  366. package/dist/ui/fonts/Noir_medium.woff2 +0 -0
  367. package/dist/ui/fonts/Noir_regular.otf +0 -0
  368. package/dist/ui/fonts/Noir_regular.woff2 +0 -0
  369. package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
  370. package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
  371. package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
  372. package/dist/ui/index.html +3 -3
  373. package/dist/ui/product/agent manager.png +0 -0
  374. package/dist/ui/product/agent_manager_02.png +0 -0
  375. package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
  376. package/dist/utils/aiProfileEvents.d.ts +7 -0
  377. package/dist/utils/aiProfileEvents.js +6 -0
  378. package/dist/utils/assignees.d.ts +8 -0
  379. package/dist/utils/assignees.js +10 -1
  380. package/dist/utils/assignees.test.d.ts +1 -0
  381. package/dist/utils/assignees.test.js +25 -0
  382. package/dist/utils/avatarDisplay.d.ts +1 -0
  383. package/dist/utils/avatarDisplay.js +15 -0
  384. package/dist/utils/avatarUpload.d.ts +27 -0
  385. package/dist/utils/avatarUpload.js +132 -0
  386. package/dist/utils/avatarUpload.test.d.ts +1 -0
  387. package/dist/utils/avatarUpload.test.js +40 -0
  388. package/dist/utils/bootstrapTrace.d.ts +7 -0
  389. package/dist/utils/bootstrapTrace.js +29 -0
  390. package/dist/utils/commercialLifecyclePresentation.js +3 -0
  391. package/dist/utils/contextAssetEvents.d.ts +2 -0
  392. package/dist/utils/contextFiles.d.ts +3 -1
  393. package/dist/utils/contextFiles.js +2 -1
  394. package/dist/utils/syncEventDetails.d.ts +3 -0
  395. package/dist/utils/syncEventDetails.js +64 -0
  396. package/dist/utils/syncEventDetails.test.d.ts +1 -0
  397. package/dist/utils/syncEventDetails.test.js +23 -0
  398. package/dist/utils/syncEventPresentation.d.ts +15 -0
  399. package/dist/utils/syncEventPresentation.js +129 -0
  400. package/dist/utils/syncEventPresentation.test.d.ts +1 -0
  401. package/dist/utils/syncEventPresentation.test.js +64 -0
  402. package/dist/utils/syncStatusPresentation.d.ts +72 -0
  403. package/dist/utils/syncStatusPresentation.js +217 -0
  404. package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
  405. package/dist/utils/syncStatusPresentation.test.js +164 -0
  406. package/dist/utils/taskActivity.d.ts +9 -1
  407. package/dist/utils/taskActivity.js +290 -22
  408. package/dist/utils/taskActivity.test.js +141 -8
  409. package/dist/utils/taskSearch.d.ts +22 -0
  410. package/dist/utils/taskSearch.js +87 -0
  411. package/dist/utils/taskSearch.test.d.ts +1 -0
  412. package/dist/utils/taskSearch.test.js +69 -0
  413. package/dist/utils/taskUtils.js +10 -3
  414. package/dist/utils/taskUtils.test.d.ts +1 -0
  415. package/dist/utils/taskUtils.test.js +19 -0
  416. package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
  417. package/dist/utils/workspaceSyncPresentation.js +68 -2
  418. package/dist/utils/workspaceSyncPresentation.test.js +36 -1
  419. package/package.json +3 -2
  420. package/scripts/export-sqlite-to-postgres.mjs +7 -9
  421. package/scripts/run-e2e-realtime.sh +17 -3
  422. package/scripts/run-smoke.sh +17 -5
  423. package/scripts/run-soak.sh +17 -5
  424. package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  425. package/src/resources/templates/workflows/collaborate.yaml +1 -1
  426. package/src/resources/templates/workflows/evaluate.yaml +2 -2
  427. package/src/resources/templates/workflows/plan.yaml +1 -1
  428. package/src/resources/templates/workflows/review.yaml +2 -2
  429. package/dist/ui/assets/AgentsModule-N2MnQBZk.js +0 -1
  430. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BG6P_GVW.js +0 -3
  431. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  432. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +0 -1
  433. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +0 -252
  434. package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
  435. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +0 -1
  436. package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
  437. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +0 -9
  438. package/dist/ui/assets/index-C2-OXZV7.css +0 -1
  439. package/dist/ui/assets/index-DcSI5F86.js +0 -6
  440. package/scripts/migrate-planning-model.ts +0 -233
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createRoutes, matchRoute, parseJsonBody } from './routes';
3
- import { createSessionTokenCookie } from './auth';
3
+ import { createSessionTokenCookie, resolveRequestAccess } from './auth';
4
4
  import { TaskforceCore } from '../core/Taskforce';
5
5
  import { Readable, Writable } from 'stream';
6
6
  import * as fs from 'fs';
@@ -102,6 +102,11 @@ vi.mock('../core/Taskforce', () => {
102
102
  });
103
103
  listInitiatives = vi.fn().mockReturnValue([]);
104
104
  listWorkstreams = vi.fn().mockReturnValue([]);
105
+ listPlanningBootstrap = vi.fn().mockReturnValue({
106
+ initiatives: [],
107
+ workstreams: [],
108
+ workstreamTaskSummaries: []
109
+ });
105
110
  getInitiativeTemplates = vi.fn();
106
111
  createInitiativeFromTemplate = vi.fn();
107
112
  getCategories = vi.fn();
@@ -133,6 +138,7 @@ vi.mock('../core/Taskforce', () => {
133
138
  listMcpAccessTokens = vi.fn().mockReturnValue({ items: [], total: 0, page: 1, pageSize: 20 });
134
139
  createMcpAccessToken = vi.fn();
135
140
  revokeMcpAccessToken = vi.fn();
141
+ deleteMcpAccessToken = vi.fn();
136
142
  regenerateMcpAccessToken = vi.fn();
137
143
  inspectMcpAccessToken = vi.fn();
138
144
  authenticateMcpAccessToken = vi.fn();
@@ -156,12 +162,17 @@ vi.mock('../core/Taskforce', () => {
156
162
  listWorkspaceUsers = vi.fn().mockReturnValue([]);
157
163
  listAssignableActors = vi.fn();
158
164
  listAiProfiles = vi.fn().mockReturnValue([]);
165
+ countAiProfileUsage = vi.fn().mockReturnValue({ used: 0, limit: null, remaining: null });
166
+ mergeAiProfiles = vi.fn();
167
+ archiveAiProfile = vi.fn();
168
+ updateAiProfileAvatar = vi.fn();
159
169
  replaceWorkspaceUsersForSync = vi.fn().mockReturnValue([]);
160
170
  listDocumentReviewSessionsForSync = vi.fn().mockReturnValue([]);
161
171
  listDocumentReviewCommentsForSync = vi.fn().mockReturnValue([]);
162
172
  listAnnotatedAttachmentSessionsForSync = vi.fn().mockReturnValue([]);
163
173
  listTaskEventsForSync = vi.fn().mockReturnValue([]);
164
174
  listTaskEvents = vi.fn().mockReturnValue([]);
175
+ recordTaskEvent = vi.fn();
165
176
  getDocumentReviewSession = vi.fn().mockReturnValue(null);
166
177
  upsertAiProfileForSync = vi.fn();
167
178
  upsertDocumentReviewSessionForSync = vi.fn();
@@ -235,6 +246,7 @@ vi.mock('../core/Taskforce', () => {
235
246
  updatedAt: '2026-01-01T00:00:00.000Z'
236
247
  }
237
248
  ]);
249
+ countSystemUsers = vi.fn().mockReturnValue(1);
238
250
  getSystemRole = vi.fn().mockReturnValue('user');
239
251
  getUserBetaAccess = vi.fn().mockReturnValue(true);
240
252
  setSystemRole = vi.fn().mockReturnValue(true);
@@ -411,7 +423,7 @@ vi.mock('../core/Taskforce', () => {
411
423
  }
412
424
  },
413
425
  {
414
- featureKey: 'workspace.switching',
426
+ featureKey: 'workspace.workspaces',
415
427
  label: 'Workspaces',
416
428
  description: 'Allow multiple workspaces with a configurable workspace limit.',
417
429
  allowedAccessModes: ['enabled', 'disabled'],
@@ -624,6 +636,12 @@ describe('Server Routes', () => {
624
636
  if (typeof body === 'string') {
625
637
  req.push(body);
626
638
  }
639
+ else if (Buffer.isBuffer(body)) {
640
+ req.push(body);
641
+ }
642
+ else if (body instanceof Uint8Array) {
643
+ req.push(Buffer.from(body));
644
+ }
627
645
  else {
628
646
  req.push(body ? JSON.stringify(body) : null);
629
647
  }
@@ -700,6 +718,17 @@ describe('Server Routes', () => {
700
718
  process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = previous;
701
719
  }
702
720
  };
721
+ const createCloudAuthConfig = () => ({
722
+ runtimeMode: 'cloud',
723
+ enabled: true,
724
+ requiredForApi: true,
725
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
726
+ defaultRole: 'member',
727
+ defaultWorkspaceId: 'default',
728
+ sessionCookieName: 'taskforce_session',
729
+ sessionSecret: 'test-secret',
730
+ sessionTtlSeconds: 3600
731
+ });
703
732
  it('GET /api/taskforce/tasks should return tasks', async () => {
704
733
  const mockTasks = { tasks: [], config: {} };
705
734
  core.listTasks.mockReturnValue(mockTasks);
@@ -711,6 +740,30 @@ describe('Server Routes', () => {
711
740
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
712
741
  expect(res.end).toHaveBeenCalledWith(JSON.stringify(mockTasks));
713
742
  });
743
+ it('GET /api/taskforce/planning/bootstrap should return lightweight planning bootstrap data', async () => {
744
+ const payload = {
745
+ initiatives: [{ id: 'initiative-1', title: 'Launch', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
746
+ workstreams: [{ id: 'workstream-1', title: 'Billing', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
747
+ workstreamTaskSummaries: [{
748
+ workstreamId: 'workstream-1',
749
+ taskCount: 2,
750
+ completedTaskCount: 1,
751
+ tasks: [
752
+ { id: 'task-1', title: 'Draft copy', referenceNumber: 12, status: 'task' },
753
+ { id: 'task-2', title: 'Review copy', referenceNumber: 13, status: 'done' }
754
+ ]
755
+ }]
756
+ };
757
+ core.listPlanningBootstrap.mockReturnValue(payload);
758
+ const match = matchRoute('GET', '/api/taskforce/planning/bootstrap', routes);
759
+ const req = createMockReq('GET', '/api/taskforce/planning/bootstrap');
760
+ const res = createMockRes();
761
+ await match?.handler(req, res, {});
762
+ expect(core.listPlanningBootstrap).toHaveBeenCalledWith('default', expect.any(Object));
763
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('planning-bootstrap'));
764
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
765
+ expect(res.end).toHaveBeenCalledWith(JSON.stringify(payload));
766
+ });
714
767
  it('GET /mcp should return MCP JSON error instead of falling through when unauthenticated in cloud runtime', async () => {
715
768
  const match = matchRoute('GET', '/mcp', routes);
716
769
  const req = createMockReq('GET', '/mcp', undefined, {
@@ -779,13 +832,15 @@ describe('Server Routes', () => {
779
832
  }));
780
833
  expect(res.end).toHaveBeenCalledWith();
781
834
  });
782
- it('POST /mcp should accept authenticated initialize requests and record token usage', async () => {
835
+ it('POST /mcp should accept authenticated initialize requests, record token usage, and close the request runtime', async () => {
836
+ const serverClose = vi.fn().mockResolvedValue(undefined);
783
837
  const createServerSpy = vi.spyOn(mcpRuntime, 'createTaskforceMcpServer').mockReturnValue({
784
838
  server: {
785
839
  connect: vi.fn().mockResolvedValue(undefined),
786
- close: vi.fn().mockResolvedValue(undefined)
840
+ close: serverClose
787
841
  }
788
842
  });
843
+ const closeTransportSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'close').mockResolvedValue(undefined);
789
844
  const handleRequestSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'handleRequest').mockImplementation(async (_req, res, parsedBody) => {
790
845
  res.writeHead(200, { 'Content-Type': 'application/json' });
791
846
  res.end(JSON.stringify({
@@ -828,6 +883,8 @@ describe('Server Routes', () => {
828
883
  expect(match).not.toBeNull();
829
884
  await match.handler(req, res, match.params);
830
885
  expect(core.authenticateMcpAccessToken).toHaveBeenCalledWith('tfmcp_test_token');
886
+ const runtimeOptions = createServerSpy.mock.calls[0]?.[0];
887
+ expect(runtimeOptions?.core).toBe(core);
831
888
  expect(createServerSpy).toHaveBeenCalledWith(expect.objectContaining({
832
889
  workspaceId: 'workspace-1',
833
890
  runtimeMode: 'cloud',
@@ -857,6 +914,62 @@ describe('Server Routes', () => {
857
914
  })
858
915
  })
859
916
  }));
917
+ expect(closeTransportSpy).not.toHaveBeenCalled();
918
+ expect(serverClose).toHaveBeenCalledTimes(1);
919
+ closeTransportSpy.mockRestore();
920
+ handleRequestSpy.mockRestore();
921
+ createServerSpy.mockRestore();
922
+ });
923
+ it('POST /mcp should distinguish transport failures from tool backend failures', async () => {
924
+ const serverClose = vi.fn().mockResolvedValue(undefined);
925
+ const createServerSpy = vi.spyOn(mcpRuntime, 'createTaskforceMcpServer').mockReturnValue({
926
+ server: {
927
+ connect: vi.fn().mockResolvedValue(undefined),
928
+ close: serverClose
929
+ }
930
+ });
931
+ const handleRequestSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'handleRequest').mockRejectedValue(new Error('transport broke'));
932
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
933
+ core.authenticateMcpAccessToken.mockReturnValue({
934
+ tokenId: 'token-transport-1',
935
+ userId: 'user-1',
936
+ workspaceId: 'workspace-1',
937
+ scopes: ['tasks:read', 'workspace:read'],
938
+ status: 'active'
939
+ });
940
+ const match = matchRoute('POST', '/mcp', routes);
941
+ const req = createMockReq('POST', '/mcp', {
942
+ jsonrpc: '2.0',
943
+ id: 2,
944
+ method: 'tools/call',
945
+ params: {
946
+ name: 'get_workstream',
947
+ arguments: { id: 'WS-1' }
948
+ }
949
+ }, {
950
+ 'x-taskforce-runtime-mode': 'cloud',
951
+ authorization: 'Bearer tfmcp_transport_token',
952
+ accept: 'application/json, text/event-stream',
953
+ 'content-type': 'application/json',
954
+ 'x-request-id': 'req-mcp-transport'
955
+ });
956
+ const res = createMockRes();
957
+ expect(match).not.toBeNull();
958
+ await match.handler(req, res, match.params);
959
+ expect(res.statusCode).toBe(500);
960
+ expect(JSON.parse(res.__getBody())).toEqual(expect.objectContaining({
961
+ jsonrpc: '2.0',
962
+ error: expect.objectContaining({
963
+ message: 'MCP transport failure.',
964
+ data: expect.objectContaining({
965
+ kind: 'tool_transport_failure',
966
+ retryable: true,
967
+ requestId: 'req-mcp-transport'
968
+ })
969
+ })
970
+ }));
971
+ expect(serverClose).toHaveBeenCalledTimes(1);
972
+ consoleErrorSpy.mockRestore();
860
973
  handleRequestSpy.mockRestore();
861
974
  createServerSpy.mockRestore();
862
975
  });
@@ -1040,7 +1153,10 @@ describe('Server Routes', () => {
1040
1153
  clientId: 'client-1',
1041
1154
  provider: 'chatgpt',
1042
1155
  scopes: ['tasks:read'],
1043
- status: 'active'
1156
+ status: 'active',
1157
+ aiProfileId: 'ai-profile-1',
1158
+ aiProfileToken: 'tfp_connector_codex',
1159
+ aiProfileName: 'Codex Connector'
1044
1160
  });
1045
1161
  const match = matchRoute('POST', '/mcp', routes);
1046
1162
  const req = createMockReq('POST', '/mcp', {
@@ -1068,7 +1184,9 @@ describe('Server Routes', () => {
1068
1184
  scopes: ['tasks:read'],
1069
1185
  actorType: 'oauth',
1070
1186
  tokenId: 'grant-1',
1071
- userId: 'user-oauth'
1187
+ userId: 'user-oauth',
1188
+ aiProfileToken: 'tfp_connector_codex',
1189
+ aiProfileName: 'Codex Connector'
1072
1190
  })
1073
1191
  }));
1074
1192
  handleRequestSpy.mockRestore();
@@ -1522,6 +1640,43 @@ describe('Server Routes', () => {
1522
1640
  items: expect.any(Array)
1523
1641
  }));
1524
1642
  });
1643
+ it('DELETE /api/taskforce/settings/mcp/tokens/:id should delete an inactive token for the signed-in user', async () => {
1644
+ const authConfig = {
1645
+ runtimeMode: 'cloud',
1646
+ enabled: true,
1647
+ requiredForApi: true,
1648
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
1649
+ defaultRole: 'member',
1650
+ defaultWorkspaceId: 'workspace-1',
1651
+ sessionCookieName: 'taskforce_session',
1652
+ sessionSecret: 'test-secret',
1653
+ sessionTtlSeconds: 3600
1654
+ };
1655
+ routes = createRoutes(core, { authConfig });
1656
+ core.deleteMcpAccessToken.mockReturnValue(true);
1657
+ const cookie = createSessionTokenCookie('session-mcp-token-delete', authConfig, {
1658
+ role: 'member',
1659
+ workspaceId: 'workspace-1',
1660
+ userId: 'user-1'
1661
+ });
1662
+ const match = matchRoute('DELETE', '/api/taskforce/settings/mcp/tokens/token-1', routes);
1663
+ const req = createMockReq('DELETE', '/api/taskforce/settings/mcp/tokens/token-1', undefined, {
1664
+ cookie,
1665
+ 'x-taskforce-runtime-mode': 'cloud'
1666
+ });
1667
+ const res = createMockRes();
1668
+ expect(match).not.toBeNull();
1669
+ await match.handler(req, res, { id: 'token-1' });
1670
+ expect(core.deleteMcpAccessToken).toHaveBeenCalledWith({
1671
+ actorUserId: 'user-1',
1672
+ workspaceId: 'workspace-1',
1673
+ tokenId: 'token-1'
1674
+ });
1675
+ expect(JSON.parse(res.__getBody())).toEqual({
1676
+ success: true,
1677
+ deleted: true
1678
+ });
1679
+ });
1525
1680
  it('POST /api/taskforce/global-settings should update global settings', async () => {
1526
1681
  const payload = { theme: 'light', shortcut: 'Ctrl+Shift+L' };
1527
1682
  const match = matchRoute('POST', '/api/taskforce/global-settings', routes);
@@ -1589,6 +1744,49 @@ describe('Server Routes', () => {
1589
1744
  });
1590
1745
  expect(getAfterRes.end).toHaveBeenCalledWith(expect.stringContaining('"sourceOfTruth":"cloud"'));
1591
1746
  });
1747
+ it('POST /api/taskforce/ui-state should accept large workspace-sync baselines', async () => {
1748
+ const match = matchRoute('POST', '/api/taskforce/ui-state', routes);
1749
+ const largeWatermark = 'x'.repeat(1024 * 1024 + 1);
1750
+ const req = createMockReq('POST', '/api/taskforce/ui-state', {
1751
+ stateKey: 'workspace-sync',
1752
+ workspaceId: 'workspace-large-baseline',
1753
+ patch: {
1754
+ version: 2,
1755
+ enabled: true,
1756
+ phase: 'active',
1757
+ pushBaseline: {
1758
+ taskIds: ['task-1'],
1759
+ taskWatermarks: [['task-1', largeWatermark]],
1760
+ taskChangeKeys: [],
1761
+ initiativeWatermarks: [],
1762
+ workstreamWatermarks: [],
1763
+ aiProfileWatermarks: [],
1764
+ aiProfileChangeKeys: [],
1765
+ documentWatermarks: [],
1766
+ assetWatermarks: [],
1767
+ documentReviewSessionWatermarks: [],
1768
+ documentReviewCommentWatermarks: [],
1769
+ annotatedAttachmentSessionWatermarks: [],
1770
+ taskEventWatermarks: [],
1771
+ taxonomyStateFingerprint: ''
1772
+ }
1773
+ }
1774
+ }, {
1775
+ 'x-taskforce-runtime-mode': 'local',
1776
+ 'x-taskforce-workspace-id': 'workspace-current'
1777
+ });
1778
+ const res = createMockRes();
1779
+ await match?.handler(req, res, {});
1780
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
1781
+ expect(core.updateUiState).toHaveBeenCalledWith('workspace-large-baseline', 'anonymous', {
1782
+ stateKey: 'workspace-sync',
1783
+ patch: expect.objectContaining({
1784
+ pushBaseline: expect.objectContaining({
1785
+ taskWatermarks: [['task-1', largeWatermark]]
1786
+ })
1787
+ })
1788
+ });
1789
+ });
1592
1790
  it('GET /api/taskforce/ui-state should ignore query workspace overrides in cloud mode', async () => {
1593
1791
  core.getUiState.mockReturnValue({ activeWorkspaceModule: 'tasks' });
1594
1792
  const match = matchRoute('GET', '/api/taskforce/ui-state', routes);
@@ -2157,6 +2355,140 @@ describe('Server Routes', () => {
2157
2355
  expect(fetchMock).not.toHaveBeenCalled();
2158
2356
  vi.unstubAllGlobals();
2159
2357
  });
2358
+ it('GET /api/taskforce/context/:filename should proxy R2 user avatars inline instead of redirecting', async () => {
2359
+ const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
2360
+ status: 200,
2361
+ headers: { 'Content-Type': 'image/webp' }
2362
+ }));
2363
+ vi.stubGlobal('fetch', fetchMock);
2364
+ const routesWithStorage = createRoutes(core, {
2365
+ objectStorage: {
2366
+ provider: 'r2',
2367
+ r2: {
2368
+ accountId: 'acct-1',
2369
+ bucket: 'taskforce-docs',
2370
+ endpoint: 'https://example.r2.cloudflarestorage.com',
2371
+ accessKeyId: 'access-key-1',
2372
+ secretAccessKey: 'secret-key-1',
2373
+ keyPrefix: 'taskforce',
2374
+ signedUploadTtlSeconds: 600,
2375
+ signedDownloadTtlSeconds: 300
2376
+ }
2377
+ }
2378
+ });
2379
+ const encoded = encodeURIComponent('users/user-1/profile/avatar/avatar-draft-1-headshot.webp');
2380
+ const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routesWithStorage);
2381
+ const req = createMockReq('GET', `/api/taskforce/context/${encoded}`);
2382
+ const res = createMockRes();
2383
+ await match?.handler(req, res, { filename: encoded });
2384
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
2385
+ 'Content-Type': 'image/webp',
2386
+ 'Content-Disposition': 'inline'
2387
+ }));
2388
+ expect(String(res.getHeader('location') || '')).toBe('');
2389
+ expect(res.__getBody()).toBe('avatar-bytes');
2390
+ expect(String(fetchMock.mock.calls[0][0])).toContain('https://example.r2.cloudflarestorage.com/taskforce-docs/taskforce/users/user-1/profile/avatar/avatar-draft-1-headshot.webp');
2391
+ vi.unstubAllGlobals();
2392
+ });
2393
+ it('GET /api/taskforce/context/:filename should serve synced local AI avatars when R2 object is missing', async () => {
2394
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-ai-avatar-r2-fallback-'));
2395
+ const projectRoot = fs.realpathSync(tmpRoot);
2396
+ const basePath = path.join(projectRoot, '.taskforce');
2397
+ const relativePath = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
2398
+ const absolutePath = path.join(basePath, relativePath);
2399
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
2400
+ fs.writeFileSync(absolutePath, Buffer.from('synced-ai-avatar'));
2401
+ core.getPaths.mockReturnValue({ projectRoot, basePath });
2402
+ const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 404 }));
2403
+ vi.stubGlobal('fetch', fetchMock);
2404
+ try {
2405
+ const routesWithStorage = createRoutes(core, {
2406
+ objectStorage: {
2407
+ provider: 'r2',
2408
+ r2: {
2409
+ accountId: 'acct-1',
2410
+ bucket: 'taskforce-docs',
2411
+ endpoint: 'https://example.r2.cloudflarestorage.com',
2412
+ accessKeyId: 'access-key-1',
2413
+ secretAccessKey: 'secret-key-1',
2414
+ keyPrefix: 'taskforce',
2415
+ signedUploadTtlSeconds: 600,
2416
+ signedDownloadTtlSeconds: 300
2417
+ }
2418
+ }
2419
+ });
2420
+ const encoded = encodeURIComponent(relativePath);
2421
+ const req = createMockReq('GET', `/api/taskforce/context/${encoded}`);
2422
+ const res = createMockRes();
2423
+ await matchRoute('GET', `/api/taskforce/context/${encoded}`, routesWithStorage)?.handler(req, res, { filename: encoded });
2424
+ expect(fetchMock).toHaveBeenCalled();
2425
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
2426
+ 'Content-Type': 'image/webp',
2427
+ 'Content-Disposition': 'inline'
2428
+ }));
2429
+ expect(String(res.getHeader('location') || '')).toBe('');
2430
+ expect(res.__getBody()).toBe('synced-ai-avatar');
2431
+ }
2432
+ finally {
2433
+ vi.unstubAllGlobals();
2434
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
2435
+ }
2436
+ });
2437
+ it('GET /api/taskforce/context/:filename should proxy missing cloud-owned user avatars in local runtime', async () => {
2438
+ const previousCloudProxyBaseUrl = process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
2439
+ const previousClientId = process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
2440
+ const previousClientSecret = process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
2441
+ process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = 'https://performance-app.taskforcehq.ai';
2442
+ process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = 'cf-client-id';
2443
+ process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = 'cf-client-secret';
2444
+ const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
2445
+ status: 200,
2446
+ headers: {
2447
+ 'Content-Type': 'image/webp',
2448
+ 'Cache-Control': 'private, max-age=60'
2449
+ }
2450
+ }));
2451
+ vi.stubGlobal('fetch', fetchMock);
2452
+ try {
2453
+ const relativePath = 'users/user-1/profile/avatar/avatar-draft-1-headshot.webp';
2454
+ const encoded = encodeURIComponent(relativePath);
2455
+ const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routes);
2456
+ const req = createMockReq('GET', `/api/taskforce/context/${encoded}`, undefined, {
2457
+ host: 'localhost:5174',
2458
+ cookie: 'taskforce_session=session-1',
2459
+ accept: 'image/webp'
2460
+ });
2461
+ const res = createMockRes();
2462
+ await match?.handler(req, res, { filename: encoded });
2463
+ expect(fetchMock).toHaveBeenCalledWith(`https://performance-app.taskforcehq.ai/api/taskforce/context/${encoded}`, expect.objectContaining({
2464
+ method: 'GET',
2465
+ headers: expect.any(Headers)
2466
+ }));
2467
+ const proxiedHeaders = fetchMock.mock.calls[0][1].headers;
2468
+ expect(proxiedHeaders.get('cookie')).toBe('taskforce_session=session-1');
2469
+ expect(proxiedHeaders.get('CF-Access-Client-Id')).toBe('cf-client-id');
2470
+ expect(proxiedHeaders.get('CF-Access-Client-Secret')).toBe('cf-client-secret');
2471
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
2472
+ 'content-type': 'image/webp'
2473
+ }));
2474
+ expect(res.__getBody()).toBe('avatar-bytes');
2475
+ }
2476
+ finally {
2477
+ vi.unstubAllGlobals();
2478
+ if (previousCloudProxyBaseUrl === undefined)
2479
+ delete process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
2480
+ else
2481
+ process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = previousCloudProxyBaseUrl;
2482
+ if (previousClientId === undefined)
2483
+ delete process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
2484
+ else
2485
+ process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = previousClientId;
2486
+ if (previousClientSecret === undefined)
2487
+ delete process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
2488
+ else
2489
+ process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = previousClientSecret;
2490
+ }
2491
+ });
2160
2492
  it('GET /api/taskforce/context/:filename should proxy inline R2 markdown documents instead of redirecting', async () => {
2161
2493
  const fetchMock = vi.fn().mockResolvedValue({
2162
2494
  status: 200,
@@ -3075,6 +3407,53 @@ describe('Server Routes', () => {
3075
3407
  vi.unstubAllGlobals();
3076
3408
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3077
3409
  });
3410
+ it('POST /api/taskforce/context-upload/finalize should record direct AI avatar uploads instead of fast-finalizing', async () => {
3411
+ const fetchMock = vi.fn().mockResolvedValue({
3412
+ ok: true,
3413
+ status: 200,
3414
+ statusText: 'OK',
3415
+ headers: { get: () => 'image/webp' },
3416
+ arrayBuffer: async () => new TextEncoder().encode('avatar-bytes').buffer
3417
+ });
3418
+ vi.stubGlobal('fetch', fetchMock);
3419
+ const routesWithStorage = createRoutes(core, {
3420
+ objectStorage: {
3421
+ provider: 'r2',
3422
+ r2: {
3423
+ accountId: 'acct-1',
3424
+ bucket: 'taskforce-docs',
3425
+ endpoint: 'https://example.r2.cloudflarestorage.com',
3426
+ accessKeyId: 'access-key-1',
3427
+ secretAccessKey: 'secret-key-1',
3428
+ keyPrefix: 'taskforce',
3429
+ signedUploadTtlSeconds: 600,
3430
+ signedDownloadTtlSeconds: 300
3431
+ }
3432
+ }
3433
+ });
3434
+ const relativePath = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-avatar.webp';
3435
+ const match = matchRoute('POST', '/api/taskforce/context-upload/finalize', routesWithStorage);
3436
+ const req = createMockReq('POST', '/api/taskforce/context-upload/finalize', {
3437
+ relativePath,
3438
+ mimeType: 'image/webp',
3439
+ originalName: 'avatar.webp',
3440
+ fastFinalize: true
3441
+ }, {
3442
+ 'x-taskforce-workspace-id': 'workspace-1'
3443
+ });
3444
+ const res = createMockRes();
3445
+ await match?.handler(req, res, {});
3446
+ expect(fetchMock).toHaveBeenCalled();
3447
+ expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
3448
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
3449
+ expect(payload).toEqual(expect.objectContaining({
3450
+ success: true,
3451
+ fsPath: relativePath,
3452
+ path: `/api/taskforce/context/${encodeURIComponent(relativePath)}`
3453
+ }));
3454
+ expect(payload.mode).not.toBe('direct-fast');
3455
+ vi.unstubAllGlobals();
3456
+ });
3078
3457
  it('POST /api/taskforce/context-upload/finalize should mirror upload metadata into workspace_assets', async () => {
3079
3458
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-context-finalize-canonical-'));
3080
3459
  core.getPaths.mockReturnValue({
@@ -3108,8 +3487,21 @@ describe('Server Routes', () => {
3108
3487
  }
3109
3488
  });
3110
3489
  const match = matchRoute('POST', '/api/taskforce/context-upload/finalize', routesWithStorage);
3490
+ const attachmentPath = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Ffiles%2Fasset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf';
3491
+ const attachmentFsPath = 'workspaces/workspace-1/assets/files/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf';
3492
+ core.getTask
3493
+ .mockImplementationOnce(() => ({ id: 'task-1', attachments: [] }))
3494
+ .mockImplementationOnce(() => ({
3495
+ id: 'task-1',
3496
+ attachments: [{
3497
+ path: attachmentPath,
3498
+ fsPath: attachmentFsPath,
3499
+ caption: 'spec.pdf',
3500
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
3501
+ }]
3502
+ }));
3111
3503
  const req = createMockReq('POST', '/api/taskforce/context-upload/finalize', {
3112
- relativePath: 'workspaces/workspace-1/assets/files/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf',
3504
+ relativePath: attachmentFsPath,
3113
3505
  mimeType: 'application/pdf',
3114
3506
  taskId: 'task-1',
3115
3507
  originalName: 'spec.pdf'
@@ -3121,8 +3513,8 @@ describe('Server Routes', () => {
3121
3513
  const payload = JSON.parse(res.end.mock.calls[0][0]);
3122
3514
  expect(payload).toEqual(expect.objectContaining({
3123
3515
  success: true,
3124
- path: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Ffiles%2Fasset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf',
3125
- fsPath: 'workspaces/workspace-1/assets/files/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf',
3516
+ path: attachmentPath,
3517
+ fsPath: attachmentFsPath,
3126
3518
  taskId: 'task-1'
3127
3519
  }));
3128
3520
  const assets = store.listByWorkspace('workspace-1', { includeDeleted: true });
@@ -3136,6 +3528,25 @@ describe('Server Routes', () => {
3136
3528
  expect(store.listLinksForTask('task-1', 'workspace-1')).toEqual([
3137
3529
  expect.objectContaining({ role: 'attachment' })
3138
3530
  ]);
3531
+ expect(core.recordTaskEvent).toHaveBeenCalledWith(expect.objectContaining({
3532
+ taskId: 'task-1',
3533
+ workspaceId: 'workspace-1',
3534
+ action: 'task-attachments-changed',
3535
+ actor: 'user',
3536
+ createdAt: expect.any(String),
3537
+ details: {
3538
+ changes: {
3539
+ attachments: {
3540
+ from: [],
3541
+ to: [expect.objectContaining({
3542
+ path: attachmentPath,
3543
+ fsPath: attachmentFsPath,
3544
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
3545
+ })],
3546
+ },
3547
+ },
3548
+ },
3549
+ }));
3139
3550
  vi.unstubAllGlobals();
3140
3551
  fs.rmSync(dbRoot, { recursive: true, force: true });
3141
3552
  fs.rmSync(tmpRoot, { recursive: true, force: true });
@@ -3447,6 +3858,53 @@ describe('Server Routes', () => {
3447
3858
  fs.rmSync(dbRoot, { recursive: true, force: true });
3448
3859
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3449
3860
  });
3861
+ it('GET /api/taskforce/documents should exclude raw .taskforce/docs markdown files from the document manager', async () => {
3862
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-exclude-raw-docs-'));
3863
+ const basePath = path.join(tmpRoot, '.taskforce');
3864
+ fs.mkdirSync(path.join(basePath, 'docs'), { recursive: true });
3865
+ fs.writeFileSync(path.join(basePath, 'docs', 'legacy-plan.md'), '# legacy\n', 'utf8');
3866
+ core.getPaths.mockReturnValue({
3867
+ projectRoot: tmpRoot,
3868
+ basePath,
3869
+ });
3870
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
3871
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
3872
+ core.getTenantId = vi.fn().mockReturnValue('default');
3873
+ store.upsertAsset({
3874
+ assetId: 'asset-doc-local-2',
3875
+ workspaceId: 'default',
3876
+ kind: 'document',
3877
+ originalFilename: 'managed-plan.md',
3878
+ mimeType: 'text/markdown',
3879
+ storageProvider: 'local',
3880
+ storageKey: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
3881
+ contentSha256: 'sha-doc-local-2',
3882
+ sizeBytes: 99,
3883
+ documentId: 'doc-local-2',
3884
+ version: 1,
3885
+ updatedAt: '2026-04-19T12:00:00.000Z'
3886
+ });
3887
+ const routesWithDb = createRoutes(core);
3888
+ const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
3889
+ const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
3890
+ 'x-taskforce-workspace-id': 'default',
3891
+ 'x-taskforce-runtime-mode': 'local'
3892
+ });
3893
+ const res = createMockRes();
3894
+ await match?.handler(req, res, {});
3895
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
3896
+ expect(Array.isArray(payload.documents)).toBe(true);
3897
+ expect(payload.documents).toHaveLength(1);
3898
+ expect(payload.documents[0]).toEqual(expect.objectContaining({
3899
+ id: 'asset-doc-local-2',
3900
+ assetId: 'asset-doc-local-2',
3901
+ fsPath: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
3902
+ attachmentState: 'unattached'
3903
+ }));
3904
+ expect(payload.documents.some((doc) => String(doc.fsPath || '').includes('docs/legacy-plan.md'))).toBe(false);
3905
+ fs.rmSync(dbRoot, { recursive: true, force: true });
3906
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3907
+ });
3450
3908
  it('GET /api/taskforce/documents should mark canonical docs without links as unattached', async () => {
3451
3909
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-unattached-local-'));
3452
3910
  core.getPaths.mockReturnValue({
@@ -3543,8 +4001,8 @@ describe('Server Routes', () => {
3543
4001
  fs.rmSync(dbRoot, { recursive: true, force: true });
3544
4002
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3545
4003
  });
3546
- it('GET /api/taskforce/documents should normalize readable names and attachment metadata for sparse canonical assets', async () => {
3547
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-normalized-cloud-'));
4004
+ it('GET /api/taskforce/documents should count non-task links in attachment metadata while only surfacing task titles for task links', async () => {
4005
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-generic-links-'));
3548
4006
  core.getPaths.mockReturnValue({
3549
4007
  projectRoot: tmpRoot,
3550
4008
  basePath: path.join(tmpRoot, '.taskforce'),
@@ -3553,29 +4011,142 @@ describe('Server Routes', () => {
3553
4011
  core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
3554
4012
  core.getTenantId = vi.fn().mockReturnValue('default');
3555
4013
  store.upsertAsset({
3556
- assetId: 'asset-doc-sparse-1',
3557
- workspaceId: 'workspace-1',
4014
+ assetId: 'asset-doc-generic-link-1',
4015
+ workspaceId: 'default',
3558
4016
  kind: 'document',
3559
- logicalName: 'asset-doc-sparse-1',
3560
- originalFilename: 'asset-doc-sparse-1-implementation-plan-1773922162143.md',
4017
+ originalFilename: 'shared-notes.md',
3561
4018
  mimeType: 'text/markdown',
3562
- storageProvider: 'r2',
3563
- storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-sparse-1-implementation-plan-1773922162143.md',
3564
- contentSha256: 'sha-doc-sparse-1',
3565
- sizeBytes: 222,
3566
- documentId: 'doc-sparse-1',
4019
+ storageProvider: 'local',
4020
+ storageKey: 'workspaces/default/assets/documents/asset-doc-generic-link-1-shared-notes.md',
4021
+ contentSha256: 'sha-doc-generic-link-1',
4022
+ sizeBytes: 64,
4023
+ documentId: 'doc-generic-link-1',
3567
4024
  version: 1,
3568
- updatedAt: '2026-03-18T15:00:00.000Z'
4025
+ updatedAt: '2026-03-18T13:30:00.000Z'
3569
4026
  });
3570
4027
  store.upsertLink({
3571
- workspaceId: 'workspace-1',
3572
- assetId: 'asset-doc-sparse-1',
3573
- taskId: 'task-sparse-1',
3574
- role: 'attachment',
3575
- displayName: 'AI Profiles - Implementation Plan v2'
4028
+ workspaceId: 'default',
4029
+ assetId: 'asset-doc-generic-link-1',
4030
+ targetType: 'initiative',
4031
+ targetId: 'initiative-1',
4032
+ role: 'attachment'
3576
4033
  });
3577
- core.getTask.mockImplementation((id) => id === 'task-sparse-1'
3578
- ? { id: 'task-sparse-1', title: 'Task Sparse 1' }
4034
+ const routesWithDb = createRoutes(core);
4035
+ const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
4036
+ const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
4037
+ 'x-taskforce-workspace-id': 'default',
4038
+ 'x-taskforce-runtime-mode': 'local'
4039
+ });
4040
+ const res = createMockRes();
4041
+ await match?.handler(req, res, {});
4042
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
4043
+ expect(payload.documents).toEqual([
4044
+ expect.objectContaining({
4045
+ id: 'asset-doc-generic-link-1',
4046
+ assetId: 'asset-doc-generic-link-1',
4047
+ attachmentCount: 1,
4048
+ attachmentState: 'attached',
4049
+ taskId: null
4050
+ })
4051
+ ]);
4052
+ fs.rmSync(dbRoot, { recursive: true, force: true });
4053
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
4054
+ });
4055
+ it('POST /api/taskforce/documents/:assetId/attach should attach a canonical document to a task target', async () => {
4056
+ vi.useFakeTimers();
4057
+ vi.setSystemTime(new Date('2026-03-18T14:45:00.000Z'));
4058
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-attach-task-'));
4059
+ core.getPaths.mockReturnValue({
4060
+ projectRoot: tmpRoot,
4061
+ basePath: path.join(tmpRoot, '.taskforce'),
4062
+ });
4063
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
4064
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
4065
+ core.getTenantId = vi.fn().mockReturnValue('default');
4066
+ store.upsertAsset({
4067
+ assetId: 'asset-doc-attach-route-1',
4068
+ workspaceId: 'default',
4069
+ kind: 'document',
4070
+ logicalName: 'Attach Me',
4071
+ originalFilename: 'attach-me.md',
4072
+ mimeType: 'text/markdown',
4073
+ storageProvider: 'local',
4074
+ storageKey: 'workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md',
4075
+ contentSha256: 'sha-doc-attach-route-1',
4076
+ sizeBytes: 28,
4077
+ documentId: 'doc-attach-route-1',
4078
+ version: 1,
4079
+ updatedAt: '2026-03-18T13:30:00.000Z'
4080
+ });
4081
+ core.getTask.mockImplementation((id) => id === 'task-attach-1'
4082
+ ? { id: 'task-attach-1', title: 'Attach target', attachments: [] }
4083
+ : null);
4084
+ const routesWithDb = createRoutes(core);
4085
+ const match = matchRoute('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', routesWithDb);
4086
+ const req = createMockReq('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', {
4087
+ targetType: 'task',
4088
+ targetId: 'task-attach-1',
4089
+ }, {
4090
+ 'x-taskforce-workspace-id': 'default'
4091
+ });
4092
+ const res = createMockRes();
4093
+ try {
4094
+ await match?.handler(req, res, { assetId: 'asset-doc-attach-route-1' });
4095
+ }
4096
+ finally {
4097
+ vi.useRealTimers();
4098
+ }
4099
+ expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
4100
+ expect(core.updateTask).toHaveBeenCalledWith('task-attach-1', expect.objectContaining({
4101
+ attachments: [
4102
+ expect.objectContaining({
4103
+ assetId: 'asset-doc-attach-route-1',
4104
+ taskId: 'task-attach-1',
4105
+ path: '/api/taskforce/documents/asset-doc-attach-route-1/content',
4106
+ timestamp: '2026-03-18T14:45:00.000Z',
4107
+ })
4108
+ ]
4109
+ }), 'default', expect.objectContaining({
4110
+ actorRef: 'user',
4111
+ saveSource: 'manual',
4112
+ }));
4113
+ expect(core.notifyDocumentMetadataMutation).toHaveBeenCalledWith('default', ['workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md'], 'upsert');
4114
+ fs.rmSync(dbRoot, { recursive: true, force: true });
4115
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
4116
+ });
4117
+ it('GET /api/taskforce/documents should normalize readable names and attachment metadata for sparse canonical assets', async () => {
4118
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-normalized-cloud-'));
4119
+ core.getPaths.mockReturnValue({
4120
+ projectRoot: tmpRoot,
4121
+ basePath: path.join(tmpRoot, '.taskforce'),
4122
+ });
4123
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
4124
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
4125
+ core.getTenantId = vi.fn().mockReturnValue('default');
4126
+ store.upsertAsset({
4127
+ assetId: 'asset-doc-sparse-1',
4128
+ workspaceId: 'workspace-1',
4129
+ kind: 'document',
4130
+ logicalName: 'asset-doc-sparse-1',
4131
+ originalFilename: 'asset-doc-sparse-1-implementation-plan-1773922162143.md',
4132
+ mimeType: 'text/markdown',
4133
+ storageProvider: 'r2',
4134
+ storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-sparse-1-implementation-plan-1773922162143.md',
4135
+ contentSha256: 'sha-doc-sparse-1',
4136
+ sizeBytes: 222,
4137
+ documentId: 'doc-sparse-1',
4138
+ version: 1,
4139
+ updatedAt: '2026-03-18T15:00:00.000Z'
4140
+ });
4141
+ store.upsertLink({
4142
+ workspaceId: 'workspace-1',
4143
+ assetId: 'asset-doc-sparse-1',
4144
+ taskId: 'task-sparse-1',
4145
+ role: 'attachment',
4146
+ displayName: 'AI Profiles - Implementation Plan v2'
4147
+ });
4148
+ core.getTask.mockImplementation((id) => id === 'task-sparse-1'
4149
+ ? { id: 'task-sparse-1', title: 'Task Sparse 1' }
3579
4150
  : null);
3580
4151
  const routesWithDb = createRoutes(core, {
3581
4152
  authConfig: { runtimeMode: 'cloud' }
@@ -4559,6 +5130,40 @@ describe('Server Routes', () => {
4559
5130
  expect(payload.inviteeKind).toBe('existing_user');
4560
5131
  expect(payload.passwordRequired).toBe(false);
4561
5132
  });
5133
+ it('POST /api/taskforce/auth/invite/accept should preserve the created identity in the session cookie', async () => {
5134
+ const authConfig = {
5135
+ runtimeMode: 'cloud',
5136
+ enabled: true,
5137
+ requiredForApi: true,
5138
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
5139
+ defaultRole: 'member',
5140
+ defaultWorkspaceId: 'default',
5141
+ sessionCookieName: 'taskforce_session',
5142
+ sessionSecret: 'test-secret',
5143
+ sessionTtlSeconds: 3600
5144
+ };
5145
+ routes = createRoutes(core, { authConfig });
5146
+ core.acceptInviteWithToken.mockReturnValueOnce({
5147
+ userId: 'u1',
5148
+ email: 'u1@example.com',
5149
+ workspaceId: 'workspace-1',
5150
+ role: 'member',
5151
+ identityId: 'identity-password-u1'
5152
+ });
5153
+ const match = matchRoute('POST', '/api/taskforce/auth/invite/accept', routes);
5154
+ const req = createMockReq('POST', '/api/taskforce/auth/invite/accept', { token: 'accept-token', password: 'password123' }, {
5155
+ 'x-taskforce-runtime-mode': 'cloud',
5156
+ host: 'localhost'
5157
+ });
5158
+ const res = createMockRes();
5159
+ await match?.handler(req, res, {});
5160
+ const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
5161
+ expect(typeof setCookie).toBe('string');
5162
+ const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
5163
+ const access = resolveRequestAccess(cookieReq, authConfig);
5164
+ expect(access.identityId).toBe('identity-password-u1');
5165
+ expect(access.userId).toBe('u1');
5166
+ });
4562
5167
  it('POST /api/taskforce/auth/invite/accept should return expired code and audit action', async () => {
4563
5168
  const authConfig = {
4564
5169
  runtimeMode: 'cloud',
@@ -4649,7 +5254,8 @@ describe('Server Routes', () => {
4649
5254
  const cookie = createSessionTokenCookie('session-join', authConfig, {
4650
5255
  userId: 'u1',
4651
5256
  role: 'member',
4652
- workspaceId: 'workspace-1'
5257
+ workspaceId: 'workspace-1',
5258
+ identityId: 'identity-u1'
4653
5259
  });
4654
5260
  const match = matchRoute('POST', '/api/taskforce/auth/invite/join', routes);
4655
5261
  const req = createMockReq('POST', '/api/taskforce/auth/invite/join', { token: 'join-token' }, {
@@ -4663,6 +5269,11 @@ describe('Server Routes', () => {
4663
5269
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4664
5270
  const payload = JSON.parse(res.end.mock.calls[0][0]);
4665
5271
  expect(payload.workspaceId).toBe('workspace-1');
5272
+ const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
5273
+ expect(typeof setCookie).toBe('string');
5274
+ const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
5275
+ const access = resolveRequestAccess(cookieReq, authConfig);
5276
+ expect(access.identityId).toBe('identity-u1');
4666
5277
  });
4667
5278
  it('POST /api/taskforce/auth/login should auto-verify bootstrap admin and sign in', async () => {
4668
5279
  const authConfig = {
@@ -4991,6 +5602,7 @@ describe('Server Routes', () => {
4991
5602
  const res = createMockRes();
4992
5603
  await match?.handler(req, res, {});
4993
5604
  expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
5605
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('workspaces'));
4994
5606
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4995
5607
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"workspace-2"'));
4996
5608
  });
@@ -5884,6 +6496,9 @@ describe('Server Routes', () => {
5884
6496
  await match?.handler(req, res, {});
5885
6497
  const payloadRaw = res.end.mock.calls[0]?.[0];
5886
6498
  const payload = JSON.parse(String(payloadRaw || '{}'));
6499
+ expect(payload.knownPaths).toEqual([
6500
+ 'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md'
6501
+ ]);
5887
6502
  expect(payload.documents).toEqual(expect.arrayContaining([
5888
6503
  expect.objectContaining({
5889
6504
  path: 'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md',
@@ -5898,7 +6513,56 @@ describe('Server Routes', () => {
5898
6513
  expect(String(payload.fingerprint || '')).toContain('asset-doc-snapshot-1');
5899
6514
  fs.rmSync(dbRoot, { recursive: true, force: true });
5900
6515
  });
5901
- it('GET /api/taskforce/sync/workspace/documents should include docs folder markdown files without canonical asset metadata', async () => {
6516
+ it('GET /api/taskforce/sync/workspace/documents should retain canonical knownPaths even when content is temporarily unreadable', async () => {
6517
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-known-paths-'));
6518
+ const projectRoot = fs.realpathSync(tmpRoot);
6519
+ const basePath = path.join(tmpRoot, '.taskforce');
6520
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
6521
+ store.upsertAsset({
6522
+ assetId: 'asset-doc-known-path-1',
6523
+ workspaceId: 'workspace-local',
6524
+ kind: 'document',
6525
+ originalFilename: 'missing-content.md',
6526
+ mimeType: 'text/markdown',
6527
+ storageProvider: 'local',
6528
+ storageKey: 'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md',
6529
+ contentSha256: 'missing-content-hash',
6530
+ sizeBytes: 17,
6531
+ documentId: 'doc-known-path-1',
6532
+ version: 1,
6533
+ isLatest: true,
6534
+ createdAt: '2026-03-17T09:00:00.000Z',
6535
+ updatedAt: '2026-03-17T10:00:00.000Z'
6536
+ });
6537
+ store.upsertLink({
6538
+ workspaceId: 'workspace-local',
6539
+ assetId: 'asset-doc-known-path-1',
6540
+ taskId: 'task-1',
6541
+ role: 'attachment'
6542
+ });
6543
+ core.getPaths.mockReturnValue({
6544
+ projectRoot,
6545
+ basePath,
6546
+ });
6547
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
6548
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
6549
+ core.getTenantId = vi.fn().mockReturnValue('default');
6550
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
6551
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/documents', routes);
6552
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/documents?workspaceId=workspace-local', undefined, {
6553
+ 'x-taskforce-workspace-id': 'workspace-local'
6554
+ });
6555
+ const res = createMockRes();
6556
+ await match?.handler(req, res, {});
6557
+ const payloadRaw = res.end.mock.calls[0]?.[0];
6558
+ const payload = JSON.parse(String(payloadRaw || '{}'));
6559
+ expect(payload.documents).toEqual([]);
6560
+ expect(payload.knownPaths).toEqual([
6561
+ 'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md'
6562
+ ]);
6563
+ fs.rmSync(dbRoot, { recursive: true, force: true });
6564
+ });
6565
+ it('GET /api/taskforce/sync/workspace/documents should exclude docs folder markdown files that are not canonical assets', async () => {
5902
6566
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-legacy-ignore-'));
5903
6567
  const projectRoot = fs.realpathSync(tmpRoot);
5904
6568
  const basePath = path.join(tmpRoot, '.taskforce');
@@ -5923,12 +6587,7 @@ describe('Server Routes', () => {
5923
6587
  const payloadRaw = res.end.mock.calls[0]?.[0];
5924
6588
  const payload = JSON.parse(String(payloadRaw || '{}'));
5925
6589
  expect(Array.isArray(payload.documents)).toBe(true);
5926
- expect(payload.documents).toEqual([
5927
- expect.objectContaining({
5928
- path: 'docs/legacy-notes.md',
5929
- content: '# legacy\n'
5930
- })
5931
- ]);
6590
+ expect(payload.documents).toEqual([]);
5932
6591
  fs.rmSync(dbRoot, { recursive: true, force: true });
5933
6592
  });
5934
6593
  it('GET /api/taskforce/sync/workspace/document-reviews should include document review sessions for the requested workspace', async () => {
@@ -6063,6 +6722,58 @@ describe('Server Routes', () => {
6063
6722
  ]));
6064
6723
  fs.rmSync(dbRoot, { recursive: true, force: true });
6065
6724
  });
6725
+ it('GET /api/taskforce/sync/workspace/assets should include AI profile avatar files referenced without canonical metadata', async () => {
6726
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-ai-avatar-assets-'));
6727
+ const projectRoot = fs.realpathSync(tmpRoot);
6728
+ const basePath = path.join(tmpRoot, '.taskforce');
6729
+ const avatarStorageKey = 'workspaces/workspace-local/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
6730
+ const avatarDir = path.join(basePath, 'workspaces/workspace-local/assets/images/ai-profiles/ai-profile-codex/avatar');
6731
+ fs.mkdirSync(avatarDir, { recursive: true });
6732
+ const fileBuffer = Buffer.from('ai-avatar-bytes');
6733
+ fs.writeFileSync(path.join(avatarDir, 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp'), fileBuffer);
6734
+ const { tmpRoot: dbRoot, db } = createWorkspaceAssetTestStore();
6735
+ core.getPaths.mockReturnValue({
6736
+ projectRoot,
6737
+ basePath,
6738
+ });
6739
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
6740
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
6741
+ core.getTenantId = vi.fn().mockReturnValue('default');
6742
+ core.listAiProfiles.mockReturnValue([
6743
+ {
6744
+ id: 'ai-profile-codex',
6745
+ workspaceId: 'workspace-local',
6746
+ name: 'Codex',
6747
+ username: 'codex',
6748
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
6749
+ avatarSourceUrl: null,
6750
+ createdAt: '2026-03-17T09:00:00.000Z',
6751
+ updatedAt: '2026-03-17T10:00:00.000Z'
6752
+ }
6753
+ ]);
6754
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
6755
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/assets', routes);
6756
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/assets?workspaceId=workspace-local', undefined, {
6757
+ 'x-taskforce-workspace-id': 'workspace-local'
6758
+ });
6759
+ const res = createMockRes();
6760
+ await match?.handler(req, res, {});
6761
+ const payloadRaw = res.end.mock.calls[0]?.[0];
6762
+ const payload = JSON.parse(String(payloadRaw || '{}'));
6763
+ expect(payload.knownPaths).toContain(avatarStorageKey);
6764
+ expect(payload.assets).toEqual(expect.arrayContaining([
6765
+ expect.objectContaining({
6766
+ path: avatarStorageKey,
6767
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
6768
+ kind: 'image',
6769
+ mimeType: 'image/webp',
6770
+ originalFilename: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp',
6771
+ contentBase64: fileBuffer.toString('base64')
6772
+ })
6773
+ ]));
6774
+ fs.rmSync(dbRoot, { recursive: true, force: true });
6775
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
6776
+ });
6066
6777
  it('GET /api/taskforce/sync/workspace/assets should ignore malformed external workspace asset rows', async () => {
6067
6778
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-assets-external-'));
6068
6779
  const projectRoot = fs.realpathSync(tmpRoot);
@@ -6729,6 +7440,82 @@ describe('Server Routes', () => {
6729
7440
  ]));
6730
7441
  fs.rmSync(dbRoot, { recursive: true, force: true });
6731
7442
  });
7443
+ it('GET /api/taskforce/sync/workspace/pull should include referenced AI profile avatar assets without canonical metadata', async () => {
7444
+ const authConfig = {
7445
+ runtimeMode: 'cloud',
7446
+ enabled: true,
7447
+ requiredForApi: true,
7448
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7449
+ defaultRole: 'member',
7450
+ defaultWorkspaceId: 'default',
7451
+ sessionCookieName: 'taskforce_session',
7452
+ sessionSecret: 'test-secret',
7453
+ sessionTtlSeconds: 3600
7454
+ };
7455
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-pull-ai-avatar-'));
7456
+ const basePath = path.join(tmpRoot, '.taskforce');
7457
+ const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-modesty/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
7458
+ const avatarPath = path.join(basePath, avatarStorageKey);
7459
+ const avatarBuffer = Buffer.from('ai-avatar-bytes');
7460
+ fs.mkdirSync(path.dirname(avatarPath), { recursive: true });
7461
+ fs.writeFileSync(avatarPath, avatarBuffer);
7462
+ const { tmpRoot: dbRoot, db } = createWorkspaceAssetTestStore();
7463
+ core.getPaths.mockReturnValue({
7464
+ projectRoot: tmpRoot,
7465
+ basePath,
7466
+ });
7467
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
7468
+ core.getTenantId = vi.fn().mockReturnValue('default');
7469
+ core.listAiProfiles.mockReturnValue([
7470
+ {
7471
+ id: 'ai-profile-modesty',
7472
+ workspaceId: 'workspace-1',
7473
+ name: 'Modesty',
7474
+ username: 'modesty',
7475
+ icon: 'Bot',
7476
+ color: '#0ea5e9',
7477
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
7478
+ avatarSourceUrl: null,
7479
+ createdAt: '2026-03-17T09:00:00.000Z',
7480
+ updatedAt: '2026-03-17T10:00:00.000Z'
7481
+ }
7482
+ ]);
7483
+ routes = createRoutes(core, { authConfig });
7484
+ core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7485
+ const cookie = createSessionTokenCookie('session-sync-pull-ai-avatar', authConfig, {
7486
+ role: 'owner',
7487
+ workspaceId: 'workspace-1',
7488
+ userId: 'user-1'
7489
+ });
7490
+ core.listTasks.mockReturnValueOnce({ tasks: [] });
7491
+ core.listArchive.mockReturnValueOnce({ archived: [] });
7492
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7493
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=20', undefined, { cookie });
7494
+ const res = createMockRes();
7495
+ await match?.handler(req, res, {});
7496
+ const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
7497
+ expect(payload.changes).toEqual(expect.arrayContaining([
7498
+ expect.objectContaining({
7499
+ op: 'ai-profile-upsert',
7500
+ profile: expect.objectContaining({
7501
+ id: 'ai-profile-modesty',
7502
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`
7503
+ })
7504
+ }),
7505
+ expect.objectContaining({
7506
+ op: 'asset-upsert',
7507
+ path: avatarStorageKey,
7508
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
7509
+ kind: 'image',
7510
+ mimeType: 'image/webp',
7511
+ originalFilename: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp',
7512
+ linkRole: 'image',
7513
+ contentBase64: avatarBuffer.toString('base64')
7514
+ })
7515
+ ]));
7516
+ fs.rmSync(dbRoot, { recursive: true, force: true });
7517
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
7518
+ });
6732
7519
  it('GET /api/taskforce/sync/workspace/pull should include mixed document and binary attachment upserts for the same task', async () => {
6733
7520
  const authConfig = {
6734
7521
  runtimeMode: 'cloud',
@@ -7086,7 +7873,7 @@ describe('Server Routes', () => {
7086
7873
  task: expect.objectContaining({ id: 'task-b' })
7087
7874
  }));
7088
7875
  });
7089
- it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
7876
+ it('GET /api/taskforce/sync/workspace/pull should reject an invalid sync cursor instead of replaying from the beginning', async () => {
7090
7877
  const authConfig = {
7091
7878
  runtimeMode: 'cloud',
7092
7879
  enabled: true,
@@ -7099,21 +7886,179 @@ describe('Server Routes', () => {
7099
7886
  sessionTtlSeconds: 3600
7100
7887
  };
7101
7888
  routes = createRoutes(core, { authConfig });
7102
- const cookie = createSessionTokenCookie('session-sync-pull-forbidden', authConfig, {
7889
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7890
+ const cookie = createSessionTokenCookie('session-sync-pull-invalid-cursor', authConfig, {
7103
7891
  role: 'owner',
7104
- workspaceId: 'workspace-cloud-selected',
7892
+ workspaceId: 'workspace-1',
7105
7893
  userId: 'user-1'
7106
7894
  });
7107
- core.listUserWorkspaces.mockReturnValueOnce([
7108
- {
7109
- id: 'workspace-a',
7110
- slug: 'workspace-a',
7111
- name: 'Workspace A',
7112
- description: null,
7113
- role: 'owner',
7114
- status: 'active',
7115
- betaAccess: true
7116
- }
7895
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7896
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor', undefined, { cookie });
7897
+ const res = createMockRes();
7898
+ await match?.handler(req, res, {});
7899
+ expect(core.listTasks).not.toHaveBeenCalled();
7900
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
7901
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"INVALID_SYNC_CURSOR"'));
7902
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
7903
+ action: 'sync-workspace-pull-invalid-cursor'
7904
+ }));
7905
+ });
7906
+ it('GET /api/taskforce/sync/workspace/pull should ignore cursors during repair', async () => {
7907
+ const authConfig = {
7908
+ runtimeMode: 'cloud',
7909
+ enabled: true,
7910
+ requiredForApi: true,
7911
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7912
+ defaultRole: 'member',
7913
+ defaultWorkspaceId: 'default',
7914
+ sessionCookieName: 'taskforce_session',
7915
+ sessionSecret: 'test-secret',
7916
+ sessionTtlSeconds: 3600
7917
+ };
7918
+ routes = createRoutes(core, { authConfig });
7919
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7920
+ core.listTasks.mockReturnValue({
7921
+ tasks: [
7922
+ { id: 'task-repair-1', title: 'Repair me', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' }
7923
+ ]
7924
+ });
7925
+ core.listArchive.mockReturnValue({ archived: [] });
7926
+ const cookie = createSessionTokenCookie('session-sync-pull-repair-cursor', authConfig, {
7927
+ role: 'owner',
7928
+ workspaceId: 'workspace-1',
7929
+ userId: 'user-1'
7930
+ });
7931
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7932
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor&repair=1', undefined, { cookie });
7933
+ const res = createMockRes();
7934
+ await match?.handler(req, res, {});
7935
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7936
+ expect(core.listTasks).toHaveBeenCalled();
7937
+ const body = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
7938
+ expect(body.changes).toEqual(expect.arrayContaining([
7939
+ expect.objectContaining({
7940
+ op: 'upsert',
7941
+ task: expect.objectContaining({ id: 'task-repair-1' })
7942
+ })
7943
+ ]));
7944
+ });
7945
+ it('GET /api/taskforce/sync/workspace/pull should still return success when success audit throws', async () => {
7946
+ const authConfig = {
7947
+ runtimeMode: 'cloud',
7948
+ enabled: true,
7949
+ requiredForApi: true,
7950
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7951
+ defaultRole: 'member',
7952
+ defaultWorkspaceId: 'default',
7953
+ sessionCookieName: 'taskforce_session',
7954
+ sessionSecret: 'test-secret',
7955
+ sessionTtlSeconds: 3600
7956
+ };
7957
+ routes = createRoutes(core, { authConfig });
7958
+ core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7959
+ const cookie = createSessionTokenCookie('session-sync-pull-audit-throw', authConfig, {
7960
+ role: 'owner',
7961
+ workspaceId: 'workspace-1',
7962
+ userId: 'user-1'
7963
+ });
7964
+ const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
7965
+ core.addAdminAuditLog.mockImplementation((entry) => {
7966
+ if (entry?.action === 'sync-workspace-pull-success') {
7967
+ throw new Error('audit unavailable');
7968
+ }
7969
+ return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
7970
+ });
7971
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7972
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10', undefined, { cookie });
7973
+ const res = createMockRes();
7974
+ await match?.handler(req, res, {});
7975
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
7976
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7977
+ expect(res.end).toHaveBeenCalledTimes(1);
7978
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
7979
+ });
7980
+ it('GET /api/taskforce/sync/workspace/pull should degrade optional snapshot failures into diagnostics instead of 500', async () => {
7981
+ const authConfig = {
7982
+ runtimeMode: 'cloud',
7983
+ enabled: true,
7984
+ requiredForApi: true,
7985
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7986
+ defaultRole: 'member',
7987
+ defaultWorkspaceId: 'default',
7988
+ sessionCookieName: 'taskforce_session',
7989
+ sessionSecret: 'test-secret',
7990
+ sessionTtlSeconds: 3600
7991
+ };
7992
+ routes = createRoutes(core, { authConfig });
7993
+ core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7994
+ core.listTasks.mockReturnValueOnce({
7995
+ tasks: [
7996
+ {
7997
+ id: 'task-survives',
7998
+ title: 'Survives partial feed failure',
7999
+ status: 'task',
8000
+ category: 'general',
8001
+ type: 'task',
8002
+ createdAt: '2026-04-10T10:00:00.000Z'
8003
+ }
8004
+ ]
8005
+ });
8006
+ core.listDocumentReviewSessionsForSync.mockImplementationOnce(() => {
8007
+ throw new Error('document review session snapshot exploded');
8008
+ });
8009
+ const cookie = createSessionTokenCookie('session-sync-pull-partial', authConfig, {
8010
+ role: 'owner',
8011
+ workspaceId: 'workspace-1',
8012
+ userId: 'user-1'
8013
+ });
8014
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
8015
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10', undefined, { cookie });
8016
+ const res = createMockRes();
8017
+ await match?.handler(req, res, {});
8018
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8019
+ const body = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
8020
+ expect(body.success).toBe(true);
8021
+ expect(body.changes).toEqual(expect.arrayContaining([
8022
+ expect.objectContaining({
8023
+ op: 'upsert',
8024
+ task: expect.objectContaining({ id: 'task-survives' })
8025
+ })
8026
+ ]));
8027
+ expect(body.diagnostics?.snapshotErrors).toEqual(expect.arrayContaining([
8028
+ expect.objectContaining({
8029
+ domain: 'document-review-sessions',
8030
+ error: 'document review session snapshot exploded'
8031
+ })
8032
+ ]));
8033
+ });
8034
+ it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
8035
+ const authConfig = {
8036
+ runtimeMode: 'cloud',
8037
+ enabled: true,
8038
+ requiredForApi: true,
8039
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
8040
+ defaultRole: 'member',
8041
+ defaultWorkspaceId: 'default',
8042
+ sessionCookieName: 'taskforce_session',
8043
+ sessionSecret: 'test-secret',
8044
+ sessionTtlSeconds: 3600
8045
+ };
8046
+ routes = createRoutes(core, { authConfig });
8047
+ const cookie = createSessionTokenCookie('session-sync-pull-forbidden', authConfig, {
8048
+ role: 'owner',
8049
+ workspaceId: 'workspace-cloud-selected',
8050
+ userId: 'user-1'
8051
+ });
8052
+ core.listUserWorkspaces.mockReturnValueOnce([
8053
+ {
8054
+ id: 'workspace-a',
8055
+ slug: 'workspace-a',
8056
+ name: 'Workspace A',
8057
+ description: null,
8058
+ role: 'owner',
8059
+ status: 'active',
8060
+ betaAccess: true
8061
+ }
7117
8062
  ]);
7118
8063
  const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7119
8064
  const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-denied', undefined, { cookie });
@@ -7147,6 +8092,53 @@ describe('Server Routes', () => {
7147
8092
  }), 'workspace-local');
7148
8093
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7149
8094
  });
8095
+ it('POST /api/taskforce/sync/workspace/apply-local should still return success when success sync telemetry throws after apply', async () => {
8096
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
8097
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
8098
+ core.recordSyncEvent.mockImplementationOnce(() => {
8099
+ throw new Error('telemetry unavailable');
8100
+ });
8101
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
8102
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
8103
+ changes: [
8104
+ { op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
8105
+ ]
8106
+ }, {
8107
+ 'x-taskforce-workspace-id': 'workspace-local'
8108
+ });
8109
+ const res = createMockRes();
8110
+ await match?.handler(req, res, {});
8111
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
8112
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
8113
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8114
+ expect(res.end).toHaveBeenCalledTimes(1);
8115
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
8116
+ });
8117
+ it('POST /api/taskforce/sync/workspace/apply-local should still return error response when failure-path telemetry throws', async () => {
8118
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
8119
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
8120
+ core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
8121
+ throw new Error('apply failed');
8122
+ });
8123
+ core.recordSyncEvent.mockImplementationOnce(() => {
8124
+ throw new Error('telemetry unavailable');
8125
+ });
8126
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
8127
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
8128
+ changes: [
8129
+ { op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
8130
+ ]
8131
+ }, {
8132
+ 'x-taskforce-workspace-id': 'workspace-local'
8133
+ });
8134
+ const res = createMockRes();
8135
+ await match?.handler(req, res, {});
8136
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
8137
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
8138
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
8139
+ expect(res.end).toHaveBeenCalledTimes(1);
8140
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
8141
+ });
7150
8142
  it('POST /api/taskforce/sync/workspace/apply-local should apply document review session upserts and deletes', async () => {
7151
8143
  core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
7152
8144
  routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
@@ -7891,7 +8883,7 @@ describe('Server Routes', () => {
7891
8883
  ]);
7892
8884
  fs.rmSync(dbRoot, { recursive: true, force: true });
7893
8885
  });
7894
- it('POST /api/taskforce/sync/workspace/apply-local should preserve document references during normal sync when content is unchanged', async () => {
8886
+ it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud document references during normal sync even when content is unchanged', async () => {
7895
8887
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-doc-metadata-'));
7896
8888
  const projectRoot = fs.realpathSync(tmpRoot);
7897
8889
  const basePath = path.join(tmpRoot, '.taskforce');
@@ -7945,7 +8937,7 @@ describe('Server Routes', () => {
7945
8937
  const res = createMockRes();
7946
8938
  await match?.handler(req, res, {});
7947
8939
  expect(store.get('asset-sync-doc-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
7948
- referenceNumber: 9
8940
+ referenceNumber: 42
7949
8941
  }));
7950
8942
  fs.rmSync(dbRoot, { recursive: true, force: true });
7951
8943
  });
@@ -8008,7 +9000,7 @@ describe('Server Routes', () => {
8008
9000
  }));
8009
9001
  fs.rmSync(dbRoot, { recursive: true, force: true });
8010
9002
  });
8011
- it('POST /api/taskforce/sync/workspace/apply-local should preserve image references during normal sync when content is unchanged', async () => {
9003
+ it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud image references during normal sync even when content is unchanged', async () => {
8012
9004
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-image-metadata-'));
8013
9005
  const projectRoot = fs.realpathSync(tmpRoot);
8014
9006
  const basePath = path.join(tmpRoot, '.taskforce');
@@ -8060,7 +9052,7 @@ describe('Server Routes', () => {
8060
9052
  const res = createMockRes();
8061
9053
  await match?.handler(req, res, {});
8062
9054
  expect(store.get('asset-sync-image-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
8063
- referenceNumber: 9
9055
+ referenceNumber: 42
8064
9056
  }));
8065
9057
  fs.rmSync(dbRoot, { recursive: true, force: true });
8066
9058
  });
@@ -8387,7 +9379,7 @@ describe('Server Routes', () => {
8387
9379
  action: 'sync-workspace-push-idempotent-replay'
8388
9380
  }));
8389
9381
  });
8390
- it('POST /api/taskforce/sync/workspace/push should persist synced cloud attachments into R2-backed canonical assets', async () => {
9382
+ it('POST /api/taskforce/sync/workspace/push should still return success when idempotency persistence fails after apply', async () => {
8391
9383
  const authConfig = {
8392
9384
  runtimeMode: 'cloud',
8393
9385
  enabled: true,
@@ -8399,101 +9391,299 @@ describe('Server Routes', () => {
8399
9391
  sessionSecret: 'test-secret',
8400
9392
  sessionTtlSeconds: 3600
8401
9393
  };
8402
- const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
8403
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-push-r2-'));
8404
- core.getPaths.mockReturnValue({
8405
- projectRoot: tmpRoot,
8406
- basePath: path.join(tmpRoot, '.taskforce')
8407
- });
8408
- core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
8409
- core.getTenantId = vi.fn().mockReturnValue('default');
9394
+ routes = createRoutes(core, { authConfig });
8410
9395
  core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
8411
- core.applyWorkspaceSyncSnapshot.mockReturnValue({
8412
- total: 0,
8413
- created: 0,
9396
+ const cookie = createSessionTokenCookie('session-sync-push-idempotency-write-fail', authConfig, {
9397
+ role: 'owner',
9398
+ workspaceId: 'workspace-1',
9399
+ userId: 'user-1'
9400
+ });
9401
+ core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
9402
+ total: 1,
9403
+ created: 1,
8414
9404
  updated: 0,
8415
9405
  archived: 0,
8416
9406
  unarchived: 0,
8417
9407
  skipped: 0
8418
9408
  });
8419
- const fetchMock = vi.fn().mockResolvedValue({
8420
- ok: true,
8421
- status: 200,
8422
- statusText: 'OK',
8423
- headers: new Headers(),
8424
- text: async () => '',
8425
- json: async () => ({})
8426
- });
8427
- vi.stubGlobal('fetch', fetchMock);
8428
- routes = createRoutes(core, {
8429
- authConfig,
8430
- objectStorage: {
8431
- provider: 'r2',
8432
- r2: {
8433
- accountId: 'acct-1',
8434
- bucket: 'taskforce-perf-assets',
8435
- endpoint: 'https://acct-1.r2.cloudflarestorage.com',
8436
- accessKeyId: 'access-key-1',
8437
- secretAccessKey: 'secret-key-1',
8438
- keyPrefix: '',
8439
- signedUploadTtlSeconds: 600,
8440
- signedDownloadTtlSeconds: 300
8441
- }
8442
- }
8443
- });
8444
- const cookie = createSessionTokenCookie('session-sync-push-r2', authConfig, {
8445
- role: 'owner',
8446
- workspaceId: 'workspace-1',
8447
- userId: 'user-1'
9409
+ core.writePushIdempotency.mockImplementationOnce(() => {
9410
+ throw new Error('disk full');
8448
9411
  });
8449
- const markdownPath = 'workspaces/workspace-1/assets/documents/asset-doc-sync-1-plan.md';
8450
- const imagePath = 'workspaces/workspace-1/assets/images/asset-image-sync-1-proof.png';
8451
9412
  const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
8452
9413
  const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
8453
9414
  workspaceId: 'workspace-1',
9415
+ idempotencyKey: 'push-idempotency-write-fail-1',
8454
9416
  changes: [
8455
- {
8456
- op: 'document-upsert',
8457
- path: markdownPath,
8458
- updatedAt: '2026-03-18T23:20:00.000Z',
8459
- content: '# Plan',
8460
- assetId: 'asset-doc-sync-1',
8461
- documentId: 'doc-sync-1',
8462
- taskId: 'task-sync-1',
8463
- originalFilename: 'plan.md',
8464
- linkRole: 'attachment'
8465
- },
8466
- {
8467
- op: 'asset-upsert',
8468
- path: imagePath,
8469
- updatedAt: '2026-03-18T23:20:01.000Z',
8470
- contentBase64: Buffer.from('png-bytes').toString('base64'),
8471
- assetId: 'asset-image-sync-1',
8472
- kind: 'image',
8473
- mimeType: 'image/png',
8474
- taskId: 'task-sync-1',
8475
- originalFilename: 'proof.png',
8476
- linkRole: 'image'
8477
- }
9417
+ { op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
8478
9418
  ]
8479
9419
  }, { cookie });
8480
9420
  const res = createMockRes();
8481
9421
  await match?.handler(req, res, {});
9422
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
8482
9423
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8483
- expect(fetchMock).toHaveBeenCalledTimes(2);
8484
- const docAsset = store.getByStorageKey(markdownPath, 'workspace-1');
8485
- const imageAsset = store.getByStorageKey(imagePath, 'workspace-1');
8486
- expect(docAsset).toEqual(expect.objectContaining({
8487
- assetId: 'asset-doc-sync-1',
8488
- storageProvider: 'r2',
8489
- storageKey: markdownPath
9424
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
9425
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
9426
+ action: 'sync-workspace-push-idempotency-write-failed'
8490
9427
  }));
8491
- expect(imageAsset).toEqual(expect.objectContaining({
8492
- assetId: 'asset-image-sync-1',
8493
- storageProvider: 'r2',
8494
- storageKey: imagePath
9428
+ expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
9429
+ eventType: 'push',
9430
+ status: 'success'
8495
9431
  }));
8496
- expect(store.listLinksForTask('task-sync-1', 'workspace-1')).toEqual(expect.arrayContaining([
9432
+ });
9433
+ it('POST /api/taskforce/sync/workspace/push should still return success when applied-task readback fails after apply', async () => {
9434
+ const authConfig = {
9435
+ runtimeMode: 'cloud',
9436
+ enabled: true,
9437
+ requiredForApi: true,
9438
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
9439
+ defaultRole: 'member',
9440
+ defaultWorkspaceId: 'default',
9441
+ sessionCookieName: 'taskforce_session',
9442
+ sessionSecret: 'test-secret',
9443
+ sessionTtlSeconds: 3600
9444
+ };
9445
+ routes = createRoutes(core, { authConfig });
9446
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
9447
+ const cookie = createSessionTokenCookie('session-sync-push-upsert-echo-fail', authConfig, {
9448
+ role: 'owner',
9449
+ workspaceId: 'workspace-1',
9450
+ userId: 'user-1'
9451
+ });
9452
+ core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
9453
+ total: 1,
9454
+ created: 1,
9455
+ updated: 0,
9456
+ archived: 0,
9457
+ unarchived: 0,
9458
+ skipped: 0
9459
+ });
9460
+ core.getTask.mockImplementationOnce(() => {
9461
+ throw new Error('readback unavailable');
9462
+ });
9463
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
9464
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
9465
+ workspaceId: 'workspace-1',
9466
+ idempotencyKey: 'push-upsert-echo-fail-1',
9467
+ changes: [
9468
+ { op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
9469
+ ]
9470
+ }, { cookie });
9471
+ const res = createMockRes();
9472
+ await match?.handler(req, res, {});
9473
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
9474
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9475
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
9476
+ expect(payload.success).toBe(true);
9477
+ expect(payload.appliedTaskUpserts).toEqual([]);
9478
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
9479
+ action: 'sync-workspace-push-upsert-echo-failed'
9480
+ }));
9481
+ expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
9482
+ eventType: 'push',
9483
+ status: 'success'
9484
+ }));
9485
+ });
9486
+ it('POST /api/taskforce/sync/workspace/push should still return success when success-path audit logging throws after apply', async () => {
9487
+ const authConfig = {
9488
+ runtimeMode: 'cloud',
9489
+ enabled: true,
9490
+ requiredForApi: true,
9491
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
9492
+ defaultRole: 'member',
9493
+ defaultWorkspaceId: 'default',
9494
+ sessionCookieName: 'taskforce_session',
9495
+ sessionSecret: 'test-secret',
9496
+ sessionTtlSeconds: 3600
9497
+ };
9498
+ routes = createRoutes(core, { authConfig });
9499
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
9500
+ const cookie = createSessionTokenCookie('session-sync-push-success-audit-fail', authConfig, {
9501
+ role: 'owner',
9502
+ workspaceId: 'workspace-1',
9503
+ userId: 'user-1'
9504
+ });
9505
+ core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
9506
+ total: 1,
9507
+ created: 1,
9508
+ updated: 0,
9509
+ archived: 0,
9510
+ unarchived: 0,
9511
+ skipped: 0
9512
+ });
9513
+ const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
9514
+ core.addAdminAuditLog.mockImplementation((entry) => {
9515
+ if (entry?.action === 'sync-workspace-push-success') {
9516
+ throw new Error('audit unavailable');
9517
+ }
9518
+ return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
9519
+ });
9520
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
9521
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
9522
+ workspaceId: 'workspace-1',
9523
+ idempotencyKey: 'push-success-audit-fail-1',
9524
+ changes: [
9525
+ { op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
9526
+ ]
9527
+ }, { cookie });
9528
+ const res = createMockRes();
9529
+ await match?.handler(req, res, {});
9530
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
9531
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
9532
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9533
+ expect(res.end).toHaveBeenCalledTimes(1);
9534
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
9535
+ expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
9536
+ eventType: 'push',
9537
+ status: 'success'
9538
+ }));
9539
+ });
9540
+ it('POST /api/taskforce/sync/workspace/push should still return error response when failure-path telemetry throws', async () => {
9541
+ const authConfig = {
9542
+ runtimeMode: 'cloud',
9543
+ enabled: true,
9544
+ requiredForApi: true,
9545
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
9546
+ defaultRole: 'member',
9547
+ defaultWorkspaceId: 'default',
9548
+ sessionCookieName: 'taskforce_session',
9549
+ sessionSecret: 'test-secret',
9550
+ sessionTtlSeconds: 3600
9551
+ };
9552
+ routes = createRoutes(core, { authConfig });
9553
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
9554
+ const cookie = createSessionTokenCookie('session-sync-push-failure-telemetry-throw', authConfig, {
9555
+ role: 'owner',
9556
+ workspaceId: 'workspace-1',
9557
+ userId: 'user-1'
9558
+ });
9559
+ core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
9560
+ throw new Error('apply failed');
9561
+ });
9562
+ core.recordSyncEvent.mockImplementationOnce(() => {
9563
+ throw new Error('telemetry unavailable');
9564
+ });
9565
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
9566
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
9567
+ workspaceId: 'workspace-1',
9568
+ changes: [
9569
+ { op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
9570
+ ]
9571
+ }, { cookie });
9572
+ const res = createMockRes();
9573
+ await match?.handler(req, res, {});
9574
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
9575
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
9576
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
9577
+ expect(res.end).toHaveBeenCalledTimes(1);
9578
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
9579
+ });
9580
+ it('POST /api/taskforce/sync/workspace/push should persist synced cloud attachments into R2-backed canonical assets', async () => {
9581
+ const authConfig = {
9582
+ runtimeMode: 'cloud',
9583
+ enabled: true,
9584
+ requiredForApi: true,
9585
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
9586
+ defaultRole: 'member',
9587
+ defaultWorkspaceId: 'default',
9588
+ sessionCookieName: 'taskforce_session',
9589
+ sessionSecret: 'test-secret',
9590
+ sessionTtlSeconds: 3600
9591
+ };
9592
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
9593
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-push-r2-'));
9594
+ core.getPaths.mockReturnValue({
9595
+ projectRoot: tmpRoot,
9596
+ basePath: path.join(tmpRoot, '.taskforce')
9597
+ });
9598
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
9599
+ core.getTenantId = vi.fn().mockReturnValue('default');
9600
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
9601
+ core.applyWorkspaceSyncSnapshot.mockReturnValue({
9602
+ total: 0,
9603
+ created: 0,
9604
+ updated: 0,
9605
+ archived: 0,
9606
+ unarchived: 0,
9607
+ skipped: 0
9608
+ });
9609
+ const fetchMock = vi.fn().mockResolvedValue({
9610
+ ok: true,
9611
+ status: 200,
9612
+ statusText: 'OK',
9613
+ headers: new Headers(),
9614
+ text: async () => '',
9615
+ json: async () => ({})
9616
+ });
9617
+ vi.stubGlobal('fetch', fetchMock);
9618
+ routes = createRoutes(core, {
9619
+ authConfig,
9620
+ objectStorage: {
9621
+ provider: 'r2',
9622
+ r2: {
9623
+ accountId: 'acct-1',
9624
+ bucket: 'taskforce-perf-assets',
9625
+ endpoint: 'https://acct-1.r2.cloudflarestorage.com',
9626
+ accessKeyId: 'access-key-1',
9627
+ secretAccessKey: 'secret-key-1',
9628
+ keyPrefix: '',
9629
+ signedUploadTtlSeconds: 600,
9630
+ signedDownloadTtlSeconds: 300
9631
+ }
9632
+ }
9633
+ });
9634
+ const cookie = createSessionTokenCookie('session-sync-push-r2', authConfig, {
9635
+ role: 'owner',
9636
+ workspaceId: 'workspace-1',
9637
+ userId: 'user-1'
9638
+ });
9639
+ const markdownPath = 'workspaces/workspace-1/assets/documents/asset-doc-sync-1-plan.md';
9640
+ const imagePath = 'workspaces/workspace-1/assets/images/asset-image-sync-1-proof.png';
9641
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
9642
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
9643
+ workspaceId: 'workspace-1',
9644
+ changes: [
9645
+ {
9646
+ op: 'document-upsert',
9647
+ path: markdownPath,
9648
+ updatedAt: '2026-03-18T23:20:00.000Z',
9649
+ content: '# Plan',
9650
+ assetId: 'asset-doc-sync-1',
9651
+ documentId: 'doc-sync-1',
9652
+ taskId: 'task-sync-1',
9653
+ originalFilename: 'plan.md',
9654
+ linkRole: 'attachment'
9655
+ },
9656
+ {
9657
+ op: 'asset-upsert',
9658
+ path: imagePath,
9659
+ updatedAt: '2026-03-18T23:20:01.000Z',
9660
+ contentBase64: Buffer.from('png-bytes').toString('base64'),
9661
+ assetId: 'asset-image-sync-1',
9662
+ kind: 'image',
9663
+ mimeType: 'image/png',
9664
+ taskId: 'task-sync-1',
9665
+ originalFilename: 'proof.png',
9666
+ linkRole: 'image'
9667
+ }
9668
+ ]
9669
+ }, { cookie });
9670
+ const res = createMockRes();
9671
+ await match?.handler(req, res, {});
9672
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9673
+ expect(fetchMock).toHaveBeenCalledTimes(2);
9674
+ const docAsset = store.getByStorageKey(markdownPath, 'workspace-1');
9675
+ const imageAsset = store.getByStorageKey(imagePath, 'workspace-1');
9676
+ expect(docAsset).toEqual(expect.objectContaining({
9677
+ assetId: 'asset-doc-sync-1',
9678
+ storageProvider: 'r2',
9679
+ storageKey: markdownPath
9680
+ }));
9681
+ expect(imageAsset).toEqual(expect.objectContaining({
9682
+ assetId: 'asset-image-sync-1',
9683
+ storageProvider: 'r2',
9684
+ storageKey: imagePath
9685
+ }));
9686
+ expect(store.listLinksForTask('task-sync-1', 'workspace-1')).toEqual(expect.arrayContaining([
8497
9687
  expect.objectContaining({ assetId: 'asset-doc-sync-1', role: 'attachment', deletedAt: null }),
8498
9688
  expect.objectContaining({ assetId: 'asset-image-sync-1', role: 'image', deletedAt: null })
8499
9689
  ]));
@@ -9417,11 +10607,11 @@ describe('Server Routes', () => {
9417
10607
  expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
9418
10608
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9419
10609
  });
9420
- it('POST /api/taskforce/session/workspace should be disabled in local runtime', async () => {
10610
+ it('POST /api/taskforce/session/workspace should allow a read-only user to switch to another active workspace', async () => {
9421
10611
  const authConfig = {
9422
- runtimeMode: 'local',
10612
+ runtimeMode: 'cloud',
9423
10613
  enabled: true,
9424
- requiredForApi: false,
10614
+ requiredForApi: true,
9425
10615
  excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
9426
10616
  defaultRole: 'member',
9427
10617
  defaultWorkspaceId: 'default',
@@ -9430,13 +10620,134 @@ describe('Server Routes', () => {
9430
10620
  sessionTtlSeconds: 3600
9431
10621
  };
9432
10622
  routes = createRoutes(core, { authConfig });
10623
+ const cookie = createSessionTokenCookie('session-read-only', authConfig, {
10624
+ role: 'read-only',
10625
+ workspaceId: 'workspace-shared',
10626
+ userId: 'user-1'
10627
+ });
10628
+ core.listUserWorkspaces.mockReturnValue([
10629
+ {
10630
+ id: 'workspace-shared',
10631
+ slug: 'workspace-shared',
10632
+ name: 'Shared Workspace',
10633
+ description: null,
10634
+ role: 'read-only',
10635
+ status: 'active',
10636
+ betaAccess: true
10637
+ },
10638
+ {
10639
+ id: 'workspace-home',
10640
+ slug: 'workspace-home',
10641
+ name: 'Home Workspace',
10642
+ description: null,
10643
+ role: 'owner',
10644
+ status: 'active',
10645
+ betaAccess: true
10646
+ }
10647
+ ]);
10648
+ core.resolveUserAccess.mockReturnValue({
10649
+ userId: 'user-1',
10650
+ workspaceId: 'workspace-home',
10651
+ role: 'owner',
10652
+ permissionMode: 'read-write',
10653
+ disabled: false,
10654
+ betaAccess: true
10655
+ });
9433
10656
  const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
9434
10657
  const req = createMockReq('POST', '/api/taskforce/session/workspace', {
9435
- workspaceId: 'workspace-2'
10658
+ workspaceId: 'workspace-home'
10659
+ }, {
10660
+ cookie
9436
10661
  });
9437
10662
  const res = createMockRes();
10663
+ res.setHeader = vi.fn();
9438
10664
  await match?.handler(req, res, {});
9439
- expect(res.writeHead).toHaveBeenCalledWith(501, expect.any(Object));
10665
+ expect(core.resolveUserAccess).toHaveBeenCalledWith('user-1', 'workspace-home', 'read-only');
10666
+ expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
10667
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10668
+ const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
10669
+ expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-home', role: 'owner' });
10670
+ });
10671
+ it('POST /api/taskforce/session/workspace should allow switching into an active read-only workspace', async () => {
10672
+ const authConfig = {
10673
+ runtimeMode: 'cloud',
10674
+ enabled: true,
10675
+ requiredForApi: true,
10676
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
10677
+ defaultRole: 'member',
10678
+ defaultWorkspaceId: 'default',
10679
+ sessionCookieName: 'taskforce_session',
10680
+ sessionSecret: 'test-secret',
10681
+ sessionTtlSeconds: 3600
10682
+ };
10683
+ routes = createRoutes(core, { authConfig });
10684
+ const cookie = createSessionTokenCookie('session-member', authConfig, {
10685
+ role: 'owner',
10686
+ workspaceId: 'workspace-home',
10687
+ userId: 'user-1'
10688
+ });
10689
+ core.listUserWorkspaces.mockReturnValue([
10690
+ {
10691
+ id: 'workspace-home',
10692
+ slug: 'workspace-home',
10693
+ name: 'Home Workspace',
10694
+ description: null,
10695
+ role: 'owner',
10696
+ status: 'active',
10697
+ betaAccess: true
10698
+ },
10699
+ {
10700
+ id: 'workspace-shared',
10701
+ slug: 'workspace-shared',
10702
+ name: 'Shared Workspace',
10703
+ description: null,
10704
+ role: 'read-only',
10705
+ status: 'active',
10706
+ betaAccess: true
10707
+ }
10708
+ ]);
10709
+ core.resolveUserAccess.mockReturnValue({
10710
+ userId: 'user-1',
10711
+ workspaceId: 'workspace-shared',
10712
+ role: 'read-only',
10713
+ permissionMode: 'read-only',
10714
+ disabled: false,
10715
+ betaAccess: true
10716
+ });
10717
+ const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
10718
+ const req = createMockReq('POST', '/api/taskforce/session/workspace', {
10719
+ workspaceId: 'workspace-shared'
10720
+ }, {
10721
+ cookie
10722
+ });
10723
+ const res = createMockRes();
10724
+ res.setHeader = vi.fn();
10725
+ await match?.handler(req, res, {});
10726
+ expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
10727
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10728
+ const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
10729
+ expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-shared', role: 'read-only' });
10730
+ });
10731
+ it('POST /api/taskforce/session/workspace should be disabled in local runtime', async () => {
10732
+ const authConfig = {
10733
+ runtimeMode: 'local',
10734
+ enabled: true,
10735
+ requiredForApi: false,
10736
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
10737
+ defaultRole: 'member',
10738
+ defaultWorkspaceId: 'default',
10739
+ sessionCookieName: 'taskforce_session',
10740
+ sessionSecret: 'test-secret',
10741
+ sessionTtlSeconds: 3600
10742
+ };
10743
+ routes = createRoutes(core, { authConfig });
10744
+ const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
10745
+ const req = createMockReq('POST', '/api/taskforce/session/workspace', {
10746
+ workspaceId: 'workspace-2'
10747
+ });
10748
+ const res = createMockRes();
10749
+ await match?.handler(req, res, {});
10750
+ expect(res.writeHead).toHaveBeenCalledWith(501, expect.any(Object));
9440
10751
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('Workspace switching is unavailable in local mode.'));
9441
10752
  expect(core.listUserWorkspaces).not.toHaveBeenCalled();
9442
10753
  });
@@ -9512,7 +10823,10 @@ describe('Server Routes', () => {
9512
10823
  await match?.handler(req, res, {});
9513
10824
  const payload = JSON.parse(res.end.mock.calls[0][0]);
9514
10825
  expect(payload.authenticated).toBe(true);
10826
+ expect(payload.authRequiredForApi).toBe(true);
10827
+ expect(payload.runtimeMode).toBe('cloud');
9515
10828
  expect(payload.betaAccess).toBe(false);
10829
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('auth-session'));
9516
10830
  expect(core.getUserBetaAccess).toHaveBeenCalledWith('u-no-workspace');
9517
10831
  });
9518
10832
  it('GET /api/taskforce/auth/session should expose avatarUrl from the resolved profile', async () => {
@@ -9723,6 +11037,74 @@ describe('Server Routes', () => {
9723
11037
  mimeType: 'image/png'
9724
11038
  }));
9725
11039
  });
11040
+ it('POST /api/taskforce/auth/profile/avatar/upload should store the avatar body through object storage', async () => {
11041
+ const authConfig = {
11042
+ runtimeMode: 'cloud',
11043
+ enabled: true,
11044
+ requiredForApi: true,
11045
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
11046
+ defaultRole: 'member',
11047
+ defaultWorkspaceId: 'default',
11048
+ sessionCookieName: 'taskforce_session',
11049
+ sessionSecret: 'test-secret',
11050
+ sessionTtlSeconds: 3600
11051
+ };
11052
+ const fetchMock = vi.fn().mockResolvedValue({
11053
+ ok: true,
11054
+ status: 200,
11055
+ statusText: 'OK',
11056
+ text: async () => ''
11057
+ });
11058
+ vi.stubGlobal('fetch', fetchMock);
11059
+ const routesWithStorage = createRoutes(core, {
11060
+ authConfig,
11061
+ objectStorage: {
11062
+ provider: 'r2',
11063
+ r2: {
11064
+ accountId: 'acct-1',
11065
+ bucket: 'taskforce-docs',
11066
+ endpoint: 'https://example.r2.cloudflarestorage.com',
11067
+ accessKeyId: 'access-key-1',
11068
+ secretAccessKey: 'secret-key-1',
11069
+ keyPrefix: 'taskforce',
11070
+ signedUploadTtlSeconds: 600,
11071
+ signedDownloadTtlSeconds: 300
11072
+ }
11073
+ }
11074
+ });
11075
+ core.getUserAvatarUploadDraft.mockReturnValueOnce({
11076
+ draftId: 'avatar-draft-1',
11077
+ userId: 'u1',
11078
+ storageKey: 'users/u1/profile/avatar/avatar-draft-1-headshot.png',
11079
+ mimeType: 'image/png',
11080
+ finalizedAt: null,
11081
+ discardedAt: null
11082
+ });
11083
+ const cookie = createSessionTokenCookie('session-avatar-upload', authConfig, {
11084
+ userId: 'u1',
11085
+ role: 'member',
11086
+ workspaceId: 'workspace-1'
11087
+ });
11088
+ const match = matchRoute('POST', '/api/taskforce/auth/profile/avatar/upload', routesWithStorage);
11089
+ const req = createMockReq('POST', '/api/taskforce/auth/profile/avatar/upload', Buffer.from([1, 2, 3, 4]), {
11090
+ cookie,
11091
+ 'content-type': 'image/png',
11092
+ 'x-taskforce-avatar-draft-id': 'avatar-draft-1'
11093
+ });
11094
+ const res = createMockRes();
11095
+ await match?.handler(req, res, {});
11096
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
11097
+ expect(payload.success).toBe(true);
11098
+ expect(payload.draftId).toBe('avatar-draft-1');
11099
+ const r2Call = fetchMock.mock.calls[0];
11100
+ expect(String(r2Call?.[0])).toContain('https://example.r2.cloudflarestorage.com/taskforce-docs/taskforce/users/u1/profile/avatar/avatar-draft-1-headshot.png');
11101
+ expect(r2Call?.[1]).toEqual(expect.objectContaining({
11102
+ method: 'PUT',
11103
+ body: expect.any(Uint8Array)
11104
+ }));
11105
+ expect((r2Call?.[1]?.headers)['content-type']).toBe('image/png');
11106
+ vi.unstubAllGlobals();
11107
+ });
9726
11108
  it('POST /api/taskforce/auth/profile/avatar/finalize should validate and finalize the uploaded draft', async () => {
9727
11109
  const authConfig = {
9728
11110
  runtimeMode: 'cloud',
@@ -9847,6 +11229,56 @@ describe('Server Routes', () => {
9847
11229
  allowed: true
9848
11230
  });
9849
11231
  });
11232
+ it('GET /api/taskforce/account/team-management-access should resolve against the authenticated session user and workspace', async () => {
11233
+ const authConfig = {
11234
+ runtimeMode: 'cloud',
11235
+ enabled: true,
11236
+ requiredForApi: true,
11237
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
11238
+ defaultRole: 'member',
11239
+ defaultWorkspaceId: 'default',
11240
+ sessionCookieName: 'taskforce_session',
11241
+ sessionSecret: 'test-secret',
11242
+ sessionTtlSeconds: 3600
11243
+ };
11244
+ routes = createRoutes(core, { authConfig });
11245
+ core.listUserWorkspaces.mockReturnValueOnce([
11246
+ { id: 'workspace-auth-existing-home', slug: 'home', name: 'Home', description: null, role: 'owner', status: 'active', betaAccess: true },
11247
+ { id: 'workspace-invited', slug: 'invited', name: 'Invited', description: null, role: 'member', status: 'active', betaAccess: true }
11248
+ ]);
11249
+ core.resolveUserAccess.mockImplementation((userId, workspaceId, role) => ({
11250
+ userId,
11251
+ workspaceId,
11252
+ role: userId === 'joined-member' && workspaceId === 'workspace-invited' ? 'member' : role,
11253
+ disabled: false,
11254
+ betaAccess: true
11255
+ }));
11256
+ const cookie = createSessionTokenCookie('session-joined-member', authConfig, {
11257
+ userId: 'joined-member',
11258
+ role: 'member',
11259
+ workspaceId: 'workspace-invited'
11260
+ });
11261
+ const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
11262
+ const req = createMockReq('GET', '/api/taskforce/account/team-management-access', undefined, {
11263
+ cookie,
11264
+ 'x-taskforce-runtime-mode': 'cloud',
11265
+ 'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
11266
+ 'x-taskforce-workspace-id': 'workspace-auth-existing-home',
11267
+ 'x-taskforce-role': 'owner'
11268
+ });
11269
+ const res = createMockRes();
11270
+ await match?.handler(req, res, {});
11271
+ expect(core.resolveUserAccess).toHaveBeenCalledWith('joined-member', 'workspace-invited', 'member');
11272
+ expect(core.getAccountEntitlementsPayload).toHaveBeenCalledWith({ userId: 'joined-member', workspaceId: 'workspace-invited' });
11273
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
11274
+ expect(payload).toMatchObject({
11275
+ workspaceId: 'workspace-invited',
11276
+ role: 'member',
11277
+ canManageWorkspace: false,
11278
+ teamPlanMode: 'team',
11279
+ allowed: false
11280
+ });
11281
+ });
9850
11282
  it('GET /api/taskforce/account/team-management-access should fall back to the hinted workspace role when local access cannot resolve the cloud user id', async () => {
9851
11283
  core.resolveUserAccess.mockReturnValueOnce(null);
9852
11284
  const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
@@ -9923,6 +11355,40 @@ describe('Server Routes', () => {
9923
11355
  planSelectionRequired: false
9924
11356
  });
9925
11357
  });
11358
+ it('GET /api/taskforce/account/ai-profile-seat-usage should return cloud-authoritative ai seat usage', async () => {
11359
+ const authConfig = {
11360
+ runtimeMode: 'cloud',
11361
+ enabled: true,
11362
+ requiredForApi: true,
11363
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
11364
+ defaultRole: 'member',
11365
+ defaultWorkspaceId: 'default',
11366
+ sessionCookieName: 'taskforce_session',
11367
+ sessionSecret: 'test-secret',
11368
+ sessionTtlSeconds: 3600
11369
+ };
11370
+ routes = createRoutes(core, { authConfig });
11371
+ core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
11372
+ const match = matchRoute('GET', '/api/taskforce/account/ai-profile-seat-usage', routes);
11373
+ const cookie = createSessionTokenCookie('session-ai-seat-usage', authConfig, {
11374
+ userId: 'u1',
11375
+ role: 'owner',
11376
+ workspaceId: 'workspace-1'
11377
+ });
11378
+ const req = createMockReq('GET', '/api/taskforce/account/ai-profile-seat-usage?workspaceId=workspace-1', undefined, {
11379
+ cookie,
11380
+ 'x-taskforce-runtime-mode': 'cloud',
11381
+ 'x-taskforce-user-id': 'u1',
11382
+ 'x-taskforce-role': 'owner',
11383
+ 'x-taskforce-workspace-id': 'workspace-1'
11384
+ });
11385
+ const res = createMockRes();
11386
+ await match?.handler(req, res, {});
11387
+ expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
11388
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11389
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
11390
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 0, limit: 20, remaining: 20 });
11391
+ });
9926
11392
  it('GET /api/taskforce/account/access-status should return pending plan-selection gate', async () => {
9927
11393
  core.getAccountAccessStatus.mockReturnValueOnce({
9928
11394
  gate: 'plan_selection_required',
@@ -9995,6 +11461,7 @@ describe('Server Routes', () => {
9995
11461
  error.code = 'ENTITLEMENT_NOT_FOUND';
9996
11462
  throw error;
9997
11463
  });
11464
+ core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
9998
11465
  const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
9999
11466
  const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
10000
11467
  'x-taskforce-user-id': 'u1'
@@ -10015,7 +11482,75 @@ describe('Server Routes', () => {
10015
11482
  workspaceRole: 'owner',
10016
11483
  canManageWorkspace: true,
10017
11484
  teamPlanMode: 'personal',
10018
- teamManagementAllowed: false
11485
+ teamManagementAllowed: false,
11486
+ aiProfileSeatUsage: {
11487
+ used: 0,
11488
+ limit: 20,
11489
+ remaining: 20
11490
+ }
11491
+ });
11492
+ });
11493
+ it('GET /api/taskforce/account/profile-summary should not leak stale billing mirror plan identity when entitlement is missing', async () => {
11494
+ core.resolveUserAccess.mockReturnValueOnce({
11495
+ userId: 'u1',
11496
+ workspaceId: 'workspace-1',
11497
+ role: 'member',
11498
+ disabled: false,
11499
+ betaAccess: true
11500
+ });
11501
+ core.getAccountAccessStatus.mockReturnValueOnce({
11502
+ gate: 'missing_entitlement',
11503
+ hasEntitlement: false,
11504
+ canAccessApp: false,
11505
+ canAccessPlans: true,
11506
+ planSelectionRequired: false,
11507
+ message: 'No entitlement is linked to this account.',
11508
+ entitlement: null
11509
+ });
11510
+ core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
11511
+ const baseDb = core.getDatabaseAdapter();
11512
+ core.getDatabaseAdapter = vi.fn().mockReturnValue({
11513
+ ...baseDb,
11514
+ prepare: vi.fn((sql) => {
11515
+ if (sql.includes('FROM user_billing')) {
11516
+ return {
11517
+ get: () => ({
11518
+ stripe_status: 'active',
11519
+ stripe_customer_id: 'cus_stale',
11520
+ stripe_subscription_id: 'sub_stale',
11521
+ stripe_price_id: 'price_stale',
11522
+ billing_interval: 'month',
11523
+ entitlement_state: 'active',
11524
+ tier: 'team-5'
11525
+ })
11526
+ };
11527
+ }
11528
+ if (sql.includes('FROM plan_catalog')) {
11529
+ return {
11530
+ get: () => ({ display_name: 'Team 5' })
11531
+ };
11532
+ }
11533
+ return baseDb.prepare(sql);
11534
+ })
11535
+ });
11536
+ const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
11537
+ const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
11538
+ 'x-taskforce-user-id': 'u1'
11539
+ });
11540
+ const res = createMockRes();
11541
+ await match?.handler(req, res, {});
11542
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11543
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
11544
+ expect(payload).toMatchObject({
11545
+ planId: null,
11546
+ planVersionId: null,
11547
+ entitlementState: null,
11548
+ gate: 'missing_entitlement',
11549
+ message: 'No entitlement is linked to this account.',
11550
+ planSelectionRequired: false,
11551
+ stripeStatus: 'active',
11552
+ stripeCustomerId: 'cus_stale',
11553
+ stripeSubscriptionId: 'sub_stale'
10019
11554
  });
10020
11555
  });
10021
11556
  it('GET /api/taskforce/account/entitlements should surface checkout-pending gate when billing is not complete', async () => {
@@ -10087,6 +11622,20 @@ describe('Server Routes', () => {
10087
11622
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"PLAN_SELECTION_REQUIRED"'));
10088
11623
  expect(core.listTasks).not.toHaveBeenCalled();
10089
11624
  });
11625
+ it('GET /api/taskforce/tasks should require authenticated cloud users', async () => {
11626
+ const authConfig = createCloudAuthConfig();
11627
+ routes = createRoutes(core, { authConfig });
11628
+ core.listTasks.mockReturnValue({ tasks: [] });
11629
+ const match = matchRoute('GET', '/api/taskforce/tasks', routes);
11630
+ const req = createMockReq('GET', '/api/taskforce/tasks', undefined, {
11631
+ 'x-taskforce-runtime-mode': 'cloud'
11632
+ });
11633
+ const res = createMockRes();
11634
+ await match?.handler(req, res, {});
11635
+ expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
11636
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"Authentication required."'));
11637
+ expect(core.listTasks).not.toHaveBeenCalled();
11638
+ });
10090
11639
  it('GET /api/taskforce/tasks should block authenticated users with missing entitlements', async () => {
10091
11640
  const authConfig = {
10092
11641
  runtimeMode: 'cloud',
@@ -10194,17 +11743,31 @@ describe('Server Routes', () => {
10194
11743
  try {
10195
11744
  core.getTenantId = vi.fn().mockReturnValue('default');
10196
11745
  core.getDatabaseAdapter = vi.fn().mockReturnValue({
10197
- prepare: vi.fn().mockReturnValue({
10198
- all: vi.fn().mockReturnValue([
10199
- {
10200
- plan_version_id: 'free-v1',
10201
- billing_interval: 'month',
10202
- stripe_price_id: 'free:free-v1:month',
10203
- pricing_type: 'free',
10204
- unit_amount: 0,
10205
- currency: null
10206
- }
10207
- ])
11746
+ prepare: vi.fn().mockImplementation((sql) => {
11747
+ if (sql.includes('FROM billing_price_mappings')) {
11748
+ return {
11749
+ get: vi.fn().mockReturnValue({
11750
+ count: 1,
11751
+ max_updated_at: '2026-01-01T00:00:00.000Z'
11752
+ }),
11753
+ all: vi.fn().mockReturnValue([
11754
+ {
11755
+ plan_version_id: 'free-v1',
11756
+ billing_interval: 'month',
11757
+ stripe_price_id: 'free:free-v1:month',
11758
+ pricing_type: 'free',
11759
+ unit_amount: 0,
11760
+ currency: null
11761
+ }
11762
+ ])
11763
+ };
11764
+ }
11765
+ return {
11766
+ get: vi.fn().mockReturnValue({
11767
+ count: 0,
11768
+ max_updated_at: null
11769
+ })
11770
+ };
10208
11771
  })
10209
11772
  });
10210
11773
  core.listPlanCatalog.mockReturnValueOnce([
@@ -10224,6 +11787,7 @@ describe('Server Routes', () => {
10224
11787
  }
10225
11788
  }
10226
11789
  ]);
11790
+ core.listPlanFeatureCatalog = vi.fn().mockReturnValue([]);
10227
11791
  routes = createRoutes(core);
10228
11792
  const match = matchRoute('GET', '/api/taskforce/public/pricing', routes);
10229
11793
  const req = createMockReq('GET', '/api/taskforce/public/pricing', undefined, {
@@ -10235,7 +11799,15 @@ describe('Server Routes', () => {
10235
11799
  const payload = JSON.parse(String(res.end.mock.calls[0][0]));
10236
11800
  expect(payload.plans).toHaveLength(1);
10237
11801
  expect(payload.plans[0].features).toEqual([
10238
- { featureKey: 'ai.assistance', access: 'enabled', config: {} }
11802
+ {
11803
+ featureKey: 'ai.assistance',
11804
+ access: 'enabled',
11805
+ config: {},
11806
+ label: null,
11807
+ description: null,
11808
+ publicLabel: null,
11809
+ publicDescription: null
11810
+ }
10239
11811
  ]);
10240
11812
  }
10241
11813
  finally {
@@ -10267,6 +11839,7 @@ describe('Server Routes', () => {
10267
11839
  expect(core.upsertPlanCatalog).toHaveBeenCalledWith({
10268
11840
  planId: 'enterprise',
10269
11841
  displayName: 'Enterprise',
11842
+ description: undefined,
10270
11843
  status: 'active',
10271
11844
  initialTrialDays: 0,
10272
11845
  initialMode: undefined,
@@ -10286,15 +11859,51 @@ describe('Server Routes', () => {
10286
11859
  expect(core.listPlanCatalog).toHaveBeenCalled();
10287
11860
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10288
11861
  });
10289
- it('GET /api/platform/plans/:planId/versions should list versions', async () => {
10290
- const match = matchRoute('GET', '/api/platform/plans/collab/versions', routes);
10291
- const req = createMockReq('GET', '/api/platform/plans/collab/versions', undefined, {
11862
+ it('GET /api/platform/onboarding-plan-options should return onboarding plan options for system admin', async () => {
11863
+ core.listOnboardingPlanOptions = vi.fn().mockReturnValue([
11864
+ {
11865
+ planId: 'free',
11866
+ planDisplayName: 'Free',
11867
+ defaultPlanVersionId: 'free-v1',
11868
+ defaultVersionNumber: 1,
11869
+ planStatus: 'active',
11870
+ supportsAutoGrantFree: true,
11871
+ supportsAutoStartTrial: false
11872
+ }
11873
+ ]);
11874
+ const match = matchRoute('GET', '/api/platform/onboarding-plan-options', routes);
11875
+ const req = createMockReq('GET', '/api/platform/onboarding-plan-options', undefined, {
10292
11876
  'x-taskforce-runtime-mode': 'cloud',
10293
11877
  'x-taskforce-system-role': 'system_admin'
10294
11878
  });
10295
11879
  const res = createMockRes();
10296
- await match?.handler(req, res, { planId: 'collab' });
10297
- expect(core.listPlanVersions).toHaveBeenCalledWith('collab');
11880
+ await match?.handler(req, res, {});
11881
+ expect(core.listOnboardingPlanOptions).toHaveBeenCalled();
11882
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11883
+ expect(JSON.parse(res.end.mock.calls[0]?.[0] || '{}')).toEqual({
11884
+ success: true,
11885
+ options: [
11886
+ {
11887
+ planId: 'free',
11888
+ planDisplayName: 'Free',
11889
+ defaultPlanVersionId: 'free-v1',
11890
+ defaultVersionNumber: 1,
11891
+ planStatus: 'active',
11892
+ supportsAutoGrantFree: true,
11893
+ supportsAutoStartTrial: false
11894
+ }
11895
+ ]
11896
+ });
11897
+ });
11898
+ it('GET /api/platform/plans/:planId/versions should list versions', async () => {
11899
+ const match = matchRoute('GET', '/api/platform/plans/collab/versions', routes);
11900
+ const req = createMockReq('GET', '/api/platform/plans/collab/versions', undefined, {
11901
+ 'x-taskforce-runtime-mode': 'cloud',
11902
+ 'x-taskforce-system-role': 'system_admin'
11903
+ });
11904
+ const res = createMockRes();
11905
+ await match?.handler(req, res, { planId: 'collab' });
11906
+ expect(core.listPlanVersions).toHaveBeenCalledWith('collab');
10298
11907
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10299
11908
  });
10300
11909
  it('POST /api/platform/plans/:planId/versions should forward per-version trial days', async () => {
@@ -10344,6 +11953,37 @@ describe('Server Routes', () => {
10344
11953
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.team_management"'));
10345
11954
  expect(res.end).not.toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.invites"'));
10346
11955
  });
11956
+ it('PATCH /api/platform/features/:featureKey should update public marketing copy', async () => {
11957
+ core.updatePlanFeatureCatalogMarketingCopy = vi.fn().mockReturnValue({
11958
+ featureKey: 'collaboration.ai_profiles',
11959
+ label: 'AI Profiles',
11960
+ description: 'Allow registered AI collaborator profiles with an optional per-workspace limit.',
11961
+ publicLabel: 'AI teammates',
11962
+ publicDescription: 'Add reusable AI teammates to your workspace.',
11963
+ publicDisplayOrder: 20,
11964
+ allowedAccessModes: ['enabled', 'limited', 'disabled'],
11965
+ configTemplate: { maxAiProfiles: 1 }
11966
+ });
11967
+ const match = matchRoute('PATCH', '/api/platform/features/collaboration.ai_profiles', routes);
11968
+ const req = createMockReq('PATCH', '/api/platform/features/collaboration.ai_profiles', {
11969
+ publicLabel: 'AI teammates',
11970
+ publicDescription: 'Add reusable AI teammates to your workspace.',
11971
+ publicDisplayOrder: 20
11972
+ }, {
11973
+ 'x-taskforce-runtime-mode': 'cloud',
11974
+ 'x-taskforce-system-role': 'system_admin'
11975
+ });
11976
+ const res = createMockRes();
11977
+ await match?.handler(req, res, { featureKey: 'collaboration.ai_profiles' });
11978
+ expect(core.updatePlanFeatureCatalogMarketingCopy).toHaveBeenCalledWith({
11979
+ featureKey: 'collaboration.ai_profiles',
11980
+ publicLabel: 'AI teammates',
11981
+ publicDescription: 'Add reusable AI teammates to your workspace.',
11982
+ publicDisplayOrder: 20
11983
+ });
11984
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11985
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"publicLabel":"AI teammates"'));
11986
+ });
10347
11987
  it('PATCH /api/platform/plans/:planId/versions/:versionId/features should return normalized dependency state', async () => {
10348
11988
  core.updatePlanVersionFeatures.mockReturnValueOnce({
10349
11989
  planVersionId: 'collab-v1',
@@ -10473,13 +12113,20 @@ describe('Server Routes', () => {
10473
12113
  updatedAt: '2026-02-01T00:00:00.000Z'
10474
12114
  }
10475
12115
  ]);
12116
+ core.countSystemUsers.mockReturnValueOnce(1);
10476
12117
  const match = matchRoute('GET', '/api/taskforce/admin/system-users', routes);
10477
- const req = createMockReq('GET', '/api/taskforce/admin/system-users', undefined, { 'x-taskforce-system-role': 'system_admin' });
12118
+ const req = createMockReq('GET', '/api/taskforce/admin/system-users?q=u1&sort=oldest&limit=25&offset=50', undefined, { 'x-taskforce-system-role': 'system_admin' });
10478
12119
  const res = createMockRes();
10479
12120
  await match?.handler(req, res, {});
10480
- expect(core.listSystemUsers).toHaveBeenCalled();
12121
+ expect(core.countSystemUsers).toHaveBeenCalledWith({ query: 'u1' });
12122
+ expect(core.listSystemUsers).toHaveBeenCalledWith({ query: 'u1', sort: 'oldest', limit: 25, offset: 50 });
10481
12123
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10482
- expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"users"'));
12124
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
12125
+ expect(payload.users).toHaveLength(1);
12126
+ expect(payload.total).toBe(1);
12127
+ expect(payload.limit).toBe(25);
12128
+ expect(payload.offset).toBe(50);
12129
+ expect(payload.hasMore).toBe(false);
10483
12130
  });
10484
12131
  it('GET /api/taskforce/admin/system-users should allow authenticated system admin without role header', async () => {
10485
12132
  const authConfig = {
@@ -10505,7 +12152,8 @@ describe('Server Routes', () => {
10505
12152
  const res = createMockRes();
10506
12153
  await match?.handler(req, res, {});
10507
12154
  expect(core.getSystemRole).toHaveBeenCalledWith('admin-user');
10508
- expect(core.listSystemUsers).toHaveBeenCalled();
12155
+ expect(core.countSystemUsers).toHaveBeenCalledWith({ query: '' });
12156
+ expect(core.listSystemUsers).toHaveBeenCalledWith({ query: '', sort: 'newest', limit: 50, offset: 0 });
10509
12157
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10510
12158
  });
10511
12159
  it('GET /api/taskforce/admin/audit-logs should return events for admins', async () => {
@@ -10767,6 +12415,268 @@ describe('Server Routes', () => {
10767
12415
  expect(matchRoute('POST', '/api/taskforce/admin/users/invite', routes)).not.toBeNull();
10768
12416
  expect(matchRoute('PATCH', '/api/taskforce/admin/users/u1/role', routes)).not.toBeNull();
10769
12417
  expect(matchRoute('GET', '/api/taskforce/admin/workspace-audit-logs', routes)).not.toBeNull();
12418
+ expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes)).not.toBeNull();
12419
+ expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar/upload', routes)).not.toBeNull();
12420
+ expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/metadata', routes)).toBeNull();
12421
+ });
12422
+ it('POST /api/taskforce/admin/users/invite should authorize against the target workspace', async () => {
12423
+ const authConfig = createCloudAuthConfig();
12424
+ routes = createRoutes(core, { authConfig });
12425
+ core.listUserWorkspaces.mockReturnValue([
12426
+ { id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
12427
+ { id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
12428
+ ]);
12429
+ core.listWorkspaceUsers.mockImplementation((workspaceId) => {
12430
+ if (workspaceId === 'workspace-home') {
12431
+ return [{
12432
+ userId: 'actor-user',
12433
+ email: 'actor@example.com',
12434
+ displayName: 'Actor',
12435
+ role: 'owner',
12436
+ permissionMode: 'read-write',
12437
+ betaAccess: true,
12438
+ status: 'active',
12439
+ disabled: false
12440
+ }];
12441
+ }
12442
+ if (workspaceId === 'workspace-shared') {
12443
+ return [{
12444
+ userId: 'actor-user',
12445
+ email: 'actor@example.com',
12446
+ displayName: 'Actor',
12447
+ role: 'member',
12448
+ permissionMode: 'read-only',
12449
+ betaAccess: true,
12450
+ status: 'active',
12451
+ disabled: false
12452
+ }];
12453
+ }
12454
+ return [];
12455
+ });
12456
+ core.inviteWorkspaceUser.mockReturnValue({
12457
+ userId: 'invited-user',
12458
+ email: 'invited@example.com',
12459
+ role: 'member',
12460
+ status: 'invited',
12461
+ disabled: false
12462
+ });
12463
+ const cookie = createSessionTokenCookie('session-target-invite-deny', authConfig, {
12464
+ userId: 'actor-user',
12465
+ role: 'owner',
12466
+ workspaceId: 'workspace-home'
12467
+ });
12468
+ const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
12469
+ const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
12470
+ workspaceId: 'workspace-shared',
12471
+ email: 'invited@example.com',
12472
+ role: 'member'
12473
+ }, {
12474
+ cookie,
12475
+ 'x-taskforce-runtime-mode': 'cloud',
12476
+ 'x-taskforce-role': 'owner',
12477
+ 'x-taskforce-user-id': 'actor-user',
12478
+ 'x-taskforce-workspace-id': 'workspace-home'
12479
+ });
12480
+ const res = createMockRes();
12481
+ await match?.handler(req, res, {});
12482
+ expect(core.inviteWorkspaceUser).not.toHaveBeenCalled();
12483
+ expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
12484
+ expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
12485
+ });
12486
+ it('POST /api/taskforce/admin/users/invite should resolve feature access and inviter identity from the authenticated session user', async () => {
12487
+ const authConfig = createCloudAuthConfig();
12488
+ routes = createRoutes(core, { authConfig });
12489
+ core.listWorkspaceUsers.mockImplementation((workspaceId) => {
12490
+ if (workspaceId === 'workspace-shared') {
12491
+ return [{
12492
+ userId: 'joined-admin',
12493
+ email: 'joined-admin@example.com',
12494
+ displayName: 'Joined Admin',
12495
+ role: 'admin',
12496
+ permissionMode: 'read-write',
12497
+ betaAccess: true,
12498
+ status: 'active',
12499
+ disabled: false
12500
+ }];
12501
+ }
12502
+ return [];
12503
+ });
12504
+ core.evaluateFeatureAccess.mockImplementation(({ userId }) => ({
12505
+ allowed: userId === 'joined-admin',
12506
+ reasonCode: userId === 'joined-admin' ? undefined : 'ACCOUNT_ENTITLEMENT_REQUIRED'
12507
+ }));
12508
+ core.inviteWorkspaceUser.mockReturnValue({
12509
+ userId: 'invited-user',
12510
+ email: 'invited@example.com',
12511
+ role: 'member',
12512
+ status: 'invited',
12513
+ disabled: false
12514
+ });
12515
+ core.requestInviteAcceptance.mockReturnValue({ token: 'invite-token-1' });
12516
+ core.getWorkspaceProfile.mockReturnValue({
12517
+ id: 'workspace-shared',
12518
+ name: 'Shared Workspace'
12519
+ });
12520
+ core.resolveUserIdentity.mockReturnValue({
12521
+ userId: 'joined-admin',
12522
+ email: 'joined-admin@example.com',
12523
+ displayName: 'Joined Admin'
12524
+ });
12525
+ const cookie = createSessionTokenCookie('session-target-invite-identity', authConfig, {
12526
+ userId: 'joined-admin',
12527
+ role: 'admin',
12528
+ workspaceId: 'workspace-shared'
12529
+ });
12530
+ const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
12531
+ const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
12532
+ workspaceId: 'workspace-shared',
12533
+ email: 'invited@example.com',
12534
+ role: 'member'
12535
+ }, {
12536
+ cookie,
12537
+ 'x-taskforce-runtime-mode': 'cloud',
12538
+ 'x-taskforce-role': 'owner',
12539
+ 'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
12540
+ 'x-taskforce-workspace-id': 'workspace-auth-existing-home'
12541
+ });
12542
+ const res = createMockRes();
12543
+ await match?.handler(req, res, {});
12544
+ expect(core.evaluateFeatureAccess).toHaveBeenCalledWith(expect.objectContaining({
12545
+ userId: 'joined-admin',
12546
+ workspaceId: 'workspace-shared',
12547
+ featureKey: 'collaboration.invites'
12548
+ }));
12549
+ expect(core.resolveUserIdentity).toHaveBeenCalledWith('joined-admin');
12550
+ expect(core.inviteWorkspaceUser).toHaveBeenCalled();
12551
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
12552
+ });
12553
+ it('PATCH /api/taskforce/admin/users/:id/role should reject active-workspace admins who are only target-workspace members', async () => {
12554
+ const authConfig = createCloudAuthConfig();
12555
+ routes = createRoutes(core, { authConfig });
12556
+ core.listUserWorkspaces.mockReturnValue([
12557
+ { id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
12558
+ { id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
12559
+ ]);
12560
+ core.listWorkspaceUsers.mockImplementation((workspaceId) => {
12561
+ if (workspaceId === 'workspace-home') {
12562
+ return [{
12563
+ userId: 'actor-user',
12564
+ email: 'actor@example.com',
12565
+ displayName: 'Actor',
12566
+ role: 'owner',
12567
+ permissionMode: 'read-write',
12568
+ betaAccess: true,
12569
+ status: 'active',
12570
+ disabled: false
12571
+ }];
12572
+ }
12573
+ if (workspaceId === 'workspace-shared') {
12574
+ return [{
12575
+ userId: 'actor-user',
12576
+ email: 'actor@example.com',
12577
+ displayName: 'Actor',
12578
+ role: 'member',
12579
+ permissionMode: 'read-only',
12580
+ betaAccess: true,
12581
+ status: 'active',
12582
+ disabled: false
12583
+ }];
12584
+ }
12585
+ return [];
12586
+ });
12587
+ core.updateWorkspaceUserRole.mockReturnValue(true);
12588
+ const cookie = createSessionTokenCookie('session-target-role-deny', authConfig, {
12589
+ userId: 'actor-user',
12590
+ role: 'owner',
12591
+ workspaceId: 'workspace-home'
12592
+ });
12593
+ const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
12594
+ const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
12595
+ workspaceId: 'workspace-shared',
12596
+ role: 'admin'
12597
+ }, {
12598
+ cookie,
12599
+ 'x-taskforce-runtime-mode': 'cloud',
12600
+ 'x-taskforce-role': 'owner',
12601
+ 'x-taskforce-user-id': 'actor-user',
12602
+ 'x-taskforce-workspace-id': 'workspace-home'
12603
+ });
12604
+ const res = createMockRes();
12605
+ await match?.handler(req, res, { id: 'target-user' });
12606
+ expect(core.updateWorkspaceUserRole).not.toHaveBeenCalled();
12607
+ expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
12608
+ expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
12609
+ });
12610
+ it('PATCH /api/taskforce/admin/users/:id/role should allow target-workspace admins with a different active workspace', async () => {
12611
+ const authConfig = createCloudAuthConfig();
12612
+ routes = createRoutes(core, { authConfig });
12613
+ core.listUserWorkspaces.mockReturnValue([
12614
+ { id: 'workspace-home', slug: 'home', name: 'Home', role: 'member', status: 'active', betaAccess: true },
12615
+ { id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'admin', status: 'active', betaAccess: true }
12616
+ ]);
12617
+ core.listWorkspaceUsers.mockImplementation((workspaceId) => {
12618
+ if (workspaceId === 'workspace-home') {
12619
+ return [{
12620
+ userId: 'actor-user',
12621
+ email: 'actor@example.com',
12622
+ displayName: 'Actor',
12623
+ role: 'member',
12624
+ permissionMode: 'read-write',
12625
+ betaAccess: true,
12626
+ status: 'active',
12627
+ disabled: false
12628
+ }];
12629
+ }
12630
+ if (workspaceId === 'workspace-shared') {
12631
+ return [{
12632
+ userId: 'actor-user',
12633
+ email: 'actor@example.com',
12634
+ displayName: 'Actor',
12635
+ role: 'admin',
12636
+ permissionMode: 'read-write',
12637
+ betaAccess: true,
12638
+ status: 'active',
12639
+ disabled: false
12640
+ }];
12641
+ }
12642
+ return [];
12643
+ });
12644
+ core.updateWorkspaceUserRole.mockReturnValue(true);
12645
+ const cookie = createSessionTokenCookie('session-target-role-allow', authConfig, {
12646
+ userId: 'actor-user',
12647
+ role: 'member',
12648
+ workspaceId: 'workspace-home'
12649
+ });
12650
+ const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
12651
+ const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
12652
+ workspaceId: 'workspace-shared',
12653
+ role: 'admin'
12654
+ }, {
12655
+ cookie,
12656
+ 'x-taskforce-runtime-mode': 'cloud',
12657
+ 'x-taskforce-role': 'member',
12658
+ 'x-taskforce-user-id': 'actor-user',
12659
+ 'x-taskforce-workspace-id': 'workspace-home'
12660
+ });
12661
+ const res = createMockRes();
12662
+ await match?.handler(req, res, { id: 'target-user' });
12663
+ expect(core.evaluateFeatureAccess).toHaveBeenCalledWith({
12664
+ userId: 'actor-user',
12665
+ workspaceId: 'workspace-shared',
12666
+ featureKey: 'collaboration.team_management'
12667
+ });
12668
+ expect(core.updateWorkspaceUserRole).toHaveBeenCalledWith('target-user', 'workspace-shared', 'admin');
12669
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
12670
+ action: 'update-workspace-user-role',
12671
+ actorUserId: 'actor-user',
12672
+ actorRole: 'admin',
12673
+ workspaceId: 'workspace-shared',
12674
+ details: expect.objectContaining({
12675
+ workspaceId: 'workspace-shared',
12676
+ targetUserId: 'target-user'
12677
+ })
12678
+ }));
12679
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10770
12680
  });
10771
12681
  it('GET /api/taskforce/workspace/assignee-options should reject unauthenticated cloud requests', async () => {
10772
12682
  const authConfig = {
@@ -10861,6 +12771,7 @@ describe('Server Routes', () => {
10861
12771
  userId: 'member-1',
10862
12772
  email: 'member1@example.com',
10863
12773
  displayName: 'Member One',
12774
+ avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
10864
12775
  role: 'member',
10865
12776
  permissionMode: 'read-write',
10866
12777
  betaAccess: true,
@@ -10882,11 +12793,14 @@ describe('Server Routes', () => {
10882
12793
  provider: 'OpenAI',
10883
12794
  model: 'gpt-5.4',
10884
12795
  surfaceType: 'coding_tool',
12796
+ seatScope: null,
12797
+ archivedAt: null,
10885
12798
  createdAt: '2026-03-01T00:00:00.000Z',
10886
12799
  updatedAt: '2026-03-01T00:00:00.000Z',
10887
12800
  lastActiveAt: null
10888
12801
  }
10889
12802
  ]);
12803
+ core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
10890
12804
  const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
10891
12805
  const cookie = createSessionTokenCookie('session-member-assignees', authConfig, {
10892
12806
  userId: 'member-user',
@@ -10903,9 +12817,14 @@ describe('Server Routes', () => {
10903
12817
  const res = createMockRes();
10904
12818
  await match?.handler(req, res, {});
10905
12819
  expect(core.listWorkspaceUsers).toHaveBeenCalledWith('workspace-1');
10906
- expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1');
12820
+ expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
12821
+ diagnostics: expect.any(Object)
12822
+ }));
12823
+ expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
12824
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('assignee-options'));
10907
12825
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10908
12826
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12827
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
10909
12828
  expect(payload.assignees).toEqual([
10910
12829
  {
10911
12830
  value: 'member-1',
@@ -10916,6 +12835,7 @@ describe('Server Routes', () => {
10916
12835
  userId: 'member-1',
10917
12836
  email: 'member1@example.com',
10918
12837
  displayName: 'Member One',
12838
+ avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
10919
12839
  status: 'active',
10920
12840
  disabled: false
10921
12841
  },
@@ -10925,19 +12845,104 @@ describe('Server Routes', () => {
10925
12845
  username: 'codex',
10926
12846
  icon: 'Bot',
10927
12847
  color: '#ef6c00',
12848
+ avatarUrl: null,
12849
+ avatarSourceUrl: null,
12850
+ avatarRevision: 0,
12851
+ avatarUpdatedAt: null,
10928
12852
  kind: 'agent',
10929
12853
  description: 'Builds product code',
10930
12854
  role: 'Implementer',
10931
12855
  provider: 'OpenAI',
10932
12856
  model: 'gpt-5.4',
10933
12857
  surfaceType: 'coding_tool',
12858
+ seatScope: null,
12859
+ archivedAt: null,
12860
+ createdAt: '2026-03-01T00:00:00.000Z',
12861
+ updatedAt: '2026-03-01T00:00:00.000Z',
12862
+ lastActiveAt: null
12863
+ }
12864
+ ]);
12865
+ });
12866
+ it('GET /api/taskforce/workspace/assignee-options should support agent-only roster loads', async () => {
12867
+ const authConfig = {
12868
+ runtimeMode: 'cloud',
12869
+ enabled: true,
12870
+ requiredForApi: true,
12871
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
12872
+ defaultRole: 'member',
12873
+ defaultWorkspaceId: 'default',
12874
+ sessionCookieName: 'taskforce_session',
12875
+ sessionSecret: 'test-secret',
12876
+ sessionTtlSeconds: 3600
12877
+ };
12878
+ routes = createRoutes(core, { authConfig });
12879
+ core.listWorkspaceUsers.mockReturnValue([
12880
+ {
12881
+ userId: 'member-1',
12882
+ email: 'member1@example.com',
12883
+ displayName: 'Member One',
12884
+ avatarUrl: null,
12885
+ role: 'member',
12886
+ permissionMode: 'read-write',
12887
+ betaAccess: true,
12888
+ status: 'active',
12889
+ disabled: false
12890
+ }
12891
+ ]);
12892
+ core.listAiProfiles.mockReturnValue([
12893
+ {
12894
+ id: 'ai-profile-1',
12895
+ workspaceId: 'workspace-1',
12896
+ profileToken: 'tfp_codex_sync',
12897
+ name: 'Codex',
12898
+ username: 'codex',
12899
+ icon: 'Bot',
12900
+ color: '#ef6c00',
12901
+ description: 'Builds product code',
12902
+ role: 'Implementer',
12903
+ provider: 'OpenAI',
12904
+ model: 'gpt-5.4',
12905
+ surfaceType: 'coding_tool',
12906
+ seatScope: 'cloud_metered',
12907
+ archivedAt: null,
10934
12908
  createdAt: '2026-03-01T00:00:00.000Z',
10935
12909
  updatedAt: '2026-03-01T00:00:00.000Z',
10936
12910
  lastActiveAt: null
10937
12911
  }
10938
12912
  ]);
12913
+ core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
12914
+ const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
12915
+ const cookie = createSessionTokenCookie('session-member-agent-roster', authConfig, {
12916
+ userId: 'member-user',
12917
+ role: 'member',
12918
+ workspaceId: 'workspace-1'
12919
+ });
12920
+ const req = createMockReq('GET', '/api/taskforce/workspace/assignee-options?kind=agent', undefined, {
12921
+ cookie,
12922
+ 'x-taskforce-runtime-mode': 'cloud',
12923
+ 'x-taskforce-role': 'member',
12924
+ 'x-taskforce-user-id': 'member-user',
12925
+ 'x-taskforce-workspace-id': 'workspace-1'
12926
+ });
12927
+ const res = createMockRes();
12928
+ await match?.handler(req, res, {});
12929
+ expect(core.listWorkspaceUsers).not.toHaveBeenCalled();
12930
+ expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
12931
+ diagnostics: expect.any(Object)
12932
+ }));
12933
+ expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
12934
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
12935
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
12936
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
12937
+ expect(payload.assignees).toEqual([
12938
+ expect.objectContaining({
12939
+ value: 'ai-profile-1',
12940
+ label: 'Codex',
12941
+ kind: 'agent'
12942
+ })
12943
+ ]);
10939
12944
  });
10940
- it('GET /api/taskforce/workspace/assignee-options should include ai profile tokens for workspace admins', async () => {
12945
+ it('GET /api/taskforce/workspace/assignee-options should not include ai profile tokens for workspace admins', async () => {
10941
12946
  const authConfig = {
10942
12947
  runtimeMode: 'cloud',
10943
12948
  enabled: true,
@@ -10970,6 +12975,7 @@ describe('Server Routes', () => {
10970
12975
  lastActiveAt: null
10971
12976
  }
10972
12977
  ]);
12978
+ core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
10973
12979
  const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
10974
12980
  const cookie = createSessionTokenCookie('session-admin-assignees', authConfig, {
10975
12981
  userId: 'admin-user',
@@ -10986,12 +12992,384 @@ describe('Server Routes', () => {
10986
12992
  const res = createMockRes();
10987
12993
  await match?.handler(req, res, {});
10988
12994
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12995
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
10989
12996
  expect(payload.assignees).toEqual([
10990
12997
  expect.objectContaining({
10991
12998
  value: 'ai-profile-1',
10992
- profileToken: 'tfp_codex_sync'
12999
+ label: 'Codex'
10993
13000
  })
10994
13001
  ]);
13002
+ expect(payload.assignees[0]).not.toHaveProperty('profileToken');
13003
+ });
13004
+ it('POST /api/taskforce/workspace/ai-profiles/avatar should reject direct avatar URL writes', async () => {
13005
+ const authConfig = {
13006
+ runtimeMode: 'cloud',
13007
+ enabled: true,
13008
+ requiredForApi: true,
13009
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13010
+ defaultRole: 'member',
13011
+ defaultWorkspaceId: 'default',
13012
+ sessionCookieName: 'taskforce_session',
13013
+ sessionSecret: 'test-secret',
13014
+ sessionTtlSeconds: 3600
13015
+ };
13016
+ routes = createRoutes(core, { authConfig });
13017
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes);
13018
+ const cookie = createSessionTokenCookie('session-admin-avatar-ai-profile-reject', authConfig, {
13019
+ userId: 'admin-user',
13020
+ role: 'admin',
13021
+ workspaceId: 'workspace-1'
13022
+ });
13023
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/avatar', {
13024
+ profileId: 'ai-profile-1',
13025
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Favatar.webp'
13026
+ }, {
13027
+ cookie,
13028
+ 'x-taskforce-runtime-mode': 'cloud',
13029
+ 'x-taskforce-role': 'admin',
13030
+ 'x-taskforce-user-id': 'admin-user',
13031
+ 'x-taskforce-workspace-id': 'workspace-1'
13032
+ });
13033
+ const res = createMockRes();
13034
+ await match?.handler(req, res, {});
13035
+ expect(core.updateAiProfileAvatar).not.toHaveBeenCalled();
13036
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
13037
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13038
+ expect(payload).toEqual({
13039
+ error: 'AI profile avatar updates must use the stable avatar upload endpoint.',
13040
+ code: 'AI_PROFILE_AVATAR_STABLE_UPLOAD_REQUIRED'
13041
+ });
13042
+ });
13043
+ it('POST /api/taskforce/workspace/ai-profiles/avatar should allow clear-only updates and redact profile tokens', async () => {
13044
+ const authConfig = {
13045
+ runtimeMode: 'cloud',
13046
+ enabled: true,
13047
+ requiredForApi: true,
13048
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13049
+ defaultRole: 'member',
13050
+ defaultWorkspaceId: 'default',
13051
+ sessionCookieName: 'taskforce_session',
13052
+ sessionSecret: 'test-secret',
13053
+ sessionTtlSeconds: 3600
13054
+ };
13055
+ routes = createRoutes(core, { authConfig });
13056
+ core.listAiProfiles.mockReturnValue([
13057
+ {
13058
+ id: 'ai-profile-1',
13059
+ workspaceId: 'workspace-1',
13060
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-profile-1%2Favatar%2Fdisplay.webp',
13061
+ avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-profile-1%2Favatar%2Fsource.webp'
13062
+ }
13063
+ ]);
13064
+ core.updateAiProfileAvatar.mockReturnValue({
13065
+ id: 'ai-profile-1',
13066
+ workspaceId: 'workspace-1',
13067
+ profileToken: 'tfp_codex_sync',
13068
+ name: 'Codex',
13069
+ username: 'codex',
13070
+ icon: 'Bot',
13071
+ color: '#ef6c00',
13072
+ avatarUrl: null,
13073
+ avatarSourceUrl: null,
13074
+ description: 'Builds product code',
13075
+ role: 'Implementer',
13076
+ provider: 'OpenAI',
13077
+ model: 'gpt-5.4',
13078
+ surfaceType: 'coding_tool',
13079
+ archivedAt: null,
13080
+ createdAt: '2026-03-01T00:00:00.000Z',
13081
+ updatedAt: '2026-04-19T17:00:00.000Z',
13082
+ lastActiveAt: null
13083
+ });
13084
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes);
13085
+ const cookie = createSessionTokenCookie('session-admin-avatar-ai-profile-clear', authConfig, {
13086
+ userId: 'admin-user',
13087
+ role: 'admin',
13088
+ workspaceId: 'workspace-1'
13089
+ });
13090
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/avatar', {
13091
+ profileId: 'ai-profile-1',
13092
+ avatarUrl: null,
13093
+ avatarSourceUrl: null
13094
+ }, {
13095
+ cookie,
13096
+ 'x-taskforce-runtime-mode': 'cloud',
13097
+ 'x-taskforce-role': 'admin',
13098
+ 'x-taskforce-user-id': 'admin-user',
13099
+ 'x-taskforce-workspace-id': 'workspace-1'
13100
+ });
13101
+ const res = createMockRes();
13102
+ await match?.handler(req, res, {});
13103
+ expect(core.updateAiProfileAvatar).toHaveBeenCalledWith({
13104
+ workspaceId: 'workspace-1',
13105
+ profileId: 'ai-profile-1',
13106
+ avatarUrl: null,
13107
+ avatarSourceUrl: null
13108
+ });
13109
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
13110
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13111
+ expect(payload.success).toBe(true);
13112
+ expect(payload.profile).toEqual(expect.objectContaining({
13113
+ id: 'ai-profile-1',
13114
+ avatarUrl: null,
13115
+ avatarSourceUrl: null
13116
+ }));
13117
+ expect(payload.profile).not.toHaveProperty('profileToken');
13118
+ });
13119
+ it('POST /api/taskforce/workspace/ai-profiles/merge should redact profile tokens from admin responses', async () => {
13120
+ const authConfig = {
13121
+ runtimeMode: 'cloud',
13122
+ enabled: true,
13123
+ requiredForApi: true,
13124
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13125
+ defaultRole: 'member',
13126
+ defaultWorkspaceId: 'default',
13127
+ sessionCookieName: 'taskforce_session',
13128
+ sessionSecret: 'test-secret',
13129
+ sessionTtlSeconds: 3600
13130
+ };
13131
+ routes = createRoutes(core, { authConfig });
13132
+ core.mergeAiProfiles.mockReturnValue({
13133
+ id: 'ai-profile-keep',
13134
+ workspaceId: 'workspace-1',
13135
+ profileToken: 'tfp_codex_sync',
13136
+ name: 'Codex',
13137
+ username: 'codex',
13138
+ icon: 'Bot',
13139
+ color: '#ef6c00',
13140
+ description: 'Builds product code',
13141
+ role: 'Implementer',
13142
+ provider: 'OpenAI',
13143
+ model: 'gpt-5.4',
13144
+ surfaceType: 'coding_tool',
13145
+ archivedAt: null,
13146
+ createdAt: '2026-03-01T00:00:00.000Z',
13147
+ updatedAt: '2026-04-19T17:00:00.000Z',
13148
+ lastActiveAt: null
13149
+ });
13150
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/merge', routes);
13151
+ const cookie = createSessionTokenCookie('session-admin-merge-ai-profile', authConfig, {
13152
+ userId: 'admin-user',
13153
+ role: 'admin',
13154
+ workspaceId: 'workspace-1'
13155
+ });
13156
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/merge', {
13157
+ keepId: 'ai-profile-keep',
13158
+ mergeId: 'ai-profile-merge'
13159
+ }, {
13160
+ cookie,
13161
+ 'x-taskforce-runtime-mode': 'cloud',
13162
+ 'x-taskforce-role': 'admin',
13163
+ 'x-taskforce-user-id': 'admin-user',
13164
+ 'x-taskforce-workspace-id': 'workspace-1'
13165
+ });
13166
+ const res = createMockRes();
13167
+ await match?.handler(req, res, {});
13168
+ expect(core.mergeAiProfiles).toHaveBeenCalledWith({
13169
+ workspaceId: 'workspace-1',
13170
+ keepId: 'ai-profile-keep',
13171
+ mergeId: 'ai-profile-merge'
13172
+ });
13173
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
13174
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13175
+ expect(payload.profile).toEqual(expect.objectContaining({
13176
+ id: 'ai-profile-keep',
13177
+ name: 'Codex'
13178
+ }));
13179
+ expect(payload.profile).not.toHaveProperty('profileToken');
13180
+ });
13181
+ it('POST /api/taskforce/workspace/ai-profiles/archive should archive active profiles for workspace admins', async () => {
13182
+ const authConfig = {
13183
+ runtimeMode: 'cloud',
13184
+ enabled: true,
13185
+ requiredForApi: true,
13186
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13187
+ defaultRole: 'member',
13188
+ defaultWorkspaceId: 'default',
13189
+ sessionCookieName: 'taskforce_session',
13190
+ sessionSecret: 'test-secret',
13191
+ sessionTtlSeconds: 3600
13192
+ };
13193
+ routes = createRoutes(core, { authConfig });
13194
+ core.archiveAiProfile.mockReturnValue({
13195
+ id: 'ai-profile-1',
13196
+ workspaceId: 'workspace-1',
13197
+ profileToken: 'tfp_codex_sync',
13198
+ name: 'Codex',
13199
+ username: 'codex',
13200
+ icon: 'Bot',
13201
+ color: '#ef6c00',
13202
+ description: 'Builds product code',
13203
+ role: 'Implementer',
13204
+ provider: 'OpenAI',
13205
+ model: 'gpt-5.4',
13206
+ surfaceType: 'coding_tool',
13207
+ archivedAt: '2026-04-19T17:00:00.000Z',
13208
+ archivedBy: 'admin-user',
13209
+ archivedReason: 'manual_archive',
13210
+ mergedIntoProfileId: null,
13211
+ createdAt: '2026-03-01T00:00:00.000Z',
13212
+ updatedAt: '2026-04-19T17:00:00.000Z',
13213
+ lastActiveAt: null
13214
+ });
13215
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
13216
+ const cookie = createSessionTokenCookie('session-admin-archive-ai-profile', authConfig, {
13217
+ userId: 'admin-user',
13218
+ role: 'admin',
13219
+ workspaceId: 'workspace-1'
13220
+ });
13221
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
13222
+ profileId: 'ai-profile-1',
13223
+ reason: 'manual_archive'
13224
+ }, {
13225
+ cookie,
13226
+ 'x-taskforce-runtime-mode': 'cloud',
13227
+ 'x-taskforce-role': 'admin',
13228
+ 'x-taskforce-user-id': 'admin-user',
13229
+ 'x-taskforce-workspace-id': 'workspace-1'
13230
+ });
13231
+ const res = createMockRes();
13232
+ await match?.handler(req, res, {});
13233
+ expect(core.archiveAiProfile).toHaveBeenCalledWith({
13234
+ workspaceId: 'workspace-1',
13235
+ profileId: 'ai-profile-1',
13236
+ archivedBy: 'admin-user',
13237
+ reason: 'manual_archive'
13238
+ });
13239
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
13240
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13241
+ expect(payload.profile).toEqual(expect.objectContaining({
13242
+ id: 'ai-profile-1',
13243
+ archivedReason: 'manual_archive'
13244
+ }));
13245
+ expect(payload.profile).not.toHaveProperty('profileToken');
13246
+ });
13247
+ it('POST /api/taskforce/workspace/ai-profiles/archive should honor actual owner membership even when the request role is stale', async () => {
13248
+ const authConfig = {
13249
+ runtimeMode: 'cloud',
13250
+ enabled: true,
13251
+ requiredForApi: true,
13252
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13253
+ defaultRole: 'member',
13254
+ defaultWorkspaceId: 'default',
13255
+ sessionCookieName: 'taskforce_session',
13256
+ sessionSecret: 'test-secret',
13257
+ sessionTtlSeconds: 3600
13258
+ };
13259
+ routes = createRoutes(core, { authConfig });
13260
+ core.listUserWorkspaces.mockReturnValue([
13261
+ {
13262
+ id: 'workspace-1',
13263
+ slug: 'workspace-1',
13264
+ name: 'Workspace 1',
13265
+ description: null,
13266
+ role: 'owner',
13267
+ status: 'active',
13268
+ betaAccess: true
13269
+ }
13270
+ ]);
13271
+ core.listWorkspaceUsers.mockReturnValue([
13272
+ {
13273
+ userId: 'owner-user',
13274
+ email: 'owner@example.com',
13275
+ displayName: 'Owner User',
13276
+ role: 'owner',
13277
+ permissionMode: 'read-write',
13278
+ betaAccess: true,
13279
+ status: 'active',
13280
+ disabled: false
13281
+ }
13282
+ ]);
13283
+ core.archiveAiProfile.mockReturnValue({
13284
+ id: 'ai-profile-1',
13285
+ workspaceId: 'workspace-1',
13286
+ profileToken: 'tfp_codex_sync',
13287
+ name: 'Codex',
13288
+ username: 'codex',
13289
+ icon: 'Bot',
13290
+ color: '#ef6c00',
13291
+ description: 'Builds product code',
13292
+ role: 'Implementer',
13293
+ provider: 'OpenAI',
13294
+ model: 'gpt-5.4',
13295
+ surfaceType: 'coding_tool',
13296
+ archivedAt: '2026-04-19T17:00:00.000Z',
13297
+ archivedBy: 'owner-user',
13298
+ archivedReason: 'manual_archive',
13299
+ mergedIntoProfileId: null,
13300
+ createdAt: '2026-03-01T00:00:00.000Z',
13301
+ updatedAt: '2026-04-19T17:00:00.000Z',
13302
+ lastActiveAt: null
13303
+ });
13304
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
13305
+ const cookie = createSessionTokenCookie('session-owner-archive', authConfig, {
13306
+ userId: 'owner-user',
13307
+ role: 'member',
13308
+ workspaceId: 'workspace-1'
13309
+ });
13310
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
13311
+ profileId: 'ai-profile-1'
13312
+ }, {
13313
+ cookie,
13314
+ 'x-taskforce-runtime-mode': 'cloud',
13315
+ 'x-taskforce-role': 'member',
13316
+ 'x-taskforce-user-id': 'owner-user',
13317
+ 'x-taskforce-workspace-id': 'workspace-1'
13318
+ });
13319
+ const res = createMockRes();
13320
+ await match?.handler(req, res, {});
13321
+ expect(core.archiveAiProfile).toHaveBeenCalledWith(expect.objectContaining({
13322
+ workspaceId: 'workspace-1',
13323
+ profileId: 'ai-profile-1',
13324
+ archivedBy: 'owner-user'
13325
+ }));
13326
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
13327
+ });
13328
+ it('POST /api/taskforce/workspace/ai-profiles/archive should surface open-task conflicts', async () => {
13329
+ const authConfig = {
13330
+ runtimeMode: 'cloud',
13331
+ enabled: true,
13332
+ requiredForApi: true,
13333
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13334
+ defaultRole: 'member',
13335
+ defaultWorkspaceId: 'default',
13336
+ sessionCookieName: 'taskforce_session',
13337
+ sessionSecret: 'test-secret',
13338
+ sessionTtlSeconds: 3600
13339
+ };
13340
+ routes = createRoutes(core, { authConfig });
13341
+ const error = new Error('This AI profile still owns open tasks. Reassign or close them before removing it from the roster.');
13342
+ error.statusCode = 409;
13343
+ error.details = { workspaceId: 'workspace-1', profileId: 'ai-profile-1', openTaskCount: 2 };
13344
+ core.archiveAiProfile.mockImplementation(() => {
13345
+ throw error;
13346
+ });
13347
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
13348
+ const cookie = createSessionTokenCookie('session-admin-archive-ai-profile-conflict', authConfig, {
13349
+ userId: 'admin-user',
13350
+ role: 'admin',
13351
+ workspaceId: 'workspace-1'
13352
+ });
13353
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
13354
+ profileId: 'ai-profile-1',
13355
+ reason: 'manual_archive'
13356
+ }, {
13357
+ cookie,
13358
+ 'x-taskforce-runtime-mode': 'cloud',
13359
+ 'x-taskforce-role': 'admin',
13360
+ 'x-taskforce-user-id': 'admin-user',
13361
+ 'x-taskforce-workspace-id': 'workspace-1'
13362
+ });
13363
+ const res = createMockRes();
13364
+ await match?.handler(req, res, {});
13365
+ expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
13366
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13367
+ expect(payload.error).toContain('This AI profile still owns open tasks');
13368
+ expect(payload.details).toEqual({
13369
+ workspaceId: 'workspace-1',
13370
+ profileId: 'ai-profile-1',
13371
+ openTaskCount: 2
13372
+ });
10995
13373
  });
10996
13374
  it('GET /api/taskforce/admin/workspace-audit-logs should return events for workspace admins', async () => {
10997
13375
  const authConfig = {
@@ -11090,13 +13468,13 @@ describe('Server Routes', () => {
11090
13468
  billing: {
11091
13469
  onboardingPolicy: {
11092
13470
  mode: 'auto_grant_free',
11093
- planVersionId: 'starter-v1'
13471
+ planId: 'starter'
11094
13472
  }
11095
13473
  }
11096
13474
  });
11097
13475
  core.getOnboardingPolicy.mockReturnValue({
11098
13476
  mode: 'auto_grant_free',
11099
- planVersionId: 'starter-v1'
13477
+ planId: 'starter'
11100
13478
  });
11101
13479
  const match = matchRoute('GET', '/api/taskforce/admin/system-settings', routes);
11102
13480
  const req = createMockReq('GET', '/api/taskforce/admin/system-settings', undefined, { 'x-taskforce-role': 'admin' });
@@ -11104,9 +13482,9 @@ describe('Server Routes', () => {
11104
13482
  await match?.handler(req, res, {});
11105
13483
  const payload = JSON.parse(res.end.mock.calls[0][0]);
11106
13484
  expect(payload.status.onboardingPolicyMode).toBe('auto_grant_free');
11107
- expect(payload.status.onboardingPlanVersionId).toBe('starter-v1');
13485
+ expect(payload.status.onboardingPlanId).toBe('starter');
11108
13486
  expect(payload.settings.onboardingPolicyMode).toBe('auto_grant_free');
11109
- expect(payload.settings.onboardingPlanVersionId).toBe('starter-v1');
13487
+ expect(payload.settings.onboardingPlanId).toBe('starter');
11110
13488
  });
11111
13489
  it('POST /api/taskforce/admin/system-settings should persist onboarding policy fields', async () => {
11112
13490
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
@@ -11118,13 +13496,13 @@ describe('Server Routes', () => {
11118
13496
  authRateLimitMaxRequests: 10,
11119
13497
  authRateLimitWindowMs: 900000,
11120
13498
  onboardingPolicyMode: 'auto_grant_free',
11121
- onboardingPlanVersionId: 'starter-v1'
13499
+ onboardingPlanId: 'starter'
11122
13500
  }, { 'x-taskforce-role': 'admin' });
11123
13501
  const res = createMockRes();
11124
13502
  await match?.handler(req, res, {});
11125
13503
  expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
11126
13504
  mode: 'auto_grant_free',
11127
- planVersionId: 'starter-v1'
13505
+ planId: 'starter'
11128
13506
  });
11129
13507
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
11130
13508
  auth: {
@@ -11138,7 +13516,7 @@ describe('Server Routes', () => {
11138
13516
  billing: {
11139
13517
  onboardingPolicy: {
11140
13518
  mode: 'auto_grant_free',
11141
- planVersionId: 'starter-v1'
13519
+ planId: 'starter'
11142
13520
  }
11143
13521
  }
11144
13522
  });
@@ -11146,7 +13524,7 @@ describe('Server Routes', () => {
11146
13524
  it('POST /api/taskforce/admin/system-settings should preserve onboarding policy when onboarding fields are omitted', async () => {
11147
13525
  core.getOnboardingPolicy.mockReturnValue({
11148
13526
  mode: 'auto_grant_free',
11149
- planVersionId: 'starter-v1'
13527
+ planId: 'starter'
11150
13528
  });
11151
13529
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
11152
13530
  const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
@@ -11161,7 +13539,7 @@ describe('Server Routes', () => {
11161
13539
  await match?.handler(req, res, {});
11162
13540
  expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
11163
13541
  mode: 'auto_grant_free',
11164
- planVersionId: 'starter-v1'
13542
+ planId: 'starter'
11165
13543
  });
11166
13544
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
11167
13545
  auth: {
@@ -11175,7 +13553,7 @@ describe('Server Routes', () => {
11175
13553
  billing: {
11176
13554
  onboardingPolicy: {
11177
13555
  mode: 'auto_grant_free',
11178
- planVersionId: 'starter-v1'
13556
+ planId: 'starter'
11179
13557
  }
11180
13558
  }
11181
13559
  });
@@ -11184,7 +13562,11 @@ describe('Server Routes', () => {
11184
13562
  core.getGlobalSettings.mockReturnValue({
11185
13563
  site: {
11186
13564
  showRunTaskforceLocally: false,
11187
- pricingPageEnabled: false
13565
+ pricingPageEnabled: false,
13566
+ animatedBackgroundEnabled: true,
13567
+ siteTheme: 'light',
13568
+ pricingPageTitle: 'Choose Your Plan',
13569
+ pricingPageDescription: 'Compare plans and pick the right one.'
11188
13570
  }
11189
13571
  });
11190
13572
  const match = matchRoute('GET', '/api/taskforce/admin/site-settings', routes);
@@ -11194,14 +13576,26 @@ describe('Server Routes', () => {
11194
13576
  const payload = JSON.parse(res.end.mock.calls[0][0]);
11195
13577
  expect(payload.status.showRunTaskforceLocally).toBe(false);
11196
13578
  expect(payload.status.pricingPageEnabled).toBe(false);
13579
+ expect(payload.status.animatedBackgroundEnabled).toBe(true);
13580
+ expect(payload.status.siteTheme).toBe('light');
13581
+ expect(payload.status.pricingPageTitle).toBe('Choose Your Plan');
13582
+ expect(payload.status.pricingPageDescription).toBe('Compare plans and pick the right one.');
11197
13583
  expect(payload.settings.showRunTaskforceLocally).toBe(false);
11198
13584
  expect(payload.settings.pricingPageEnabled).toBe(false);
13585
+ expect(payload.settings.animatedBackgroundEnabled).toBe(true);
13586
+ expect(payload.settings.siteTheme).toBe('light');
13587
+ expect(payload.settings.pricingPageTitle).toBe('Choose Your Plan');
13588
+ expect(payload.settings.pricingPageDescription).toBe('Compare plans and pick the right one.');
11199
13589
  });
11200
13590
  it('GET /api/taskforce/admin/site_settings should expose effective website settings for legacy admin bundles', async () => {
11201
13591
  core.getGlobalSettings.mockReturnValue({
11202
13592
  site: {
11203
13593
  showRunTaskforceLocally: false,
11204
- pricingPageEnabled: false
13594
+ pricingPageEnabled: false,
13595
+ animatedBackgroundEnabled: true,
13596
+ siteTheme: 'light',
13597
+ pricingPageTitle: 'Choose Your Plan',
13598
+ pricingPageDescription: 'Compare plans and pick the right one.'
11205
13599
  }
11206
13600
  });
11207
13601
  const match = matchRoute('GET', '/api/taskforce/admin/site_settings', routes);
@@ -11211,21 +13605,37 @@ describe('Server Routes', () => {
11211
13605
  const payload = JSON.parse(res.end.mock.calls[0][0]);
11212
13606
  expect(payload.status.showRunTaskforceLocally).toBe(false);
11213
13607
  expect(payload.status.pricingPageEnabled).toBe(false);
13608
+ expect(payload.status.animatedBackgroundEnabled).toBe(true);
13609
+ expect(payload.status.siteTheme).toBe('light');
13610
+ expect(payload.status.pricingPageTitle).toBe('Choose Your Plan');
13611
+ expect(payload.status.pricingPageDescription).toBe('Compare plans and pick the right one.');
11214
13612
  expect(payload.settings.showRunTaskforceLocally).toBe(false);
11215
13613
  expect(payload.settings.pricingPageEnabled).toBe(false);
13614
+ expect(payload.settings.animatedBackgroundEnabled).toBe(true);
13615
+ expect(payload.settings.siteTheme).toBe('light');
13616
+ expect(payload.settings.pricingPageTitle).toBe('Choose Your Plan');
13617
+ expect(payload.settings.pricingPageDescription).toBe('Compare plans and pick the right one.');
11216
13618
  });
11217
13619
  it('POST /api/taskforce/admin/site-settings should persist website settings', async () => {
11218
13620
  const match = matchRoute('POST', '/api/taskforce/admin/site-settings', routes);
11219
13621
  const req = createMockReq('POST', '/api/taskforce/admin/site-settings', {
11220
13622
  showRunTaskforceLocally: false,
11221
- pricingPageEnabled: false
13623
+ pricingPageEnabled: false,
13624
+ animatedBackgroundEnabled: true,
13625
+ siteTheme: 'light',
13626
+ pricingPageTitle: 'Choose Your Plan',
13627
+ pricingPageDescription: 'Compare plans and pick the right one.'
11222
13628
  }, { 'x-taskforce-role': 'admin' });
11223
13629
  const res = createMockRes();
11224
13630
  await match?.handler(req, res, {});
11225
13631
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
11226
13632
  site: {
11227
13633
  showRunTaskforceLocally: false,
11228
- pricingPageEnabled: false
13634
+ pricingPageEnabled: false,
13635
+ animatedBackgroundEnabled: true,
13636
+ siteTheme: 'light',
13637
+ pricingPageTitle: 'Choose Your Plan',
13638
+ pricingPageDescription: 'Compare plans and pick the right one.'
11229
13639
  }
11230
13640
  });
11231
13641
  });
@@ -11233,14 +13643,22 @@ describe('Server Routes', () => {
11233
13643
  const match = matchRoute('POST', '/api/taskforce/admin/site_settings', routes);
11234
13644
  const req = createMockReq('POST', '/api/taskforce/admin/site_settings', {
11235
13645
  showRunTaskforceLocally: false,
11236
- pricingPageEnabled: false
13646
+ pricingPageEnabled: false,
13647
+ animatedBackgroundEnabled: true,
13648
+ siteTheme: 'light',
13649
+ pricingPageTitle: 'Choose Your Plan',
13650
+ pricingPageDescription: 'Compare plans and pick the right one.'
11237
13651
  }, { 'x-taskforce-role': 'admin' });
11238
13652
  const res = createMockRes();
11239
13653
  await match?.handler(req, res, {});
11240
13654
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
11241
13655
  site: {
11242
13656
  showRunTaskforceLocally: false,
11243
- pricingPageEnabled: false
13657
+ pricingPageEnabled: false,
13658
+ animatedBackgroundEnabled: true,
13659
+ siteTheme: 'light',
13660
+ pricingPageTitle: 'Choose Your Plan',
13661
+ pricingPageDescription: 'Compare plans and pick the right one.'
11244
13662
  }
11245
13663
  });
11246
13664
  });
@@ -11248,7 +13666,11 @@ describe('Server Routes', () => {
11248
13666
  core.getGlobalSettings.mockReturnValue({
11249
13667
  site: {
11250
13668
  showRunTaskforceLocally: false,
11251
- pricingPageEnabled: false
13669
+ pricingPageEnabled: false,
13670
+ animatedBackgroundEnabled: true,
13671
+ siteTheme: 'light',
13672
+ pricingPageTitle: 'Choose Your Plan',
13673
+ pricingPageDescription: 'Compare plans and pick the right one.'
11252
13674
  }
11253
13675
  });
11254
13676
  const match = matchRoute('GET', '/api/taskforce/public/site-config', routes);
@@ -11263,7 +13685,11 @@ describe('Server Routes', () => {
11263
13685
  success: true,
11264
13686
  config: {
11265
13687
  showRunTaskforceLocally: false,
11266
- pricingPageEnabled: false
13688
+ pricingPageEnabled: false,
13689
+ animatedBackgroundEnabled: true,
13690
+ siteTheme: 'light',
13691
+ pricingPageTitle: 'Choose Your Plan',
13692
+ pricingPageDescription: 'Compare plans and pick the right one.'
11267
13693
  }
11268
13694
  });
11269
13695
  });
@@ -11271,7 +13697,11 @@ describe('Server Routes', () => {
11271
13697
  core.getGlobalSettings.mockReturnValue({
11272
13698
  site: {
11273
13699
  showRunTaskforceLocally: false,
11274
- pricingPageEnabled: false
13700
+ pricingPageEnabled: false,
13701
+ animatedBackgroundEnabled: true,
13702
+ siteTheme: 'light',
13703
+ pricingPageTitle: 'Choose Your Plan',
13704
+ pricingPageDescription: 'Compare plans and pick the right one.'
11275
13705
  }
11276
13706
  });
11277
13707
  const match = matchRoute('GET', '/api/taskforce/public/site-config-script', routes);
@@ -11284,17 +13714,20 @@ describe('Server Routes', () => {
11284
13714
  }));
11285
13715
  expect(res.end.mock.calls[0][0]).toContain('window.__TASKFORCE_SITE_CONFIG__ = ');
11286
13716
  expect(res.end.mock.calls[0][0]).toContain('"pricingPageEnabled":false');
13717
+ expect(res.end.mock.calls[0][0]).toContain('"animatedBackgroundEnabled":true');
13718
+ expect(res.end.mock.calls[0][0]).toContain('"siteTheme":"light"');
13719
+ expect(res.end.mock.calls[0][0]).toContain('"pricingPageTitle":"Choose Your Plan"');
11287
13720
  });
11288
13721
  it('POST /api/taskforce/admin/system-settings should reject invalid onboarding settings', async () => {
11289
13722
  core.validateOnboardingPolicy.mockReturnValueOnce({
11290
13723
  valid: false,
11291
- code: 'ONBOARDING_PLAN_VERSION_REQUIRED',
11292
- message: 'An onboarding plan version is required for the selected onboarding mode.'
13724
+ code: 'ONBOARDING_PLAN_REQUIRED',
13725
+ message: 'An onboarding plan is required for the selected onboarding mode.'
11293
13726
  });
11294
13727
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
11295
13728
  const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
11296
13729
  onboardingPolicyMode: 'auto_grant_free',
11297
- onboardingPlanVersionId: null
13730
+ onboardingPlanId: null
11298
13731
  }, { 'x-taskforce-role': 'admin' });
11299
13732
  const res = createMockRes();
11300
13733
  await match?.handler(req, res, {});
@@ -11460,6 +13893,94 @@ describe('Server Routes', () => {
11460
13893
  ]);
11461
13894
  fs.rmSync(dbRoot, { recursive: true, force: true });
11462
13895
  });
13896
+ it('GET /api/taskforce/admin/orphaned-assets should not list AI profile avatar assets still referenced by profiles', async () => {
13897
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
13898
+ const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
13899
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
13900
+ core.getTenantId = vi.fn().mockReturnValue('default');
13901
+ core.listAiProfiles.mockReturnValueOnce([
13902
+ {
13903
+ id: 'ai-profile-codex',
13904
+ workspaceId: 'workspace-1',
13905
+ name: 'Codex',
13906
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
13907
+ avatarSourceUrl: null,
13908
+ createdAt: '2026-03-18T17:00:00.000Z',
13909
+ updatedAt: '2026-03-18T17:05:00.000Z'
13910
+ }
13911
+ ]);
13912
+ store.upsertAsset({
13913
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
13914
+ workspaceId: 'workspace-1',
13915
+ kind: 'image',
13916
+ originalFilename: 'avatar.webp',
13917
+ mimeType: 'image/webp',
13918
+ storageProvider: 'r2',
13919
+ storageKey: avatarStorageKey,
13920
+ contentSha256: 'sha-avatar',
13921
+ sizeBytes: 42,
13922
+ createdAt: '2026-03-18T17:00:00.000Z',
13923
+ updatedAt: '2026-03-18T17:05:00.000Z'
13924
+ });
13925
+ routes = createRoutes(core);
13926
+ const match = matchRoute('GET', '/api/taskforce/admin/orphaned-assets', routes);
13927
+ const req = createMockReq('GET', '/api/taskforce/admin/orphaned-assets?workspaceId=workspace-1', undefined, {
13928
+ 'x-taskforce-role': 'admin',
13929
+ 'x-taskforce-workspace-id': 'workspace-1'
13930
+ });
13931
+ const res = createMockRes();
13932
+ await match?.handler(req, res, {});
13933
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13934
+ expect(payload.success).toBe(true);
13935
+ expect(payload.assets).toEqual([]);
13936
+ fs.rmSync(dbRoot, { recursive: true, force: true });
13937
+ });
13938
+ it('POST /api/taskforce/admin/orphaned-assets/delete should reject AI profile avatar assets still referenced by profiles', async () => {
13939
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
13940
+ const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-cropped.webp';
13941
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
13942
+ core.getTenantId = vi.fn().mockReturnValue('default');
13943
+ core.listAiProfiles.mockReturnValueOnce([
13944
+ {
13945
+ id: 'ai-profile-codex',
13946
+ workspaceId: 'workspace-1',
13947
+ name: 'Codex',
13948
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
13949
+ avatarSourceUrl: null,
13950
+ createdAt: '2026-03-18T17:00:00.000Z',
13951
+ updatedAt: '2026-03-18T17:05:00.000Z'
13952
+ }
13953
+ ]);
13954
+ store.upsertAsset({
13955
+ assetId: 'asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
13956
+ workspaceId: 'workspace-1',
13957
+ kind: 'image',
13958
+ originalFilename: 'avatar.webp',
13959
+ mimeType: 'image/webp',
13960
+ storageProvider: 'r2',
13961
+ storageKey: avatarStorageKey,
13962
+ contentSha256: 'sha-avatar',
13963
+ sizeBytes: 42,
13964
+ createdAt: '2026-03-18T17:00:00.000Z',
13965
+ updatedAt: '2026-03-18T17:05:00.000Z'
13966
+ });
13967
+ routes = createRoutes(core);
13968
+ const match = matchRoute('POST', '/api/taskforce/admin/orphaned-assets/delete', routes);
13969
+ const req = createMockReq('POST', '/api/taskforce/admin/orphaned-assets/delete', {
13970
+ assetId: 'asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
13971
+ workspaceId: 'workspace-1'
13972
+ }, {
13973
+ 'x-taskforce-role': 'admin',
13974
+ 'x-taskforce-workspace-id': 'workspace-1'
13975
+ });
13976
+ const res = createMockRes();
13977
+ await match?.handler(req, res, {});
13978
+ expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
13979
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13980
+ expect(payload.error).toMatch(/referenced by an AI profile avatar/i);
13981
+ expect(store.get('asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'workspace-1')?.deletedAt).toBeNull();
13982
+ fs.rmSync(dbRoot, { recursive: true, force: true });
13983
+ });
11463
13984
  it('POST /api/taskforce/admin/object-storage-settings should persist sanitized values', async () => {
11464
13985
  const match = matchRoute('POST', '/api/taskforce/admin/object-storage-settings', routes);
11465
13986
  const req = createMockReq('POST', '/api/taskforce/admin/object-storage-settings', {