@taskforcehq/taskforce 0.3.304 → 0.3.306

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 (383) hide show
  1. package/README.md +140 -247
  2. package/dist/Taskforce.module.css +295 -89
  3. package/dist/TaskforceCore.js +378 -111
  4. package/dist/TaskforceCore.test.js +1221 -107
  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 +52 -2
  14. package/dist/compat/workspaceSyncCompat.test.js +38 -1
  15. package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
  16. package/dist/components/context/ContextAttachmentManager.js +549 -0
  17. package/dist/components/features/AgentsModule.d.ts +10 -1
  18. package/dist/components/features/AgentsModule.js +260 -15
  19. package/dist/components/features/AgentsModule.test.js +255 -5
  20. package/dist/components/features/DocumentIndex.d.ts +1 -1
  21. package/dist/components/features/DocumentIndex.js +1 -1
  22. package/dist/components/features/DocumentViewer.d.ts +6 -2
  23. package/dist/components/features/DocumentViewer.js +90 -10
  24. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  25. package/dist/components/features/DocumentWorkspace.js +12 -155
  26. package/dist/components/features/DocumentWorkspace.test.js +226 -6
  27. package/dist/components/features/TaskSettings.js +29 -3
  28. package/dist/components/features/TaskSettings.test.js +42 -1
  29. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  30. package/dist/components/features/documentWorkspaceModel.js +57 -0
  31. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  32. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  33. package/dist/components/task/TaskCard.js +19 -28
  34. package/dist/components/task/TaskCard.test.js +38 -3
  35. package/dist/components/task/TaskContextUpload.js +4 -526
  36. package/dist/components/task/TaskForm.d.ts +8 -8
  37. package/dist/components/task/TaskForm.js +362 -230
  38. package/dist/components/task/TaskForm.test.js +558 -381
  39. package/dist/components/task/TaskKanban.d.ts +2 -1
  40. package/dist/components/task/TaskKanban.js +96 -14
  41. package/dist/components/task/TaskKanban.test.js +36 -0
  42. package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
  43. package/dist/components/ui/EditableAvatarButton.js +18 -0
  44. package/dist/components/views/PlanComparisonPage.d.ts +9 -1
  45. package/dist/components/views/PlanComparisonPage.js +70 -17
  46. package/dist/components/views/PlanComparisonPage.test.js +289 -41
  47. package/dist/components/views/PlansPage.js +239 -106
  48. package/dist/components/views/PlansPage.test.js +593 -42
  49. package/dist/components/views/StandaloneLayout.js +310 -554
  50. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  51. package/dist/components/views/panels/PlanningDrawer.d.ts +8 -1
  52. package/dist/components/views/panels/PlanningDrawer.js +18 -11
  53. package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
  54. package/dist/components/views/panels/PlanningDrawer.test.js +141 -0
  55. package/dist/components/views/planningScope.d.ts +20 -0
  56. package/dist/components/views/planningScope.js +25 -0
  57. package/dist/components/views/planningScope.test.d.ts +1 -0
  58. package/dist/components/views/planningScope.test.js +60 -0
  59. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +27 -14
  60. package/dist/components/views/standalone/modals/AccountHubModal.js +87 -15
  61. package/dist/components/views/standalone/modals/AccountHubModal.test.js +42 -2
  62. package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
  63. package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
  64. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
  65. package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
  66. package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
  67. package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
  68. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
  69. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
  70. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
  71. package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
  72. package/dist/config/envSchema.js +1 -1
  73. package/dist/core/AiProfileService.d.ts +45 -3
  74. package/dist/core/AiProfileService.js +774 -63
  75. package/dist/core/AiProfileService.test.js +22 -2
  76. package/dist/core/AiProfiles.test.js +761 -2
  77. package/dist/core/AttachmentLinkService.js +57 -15
  78. package/dist/core/AuthIdentityService.d.ts +107 -0
  79. package/dist/core/AuthIdentityService.js +284 -0
  80. package/dist/core/AuthTokenService.d.ts +4 -0
  81. package/dist/core/AuthTokenService.js +34 -6
  82. package/dist/core/AuthTokenService.test.d.ts +1 -0
  83. package/dist/core/AuthTokenService.test.js +78 -0
  84. package/dist/core/EntitlementsPolicy.test.js +77 -15
  85. package/dist/core/GlobalSettingsService.js +115 -6
  86. package/dist/core/PlanEntitlementService.d.ts +30 -3
  87. package/dist/core/PlanEntitlementService.js +454 -93
  88. package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
  89. package/dist/core/PlanFeatureCatalog.test.js +84 -0
  90. package/dist/core/PlanVersionPolicy.test.js +54 -28
  91. package/dist/core/PlanningEntities.test.js +52 -0
  92. package/dist/core/SignupMonetizationSettings.test.js +241 -44
  93. package/dist/core/SyncReconciliationService.js +2 -65
  94. package/dist/core/SystemAdmin.test.js +244 -88
  95. package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
  96. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  97. package/dist/core/Taskforce.d.ts +228 -14
  98. package/dist/core/Taskforce.js +814 -201
  99. package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
  100. package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
  101. package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
  102. package/dist/core/WorkspaceLifecycleService.js +0 -5
  103. package/dist/core/WorkspacePermissions.test.js +25 -1
  104. package/dist/core/WorkspacePlanMode.test.js +128 -27
  105. package/dist/core/shared.d.ts +3 -1
  106. package/dist/core/shared.js +16 -1
  107. package/dist/core/types.d.ts +68 -2
  108. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +26 -1
  109. package/dist/hooks/sync/auth.js +3 -1
  110. package/dist/hooks/sync/bootstrap.js +2 -0
  111. package/dist/hooks/sync/controlPlane.d.ts +37 -0
  112. package/dist/hooks/sync/controlPlane.js +78 -0
  113. package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
  114. package/dist/hooks/sync/controlPlane.test.js +121 -0
  115. package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
  116. package/dist/hooks/sync/lifecyclePolicy.js +93 -0
  117. package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
  118. package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
  119. package/dist/hooks/sync/orchestratorShared.d.ts +10 -1
  120. package/dist/hooks/sync/orchestratorShared.js +68 -47
  121. package/dist/hooks/sync/orchestratorShared.test.js +50 -1
  122. package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
  123. package/dist/hooks/sync/pullLifecycle.js +24 -0
  124. package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
  125. package/dist/hooks/sync/pullLifecycle.test.js +80 -0
  126. package/dist/hooks/sync/recovery.d.ts +16 -2
  127. package/dist/hooks/sync/recovery.js +171 -53
  128. package/dist/hooks/sync/recovery.test.d.ts +1 -0
  129. package/dist/hooks/sync/recovery.test.js +292 -0
  130. package/dist/hooks/sync/transfers.d.ts +16 -3
  131. package/dist/hooks/sync/transfers.js +186 -65
  132. package/dist/hooks/sync/transfers.test.d.ts +1 -0
  133. package/dist/hooks/sync/transfers.test.js +396 -0
  134. package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
  135. package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
  136. package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
  137. package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
  138. package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
  139. package/dist/hooks/sync/useSyncStatusActions.js +88 -0
  140. package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
  141. package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
  142. package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
  143. package/dist/hooks/sync/useSyncStatusControls.js +60 -0
  144. package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
  145. package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
  146. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
  147. package/dist/hooks/useSyncOrchestrator.d.ts +16 -6
  148. package/dist/hooks/useSyncOrchestrator.js +424 -175
  149. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +487 -10
  150. package/dist/hooks/useTaskData.js +8 -3
  151. package/dist/hooks/useTaskData.test.js +45 -0
  152. package/dist/hooks/useTaskMutations.d.ts +2 -1
  153. package/dist/hooks/useTaskMutations.js +12 -1
  154. package/dist/hooks/useTaskMutations.test.js +30 -1
  155. package/dist/hooks/useTaskforce.d.ts +65 -4
  156. package/dist/hooks/useTaskforce.js +564 -243
  157. package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
  158. package/dist/hooks/useTaskforce.runtime-routing.test.js +152 -0
  159. package/dist/hooks/useTaskforce.sync-behavior.test.js +2454 -208
  160. package/dist/hooks/useWorkspaceSyncController.d.ts +4 -1
  161. package/dist/hooks/useWorkspaceSyncController.js +17 -2
  162. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  163. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
  164. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +316 -35
  165. package/dist/localization/locales/en-US.d.ts +0 -1
  166. package/dist/localization/locales/en-US.js +0 -1
  167. package/dist/localization/locales/es-419.js +0 -1
  168. package/dist/localization/locales/pt-BR.js +0 -1
  169. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  170. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  171. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  172. package/dist/mcp/collaborationRegistrar.js +71 -0
  173. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  174. package/dist/mcp/documentAssetRegistrar.js +346 -0
  175. package/dist/mcp/runtime.d.ts +2 -0
  176. package/dist/mcp/runtime.js +2433 -3534
  177. package/dist/mcp/runtime.test.js +440 -2
  178. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  179. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  180. package/dist/mcp/toolCatalog.d.ts +35 -0
  181. package/dist/mcp/toolCatalog.js +3 -0
  182. package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
  183. package/dist/migrations/taskSchemaMigrations.js +171 -61
  184. package/dist/migrations/taskSchemaMigrations.test.js +15 -0
  185. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  186. package/dist/resources/templates/workflows/collaborate.yaml +1 -1
  187. package/dist/resources/templates/workflows/evaluate.yaml +2 -2
  188. package/dist/resources/templates/workflows/plan.yaml +1 -1
  189. package/dist/resources/templates/workflows/review.yaml +2 -2
  190. package/dist/server/annotatedAttachmentsRoutes.test.js +3 -3
  191. package/dist/server/auth/providers/apple.d.ts +33 -0
  192. package/dist/server/auth/providers/apple.js +82 -0
  193. package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
  194. package/dist/server/auth/providers/appleClientSecret.js +65 -0
  195. package/dist/server/auth/providers/github.d.ts +26 -0
  196. package/dist/server/auth/providers/github.js +86 -0
  197. package/dist/server/auth/providers/google.d.ts +21 -0
  198. package/dist/server/auth/providers/google.js +56 -0
  199. package/dist/server/auth/providers/types.d.ts +21 -0
  200. package/dist/server/auth/providers/types.js +1 -0
  201. package/dist/server/auth.d.ts +2 -0
  202. package/dist/server/auth.js +6 -3
  203. package/dist/server/documentReviewRoutes.test.js +36 -3
  204. package/dist/server/index.cookieProxy.test.js +1 -0
  205. package/dist/server/index.d.ts +12 -0
  206. package/dist/server/index.js +120 -9
  207. package/dist/server/index.rateLimit.test.js +3 -0
  208. package/dist/server/index.test.js +106 -3
  209. package/dist/server/routes/admin.d.ts +4 -5
  210. package/dist/server/routes/admin.js +410 -80
  211. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  212. package/dist/server/routes/annotatedAttachments.js +1 -1
  213. package/dist/server/routes/auth.d.ts +16 -6
  214. package/dist/server/routes/auth.js +1015 -118
  215. package/dist/server/routes/authSupport.d.ts +30 -0
  216. package/dist/server/routes/authSupport.js +81 -0
  217. package/dist/server/routes/billing.d.ts +1 -1
  218. package/dist/server/routes/billing.js +276 -34
  219. package/dist/server/routes/billing.test.js +760 -52
  220. package/dist/server/routes/documentReviews.d.ts +1 -1
  221. package/dist/server/routes/documentReviews.js +1 -1
  222. package/dist/server/routes/documents.d.ts +5 -2
  223. package/dist/server/routes/documents.js +242 -74
  224. package/dist/server/routes/primitives.d.ts +11 -0
  225. package/dist/server/routes/primitives.js +73 -0
  226. package/dist/server/routes/resources.d.ts +1 -1
  227. package/dist/server/routes/resources.js +1 -1
  228. package/dist/server/routes/shared.d.ts +29 -3
  229. package/dist/server/routes/shared.js +69 -4
  230. package/dist/server/routes/sync.d.ts +1 -1
  231. package/dist/server/routes/sync.integration.test.js +437 -39
  232. package/dist/server/routes/sync.js +62 -606
  233. package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
  234. package/dist/server/routes/syncAuxRoutes.js +91 -0
  235. package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
  236. package/dist/server/routes/syncAuxRoutes.test.js +158 -0
  237. package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
  238. package/dist/server/routes/syncPullApplyRoutes.js +204 -0
  239. package/dist/server/routes/syncPushRoutes.d.ts +24 -0
  240. package/dist/server/routes/syncPushRoutes.js +212 -0
  241. package/dist/server/routes/syncRouteGuards.d.ts +36 -0
  242. package/dist/server/routes/syncRouteGuards.js +67 -0
  243. package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
  244. package/dist/server/routes/syncRouteGuards.test.js +94 -0
  245. package/dist/server/routes/syncRouteTypes.d.ts +13 -0
  246. package/dist/server/routes/syncRouteTypes.js +1 -0
  247. package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
  248. package/dist/server/routes/syncSnapshotRoutes.js +180 -0
  249. package/dist/server/routes/tasks.d.ts +1 -1
  250. package/dist/server/routes/tasks.js +44 -2
  251. package/dist/server/routes/workspaces.d.ts +1 -1
  252. package/dist/server/routes/workspaces.js +37 -6
  253. package/dist/server/routes.d.ts +2 -9
  254. package/dist/server/routes.js +136 -165
  255. package/dist/server/routes.test.js +2457 -162
  256. package/dist/services/workflowExportSnapshots.test.js +2 -0
  257. package/dist/shared/aiProfileSeatScope.d.ts +5 -0
  258. package/dist/shared/aiProfileSeatScope.js +21 -0
  259. package/dist/shared/passwordPolicy.d.ts +13 -0
  260. package/dist/shared/passwordPolicy.js +84 -0
  261. package/dist/shared/passwordPolicy.test.d.ts +1 -0
  262. package/dist/shared/passwordPolicy.test.js +34 -0
  263. package/dist/storage/workspaceAssetStore.d.ts +15 -3
  264. package/dist/storage/workspaceAssetStore.js +132 -44
  265. package/dist/storage/workspaceAssetStore.test.js +93 -6
  266. package/dist/styles/fonts.css +70 -0
  267. package/dist/sync/collaborationSyncPayload.d.ts +6 -1
  268. package/dist/sync/collaborationSyncPayload.js +17 -11
  269. package/dist/sync/collaborationSyncPayload.test.js +13 -1
  270. package/dist/sync/contentSyncState.d.ts +4 -8
  271. package/dist/sync/contentSyncState.js +33 -77
  272. package/dist/sync/syncApplyHandlers.d.ts +7 -0
  273. package/dist/sync/syncApplyHandlers.js +28 -13
  274. package/dist/sync/syncApplyHandlers.test.js +72 -3
  275. package/dist/sync/syncFieldSemantics.d.ts +12 -0
  276. package/dist/sync/syncFieldSemantics.js +55 -0
  277. package/dist/sync/syncResourceAdapters.d.ts +44 -0
  278. package/dist/sync/syncResourceAdapters.js +77 -0
  279. package/dist/sync/syncService.d.ts +6 -0
  280. package/dist/sync/syncService.js +60 -0
  281. package/dist/sync/syncService.test.js +71 -3
  282. package/dist/sync/taskSyncChangeKey.d.ts +2 -0
  283. package/dist/sync/taskSyncChangeKey.js +143 -0
  284. package/dist/sync/taskSyncPayload.js +3 -8
  285. package/dist/sync/workspacePullFeed.js +4 -3
  286. package/dist/sync/workspacePullFeed.test.js +43 -0
  287. package/dist/sync/workspaceRepair.js +5 -2
  288. package/dist/sync/workspaceSyncModel.d.ts +48 -0
  289. package/dist/sync/workspaceSyncModel.js +195 -31
  290. package/dist/sync/workspaceSyncModel.test.js +325 -12
  291. package/dist/sync/workspaceSyncState.d.ts +19 -0
  292. package/dist/sync/workspaceSyncState.js +3 -1
  293. package/dist/sync/workspaceSyncSurface.js +3 -8
  294. package/dist/types.d.ts +17 -0
  295. package/dist/ui/assets/AgentsModule-DrLawwNl.js +1 -0
  296. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-Z9_whf7F.js} +1 -1
  297. package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
  298. package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
  299. package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
  300. package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
  301. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +252 -0
  302. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +1 -0
  303. package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-9E6ParrY.js} +1 -1
  304. package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
  305. package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
  306. package/dist/ui/assets/PlansPage-CvfnMiWq.css +1 -0
  307. package/dist/ui/assets/PlansPage-DRXscYxH.js +1 -0
  308. package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
  309. package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
  310. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +9 -0
  311. package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BA7jcEpH.js} +1 -1
  312. package/dist/ui/assets/index-9eT8e0Lu.css +1 -0
  313. package/dist/ui/assets/index-C6k75jr8.js +6 -0
  314. package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-DuEd_65c.js} +1 -1
  315. package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
  316. package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
  317. package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
  318. package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  319. package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
  320. package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
  321. package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
  322. package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
  323. package/dist/ui/fonts/Noir_medium.otf +0 -0
  324. package/dist/ui/fonts/Noir_medium.woff2 +0 -0
  325. package/dist/ui/fonts/Noir_regular.otf +0 -0
  326. package/dist/ui/fonts/Noir_regular.woff2 +0 -0
  327. package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
  328. package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
  329. package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
  330. package/dist/ui/index.html +3 -3
  331. package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
  332. package/dist/utils/assignees.d.ts +2 -0
  333. package/dist/utils/assignees.js +2 -0
  334. package/dist/utils/avatarUpload.d.ts +27 -0
  335. package/dist/utils/avatarUpload.js +132 -0
  336. package/dist/utils/avatarUpload.test.d.ts +1 -0
  337. package/dist/utils/avatarUpload.test.js +40 -0
  338. package/dist/utils/bootstrapTrace.d.ts +7 -0
  339. package/dist/utils/bootstrapTrace.js +29 -0
  340. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  341. package/dist/utils/commercialLifecyclePresentation.js +199 -0
  342. package/dist/utils/contextAssetEvents.d.ts +2 -0
  343. package/dist/utils/syncEventDetails.d.ts +3 -0
  344. package/dist/utils/syncEventDetails.js +64 -0
  345. package/dist/utils/syncEventDetails.test.d.ts +1 -0
  346. package/dist/utils/syncEventDetails.test.js +23 -0
  347. package/dist/utils/syncEventPresentation.d.ts +15 -0
  348. package/dist/utils/syncEventPresentation.js +129 -0
  349. package/dist/utils/syncEventPresentation.test.d.ts +1 -0
  350. package/dist/utils/syncEventPresentation.test.js +64 -0
  351. package/dist/utils/syncStatusPresentation.d.ts +72 -0
  352. package/dist/utils/syncStatusPresentation.js +217 -0
  353. package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
  354. package/dist/utils/syncStatusPresentation.test.js +164 -0
  355. package/dist/utils/taskActivity.d.ts +2 -0
  356. package/dist/utils/taskActivity.js +228 -22
  357. package/dist/utils/taskActivity.test.js +119 -8
  358. package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
  359. package/dist/utils/workspaceSyncPresentation.js +68 -2
  360. package/dist/utils/workspaceSyncPresentation.test.js +36 -1
  361. package/package.json +10 -2
  362. package/scripts/check-cloud-mcp.mjs +83 -0
  363. package/scripts/export-sqlite-to-postgres.mjs +7 -9
  364. package/scripts/playwright-auth.sh +5 -1
  365. package/scripts/run-billing-performance-smoke.sh +24 -0
  366. package/scripts/run-e2e-realtime.sh +17 -3
  367. package/scripts/run-smoke.sh +17 -5
  368. package/scripts/run-soak.sh +17 -5
  369. package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  370. package/src/resources/templates/workflows/collaborate.yaml +1 -1
  371. package/src/resources/templates/workflows/evaluate.yaml +2 -2
  372. package/src/resources/templates/workflows/plan.yaml +1 -1
  373. package/src/resources/templates/workflows/review.yaml +2 -2
  374. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +0 -1
  375. package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
  376. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  377. package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
  378. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
  379. package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
  380. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
  381. package/dist/ui/assets/index-DXUdtH1B.js +0 -6
  382. package/dist/ui/assets/index-Ii0B0rTO.css +0 -1
  383. 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();
