@taskforcehq/taskforce 0.3.305 → 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 (337) hide show
  1. package/dist/Taskforce.module.css +295 -89
  2. package/dist/TaskforceCore.js +215 -43
  3. package/dist/TaskforceCore.test.js +433 -28
  4. package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
  5. package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  6. package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
  7. package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
  8. package/dist/assets/fonts/Noir_medium.woff2 +0 -0
  9. package/dist/assets/fonts/Noir_regular.woff2 +0 -0
  10. package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
  11. package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
  12. package/dist/compat/workspaceSyncCompat.js +46 -2
  13. package/dist/compat/workspaceSyncCompat.test.js +38 -1
  14. package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
  15. package/dist/components/context/ContextAttachmentManager.js +549 -0
  16. package/dist/components/features/AgentsModule.d.ts +10 -1
  17. package/dist/components/features/AgentsModule.js +260 -15
  18. package/dist/components/features/AgentsModule.test.js +255 -5
  19. package/dist/components/features/DocumentViewer.d.ts +5 -1
  20. package/dist/components/features/DocumentViewer.js +60 -3
  21. package/dist/components/features/DocumentWorkspace.js +1 -1
  22. package/dist/components/features/DocumentWorkspace.test.js +33 -0
  23. package/dist/components/features/documentWorkspaceModel.d.ts +1 -1
  24. package/dist/components/task/TaskCard.js +19 -28
  25. package/dist/components/task/TaskCard.test.js +38 -3
  26. package/dist/components/task/TaskContextUpload.js +4 -526
  27. package/dist/components/task/TaskForm.js +85 -90
  28. package/dist/components/task/TaskForm.test.js +110 -8
  29. package/dist/components/task/TaskKanban.d.ts +2 -1
  30. package/dist/components/task/TaskKanban.js +94 -13
  31. package/dist/components/task/TaskKanban.test.js +36 -0
  32. package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
  33. package/dist/components/ui/EditableAvatarButton.js +18 -0
  34. package/dist/components/views/PlanComparisonPage.d.ts +9 -1
  35. package/dist/components/views/PlanComparisonPage.js +70 -14
  36. package/dist/components/views/PlanComparisonPage.test.js +238 -0
  37. package/dist/components/views/PlansPage.js +127 -36
  38. package/dist/components/views/PlansPage.test.js +294 -12
  39. package/dist/components/views/StandaloneLayout.js +283 -533
  40. package/dist/components/views/panels/PlanningDrawer.d.ts +8 -1
  41. package/dist/components/views/panels/PlanningDrawer.js +18 -11
  42. package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
  43. package/dist/components/views/panels/PlanningDrawer.test.js +141 -0
  44. package/dist/components/views/planningScope.d.ts +20 -0
  45. package/dist/components/views/planningScope.js +25 -0
  46. package/dist/components/views/planningScope.test.d.ts +1 -0
  47. package/dist/components/views/planningScope.test.js +60 -0
  48. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +26 -1
  49. package/dist/components/views/standalone/modals/AccountHubModal.js +76 -2
  50. package/dist/components/views/standalone/modals/AccountHubModal.test.js +20 -3
  51. package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
  52. package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
  53. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
  54. package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
  55. package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
  56. package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
  57. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
  58. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
  59. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
  60. package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
  61. package/dist/core/AiProfileService.d.ts +39 -2
  62. package/dist/core/AiProfileService.js +639 -73
  63. package/dist/core/AiProfileService.test.js +19 -1
  64. package/dist/core/AiProfiles.test.js +566 -7
  65. package/dist/core/AttachmentLinkService.js +57 -15
  66. package/dist/core/AuthIdentityService.d.ts +107 -0
  67. package/dist/core/AuthIdentityService.js +284 -0
  68. package/dist/core/AuthTokenService.d.ts +4 -0
  69. package/dist/core/AuthTokenService.js +34 -6
  70. package/dist/core/AuthTokenService.test.js +3 -3
  71. package/dist/core/EntitlementsPolicy.test.js +2 -2
  72. package/dist/core/GlobalSettingsService.js +115 -6
  73. package/dist/core/PlanEntitlementService.d.ts +23 -2
  74. package/dist/core/PlanEntitlementService.js +343 -57
  75. package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
  76. package/dist/core/PlanFeatureCatalog.test.js +84 -0
  77. package/dist/core/PlanVersionPolicy.test.js +35 -2
  78. package/dist/core/PlanningEntities.test.js +52 -0
  79. package/dist/core/SignupMonetizationSettings.test.js +199 -27
  80. package/dist/core/SyncReconciliationService.js +2 -65
  81. package/dist/core/SystemAdmin.test.js +42 -42
  82. package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
  83. package/dist/core/Taskforce.d.ts +225 -14
  84. package/dist/core/Taskforce.js +765 -192
  85. package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
  86. package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
  87. package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
  88. package/dist/core/WorkspaceLifecycleService.js +0 -5
  89. package/dist/core/WorkspacePermissions.test.js +25 -1
  90. package/dist/core/WorkspacePlanMode.test.js +117 -16
  91. package/dist/core/shared.d.ts +1 -1
  92. package/dist/core/shared.js +5 -1
  93. package/dist/core/types.d.ts +68 -2
  94. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +26 -1
  95. package/dist/hooks/sync/bootstrap.js +2 -0
  96. package/dist/hooks/sync/controlPlane.d.ts +37 -0
  97. package/dist/hooks/sync/controlPlane.js +78 -0
  98. package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
  99. package/dist/hooks/sync/controlPlane.test.js +121 -0
  100. package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
  101. package/dist/hooks/sync/lifecyclePolicy.js +93 -0
  102. package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
  103. package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
  104. package/dist/hooks/sync/orchestratorShared.d.ts +10 -1
  105. package/dist/hooks/sync/orchestratorShared.js +68 -47
  106. package/dist/hooks/sync/orchestratorShared.test.js +50 -1
  107. package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
  108. package/dist/hooks/sync/pullLifecycle.js +24 -0
  109. package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
  110. package/dist/hooks/sync/pullLifecycle.test.js +80 -0
  111. package/dist/hooks/sync/recovery.d.ts +16 -2
  112. package/dist/hooks/sync/recovery.js +171 -53
  113. package/dist/hooks/sync/recovery.test.d.ts +1 -0
  114. package/dist/hooks/sync/recovery.test.js +292 -0
  115. package/dist/hooks/sync/transfers.d.ts +15 -3
  116. package/dist/hooks/sync/transfers.js +179 -62
  117. package/dist/hooks/sync/transfers.test.d.ts +1 -0
  118. package/dist/hooks/sync/transfers.test.js +396 -0
  119. package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
  120. package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
  121. package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
  122. package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
  123. package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
  124. package/dist/hooks/sync/useSyncStatusActions.js +88 -0
  125. package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
  126. package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
  127. package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
  128. package/dist/hooks/sync/useSyncStatusControls.js +60 -0
  129. package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
  130. package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
  131. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
  132. package/dist/hooks/useSyncOrchestrator.d.ts +9 -2
  133. package/dist/hooks/useSyncOrchestrator.js +359 -178
  134. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +407 -0
  135. package/dist/hooks/useTaskData.js +8 -3
  136. package/dist/hooks/useTaskData.test.js +45 -0
  137. package/dist/hooks/useTaskforce.d.ts +60 -2
  138. package/dist/hooks/useTaskforce.js +549 -240
  139. package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
  140. package/dist/hooks/useTaskforce.runtime-routing.test.js +152 -0
  141. package/dist/hooks/useTaskforce.sync-behavior.test.js +2380 -212
  142. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  143. package/dist/hooks/useWorkspaceSyncController.js +6 -1
  144. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
  145. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +316 -35
  146. package/dist/localization/locales/en-US.d.ts +0 -1
  147. package/dist/localization/locales/en-US.js +0 -1
  148. package/dist/localization/locales/es-419.js +0 -1
  149. package/dist/localization/locales/pt-BR.js +0 -1
  150. package/dist/mcp/documentAssetRegistrar.js +3 -3
  151. package/dist/mcp/runtime.d.ts +2 -0
  152. package/dist/mcp/runtime.js +131 -64
  153. package/dist/mcp/runtime.test.js +291 -12
  154. package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
  155. package/dist/migrations/taskSchemaMigrations.js +171 -61
  156. package/dist/migrations/taskSchemaMigrations.test.js +15 -0
  157. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  158. package/dist/resources/templates/workflows/collaborate.yaml +1 -1
  159. package/dist/resources/templates/workflows/evaluate.yaml +2 -2
  160. package/dist/resources/templates/workflows/plan.yaml +1 -1
  161. package/dist/resources/templates/workflows/review.yaml +2 -2
  162. package/dist/server/annotatedAttachmentsRoutes.test.js +3 -3
  163. package/dist/server/auth/providers/apple.d.ts +33 -0
  164. package/dist/server/auth/providers/apple.js +82 -0
  165. package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
  166. package/dist/server/auth/providers/appleClientSecret.js +65 -0
  167. package/dist/server/auth/providers/github.d.ts +26 -0
  168. package/dist/server/auth/providers/github.js +86 -0
  169. package/dist/server/auth/providers/google.d.ts +21 -0
  170. package/dist/server/auth/providers/google.js +56 -0
  171. package/dist/server/auth/providers/types.d.ts +21 -0
  172. package/dist/server/auth/providers/types.js +1 -0
  173. package/dist/server/auth.d.ts +2 -0
  174. package/dist/server/auth.js +6 -3
  175. package/dist/server/documentReviewRoutes.test.js +36 -3
  176. package/dist/server/index.cookieProxy.test.js +1 -0
  177. package/dist/server/index.d.ts +12 -0
  178. package/dist/server/index.js +120 -9
  179. package/dist/server/index.rateLimit.test.js +3 -0
  180. package/dist/server/index.test.js +106 -3
  181. package/dist/server/routes/admin.d.ts +3 -0
  182. package/dist/server/routes/admin.js +272 -19
  183. package/dist/server/routes/auth.d.ts +15 -2
  184. package/dist/server/routes/auth.js +994 -57
  185. package/dist/server/routes/authSupport.d.ts +1 -1
  186. package/dist/server/routes/authSupport.js +1 -1
  187. package/dist/server/routes/billing.js +361 -35
  188. package/dist/server/routes/billing.test.js +592 -12
  189. package/dist/server/routes/documents.d.ts +1 -0
  190. package/dist/server/routes/documents.js +172 -70
  191. package/dist/server/routes/shared.d.ts +28 -2
  192. package/dist/server/routes/shared.js +69 -4
  193. package/dist/server/routes/sync.integration.test.js +437 -39
  194. package/dist/server/routes/sync.js +61 -605
  195. package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
  196. package/dist/server/routes/syncAuxRoutes.js +91 -0
  197. package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
  198. package/dist/server/routes/syncAuxRoutes.test.js +158 -0
  199. package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
  200. package/dist/server/routes/syncPullApplyRoutes.js +204 -0
  201. package/dist/server/routes/syncPushRoutes.d.ts +24 -0
  202. package/dist/server/routes/syncPushRoutes.js +212 -0
  203. package/dist/server/routes/syncRouteGuards.d.ts +36 -0
  204. package/dist/server/routes/syncRouteGuards.js +67 -0
  205. package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
  206. package/dist/server/routes/syncRouteGuards.test.js +94 -0
  207. package/dist/server/routes/syncRouteTypes.d.ts +13 -0
  208. package/dist/server/routes/syncRouteTypes.js +1 -0
  209. package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
  210. package/dist/server/routes/syncSnapshotRoutes.js +180 -0
  211. package/dist/server/routes/tasks.js +43 -1
  212. package/dist/server/routes/workspaces.js +36 -5
  213. package/dist/server/routes.js +60 -3
  214. package/dist/server/routes.test.js +1824 -171
  215. package/dist/services/workflowExportSnapshots.test.js +2 -0
  216. package/dist/shared/aiProfileSeatScope.d.ts +5 -0
  217. package/dist/shared/aiProfileSeatScope.js +21 -0
  218. package/dist/shared/passwordPolicy.d.ts +13 -0
  219. package/dist/shared/passwordPolicy.js +84 -0
  220. package/dist/shared/passwordPolicy.test.d.ts +1 -0
  221. package/dist/shared/passwordPolicy.test.js +34 -0
  222. package/dist/storage/workspaceAssetStore.d.ts +15 -3
  223. package/dist/storage/workspaceAssetStore.js +132 -44
  224. package/dist/storage/workspaceAssetStore.test.js +93 -6
  225. package/dist/styles/fonts.css +70 -0
  226. package/dist/sync/collaborationSyncPayload.d.ts +6 -1
  227. package/dist/sync/collaborationSyncPayload.js +17 -11
  228. package/dist/sync/collaborationSyncPayload.test.js +13 -1
  229. package/dist/sync/contentSyncState.d.ts +4 -8
  230. package/dist/sync/contentSyncState.js +17 -70
  231. package/dist/sync/syncApplyHandlers.d.ts +7 -0
  232. package/dist/sync/syncApplyHandlers.js +28 -13
  233. package/dist/sync/syncApplyHandlers.test.js +70 -3
  234. package/dist/sync/syncFieldSemantics.d.ts +12 -0
  235. package/dist/sync/syncFieldSemantics.js +55 -0
  236. package/dist/sync/syncResourceAdapters.d.ts +44 -0
  237. package/dist/sync/syncResourceAdapters.js +77 -0
  238. package/dist/sync/syncService.d.ts +5 -0
  239. package/dist/sync/syncService.js +52 -0
  240. package/dist/sync/syncService.test.js +57 -3
  241. package/dist/sync/taskSyncChangeKey.d.ts +2 -0
  242. package/dist/sync/taskSyncChangeKey.js +143 -0
  243. package/dist/sync/taskSyncPayload.js +3 -8
  244. package/dist/sync/workspacePullFeed.js +4 -3
  245. package/dist/sync/workspacePullFeed.test.js +43 -0
  246. package/dist/sync/workspaceRepair.js +5 -2
  247. package/dist/sync/workspaceSyncModel.d.ts +45 -0
  248. package/dist/sync/workspaceSyncModel.js +183 -28
  249. package/dist/sync/workspaceSyncModel.test.js +291 -12
  250. package/dist/sync/workspaceSyncState.d.ts +17 -0
  251. package/dist/sync/workspaceSyncState.js +2 -1
  252. package/dist/sync/workspaceSyncSurface.js +3 -8
  253. package/dist/types.d.ts +17 -0
  254. package/dist/ui/assets/AgentsModule-DrLawwNl.js +1 -0
  255. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BG6P_GVW.js → AnnotatedAttachmentWorkspace-Z9_whf7F.js} +1 -1
  256. package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
  257. package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
  258. package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
  259. package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
  260. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +252 -0
  261. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +1 -0
  262. package/dist/ui/assets/{InitiativesModule-Dhm7M8e7.js → InitiativesModule-9E6ParrY.js} +1 -1
  263. package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
  264. package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
  265. package/dist/ui/assets/PlansPage-CvfnMiWq.css +1 -0
  266. package/dist/ui/assets/PlansPage-DRXscYxH.js +1 -0
  267. package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
  268. package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
  269. package/dist/ui/assets/{TaskSettings-ln0aF7xc.js → TaskSettings-DZX4jk7e.js} +1 -1
  270. package/dist/ui/assets/{WorkflowsModule-BcS4afRn.js → WorkflowsModule-BA7jcEpH.js} +1 -1
  271. package/dist/ui/assets/index-9eT8e0Lu.css +1 -0
  272. package/dist/ui/assets/index-C6k75jr8.js +6 -0
  273. package/dist/ui/assets/{vendor-icons-BSUaRwF8.js → vendor-icons-DuEd_65c.js} +1 -1
  274. package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
  275. package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
  276. package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
  277. package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  278. package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
  279. package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
  280. package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
  281. package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
  282. package/dist/ui/fonts/Noir_medium.otf +0 -0
  283. package/dist/ui/fonts/Noir_medium.woff2 +0 -0
  284. package/dist/ui/fonts/Noir_regular.otf +0 -0
  285. package/dist/ui/fonts/Noir_regular.woff2 +0 -0
  286. package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
  287. package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
  288. package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
  289. package/dist/ui/index.html +3 -3
  290. package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
  291. package/dist/utils/assignees.d.ts +2 -0
  292. package/dist/utils/assignees.js +2 -0
  293. package/dist/utils/avatarUpload.d.ts +27 -0
  294. package/dist/utils/avatarUpload.js +132 -0
  295. package/dist/utils/avatarUpload.test.d.ts +1 -0
  296. package/dist/utils/avatarUpload.test.js +40 -0
  297. package/dist/utils/bootstrapTrace.d.ts +7 -0
  298. package/dist/utils/bootstrapTrace.js +29 -0
  299. package/dist/utils/commercialLifecyclePresentation.js +3 -0
  300. package/dist/utils/contextAssetEvents.d.ts +2 -0
  301. package/dist/utils/syncEventDetails.d.ts +3 -0
  302. package/dist/utils/syncEventDetails.js +64 -0
  303. package/dist/utils/syncEventDetails.test.d.ts +1 -0
  304. package/dist/utils/syncEventDetails.test.js +23 -0
  305. package/dist/utils/syncEventPresentation.d.ts +15 -0
  306. package/dist/utils/syncEventPresentation.js +129 -0
  307. package/dist/utils/syncEventPresentation.test.d.ts +1 -0
  308. package/dist/utils/syncEventPresentation.test.js +64 -0
  309. package/dist/utils/syncStatusPresentation.d.ts +72 -0
  310. package/dist/utils/syncStatusPresentation.js +217 -0
  311. package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
  312. package/dist/utils/syncStatusPresentation.test.js +164 -0
  313. package/dist/utils/taskActivity.d.ts +2 -0
  314. package/dist/utils/taskActivity.js +228 -22
  315. package/dist/utils/taskActivity.test.js +119 -8
  316. package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
  317. package/dist/utils/workspaceSyncPresentation.js +68 -2
  318. package/dist/utils/workspaceSyncPresentation.test.js +36 -1
  319. package/package.json +3 -2
  320. package/scripts/export-sqlite-to-postgres.mjs +7 -9
  321. package/scripts/run-e2e-realtime.sh +17 -3
  322. package/scripts/run-smoke.sh +17 -5
  323. package/scripts/run-soak.sh +17 -5
  324. package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  325. package/src/resources/templates/workflows/collaborate.yaml +1 -1
  326. package/src/resources/templates/workflows/evaluate.yaml +2 -2
  327. package/src/resources/templates/workflows/plan.yaml +1 -1
  328. package/src/resources/templates/workflows/review.yaml +2 -2
  329. package/dist/ui/assets/AgentsModule-N2MnQBZk.js +0 -1
  330. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +0 -1
  331. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +0 -252
  332. package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
  333. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +0 -1
  334. package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
  335. package/dist/ui/assets/index-C2-OXZV7.css +0 -1
  336. package/dist/ui/assets/index-DcSI5F86.js +0 -6
  337. 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
  }