@@ -156,6 +161,9 @@ vi.mock('../core/Taskforce', () => {
156
161
  listWorkspaceUsers = vi.fn().mockReturnValue([]);
157
162
  listAssignableActors = vi.fn();
158
163
  listAiProfiles = vi.fn().mockReturnValue([]);
164
+ countAiProfileUsage = vi.fn().mockReturnValue({ used: 0, limit: null, remaining: null });
165
+ mergeAiProfiles = vi.fn();
166
+ archiveAiProfile = vi.fn();
159
167
  replaceWorkspaceUsersForSync = vi.fn().mockReturnValue([]);
160
168
  listDocumentReviewSessionsForSync = vi.fn().mockReturnValue([]);
161
169
  listDocumentReviewCommentsForSync = vi.fn().mockReturnValue([]);
@@ -235,6 +243,7 @@ vi.mock('../core/Taskforce', () => {
235
243
  updatedAt: '2026-01-01T00:00:00.000Z'
236
244
  }
237
245
  ]);
246
+ countSystemUsers = vi.fn().mockReturnValue(1);
238
247
  getSystemRole = vi.fn().mockReturnValue('user');
239
248
  getUserBetaAccess = vi.fn().mockReturnValue(true);
240
249
  setSystemRole = vi.fn().mockReturnValue(true);
@@ -411,7 +420,7 @@ vi.mock('../core/Taskforce', () => {
411
420
  }
412
421
  },
413
422
  {
414
- featureKey: 'workspace.switching',
423
+ featureKey: 'workspace.workspaces',
415
424
  label: 'Workspaces',
416
425
  description: 'Allow multiple workspaces with a configurable workspace limit.',
417
426
  allowedAccessModes: ['enabled', 'disabled'],
@@ -624,6 +633,12 @@ describe('Server Routes', () => {
624
633
  if (typeof body === 'string') {
625
634
  req.push(body);
626
635
  }
636
+ else if (Buffer.isBuffer(body)) {
637
+ req.push(body);
638
+ }
639
+ else if (body instanceof Uint8Array) {
640
+ req.push(Buffer.from(body));
641
+ }
627
642
  else {
628
643
  req.push(body ? JSON.stringify(body) : null);
629
644
  }
@@ -684,6 +699,33 @@ describe('Server Routes', () => {
684
699
  res.__getBody = () => Buffer.concat(bodyChunks).toString('utf8');
685
700
  return res;
686
701
  };
702
+ const withDevAuthTokenExposure = async (value, run) => {
703
+ const previous = process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
704
+ if (value === undefined)
705
+ delete process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
706
+ else
707
+ process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = value;
708
+ try {
709
+ await run();
710
+ }
711
+ finally {
712
+ if (previous === undefined)
713
+ delete process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
714
+ else
715
+ process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = previous;
716
+ }
717
+ };
718
+ const createCloudAuthConfig = () => ({
719
+ runtimeMode: 'cloud',
720
+ enabled: true,
721
+ requiredForApi: true,
722
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
723
+ defaultRole: 'member',
724
+ defaultWorkspaceId: 'default',
725
+ sessionCookieName: 'taskforce_session',
726
+ sessionSecret: 'test-secret',
727
+ sessionTtlSeconds: 3600
728
+ });
687
729
  it('GET /api/taskforce/tasks should return tasks', async () => {
688
730
  const mockTasks = { tasks: [], config: {} };
689
731
  core.listTasks.mockReturnValue(mockTasks);
@@ -695,6 +737,30 @@ describe('Server Routes', () => {
695
737
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
696
738
  expect(res.end).toHaveBeenCalledWith(JSON.stringify(mockTasks));
697
739
  });
740
+ it('GET /api/taskforce/planning/bootstrap should return lightweight planning bootstrap data', async () => {
741
+ const payload = {
742
+ initiatives: [{ id: 'initiative-1', title: 'Launch', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
743
+ workstreams: [{ id: 'workstream-1', title: 'Billing', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
744
+ workstreamTaskSummaries: [{
745
+ workstreamId: 'workstream-1',
746
+ taskCount: 2,
747
+ completedTaskCount: 1,
748
+ tasks: [
749
+ { id: 'task-1', title: 'Draft copy', referenceNumber: 12, status: 'task' },
750
+ { id: 'task-2', title: 'Review copy', referenceNumber: 13, status: 'done' }
751
+ ]
752
+ }]
753
+ };
754
+ core.listPlanningBootstrap.mockReturnValue(payload);
755
+ const match = matchRoute('GET', '/api/taskforce/planning/bootstrap', routes);
756
+ const req = createMockReq('GET', '/api/taskforce/planning/bootstrap');
757
+ const res = createMockRes();
758
+ await match?.handler(req, res, {});
759
+ expect(core.listPlanningBootstrap).toHaveBeenCalledWith('default', expect.any(Object));
760
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('planning-bootstrap'));
761
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
762
+ expect(res.end).toHaveBeenCalledWith(JSON.stringify(payload));
763
+ });
698
764
  it('GET /mcp should return MCP JSON error instead of falling through when unauthenticated in cloud runtime', async () => {
699
765
  const match = matchRoute('GET', '/mcp', routes);
700
766
  const req = createMockReq('GET', '/mcp', undefined, {
@@ -1914,6 +1980,20 @@ describe('Server Routes', () => {
1914
1980
  });
1915
1981
  });
1916
1982
  it('POST /api/taskforce/settings/mcp/test-connection should validate the token against workspace and scopes', async () => {
1983
+ const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
1984
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
1985
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
1986
+ jsonrpc: '2.0',
1987
+ id: 'mcp-connection-test',
1988
+ result: {
1989
+ protocolVersion: '2025-03-26',
1990
+ serverInfo: { name: 'taskforce-cloud' }
1991
+ }
1992
+ }), {
1993
+ status: 200,
1994
+ headers: { 'Content-Type': 'application/json' }
1995
+ }));
1996
+ vi.stubGlobal('fetch', fetchMock);
1917
1997
  core.inspectMcpAccessToken.mockReturnValue({
1918
1998
  tokenId: 'mcp-token-1',
1919
1999
  tokenPrefix: 'tfmcp_abc123',
@@ -1940,12 +2020,136 @@ describe('Server Routes', () => {
1940
2020
  });
1941
2021
  const res = createMockRes();
1942
2022
  res.req = req;
1943
- await match?.handler(req, res, {});
2023
+ try {
2024
+ await match?.handler(req, res, {});
2025
+ }
2026
+ finally {
2027
+ vi.unstubAllGlobals();
2028
+ if (previousCloudEnvironment === undefined)
2029
+ delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
2030
+ else
2031
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
2032
+ }
1944
2033
  expect(core.inspectMcpAccessToken).toHaveBeenCalledWith('tfmcp_abc123_secret');
2034
+ expect(fetchMock).toHaveBeenCalledWith('https://performance-mcp.taskforcehq.ai/mcp', expect.objectContaining({
2035
+ method: 'POST',
2036
+ headers: expect.objectContaining({
2037
+ Authorization: 'Bearer tfmcp_abc123_secret'
2038
+ })
2039
+ }));
1945
2040
  const payload = JSON.parse(res.end.mock.calls[0][0]);
1946
2041
  expect(payload.success).toBe(true);
1947
2042
  expect(payload.result.workspaceName).toBe('Workspace One');
1948
2043
  expect(payload.result.scopes).toEqual(['tasks:read', 'tasks:write']);
2044
+ expect(payload.result.endpoint).toBe('https://performance-mcp.taskforcehq.ai/mcp');
2045
+ expect(payload.result.serverName).toBe('taskforce-cloud');
2046
+ expect(payload.result.protocolVersion).toBe('2025-03-26');
2047
+ });
2048
+ it('POST /api/taskforce/settings/mcp/test-connection should return a clear error when the dedicated MCP host returns a gateway failure', async () => {
2049
+ const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
2050
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
2051
+ const fetchMock = vi.fn(async () => new Response('Bad gateway', {
2052
+ status: 502,
2053
+ headers: { 'Content-Type': 'text/plain' }
2054
+ }));
2055
+ vi.stubGlobal('fetch', fetchMock);
2056
+ core.inspectMcpAccessToken.mockReturnValue({
2057
+ tokenId: 'mcp-token-1',
2058
+ tokenPrefix: 'tfmcp_abc123',
2059
+ workspaceId: 'workspace-1',
2060
+ userId: 'user-1',
2061
+ name: 'Workspace MCP',
2062
+ scopes: ['tasks:read', 'tasks:write'],
2063
+ status: 'active'
2064
+ });
2065
+ const match = matchRoute('POST', '/api/taskforce/settings/mcp/test-connection', routes);
2066
+ const req = createMockReq('POST', '/api/taskforce/settings/mcp/test-connection', {
2067
+ token: 'tfmcp_abc123_secret',
2068
+ requiredScopes: ['tasks:read', 'tasks:write']
2069
+ }, {
2070
+ 'x-taskforce-runtime-mode': 'cloud',
2071
+ 'x-taskforce-user-id': 'user-1',
2072
+ 'x-taskforce-workspace-id': 'workspace-1'
2073
+ });
2074
+ const res = createMockRes();
2075
+ res.req = req;
2076
+ try {
2077
+ await match?.handler(req, res, {});
2078
+ }
2079
+ finally {
2080
+ vi.unstubAllGlobals();
2081
+ if (previousCloudEnvironment === undefined)
2082
+ delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
2083
+ else
2084
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
2085
+ }
2086
+ expect(res.writeHead).toHaveBeenCalledWith(502, { 'Content-Type': 'application/json' });
2087
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
2088
+ expect(payload.success).toBe(false);
2089
+ expect(payload.code).toBe('MCP_ENDPOINT_GATEWAY_ERROR');
2090
+ expect(payload.error).toContain('https://performance-mcp.taskforcehq.ai/mcp');
2091
+ expect(payload.details).toEqual(expect.objectContaining({
2092
+ endpoint: 'https://performance-mcp.taskforcehq.ai/mcp',
2093
+ upstreamStatus: 502
2094
+ }));
2095
+ });
2096
+ it('POST /api/taskforce/settings/mcp/test-connection should prefer the dedicated MCP hostname when cloudMcpBaseUrl points at the app host', async () => {
2097
+ const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
2098
+ const previousCloudMcpBaseUrl = process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL;
2099
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
2100
+ process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL = 'https://performance-app.taskforcehq.ai';
2101
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
2102
+ jsonrpc: '2.0',
2103
+ id: 'mcp-connection-test',
2104
+ result: {
2105
+ serverInfo: { name: 'taskforce-cloud' }
2106
+ }
2107
+ }), {
2108
+ status: 200,
2109
+ headers: { 'Content-Type': 'application/json' }
2110
+ }));
2111
+ vi.stubGlobal('fetch', fetchMock);
2112
+ core.inspectMcpAccessToken.mockReturnValue({
2113
+ tokenId: 'mcp-token-1',
2114
+ tokenPrefix: 'tfmcp_abc123',
2115
+ workspaceId: 'workspace-1',
2116
+ userId: 'user-1',
2117
+ name: 'Workspace MCP',
2118
+ scopes: ['tasks:read', 'tasks:write'],
2119
+ status: 'active'
2120
+ });
2121
+ core.getWorkspaceProfile.mockReturnValue({
2122
+ id: 'workspace-1',
2123
+ slug: 'workspace-1',
2124
+ name: 'Workspace One',
2125
+ description: null
2126
+ });
2127
+ const match = matchRoute('POST', '/api/taskforce/settings/mcp/test-connection', routes);
2128
+ const req = createMockReq('POST', '/api/taskforce/settings/mcp/test-connection', {
2129
+ token: 'tfmcp_abc123_secret',
2130
+ requiredScopes: ['tasks:read']
2131
+ }, {
2132
+ 'x-taskforce-runtime-mode': 'cloud',
2133
+ 'x-taskforce-user-id': 'user-1',
2134
+ 'x-taskforce-workspace-id': 'workspace-1'
2135
+ });
2136
+ const res = createMockRes();
2137
+ res.req = req;
2138
+ try {
2139
+ await match?.handler(req, res, {});
2140
+ }
2141
+ finally {
2142
+ vi.unstubAllGlobals();
2143
+ if (previousCloudEnvironment === undefined)
2144
+ delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
2145
+ else
2146
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
2147
+ if (previousCloudMcpBaseUrl === undefined)
2148
+ delete process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL;
2149
+ else
2150
+ process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL = previousCloudMcpBaseUrl;
2151
+ }
2152
+ expect(fetchMock).toHaveBeenCalledWith('https://performance-mcp.taskforcehq.ai/mcp', expect.any(Object));
1949
2153
  });
1950
2154
  it('POST /api/taskforce/settings/mcp/test-connection should report missing scopes', async () => {
1951
2155
  core.inspectMcpAccessToken.mockReturnValue({
@@ -2003,6 +2207,96 @@ describe('Server Routes', () => {
2003
2207
  expect(fetchMock).not.toHaveBeenCalled();
2004
2208
  vi.unstubAllGlobals();
2005
2209
  });
2210
+ it('GET /api/taskforce/context/:filename should proxy R2 user avatars inline instead of redirecting', async () => {
2211
+ const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
2212
+ status: 200,
2213
+ headers: { 'Content-Type': 'image/webp' }
2214
+ }));
2215
+ vi.stubGlobal('fetch', fetchMock);
2216
+ const routesWithStorage = createRoutes(core, {
2217
+ objectStorage: {
2218
+ provider: 'r2',
2219
+ r2: {
2220
+ accountId: 'acct-1',
2221
+ bucket: 'taskforce-docs',
2222
+ endpoint: 'https://example.r2.cloudflarestorage.com',
2223
+ accessKeyId: 'access-key-1',
2224
+ secretAccessKey: 'secret-key-1',
2225
+ keyPrefix: 'taskforce',
2226
+ signedUploadTtlSeconds: 600,
2227
+ signedDownloadTtlSeconds: 300
2228
+ }
2229
+ }
2230
+ });
2231
+ const encoded = encodeURIComponent('users/user-1/profile/avatar/avatar-draft-1-headshot.webp');
2232
+ const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routesWithStorage);
2233
+ const req = createMockReq('GET', `/api/taskforce/context/${encoded}`);
2234
+ const res = createMockRes();
2235
+ await match?.handler(req, res, { filename: encoded });
2236
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
2237
+ 'Content-Type': 'image/webp',
2238
+ 'Content-Disposition': 'inline'
2239
+ }));
2240
+ expect(String(res.getHeader('location') || '')).toBe('');
2241
+ expect(res.__getBody()).toBe('avatar-bytes');
2242
+ 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');
2243
+ vi.unstubAllGlobals();
2244
+ });
2245
+ it('GET /api/taskforce/context/:filename should proxy missing cloud-owned user avatars in local runtime', async () => {
2246
+ const previousCloudProxyBaseUrl = process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
2247
+ const previousClientId = process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
2248
+ const previousClientSecret = process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
2249
+ process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = 'https://performance-app.taskforcehq.ai';
2250
+ process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = 'cf-client-id';
2251
+ process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = 'cf-client-secret';
2252
+ const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
2253
+ status: 200,
2254
+ headers: {
2255
+ 'Content-Type': 'image/webp',
2256
+ 'Cache-Control': 'private, max-age=60'
2257
+ }
2258
+ }));
2259
+ vi.stubGlobal('fetch', fetchMock);
2260
+ try {
2261
+ const relativePath = 'users/user-1/profile/avatar/avatar-draft-1-headshot.webp';
2262
+ const encoded = encodeURIComponent(relativePath);
2263
+ const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routes);
2264
+ const req = createMockReq('GET', `/api/taskforce/context/${encoded}`, undefined, {
2265
+ host: 'localhost:5174',
2266
+ cookie: 'taskforce_session=session-1',
2267
+ accept: 'image/webp'
2268
+ });
2269
+ const res = createMockRes();
2270
+ await match?.handler(req, res, { filename: encoded });
2271
+ expect(fetchMock).toHaveBeenCalledWith(`https://performance-app.taskforcehq.ai/api/taskforce/context/${encoded}`, expect.objectContaining({
2272
+ method: 'GET',
2273
+ headers: expect.any(Headers)
2274
+ }));
2275
+ const proxiedHeaders = fetchMock.mock.calls[0][1].headers;
2276
+ expect(proxiedHeaders.get('cookie')).toBe('taskforce_session=session-1');
2277
+ expect(proxiedHeaders.get('CF-Access-Client-Id')).toBe('cf-client-id');
2278
+ expect(proxiedHeaders.get('CF-Access-Client-Secret')).toBe('cf-client-secret');
2279
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
2280
+ 'content-type': 'image/webp'
2281
+ }));
2282
+ expect(res.__getBody()).toBe('avatar-bytes');
2283
+ }
2284
+ finally {
2285
+ vi.unstubAllGlobals();
2286
+ if (previousCloudProxyBaseUrl === undefined)
2287
+ delete process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
2288
+ else
2289
+ process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = previousCloudProxyBaseUrl;
2290
+ if (previousClientId === undefined)
2291
+ delete process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
2292
+ else
2293
+ process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = previousClientId;
2294
+ if (previousClientSecret === undefined)
2295
+ delete process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
2296
+ else
2297
+ process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = previousClientSecret;
2298
+ }
2299
+ });
2006
2300
  it('GET /api/taskforce/context/:filename should proxy inline R2 markdown documents instead of redirecting', async () => {
2007
2301
  const fetchMock = vi.fn().mockResolvedValue({
2008
2302
  status: 200,
@@ -2230,6 +2524,63 @@ describe('Server Routes', () => {
2230
2524
  expect(fetchMock).not.toHaveBeenCalled();
2231
2525
  vi.unstubAllGlobals();
2232
2526
  });
2527
+ it('GET /api/taskforce/documents/:assetId/content should fall back to object storage when local canonical metadata points at a missing file', async () => {
2528
+ const fetchMock = vi.fn().mockResolvedValue({
2529
+ status: 200,
2530
+ ok: true,
2531
+ headers: new Headers({ 'content-type': 'text/markdown' }),
2532
+ arrayBuffer: async () => Buffer.from('# Recovered canonical doc\n').buffer,
2533
+ });
2534
+ vi.stubGlobal('fetch', fetchMock);
2535
+ const routesWithStorage = createRoutes(core, {
2536
+ objectStorage: {
2537
+ provider: 'r2',
2538
+ r2: {
2539
+ accountId: 'acct-1',
2540
+ bucket: 'taskforce-docs',
2541
+ endpoint: 'https://example.r2.cloudflarestorage.com',
2542
+ accessKeyId: 'access-key-1',
2543
+ secretAccessKey: 'secret-key-1',
2544
+ keyPrefix: 'taskforce',
2545
+ signedUploadTtlSeconds: 600,
2546
+ signedDownloadTtlSeconds: 300
2547
+ }
2548
+ }
2549
+ });
2550
+ const store = new WorkspaceAssetStore(core.getDatabaseAdapter(), core.getTenantId());
2551
+ store.upsertAsset({
2552
+ assetId: 'asset-doc-content-fallback-1',
2553
+ workspaceId: 'default',
2554
+ kind: 'document',
2555
+ logicalName: 'Recovered canonical doc',
2556
+ originalFilename: 'recovered-canonical-doc.md',
2557
+ mimeType: 'text/markdown',
2558
+ storageProvider: 'local',
2559
+ storageKey: 'workspaces/default/assets/documents/asset-doc-content-fallback-1-recovered-canonical-doc.md',
2560
+ contentSha256: 'sha-canonical-doc-fallback-1',
2561
+ sizeBytes: 25,
2562
+ documentId: 'doc-content-fallback-1',
2563
+ version: 1,
2564
+ isLatest: true,
2565
+ scanStatus: 'clean',
2566
+ scanEngine: null,
2567
+ scanDetails: null,
2568
+ deletedAt: null,
2569
+ purgeAfter: null
2570
+ });
2571
+ const req = createMockReq('GET', '/api/taskforce/documents/asset-doc-content-fallback-1/content');
2572
+ const res = createMockRes();
2573
+ await matchRoute('GET', '/api/taskforce/documents/asset-doc-content-fallback-1/content', routesWithStorage)?.handler(req, res, {
2574
+ assetId: 'asset-doc-content-fallback-1'
2575
+ });
2576
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
2577
+ 'Content-Type': 'text/markdown',
2578
+ 'Content-Disposition': 'inline'
2579
+ }));
2580
+ expect(res.end).toHaveBeenCalledWith(expect.any(Buffer));
2581
+ expect(fetchMock).toHaveBeenCalled();
2582
+ vi.unstubAllGlobals();
2583
+ });
2233
2584
  it('GET /api/taskforce/documents/:assetId/content should reject non-document assets', async () => {
2234
2585
  const routesWithStorage = createRoutes(core, {
2235
2586
  objectStorage: {
@@ -3236,6 +3587,53 @@ describe('Server Routes', () => {
3236
3587
  fs.rmSync(dbRoot, { recursive: true, force: true });
3237
3588
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3238
3589
  });
3590
+ it('GET /api/taskforce/documents should exclude raw .taskforce/docs markdown files from the document manager', async () => {
3591
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-exclude-raw-docs-'));
3592
+ const basePath = path.join(tmpRoot, '.taskforce');
3593
+ fs.mkdirSync(path.join(basePath, 'docs'), { recursive: true });
3594
+ fs.writeFileSync(path.join(basePath, 'docs', 'legacy-plan.md'), '# legacy\n', 'utf8');
3595
+ core.getPaths.mockReturnValue({
3596
+ projectRoot: tmpRoot,
3597
+ basePath,
3598
+ });
3599
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
3600
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
3601
+ core.getTenantId = vi.fn().mockReturnValue('default');
3602
+ store.upsertAsset({
3603
+ assetId: 'asset-doc-local-2',
3604
+ workspaceId: 'default',
3605
+ kind: 'document',
3606
+ originalFilename: 'managed-plan.md',
3607
+ mimeType: 'text/markdown',
3608
+ storageProvider: 'local',
3609
+ storageKey: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
3610
+ contentSha256: 'sha-doc-local-2',
3611
+ sizeBytes: 99,
3612
+ documentId: 'doc-local-2',
3613
+ version: 1,
3614
+ updatedAt: '2026-04-19T12:00:00.000Z'
3615
+ });
3616
+ const routesWithDb = createRoutes(core);
3617
+ const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
3618
+ const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
3619
+ 'x-taskforce-workspace-id': 'default',
3620
+ 'x-taskforce-runtime-mode': 'local'
3621
+ });
3622
+ const res = createMockRes();
3623
+ await match?.handler(req, res, {});
3624
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
3625
+ expect(Array.isArray(payload.documents)).toBe(true);
3626
+ expect(payload.documents).toHaveLength(1);
3627
+ expect(payload.documents[0]).toEqual(expect.objectContaining({
3628
+ id: 'asset-doc-local-2',
3629
+ assetId: 'asset-doc-local-2',
3630
+ fsPath: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
3631
+ attachmentState: 'unattached'
3632
+ }));
3633
+ expect(payload.documents.some((doc) => String(doc.fsPath || '').includes('docs/legacy-plan.md'))).toBe(false);
3634
+ fs.rmSync(dbRoot, { recursive: true, force: true });
3635
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3636
+ });
3239
3637
  it('GET /api/taskforce/documents should mark canonical docs without links as unattached', async () => {
3240
3638
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-unattached-local-'));
3241
3639
  core.getPaths.mockReturnValue({
@@ -3332,8 +3730,8 @@ describe('Server Routes', () => {
3332
3730
  fs.rmSync(dbRoot, { recursive: true, force: true });
3333
3731
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3334
3732
  });
3335
- it('GET /api/taskforce/documents should normalize readable names and attachment metadata for sparse canonical assets', async () => {
3336
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-normalized-cloud-'));
3733
+ it('GET /api/taskforce/documents should count non-task links in attachment metadata while only surfacing task titles for task links', async () => {
3734
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-generic-links-'));
3337
3735
  core.getPaths.mockReturnValue({
3338
3736
  projectRoot: tmpRoot,
3339
3737
  basePath: path.join(tmpRoot, '.taskforce'),
@@ -3342,25 +3740,130 @@ describe('Server Routes', () => {
3342
3740
  core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
3343
3741
  core.getTenantId = vi.fn().mockReturnValue('default');
3344
3742
  store.upsertAsset({
3345
- assetId: 'asset-doc-sparse-1',
3346
- workspaceId: 'workspace-1',
3743
+ assetId: 'asset-doc-generic-link-1',
3744
+ workspaceId: 'default',
3347
3745
  kind: 'document',
3348
- logicalName: 'asset-doc-sparse-1',
3349
- originalFilename: 'asset-doc-sparse-1-implementation-plan-1773922162143.md',
3746
+ originalFilename: 'shared-notes.md',
3350
3747
  mimeType: 'text/markdown',
3351
- storageProvider: 'r2',
3352
- storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-sparse-1-implementation-plan-1773922162143.md',
3353
- contentSha256: 'sha-doc-sparse-1',
3354
- sizeBytes: 222,
3355
- documentId: 'doc-sparse-1',
3748
+ storageProvider: 'local',
3749
+ storageKey: 'workspaces/default/assets/documents/asset-doc-generic-link-1-shared-notes.md',
3750
+ contentSha256: 'sha-doc-generic-link-1',
3751
+ sizeBytes: 64,
3752
+ documentId: 'doc-generic-link-1',
3356
3753
  version: 1,
3357
- updatedAt: '2026-03-18T15:00:00.000Z'
3754
+ updatedAt: '2026-03-18T13:30:00.000Z'
3358
3755
  });
3359
3756
  store.upsertLink({
3360
- workspaceId: 'workspace-1',
3361
- assetId: 'asset-doc-sparse-1',
3362
- taskId: 'task-sparse-1',
3363
- role: 'attachment',
3757
+ workspaceId: 'default',
3758
+ assetId: 'asset-doc-generic-link-1',
3759
+ targetType: 'initiative',
3760
+ targetId: 'initiative-1',
3761
+ role: 'attachment'
3762
+ });
3763
+ const routesWithDb = createRoutes(core);
3764
+ const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
3765
+ const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
3766
+ 'x-taskforce-workspace-id': 'default',
3767
+ 'x-taskforce-runtime-mode': 'local'
3768
+ });
3769
+ const res = createMockRes();
3770
+ await match?.handler(req, res, {});
3771
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
3772
+ expect(payload.documents).toEqual([
3773
+ expect.objectContaining({
3774
+ id: 'asset-doc-generic-link-1',
3775
+ assetId: 'asset-doc-generic-link-1',
3776
+ attachmentCount: 1,
3777
+ attachmentState: 'attached',
3778
+ taskId: null
3779
+ })
3780
+ ]);
3781
+ fs.rmSync(dbRoot, { recursive: true, force: true });
3782
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3783
+ });
3784
+ it('POST /api/taskforce/documents/:assetId/attach should attach a canonical document to a task target', async () => {
3785
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-attach-task-'));
3786
+ core.getPaths.mockReturnValue({
3787
+ projectRoot: tmpRoot,
3788
+ basePath: path.join(tmpRoot, '.taskforce'),
3789
+ });
3790
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
3791
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
3792
+ core.getTenantId = vi.fn().mockReturnValue('default');
3793
+ store.upsertAsset({
3794
+ assetId: 'asset-doc-attach-route-1',
3795
+ workspaceId: 'default',
3796
+ kind: 'document',
3797
+ logicalName: 'Attach Me',
3798
+ originalFilename: 'attach-me.md',
3799
+ mimeType: 'text/markdown',
3800
+ storageProvider: 'local',
3801
+ storageKey: 'workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md',
3802
+ contentSha256: 'sha-doc-attach-route-1',
3803
+ sizeBytes: 28,
3804
+ documentId: 'doc-attach-route-1',
3805
+ version: 1,
3806
+ updatedAt: '2026-03-18T13:30:00.000Z'
3807
+ });
3808
+ core.getTask.mockImplementation((id) => id === 'task-attach-1'
3809
+ ? { id: 'task-attach-1', title: 'Attach target', attachments: [] }
3810
+ : null);
3811
+ const routesWithDb = createRoutes(core);
3812
+ const match = matchRoute('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', routesWithDb);
3813
+ const req = createMockReq('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', {
3814
+ targetType: 'task',
3815
+ targetId: 'task-attach-1',
3816
+ }, {
3817
+ 'x-taskforce-workspace-id': 'default'
3818
+ });
3819
+ const res = createMockRes();
3820
+ await match?.handler(req, res, { assetId: 'asset-doc-attach-route-1' });
3821
+ expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
3822
+ expect(core.updateTask).toHaveBeenCalledWith('task-attach-1', expect.objectContaining({
3823
+ attachments: [
3824
+ expect.objectContaining({
3825
+ assetId: 'asset-doc-attach-route-1',
3826
+ taskId: 'task-attach-1',
3827
+ path: '/api/taskforce/documents/asset-doc-attach-route-1/content',
3828
+ })
3829
+ ]
3830
+ }), 'default', expect.objectContaining({
3831
+ actorRef: 'user',
3832
+ saveSource: 'manual',
3833
+ }));
3834
+ expect(core.notifyDocumentMetadataMutation).toHaveBeenCalledWith('default', ['workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md'], 'upsert');
3835
+ fs.rmSync(dbRoot, { recursive: true, force: true });
3836
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3837
+ });
3838
+ it('GET /api/taskforce/documents should normalize readable names and attachment metadata for sparse canonical assets', async () => {
3839
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-normalized-cloud-'));
3840
+ core.getPaths.mockReturnValue({
3841
+ projectRoot: tmpRoot,
3842
+ basePath: path.join(tmpRoot, '.taskforce'),
3843
+ });
3844
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
3845
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
3846
+ core.getTenantId = vi.fn().mockReturnValue('default');
3847
+ store.upsertAsset({
3848
+ assetId: 'asset-doc-sparse-1',
3849
+ workspaceId: 'workspace-1',
3850
+ kind: 'document',
3851
+ logicalName: 'asset-doc-sparse-1',
3852
+ originalFilename: 'asset-doc-sparse-1-implementation-plan-1773922162143.md',
3853
+ mimeType: 'text/markdown',
3854
+ storageProvider: 'r2',
3855
+ storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-sparse-1-implementation-plan-1773922162143.md',
3856
+ contentSha256: 'sha-doc-sparse-1',
3857
+ sizeBytes: 222,
3858
+ documentId: 'doc-sparse-1',
3859
+ version: 1,
3860
+ updatedAt: '2026-03-18T15:00:00.000Z'
3861
+ });
3862
+ store.upsertLink({
3863
+ workspaceId: 'workspace-1',
3864
+ assetId: 'asset-doc-sparse-1',
3865
+ taskId: 'task-sparse-1',
3866
+ role: 'attachment',
3364
3867
  displayName: 'AI Profiles - Implementation Plan v2'
3365
3868
  });
3366
3869
  core.getTask.mockImplementation((id) => id === 'task-sparse-1'
@@ -3485,10 +3988,106 @@ describe('Server Routes', () => {
3485
3988
  const res = createMockRes();
3486
3989
  await match?.handler(req, res, { assetId: 'asset-doc-attached-1' });
3487
3990
  expect(res.writeHead).toHaveBeenCalledWith(409, { 'Content-Type': 'application/json' });
3991
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
3992
+ expect(payload).toEqual(expect.objectContaining({
3993
+ error: 'Document is still attached to one or more tasks.',
3994
+ activeAttachmentCount: 1,
3995
+ requiresForceDetach: true
3996
+ }));
3488
3997
  expect(store.get('asset-doc-attached-1', 'default')?.deletedAt).toBeNull();
3489
3998
  fs.rmSync(dbRoot, { recursive: true, force: true });
3490
3999
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3491
4000
  });
4001
+ it('DELETE /api/taskforce/documents/:assetId should unlink attached canonical documents when forceDetach is true', async () => {
4002
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-delete-force-attached-'));
4003
+ const basePath = path.join(tmpRoot, '.taskforce');
4004
+ const documentPath = path.join(basePath, 'workspaces', 'default', 'assets', 'documents', 'asset-doc-force-delete-1-notes.md');
4005
+ fs.mkdirSync(path.dirname(documentPath), { recursive: true });
4006
+ fs.writeFileSync(documentPath, '# attached\n', 'utf8');
4007
+ core.getPaths.mockReturnValue({
4008
+ projectRoot: tmpRoot,
4009
+ basePath,
4010
+ });
4011
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
4012
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
4013
+ core.getTenantId = vi.fn().mockReturnValue('default');
4014
+ store.upsertAsset({
4015
+ assetId: 'asset-doc-force-delete-1',
4016
+ workspaceId: 'default',
4017
+ kind: 'document',
4018
+ originalFilename: 'notes.md',
4019
+ mimeType: 'text/markdown',
4020
+ storageProvider: 'local',
4021
+ storageKey: 'workspaces/default/assets/documents/asset-doc-force-delete-1-notes.md',
4022
+ contentSha256: 'sha-doc-force-delete-1',
4023
+ sizeBytes: 16,
4024
+ documentId: 'doc-force-delete-1',
4025
+ version: 1,
4026
+ updatedAt: '2026-03-18T14:00:00.000Z'
4027
+ });
4028
+ store.upsertLink({
4029
+ workspaceId: 'default',
4030
+ assetId: 'asset-doc-force-delete-1',
4031
+ taskId: 'task-keep',
4032
+ role: 'attachment'
4033
+ });
4034
+ core.getTask.mockImplementation((id) => id === 'task-keep'
4035
+ ? {
4036
+ id: 'task-keep',
4037
+ title: 'Task Keep',
4038
+ attachments: [{
4039
+ path: '/api/taskforce/documents/asset-doc-force-delete-1/content',
4040
+ fsPath: 'workspaces/default/assets/documents/asset-doc-force-delete-1-notes.md',
4041
+ caption: 'notes.md',
4042
+ assetId: 'asset-doc-force-delete-1'
4043
+ }, {
4044
+ path: '/api/taskforce/context/other-file.md',
4045
+ fsPath: 'workspaces/default/assets/files/other-file.md',
4046
+ caption: 'other-file.md'
4047
+ }]
4048
+ }
4049
+ : null);
4050
+ core.updateTask.mockImplementation((_id, patch) => ({
4051
+ id: 'task-keep',
4052
+ attachments: patch.attachments
4053
+ }));
4054
+ const routesWithDb = createRoutes(core);
4055
+ const match = matchRoute('DELETE', '/api/taskforce/documents/asset-doc-force-delete-1', routesWithDb);
4056
+ const req = createMockReq('DELETE', '/api/taskforce/documents/asset-doc-force-delete-1', {
4057
+ forceDetach: true
4058
+ }, {
4059
+ 'x-taskforce-workspace-id': 'default'
4060
+ });
4061
+ const res = createMockRes();
4062
+ await match?.handler(req, res, { assetId: 'asset-doc-force-delete-1' });
4063
+ expect(core.updateTask).toHaveBeenCalledWith('task-keep', {
4064
+ attachments: [{
4065
+ path: '/api/taskforce/context/other-file.md',
4066
+ fsPath: 'workspaces/default/assets/files/other-file.md',
4067
+ caption: 'other-file.md'
4068
+ }]
4069
+ }, 'default', expect.objectContaining({ actorRef: 'user', saveSource: 'manual' }));
4070
+ expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
4071
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
4072
+ expect(payload).toEqual(expect.objectContaining({
4073
+ success: true,
4074
+ softDeleted: true,
4075
+ detachedTaskCount: 1
4076
+ }));
4077
+ expect(store.get('asset-doc-force-delete-1', 'default')).toEqual(expect.objectContaining({
4078
+ deletedAt: expect.any(String),
4079
+ purgeAfter: expect.any(String)
4080
+ }));
4081
+ expect(store.listLinksForAsset('asset-doc-force-delete-1', 'default', { includeDeleted: true })).toEqual([
4082
+ expect.objectContaining({
4083
+ taskId: 'task-keep',
4084
+ role: 'attachment',
4085
+ deletedAt: expect.any(String)
4086
+ })
4087
+ ]);
4088
+ fs.rmSync(dbRoot, { recursive: true, force: true });
4089
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
4090
+ });
3492
4091
  it('PATCH /api/taskforce/documents/:assetId should update the shared display name without changing storage identity', async () => {
3493
4092
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-rename-'));
3494
4093
  core.getPaths.mockReturnValue({
@@ -3726,7 +4325,85 @@ describe('Server Routes', () => {
3726
4325
  }));
3727
4326
  expect(res.setHeader).not.toHaveBeenCalledWith('Set-Cookie', expect.any(String));
3728
4327
  expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
3729
- expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"verificationRequired":true'));
4328
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4329
+ expect(payload).toEqual(expect.objectContaining({
4330
+ success: true,
4331
+ verificationRequired: true
4332
+ }));
4333
+ expect(payload.verificationToken).toBeUndefined();
4334
+ });
4335
+ it('POST /api/taskforce/auth/register should not depend on onboarding policy lookups', async () => {
4336
+ const authConfig = {
4337
+ runtimeMode: 'cloud',
4338
+ enabled: true,
4339
+ requiredForApi: true,
4340
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4341
+ defaultRole: 'member',
4342
+ defaultWorkspaceId: 'default',
4343
+ sessionCookieName: 'taskforce_session',
4344
+ sessionSecret: 'test-secret',
4345
+ sessionTtlSeconds: 3600
4346
+ };
4347
+ core.getOnboardingPolicy.mockImplementationOnce(() => {
4348
+ throw new Error('for SELECT DISTINCT, ORDER BY expressions must appear in select list');
4349
+ });
4350
+ core.getGlobalSettings.mockReturnValue({
4351
+ auth: {
4352
+ allowSelfRegistration: true,
4353
+ requireVerifiedEmail: false
4354
+ }
4355
+ });
4356
+ routes = createRoutes(core, { authConfig });
4357
+ core.registerPasswordUser.mockReturnValueOnce({
4358
+ userId: 'u-new',
4359
+ workspaceId: 'default',
4360
+ role: 'member',
4361
+ planSelectionRequired: false
4362
+ });
4363
+ const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
4364
+ const req = createMockReq('POST', '/api/taskforce/auth/register', {
4365
+ email: 'newuser@example.com',
4366
+ password: 'password123'
4367
+ });
4368
+ const res = createMockRes();
4369
+ await match?.handler(req, res, {});
4370
+ expect(core.registerPasswordUser).toHaveBeenCalledWith(expect.objectContaining({
4371
+ email: 'newuser@example.com'
4372
+ }));
4373
+ expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
4374
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
4375
+ });
4376
+ it('POST /api/taskforce/auth/register should only expose verificationToken when explicitly enabled', async () => {
4377
+ const authConfig = {
4378
+ runtimeMode: 'cloud',
4379
+ enabled: true,
4380
+ requiredForApi: true,
4381
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4382
+ defaultRole: 'member',
4383
+ defaultWorkspaceId: 'default',
4384
+ sessionCookieName: 'taskforce_session',
4385
+ sessionSecret: 'test-secret',
4386
+ sessionTtlSeconds: 3600
4387
+ };
4388
+ await withDevAuthTokenExposure('true', async () => {
4389
+ routes = createRoutes(core, { authConfig });
4390
+ core.registerPasswordUser.mockReturnValueOnce({
4391
+ userId: 'user-opt-in',
4392
+ workspaceId: null,
4393
+ role: 'member',
4394
+ planSelectionRequired: false
4395
+ });
4396
+ core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-opt-in' });
4397
+ const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
4398
+ const req = createMockReq('POST', '/api/taskforce/auth/register', {
4399
+ email: 'opt-in@example.com',
4400
+ password: 'password123'
4401
+ });
4402
+ const res = createMockRes();
4403
+ await match?.handler(req, res, {});
4404
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4405
+ expect(payload.verificationToken).toBe('verify-token-opt-in');
4406
+ });
3730
4407
  });
3731
4408
  it('POST /api/taskforce/auth/register should allow first-time registration without workspaceId', async () => {
3732
4409
  const authConfig = {
@@ -3851,35 +4528,37 @@ describe('Server Routes', () => {
3851
4528
  sessionSecret: 'test-secret',
3852
4529
  sessionTtlSeconds: 3600
3853
4530
  };
3854
- routes = createRoutes(core, { authConfig });
3855
- core.hasWorkspaceAdmin.mockReturnValueOnce(true);
3856
- core.isEmailVerified.mockReturnValueOnce(false);
3857
- core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-existing' });
3858
- const error = new Error('Email is already registered');
3859
- error.statusCode = 409;
3860
- error.code = 'EMAIL_ALREADY_EXISTS';
3861
- core.registerPasswordUser.mockImplementationOnce(() => {
3862
- throw error;
3863
- });
3864
- const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
3865
- const req = createMockReq('POST', '/api/taskforce/auth/register', {
3866
- email: 'owner@example.com',
3867
- password: 'password123',
3868
- workspaceId: 'workspace-1'
4531
+ await withDevAuthTokenExposure('true', async () => {
4532
+ routes = createRoutes(core, { authConfig });
4533
+ core.hasWorkspaceAdmin.mockReturnValueOnce(true);
4534
+ core.isEmailVerified.mockReturnValueOnce(false);
4535
+ core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-existing' });
4536
+ const error = new Error('Email is already registered');
4537
+ error.statusCode = 409;
4538
+ error.code = 'EMAIL_ALREADY_EXISTS';
4539
+ core.registerPasswordUser.mockImplementationOnce(() => {
4540
+ throw error;
4541
+ });
4542
+ const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
4543
+ const req = createMockReq('POST', '/api/taskforce/auth/register', {
4544
+ email: 'owner@example.com',
4545
+ password: 'password123',
4546
+ workspaceId: 'workspace-1'
4547
+ });
4548
+ const res = createMockRes();
4549
+ await match?.handler(req, res, {});
4550
+ expect(core.isEmailVerified).toHaveBeenCalledWith('owner@example.com');
4551
+ expect(core.requestEmailVerification).toHaveBeenCalledWith({ email: 'owner@example.com' });
4552
+ expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
4553
+ const responsePayload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4554
+ expect(responsePayload).toEqual(expect.objectContaining({
4555
+ success: false,
4556
+ code: 'EMAIL_ALREADY_EXISTS_UNVERIFIED',
4557
+ verificationRequired: true,
4558
+ emailSent: true,
4559
+ verificationToken: 'verify-token-existing'
4560
+ }));
3869
4561
  });
3870
- const res = createMockRes();
3871
- await match?.handler(req, res, {});
3872
- expect(core.isEmailVerified).toHaveBeenCalledWith('owner@example.com');
3873
- expect(core.requestEmailVerification).toHaveBeenCalledWith({ email: 'owner@example.com' });
3874
- expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
3875
- const responsePayload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
3876
- expect(responsePayload).toEqual(expect.objectContaining({
3877
- success: false,
3878
- code: 'EMAIL_ALREADY_EXISTS_UNVERIFIED',
3879
- verificationRequired: true,
3880
- emailSent: true,
3881
- verificationToken: 'verify-token-existing'
3882
- }));
3883
4562
  });
3884
4563
  it('POST /api/taskforce/auth/register should return conflict when verified email exists', async () => {
3885
4564
  const authConfig = {
@@ -3952,6 +4631,124 @@ describe('Server Routes', () => {
3952
4631
  code: 'SIGNUP_PLAN_VERSION_NOT_FOUND'
3953
4632
  }));
3954
4633
  });
4634
+ it('POST /api/taskforce/auth/verify-email/request should report no-send cases without claiming delivery', async () => {
4635
+ const authConfig = {
4636
+ runtimeMode: 'cloud',
4637
+ enabled: true,
4638
+ requiredForApi: true,
4639
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4640
+ defaultRole: 'member',
4641
+ defaultWorkspaceId: 'default',
4642
+ sessionCookieName: 'taskforce_session',
4643
+ sessionSecret: 'test-secret',
4644
+ sessionTtlSeconds: 3600
4645
+ };
4646
+ routes = createRoutes(core, { authConfig });
4647
+ core.requestEmailVerification.mockReturnValueOnce({});
4648
+ const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
4649
+ const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'nobody@example.com' });
4650
+ const res = createMockRes();
4651
+ await match?.handler(req, res, {});
4652
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4653
+ expect(payload).toMatchObject({
4654
+ success: true,
4655
+ emailSent: false,
4656
+ deliveryAttempted: false
4657
+ });
4658
+ expect(payload.emailError).toBeUndefined();
4659
+ });
4660
+ it('POST /api/taskforce/auth/verify-email/request should only expose verificationToken when explicitly enabled', async () => {
4661
+ const authConfig = {
4662
+ runtimeMode: 'cloud',
4663
+ enabled: true,
4664
+ requiredForApi: true,
4665
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4666
+ defaultRole: 'member',
4667
+ defaultWorkspaceId: 'default',
4668
+ sessionCookieName: 'taskforce_session',
4669
+ sessionSecret: 'test-secret',
4670
+ sessionTtlSeconds: 3600
4671
+ };
4672
+ await withDevAuthTokenExposure(undefined, async () => {
4673
+ routes = createRoutes(core, { authConfig });
4674
+ core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-hidden' });
4675
+ const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
4676
+ const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'owner@example.com' });
4677
+ const res = createMockRes();
4678
+ await match?.handler(req, res, {});
4679
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4680
+ expect(payload.verificationToken).toBeUndefined();
4681
+ });
4682
+ await withDevAuthTokenExposure('true', async () => {
4683
+ routes = createRoutes(core, { authConfig });
4684
+ core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-visible' });
4685
+ const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
4686
+ const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'owner@example.com' });
4687
+ const res = createMockRes();
4688
+ await match?.handler(req, res, {});
4689
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4690
+ expect(payload.verificationToken).toBe('verify-token-visible');
4691
+ });
4692
+ });
4693
+ it('POST /api/taskforce/auth/password-reset/request should report no-send cases without claiming delivery', async () => {
4694
+ const authConfig = {
4695
+ runtimeMode: 'cloud',
4696
+ enabled: true,
4697
+ requiredForApi: true,
4698
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4699
+ defaultRole: 'member',
4700
+ defaultWorkspaceId: 'default',
4701
+ sessionCookieName: 'taskforce_session',
4702
+ sessionSecret: 'test-secret',
4703
+ sessionTtlSeconds: 3600
4704
+ };
4705
+ routes = createRoutes(core, { authConfig });
4706
+ core.requestPasswordReset.mockReturnValueOnce({});
4707
+ const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
4708
+ const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'nobody@example.com' });
4709
+ const res = createMockRes();
4710
+ await match?.handler(req, res, {});
4711
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4712
+ expect(payload).toMatchObject({
4713
+ success: true,
4714
+ emailSent: false,
4715
+ deliveryAttempted: false
4716
+ });
4717
+ expect(payload.emailError).toBeUndefined();
4718
+ });
4719
+ it('POST /api/taskforce/auth/password-reset/request should only expose resetToken when explicitly enabled', async () => {
4720
+ const authConfig = {
4721
+ runtimeMode: 'cloud',
4722
+ enabled: true,
4723
+ requiredForApi: true,
4724
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4725
+ defaultRole: 'member',
4726
+ defaultWorkspaceId: 'default',
4727
+ sessionCookieName: 'taskforce_session',
4728
+ sessionSecret: 'test-secret',
4729
+ sessionTtlSeconds: 3600
4730
+ };
4731
+ await withDevAuthTokenExposure(undefined, async () => {
4732
+ routes = createRoutes(core, { authConfig });
4733
+ core.requestPasswordReset.mockReturnValueOnce({ token: 'reset-token-hidden' });
4734
+ const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
4735
+ const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'owner@example.com' });
4736
+ const res = createMockRes();
4737
+ await match?.handler(req, res, {});
4738
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4739
+ expect(payload.resetToken).toBeUndefined();
4740
+ });
4741
+ await withDevAuthTokenExposure('true', async () => {
4742
+ routes = createRoutes(core, { authConfig });
4743
+ core.requestPasswordReset.mockReturnValueOnce({ token: 'reset-token-visible' });
4744
+ const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
4745
+ const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'owner@example.com' });
4746
+ const res = createMockRes();
4747
+ await match?.handler(req, res, {});
4748
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4749
+ expect(payload.resetToken).toBe('reset-token-visible');
4750
+ });
4751
+ });
3955
4752
  it('POST /api/taskforce/auth/verify-email/confirm should verify token', async () => {
3956
4753
  const authConfig = {
3957
4754
  runtimeMode: 'cloud',
@@ -4054,6 +4851,40 @@ describe('Server Routes', () => {
4054
4851
  expect(payload.inviteeKind).toBe('existing_user');
4055
4852
  expect(payload.passwordRequired).toBe(false);
4056
4853
  });
4854
+ it('POST /api/taskforce/auth/invite/accept should preserve the created identity in the session cookie', async () => {
4855
+ const authConfig = {
4856
+ runtimeMode: 'cloud',
4857
+ enabled: true,
4858
+ requiredForApi: true,
4859
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4860
+ defaultRole: 'member',
4861
+ defaultWorkspaceId: 'default',
4862
+ sessionCookieName: 'taskforce_session',
4863
+ sessionSecret: 'test-secret',
4864
+ sessionTtlSeconds: 3600
4865
+ };
4866
+ routes = createRoutes(core, { authConfig });
4867
+ core.acceptInviteWithToken.mockReturnValueOnce({
4868
+ userId: 'u1',
4869
+ email: 'u1@example.com',
4870
+ workspaceId: 'workspace-1',
4871
+ role: 'member',
4872
+ identityId: 'identity-password-u1'
4873
+ });
4874
+ const match = matchRoute('POST', '/api/taskforce/auth/invite/accept', routes);
4875
+ const req = createMockReq('POST', '/api/taskforce/auth/invite/accept', { token: 'accept-token', password: 'password123' }, {
4876
+ 'x-taskforce-runtime-mode': 'cloud',
4877
+ host: 'localhost'
4878
+ });
4879
+ const res = createMockRes();
4880
+ await match?.handler(req, res, {});
4881
+ const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
4882
+ expect(typeof setCookie).toBe('string');
4883
+ const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
4884
+ const access = resolveRequestAccess(cookieReq, authConfig);
4885
+ expect(access.identityId).toBe('identity-password-u1');
4886
+ expect(access.userId).toBe('u1');
4887
+ });
4057
4888
  it('POST /api/taskforce/auth/invite/accept should return expired code and audit action', async () => {
4058
4889
  const authConfig = {
4059
4890
  runtimeMode: 'cloud',
@@ -4144,7 +4975,8 @@ describe('Server Routes', () => {
4144
4975
  const cookie = createSessionTokenCookie('session-join', authConfig, {
4145
4976
  userId: 'u1',
4146
4977
  role: 'member',
4147
- workspaceId: 'workspace-1'
4978
+ workspaceId: 'workspace-1',
4979
+ identityId: 'identity-u1'
4148
4980
  });
4149
4981
  const match = matchRoute('POST', '/api/taskforce/auth/invite/join', routes);
4150
4982
  const req = createMockReq('POST', '/api/taskforce/auth/invite/join', { token: 'join-token' }, {
@@ -4158,6 +4990,11 @@ describe('Server Routes', () => {
4158
4990
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4159
4991
  const payload = JSON.parse(res.end.mock.calls[0][0]);
4160
4992
  expect(payload.workspaceId).toBe('workspace-1');
4993
+ const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
4994
+ expect(typeof setCookie).toBe('string');
4995
+ const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
4996
+ const access = resolveRequestAccess(cookieReq, authConfig);
4997
+ expect(access.identityId).toBe('identity-u1');
4161
4998
  });
4162
4999
  it('POST /api/taskforce/auth/login should auto-verify bootstrap admin and sign in', async () => {
4163
5000
  const authConfig = {
@@ -4390,22 +5227,56 @@ describe('Server Routes', () => {
4390
5227
  };
4391
5228
  routes = createRoutes(core, { authConfig });
4392
5229
  core.authenticatePasswordUserDetailed.mockReturnValueOnce({
4393
- ok: true,
4394
- access: { userId: 'u4', workspaceId: 'default', role: 'member' }
5230
+ ok: true,
5231
+ access: { userId: 'u4', workspaceId: 'default', role: 'member' }
5232
+ });
5233
+ const match = matchRoute('POST', '/api/taskforce/auth/login', routes);
5234
+ const req = createMockReq('POST', '/api/taskforce/auth/login', {
5235
+ email: 'returning@example.com',
5236
+ password: 'password123'
5237
+ });
5238
+ const res = createMockRes();
5239
+ await match?.handler(req, res, {});
5240
+ expect(core.authenticatePasswordUserDetailed).toHaveBeenCalledWith(expect.objectContaining({
5241
+ email: 'returning@example.com',
5242
+ workspaceId: 'default'
5243
+ }));
5244
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
5245
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
5246
+ });
5247
+ it('POST /api/taskforce/auth/login should not depend on onboarding policy lookups', async () => {
5248
+ const authConfig = {
5249
+ runtimeMode: 'cloud',
5250
+ enabled: true,
5251
+ requiredForApi: true,
5252
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
5253
+ defaultRole: 'member',
5254
+ defaultWorkspaceId: 'default',
5255
+ sessionCookieName: 'taskforce_session',
5256
+ sessionSecret: 'test-secret',
5257
+ sessionTtlSeconds: 3600
5258
+ };
5259
+ core.getOnboardingPolicy.mockImplementationOnce(() => {
5260
+ throw new Error('for SELECT DISTINCT, ORDER BY expressions must appear in select list');
5261
+ });
5262
+ routes = createRoutes(core, { authConfig });
5263
+ core.authenticatePasswordUserDetailed.mockReturnValueOnce({
5264
+ ok: false,
5265
+ code: 'INVALID_CREDENTIALS'
4395
5266
  });
4396
5267
  const match = matchRoute('POST', '/api/taskforce/auth/login', routes);
4397
5268
  const req = createMockReq('POST', '/api/taskforce/auth/login', {
4398
- email: 'returning@example.com',
4399
- password: 'password123'
5269
+ email: 'user@example.com',
5270
+ password: 'wrongpass'
4400
5271
  });
4401
5272
  const res = createMockRes();
4402
5273
  await match?.handler(req, res, {});
5274
+ expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
5275
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('Invalid email/password.'));
4403
5276
  expect(core.authenticatePasswordUserDetailed).toHaveBeenCalledWith(expect.objectContaining({
4404
- email: 'returning@example.com',
4405
- workspaceId: 'default'
5277
+ email: 'user@example.com',
5278
+ requireVerifiedEmail: true
4406
5279
  }));
4407
- expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4408
- expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
4409
5280
  });
4410
5281
  it('GET /api/taskforce/workspaces should list authenticated user workspaces', async () => {
4411
5282
  const authConfig = {
@@ -4452,6 +5323,7 @@ describe('Server Routes', () => {
4452
5323
  const res = createMockRes();
4453
5324
  await match?.handler(req, res, {});
4454
5325
  expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
5326
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('workspaces'));
4455
5327
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4456
5328
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"workspace-2"'));
4457
5329
  });
@@ -5345,6 +6217,9 @@ describe('Server Routes', () => {
5345
6217
  await match?.handler(req, res, {});
5346
6218
  const payloadRaw = res.end.mock.calls[0]?.[0];
5347
6219
  const payload = JSON.parse(String(payloadRaw || '{}'));
6220
+ expect(payload.knownPaths).toEqual([
6221
+ 'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md'
6222
+ ]);
5348
6223
  expect(payload.documents).toEqual(expect.arrayContaining([
5349
6224
  expect.objectContaining({
5350
6225
  path: 'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md',
@@ -5359,7 +6234,56 @@ describe('Server Routes', () => {
5359
6234
  expect(String(payload.fingerprint || '')).toContain('asset-doc-snapshot-1');
5360
6235
  fs.rmSync(dbRoot, { recursive: true, force: true });
5361
6236
  });
5362
- it('GET /api/taskforce/sync/workspace/documents should include docs folder markdown files without canonical asset metadata', async () => {
6237
+ it('GET /api/taskforce/sync/workspace/documents should retain canonical knownPaths even when content is temporarily unreadable', async () => {
6238
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-known-paths-'));
6239
+ const projectRoot = fs.realpathSync(tmpRoot);
6240
+ const basePath = path.join(tmpRoot, '.taskforce');
6241
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
6242
+ store.upsertAsset({
6243
+ assetId: 'asset-doc-known-path-1',
6244
+ workspaceId: 'workspace-local',
6245
+ kind: 'document',
6246
+ originalFilename: 'missing-content.md',
6247
+ mimeType: 'text/markdown',
6248
+ storageProvider: 'local',
6249
+ storageKey: 'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md',
6250
+ contentSha256: 'missing-content-hash',
6251
+ sizeBytes: 17,
6252
+ documentId: 'doc-known-path-1',
6253
+ version: 1,
6254
+ isLatest: true,
6255
+ createdAt: '2026-03-17T09:00:00.000Z',
6256
+ updatedAt: '2026-03-17T10:00:00.000Z'
6257
+ });
6258
+ store.upsertLink({
6259
+ workspaceId: 'workspace-local',
6260
+ assetId: 'asset-doc-known-path-1',
6261
+ taskId: 'task-1',
6262
+ role: 'attachment'
6263
+ });
6264
+ core.getPaths.mockReturnValue({
6265
+ projectRoot,
6266
+ basePath,
6267
+ });
6268
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
6269
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
6270
+ core.getTenantId = vi.fn().mockReturnValue('default');
6271
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
6272
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/documents', routes);
6273
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/documents?workspaceId=workspace-local', undefined, {
6274
+ 'x-taskforce-workspace-id': 'workspace-local'
6275
+ });
6276
+ const res = createMockRes();
6277
+ await match?.handler(req, res, {});
6278
+ const payloadRaw = res.end.mock.calls[0]?.[0];
6279
+ const payload = JSON.parse(String(payloadRaw || '{}'));
6280
+ expect(payload.documents).toEqual([]);
6281
+ expect(payload.knownPaths).toEqual([
6282
+ 'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md'
6283
+ ]);
6284
+ fs.rmSync(dbRoot, { recursive: true, force: true });
6285
+ });
6286
+ it('GET /api/taskforce/sync/workspace/documents should exclude docs folder markdown files that are not canonical assets', async () => {
5363
6287
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-legacy-ignore-'));
5364
6288
  const projectRoot = fs.realpathSync(tmpRoot);
5365
6289
  const basePath = path.join(tmpRoot, '.taskforce');
@@ -5384,12 +6308,7 @@ describe('Server Routes', () => {
5384
6308
  const payloadRaw = res.end.mock.calls[0]?.[0];
5385
6309
  const payload = JSON.parse(String(payloadRaw || '{}'));
5386
6310
  expect(Array.isArray(payload.documents)).toBe(true);
5387
- expect(payload.documents).toEqual([
5388
- expect.objectContaining({
5389
- path: 'docs/legacy-notes.md',
5390
- content: '# legacy\n'
5391
- })
5392
- ]);
6311
+ expect(payload.documents).toEqual([]);
5393
6312
  fs.rmSync(dbRoot, { recursive: true, force: true });
5394
6313
  });
5395
6314
  it('GET /api/taskforce/sync/workspace/document-reviews should include document review sessions for the requested workspace', async () => {
@@ -5726,6 +6645,109 @@ describe('Server Routes', () => {
5726
6645
  ]));
5727
6646
  fs.rmSync(dbRoot, { recursive: true, force: true });
5728
6647
  });
6648
+ it('GET /api/taskforce/sync/workspace/pull should hydrate canonical markdown documents from object storage', async () => {
6649
+ const authConfig = {
6650
+ runtimeMode: 'cloud',
6651
+ enabled: true,
6652
+ requiredForApi: true,
6653
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
6654
+ defaultRole: 'member',
6655
+ defaultWorkspaceId: 'default',
6656
+ sessionCookieName: 'taskforce_session',
6657
+ sessionSecret: 'test-secret',
6658
+ sessionTtlSeconds: 3600
6659
+ };
6660
+ const fetchMock = vi.fn().mockResolvedValue({
6661
+ status: 200,
6662
+ ok: true,
6663
+ headers: new Headers({ 'content-type': 'text/markdown' }),
6664
+ arrayBuffer: async () => Uint8Array.from(Buffer.from('# Canonical R2 doc\n')).buffer,
6665
+ });
6666
+ vi.stubGlobal('fetch', fetchMock);
6667
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-pull-doc-r2-'));
6668
+ const basePath = path.join(tmpRoot, '.taskforce');
6669
+ fs.mkdirSync(basePath, { recursive: true });
6670
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
6671
+ store.upsertAsset({
6672
+ assetId: 'asset-doc-r2-1',
6673
+ workspaceId: 'workspace-1',
6674
+ kind: 'document',
6675
+ originalFilename: 'canonical-r2-doc.md',
6676
+ mimeType: 'text/markdown',
6677
+ storageProvider: 'r2',
6678
+ storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-r2-1-canonical-r2-doc.md',
6679
+ contentSha256: 'sha-doc-r2-1',
6680
+ sizeBytes: 19,
6681
+ documentId: 'doc-r2-1',
6682
+ referenceNumber: 201,
6683
+ version: 2,
6684
+ isLatest: true,
6685
+ createdAt: '2026-03-17T09:00:00.000Z',
6686
+ updatedAt: '2026-03-17T10:00:00.000Z'
6687
+ });
6688
+ store.upsertLink({
6689
+ workspaceId: 'workspace-1',
6690
+ assetId: 'asset-doc-r2-1',
6691
+ taskId: 'task-1',
6692
+ role: 'attachment',
6693
+ createdAt: '2026-03-17T10:00:00.000Z',
6694
+ updatedAt: '2026-03-17T10:00:00.000Z'
6695
+ });
6696
+ core.getPaths.mockReturnValue({
6697
+ projectRoot: tmpRoot,
6698
+ basePath,
6699
+ });
6700
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
6701
+ core.getTenantId = vi.fn().mockReturnValue('default');
6702
+ routes = createRoutes(core, {
6703
+ authConfig,
6704
+ objectStorage: {
6705
+ provider: 'r2',
6706
+ r2: {
6707
+ accountId: 'acct-1',
6708
+ bucket: 'taskforce-docs',
6709
+ endpoint: 'https://example.r2.cloudflarestorage.com',
6710
+ accessKeyId: 'access-key-1',
6711
+ secretAccessKey: 'secret-key-1',
6712
+ keyPrefix: 'taskforce',
6713
+ signedUploadTtlSeconds: 600,
6714
+ signedDownloadTtlSeconds: 300
6715
+ }
6716
+ }
6717
+ });
6718
+ core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
6719
+ const cookie = createSessionTokenCookie('session-sync-pull-doc-r2', authConfig, {
6720
+ role: 'owner',
6721
+ workspaceId: 'workspace-1',
6722
+ userId: 'user-1'
6723
+ });
6724
+ core.listTasks.mockReturnValueOnce({ tasks: [] });
6725
+ core.listArchive.mockReturnValueOnce({ archived: [] });
6726
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
6727
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=20', undefined, { cookie });
6728
+ const res = createMockRes();
6729
+ await match?.handler(req, res, {});
6730
+ const payloadRaw = res.end.mock.calls[0]?.[0];
6731
+ const payload = JSON.parse(String(payloadRaw || '{}'));
6732
+ expect(payload.changes).toEqual(expect.arrayContaining([
6733
+ expect.objectContaining({
6734
+ op: 'document-upsert',
6735
+ path: 'workspaces/workspace-1/assets/documents/asset-doc-r2-1-canonical-r2-doc.md',
6736
+ content: '# Canonical R2 doc\n',
6737
+ assetId: 'asset-doc-r2-1',
6738
+ documentId: 'doc-r2-1',
6739
+ referenceNumber: 201,
6740
+ version: 2,
6741
+ taskId: 'task-1',
6742
+ originalFilename: 'canonical-r2-doc.md',
6743
+ linkRole: 'attachment'
6744
+ })
6745
+ ]));
6746
+ expect(fetchMock).toHaveBeenCalled();
6747
+ vi.unstubAllGlobals();
6748
+ fs.rmSync(dbRoot, { recursive: true, force: true });
6749
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
6750
+ });
5729
6751
  it('GET /api/taskforce/sync/workspace/pull should include document review session upserts and deletes', async () => {
5730
6752
  const authConfig = {
5731
6753
  runtimeMode: 'cloud',
@@ -6376,29 +7398,144 @@ describe('Server Routes', () => {
6376
7398
  workspaceId: 'workspace-cloud-selected',
6377
7399
  userId: 'user-1'
6378
7400
  });
6379
- core.listUserWorkspaces.mockReturnValueOnce([
6380
- {
6381
- id: 'workspace-local-active',
6382
- slug: 'workspace-local-active',
6383
- name: 'Workspace Local Active',
6384
- description: null,
6385
- role: 'owner',
6386
- status: 'active',
6387
- betaAccess: true
6388
- }
6389
- ]);
6390
- core.listTasks.mockReturnValueOnce({ tasks: [] });
6391
- core.listArchive.mockReturnValueOnce({ archived: [] });
7401
+ core.listUserWorkspaces.mockReturnValueOnce([
7402
+ {
7403
+ id: 'workspace-local-active',
7404
+ slug: 'workspace-local-active',
7405
+ name: 'Workspace Local Active',
7406
+ description: null,
7407
+ role: 'owner',
7408
+ status: 'active',
7409
+ betaAccess: true
7410
+ }
7411
+ ]);
7412
+ core.listTasks.mockReturnValueOnce({ tasks: [] });
7413
+ core.listArchive.mockReturnValueOnce({ archived: [] });
7414
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7415
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?limit=10&workspaceId=workspace-local-active', undefined, { cookie });
7416
+ const res = createMockRes();
7417
+ await match?.handler(req, res, {});
7418
+ expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
7419
+ expect(core.listTasks).toHaveBeenCalledWith('workspace-local-active');
7420
+ expect(core.listArchive).toHaveBeenCalledWith('workspace-local-active');
7421
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7422
+ });
7423
+ it('GET /api/taskforce/sync/workspace/pull should maintain stable cursor pagination for equal watermarks', async () => {
7424
+ const authConfig = {
7425
+ runtimeMode: 'cloud',
7426
+ enabled: true,
7427
+ requiredForApi: true,
7428
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7429
+ defaultRole: 'member',
7430
+ defaultWorkspaceId: 'default',
7431
+ sessionCookieName: 'taskforce_session',
7432
+ sessionSecret: 'test-secret',
7433
+ sessionTtlSeconds: 3600
7434
+ };
7435
+ routes = createRoutes(core, { authConfig });
7436
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7437
+ const cookie = createSessionTokenCookie('session-sync-pull-cursor-contract', authConfig, {
7438
+ role: 'owner',
7439
+ workspaceId: 'workspace-1',
7440
+ userId: 'user-1'
7441
+ });
7442
+ core.listTasks.mockReturnValue({
7443
+ tasks: [
7444
+ { id: 'task-b', title: 'Task B', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' },
7445
+ { id: 'task-a', title: 'Task A', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' },
7446
+ { id: 'task-c', title: 'Task C', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:11:00.000Z' }
7447
+ ]
7448
+ });
7449
+ core.listArchive.mockReturnValue({ archived: [] });
7450
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7451
+ const firstReq = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=1', undefined, { cookie });
7452
+ const firstRes = createMockRes();
7453
+ await match?.handler(firstReq, firstRes, {});
7454
+ const firstBody = JSON.parse(String(firstRes.end.mock.calls[0]?.[0] || '{}'));
7455
+ expect(firstBody?.changes?.[0]).toEqual(expect.objectContaining({
7456
+ op: 'upsert',
7457
+ task: expect.objectContaining({ id: 'task-a' })
7458
+ }));
7459
+ expect(typeof firstBody?.nextCursor).toBe('string');
7460
+ const secondReq = createMockReq('GET', `/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=1&cursor=${encodeURIComponent(String(firstBody.nextCursor || ''))}`, undefined, { cookie });
7461
+ const secondRes = createMockRes();
7462
+ await match?.handler(secondReq, secondRes, {});
7463
+ const secondBody = JSON.parse(String(secondRes.end.mock.calls[0]?.[0] || '{}'));
7464
+ expect(secondBody?.changes?.[0]).toEqual(expect.objectContaining({
7465
+ op: 'upsert',
7466
+ task: expect.objectContaining({ id: 'task-b' })
7467
+ }));
7468
+ });
7469
+ it('GET /api/taskforce/sync/workspace/pull should reject an invalid sync cursor instead of replaying from the beginning', async () => {
7470
+ const authConfig = {
7471
+ runtimeMode: 'cloud',
7472
+ enabled: true,
7473
+ requiredForApi: true,
7474
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7475
+ defaultRole: 'member',
7476
+ defaultWorkspaceId: 'default',
7477
+ sessionCookieName: 'taskforce_session',
7478
+ sessionSecret: 'test-secret',
7479
+ sessionTtlSeconds: 3600
7480
+ };
7481
+ routes = createRoutes(core, { authConfig });
7482
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7483
+ const cookie = createSessionTokenCookie('session-sync-pull-invalid-cursor', authConfig, {
7484
+ role: 'owner',
7485
+ workspaceId: 'workspace-1',
7486
+ userId: 'user-1'
7487
+ });
7488
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7489
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor', undefined, { cookie });
7490
+ const res = createMockRes();
7491
+ await match?.handler(req, res, {});
7492
+ expect(core.listTasks).not.toHaveBeenCalled();
7493
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
7494
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"INVALID_SYNC_CURSOR"'));
7495
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
7496
+ action: 'sync-workspace-pull-invalid-cursor'
7497
+ }));
7498
+ });
7499
+ it('GET /api/taskforce/sync/workspace/pull should ignore cursors during repair', async () => {
7500
+ const authConfig = {
7501
+ runtimeMode: 'cloud',
7502
+ enabled: true,
7503
+ requiredForApi: true,
7504
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7505
+ defaultRole: 'member',
7506
+ defaultWorkspaceId: 'default',
7507
+ sessionCookieName: 'taskforce_session',
7508
+ sessionSecret: 'test-secret',
7509
+ sessionTtlSeconds: 3600
7510
+ };
7511
+ routes = createRoutes(core, { authConfig });
7512
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7513
+ core.listTasks.mockReturnValue({
7514
+ tasks: [
7515
+ { id: 'task-repair-1', title: 'Repair me', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' }
7516
+ ]
7517
+ });
7518
+ core.listArchive.mockReturnValue({ archived: [] });
7519
+ const cookie = createSessionTokenCookie('session-sync-pull-repair-cursor', authConfig, {
7520
+ role: 'owner',
7521
+ workspaceId: 'workspace-1',
7522
+ userId: 'user-1'
7523
+ });
6392
7524
  const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
6393
- const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?limit=10&workspaceId=workspace-local-active', undefined, { cookie });
7525
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor&repair=1', undefined, { cookie });
6394
7526
  const res = createMockRes();
6395
7527
  await match?.handler(req, res, {});
6396
- expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
6397
- expect(core.listTasks).toHaveBeenCalledWith('workspace-local-active');
6398
- expect(core.listArchive).toHaveBeenCalledWith('workspace-local-active');
6399
7528
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7529
+ expect(core.listTasks).toHaveBeenCalled();
7530
+ const body = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
7531
+ expect(body.changes).toEqual(expect.arrayContaining([
7532
+ expect.objectContaining({
7533
+ op: 'upsert',
7534
+ task: expect.objectContaining({ id: 'task-repair-1' })
7535
+ })
7536
+ ]));
6400
7537
  });
6401
- it('GET /api/taskforce/sync/workspace/pull should maintain stable cursor pagination for equal watermarks', async () => {
7538
+ it('GET /api/taskforce/sync/workspace/pull should still return success when success audit throws', async () => {
6402
7539
  const authConfig = {
6403
7540
  runtimeMode: 'cloud',
6404
7541
  enabled: true,
@@ -6411,38 +7548,27 @@ describe('Server Routes', () => {
6411
7548
  sessionTtlSeconds: 3600
6412
7549
  };
6413
7550
  routes = createRoutes(core, { authConfig });
6414
- core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
6415
- const cookie = createSessionTokenCookie('session-sync-pull-cursor-contract', authConfig, {
7551
+ core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7552
+ const cookie = createSessionTokenCookie('session-sync-pull-audit-throw', authConfig, {
6416
7553
  role: 'owner',
6417
7554
  workspaceId: 'workspace-1',
6418
7555
  userId: 'user-1'
6419
7556
  });
6420
- core.listTasks.mockReturnValue({
6421
- tasks: [
6422
- { id: 'task-b', title: 'Task B', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' },
6423
- { id: 'task-a', title: 'Task A', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' },
6424
- { id: 'task-c', title: 'Task C', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:11:00.000Z' }
6425
- ]
7557
+ const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
7558
+ core.addAdminAuditLog.mockImplementation((entry) => {
7559
+ if (entry?.action === 'sync-workspace-pull-success') {
7560
+ throw new Error('audit unavailable');
7561
+ }
7562
+ return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
6426
7563
  });
6427
- core.listArchive.mockReturnValue({ archived: [] });
6428
7564
  const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
6429
- const firstReq = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=1', undefined, { cookie });
6430
- const firstRes = createMockRes();
6431
- await match?.handler(firstReq, firstRes, {});
6432
- const firstBody = JSON.parse(String(firstRes.end.mock.calls[0]?.[0] || '{}'));
6433
- expect(firstBody?.changes?.[0]).toEqual(expect.objectContaining({
6434
- op: 'upsert',
6435
- task: expect.objectContaining({ id: 'task-a' })
6436
- }));
6437
- expect(typeof firstBody?.nextCursor).toBe('string');
6438
- const secondReq = createMockReq('GET', `/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=1&cursor=${encodeURIComponent(String(firstBody.nextCursor || ''))}`, undefined, { cookie });
6439
- const secondRes = createMockRes();
6440
- await match?.handler(secondReq, secondRes, {});
6441
- const secondBody = JSON.parse(String(secondRes.end.mock.calls[0]?.[0] || '{}'));
6442
- expect(secondBody?.changes?.[0]).toEqual(expect.objectContaining({
6443
- op: 'upsert',
6444
- task: expect.objectContaining({ id: 'task-b' })
6445
- }));
7565
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10', undefined, { cookie });
7566
+ const res = createMockRes();
7567
+ await match?.handler(req, res, {});
7568
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
7569
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7570
+ expect(res.end).toHaveBeenCalledTimes(1);
7571
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
6446
7572
  });
6447
7573
  it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
6448
7574
  const authConfig = {
@@ -6505,6 +7631,53 @@ describe('Server Routes', () => {
6505
7631
  }), 'workspace-local');
6506
7632
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
6507
7633
  });
7634
+ it('POST /api/taskforce/sync/workspace/apply-local should still return success when success sync telemetry throws after apply', async () => {
7635
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
7636
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
7637
+ core.recordSyncEvent.mockImplementationOnce(() => {
7638
+ throw new Error('telemetry unavailable');
7639
+ });
7640
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
7641
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
7642
+ changes: [
7643
+ { op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
7644
+ ]
7645
+ }, {
7646
+ 'x-taskforce-workspace-id': 'workspace-local'
7647
+ });
7648
+ const res = createMockRes();
7649
+ await match?.handler(req, res, {});
7650
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
7651
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
7652
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7653
+ expect(res.end).toHaveBeenCalledTimes(1);
7654
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
7655
+ });
7656
+ it('POST /api/taskforce/sync/workspace/apply-local should still return error response when failure-path telemetry throws', async () => {
7657
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
7658
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
7659
+ core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
7660
+ throw new Error('apply failed');
7661
+ });
7662
+ core.recordSyncEvent.mockImplementationOnce(() => {
7663
+ throw new Error('telemetry unavailable');
7664
+ });
7665
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
7666
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
7667
+ changes: [
7668
+ { op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
7669
+ ]
7670
+ }, {
7671
+ 'x-taskforce-workspace-id': 'workspace-local'
7672
+ });
7673
+ const res = createMockRes();
7674
+ await match?.handler(req, res, {});
7675
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
7676
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
7677
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
7678
+ expect(res.end).toHaveBeenCalledTimes(1);
7679
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
7680
+ });
6508
7681
  it('POST /api/taskforce/sync/workspace/apply-local should apply document review session upserts and deletes', async () => {
6509
7682
  core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
6510
7683
  routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
@@ -7249,7 +8422,7 @@ describe('Server Routes', () => {
7249
8422
  ]);
7250
8423
  fs.rmSync(dbRoot, { recursive: true, force: true });
7251
8424
  });
7252
- it('POST /api/taskforce/sync/workspace/apply-local should preserve document references during normal sync when content is unchanged', async () => {
8425
+ it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud document references during normal sync even when content is unchanged', async () => {
7253
8426
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-doc-metadata-'));
7254
8427
  const projectRoot = fs.realpathSync(tmpRoot);
7255
8428
  const basePath = path.join(tmpRoot, '.taskforce');
@@ -7303,7 +8476,7 @@ describe('Server Routes', () => {
7303
8476
  const res = createMockRes();
7304
8477
  await match?.handler(req, res, {});
7305
8478
  expect(store.get('asset-sync-doc-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
7306
- referenceNumber: 9
8479
+ referenceNumber: 42
7307
8480
  }));
7308
8481
  fs.rmSync(dbRoot, { recursive: true, force: true });
7309
8482
  });
@@ -7366,7 +8539,7 @@ describe('Server Routes', () => {
7366
8539
  }));
7367
8540
  fs.rmSync(dbRoot, { recursive: true, force: true });
7368
8541
  });
7369
- it('POST /api/taskforce/sync/workspace/apply-local should preserve image references during normal sync when content is unchanged', async () => {
8542
+ it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud image references during normal sync even when content is unchanged', async () => {
7370
8543
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-image-metadata-'));
7371
8544
  const projectRoot = fs.realpathSync(tmpRoot);
7372
8545
  const basePath = path.join(tmpRoot, '.taskforce');
@@ -7418,7 +8591,7 @@ describe('Server Routes', () => {
7418
8591
  const res = createMockRes();
7419
8592
  await match?.handler(req, res, {});
7420
8593
  expect(store.get('asset-sync-image-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
7421
- referenceNumber: 9
8594
+ referenceNumber: 42
7422
8595
  }));
7423
8596
  fs.rmSync(dbRoot, { recursive: true, force: true });
7424
8597
  });
@@ -7735,15 +8908,213 @@ describe('Server Routes', () => {
7735
8908
  const res2 = createMockRes();
7736
8909
  await match?.handler(req2, res2, {});
7737
8910
  expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
7738
- expect(res2.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7739
- const secondPayload = JSON.parse(String(res2.end.mock.calls.at(-1)?.[0] || '{}'));
7740
- expect(secondPayload.emittedEventIds).toEqual(firstPayload.emittedEventIds);
7741
- expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
7742
- action: 'sync-workspace-push-success'
7743
- }));
7744
- expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
7745
- action: 'sync-workspace-push-idempotent-replay'
7746
- }));
8911
+ expect(res2.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8912
+ const secondPayload = JSON.parse(String(res2.end.mock.calls.at(-1)?.[0] || '{}'));
8913
+ expect(secondPayload.emittedEventIds).toEqual(firstPayload.emittedEventIds);
8914
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
8915
+ action: 'sync-workspace-push-success'
8916
+ }));
8917
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
8918
+ action: 'sync-workspace-push-idempotent-replay'
8919
+ }));
8920
+ });
8921
+ it('POST /api/taskforce/sync/workspace/push should still return success when idempotency persistence fails after apply', async () => {
8922
+ const authConfig = {
8923
+ runtimeMode: 'cloud',
8924
+ enabled: true,
8925
+ requiredForApi: true,
8926
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
8927
+ defaultRole: 'member',
8928
+ defaultWorkspaceId: 'default',
8929
+ sessionCookieName: 'taskforce_session',
8930
+ sessionSecret: 'test-secret',
8931
+ sessionTtlSeconds: 3600
8932
+ };
8933
+ routes = createRoutes(core, { authConfig });
8934
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
8935
+ const cookie = createSessionTokenCookie('session-sync-push-idempotency-write-fail', authConfig, {
8936
+ role: 'owner',
8937
+ workspaceId: 'workspace-1',
8938
+ userId: 'user-1'
8939
+ });
8940
+ core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
8941
+ total: 1,
8942
+ created: 1,
8943
+ updated: 0,
8944
+ archived: 0,
8945
+ unarchived: 0,
8946
+ skipped: 0
8947
+ });
8948
+ core.writePushIdempotency.mockImplementationOnce(() => {
8949
+ throw new Error('disk full');
8950
+ });
8951
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
8952
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
8953
+ workspaceId: 'workspace-1',
8954
+ idempotencyKey: 'push-idempotency-write-fail-1',
8955
+ changes: [
8956
+ { op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
8957
+ ]
8958
+ }, { cookie });
8959
+ const res = createMockRes();
8960
+ await match?.handler(req, res, {});
8961
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
8962
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8963
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
8964
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
8965
+ action: 'sync-workspace-push-idempotency-write-failed'
8966
+ }));
8967
+ expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
8968
+ eventType: 'push',
8969
+ status: 'success'
8970
+ }));
8971
+ });
8972
+ it('POST /api/taskforce/sync/workspace/push should still return success when applied-task readback fails after apply', async () => {
8973
+ const authConfig = {
8974
+ runtimeMode: 'cloud',
8975
+ enabled: true,
8976
+ requiredForApi: true,
8977
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
8978
+ defaultRole: 'member',
8979
+ defaultWorkspaceId: 'default',
8980
+ sessionCookieName: 'taskforce_session',
8981
+ sessionSecret: 'test-secret',
8982
+ sessionTtlSeconds: 3600
8983
+ };
8984
+ routes = createRoutes(core, { authConfig });
8985
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
8986
+ const cookie = createSessionTokenCookie('session-sync-push-upsert-echo-fail', authConfig, {
8987
+ role: 'owner',
8988
+ workspaceId: 'workspace-1',
8989
+ userId: 'user-1'
8990
+ });
8991
+ core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
8992
+ total: 1,
8993
+ created: 1,
8994
+ updated: 0,
8995
+ archived: 0,
8996
+ unarchived: 0,
8997
+ skipped: 0
8998
+ });
8999
+ core.getTask.mockImplementationOnce(() => {
9000
+ throw new Error('readback unavailable');
9001
+ });
9002
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
9003
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
9004
+ workspaceId: 'workspace-1',
9005
+ idempotencyKey: 'push-upsert-echo-fail-1',
9006
+ changes: [
9007
+ { op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
9008
+ ]
9009
+ }, { cookie });
9010
+ const res = createMockRes();
9011
+ await match?.handler(req, res, {});
9012
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
9013
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9014
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
9015
+ expect(payload.success).toBe(true);
9016
+ expect(payload.appliedTaskUpserts).toEqual([]);
9017
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
9018
+ action: 'sync-workspace-push-upsert-echo-failed'
9019
+ }));
9020
+ expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
9021
+ eventType: 'push',
9022
+ status: 'success'
9023
+ }));
9024
+ });
9025
+ it('POST /api/taskforce/sync/workspace/push should still return success when success-path audit logging throws after apply', async () => {
9026
+ const authConfig = {
9027
+ runtimeMode: 'cloud',
9028
+ enabled: true,
9029
+ requiredForApi: true,
9030
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
9031
+ defaultRole: 'member',
9032
+ defaultWorkspaceId: 'default',
9033
+ sessionCookieName: 'taskforce_session',
9034
+ sessionSecret: 'test-secret',
9035
+ sessionTtlSeconds: 3600
9036
+ };
9037
+ routes = createRoutes(core, { authConfig });
9038
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
9039
+ const cookie = createSessionTokenCookie('session-sync-push-success-audit-fail', authConfig, {
9040
+ role: 'owner',
9041
+ workspaceId: 'workspace-1',
9042
+ userId: 'user-1'
9043
+ });
9044
+ core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
9045
+ total: 1,
9046
+ created: 1,
9047
+ updated: 0,
9048
+ archived: 0,
9049
+ unarchived: 0,
9050
+ skipped: 0
9051
+ });
9052
+ const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
9053
+ core.addAdminAuditLog.mockImplementation((entry) => {
9054
+ if (entry?.action === 'sync-workspace-push-success') {
9055
+ throw new Error('audit unavailable');
9056
+ }
9057
+ return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
9058
+ });
9059
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
9060
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
9061
+ workspaceId: 'workspace-1',
9062
+ idempotencyKey: 'push-success-audit-fail-1',
9063
+ changes: [
9064
+ { op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
9065
+ ]
9066
+ }, { cookie });
9067
+ const res = createMockRes();
9068
+ await match?.handler(req, res, {});
9069
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
9070
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
9071
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9072
+ expect(res.end).toHaveBeenCalledTimes(1);
9073
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
9074
+ expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
9075
+ eventType: 'push',
9076
+ status: 'success'
9077
+ }));
9078
+ });
9079
+ it('POST /api/taskforce/sync/workspace/push should still return error response when failure-path telemetry throws', async () => {
9080
+ const authConfig = {
9081
+ runtimeMode: 'cloud',
9082
+ enabled: true,
9083
+ requiredForApi: true,
9084
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
9085
+ defaultRole: 'member',
9086
+ defaultWorkspaceId: 'default',
9087
+ sessionCookieName: 'taskforce_session',
9088
+ sessionSecret: 'test-secret',
9089
+ sessionTtlSeconds: 3600
9090
+ };
9091
+ routes = createRoutes(core, { authConfig });
9092
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
9093
+ const cookie = createSessionTokenCookie('session-sync-push-failure-telemetry-throw', authConfig, {
9094
+ role: 'owner',
9095
+ workspaceId: 'workspace-1',
9096
+ userId: 'user-1'
9097
+ });
9098
+ core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
9099
+ throw new Error('apply failed');
9100
+ });
9101
+ core.recordSyncEvent.mockImplementationOnce(() => {
9102
+ throw new Error('telemetry unavailable');
9103
+ });
9104
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
9105
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
9106
+ workspaceId: 'workspace-1',
9107
+ changes: [
9108
+ { op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
9109
+ ]
9110
+ }, { cookie });
9111
+ const res = createMockRes();
9112
+ await match?.handler(req, res, {});
9113
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
9114
+ expect(res.writeHead).toHaveBeenCalledTimes(1);
9115
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
9116
+ expect(res.end).toHaveBeenCalledTimes(1);
9117
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
7747
9118
  });
7748
9119
  it('POST /api/taskforce/sync/workspace/push should persist synced cloud attachments into R2-backed canonical assets', async () => {
7749
9120
  const authConfig = {
@@ -8775,6 +10146,127 @@ describe('Server Routes', () => {
8775
10146
  expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
8776
10147
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8777
10148
  });
10149
+ it('POST /api/taskforce/session/workspace should allow a read-only user to switch to another active workspace', async () => {
10150
+ const authConfig = {
10151
+ runtimeMode: 'cloud',
10152
+ enabled: true,
10153
+ requiredForApi: true,
10154
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
10155
+ defaultRole: 'member',
10156
+ defaultWorkspaceId: 'default',
10157
+ sessionCookieName: 'taskforce_session',
10158
+ sessionSecret: 'test-secret',
10159
+ sessionTtlSeconds: 3600
10160
+ };
10161
+ routes = createRoutes(core, { authConfig });
10162
+ const cookie = createSessionTokenCookie('session-read-only', authConfig, {
10163
+ role: 'read-only',
10164
+ workspaceId: 'workspace-shared',
10165
+ userId: 'user-1'
10166
+ });
10167
+ core.listUserWorkspaces.mockReturnValue([
10168
+ {
10169
+ id: 'workspace-shared',
10170
+ slug: 'workspace-shared',
10171
+ name: 'Shared Workspace',
10172
+ description: null,
10173
+ role: 'read-only',
10174
+ status: 'active',
10175
+ betaAccess: true
10176
+ },
10177
+ {
10178
+ id: 'workspace-home',
10179
+ slug: 'workspace-home',
10180
+ name: 'Home Workspace',
10181
+ description: null,
10182
+ role: 'owner',
10183
+ status: 'active',
10184
+ betaAccess: true
10185
+ }
10186
+ ]);
10187
+ core.resolveUserAccess.mockReturnValue({
10188
+ userId: 'user-1',
10189
+ workspaceId: 'workspace-home',
10190
+ role: 'owner',
10191
+ permissionMode: 'read-write',
10192
+ disabled: false,
10193
+ betaAccess: true
10194
+ });
10195
+ const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
10196
+ const req = createMockReq('POST', '/api/taskforce/session/workspace', {
10197
+ workspaceId: 'workspace-home'
10198
+ }, {
10199
+ cookie
10200
+ });
10201
+ const res = createMockRes();
10202
+ res.setHeader = vi.fn();
10203
+ await match?.handler(req, res, {});
10204
+ expect(core.resolveUserAccess).toHaveBeenCalledWith('user-1', 'workspace-home', 'read-only');
10205
+ expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
10206
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10207
+ const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
10208
+ expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-home', role: 'owner' });
10209
+ });
10210
+ it('POST /api/taskforce/session/workspace should allow switching into an active read-only workspace', async () => {
10211
+ const authConfig = {
10212
+ runtimeMode: 'cloud',
10213
+ enabled: true,
10214
+ requiredForApi: true,
10215
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
10216
+ defaultRole: 'member',
10217
+ defaultWorkspaceId: 'default',
10218
+ sessionCookieName: 'taskforce_session',
10219
+ sessionSecret: 'test-secret',
10220
+ sessionTtlSeconds: 3600
10221
+ };
10222
+ routes = createRoutes(core, { authConfig });
10223
+ const cookie = createSessionTokenCookie('session-member', authConfig, {
10224
+ role: 'owner',
10225
+ workspaceId: 'workspace-home',
10226
+ userId: 'user-1'
10227
+ });
10228
+ core.listUserWorkspaces.mockReturnValue([
10229
+ {
10230
+ id: 'workspace-home',
10231
+ slug: 'workspace-home',
10232
+ name: 'Home Workspace',
10233
+ description: null,
10234
+ role: 'owner',
10235
+ status: 'active',
10236
+ betaAccess: true
10237
+ },
10238
+ {
10239
+ id: 'workspace-shared',
10240
+ slug: 'workspace-shared',
10241
+ name: 'Shared Workspace',
10242
+ description: null,
10243
+ role: 'read-only',
10244
+ status: 'active',
10245
+ betaAccess: true
10246
+ }
10247
+ ]);
10248
+ core.resolveUserAccess.mockReturnValue({
10249
+ userId: 'user-1',
10250
+ workspaceId: 'workspace-shared',
10251
+ role: 'read-only',
10252
+ permissionMode: 'read-only',
10253
+ disabled: false,
10254
+ betaAccess: true
10255
+ });
10256
+ const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
10257
+ const req = createMockReq('POST', '/api/taskforce/session/workspace', {
10258
+ workspaceId: 'workspace-shared'
10259
+ }, {
10260
+ cookie
10261
+ });
10262
+ const res = createMockRes();
10263
+ res.setHeader = vi.fn();
10264
+ await match?.handler(req, res, {});
10265
+ expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
10266
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10267
+ const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
10268
+ expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-shared', role: 'read-only' });
10269
+ });
8778
10270
  it('POST /api/taskforce/session/workspace should be disabled in local runtime', async () => {
8779
10271
  const authConfig = {
8780
10272
  runtimeMode: 'local',
@@ -8871,6 +10363,7 @@ describe('Server Routes', () => {
8871
10363
  const payload = JSON.parse(res.end.mock.calls[0][0]);
8872
10364
  expect(payload.authenticated).toBe(true);
8873
10365
  expect(payload.betaAccess).toBe(false);
10366
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('auth-session'));
8874
10367
  expect(core.getUserBetaAccess).toHaveBeenCalledWith('u-no-workspace');
8875
10368
  });
8876
10369
  it('GET /api/taskforce/auth/session should expose avatarUrl from the resolved profile', async () => {
@@ -9081,6 +10574,74 @@ describe('Server Routes', () => {
9081
10574
  mimeType: 'image/png'
9082
10575
  }));
9083
10576
  });
10577
+ it('POST /api/taskforce/auth/profile/avatar/upload should store the avatar body through object storage', async () => {
10578
+ const authConfig = {
10579
+ runtimeMode: 'cloud',
10580
+ enabled: true,
10581
+ requiredForApi: true,
10582
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
10583
+ defaultRole: 'member',
10584
+ defaultWorkspaceId: 'default',
10585
+ sessionCookieName: 'taskforce_session',
10586
+ sessionSecret: 'test-secret',
10587
+ sessionTtlSeconds: 3600
10588
+ };
10589
+ const fetchMock = vi.fn().mockResolvedValue({
10590
+ ok: true,
10591
+ status: 200,
10592
+ statusText: 'OK',
10593
+ text: async () => ''
10594
+ });
10595
+ vi.stubGlobal('fetch', fetchMock);
10596
+ const routesWithStorage = createRoutes(core, {
10597
+ authConfig,
10598
+ objectStorage: {
10599
+ provider: 'r2',
10600
+ r2: {
10601
+ accountId: 'acct-1',
10602
+ bucket: 'taskforce-docs',
10603
+ endpoint: 'https://example.r2.cloudflarestorage.com',
10604
+ accessKeyId: 'access-key-1',
10605
+ secretAccessKey: 'secret-key-1',
10606
+ keyPrefix: 'taskforce',
10607
+ signedUploadTtlSeconds: 600,
10608
+ signedDownloadTtlSeconds: 300
10609
+ }
10610
+ }
10611
+ });
10612
+ core.getUserAvatarUploadDraft.mockReturnValueOnce({
10613
+ draftId: 'avatar-draft-1',
10614
+ userId: 'u1',
10615
+ storageKey: 'users/u1/profile/avatar/avatar-draft-1-headshot.png',
10616
+ mimeType: 'image/png',
10617
+ finalizedAt: null,
10618
+ discardedAt: null
10619
+ });
10620
+ const cookie = createSessionTokenCookie('session-avatar-upload', authConfig, {
10621
+ userId: 'u1',
10622
+ role: 'member',
10623
+ workspaceId: 'workspace-1'
10624
+ });
10625
+ const match = matchRoute('POST', '/api/taskforce/auth/profile/avatar/upload', routesWithStorage);
10626
+ const req = createMockReq('POST', '/api/taskforce/auth/profile/avatar/upload', Buffer.from([1, 2, 3, 4]), {
10627
+ cookie,
10628
+ 'content-type': 'image/png',
10629
+ 'x-taskforce-avatar-draft-id': 'avatar-draft-1'
10630
+ });
10631
+ const res = createMockRes();
10632
+ await match?.handler(req, res, {});
10633
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
10634
+ expect(payload.success).toBe(true);
10635
+ expect(payload.draftId).toBe('avatar-draft-1');
10636
+ const r2Call = fetchMock.mock.calls[0];
10637
+ expect(String(r2Call?.[0])).toContain('https://example.r2.cloudflarestorage.com/taskforce-docs/taskforce/users/u1/profile/avatar/avatar-draft-1-headshot.png');
10638
+ expect(r2Call?.[1]).toEqual(expect.objectContaining({
10639
+ method: 'PUT',
10640
+ body: expect.any(Uint8Array)
10641
+ }));
10642
+ expect((r2Call?.[1]?.headers)['content-type']).toBe('image/png');
10643
+ vi.unstubAllGlobals();
10644
+ });
9084
10645
  it('POST /api/taskforce/auth/profile/avatar/finalize should validate and finalize the uploaded draft', async () => {
9085
10646
  const authConfig = {
9086
10647
  runtimeMode: 'cloud',
@@ -9205,6 +10766,56 @@ describe('Server Routes', () => {
9205
10766
  allowed: true
9206
10767
  });
9207
10768
  });
10769
+ it('GET /api/taskforce/account/team-management-access should resolve against the authenticated session user and workspace', async () => {
10770
+ const authConfig = {
10771
+ runtimeMode: 'cloud',
10772
+ enabled: true,
10773
+ requiredForApi: true,
10774
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
10775
+ defaultRole: 'member',
10776
+ defaultWorkspaceId: 'default',
10777
+ sessionCookieName: 'taskforce_session',
10778
+ sessionSecret: 'test-secret',
10779
+ sessionTtlSeconds: 3600
10780
+ };
10781
+ routes = createRoutes(core, { authConfig });
10782
+ core.listUserWorkspaces.mockReturnValueOnce([
10783
+ { id: 'workspace-auth-existing-home', slug: 'home', name: 'Home', description: null, role: 'owner', status: 'active', betaAccess: true },
10784
+ { id: 'workspace-invited', slug: 'invited', name: 'Invited', description: null, role: 'member', status: 'active', betaAccess: true }
10785
+ ]);
10786
+ core.resolveUserAccess.mockImplementation((userId, workspaceId, role) => ({
10787
+ userId,
10788
+ workspaceId,
10789
+ role: userId === 'joined-member' && workspaceId === 'workspace-invited' ? 'member' : role,
10790
+ disabled: false,
10791
+ betaAccess: true
10792
+ }));
10793
+ const cookie = createSessionTokenCookie('session-joined-member', authConfig, {
10794
+ userId: 'joined-member',
10795
+ role: 'member',
10796
+ workspaceId: 'workspace-invited'
10797
+ });
10798
+ const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
10799
+ const req = createMockReq('GET', '/api/taskforce/account/team-management-access', undefined, {
10800
+ cookie,
10801
+ 'x-taskforce-runtime-mode': 'cloud',
10802
+ 'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
10803
+ 'x-taskforce-workspace-id': 'workspace-auth-existing-home',
10804
+ 'x-taskforce-role': 'owner'
10805
+ });
10806
+ const res = createMockRes();
10807
+ await match?.handler(req, res, {});
10808
+ expect(core.resolveUserAccess).toHaveBeenCalledWith('joined-member', 'workspace-invited', 'member');
10809
+ expect(core.getAccountEntitlementsPayload).toHaveBeenCalledWith({ userId: 'joined-member', workspaceId: 'workspace-invited' });
10810
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
10811
+ expect(payload).toMatchObject({
10812
+ workspaceId: 'workspace-invited',
10813
+ role: 'member',
10814
+ canManageWorkspace: false,
10815
+ teamPlanMode: 'team',
10816
+ allowed: false
10817
+ });
10818
+ });
9208
10819
  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 () => {
9209
10820
  core.resolveUserAccess.mockReturnValueOnce(null);
9210
10821
  const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
@@ -9281,6 +10892,40 @@ describe('Server Routes', () => {
9281
10892
  planSelectionRequired: false
9282
10893
  });
9283
10894
  });
10895
+ it('GET /api/taskforce/account/ai-profile-seat-usage should return cloud-authoritative ai seat usage', async () => {
10896
+ const authConfig = {
10897
+ runtimeMode: 'cloud',
10898
+ enabled: true,
10899
+ requiredForApi: true,
10900
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
10901
+ defaultRole: 'member',
10902
+ defaultWorkspaceId: 'default',
10903
+ sessionCookieName: 'taskforce_session',
10904
+ sessionSecret: 'test-secret',
10905
+ sessionTtlSeconds: 3600
10906
+ };
10907
+ routes = createRoutes(core, { authConfig });
10908
+ core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
10909
+ const match = matchRoute('GET', '/api/taskforce/account/ai-profile-seat-usage', routes);
10910
+ const cookie = createSessionTokenCookie('session-ai-seat-usage', authConfig, {
10911
+ userId: 'u1',
10912
+ role: 'owner',
10913
+ workspaceId: 'workspace-1'
10914
+ });
10915
+ const req = createMockReq('GET', '/api/taskforce/account/ai-profile-seat-usage?workspaceId=workspace-1', undefined, {
10916
+ cookie,
10917
+ 'x-taskforce-runtime-mode': 'cloud',
10918
+ 'x-taskforce-user-id': 'u1',
10919
+ 'x-taskforce-role': 'owner',
10920
+ 'x-taskforce-workspace-id': 'workspace-1'
10921
+ });
10922
+ const res = createMockRes();
10923
+ await match?.handler(req, res, {});
10924
+ expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
10925
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10926
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
10927
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 0, limit: 20, remaining: 20 });
10928
+ });
9284
10929
  it('GET /api/taskforce/account/access-status should return pending plan-selection gate', async () => {
9285
10930
  core.getAccountAccessStatus.mockReturnValueOnce({
9286
10931
  gate: 'plan_selection_required',
@@ -9353,6 +10998,78 @@ describe('Server Routes', () => {
9353
10998
  error.code = 'ENTITLEMENT_NOT_FOUND';
9354
10999
  throw error;
9355
11000
  });
11001
+ core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
11002
+ const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
11003
+ const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
11004
+ 'x-taskforce-user-id': 'u1'
11005
+ });
11006
+ const res = createMockRes();
11007
+ await match?.handler(req, res, {});
11008
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11009
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
11010
+ expect(payload).toMatchObject({
11011
+ planId: 'pro',
11012
+ planVersionId: 'pro-v1',
11013
+ entitlementState: 'checkout_pending',
11014
+ gate: 'checkout_pending',
11015
+ message: 'Complete checkout to continue.',
11016
+ billingConflictCode: null,
11017
+ planSelectionRequired: false,
11018
+ workspaceId: 'workspace-1',
11019
+ workspaceRole: 'owner',
11020
+ canManageWorkspace: true,
11021
+ teamPlanMode: 'personal',
11022
+ teamManagementAllowed: false,
11023
+ aiProfileSeatUsage: {
11024
+ used: 0,
11025
+ limit: 20,
11026
+ remaining: 20
11027
+ }
11028
+ });
11029
+ });
11030
+ it('GET /api/taskforce/account/profile-summary should not leak stale billing mirror plan identity when entitlement is missing', async () => {
11031
+ core.resolveUserAccess.mockReturnValueOnce({
11032
+ userId: 'u1',
11033
+ workspaceId: 'workspace-1',
11034
+ role: 'member',
11035
+ disabled: false,
11036
+ betaAccess: true
11037
+ });
11038
+ core.getAccountAccessStatus.mockReturnValueOnce({
11039
+ gate: 'missing_entitlement',
11040
+ hasEntitlement: false,
11041
+ canAccessApp: false,
11042
+ canAccessPlans: true,
11043
+ planSelectionRequired: false,
11044
+ message: 'No entitlement is linked to this account.',
11045
+ entitlement: null
11046
+ });
11047
+ core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
11048
+ const baseDb = core.getDatabaseAdapter();
11049
+ core.getDatabaseAdapter = vi.fn().mockReturnValue({
11050
+ ...baseDb,
11051
+ prepare: vi.fn((sql) => {
11052
+ if (sql.includes('FROM user_billing')) {
11053
+ return {
11054
+ get: () => ({
11055
+ stripe_status: 'active',
11056
+ stripe_customer_id: 'cus_stale',
11057
+ stripe_subscription_id: 'sub_stale',
11058
+ stripe_price_id: 'price_stale',
11059
+ billing_interval: 'month',
11060
+ entitlement_state: 'active',
11061
+ tier: 'team-5'
11062
+ })
11063
+ };
11064
+ }
11065
+ if (sql.includes('FROM plan_catalog')) {
11066
+ return {
11067
+ get: () => ({ display_name: 'Team 5' })
11068
+ };
11069
+ }
11070
+ return baseDb.prepare(sql);
11071
+ })
11072
+ });
9356
11073
  const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
9357
11074
  const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
9358
11075
  'x-taskforce-user-id': 'u1'
@@ -9362,18 +11079,15 @@ describe('Server Routes', () => {
9362
11079
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9363
11080
  const payload = JSON.parse(res.end.mock.calls[0][0]);
9364
11081
  expect(payload).toMatchObject({
9365
- planId: 'pro',
9366
- planVersionId: 'pro-v1',
9367
- entitlementState: 'checkout_pending',
9368
- gate: 'checkout_pending',
9369
- message: 'Complete checkout to continue.',
9370
- billingConflictCode: null,
11082
+ planId: null,
11083
+ planVersionId: null,
11084
+ entitlementState: null,
11085
+ gate: 'missing_entitlement',
11086
+ message: 'No entitlement is linked to this account.',
9371
11087
  planSelectionRequired: false,
9372
- workspaceId: 'workspace-1',
9373
- workspaceRole: 'owner',
9374
- canManageWorkspace: true,
9375
- teamPlanMode: 'personal',
9376
- teamManagementAllowed: false
11088
+ stripeStatus: 'active',
11089
+ stripeCustomerId: 'cus_stale',
11090
+ stripeSubscriptionId: 'sub_stale'
9377
11091
  });
9378
11092
  });
9379
11093
  it('GET /api/taskforce/account/entitlements should surface checkout-pending gate when billing is not complete', async () => {
@@ -9445,6 +11159,20 @@ describe('Server Routes', () => {
9445
11159
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"PLAN_SELECTION_REQUIRED"'));
9446
11160
  expect(core.listTasks).not.toHaveBeenCalled();
9447
11161
  });
11162
+ it('GET /api/taskforce/tasks should require authenticated cloud users', async () => {
11163
+ const authConfig = createCloudAuthConfig();
11164
+ routes = createRoutes(core, { authConfig });
11165
+ core.listTasks.mockReturnValue({ tasks: [] });
11166
+ const match = matchRoute('GET', '/api/taskforce/tasks', routes);
11167
+ const req = createMockReq('GET', '/api/taskforce/tasks', undefined, {
11168
+ 'x-taskforce-runtime-mode': 'cloud'
11169
+ });
11170
+ const res = createMockRes();
11171
+ await match?.handler(req, res, {});
11172
+ expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
11173
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"Authentication required."'));
11174
+ expect(core.listTasks).not.toHaveBeenCalled();
11175
+ });
9448
11176
  it('GET /api/taskforce/tasks should block authenticated users with missing entitlements', async () => {
9449
11177
  const authConfig = {
9450
11178
  runtimeMode: 'cloud',
@@ -9552,17 +11280,31 @@ describe('Server Routes', () => {
9552
11280
  try {
9553
11281
  core.getTenantId = vi.fn().mockReturnValue('default');
9554
11282
  core.getDatabaseAdapter = vi.fn().mockReturnValue({
9555
- prepare: vi.fn().mockReturnValue({
9556
- all: vi.fn().mockReturnValue([
9557
- {
9558
- plan_version_id: 'free-v1',
9559
- billing_interval: 'month',
9560
- stripe_price_id: 'free:free-v1:month',
9561
- pricing_type: 'free',
9562
- unit_amount: 0,
9563
- currency: null
9564
- }
9565
- ])
11283
+ prepare: vi.fn().mockImplementation((sql) => {
11284
+ if (sql.includes('FROM billing_price_mappings')) {
11285
+ return {
11286
+ get: vi.fn().mockReturnValue({
11287
+ count: 1,
11288
+ max_updated_at: '2026-01-01T00:00:00.000Z'
11289
+ }),
11290
+ all: vi.fn().mockReturnValue([
11291
+ {
11292
+ plan_version_id: 'free-v1',
11293
+ billing_interval: 'month',
11294
+ stripe_price_id: 'free:free-v1:month',
11295
+ pricing_type: 'free',
11296
+ unit_amount: 0,
11297
+ currency: null
11298
+ }
11299
+ ])
11300
+ };
11301
+ }
11302
+ return {
11303
+ get: vi.fn().mockReturnValue({
11304
+ count: 0,
11305
+ max_updated_at: null
11306
+ })
11307
+ };
9566
11308
  })
9567
11309
  });
9568
11310
  core.listPlanCatalog.mockReturnValueOnce([
@@ -9582,6 +11324,7 @@ describe('Server Routes', () => {
9582
11324
  }
9583
11325
  }
9584
11326
  ]);
11327
+ core.listPlanFeatureCatalog = vi.fn().mockReturnValue([]);
9585
11328
  routes = createRoutes(core);
9586
11329
  const match = matchRoute('GET', '/api/taskforce/public/pricing', routes);
9587
11330
  const req = createMockReq('GET', '/api/taskforce/public/pricing', undefined, {
@@ -9593,7 +11336,15 @@ describe('Server Routes', () => {
9593
11336
  const payload = JSON.parse(String(res.end.mock.calls[0][0]));
9594
11337
  expect(payload.plans).toHaveLength(1);
9595
11338
  expect(payload.plans[0].features).toEqual([
9596
- { featureKey: 'ai.assistance', access: 'enabled', config: {} }
11339
+ {
11340
+ featureKey: 'ai.assistance',
11341
+ access: 'enabled',
11342
+ config: {},
11343
+ label: null,
11344
+ description: null,
11345
+ publicLabel: null,
11346
+ publicDescription: null
11347
+ }
9597
11348
  ]);
9598
11349
  }
9599
11350
  finally {
@@ -9625,6 +11376,7 @@ describe('Server Routes', () => {
9625
11376
  expect(core.upsertPlanCatalog).toHaveBeenCalledWith({
9626
11377
  planId: 'enterprise',
9627
11378
  displayName: 'Enterprise',
11379
+ description: undefined,
9628
11380
  status: 'active',
9629
11381
  initialTrialDays: 0,
9630
11382
  initialMode: undefined,
@@ -9644,6 +11396,42 @@ describe('Server Routes', () => {
9644
11396
  expect(core.listPlanCatalog).toHaveBeenCalled();
9645
11397
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9646
11398
  });
11399
+ it('GET /api/platform/onboarding-plan-options should return onboarding plan options for system admin', async () => {
11400
+ core.listOnboardingPlanOptions = vi.fn().mockReturnValue([
11401
+ {
11402
+ planId: 'free',
11403
+ planDisplayName: 'Free',
11404
+ defaultPlanVersionId: 'free-v1',
11405
+ defaultVersionNumber: 1,
11406
+ planStatus: 'active',
11407
+ supportsAutoGrantFree: true,
11408
+ supportsAutoStartTrial: false
11409
+ }
11410
+ ]);
11411
+ const match = matchRoute('GET', '/api/platform/onboarding-plan-options', routes);
11412
+ const req = createMockReq('GET', '/api/platform/onboarding-plan-options', undefined, {
11413
+ 'x-taskforce-runtime-mode': 'cloud',
11414
+ 'x-taskforce-system-role': 'system_admin'
11415
+ });
11416
+ const res = createMockRes();
11417
+ await match?.handler(req, res, {});
11418
+ expect(core.listOnboardingPlanOptions).toHaveBeenCalled();
11419
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11420
+ expect(JSON.parse(res.end.mock.calls[0]?.[0] || '{}')).toEqual({
11421
+ success: true,
11422
+ options: [
11423
+ {
11424
+ planId: 'free',
11425
+ planDisplayName: 'Free',
11426
+ defaultPlanVersionId: 'free-v1',
11427
+ defaultVersionNumber: 1,
11428
+ planStatus: 'active',
11429
+ supportsAutoGrantFree: true,
11430
+ supportsAutoStartTrial: false
11431
+ }
11432
+ ]
11433
+ });
11434
+ });
9647
11435
  it('GET /api/platform/plans/:planId/versions should list versions', async () => {
9648
11436
  const match = matchRoute('GET', '/api/platform/plans/collab/versions', routes);
9649
11437
  const req = createMockReq('GET', '/api/platform/plans/collab/versions', undefined, {
@@ -9702,6 +11490,37 @@ describe('Server Routes', () => {
9702
11490
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.team_management"'));
9703
11491
  expect(res.end).not.toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.invites"'));
9704
11492
  });
11493
+ it('PATCH /api/platform/features/:featureKey should update public marketing copy', async () => {
11494
+ core.updatePlanFeatureCatalogMarketingCopy = vi.fn().mockReturnValue({
11495
+ featureKey: 'collaboration.ai_profiles',
11496
+ label: 'AI Profiles',
11497
+ description: 'Allow registered AI collaborator profiles with an optional per-workspace limit.',
11498
+ publicLabel: 'AI teammates',
11499
+ publicDescription: 'Add reusable AI teammates to your workspace.',
11500
+ publicDisplayOrder: 20,
11501
+ allowedAccessModes: ['enabled', 'limited', 'disabled'],
11502
+ configTemplate: { maxAiProfiles: 1 }
11503
+ });
11504
+ const match = matchRoute('PATCH', '/api/platform/features/collaboration.ai_profiles', routes);
11505
+ const req = createMockReq('PATCH', '/api/platform/features/collaboration.ai_profiles', {
11506
+ publicLabel: 'AI teammates',
11507
+ publicDescription: 'Add reusable AI teammates to your workspace.',
11508
+ publicDisplayOrder: 20
11509
+ }, {
11510
+ 'x-taskforce-runtime-mode': 'cloud',
11511
+ 'x-taskforce-system-role': 'system_admin'
11512
+ });
11513
+ const res = createMockRes();
11514
+ await match?.handler(req, res, { featureKey: 'collaboration.ai_profiles' });
11515
+ expect(core.updatePlanFeatureCatalogMarketingCopy).toHaveBeenCalledWith({
11516
+ featureKey: 'collaboration.ai_profiles',
11517
+ publicLabel: 'AI teammates',
11518
+ publicDescription: 'Add reusable AI teammates to your workspace.',
11519
+ publicDisplayOrder: 20
11520
+ });
11521
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11522
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"publicLabel":"AI teammates"'));
11523
+ });
9705
11524
  it('PATCH /api/platform/plans/:planId/versions/:versionId/features should return normalized dependency state', async () => {
9706
11525
  core.updatePlanVersionFeatures.mockReturnValueOnce({
9707
11526
  planVersionId: 'collab-v1',
@@ -9831,13 +11650,20 @@ describe('Server Routes', () => {
9831
11650
  updatedAt: '2026-02-01T00:00:00.000Z'
9832
11651
  }
9833
11652
  ]);
11653
+ core.countSystemUsers.mockReturnValueOnce(1);
9834
11654
  const match = matchRoute('GET', '/api/taskforce/admin/system-users', routes);
9835
- const req = createMockReq('GET', '/api/taskforce/admin/system-users', undefined, { 'x-taskforce-system-role': 'system_admin' });
11655
+ const req = createMockReq('GET', '/api/taskforce/admin/system-users?q=u1&sort=oldest&limit=25&offset=50', undefined, { 'x-taskforce-system-role': 'system_admin' });
9836
11656
  const res = createMockRes();
9837
11657
  await match?.handler(req, res, {});
9838
- expect(core.listSystemUsers).toHaveBeenCalled();
11658
+ expect(core.countSystemUsers).toHaveBeenCalledWith({ query: 'u1' });
11659
+ expect(core.listSystemUsers).toHaveBeenCalledWith({ query: 'u1', sort: 'oldest', limit: 25, offset: 50 });
9839
11660
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9840
- expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"users"'));
11661
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
11662
+ expect(payload.users).toHaveLength(1);
11663
+ expect(payload.total).toBe(1);
11664
+ expect(payload.limit).toBe(25);
11665
+ expect(payload.offset).toBe(50);
11666
+ expect(payload.hasMore).toBe(false);
9841
11667
  });
9842
11668
  it('GET /api/taskforce/admin/system-users should allow authenticated system admin without role header', async () => {
9843
11669
  const authConfig = {
@@ -9863,7 +11689,8 @@ describe('Server Routes', () => {
9863
11689
  const res = createMockRes();
9864
11690
  await match?.handler(req, res, {});
9865
11691
  expect(core.getSystemRole).toHaveBeenCalledWith('admin-user');
9866
- expect(core.listSystemUsers).toHaveBeenCalled();
11692
+ expect(core.countSystemUsers).toHaveBeenCalledWith({ query: '' });
11693
+ expect(core.listSystemUsers).toHaveBeenCalledWith({ query: '', sort: 'newest', limit: 50, offset: 0 });
9867
11694
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9868
11695
  });
9869
11696
  it('GET /api/taskforce/admin/audit-logs should return events for admins', async () => {
@@ -10125,6 +11952,266 @@ describe('Server Routes', () => {
10125
11952
  expect(matchRoute('POST', '/api/taskforce/admin/users/invite', routes)).not.toBeNull();
10126
11953
  expect(matchRoute('PATCH', '/api/taskforce/admin/users/u1/role', routes)).not.toBeNull();
10127
11954
  expect(matchRoute('GET', '/api/taskforce/admin/workspace-audit-logs', routes)).not.toBeNull();
11955
+ expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes)).not.toBeNull();
11956
+ });
11957
+ it('POST /api/taskforce/admin/users/invite should authorize against the target workspace', async () => {
11958
+ const authConfig = createCloudAuthConfig();
11959
+ routes = createRoutes(core, { authConfig });
11960
+ core.listUserWorkspaces.mockReturnValue([
11961
+ { id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
11962
+ { id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
11963
+ ]);
11964
+ core.listWorkspaceUsers.mockImplementation((workspaceId) => {
11965
+ if (workspaceId === 'workspace-home') {
11966
+ return [{
11967
+ userId: 'actor-user',
11968
+ email: 'actor@example.com',
11969
+ displayName: 'Actor',
11970
+ role: 'owner',
11971
+ permissionMode: 'read-write',
11972
+ betaAccess: true,
11973
+ status: 'active',
11974
+ disabled: false
11975
+ }];
11976
+ }
11977
+ if (workspaceId === 'workspace-shared') {
11978
+ return [{
11979
+ userId: 'actor-user',
11980
+ email: 'actor@example.com',
11981
+ displayName: 'Actor',
11982
+ role: 'member',
11983
+ permissionMode: 'read-only',
11984
+ betaAccess: true,
11985
+ status: 'active',
11986
+ disabled: false
11987
+ }];
11988
+ }
11989
+ return [];
11990
+ });
11991
+ core.inviteWorkspaceUser.mockReturnValue({
11992
+ userId: 'invited-user',
11993
+ email: 'invited@example.com',
11994
+ role: 'member',
11995
+ status: 'invited',
11996
+ disabled: false
11997
+ });
11998
+ const cookie = createSessionTokenCookie('session-target-invite-deny', authConfig, {
11999
+ userId: 'actor-user',
12000
+ role: 'owner',
12001
+ workspaceId: 'workspace-home'
12002
+ });
12003
+ const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
12004
+ const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
12005
+ workspaceId: 'workspace-shared',
12006
+ email: 'invited@example.com',
12007
+ role: 'member'
12008
+ }, {
12009
+ cookie,
12010
+ 'x-taskforce-runtime-mode': 'cloud',
12011
+ 'x-taskforce-role': 'owner',
12012
+ 'x-taskforce-user-id': 'actor-user',
12013
+ 'x-taskforce-workspace-id': 'workspace-home'
12014
+ });
12015
+ const res = createMockRes();
12016
+ await match?.handler(req, res, {});
12017
+ expect(core.inviteWorkspaceUser).not.toHaveBeenCalled();
12018
+ expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
12019
+ expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
12020
+ });
12021
+ it('POST /api/taskforce/admin/users/invite should resolve feature access and inviter identity from the authenticated session user', async () => {
12022
+ const authConfig = createCloudAuthConfig();
12023
+ routes = createRoutes(core, { authConfig });
12024
+ core.listWorkspaceUsers.mockImplementation((workspaceId) => {
12025
+ if (workspaceId === 'workspace-shared') {
12026
+ return [{
12027
+ userId: 'joined-admin',
12028
+ email: 'joined-admin@example.com',
12029
+ displayName: 'Joined Admin',
12030
+ role: 'admin',
12031
+ permissionMode: 'read-write',
12032
+ betaAccess: true,
12033
+ status: 'active',
12034
+ disabled: false
12035
+ }];
12036
+ }
12037
+ return [];
12038
+ });
12039
+ core.evaluateFeatureAccess.mockImplementation(({ userId }) => ({
12040
+ allowed: userId === 'joined-admin',
12041
+ reasonCode: userId === 'joined-admin' ? undefined : 'ACCOUNT_ENTITLEMENT_REQUIRED'
12042
+ }));
12043
+ core.inviteWorkspaceUser.mockReturnValue({
12044
+ userId: 'invited-user',
12045
+ email: 'invited@example.com',
12046
+ role: 'member',
12047
+ status: 'invited',
12048
+ disabled: false
12049
+ });
12050
+ core.requestInviteAcceptance.mockReturnValue({ token: 'invite-token-1' });
12051
+ core.getWorkspaceProfile.mockReturnValue({
12052
+ id: 'workspace-shared',
12053
+ name: 'Shared Workspace'
12054
+ });
12055
+ core.resolveUserIdentity.mockReturnValue({
12056
+ userId: 'joined-admin',
12057
+ email: 'joined-admin@example.com',
12058
+ displayName: 'Joined Admin'
12059
+ });
12060
+ const cookie = createSessionTokenCookie('session-target-invite-identity', authConfig, {
12061
+ userId: 'joined-admin',
12062
+ role: 'admin',
12063
+ workspaceId: 'workspace-shared'
12064
+ });
12065
+ const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
12066
+ const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
12067
+ workspaceId: 'workspace-shared',
12068
+ email: 'invited@example.com',
12069
+ role: 'member'
12070
+ }, {
12071
+ cookie,
12072
+ 'x-taskforce-runtime-mode': 'cloud',
12073
+ 'x-taskforce-role': 'owner',
12074
+ 'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
12075
+ 'x-taskforce-workspace-id': 'workspace-auth-existing-home'
12076
+ });
12077
+ const res = createMockRes();
12078
+ await match?.handler(req, res, {});
12079
+ expect(core.evaluateFeatureAccess).toHaveBeenCalledWith(expect.objectContaining({
12080
+ userId: 'joined-admin',
12081
+ workspaceId: 'workspace-shared',
12082
+ featureKey: 'collaboration.invites'
12083
+ }));
12084
+ expect(core.resolveUserIdentity).toHaveBeenCalledWith('joined-admin');
12085
+ expect(core.inviteWorkspaceUser).toHaveBeenCalled();
12086
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
12087
+ });
12088
+ it('PATCH /api/taskforce/admin/users/:id/role should reject active-workspace admins who are only target-workspace members', async () => {
12089
+ const authConfig = createCloudAuthConfig();
12090
+ routes = createRoutes(core, { authConfig });
12091
+ core.listUserWorkspaces.mockReturnValue([
12092
+ { id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
12093
+ { id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
12094
+ ]);
12095
+ core.listWorkspaceUsers.mockImplementation((workspaceId) => {
12096
+ if (workspaceId === 'workspace-home') {
12097
+ return [{
12098
+ userId: 'actor-user',
12099
+ email: 'actor@example.com',
12100
+ displayName: 'Actor',
12101
+ role: 'owner',
12102
+ permissionMode: 'read-write',
12103
+ betaAccess: true,
12104
+ status: 'active',
12105
+ disabled: false
12106
+ }];
12107
+ }
12108
+ if (workspaceId === 'workspace-shared') {
12109
+ return [{
12110
+ userId: 'actor-user',
12111
+ email: 'actor@example.com',
12112
+ displayName: 'Actor',
12113
+ role: 'member',
12114
+ permissionMode: 'read-only',
12115
+ betaAccess: true,
12116
+ status: 'active',
12117
+ disabled: false
12118
+ }];
12119
+ }
12120
+ return [];
12121
+ });
12122
+ core.updateWorkspaceUserRole.mockReturnValue(true);
12123
+ const cookie = createSessionTokenCookie('session-target-role-deny', authConfig, {
12124
+ userId: 'actor-user',
12125
+ role: 'owner',
12126
+ workspaceId: 'workspace-home'
12127
+ });
12128
+ const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
12129
+ const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
12130
+ workspaceId: 'workspace-shared',
12131
+ role: 'admin'
12132
+ }, {
12133
+ cookie,
12134
+ 'x-taskforce-runtime-mode': 'cloud',
12135
+ 'x-taskforce-role': 'owner',
12136
+ 'x-taskforce-user-id': 'actor-user',
12137
+ 'x-taskforce-workspace-id': 'workspace-home'
12138
+ });
12139
+ const res = createMockRes();
12140
+ await match?.handler(req, res, { id: 'target-user' });
12141
+ expect(core.updateWorkspaceUserRole).not.toHaveBeenCalled();
12142
+ expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
12143
+ expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
12144
+ });
12145
+ it('PATCH /api/taskforce/admin/users/:id/role should allow target-workspace admins with a different active workspace', async () => {
12146
+ const authConfig = createCloudAuthConfig();
12147
+ routes = createRoutes(core, { authConfig });
12148
+ core.listUserWorkspaces.mockReturnValue([
12149
+ { id: 'workspace-home', slug: 'home', name: 'Home', role: 'member', status: 'active', betaAccess: true },
12150
+ { id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'admin', status: 'active', betaAccess: true }
12151
+ ]);
12152
+ core.listWorkspaceUsers.mockImplementation((workspaceId) => {
12153
+ if (workspaceId === 'workspace-home') {
12154
+ return [{
12155
+ userId: 'actor-user',
12156
+ email: 'actor@example.com',
12157
+ displayName: 'Actor',
12158
+ role: 'member',
12159
+ permissionMode: 'read-write',
12160
+ betaAccess: true,
12161
+ status: 'active',
12162
+ disabled: false
12163
+ }];
12164
+ }
12165
+ if (workspaceId === 'workspace-shared') {
12166
+ return [{
12167
+ userId: 'actor-user',
12168
+ email: 'actor@example.com',
12169
+ displayName: 'Actor',
12170
+ role: 'admin',
12171
+ permissionMode: 'read-write',
12172
+ betaAccess: true,
12173
+ status: 'active',
12174
+ disabled: false
12175
+ }];
12176
+ }
12177
+ return [];
12178
+ });
12179
+ core.updateWorkspaceUserRole.mockReturnValue(true);
12180
+ const cookie = createSessionTokenCookie('session-target-role-allow', authConfig, {
12181
+ userId: 'actor-user',
12182
+ role: 'member',
12183
+ workspaceId: 'workspace-home'
12184
+ });
12185
+ const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
12186
+ const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
12187
+ workspaceId: 'workspace-shared',
12188
+ role: 'admin'
12189
+ }, {
12190
+ cookie,
12191
+ 'x-taskforce-runtime-mode': 'cloud',
12192
+ 'x-taskforce-role': 'member',
12193
+ 'x-taskforce-user-id': 'actor-user',
12194
+ 'x-taskforce-workspace-id': 'workspace-home'
12195
+ });
12196
+ const res = createMockRes();
12197
+ await match?.handler(req, res, { id: 'target-user' });
12198
+ expect(core.evaluateFeatureAccess).toHaveBeenCalledWith({
12199
+ userId: 'actor-user',
12200
+ workspaceId: 'workspace-shared',
12201
+ featureKey: 'collaboration.team_management'
12202
+ });
12203
+ expect(core.updateWorkspaceUserRole).toHaveBeenCalledWith('target-user', 'workspace-shared', 'admin');
12204
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
12205
+ action: 'update-workspace-user-role',
12206
+ actorUserId: 'actor-user',
12207
+ actorRole: 'admin',
12208
+ workspaceId: 'workspace-shared',
12209
+ details: expect.objectContaining({
12210
+ workspaceId: 'workspace-shared',
12211
+ targetUserId: 'target-user'
12212
+ })
12213
+ }));
12214
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10128
12215
  });
10129
12216
  it('GET /api/taskforce/workspace/assignee-options should reject unauthenticated cloud requests', async () => {
10130
12217
  const authConfig = {
@@ -10219,6 +12306,7 @@ describe('Server Routes', () => {
10219
12306
  userId: 'member-1',
10220
12307
  email: 'member1@example.com',
10221
12308
  displayName: 'Member One',
12309
+ avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
10222
12310
  role: 'member',
10223
12311
  permissionMode: 'read-write',
10224
12312
  betaAccess: true,
@@ -10240,11 +12328,14 @@ describe('Server Routes', () => {
10240
12328
  provider: 'OpenAI',
10241
12329
  model: 'gpt-5.4',
10242
12330
  surfaceType: 'coding_tool',
12331
+ seatScope: null,
12332
+ archivedAt: null,
10243
12333
  createdAt: '2026-03-01T00:00:00.000Z',
10244
12334
  updatedAt: '2026-03-01T00:00:00.000Z',
10245
12335
  lastActiveAt: null
10246
12336
  }
10247
12337
  ]);
12338
+ core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
10248
12339
  const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
10249
12340
  const cookie = createSessionTokenCookie('session-member-assignees', authConfig, {
10250
12341
  userId: 'member-user',
@@ -10261,9 +12352,14 @@ describe('Server Routes', () => {
10261
12352
  const res = createMockRes();
10262
12353
  await match?.handler(req, res, {});
10263
12354
  expect(core.listWorkspaceUsers).toHaveBeenCalledWith('workspace-1');
10264
- expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1');
12355
+ expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
12356
+ diagnostics: expect.any(Object)
12357
+ }));
12358
+ expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
12359
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('assignee-options'));
10265
12360
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10266
12361
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12362
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
10267
12363
  expect(payload.assignees).toEqual([
10268
12364
  {
10269
12365
  value: 'member-1',
@@ -10274,6 +12370,7 @@ describe('Server Routes', () => {
10274
12370
  userId: 'member-1',
10275
12371
  email: 'member1@example.com',
10276
12372
  displayName: 'Member One',
12373
+ avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
10277
12374
  status: 'active',
10278
12375
  disabled: false
10279
12376
  },
@@ -10283,12 +12380,16 @@ describe('Server Routes', () => {
10283
12380
  username: 'codex',
10284
12381
  icon: 'Bot',
10285
12382
  color: '#ef6c00',
12383
+ avatarUrl: null,
12384
+ avatarSourceUrl: null,
10286
12385
  kind: 'agent',
10287
12386
  description: 'Builds product code',
10288
12387
  role: 'Implementer',
10289
12388
  provider: 'OpenAI',
10290
12389
  model: 'gpt-5.4',
10291
12390
  surfaceType: 'coding_tool',
12391
+ seatScope: null,
12392
+ archivedAt: null,
10292
12393
  createdAt: '2026-03-01T00:00:00.000Z',
10293
12394
  updatedAt: '2026-03-01T00:00:00.000Z',
10294
12395
  lastActiveAt: null
@@ -10328,6 +12429,7 @@ describe('Server Routes', () => {
10328
12429
  lastActiveAt: null
10329
12430
  }
10330
12431
  ]);
12432
+ core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
10331
12433
  const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
10332
12434
  const cookie = createSessionTokenCookie('session-admin-assignees', authConfig, {
10333
12435
  userId: 'admin-user',
@@ -10344,6 +12446,7 @@ describe('Server Routes', () => {
10344
12446
  const res = createMockRes();
10345
12447
  await match?.handler(req, res, {});
10346
12448
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12449
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
10347
12450
  expect(payload.assignees).toEqual([
10348
12451
  expect.objectContaining({
10349
12452
  value: 'ai-profile-1',
@@ -10351,6 +12454,198 @@ describe('Server Routes', () => {
10351
12454
  })
10352
12455
  ]);
10353
12456
  });
12457
+ it('POST /api/taskforce/workspace/ai-profiles/archive should archive active profiles for workspace admins', async () => {
12458
+ const authConfig = {
12459
+ runtimeMode: 'cloud',
12460
+ enabled: true,
12461
+ requiredForApi: true,
12462
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
12463
+ defaultRole: 'member',
12464
+ defaultWorkspaceId: 'default',
12465
+ sessionCookieName: 'taskforce_session',
12466
+ sessionSecret: 'test-secret',
12467
+ sessionTtlSeconds: 3600
12468
+ };
12469
+ routes = createRoutes(core, { authConfig });
12470
+ core.archiveAiProfile.mockReturnValue({
12471
+ id: 'ai-profile-1',
12472
+ workspaceId: 'workspace-1',
12473
+ profileToken: 'tfp_codex_sync',
12474
+ name: 'Codex',
12475
+ username: 'codex',
12476
+ icon: 'Bot',
12477
+ color: '#ef6c00',
12478
+ description: 'Builds product code',
12479
+ role: 'Implementer',
12480
+ provider: 'OpenAI',
12481
+ model: 'gpt-5.4',
12482
+ surfaceType: 'coding_tool',
12483
+ archivedAt: '2026-04-19T17:00:00.000Z',
12484
+ archivedBy: 'admin-user',
12485
+ archivedReason: 'manual_archive',
12486
+ mergedIntoProfileId: null,
12487
+ createdAt: '2026-03-01T00:00:00.000Z',
12488
+ updatedAt: '2026-04-19T17:00:00.000Z',
12489
+ lastActiveAt: null
12490
+ });
12491
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
12492
+ const cookie = createSessionTokenCookie('session-admin-archive-ai-profile', authConfig, {
12493
+ userId: 'admin-user',
12494
+ role: 'admin',
12495
+ workspaceId: 'workspace-1'
12496
+ });
12497
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
12498
+ profileId: 'ai-profile-1',
12499
+ reason: 'manual_archive'
12500
+ }, {
12501
+ cookie,
12502
+ 'x-taskforce-runtime-mode': 'cloud',
12503
+ 'x-taskforce-role': 'admin',
12504
+ 'x-taskforce-user-id': 'admin-user',
12505
+ 'x-taskforce-workspace-id': 'workspace-1'
12506
+ });
12507
+ const res = createMockRes();
12508
+ await match?.handler(req, res, {});
12509
+ expect(core.archiveAiProfile).toHaveBeenCalledWith({
12510
+ workspaceId: 'workspace-1',
12511
+ profileId: 'ai-profile-1',
12512
+ archivedBy: 'admin-user',
12513
+ reason: 'manual_archive'
12514
+ });
12515
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
12516
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
12517
+ expect(payload.profile).toEqual(expect.objectContaining({
12518
+ id: 'ai-profile-1',
12519
+ archivedReason: 'manual_archive'
12520
+ }));
12521
+ });
12522
+ it('POST /api/taskforce/workspace/ai-profiles/archive should honor actual owner membership even when the request role is stale', async () => {
12523
+ const authConfig = {
12524
+ runtimeMode: 'cloud',
12525
+ enabled: true,
12526
+ requiredForApi: true,
12527
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
12528
+ defaultRole: 'member',
12529
+ defaultWorkspaceId: 'default',
12530
+ sessionCookieName: 'taskforce_session',
12531
+ sessionSecret: 'test-secret',
12532
+ sessionTtlSeconds: 3600
12533
+ };
12534
+ routes = createRoutes(core, { authConfig });
12535
+ core.listUserWorkspaces.mockReturnValue([
12536
+ {
12537
+ id: 'workspace-1',
12538
+ slug: 'workspace-1',
12539
+ name: 'Workspace 1',
12540
+ description: null,
12541
+ role: 'owner',
12542
+ status: 'active',
12543
+ betaAccess: true
12544
+ }
12545
+ ]);
12546
+ core.listWorkspaceUsers.mockReturnValue([
12547
+ {
12548
+ userId: 'owner-user',
12549
+ email: 'owner@example.com',
12550
+ displayName: 'Owner User',
12551
+ role: 'owner',
12552
+ permissionMode: 'read-write',
12553
+ betaAccess: true,
12554
+ status: 'active',
12555
+ disabled: false
12556
+ }
12557
+ ]);
12558
+ core.archiveAiProfile.mockReturnValue({
12559
+ id: 'ai-profile-1',
12560
+ workspaceId: 'workspace-1',
12561
+ profileToken: 'tfp_codex_sync',
12562
+ name: 'Codex',
12563
+ username: 'codex',
12564
+ icon: 'Bot',
12565
+ color: '#ef6c00',
12566
+ description: 'Builds product code',
12567
+ role: 'Implementer',
12568
+ provider: 'OpenAI',
12569
+ model: 'gpt-5.4',
12570
+ surfaceType: 'coding_tool',
12571
+ archivedAt: '2026-04-19T17:00:00.000Z',
12572
+ archivedBy: 'owner-user',
12573
+ archivedReason: 'manual_archive',
12574
+ mergedIntoProfileId: null,
12575
+ createdAt: '2026-03-01T00:00:00.000Z',
12576
+ updatedAt: '2026-04-19T17:00:00.000Z',
12577
+ lastActiveAt: null
12578
+ });
12579
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
12580
+ const cookie = createSessionTokenCookie('session-owner-archive', authConfig, {
12581
+ userId: 'owner-user',
12582
+ role: 'member',
12583
+ workspaceId: 'workspace-1'
12584
+ });
12585
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
12586
+ profileId: 'ai-profile-1'
12587
+ }, {
12588
+ cookie,
12589
+ 'x-taskforce-runtime-mode': 'cloud',
12590
+ 'x-taskforce-role': 'member',
12591
+ 'x-taskforce-user-id': 'owner-user',
12592
+ 'x-taskforce-workspace-id': 'workspace-1'
12593
+ });
12594
+ const res = createMockRes();
12595
+ await match?.handler(req, res, {});
12596
+ expect(core.archiveAiProfile).toHaveBeenCalledWith(expect.objectContaining({
12597
+ workspaceId: 'workspace-1',
12598
+ profileId: 'ai-profile-1',
12599
+ archivedBy: 'owner-user'
12600
+ }));
12601
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
12602
+ });
12603
+ it('POST /api/taskforce/workspace/ai-profiles/archive should surface open-task conflicts', async () => {
12604
+ const authConfig = {
12605
+ runtimeMode: 'cloud',
12606
+ enabled: true,
12607
+ requiredForApi: true,
12608
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
12609
+ defaultRole: 'member',
12610
+ defaultWorkspaceId: 'default',
12611
+ sessionCookieName: 'taskforce_session',
12612
+ sessionSecret: 'test-secret',
12613
+ sessionTtlSeconds: 3600
12614
+ };
12615
+ routes = createRoutes(core, { authConfig });
12616
+ const error = new Error('This AI profile still owns open tasks. Reassign or close them before removing it from the roster.');
12617
+ error.statusCode = 409;
12618
+ error.details = { workspaceId: 'workspace-1', profileId: 'ai-profile-1', openTaskCount: 2 };
12619
+ core.archiveAiProfile.mockImplementation(() => {
12620
+ throw error;
12621
+ });
12622
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
12623
+ const cookie = createSessionTokenCookie('session-admin-archive-ai-profile-conflict', authConfig, {
12624
+ userId: 'admin-user',
12625
+ role: 'admin',
12626
+ workspaceId: 'workspace-1'
12627
+ });
12628
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
12629
+ profileId: 'ai-profile-1',
12630
+ reason: 'manual_archive'
12631
+ }, {
12632
+ cookie,
12633
+ 'x-taskforce-runtime-mode': 'cloud',
12634
+ 'x-taskforce-role': 'admin',
12635
+ 'x-taskforce-user-id': 'admin-user',
12636
+ 'x-taskforce-workspace-id': 'workspace-1'
12637
+ });
12638
+ const res = createMockRes();
12639
+ await match?.handler(req, res, {});
12640
+ expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
12641
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
12642
+ expect(payload.error).toContain('This AI profile still owns open tasks');
12643
+ expect(payload.details).toEqual({
12644
+ workspaceId: 'workspace-1',
12645
+ profileId: 'ai-profile-1',
12646
+ openTaskCount: 2
12647
+ });
12648
+ });
10354
12649
  it('GET /api/taskforce/admin/workspace-audit-logs should return events for workspace admins', async () => {
10355
12650
  const authConfig = {
10356
12651
  runtimeMode: 'cloud',
@@ -10448,13 +12743,13 @@ describe('Server Routes', () => {
10448
12743
  billing: {
10449
12744
  onboardingPolicy: {
10450
12745
  mode: 'auto_grant_free',
10451
- planVersionId: 'starter-v1'
12746
+ planId: 'starter'
10452
12747
  }
10453
12748
  }
10454
12749
  });
10455
12750
  core.getOnboardingPolicy.mockReturnValue({
10456
12751
  mode: 'auto_grant_free',
10457
- planVersionId: 'starter-v1'
12752
+ planId: 'starter'
10458
12753
  });
10459
12754
  const match = matchRoute('GET', '/api/taskforce/admin/system-settings', routes);
10460
12755
  const req = createMockReq('GET', '/api/taskforce/admin/system-settings', undefined, { 'x-taskforce-role': 'admin' });
@@ -10462,9 +12757,9 @@ describe('Server Routes', () => {
10462
12757
  await match?.handler(req, res, {});
10463
12758
  const payload = JSON.parse(res.end.mock.calls[0][0]);
10464
12759
  expect(payload.status.onboardingPolicyMode).toBe('auto_grant_free');
10465
- expect(payload.status.onboardingPlanVersionId).toBe('starter-v1');
12760
+ expect(payload.status.onboardingPlanId).toBe('starter');
10466
12761
  expect(payload.settings.onboardingPolicyMode).toBe('auto_grant_free');
10467
- expect(payload.settings.onboardingPlanVersionId).toBe('starter-v1');
12762
+ expect(payload.settings.onboardingPlanId).toBe('starter');
10468
12763
  });
10469
12764
  it('POST /api/taskforce/admin/system-settings should persist onboarding policy fields', async () => {
10470
12765
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
@@ -10476,13 +12771,13 @@ describe('Server Routes', () => {
10476
12771
  authRateLimitMaxRequests: 10,
10477
12772
  authRateLimitWindowMs: 900000,
10478
12773
  onboardingPolicyMode: 'auto_grant_free',
10479
- onboardingPlanVersionId: 'starter-v1'
12774
+ onboardingPlanId: 'starter'
10480
12775
  }, { 'x-taskforce-role': 'admin' });
10481
12776
  const res = createMockRes();
10482
12777
  await match?.handler(req, res, {});
10483
12778
  expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
10484
12779
  mode: 'auto_grant_free',
10485
- planVersionId: 'starter-v1'
12780
+ planId: 'starter'
10486
12781
  });
10487
12782
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
10488
12783
  auth: {
@@ -10496,7 +12791,7 @@ describe('Server Routes', () => {
10496
12791
  billing: {
10497
12792
  onboardingPolicy: {
10498
12793
  mode: 'auto_grant_free',
10499
- planVersionId: 'starter-v1'
12794
+ planId: 'starter'
10500
12795
  }
10501
12796
  }
10502
12797
  });
@@ -10504,7 +12799,7 @@ describe('Server Routes', () => {
10504
12799
  it('POST /api/taskforce/admin/system-settings should preserve onboarding policy when onboarding fields are omitted', async () => {
10505
12800
  core.getOnboardingPolicy.mockReturnValue({
10506
12801
  mode: 'auto_grant_free',
10507
- planVersionId: 'starter-v1'
12802
+ planId: 'starter'
10508
12803
  });
10509
12804
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
10510
12805
  const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
@@ -10519,7 +12814,7 @@ describe('Server Routes', () => {
10519
12814
  await match?.handler(req, res, {});
10520
12815
  expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
10521
12816
  mode: 'auto_grant_free',
10522
- planVersionId: 'starter-v1'
12817
+ planId: 'starter'
10523
12818
  });
10524
12819
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
10525
12820
  auth: {
@@ -10533,7 +12828,7 @@ describe('Server Routes', () => {
10533
12828
  billing: {
10534
12829
  onboardingPolicy: {
10535
12830
  mode: 'auto_grant_free',
10536
- planVersionId: 'starter-v1'
12831
+ planId: 'starter'
10537
12832
  }
10538
12833
  }
10539
12834
  });
@@ -10646,13 +12941,13 @@ describe('Server Routes', () => {
10646
12941
  it('POST /api/taskforce/admin/system-settings should reject invalid onboarding settings', async () => {
10647
12942
  core.validateOnboardingPolicy.mockReturnValueOnce({
10648
12943
  valid: false,
10649
- code: 'ONBOARDING_PLAN_VERSION_REQUIRED',
10650
- message: 'An onboarding plan version is required for the selected onboarding mode.'
12944
+ code: 'ONBOARDING_PLAN_REQUIRED',
12945
+ message: 'An onboarding plan is required for the selected onboarding mode.'
10651
12946
  });
10652
12947
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
10653
12948
  const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
10654
12949
  onboardingPolicyMode: 'auto_grant_free',
10655
- onboardingPlanVersionId: null
12950
+ onboardingPlanId: null
10656
12951
  }, { 'x-taskforce-role': 'admin' });
10657
12952
  const res = createMockRes();
10658
12953
  await match?.handler(req, res, {});