@@ -700,6 +715,17 @@ describe('Server Routes', () => {
700
715
  process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = previous;
701
716
  }
702
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
+ });
703
729
  it('GET /api/taskforce/tasks should return tasks', async () => {
704
730
  const mockTasks = { tasks: [], config: {} };
705
731
  core.listTasks.mockReturnValue(mockTasks);
@@ -711,6 +737,30 @@ describe('Server Routes', () => {
711
737
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
712
738
  expect(res.end).toHaveBeenCalledWith(JSON.stringify(mockTasks));
713
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
+ });
714
764
  it('GET /mcp should return MCP JSON error instead of falling through when unauthenticated in cloud runtime', async () => {
715
765
  const match = matchRoute('GET', '/mcp', routes);
716
766
  const req = createMockReq('GET', '/mcp', undefined, {
@@ -2157,6 +2207,96 @@ describe('Server Routes', () => {
2157
2207
  expect(fetchMock).not.toHaveBeenCalled();
2158
2208
  vi.unstubAllGlobals();
2159
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
+ });
2160
2300
  it('GET /api/taskforce/context/:filename should proxy inline R2 markdown documents instead of redirecting', async () => {
2161
2301
  const fetchMock = vi.fn().mockResolvedValue({
2162
2302
  status: 200,
@@ -3447,6 +3587,53 @@ describe('Server Routes', () => {
3447
3587
  fs.rmSync(dbRoot, { recursive: true, force: true });
3448
3588
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3449
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
+ });
3450
3637
  it('GET /api/taskforce/documents should mark canonical docs without links as unattached', async () => {
3451
3638
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-unattached-local-'));
3452
3639
  core.getPaths.mockReturnValue({
@@ -3543,6 +3730,111 @@ describe('Server Routes', () => {
3543
3730
  fs.rmSync(dbRoot, { recursive: true, force: true });
3544
3731
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3545
3732
  });
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-'));
3735
+ core.getPaths.mockReturnValue({
3736
+ projectRoot: tmpRoot,
3737
+ basePath: path.join(tmpRoot, '.taskforce'),
3738
+ });
3739
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
3740
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
3741
+ core.getTenantId = vi.fn().mockReturnValue('default');
3742
+ store.upsertAsset({
3743
+ assetId: 'asset-doc-generic-link-1',
3744
+ workspaceId: 'default',
3745
+ kind: 'document',
3746
+ originalFilename: 'shared-notes.md',
3747
+ mimeType: 'text/markdown',
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',
3753
+ version: 1,
3754
+ updatedAt: '2026-03-18T13:30:00.000Z'
3755
+ });
3756
+ store.upsertLink({
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
+ });
3546
3838
  it('GET /api/taskforce/documents should normalize readable names and attachment metadata for sparse canonical assets', async () => {
3547
3839
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-normalized-cloud-'));
3548
3840
  core.getPaths.mockReturnValue({
@@ -4559,6 +4851,40 @@ describe('Server Routes', () => {
4559
4851
  expect(payload.inviteeKind).toBe('existing_user');
4560
4852
  expect(payload.passwordRequired).toBe(false);
4561
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
+ });
4562
4888
  it('POST /api/taskforce/auth/invite/accept should return expired code and audit action', async () => {
4563
4889
  const authConfig = {
4564
4890
  runtimeMode: 'cloud',
@@ -4649,7 +4975,8 @@ describe('Server Routes', () => {
4649
4975
  const cookie = createSessionTokenCookie('session-join', authConfig, {
4650
4976
  userId: 'u1',
4651
4977
  role: 'member',
4652
- workspaceId: 'workspace-1'
4978
+ workspaceId: 'workspace-1',
4979
+ identityId: 'identity-u1'
4653
4980
  });
4654
4981
  const match = matchRoute('POST', '/api/taskforce/auth/invite/join', routes);
4655
4982
  const req = createMockReq('POST', '/api/taskforce/auth/invite/join', { token: 'join-token' }, {
@@ -4663,6 +4990,11 @@ describe('Server Routes', () => {
4663
4990
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4664
4991
  const payload = JSON.parse(res.end.mock.calls[0][0]);
4665
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');
4666
4998
  });
4667
4999
  it('POST /api/taskforce/auth/login should auto-verify bootstrap admin and sign in', async () => {
4668
5000
  const authConfig = {
@@ -4991,6 +5323,7 @@ describe('Server Routes', () => {
4991
5323
  const res = createMockRes();
4992
5324
  await match?.handler(req, res, {});
4993
5325
  expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
5326
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('workspaces'));
4994
5327
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4995
5328
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"workspace-2"'));
4996
5329
  });
@@ -5884,6 +6217,9 @@ describe('Server Routes', () => {
5884
6217
  await match?.handler(req, res, {});
5885
6218
  const payloadRaw = res.end.mock.calls[0]?.[0];
5886
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
+ ]);
5887
6223
  expect(payload.documents).toEqual(expect.arrayContaining([
5888
6224
  expect.objectContaining({
5889
6225
  path: 'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md',
@@ -5898,7 +6234,56 @@ describe('Server Routes', () => {
5898
6234
  expect(String(payload.fingerprint || '')).toContain('asset-doc-snapshot-1');
5899
6235
  fs.rmSync(dbRoot, { recursive: true, force: true });
5900
6236
  });
5901
- 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 () => {
5902
6287
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-legacy-ignore-'));
5903
6288
  const projectRoot = fs.realpathSync(tmpRoot);
5904
6289
  const basePath = path.join(tmpRoot, '.taskforce');
@@ -5923,12 +6308,7 @@ describe('Server Routes', () => {
5923
6308
  const payloadRaw = res.end.mock.calls[0]?.[0];
5924
6309
  const payload = JSON.parse(String(payloadRaw || '{}'));
5925
6310
  expect(Array.isArray(payload.documents)).toBe(true);
5926
- expect(payload.documents).toEqual([
5927
- expect.objectContaining({
5928
- path: 'docs/legacy-notes.md',
5929
- content: '# legacy\n'
5930
- })
5931
- ]);
6311
+ expect(payload.documents).toEqual([]);
5932
6312
  fs.rmSync(dbRoot, { recursive: true, force: true });
5933
6313
  });
5934
6314
  it('GET /api/taskforce/sync/workspace/document-reviews should include document review sessions for the requested workspace', async () => {
@@ -7086,7 +7466,7 @@ describe('Server Routes', () => {
7086
7466
  task: expect.objectContaining({ id: 'task-b' })
7087
7467
  }));
7088
7468
  });
7089
- it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
7469
+ it('GET /api/taskforce/sync/workspace/pull should reject an invalid sync cursor instead of replaying from the beginning', async () => {
7090
7470
  const authConfig = {
7091
7471
  runtimeMode: 'cloud',
7092
7472
  enabled: true,
@@ -7099,45 +7479,149 @@ describe('Server Routes', () => {
7099
7479
  sessionTtlSeconds: 3600
7100
7480
  };
7101
7481
  routes = createRoutes(core, { authConfig });
7102
- const cookie = createSessionTokenCookie('session-sync-pull-forbidden', 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, {
7103
7484
  role: 'owner',
7104
- workspaceId: 'workspace-cloud-selected',
7485
+ workspaceId: 'workspace-1',
7105
7486
  userId: 'user-1'
7106
7487
  });
7107
- core.listUserWorkspaces.mockReturnValueOnce([
7108
- {
7109
- id: 'workspace-a',
7110
- slug: 'workspace-a',
7111
- name: 'Workspace A',
7112
- description: null,
7113
- role: 'owner',
7114
- status: 'active',
7115
- betaAccess: true
7116
- }
7117
- ]);
7118
7488
  const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7119
- const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-denied', undefined, { cookie });
7489
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor', undefined, { cookie });
7120
7490
  const res = createMockRes();
7121
7491
  await match?.handler(req, res, {});
7122
- expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
7123
- expect(res.end).toHaveBeenCalledWith(expect.stringContaining('WORKSPACE_ACCESS_DENIED'));
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"'));
7124
7495
  expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
7125
- action: 'sync-workspace-pull-forbidden',
7126
- details: { requestedWorkspaceId: 'workspace-denied' }
7496
+ action: 'sync-workspace-pull-invalid-cursor'
7127
7497
  }));
7128
7498
  });
7129
- it('POST /api/taskforce/sync/workspace/apply-local should apply deltas when auth is enabled in local runtime', async () => {
7130
- core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
7131
- routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
7132
- const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
7133
- const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
7134
- changes: [
7135
- { op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } },
7136
- { op: 'delete', taskId: 'task-2' }
7137
- ]
7138
- }, {
7139
- 'x-taskforce-workspace-id': 'workspace-local'
7140
- });
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
+ });
7524
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7525
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor&repair=1', undefined, { cookie });
7526
+ const res = createMockRes();
7527
+ await match?.handler(req, res, {});
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
+ ]));
7537
+ });
7538
+ it('GET /api/taskforce/sync/workspace/pull should still return success when success audit throws', async () => {
7539
+ const authConfig = {
7540
+ runtimeMode: 'cloud',
7541
+ enabled: true,
7542
+ requiredForApi: true,
7543
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7544
+ defaultRole: 'member',
7545
+ defaultWorkspaceId: 'default',
7546
+ sessionCookieName: 'taskforce_session',
7547
+ sessionSecret: 'test-secret',
7548
+ sessionTtlSeconds: 3600
7549
+ };
7550
+ routes = createRoutes(core, { 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, {
7553
+ role: 'owner',
7554
+ workspaceId: 'workspace-1',
7555
+ userId: 'user-1'
7556
+ });
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;
7563
+ });
7564
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
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'));
7572
+ });
7573
+ it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
7574
+ const authConfig = {
7575
+ runtimeMode: 'cloud',
7576
+ enabled: true,
7577
+ requiredForApi: true,
7578
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7579
+ defaultRole: 'member',
7580
+ defaultWorkspaceId: 'default',
7581
+ sessionCookieName: 'taskforce_session',
7582
+ sessionSecret: 'test-secret',
7583
+ sessionTtlSeconds: 3600
7584
+ };
7585
+ routes = createRoutes(core, { authConfig });
7586
+ const cookie = createSessionTokenCookie('session-sync-pull-forbidden', authConfig, {
7587
+ role: 'owner',
7588
+ workspaceId: 'workspace-cloud-selected',
7589
+ userId: 'user-1'
7590
+ });
7591
+ core.listUserWorkspaces.mockReturnValueOnce([
7592
+ {
7593
+ id: 'workspace-a',
7594
+ slug: 'workspace-a',
7595
+ name: 'Workspace A',
7596
+ description: null,
7597
+ role: 'owner',
7598
+ status: 'active',
7599
+ betaAccess: true
7600
+ }
7601
+ ]);
7602
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7603
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-denied', undefined, { cookie });
7604
+ const res = createMockRes();
7605
+ await match?.handler(req, res, {});
7606
+ expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
7607
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('WORKSPACE_ACCESS_DENIED'));
7608
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
7609
+ action: 'sync-workspace-pull-forbidden',
7610
+ details: { requestedWorkspaceId: 'workspace-denied' }
7611
+ }));
7612
+ });
7613
+ it('POST /api/taskforce/sync/workspace/apply-local should apply deltas when auth is enabled in local runtime', async () => {
7614
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
7615
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
7616
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
7617
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
7618
+ changes: [
7619
+ { op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } },
7620
+ { op: 'delete', taskId: 'task-2' }
7621
+ ]
7622
+ }, {
7623
+ 'x-taskforce-workspace-id': 'workspace-local'
7624
+ });
7141
7625
  const res = createMockRes();
7142
7626
  await match?.handler(req, res, {});
7143
7627
  expect(core.deleteTaskForSync).toHaveBeenCalledWith('task-2', 'workspace-local', { persistBackup: false });
@@ -7147,6 +7631,53 @@ describe('Server Routes', () => {
7147
7631
  }), 'workspace-local');
7148
7632
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7149
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
+ });
7150
7681
  it('POST /api/taskforce/sync/workspace/apply-local should apply document review session upserts and deletes', async () => {
7151
7682
  core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
7152
7683
  routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
@@ -7891,7 +8422,7 @@ describe('Server Routes', () => {
7891
8422
  ]);
7892
8423
  fs.rmSync(dbRoot, { recursive: true, force: true });
7893
8424
  });
7894
- 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 () => {
7895
8426
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-doc-metadata-'));
7896
8427
  const projectRoot = fs.realpathSync(tmpRoot);
7897
8428
  const basePath = path.join(tmpRoot, '.taskforce');
@@ -7945,7 +8476,7 @@ describe('Server Routes', () => {
7945
8476
  const res = createMockRes();
7946
8477
  await match?.handler(req, res, {});
7947
8478
  expect(store.get('asset-sync-doc-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
7948
- referenceNumber: 9
8479
+ referenceNumber: 42
7949
8480
  }));
7950
8481
  fs.rmSync(dbRoot, { recursive: true, force: true });
7951
8482
  });
@@ -8008,7 +8539,7 @@ describe('Server Routes', () => {
8008
8539
  }));
8009
8540
  fs.rmSync(dbRoot, { recursive: true, force: true });
8010
8541
  });
8011
- 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 () => {
8012
8543
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-image-metadata-'));
8013
8544
  const projectRoot = fs.realpathSync(tmpRoot);
8014
8545
  const basePath = path.join(tmpRoot, '.taskforce');
@@ -8060,7 +8591,7 @@ describe('Server Routes', () => {
8060
8591
  const res = createMockRes();
8061
8592
  await match?.handler(req, res, {});
8062
8593
  expect(store.get('asset-sync-image-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
8063
- referenceNumber: 9
8594
+ referenceNumber: 42
8064
8595
  }));
8065
8596
  fs.rmSync(dbRoot, { recursive: true, force: true });
8066
8597
  });
@@ -8387,7 +8918,7 @@ describe('Server Routes', () => {
8387
8918
  action: 'sync-workspace-push-idempotent-replay'
8388
8919
  }));
8389
8920
  });
8390
- it('POST /api/taskforce/sync/workspace/push should persist synced cloud attachments into R2-backed canonical assets', async () => {
8921
+ it('POST /api/taskforce/sync/workspace/push should still return success when idempotency persistence fails after apply', async () => {
8391
8922
  const authConfig = {
8392
8923
  runtimeMode: 'cloud',
8393
8924
  enabled: true,
@@ -8399,109 +8930,307 @@ describe('Server Routes', () => {
8399
8930
  sessionSecret: 'test-secret',
8400
8931
  sessionTtlSeconds: 3600
8401
8932
  };
8402
- const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
8403
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-push-r2-'));
8404
- core.getPaths.mockReturnValue({
8405
- projectRoot: tmpRoot,
8406
- basePath: path.join(tmpRoot, '.taskforce')
8407
- });
8408
- core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
8409
- core.getTenantId = vi.fn().mockReturnValue('default');
8933
+ routes = createRoutes(core, { authConfig });
8410
8934
  core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
8411
- core.applyWorkspaceSyncSnapshot.mockReturnValue({
8412
- total: 0,
8413
- created: 0,
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,
8414
8943
  updated: 0,
8415
8944
  archived: 0,
8416
8945
  unarchived: 0,
8417
8946
  skipped: 0
8418
8947
  });
8419
- const fetchMock = vi.fn().mockResolvedValue({
8420
- ok: true,
8421
- status: 200,
8422
- statusText: 'OK',
8423
- headers: new Headers(),
8424
- text: async () => '',
8425
- json: async () => ({})
8426
- });
8427
- vi.stubGlobal('fetch', fetchMock);
8428
- routes = createRoutes(core, {
8429
- authConfig,
8430
- objectStorage: {
8431
- provider: 'r2',
8432
- r2: {
8433
- accountId: 'acct-1',
8434
- bucket: 'taskforce-perf-assets',
8435
- endpoint: 'https://acct-1.r2.cloudflarestorage.com',
8436
- accessKeyId: 'access-key-1',
8437
- secretAccessKey: 'secret-key-1',
8438
- keyPrefix: '',
8439
- signedUploadTtlSeconds: 600,
8440
- signedDownloadTtlSeconds: 300
8441
- }
8442
- }
8443
- });
8444
- const cookie = createSessionTokenCookie('session-sync-push-r2', authConfig, {
8445
- role: 'owner',
8446
- workspaceId: 'workspace-1',
8447
- userId: 'user-1'
8948
+ core.writePushIdempotency.mockImplementationOnce(() => {
8949
+ throw new Error('disk full');
8448
8950
  });
8449
- const markdownPath = 'workspaces/workspace-1/assets/documents/asset-doc-sync-1-plan.md';
8450
- const imagePath = 'workspaces/workspace-1/assets/images/asset-image-sync-1-proof.png';
8451
8951
  const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
8452
8952
  const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
8453
8953
  workspaceId: 'workspace-1',
8954
+ idempotencyKey: 'push-idempotency-write-fail-1',
8454
8955
  changes: [
8455
- {
8456
- op: 'document-upsert',
8457
- path: markdownPath,
8458
- updatedAt: '2026-03-18T23:20:00.000Z',
8459
- content: '# Plan',
8460
- assetId: 'asset-doc-sync-1',
8461
- documentId: 'doc-sync-1',
8462
- taskId: 'task-sync-1',
8463
- originalFilename: 'plan.md',
8464
- linkRole: 'attachment'
8465
- },
8466
- {
8467
- op: 'asset-upsert',
8468
- path: imagePath,
8469
- updatedAt: '2026-03-18T23:20:01.000Z',
8470
- contentBase64: Buffer.from('png-bytes').toString('base64'),
8471
- assetId: 'asset-image-sync-1',
8472
- kind: 'image',
8473
- mimeType: 'image/png',
8474
- taskId: 'task-sync-1',
8475
- originalFilename: 'proof.png',
8476
- linkRole: 'image'
8477
- }
8956
+ { op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
8478
8957
  ]
8479
8958
  }, { cookie });
8480
8959
  const res = createMockRes();
8481
8960
  await match?.handler(req, res, {});
8961
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
8482
8962
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8483
- expect(fetchMock).toHaveBeenCalledTimes(2);
8484
- const docAsset = store.getByStorageKey(markdownPath, 'workspace-1');
8485
- const imageAsset = store.getByStorageKey(imagePath, 'workspace-1');
8486
- expect(docAsset).toEqual(expect.objectContaining({
8487
- assetId: 'asset-doc-sync-1',
8488
- storageProvider: 'r2',
8489
- storageKey: markdownPath
8963
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
8964
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
8965
+ action: 'sync-workspace-push-idempotency-write-failed'
8490
8966
  }));
8491
- expect(imageAsset).toEqual(expect.objectContaining({
8492
- assetId: 'asset-image-sync-1',
8493
- storageProvider: 'r2',
8494
- storageKey: imagePath
8967
+ expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
8968
+ eventType: 'push',
8969
+ status: 'success'
8495
8970
  }));
8496
- expect(store.listLinksForTask('task-sync-1', 'workspace-1')).toEqual(expect.arrayContaining([
8497
- expect.objectContaining({ assetId: 'asset-doc-sync-1', role: 'attachment', deletedAt: null }),
8498
- expect.objectContaining({ assetId: 'asset-image-sync-1', role: 'image', deletedAt: null })
8499
- ]));
8500
- vi.unstubAllGlobals();
8501
- fs.rmSync(tmpRoot, { recursive: true, force: true });
8502
- fs.rmSync(dbRoot, { recursive: true, force: true });
8503
8971
  });
8504
- it('POST /api/taskforce/sync/workspace/push should audit unauthorized access', async () => {
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'));
9118
+ });
9119
+ it('POST /api/taskforce/sync/workspace/push should persist synced cloud attachments into R2-backed canonical assets', async () => {
9120
+ const authConfig = {
9121
+ runtimeMode: 'cloud',
9122
+ enabled: true,
9123
+ requiredForApi: true,
9124
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
9125
+ defaultRole: 'member',
9126
+ defaultWorkspaceId: 'default',
9127
+ sessionCookieName: 'taskforce_session',
9128
+ sessionSecret: 'test-secret',
9129
+ sessionTtlSeconds: 3600
9130
+ };
9131
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
9132
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-push-r2-'));
9133
+ core.getPaths.mockReturnValue({
9134
+ projectRoot: tmpRoot,
9135
+ basePath: path.join(tmpRoot, '.taskforce')
9136
+ });
9137
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
9138
+ core.getTenantId = vi.fn().mockReturnValue('default');
9139
+ core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
9140
+ core.applyWorkspaceSyncSnapshot.mockReturnValue({
9141
+ total: 0,
9142
+ created: 0,
9143
+ updated: 0,
9144
+ archived: 0,
9145
+ unarchived: 0,
9146
+ skipped: 0
9147
+ });
9148
+ const fetchMock = vi.fn().mockResolvedValue({
9149
+ ok: true,
9150
+ status: 200,
9151
+ statusText: 'OK',
9152
+ headers: new Headers(),
9153
+ text: async () => '',
9154
+ json: async () => ({})
9155
+ });
9156
+ vi.stubGlobal('fetch', fetchMock);
9157
+ routes = createRoutes(core, {
9158
+ authConfig,
9159
+ objectStorage: {
9160
+ provider: 'r2',
9161
+ r2: {
9162
+ accountId: 'acct-1',
9163
+ bucket: 'taskforce-perf-assets',
9164
+ endpoint: 'https://acct-1.r2.cloudflarestorage.com',
9165
+ accessKeyId: 'access-key-1',
9166
+ secretAccessKey: 'secret-key-1',
9167
+ keyPrefix: '',
9168
+ signedUploadTtlSeconds: 600,
9169
+ signedDownloadTtlSeconds: 300
9170
+ }
9171
+ }
9172
+ });
9173
+ const cookie = createSessionTokenCookie('session-sync-push-r2', authConfig, {
9174
+ role: 'owner',
9175
+ workspaceId: 'workspace-1',
9176
+ userId: 'user-1'
9177
+ });
9178
+ const markdownPath = 'workspaces/workspace-1/assets/documents/asset-doc-sync-1-plan.md';
9179
+ const imagePath = 'workspaces/workspace-1/assets/images/asset-image-sync-1-proof.png';
9180
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
9181
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
9182
+ workspaceId: 'workspace-1',
9183
+ changes: [
9184
+ {
9185
+ op: 'document-upsert',
9186
+ path: markdownPath,
9187
+ updatedAt: '2026-03-18T23:20:00.000Z',
9188
+ content: '# Plan',
9189
+ assetId: 'asset-doc-sync-1',
9190
+ documentId: 'doc-sync-1',
9191
+ taskId: 'task-sync-1',
9192
+ originalFilename: 'plan.md',
9193
+ linkRole: 'attachment'
9194
+ },
9195
+ {
9196
+ op: 'asset-upsert',
9197
+ path: imagePath,
9198
+ updatedAt: '2026-03-18T23:20:01.000Z',
9199
+ contentBase64: Buffer.from('png-bytes').toString('base64'),
9200
+ assetId: 'asset-image-sync-1',
9201
+ kind: 'image',
9202
+ mimeType: 'image/png',
9203
+ taskId: 'task-sync-1',
9204
+ originalFilename: 'proof.png',
9205
+ linkRole: 'image'
9206
+ }
9207
+ ]
9208
+ }, { cookie });
9209
+ const res = createMockRes();
9210
+ await match?.handler(req, res, {});
9211
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9212
+ expect(fetchMock).toHaveBeenCalledTimes(2);
9213
+ const docAsset = store.getByStorageKey(markdownPath, 'workspace-1');
9214
+ const imageAsset = store.getByStorageKey(imagePath, 'workspace-1');
9215
+ expect(docAsset).toEqual(expect.objectContaining({
9216
+ assetId: 'asset-doc-sync-1',
9217
+ storageProvider: 'r2',
9218
+ storageKey: markdownPath
9219
+ }));
9220
+ expect(imageAsset).toEqual(expect.objectContaining({
9221
+ assetId: 'asset-image-sync-1',
9222
+ storageProvider: 'r2',
9223
+ storageKey: imagePath
9224
+ }));
9225
+ expect(store.listLinksForTask('task-sync-1', 'workspace-1')).toEqual(expect.arrayContaining([
9226
+ expect.objectContaining({ assetId: 'asset-doc-sync-1', role: 'attachment', deletedAt: null }),
9227
+ expect.objectContaining({ assetId: 'asset-image-sync-1', role: 'image', deletedAt: null })
9228
+ ]));
9229
+ vi.unstubAllGlobals();
9230
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
9231
+ fs.rmSync(dbRoot, { recursive: true, force: true });
9232
+ });
9233
+ it('POST /api/taskforce/sync/workspace/push should audit unauthorized access', async () => {
8505
9234
  const authConfig = {
8506
9235
  runtimeMode: 'cloud',
8507
9236
  enabled: true,
@@ -9417,6 +10146,127 @@ describe('Server Routes', () => {
9417
10146
  expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
9418
10147
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9419
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
+ });
9420
10270
  it('POST /api/taskforce/session/workspace should be disabled in local runtime', async () => {
9421
10271
  const authConfig = {
9422
10272
  runtimeMode: 'local',
@@ -9513,6 +10363,7 @@ describe('Server Routes', () => {
9513
10363
  const payload = JSON.parse(res.end.mock.calls[0][0]);
9514
10364
  expect(payload.authenticated).toBe(true);
9515
10365
  expect(payload.betaAccess).toBe(false);
10366
+ expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('auth-session'));
9516
10367
  expect(core.getUserBetaAccess).toHaveBeenCalledWith('u-no-workspace');
9517
10368
  });
9518
10369
  it('GET /api/taskforce/auth/session should expose avatarUrl from the resolved profile', async () => {
@@ -9723,6 +10574,74 @@ describe('Server Routes', () => {
9723
10574
  mimeType: 'image/png'
9724
10575
  }));
9725
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
+ });
9726
10645
  it('POST /api/taskforce/auth/profile/avatar/finalize should validate and finalize the uploaded draft', async () => {
9727
10646
  const authConfig = {
9728
10647
  runtimeMode: 'cloud',
@@ -9847,6 +10766,56 @@ describe('Server Routes', () => {
9847
10766
  allowed: true
9848
10767
  });
9849
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
+ });
9850
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 () => {
9851
10820
  core.resolveUserAccess.mockReturnValueOnce(null);
9852
10821
  const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
@@ -9923,6 +10892,40 @@ describe('Server Routes', () => {
9923
10892
  planSelectionRequired: false
9924
10893
  });
9925
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
+ });
9926
10929
  it('GET /api/taskforce/account/access-status should return pending plan-selection gate', async () => {
9927
10930
  core.getAccountAccessStatus.mockReturnValueOnce({
9928
10931
  gate: 'plan_selection_required',
@@ -9995,6 +10998,78 @@ describe('Server Routes', () => {
9995
10998
  error.code = 'ENTITLEMENT_NOT_FOUND';
9996
10999
  throw error;
9997
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
+ });
9998
11073
  const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
9999
11074
  const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
10000
11075
  'x-taskforce-user-id': 'u1'
@@ -10004,18 +11079,15 @@ describe('Server Routes', () => {
10004
11079
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10005
11080
  const payload = JSON.parse(res.end.mock.calls[0][0]);
10006
11081
  expect(payload).toMatchObject({
10007
- planId: 'pro',
10008
- planVersionId: 'pro-v1',
10009
- entitlementState: 'checkout_pending',
10010
- gate: 'checkout_pending',
10011
- message: 'Complete checkout to continue.',
10012
- billingConflictCode: null,
11082
+ planId: null,
11083
+ planVersionId: null,
11084
+ entitlementState: null,
11085
+ gate: 'missing_entitlement',
11086
+ message: 'No entitlement is linked to this account.',
10013
11087
  planSelectionRequired: false,
10014
- workspaceId: 'workspace-1',
10015
- workspaceRole: 'owner',
10016
- canManageWorkspace: true,
10017
- teamPlanMode: 'personal',
10018
- teamManagementAllowed: false
11088
+ stripeStatus: 'active',
11089
+ stripeCustomerId: 'cus_stale',
11090
+ stripeSubscriptionId: 'sub_stale'
10019
11091
  });
10020
11092
  });
10021
11093
  it('GET /api/taskforce/account/entitlements should surface checkout-pending gate when billing is not complete', async () => {
@@ -10087,6 +11159,20 @@ describe('Server Routes', () => {
10087
11159
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"PLAN_SELECTION_REQUIRED"'));
10088
11160
  expect(core.listTasks).not.toHaveBeenCalled();
10089
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
+ });
10090
11176
  it('GET /api/taskforce/tasks should block authenticated users with missing entitlements', async () => {
10091
11177
  const authConfig = {
10092
11178
  runtimeMode: 'cloud',
@@ -10194,17 +11280,31 @@ describe('Server Routes', () => {
10194
11280
  try {
10195
11281
  core.getTenantId = vi.fn().mockReturnValue('default');
10196
11282
  core.getDatabaseAdapter = vi.fn().mockReturnValue({
10197
- prepare: vi.fn().mockReturnValue({
10198
- all: vi.fn().mockReturnValue([
10199
- {
10200
- plan_version_id: 'free-v1',
10201
- billing_interval: 'month',
10202
- stripe_price_id: 'free:free-v1:month',
10203
- pricing_type: 'free',
10204
- unit_amount: 0,
10205
- currency: null
10206
- }
10207
- ])
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
+ };
10208
11308
  })
10209
11309
  });
10210
11310
  core.listPlanCatalog.mockReturnValueOnce([
@@ -10224,6 +11324,7 @@ describe('Server Routes', () => {
10224
11324
  }
10225
11325
  }
10226
11326
  ]);
11327
+ core.listPlanFeatureCatalog = vi.fn().mockReturnValue([]);
10227
11328
  routes = createRoutes(core);
10228
11329
  const match = matchRoute('GET', '/api/taskforce/public/pricing', routes);
10229
11330
  const req = createMockReq('GET', '/api/taskforce/public/pricing', undefined, {
@@ -10235,7 +11336,15 @@ describe('Server Routes', () => {
10235
11336
  const payload = JSON.parse(String(res.end.mock.calls[0][0]));
10236
11337
  expect(payload.plans).toHaveLength(1);
10237
11338
  expect(payload.plans[0].features).toEqual([
10238
- { 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
+ }
10239
11348
  ]);
10240
11349
  }
10241
11350
  finally {
@@ -10267,6 +11376,7 @@ describe('Server Routes', () => {
10267
11376
  expect(core.upsertPlanCatalog).toHaveBeenCalledWith({
10268
11377
  planId: 'enterprise',
10269
11378
  displayName: 'Enterprise',
11379
+ description: undefined,
10270
11380
  status: 'active',
10271
11381
  initialTrialDays: 0,
10272
11382
  initialMode: undefined,
@@ -10286,6 +11396,42 @@ describe('Server Routes', () => {
10286
11396
  expect(core.listPlanCatalog).toHaveBeenCalled();
10287
11397
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10288
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
+ });
10289
11435
  it('GET /api/platform/plans/:planId/versions should list versions', async () => {
10290
11436
  const match = matchRoute('GET', '/api/platform/plans/collab/versions', routes);
10291
11437
  const req = createMockReq('GET', '/api/platform/plans/collab/versions', undefined, {
@@ -10344,6 +11490,37 @@ describe('Server Routes', () => {
10344
11490
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.team_management"'));
10345
11491
  expect(res.end).not.toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.invites"'));
10346
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
+ });
10347
11524
  it('PATCH /api/platform/plans/:planId/versions/:versionId/features should return normalized dependency state', async () => {
10348
11525
  core.updatePlanVersionFeatures.mockReturnValueOnce({
10349
11526
  planVersionId: 'collab-v1',
@@ -10473,13 +11650,20 @@ describe('Server Routes', () => {
10473
11650
  updatedAt: '2026-02-01T00:00:00.000Z'
10474
11651
  }
10475
11652
  ]);
11653
+ core.countSystemUsers.mockReturnValueOnce(1);
10476
11654
  const match = matchRoute('GET', '/api/taskforce/admin/system-users', routes);
10477
- const req = createMockReq('GET', '/api/taskforce/admin/system-users', undefined, { 'x-taskforce-system-role': 'system_admin' });
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' });
10478
11656
  const res = createMockRes();
10479
11657
  await match?.handler(req, res, {});
10480
- expect(core.listSystemUsers).toHaveBeenCalled();
11658
+ expect(core.countSystemUsers).toHaveBeenCalledWith({ query: 'u1' });
11659
+ expect(core.listSystemUsers).toHaveBeenCalledWith({ query: 'u1', sort: 'oldest', limit: 25, offset: 50 });
10481
11660
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10482
- 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);
10483
11667
  });
10484
11668
  it('GET /api/taskforce/admin/system-users should allow authenticated system admin without role header', async () => {
10485
11669
  const authConfig = {
@@ -10505,7 +11689,8 @@ describe('Server Routes', () => {
10505
11689
  const res = createMockRes();
10506
11690
  await match?.handler(req, res, {});
10507
11691
  expect(core.getSystemRole).toHaveBeenCalledWith('admin-user');
10508
- expect(core.listSystemUsers).toHaveBeenCalled();
11692
+ expect(core.countSystemUsers).toHaveBeenCalledWith({ query: '' });
11693
+ expect(core.listSystemUsers).toHaveBeenCalledWith({ query: '', sort: 'newest', limit: 50, offset: 0 });
10509
11694
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10510
11695
  });
10511
11696
  it('GET /api/taskforce/admin/audit-logs should return events for admins', async () => {
@@ -10767,6 +11952,266 @@ describe('Server Routes', () => {
10767
11952
  expect(matchRoute('POST', '/api/taskforce/admin/users/invite', routes)).not.toBeNull();
10768
11953
  expect(matchRoute('PATCH', '/api/taskforce/admin/users/u1/role', routes)).not.toBeNull();
10769
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));
10770
12215
  });
10771
12216
  it('GET /api/taskforce/workspace/assignee-options should reject unauthenticated cloud requests', async () => {
10772
12217
  const authConfig = {
@@ -10861,6 +12306,7 @@ describe('Server Routes', () => {
10861
12306
  userId: 'member-1',
10862
12307
  email: 'member1@example.com',
10863
12308
  displayName: 'Member One',
12309
+ avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
10864
12310
  role: 'member',
10865
12311
  permissionMode: 'read-write',
10866
12312
  betaAccess: true,
@@ -10882,11 +12328,14 @@ describe('Server Routes', () => {
10882
12328
  provider: 'OpenAI',
10883
12329
  model: 'gpt-5.4',
10884
12330
  surfaceType: 'coding_tool',
12331
+ seatScope: null,
12332
+ archivedAt: null,
10885
12333
  createdAt: '2026-03-01T00:00:00.000Z',
10886
12334
  updatedAt: '2026-03-01T00:00:00.000Z',
10887
12335
  lastActiveAt: null
10888
12336
  }
10889
12337
  ]);
12338
+ core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
10890
12339
  const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
10891
12340
  const cookie = createSessionTokenCookie('session-member-assignees', authConfig, {
10892
12341
  userId: 'member-user',
@@ -10903,9 +12352,14 @@ describe('Server Routes', () => {
10903
12352
  const res = createMockRes();
10904
12353
  await match?.handler(req, res, {});
10905
12354
  expect(core.listWorkspaceUsers).toHaveBeenCalledWith('workspace-1');
10906
- 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'));
10907
12360
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10908
12361
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12362
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
10909
12363
  expect(payload.assignees).toEqual([
10910
12364
  {
10911
12365
  value: 'member-1',
@@ -10916,6 +12370,7 @@ describe('Server Routes', () => {
10916
12370
  userId: 'member-1',
10917
12371
  email: 'member1@example.com',
10918
12372
  displayName: 'Member One',
12373
+ avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
10919
12374
  status: 'active',
10920
12375
  disabled: false
10921
12376
  },
@@ -10925,12 +12380,16 @@ describe('Server Routes', () => {
10925
12380
  username: 'codex',
10926
12381
  icon: 'Bot',
10927
12382
  color: '#ef6c00',
12383
+ avatarUrl: null,
12384
+ avatarSourceUrl: null,
10928
12385
  kind: 'agent',
10929
12386
  description: 'Builds product code',
10930
12387
  role: 'Implementer',
10931
12388
  provider: 'OpenAI',
10932
12389
  model: 'gpt-5.4',
10933
12390
  surfaceType: 'coding_tool',
12391
+ seatScope: null,
12392
+ archivedAt: null,
10934
12393
  createdAt: '2026-03-01T00:00:00.000Z',
10935
12394
  updatedAt: '2026-03-01T00:00:00.000Z',
10936
12395
  lastActiveAt: null
@@ -10970,6 +12429,7 @@ describe('Server Routes', () => {
10970
12429
  lastActiveAt: null
10971
12430
  }
10972
12431
  ]);
12432
+ core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
10973
12433
  const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
10974
12434
  const cookie = createSessionTokenCookie('session-admin-assignees', authConfig, {
10975
12435
  userId: 'admin-user',
@@ -10986,6 +12446,7 @@ describe('Server Routes', () => {
10986
12446
  const res = createMockRes();
10987
12447
  await match?.handler(req, res, {});
10988
12448
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12449
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
10989
12450
  expect(payload.assignees).toEqual([
10990
12451
  expect.objectContaining({
10991
12452
  value: 'ai-profile-1',
@@ -10993,6 +12454,198 @@ describe('Server Routes', () => {
10993
12454
  })
10994
12455
  ]);
10995
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
+ });
10996
12649
  it('GET /api/taskforce/admin/workspace-audit-logs should return events for workspace admins', async () => {
10997
12650
  const authConfig = {
10998
12651
  runtimeMode: 'cloud',
@@ -11090,13 +12743,13 @@ describe('Server Routes', () => {
11090
12743
  billing: {
11091
12744
  onboardingPolicy: {
11092
12745
  mode: 'auto_grant_free',
11093
- planVersionId: 'starter-v1'
12746
+ planId: 'starter'
11094
12747
  }
11095
12748
  }
11096
12749
  });
11097
12750
  core.getOnboardingPolicy.mockReturnValue({
11098
12751
  mode: 'auto_grant_free',
11099
- planVersionId: 'starter-v1'
12752
+ planId: 'starter'
11100
12753
  });
11101
12754
  const match = matchRoute('GET', '/api/taskforce/admin/system-settings', routes);
11102
12755
  const req = createMockReq('GET', '/api/taskforce/admin/system-settings', undefined, { 'x-taskforce-role': 'admin' });
@@ -11104,9 +12757,9 @@ describe('Server Routes', () => {
11104
12757
  await match?.handler(req, res, {});
11105
12758
  const payload = JSON.parse(res.end.mock.calls[0][0]);
11106
12759
  expect(payload.status.onboardingPolicyMode).toBe('auto_grant_free');
11107
- expect(payload.status.onboardingPlanVersionId).toBe('starter-v1');
12760
+ expect(payload.status.onboardingPlanId).toBe('starter');
11108
12761
  expect(payload.settings.onboardingPolicyMode).toBe('auto_grant_free');
11109
- expect(payload.settings.onboardingPlanVersionId).toBe('starter-v1');
12762
+ expect(payload.settings.onboardingPlanId).toBe('starter');
11110
12763
  });
11111
12764
  it('POST /api/taskforce/admin/system-settings should persist onboarding policy fields', async () => {
11112
12765
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
@@ -11118,13 +12771,13 @@ describe('Server Routes', () => {
11118
12771
  authRateLimitMaxRequests: 10,
11119
12772
  authRateLimitWindowMs: 900000,
11120
12773
  onboardingPolicyMode: 'auto_grant_free',
11121
- onboardingPlanVersionId: 'starter-v1'
12774
+ onboardingPlanId: 'starter'
11122
12775
  }, { 'x-taskforce-role': 'admin' });
11123
12776
  const res = createMockRes();
11124
12777
  await match?.handler(req, res, {});
11125
12778
  expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
11126
12779
  mode: 'auto_grant_free',
11127
- planVersionId: 'starter-v1'
12780
+ planId: 'starter'
11128
12781
  });
11129
12782
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
11130
12783
  auth: {
@@ -11138,7 +12791,7 @@ describe('Server Routes', () => {
11138
12791
  billing: {
11139
12792
  onboardingPolicy: {
11140
12793
  mode: 'auto_grant_free',
11141
- planVersionId: 'starter-v1'
12794
+ planId: 'starter'
11142
12795
  }
11143
12796
  }
11144
12797
  });
@@ -11146,7 +12799,7 @@ describe('Server Routes', () => {
11146
12799
  it('POST /api/taskforce/admin/system-settings should preserve onboarding policy when onboarding fields are omitted', async () => {
11147
12800
  core.getOnboardingPolicy.mockReturnValue({
11148
12801
  mode: 'auto_grant_free',
11149
- planVersionId: 'starter-v1'
12802
+ planId: 'starter'
11150
12803
  });
11151
12804
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
11152
12805
  const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
@@ -11161,7 +12814,7 @@ describe('Server Routes', () => {
11161
12814
  await match?.handler(req, res, {});
11162
12815
  expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
11163
12816
  mode: 'auto_grant_free',
11164
- planVersionId: 'starter-v1'
12817
+ planId: 'starter'
11165
12818
  });
11166
12819
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
11167
12820
  auth: {
@@ -11175,7 +12828,7 @@ describe('Server Routes', () => {
11175
12828
  billing: {
11176
12829
  onboardingPolicy: {
11177
12830
  mode: 'auto_grant_free',
11178
- planVersionId: 'starter-v1'
12831
+ planId: 'starter'
11179
12832
  }
11180
12833
  }
11181
12834
  });
@@ -11288,13 +12941,13 @@ describe('Server Routes', () => {
11288
12941
  it('POST /api/taskforce/admin/system-settings should reject invalid onboarding settings', async () => {
11289
12942
  core.validateOnboardingPolicy.mockReturnValueOnce({
11290
12943
  valid: false,
11291
- code: 'ONBOARDING_PLAN_VERSION_REQUIRED',
11292
- 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.'
11293
12946
  });
11294
12947
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
11295
12948
  const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
11296
12949
  onboardingPolicyMode: 'auto_grant_free',
11297
- onboardingPlanVersionId: null
12950
+ onboardingPlanId: null
11298
12951
  }, { 'x-taskforce-role': 'admin' });
11299
12952
  const res = createMockRes();
11300
12953
  await match?.handler(req, res, {});