@taskforcehq/taskforce 0.3.304 → 0.3.306

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (383) hide show
  1. package/README.md +140 -247
  2. package/dist/Taskforce.module.css +295 -89
  3. package/dist/TaskforceCore.js +378 -111
  4. package/dist/TaskforceCore.test.js +1221 -107
  5. package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
  6. package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  7. package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
  8. package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
  9. package/dist/assets/fonts/Noir_medium.woff2 +0 -0
  10. package/dist/assets/fonts/Noir_regular.woff2 +0 -0
  11. package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
  12. package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
  13. package/dist/compat/workspaceSyncCompat.js +52 -2
  14. package/dist/compat/workspaceSyncCompat.test.js +38 -1
  15. package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
  16. package/dist/components/context/ContextAttachmentManager.js +549 -0
  17. package/dist/components/features/AgentsModule.d.ts +10 -1
  18. package/dist/components/features/AgentsModule.js +260 -15
  19. package/dist/components/features/AgentsModule.test.js +255 -5
  20. package/dist/components/features/DocumentIndex.d.ts +1 -1
  21. package/dist/components/features/DocumentIndex.js +1 -1
  22. package/dist/components/features/DocumentViewer.d.ts +6 -2
  23. package/dist/components/features/DocumentViewer.js +90 -10
  24. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  25. package/dist/components/features/DocumentWorkspace.js +12 -155
  26. package/dist/components/features/DocumentWorkspace.test.js +226 -6
  27. package/dist/components/features/TaskSettings.js +29 -3
  28. package/dist/components/features/TaskSettings.test.js +42 -1
  29. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  30. package/dist/components/features/documentWorkspaceModel.js +57 -0
  31. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  32. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  33. package/dist/components/task/TaskCard.js +19 -28
  34. package/dist/components/task/TaskCard.test.js +38 -3
  35. package/dist/components/task/TaskContextUpload.js +4 -526
  36. package/dist/components/task/TaskForm.d.ts +8 -8
  37. package/dist/components/task/TaskForm.js +362 -230
  38. package/dist/components/task/TaskForm.test.js +558 -381
  39. package/dist/components/task/TaskKanban.d.ts +2 -1
  40. package/dist/components/task/TaskKanban.js +96 -14
  41. package/dist/components/task/TaskKanban.test.js +36 -0
  42. package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
  43. package/dist/components/ui/EditableAvatarButton.js +18 -0
  44. package/dist/components/views/PlanComparisonPage.d.ts +9 -1
  45. package/dist/components/views/PlanComparisonPage.js +70 -17
  46. package/dist/components/views/PlanComparisonPage.test.js +289 -41
  47. package/dist/components/views/PlansPage.js +239 -106
  48. package/dist/components/views/PlansPage.test.js +593 -42
  49. package/dist/components/views/StandaloneLayout.js +310 -554
  50. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  51. package/dist/components/views/panels/PlanningDrawer.d.ts +8 -1
  52. package/dist/components/views/panels/PlanningDrawer.js +18 -11
  53. package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
  54. package/dist/components/views/panels/PlanningDrawer.test.js +141 -0
  55. package/dist/components/views/planningScope.d.ts +20 -0
  56. package/dist/components/views/planningScope.js +25 -0
  57. package/dist/components/views/planningScope.test.d.ts +1 -0
  58. package/dist/components/views/planningScope.test.js +60 -0
  59. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +27 -14
  60. package/dist/components/views/standalone/modals/AccountHubModal.js +87 -15
  61. package/dist/components/views/standalone/modals/AccountHubModal.test.js +42 -2
  62. package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
  63. package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
  64. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
  65. package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
  66. package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
  67. package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
  68. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
  69. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
  70. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
  71. package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
  72. package/dist/config/envSchema.js +1 -1
  73. package/dist/core/AiProfileService.d.ts +45 -3
  74. package/dist/core/AiProfileService.js +774 -63
  75. package/dist/core/AiProfileService.test.js +22 -2
  76. package/dist/core/AiProfiles.test.js +761 -2
  77. package/dist/core/AttachmentLinkService.js +57 -15
  78. package/dist/core/AuthIdentityService.d.ts +107 -0
  79. package/dist/core/AuthIdentityService.js +284 -0
  80. package/dist/core/AuthTokenService.d.ts +4 -0
  81. package/dist/core/AuthTokenService.js +34 -6
  82. package/dist/core/AuthTokenService.test.d.ts +1 -0
  83. package/dist/core/AuthTokenService.test.js +78 -0
  84. package/dist/core/EntitlementsPolicy.test.js +77 -15
  85. package/dist/core/GlobalSettingsService.js +115 -6
  86. package/dist/core/PlanEntitlementService.d.ts +30 -3
  87. package/dist/core/PlanEntitlementService.js +454 -93
  88. package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
  89. package/dist/core/PlanFeatureCatalog.test.js +84 -0
  90. package/dist/core/PlanVersionPolicy.test.js +54 -28
  91. package/dist/core/PlanningEntities.test.js +52 -0
  92. package/dist/core/SignupMonetizationSettings.test.js +241 -44
  93. package/dist/core/SyncReconciliationService.js +2 -65
  94. package/dist/core/SystemAdmin.test.js +244 -88
  95. package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
  96. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  97. package/dist/core/Taskforce.d.ts +228 -14
  98. package/dist/core/Taskforce.js +814 -201
  99. package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
  100. package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
  101. package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
  102. package/dist/core/WorkspaceLifecycleService.js +0 -5
  103. package/dist/core/WorkspacePermissions.test.js +25 -1
  104. package/dist/core/WorkspacePlanMode.test.js +128 -27
  105. package/dist/core/shared.d.ts +3 -1
  106. package/dist/core/shared.js +16 -1
  107. package/dist/core/types.d.ts +68 -2
  108. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +26 -1
  109. package/dist/hooks/sync/auth.js +3 -1
  110. package/dist/hooks/sync/bootstrap.js +2 -0
  111. package/dist/hooks/sync/controlPlane.d.ts +37 -0
  112. package/dist/hooks/sync/controlPlane.js +78 -0
  113. package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
  114. package/dist/hooks/sync/controlPlane.test.js +121 -0
  115. package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
  116. package/dist/hooks/sync/lifecyclePolicy.js +93 -0
  117. package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
  118. package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
  119. package/dist/hooks/sync/orchestratorShared.d.ts +10 -1
  120. package/dist/hooks/sync/orchestratorShared.js +68 -47
  121. package/dist/hooks/sync/orchestratorShared.test.js +50 -1
  122. package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
  123. package/dist/hooks/sync/pullLifecycle.js +24 -0
  124. package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
  125. package/dist/hooks/sync/pullLifecycle.test.js +80 -0
  126. package/dist/hooks/sync/recovery.d.ts +16 -2
  127. package/dist/hooks/sync/recovery.js +171 -53
  128. package/dist/hooks/sync/recovery.test.d.ts +1 -0
  129. package/dist/hooks/sync/recovery.test.js +292 -0
  130. package/dist/hooks/sync/transfers.d.ts +16 -3
  131. package/dist/hooks/sync/transfers.js +186 -65
  132. package/dist/hooks/sync/transfers.test.d.ts +1 -0
  133. package/dist/hooks/sync/transfers.test.js +396 -0
  134. package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
  135. package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
  136. package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
  137. package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
  138. package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
  139. package/dist/hooks/sync/useSyncStatusActions.js +88 -0
  140. package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
  141. package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
  142. package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
  143. package/dist/hooks/sync/useSyncStatusControls.js +60 -0
  144. package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
  145. package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
  146. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
  147. package/dist/hooks/useSyncOrchestrator.d.ts +16 -6
  148. package/dist/hooks/useSyncOrchestrator.js +424 -175
  149. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +487 -10
  150. package/dist/hooks/useTaskData.js +8 -3
  151. package/dist/hooks/useTaskData.test.js +45 -0
  152. package/dist/hooks/useTaskMutations.d.ts +2 -1
  153. package/dist/hooks/useTaskMutations.js +12 -1
  154. package/dist/hooks/useTaskMutations.test.js +30 -1
  155. package/dist/hooks/useTaskforce.d.ts +65 -4
  156. package/dist/hooks/useTaskforce.js +564 -243
  157. package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
  158. package/dist/hooks/useTaskforce.runtime-routing.test.js +152 -0
  159. package/dist/hooks/useTaskforce.sync-behavior.test.js +2454 -208
  160. package/dist/hooks/useWorkspaceSyncController.d.ts +4 -1
  161. package/dist/hooks/useWorkspaceSyncController.js +17 -2
  162. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  163. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
  164. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +316 -35
  165. package/dist/localization/locales/en-US.d.ts +0 -1
  166. package/dist/localization/locales/en-US.js +0 -1
  167. package/dist/localization/locales/es-419.js +0 -1
  168. package/dist/localization/locales/pt-BR.js +0 -1
  169. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  170. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  171. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  172. package/dist/mcp/collaborationRegistrar.js +71 -0
  173. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  174. package/dist/mcp/documentAssetRegistrar.js +346 -0
  175. package/dist/mcp/runtime.d.ts +2 -0
  176. package/dist/mcp/runtime.js +2433 -3534
  177. package/dist/mcp/runtime.test.js +440 -2
  178. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  179. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  180. package/dist/mcp/toolCatalog.d.ts +35 -0
  181. package/dist/mcp/toolCatalog.js +3 -0
  182. package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
  183. package/dist/migrations/taskSchemaMigrations.js +171 -61
  184. package/dist/migrations/taskSchemaMigrations.test.js +15 -0
  185. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  186. package/dist/resources/templates/workflows/collaborate.yaml +1 -1
  187. package/dist/resources/templates/workflows/evaluate.yaml +2 -2
  188. package/dist/resources/templates/workflows/plan.yaml +1 -1
  189. package/dist/resources/templates/workflows/review.yaml +2 -2
  190. package/dist/server/annotatedAttachmentsRoutes.test.js +3 -3
  191. package/dist/server/auth/providers/apple.d.ts +33 -0
  192. package/dist/server/auth/providers/apple.js +82 -0
  193. package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
  194. package/dist/server/auth/providers/appleClientSecret.js +65 -0
  195. package/dist/server/auth/providers/github.d.ts +26 -0
  196. package/dist/server/auth/providers/github.js +86 -0
  197. package/dist/server/auth/providers/google.d.ts +21 -0
  198. package/dist/server/auth/providers/google.js +56 -0
  199. package/dist/server/auth/providers/types.d.ts +21 -0
  200. package/dist/server/auth/providers/types.js +1 -0
  201. package/dist/server/auth.d.ts +2 -0
  202. package/dist/server/auth.js +6 -3
  203. package/dist/server/documentReviewRoutes.test.js +36 -3
  204. package/dist/server/index.cookieProxy.test.js +1 -0
  205. package/dist/server/index.d.ts +12 -0
  206. package/dist/server/index.js +120 -9
  207. package/dist/server/index.rateLimit.test.js +3 -0
  208. package/dist/server/index.test.js +106 -3
  209. package/dist/server/routes/admin.d.ts +4 -5
  210. package/dist/server/routes/admin.js +410 -80
  211. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  212. package/dist/server/routes/annotatedAttachments.js +1 -1
  213. package/dist/server/routes/auth.d.ts +16 -6
  214. package/dist/server/routes/auth.js +1015 -118
  215. package/dist/server/routes/authSupport.d.ts +30 -0
  216. package/dist/server/routes/authSupport.js +81 -0
  217. package/dist/server/routes/billing.d.ts +1 -1
  218. package/dist/server/routes/billing.js +276 -34
  219. package/dist/server/routes/billing.test.js +760 -52
  220. package/dist/server/routes/documentReviews.d.ts +1 -1
  221. package/dist/server/routes/documentReviews.js +1 -1
  222. package/dist/server/routes/documents.d.ts +5 -2
  223. package/dist/server/routes/documents.js +242 -74
  224. package/dist/server/routes/primitives.d.ts +11 -0
  225. package/dist/server/routes/primitives.js +73 -0
  226. package/dist/server/routes/resources.d.ts +1 -1
  227. package/dist/server/routes/resources.js +1 -1
  228. package/dist/server/routes/shared.d.ts +29 -3
  229. package/dist/server/routes/shared.js +69 -4
  230. package/dist/server/routes/sync.d.ts +1 -1
  231. package/dist/server/routes/sync.integration.test.js +437 -39
  232. package/dist/server/routes/sync.js +62 -606
  233. package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
  234. package/dist/server/routes/syncAuxRoutes.js +91 -0
  235. package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
  236. package/dist/server/routes/syncAuxRoutes.test.js +158 -0
  237. package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
  238. package/dist/server/routes/syncPullApplyRoutes.js +204 -0
  239. package/dist/server/routes/syncPushRoutes.d.ts +24 -0
  240. package/dist/server/routes/syncPushRoutes.js +212 -0
  241. package/dist/server/routes/syncRouteGuards.d.ts +36 -0
  242. package/dist/server/routes/syncRouteGuards.js +67 -0
  243. package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
  244. package/dist/server/routes/syncRouteGuards.test.js +94 -0
  245. package/dist/server/routes/syncRouteTypes.d.ts +13 -0
  246. package/dist/server/routes/syncRouteTypes.js +1 -0
  247. package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
  248. package/dist/server/routes/syncSnapshotRoutes.js +180 -0
  249. package/dist/server/routes/tasks.d.ts +1 -1
  250. package/dist/server/routes/tasks.js +44 -2
  251. package/dist/server/routes/workspaces.d.ts +1 -1
  252. package/dist/server/routes/workspaces.js +37 -6
  253. package/dist/server/routes.d.ts +2 -9
  254. package/dist/server/routes.js +136 -165
  255. package/dist/server/routes.test.js +2457 -162
  256. package/dist/services/workflowExportSnapshots.test.js +2 -0
  257. package/dist/shared/aiProfileSeatScope.d.ts +5 -0
  258. package/dist/shared/aiProfileSeatScope.js +21 -0
  259. package/dist/shared/passwordPolicy.d.ts +13 -0
  260. package/dist/shared/passwordPolicy.js +84 -0
  261. package/dist/shared/passwordPolicy.test.d.ts +1 -0
  262. package/dist/shared/passwordPolicy.test.js +34 -0
  263. package/dist/storage/workspaceAssetStore.d.ts +15 -3
  264. package/dist/storage/workspaceAssetStore.js +132 -44
  265. package/dist/storage/workspaceAssetStore.test.js +93 -6
  266. package/dist/styles/fonts.css +70 -0
  267. package/dist/sync/collaborationSyncPayload.d.ts +6 -1
  268. package/dist/sync/collaborationSyncPayload.js +17 -11
  269. package/dist/sync/collaborationSyncPayload.test.js +13 -1
  270. package/dist/sync/contentSyncState.d.ts +4 -8
  271. package/dist/sync/contentSyncState.js +33 -77
  272. package/dist/sync/syncApplyHandlers.d.ts +7 -0
  273. package/dist/sync/syncApplyHandlers.js +28 -13
  274. package/dist/sync/syncApplyHandlers.test.js +72 -3
  275. package/dist/sync/syncFieldSemantics.d.ts +12 -0
  276. package/dist/sync/syncFieldSemantics.js +55 -0
  277. package/dist/sync/syncResourceAdapters.d.ts +44 -0
  278. package/dist/sync/syncResourceAdapters.js +77 -0
  279. package/dist/sync/syncService.d.ts +6 -0
  280. package/dist/sync/syncService.js +60 -0
  281. package/dist/sync/syncService.test.js +71 -3
  282. package/dist/sync/taskSyncChangeKey.d.ts +2 -0
  283. package/dist/sync/taskSyncChangeKey.js +143 -0
  284. package/dist/sync/taskSyncPayload.js +3 -8
  285. package/dist/sync/workspacePullFeed.js +4 -3
  286. package/dist/sync/workspacePullFeed.test.js +43 -0
  287. package/dist/sync/workspaceRepair.js +5 -2
  288. package/dist/sync/workspaceSyncModel.d.ts +48 -0
  289. package/dist/sync/workspaceSyncModel.js +195 -31
  290. package/dist/sync/workspaceSyncModel.test.js +325 -12
  291. package/dist/sync/workspaceSyncState.d.ts +19 -0
  292. package/dist/sync/workspaceSyncState.js +3 -1
  293. package/dist/sync/workspaceSyncSurface.js +3 -8
  294. package/dist/types.d.ts +17 -0
  295. package/dist/ui/assets/AgentsModule-DrLawwNl.js +1 -0
  296. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-Z9_whf7F.js} +1 -1
  297. package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
  298. package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
  299. package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
  300. package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
  301. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +252 -0
  302. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +1 -0
  303. package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-9E6ParrY.js} +1 -1
  304. package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
  305. package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
  306. package/dist/ui/assets/PlansPage-CvfnMiWq.css +1 -0
  307. package/dist/ui/assets/PlansPage-DRXscYxH.js +1 -0
  308. package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
  309. package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
  310. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +9 -0
  311. package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BA7jcEpH.js} +1 -1
  312. package/dist/ui/assets/index-9eT8e0Lu.css +1 -0
  313. package/dist/ui/assets/index-C6k75jr8.js +6 -0
  314. package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-DuEd_65c.js} +1 -1
  315. package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
  316. package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
  317. package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
  318. package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  319. package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
  320. package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
  321. package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
  322. package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
  323. package/dist/ui/fonts/Noir_medium.otf +0 -0
  324. package/dist/ui/fonts/Noir_medium.woff2 +0 -0
  325. package/dist/ui/fonts/Noir_regular.otf +0 -0
  326. package/dist/ui/fonts/Noir_regular.woff2 +0 -0
  327. package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
  328. package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
  329. package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
  330. package/dist/ui/index.html +3 -3
  331. package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
  332. package/dist/utils/assignees.d.ts +2 -0
  333. package/dist/utils/assignees.js +2 -0
  334. package/dist/utils/avatarUpload.d.ts +27 -0
  335. package/dist/utils/avatarUpload.js +132 -0
  336. package/dist/utils/avatarUpload.test.d.ts +1 -0
  337. package/dist/utils/avatarUpload.test.js +40 -0
  338. package/dist/utils/bootstrapTrace.d.ts +7 -0
  339. package/dist/utils/bootstrapTrace.js +29 -0
  340. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  341. package/dist/utils/commercialLifecyclePresentation.js +199 -0
  342. package/dist/utils/contextAssetEvents.d.ts +2 -0
  343. package/dist/utils/syncEventDetails.d.ts +3 -0
  344. package/dist/utils/syncEventDetails.js +64 -0
  345. package/dist/utils/syncEventDetails.test.d.ts +1 -0
  346. package/dist/utils/syncEventDetails.test.js +23 -0
  347. package/dist/utils/syncEventPresentation.d.ts +15 -0
  348. package/dist/utils/syncEventPresentation.js +129 -0
  349. package/dist/utils/syncEventPresentation.test.d.ts +1 -0
  350. package/dist/utils/syncEventPresentation.test.js +64 -0
  351. package/dist/utils/syncStatusPresentation.d.ts +72 -0
  352. package/dist/utils/syncStatusPresentation.js +217 -0
  353. package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
  354. package/dist/utils/syncStatusPresentation.test.js +164 -0
  355. package/dist/utils/taskActivity.d.ts +2 -0
  356. package/dist/utils/taskActivity.js +228 -22
  357. package/dist/utils/taskActivity.test.js +119 -8
  358. package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
  359. package/dist/utils/workspaceSyncPresentation.js +68 -2
  360. package/dist/utils/workspaceSyncPresentation.test.js +36 -1
  361. package/package.json +10 -2
  362. package/scripts/check-cloud-mcp.mjs +83 -0
  363. package/scripts/export-sqlite-to-postgres.mjs +7 -9
  364. package/scripts/playwright-auth.sh +5 -1
  365. package/scripts/run-billing-performance-smoke.sh +24 -0
  366. package/scripts/run-e2e-realtime.sh +17 -3
  367. package/scripts/run-smoke.sh +17 -5
  368. package/scripts/run-soak.sh +17 -5
  369. package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  370. package/src/resources/templates/workflows/collaborate.yaml +1 -1
  371. package/src/resources/templates/workflows/evaluate.yaml +2 -2
  372. package/src/resources/templates/workflows/plan.yaml +1 -1
  373. package/src/resources/templates/workflows/review.yaml +2 -2
  374. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +0 -1
  375. package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
  376. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  377. package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
  378. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
  379. package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
  380. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
  381. package/dist/ui/assets/index-DXUdtH1B.js +0 -6
  382. package/dist/ui/assets/index-Ii0B0rTO.css +0 -1
  383. package/scripts/migrate-planning-model.ts +0 -233
@@ -6,9 +6,20 @@
6
6
  *
7
7
  * Routes registered here:
8
8
  * GET /api/taskforce/auth/runtime-config
9
+ * GET /api/taskforce/auth/providers
10
+ * GET /api/taskforce/auth/oauth/:provider/start
11
+ * GET /api/taskforce/auth/oauth/:provider/callback
12
+ * POST /api/taskforce/auth/oauth/apple/callback
13
+ * GET /api/taskforce/account/login-methods
14
+ * DELETE /api/taskforce/account/login-methods/:provider
15
+ * POST /api/taskforce/account/login-methods/password
9
16
  * GET /api/taskforce/auth/session
10
17
  * GET /api/taskforce/auth/admin-session
11
18
  * POST /api/taskforce/auth/profile
19
+ * POST /api/taskforce/auth/profile/avatar/init
20
+ * POST /api/taskforce/auth/profile/avatar/upload
21
+ * POST /api/taskforce/auth/profile/avatar/finalize
22
+ * POST /api/taskforce/auth/profile/avatar/discard
12
23
  * POST /api/taskforce/auth/login
13
24
  * POST /api/taskforce/auth/register
14
25
  * POST /api/taskforce/auth/verify-email/request
@@ -26,6 +37,7 @@
26
37
  * GET /api/taskforce/account/plan-preview
27
38
  * GET /api/taskforce/auth/workspace-members
28
39
  * GET /api/taskforce/workspace/assignee-options
40
+ * POST /api/taskforce/workspace/ai-profiles/avatar
29
41
  * GET /api/taskforce/admin/users
30
42
  * POST /api/taskforce/admin/users/invite
31
43
  * PATCH /api/taskforce/admin/users/:id/role
@@ -38,36 +50,26 @@
38
50
  import * as fs from 'fs';
39
51
  import * as path from 'path';
40
52
  import { randomUUID } from 'crypto';
41
- import { parseJsonBody } from '../routes.js';
53
+ import { parseJsonBody, parseFormBody } from './primitives.js';
54
+ import { buildGoogleAuthUrl, exchangeGoogleCode } from '../auth/providers/google.js';
55
+ import { buildAppleAuthUrl, exchangeAppleCode } from '../auth/providers/apple.js';
56
+ import { buildGithubAuthUrl, exchangeGithubCode } from '../auth/providers/github.js';
42
57
  import { hasValidSession, createClearSessionCookie, createSessionTokenCookie, resolveRequestAccess, resolveSessionCookieAttributesForRequest, } from '../auth.js';
43
- import { resolveEffectiveEmailConfig, sendEmail } from '../email.js';
58
+ import { sendEmail } from '../email.js';
44
59
  import { resolveDeploymentConfig } from '../../config/deployment.js';
45
60
  import { resolveRuntimeModeContract } from '../../shared/runtimeContract.js';
46
- import { requestHeader, resolveErrorStatusCode } from './shared.js';
61
+ import { getRouteTimingNow, logSlowBootstrapRoute, requestBootstrapTraceId, requestHeader, resolveErrorStatusCode, serializeJsonWithTiming, setServerTimingHeader } from './shared.js';
62
+ import { buildAuthFlowBehavior, buildSiteBehavior, getRouteEmailConfig, resolvePublicAuthBaseUrl, resolveRuntimeConfigBaseUrl, shouldExposeDevAuthTokens } from './authSupport.js';
47
63
  import { normalizeTaskDataRelativePath } from '../../storage/objectStorageClient.js';
48
64
  // === SECTION: Registration ===
49
65
  export function registerAuthRoutes(deps) {
50
- const { addRoute, core, context, requestWorkspaceId, requestUserId, ensureAppAccess, ensureAuthenticatedUser, ensureWorkspaceAdminRole, ensureCloudPlanData, requireCloudRuntime, auditAuthAction, resolveAdminRequestActor, shouldExposeDevAuthTokens, resolveRuntimeConfigBaseUrl, resolvePublicAuthBaseUrl, resolveEffectiveObjectStorageConfig, getObjectStorageClient, } = deps;
66
+ const { addRoute, core, context, requestWorkspaceId, requestUserId, resolveAuthenticatedUserId, ensureAppAccess, ensureAuthenticatedUser, ensureWorkspaceAdminRole, ensureCloudPlanData, requireCloudRuntime, auditAuthAction, resolveAdminRequestActor, resolveEffectiveObjectStorageConfig, getObjectStorageClient, } = deps;
51
67
  const normalizeTeamWorkspaceRole = (value) => {
52
68
  const role = String(value || '').trim().toLowerCase();
53
69
  if (role === 'owner' || role === 'admin' || role === 'member' || role === 'read-only')
54
70
  return role;
55
71
  return null;
56
72
  };
57
- const resolveBooleanSetting = (value, fallback) => {
58
- if (typeof value === 'boolean')
59
- return value;
60
- if (typeof value === 'number')
61
- return value !== 0;
62
- const normalized = String(value || '').trim().toLowerCase();
63
- if (!normalized)
64
- return fallback;
65
- if (['1', 'true', 'yes', 'on'].includes(normalized))
66
- return true;
67
- if (['0', 'false', 'no', 'off'].includes(normalized))
68
- return false;
69
- return fallback;
70
- };
71
73
  const resolveTeamPlanModeFromEntitlements = (payload) => {
72
74
  const state = String(payload?.state || '').trim().toLowerCase();
73
75
  const teamAllowed = payload?.features?.['collaboration.team_management']?.allowed === true;
@@ -116,7 +118,7 @@ export function registerAuthRoutes(deps) {
116
118
  return null;
117
119
  };
118
120
  const buildAccountProfileSummary = (req) => {
119
- const userId = requestUserId(req);
121
+ const userId = resolveAuthenticatedUserId(req);
120
122
  const workspaceId = requestWorkspaceId(req);
121
123
  const access = core.getAccountAccessStatus(userId);
122
124
  const resolvedAccess = core.resolveUserAccess(userId, workspaceId, 'member');
@@ -138,8 +140,12 @@ export function registerAuthRoutes(deps) {
138
140
  catch {
139
141
  billingRow = undefined;
140
142
  }
141
- const planId = String(access.entitlement?.planId || billingRow?.tier || '').trim() || null;
143
+ const allowBillingPlanFallback = access.gate === 'plan_selection_required' || access.gate === 'checkout_pending';
144
+ const planId = String(access.entitlement?.planId
145
+ || (allowBillingPlanFallback ? billingRow?.tier : '')
146
+ || '').trim() || null;
142
147
  const planVersionId = String(access.entitlement?.planVersionId || '').trim() || null;
148
+ const entitlementState = access.entitlement?.state || null;
143
149
  let planRow;
144
150
  if (planId) {
145
151
  try {
@@ -164,6 +170,7 @@ export function registerAuthRoutes(deps) {
164
170
  teamPlanMode = 'personal';
165
171
  }
166
172
  }
173
+ const aiProfileSeatUsage = core.countAiProfileUsage(workspaceId);
167
174
  const billingConflictCode = resolveBillingConflictCodeForUser(userId, access.gate);
168
175
  return {
169
176
  stripeStatus: billingRow?.stripe_status || null,
@@ -174,7 +181,7 @@ export function registerAuthRoutes(deps) {
174
181
  trialEnd: billingRow?.trial_end || null,
175
182
  currentPeriodEnd: billingRow?.current_period_end || null,
176
183
  effectiveUntil: billingRow?.effective_until || null,
177
- entitlementState: access.entitlement?.state || billingRow?.entitlement_state || null,
184
+ entitlementState,
178
185
  planVersionId,
179
186
  planId,
180
187
  planName: planRow?.display_name ? String(planRow.display_name) : planId,
@@ -186,46 +193,11 @@ export function registerAuthRoutes(deps) {
186
193
  workspaceRole,
187
194
  canManageWorkspace,
188
195
  teamPlanMode,
189
- teamManagementAllowed: canManageWorkspace && teamPlanMode === 'team'
196
+ teamManagementAllowed: canManageWorkspace && teamPlanMode === 'team',
197
+ aiProfileSeatUsage
190
198
  };
191
199
  };
192
200
  // === SECTION: Email delivery helpers ===
193
- const getEmailConfig = () => {
194
- if (context.authConfig?.runtimeMode !== 'cloud') {
195
- return resolveEffectiveEmailConfig(process.env, { provider: 'console' });
196
- }
197
- const globalSettings = core.getGlobalSettings();
198
- return resolveEffectiveEmailConfig(process.env, globalSettings.email || null);
199
- };
200
- const getAuthBehavior = () => {
201
- const globalSettings = core.getGlobalSettings();
202
- const authSettings = globalSettings.auth || {};
203
- const authRateLimitMaxRequestsRaw = Number(authSettings.authRateLimitMaxRequests);
204
- const authRateLimitWindowMsRaw = Number(authSettings.authRateLimitWindowMs);
205
- const onboardingPolicy = core.getOnboardingPolicy();
206
- return {
207
- allowSelfRegistration: resolveBooleanSetting(authSettings.allowSelfRegistration, true),
208
- requireVerifiedEmail: resolveBooleanSetting(authSettings.requireVerifiedEmail, true),
209
- defaultNewUserBetaAccess: resolveBooleanSetting(authSettings.defaultNewUserBetaAccess, false),
210
- authRateLimitEnabled: resolveBooleanSetting(authSettings.authRateLimitEnabled, true),
211
- authRateLimitMaxRequests: Number.isFinite(authRateLimitMaxRequestsRaw) && authRateLimitMaxRequestsRaw >= 1
212
- ? Math.floor(authRateLimitMaxRequestsRaw)
213
- : 10,
214
- authRateLimitWindowMs: Number.isFinite(authRateLimitWindowMsRaw) && authRateLimitWindowMsRaw >= 1000
215
- ? Math.floor(authRateLimitWindowMsRaw)
216
- : 15 * 60_000,
217
- onboardingPolicyMode: onboardingPolicy.mode,
218
- onboardingPlanVersionId: onboardingPolicy.planVersionId
219
- };
220
- };
221
- const getSiteBehavior = () => {
222
- const globalSettings = core.getGlobalSettings();
223
- const siteSettings = globalSettings.site || {};
224
- return {
225
- showRunTaskforceLocally: resolveBooleanSetting(siteSettings.showRunTaskforceLocally, true),
226
- pricingPageEnabled: resolveBooleanSetting(siteSettings.pricingPageEnabled, true)
227
- };
228
- };
229
201
  const buildPasswordSessionCookieForUser = (req, authConfig, userIdRaw, options) => {
230
202
  const userId = String(userIdRaw || '').trim();
231
203
  const fallbackRole = String(options?.fallbackRole || authConfig.defaultRole || 'member').trim() || 'member';
@@ -236,7 +208,7 @@ export function registerAuthRoutes(deps) {
236
208
  const access = resolvedWorkspaceId
237
209
  ? core.resolveUserAccess(userId, resolvedWorkspaceId, fallbackRole)
238
210
  : null;
239
- return createSessionTokenCookie(`pwd:${userId}`, authConfig, {
211
+ return createSessionTokenCookie(`usr:${userId}`, authConfig, {
240
212
  role: (access?.role || fallbackRole),
241
213
  workspaceId: access?.workspaceId || '',
242
214
  userId,
@@ -290,6 +262,29 @@ export function registerAuthRoutes(deps) {
290
262
  await deleteStoredAvatarObject(draft.storageKey);
291
263
  }
292
264
  };
265
+ const parseBinaryBody = async (req, maxBytes) => {
266
+ const safeMaxBytes = Number.isFinite(maxBytes) && maxBytes > 0
267
+ ? Math.floor(maxBytes)
268
+ : MAX_AVATAR_UPLOAD_BYTES;
269
+ return new Promise((resolve, reject) => {
270
+ const chunks = [];
271
+ let receivedBytes = 0;
272
+ req.on('data', (chunk) => {
273
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
274
+ receivedBytes += buffer.length;
275
+ if (receivedBytes > safeMaxBytes) {
276
+ req.pause();
277
+ reject(Object.assign(new Error(`Payload too large (max ${safeMaxBytes} bytes)`), { statusCode: 413 }));
278
+ return;
279
+ }
280
+ chunks.push(buffer);
281
+ });
282
+ req.on('end', () => {
283
+ resolve(Buffer.concat(chunks));
284
+ });
285
+ req.on('error', reject);
286
+ });
287
+ };
293
288
  const logAuthLink = (kind, email, token, req) => {
294
289
  const base = resolvePublicAuthBaseUrl(req);
295
290
  const urlPath = kind === 'verify-email'
@@ -314,7 +309,7 @@ export function registerAuthRoutes(deps) {
314
309
  to: email,
315
310
  subject,
316
311
  text
317
- }, getEmailConfig());
312
+ }, getRouteEmailConfig(core, context));
318
313
  if (result.ok) {
319
314
  console.info(`[Taskforce Auth Email] kind=${kind} provider=${result.provider} to=${email} messageId=${result.messageId || '-'}`);
320
315
  }
@@ -332,7 +327,7 @@ export function registerAuthRoutes(deps) {
332
327
  to: email,
333
328
  subject,
334
329
  text
335
- }, getEmailConfig());
330
+ }, getRouteEmailConfig(core, context));
336
331
  if (result.ok) {
337
332
  console.info(`[Taskforce Auth Email] kind=invite provider=${result.provider} to=${email} messageId=${result.messageId || '-'}`);
338
333
  }
@@ -368,7 +363,7 @@ export function registerAuthRoutes(deps) {
368
363
  }));
369
364
  });
370
365
  addRoute('GET', '/api/taskforce/public/site-config', async (_req, res) => {
371
- const siteBehavior = getSiteBehavior();
366
+ const siteBehavior = buildSiteBehavior(core);
372
367
  res.writeHead(200, {
373
368
  'Content-Type': 'application/json',
374
369
  'Cross-Origin-Resource-Policy': 'cross-origin'
@@ -379,7 +374,7 @@ export function registerAuthRoutes(deps) {
379
374
  }));
380
375
  });
381
376
  addRoute('GET', '/api/taskforce/public/site-config-script', async (_req, res) => {
382
- const siteBehavior = getSiteBehavior();
377
+ const siteBehavior = buildSiteBehavior(core);
383
378
  const payload = JSON.stringify(siteBehavior).replace(/</g, '\\u003c');
384
379
  res.writeHead(200, {
385
380
  'Content-Type': 'application/javascript; charset=utf-8',
@@ -388,11 +383,650 @@ export function registerAuthRoutes(deps) {
388
383
  });
389
384
  res.end(`window.__TASKFORCE_SITE_CONFIG__ = ${payload};`);
390
385
  });
386
+ // GET /api/taskforce/auth/providers - Public: list enabled OAuth login providers (no secrets)
387
+ addRoute('GET', '/api/taskforce/auth/providers', async (_req, res) => {
388
+ const providers = core.getGlobalSettings().oauthProviders || {};
389
+ const enabled = [];
390
+ if (providers.google?.enabled === true)
391
+ enabled.push('google');
392
+ if (providers.github?.enabled === true)
393
+ enabled.push('github');
394
+ if (providers.apple?.enabled === true)
395
+ enabled.push('apple');
396
+ res.writeHead(200, { 'Content-Type': 'application/json' });
397
+ res.end(JSON.stringify({ providers: enabled }));
398
+ });
399
+ // === SECTION: OAuth 2.0 / OpenID Connect ===
400
+ const OAUTH_RETURN_TO_PATTERN = /^\/[A-Za-z0-9_\-./?=&%]*$/;
401
+ /**
402
+ * Validates a `return_to` value and defaults to '/' on failure.
403
+ * Prevents open-redirect attacks by only allowing same-site relative paths.
404
+ */
405
+ const sanitizeReturnTo = (raw) => {
406
+ const value = String(raw || '').trim();
407
+ if (!value || !OAUTH_RETURN_TO_PATTERN.test(value))
408
+ return '/';
409
+ return value;
410
+ };
411
+ /**
412
+ * Provider-agnostic session cookie builder for OAuth-authenticated users.
413
+ * Resolves workspace/role and mints the signed session token.
414
+ */
415
+ const buildOAuthSessionCookie = (req, authConfig, userId, identityId) => {
416
+ const resolvedWorkspaceId = core.resolvePreferredUserWorkspaceId(userId, {
417
+ preferredWorkspaceId: null,
418
+ persistedWorkspaceId: null
419
+ });
420
+ const fallbackRole = String(authConfig.defaultRole || 'member').trim() || 'member';
421
+ const access = resolvedWorkspaceId
422
+ ? core.resolveUserAccess(userId, resolvedWorkspaceId, fallbackRole)
423
+ : null;
424
+ return createSessionTokenCookie(`usr:${userId}`, authConfig, {
425
+ role: (access?.role || fallbackRole),
426
+ workspaceId: access?.workspaceId || '',
427
+ userId,
428
+ identityId,
429
+ ...resolveSessionCookieAttributesForRequest(req, authConfig),
430
+ });
431
+ };
432
+ /**
433
+ * Handles the post-exchange OAuth profile: find or create the user,
434
+ * link the provider identity if needed, and return { userId, identityId }.
435
+ *
436
+ * Resolution order:
437
+ * 1. Existing identity matching (provider, providerSubject) → login.
438
+ * 2. Existing user with same email (provider must have verified it) → link + login.
439
+ * 3. Self-registration allowed → register new user → login.
440
+ * 4. Otherwise → reject.
441
+ */
442
+ const resolveOAuthUser = (profile) => {
443
+ // Case 1: known identity
444
+ const found = core.findUserByOAuthProvider(profile.provider, profile.providerSubject);
445
+ if (found) {
446
+ // Update last_login_at snapshot
447
+ try {
448
+ const db = core.getDatabaseAdapter();
449
+ const tenantId = core.getTenantId();
450
+ db.prepare(`
451
+ UPDATE user_auth_identities
452
+ SET last_login_at = ?, avatar_url_snapshot = COALESCE(?, avatar_url_snapshot),
453
+ updated_at = ?
454
+ WHERE tenant_id = ? AND id = ?
455
+ `).run(new Date().toISOString(), profile.avatarUrl ?? null, new Date().toISOString(), tenantId, found.identityId);
456
+ }
457
+ catch { /* non-fatal */ }
458
+ return { userId: found.userId, identityId: found.identityId, isNew: false };
459
+ }
460
+ // Case 2: email match (provider must have verified it)
461
+ if (profile.email && profile.emailVerified) {
462
+ const byEmail = core.findUserByEmail(profile.email);
463
+ if (byEmail) {
464
+ try {
465
+ const identityId = core.linkOAuthProviderToUser({
466
+ userId: byEmail.userId,
467
+ provider: profile.provider,
468
+ providerSubject: profile.providerSubject,
469
+ providerEmail: profile.email,
470
+ providerEmailVerified: profile.emailVerified,
471
+ displayNameSnapshot: profile.displayName,
472
+ avatarUrlSnapshot: profile.avatarUrl,
473
+ });
474
+ return { userId: byEmail.userId, identityId, isNew: false };
475
+ }
476
+ catch (e) {
477
+ if (e?.code !== 'IDENTITY_ALREADY_EXISTS')
478
+ throw e;
479
+ // Race condition: already linked — re-query
480
+ const refound = core.findUserByOAuthProvider(profile.provider, profile.providerSubject);
481
+ if (refound)
482
+ return { ...refound, isNew: false };
483
+ throw e;
484
+ }
485
+ }
486
+ }
487
+ // Case 3: self-registration
488
+ const authBehavior = buildAuthFlowBehavior(core);
489
+ if (!authBehavior.allowSelfRegistration) {
490
+ throw Object.assign(new Error('Self-registration is currently disabled.'), {
491
+ code: 'REGISTRATION_DISABLED',
492
+ statusCode: 403
493
+ });
494
+ }
495
+ const created = core.registerOAuthUser({
496
+ email: profile.email,
497
+ emailVerified: profile.emailVerified,
498
+ provider: profile.provider,
499
+ providerSubject: profile.providerSubject,
500
+ displayName: profile.displayName,
501
+ avatarUrl: profile.avatarUrl,
502
+ betaAccess: authBehavior.defaultNewUserBetaAccess,
503
+ });
504
+ return { userId: created.userId, identityId: created.identityId, isNew: true };
505
+ };
506
+ // GET /api/taskforce/auth/oauth/:provider/start
507
+ // Initiates the OAuth authorisation redirect for Google, GitHub, or Apple.
508
+ addRoute('GET', '/api/taskforce/auth/oauth/:provider/start', async (req, res, params) => {
509
+ if (!requireCloudRuntime(res, 'OAuth Login'))
510
+ return;
511
+ const authConfig = context.authConfig;
512
+ if (!authConfig?.enabled) {
513
+ res.writeHead(400, { 'Content-Type': 'application/json' });
514
+ res.end(JSON.stringify({ success: false, error: 'Auth is not enabled.' }));
515
+ return;
516
+ }
517
+ const provider = String(params.provider || '').toLowerCase();
518
+ if (provider !== 'google' && provider !== 'github' && provider !== 'apple') {
519
+ res.writeHead(404, { 'Content-Type': 'application/json' });
520
+ res.end(JSON.stringify({ success: false, error: 'Unknown OAuth provider.' }));
521
+ return;
522
+ }
523
+ const oauthProviders = core.getGlobalSettings().oauthProviders || {};
524
+ const providerConfig = oauthProviders[provider];
525
+ if (!providerConfig?.enabled) {
526
+ res.writeHead(400, { 'Content-Type': 'application/json' });
527
+ res.end(JSON.stringify({ success: false, error: `${provider} login is not enabled.` }));
528
+ return;
529
+ }
530
+ const queryString = (req.url || '').split('?')[1] || '';
531
+ const queryParams = new URLSearchParams(queryString);
532
+ const returnTo = sanitizeReturnTo(queryParams.get('return_to'));
533
+ const clientIp = requestHeader(req, 'x-forwarded-for', '') || requestHeader(req, 'x-real-ip', '') || null;
534
+ const rawInviteToken = String(queryParams.get('invite_token') || '').trim();
535
+ const rawFlowHint = String(queryParams.get('flow') || '').trim().toLowerCase();
536
+ // Determine flow mode: link > invite_claim > login
537
+ let flowMode = 'login';
538
+ let validatedInviteToken = null;
539
+ let linkUserId = null;
540
+ if (rawFlowHint === 'link') {
541
+ // Provider-link flow: require an authenticated session
542
+ if (!authConfig || !hasValidSession(req, authConfig)) {
543
+ res.writeHead(401, { 'Content-Type': 'application/json' });
544
+ res.end(JSON.stringify({ success: false, error: 'Sign in required to link a provider.', code: 'AUTH_REQUIRED' }));
545
+ return;
546
+ }
547
+ const access = resolveRequestAccess(req, authConfig);
548
+ linkUserId = access?.userId || null;
549
+ if (!linkUserId) {
550
+ res.writeHead(401, { 'Content-Type': 'application/json' });
551
+ res.end(JSON.stringify({ success: false, error: 'Unable to resolve authenticated user.', code: 'AUTH_REQUIRED' }));
552
+ return;
553
+ }
554
+ flowMode = 'link';
555
+ }
556
+ else if (rawInviteToken) {
557
+ // Invite-claim flow
558
+ const inviteDetails = core.inspectInviteAcceptance(rawInviteToken);
559
+ if (inviteDetails.state !== 'valid') {
560
+ res.writeHead(400, { 'Content-Type': 'application/json' });
561
+ res.end(JSON.stringify({ success: false, error: 'Invite token is invalid or expired.', code: 'INVITE_TOKEN_INVALID' }));
562
+ return;
563
+ }
564
+ if (inviteDetails.inviteeState !== 'pending_setup') {
565
+ res.writeHead(400, { 'Content-Type': 'application/json' });
566
+ res.end(JSON.stringify({ success: false, error: 'This invite has already been claimed.', code: 'INVITE_ALREADY_CLAIMED' }));
567
+ return;
568
+ }
569
+ flowMode = 'invite_claim';
570
+ validatedInviteToken = rawInviteToken;
571
+ }
572
+ const { state, codeChallenge, nonce } = core.createOAuthLoginState({
573
+ provider,
574
+ flowMode,
575
+ userId: linkUserId,
576
+ returnTo,
577
+ inviteToken: validatedInviteToken,
578
+ createdIp: clientIp,
579
+ });
580
+ const baseUrl = resolvePublicAuthBaseUrl(req);
581
+ const redirectUri = `${baseUrl}/api/taskforce/auth/oauth/${provider}/callback`;
582
+ let authUrl;
583
+ if (provider === 'google') {
584
+ const cfg = providerConfig;
585
+ if (!cfg.clientId) {
586
+ res.writeHead(500, { 'Content-Type': 'application/json' });
587
+ res.end(JSON.stringify({ success: false, error: 'Google OAuth is not fully configured.' }));
588
+ return;
589
+ }
590
+ authUrl = buildGoogleAuthUrl({
591
+ clientId: cfg.clientId,
592
+ redirectUri,
593
+ state,
594
+ codeChallenge,
595
+ });
596
+ }
597
+ else if (provider === 'github') {
598
+ const cfg = providerConfig;
599
+ if (!cfg.clientId) {
600
+ res.writeHead(500, { 'Content-Type': 'application/json' });
601
+ res.end(JSON.stringify({ success: false, error: 'GitHub OAuth is not fully configured.' }));
602
+ return;
603
+ }
604
+ authUrl = buildGithubAuthUrl({
605
+ clientId: cfg.clientId,
606
+ redirectUri,
607
+ state,
608
+ codeChallenge,
609
+ });
610
+ }
611
+ else {
612
+ // Apple
613
+ const cfg = providerConfig;
614
+ if (!cfg.servicesId) {
615
+ res.writeHead(500, { 'Content-Type': 'application/json' });
616
+ res.end(JSON.stringify({ success: false, error: 'Apple Sign In is not fully configured.' }));
617
+ return;
618
+ }
619
+ authUrl = buildAppleAuthUrl({
620
+ servicesId: cfg.servicesId,
621
+ redirectUri,
622
+ state,
623
+ nonce,
624
+ });
625
+ }
626
+ res.writeHead(302, { Location: authUrl });
627
+ res.end();
628
+ });
629
+ // GET /api/taskforce/auth/oauth/:provider/callback
630
+ // Handles the OAuth callback for Google and GitHub (GET redirect with `code` + `state`).
631
+ addRoute('GET', '/api/taskforce/auth/oauth/:provider/callback', async (req, res, params) => {
632
+ if (!requireCloudRuntime(res, 'OAuth Login'))
633
+ return;
634
+ const authConfig = context.authConfig;
635
+ if (!authConfig?.enabled) {
636
+ res.writeHead(400, { 'Content-Type': 'application/json' });
637
+ res.end(JSON.stringify({ success: false, error: 'Auth is not enabled.' }));
638
+ return;
639
+ }
640
+ const provider = String(params.provider || '').toLowerCase();
641
+ if (provider !== 'google' && provider !== 'github') {
642
+ res.writeHead(404, { 'Content-Type': 'application/json' });
643
+ res.end(JSON.stringify({ success: false, error: 'Unknown OAuth provider.' }));
644
+ return;
645
+ }
646
+ const queryString = (req.url || '').split('?')[1] || '';
647
+ const queryParams = new URLSearchParams(queryString);
648
+ const stateParam = String(queryParams.get('state') || '').trim();
649
+ const codeParam = String(queryParams.get('code') || '').trim();
650
+ const errorParam = String(queryParams.get('error') || '').trim();
651
+ const baseUrl = resolvePublicAuthBaseUrl(req);
652
+ const redirectUri = `${baseUrl}/api/taskforce/auth/oauth/${provider}/callback`;
653
+ if (errorParam) {
654
+ auditAuthAction(req, 'auth-oauth-callback-denied', { provider, error: errorParam });
655
+ res.writeHead(302, { Location: `/login?error=oauth_denied&provider=${encodeURIComponent(provider)}` });
656
+ res.end();
657
+ return;
658
+ }
659
+ if (!stateParam || !codeParam) {
660
+ res.writeHead(302, { Location: `/login?error=oauth_invalid&provider=${encodeURIComponent(provider)}` });
661
+ res.end();
662
+ return;
663
+ }
664
+ // Consume state (single-use, validates expiry)
665
+ const stateRecord = core.consumeOAuthLoginState(stateParam);
666
+ if (!stateRecord) {
667
+ auditAuthAction(req, 'auth-oauth-state-invalid', { provider, stateProvided: true });
668
+ res.writeHead(302, { Location: `/login?error=oauth_state_invalid&provider=${encodeURIComponent(provider)}` });
669
+ res.end();
670
+ return;
671
+ }
672
+ if (stateRecord.provider !== provider) {
673
+ auditAuthAction(req, 'auth-oauth-state-provider-mismatch', { provider, stateProvider: stateRecord.provider });
674
+ res.writeHead(302, { Location: `/login?error=oauth_state_invalid&provider=${encodeURIComponent(provider)}` });
675
+ res.end();
676
+ return;
677
+ }
678
+ const codeVerifier = stateRecord.pkceVerifier || '';
679
+ const returnTo = sanitizeReturnTo(stateRecord.returnTo);
680
+ const oauthProviders = core.getGlobalSettings().oauthProviders || {};
681
+ try {
682
+ let profile;
683
+ if (provider === 'google') {
684
+ const cfg = (oauthProviders.google || {});
685
+ if (!cfg.clientId || !cfg.clientSecret) {
686
+ throw Object.assign(new Error('Google OAuth is not configured.'), { statusCode: 500 });
687
+ }
688
+ profile = await exchangeGoogleCode({
689
+ code: codeParam,
690
+ codeVerifier,
691
+ redirectUri,
692
+ clientId: cfg.clientId,
693
+ clientSecret: cfg.clientSecret,
694
+ });
695
+ }
696
+ else {
697
+ const cfg = (oauthProviders.github || {});
698
+ if (!cfg.clientId || !cfg.clientSecret) {
699
+ throw Object.assign(new Error('GitHub OAuth is not configured.'), { statusCode: 500 });
700
+ }
701
+ profile = await exchangeGithubCode({
702
+ code: codeParam,
703
+ codeVerifier,
704
+ redirectUri,
705
+ clientId: cfg.clientId,
706
+ clientSecret: cfg.clientSecret,
707
+ });
708
+ }
709
+ let userId;
710
+ let identityId;
711
+ let isNew = false;
712
+ if (stateRecord.flowMode === 'link') {
713
+ if (!stateRecord.userId) {
714
+ throw Object.assign(new Error('User ID missing from OAuth link state.'), { code: 'AUTH_REQUIRED', statusCode: 401 });
715
+ }
716
+ identityId = core.linkOAuthProviderToUser({
717
+ userId: stateRecord.userId,
718
+ provider: profile.provider,
719
+ providerSubject: profile.providerSubject,
720
+ providerEmail: profile.email,
721
+ providerEmailVerified: profile.emailVerified,
722
+ displayNameSnapshot: profile.displayName,
723
+ avatarUrlSnapshot: profile.avatarUrl,
724
+ });
725
+ userId = stateRecord.userId;
726
+ isNew = false;
727
+ }
728
+ else if (stateRecord.flowMode === 'invite_claim') {
729
+ if (!stateRecord.inviteToken) {
730
+ throw Object.assign(new Error('Invite token missing from OAuth state.'), { code: 'INVITE_TOKEN_MISSING', statusCode: 400 });
731
+ }
732
+ const claimed = core.claimInviteWithOAuthProvider({
733
+ token: stateRecord.inviteToken,
734
+ provider: profile.provider,
735
+ providerSubject: profile.providerSubject,
736
+ providerEmail: profile.email,
737
+ providerEmailVerified: profile.emailVerified,
738
+ displayNameSnapshot: profile.displayName,
739
+ avatarUrlSnapshot: profile.avatarUrl,
740
+ });
741
+ userId = claimed.userId;
742
+ identityId = claimed.identityId;
743
+ isNew = true;
744
+ }
745
+ else {
746
+ ({ userId, identityId, isNew } = resolveOAuthUser(profile));
747
+ }
748
+ const cookie = buildOAuthSessionCookie(req, authConfig, userId, identityId);
749
+ res.setHeader('Set-Cookie', cookie);
750
+ auditAuthAction(req, 'auth-oauth-login-success', {
751
+ provider,
752
+ userId,
753
+ isNew,
754
+ flowMode: stateRecord.flowMode,
755
+ email: profile.email || null,
756
+ });
757
+ res.writeHead(302, { Location: returnTo });
758
+ res.end();
759
+ }
760
+ catch (err) {
761
+ const code = err?.code || 'OAUTH_ERROR';
762
+ auditAuthAction(req, 'auth-oauth-callback-error', { provider, code, message: String(err?.message || '') });
763
+ if (code === 'REGISTRATION_DISABLED') {
764
+ res.writeHead(302, { Location: `/login?error=registration_disabled&provider=${encodeURIComponent(provider)}` });
765
+ }
766
+ else if (code === 'IDENTITY_ALREADY_EXISTS') {
767
+ res.writeHead(302, { Location: `${returnTo}?error=provider_already_linked&provider=${encodeURIComponent(provider)}` });
768
+ }
769
+ else if (code === 'INVITE_TOKEN_EXPIRED' || code === 'INVITE_TOKEN_REVOKED' || code === 'INVITE_TOKEN_USED' || code === 'INVITE_TOKEN_INVALID') {
770
+ res.writeHead(302, { Location: `/login?error=invite_invalid&provider=${encodeURIComponent(provider)}` });
771
+ }
772
+ else {
773
+ res.writeHead(302, { Location: `/login?error=oauth_failed&provider=${encodeURIComponent(provider)}` });
774
+ }
775
+ res.end();
776
+ }
777
+ });
778
+ // POST /api/taskforce/auth/oauth/apple/callback
779
+ // Apple Sign In sends `code`, `state`, `id_token` and optional `user` JSON via form_post.
780
+ addRoute('POST', '/api/taskforce/auth/oauth/apple/callback', async (req, res) => {
781
+ if (!requireCloudRuntime(res, 'Apple Sign In'))
782
+ return;
783
+ const authConfig = context.authConfig;
784
+ if (!authConfig?.enabled) {
785
+ res.writeHead(400, { 'Content-Type': 'application/json' });
786
+ res.end(JSON.stringify({ success: false, error: 'Auth is not enabled.' }));
787
+ return;
788
+ }
789
+ let formData;
790
+ try {
791
+ formData = await parseFormBody(req);
792
+ }
793
+ catch {
794
+ res.writeHead(400, { 'Content-Type': 'application/json' });
795
+ res.end(JSON.stringify({ success: false, error: 'Invalid form body.' }));
796
+ return;
797
+ }
798
+ const stateParam = String(formData.state || '').trim();
799
+ const codeParam = String(formData.code || '').trim();
800
+ const errorParam = String(formData.error || '').trim();
801
+ const userJson = formData.user ? String(formData.user).trim() : null;
802
+ if (errorParam) {
803
+ auditAuthAction(req, 'auth-oauth-callback-denied', { provider: 'apple', error: errorParam });
804
+ res.writeHead(302, { Location: '/login?error=oauth_denied&provider=apple' });
805
+ res.end();
806
+ return;
807
+ }
808
+ if (!stateParam || !codeParam) {
809
+ res.writeHead(302, { Location: '/login?error=oauth_invalid&provider=apple' });
810
+ res.end();
811
+ return;
812
+ }
813
+ const stateRecord = core.consumeOAuthLoginState(stateParam);
814
+ if (!stateRecord || stateRecord.provider !== 'apple') {
815
+ auditAuthAction(req, 'auth-oauth-state-invalid', { provider: 'apple', stateProvided: true });
816
+ res.writeHead(302, { Location: '/login?error=oauth_state_invalid&provider=apple' });
817
+ res.end();
818
+ return;
819
+ }
820
+ const returnTo = sanitizeReturnTo(stateRecord.returnTo);
821
+ const oauthProviders = core.getGlobalSettings().oauthProviders || {};
822
+ const appleCfg = (oauthProviders.apple || {});
823
+ if (!appleCfg.servicesId || !appleCfg.teamId || !appleCfg.keyId || !appleCfg.privateKeyPem) {
824
+ auditAuthAction(req, 'auth-oauth-callback-error', { provider: 'apple', code: 'APPLE_NOT_CONFIGURED' });
825
+ res.writeHead(302, { Location: '/login?error=oauth_failed&provider=apple' });
826
+ res.end();
827
+ return;
828
+ }
829
+ const baseUrl = resolvePublicAuthBaseUrl(req);
830
+ const redirectUri = `${baseUrl}/api/taskforce/auth/oauth/apple/callback`;
831
+ try {
832
+ const profile = await exchangeAppleCode({
833
+ code: codeParam,
834
+ redirectUri,
835
+ servicesId: appleCfg.servicesId,
836
+ teamId: appleCfg.teamId,
837
+ keyId: appleCfg.keyId,
838
+ privateKeyPem: appleCfg.privateKeyPem,
839
+ userJson,
840
+ });
841
+ let userId;
842
+ let identityId;
843
+ let isNew = false;
844
+ if (stateRecord.flowMode === 'link') {
845
+ if (!stateRecord.userId) {
846
+ throw Object.assign(new Error('User ID missing from OAuth link state.'), { code: 'AUTH_REQUIRED', statusCode: 401 });
847
+ }
848
+ identityId = core.linkOAuthProviderToUser({
849
+ userId: stateRecord.userId,
850
+ provider: 'apple',
851
+ providerSubject: profile.providerSubject,
852
+ providerEmail: profile.email,
853
+ providerEmailVerified: profile.emailVerified,
854
+ displayNameSnapshot: profile.displayName,
855
+ avatarUrlSnapshot: profile.avatarUrl,
856
+ });
857
+ userId = stateRecord.userId;
858
+ isNew = false;
859
+ }
860
+ else if (stateRecord.flowMode === 'invite_claim') {
861
+ if (!stateRecord.inviteToken) {
862
+ throw Object.assign(new Error('Invite token missing from OAuth state.'), { code: 'INVITE_TOKEN_MISSING', statusCode: 400 });
863
+ }
864
+ const claimed = core.claimInviteWithOAuthProvider({
865
+ token: stateRecord.inviteToken,
866
+ provider: 'apple',
867
+ providerSubject: profile.providerSubject,
868
+ providerEmail: profile.email,
869
+ providerEmailVerified: profile.emailVerified,
870
+ displayNameSnapshot: profile.displayName,
871
+ avatarUrlSnapshot: profile.avatarUrl,
872
+ });
873
+ userId = claimed.userId;
874
+ identityId = claimed.identityId;
875
+ isNew = true;
876
+ }
877
+ else {
878
+ ({ userId, identityId, isNew } = resolveOAuthUser(profile));
879
+ }
880
+ const cookie = buildOAuthSessionCookie(req, authConfig, userId, identityId);
881
+ res.setHeader('Set-Cookie', cookie);
882
+ auditAuthAction(req, 'auth-oauth-login-success', {
883
+ provider: 'apple',
884
+ userId,
885
+ isNew,
886
+ flowMode: stateRecord.flowMode,
887
+ email: profile.email || null,
888
+ });
889
+ res.writeHead(302, { Location: returnTo });
890
+ res.end();
891
+ }
892
+ catch (err) {
893
+ const code = err?.code || 'OAUTH_ERROR';
894
+ auditAuthAction(req, 'auth-oauth-callback-error', { provider: 'apple', code, message: String(err?.message || '') });
895
+ if (code === 'REGISTRATION_DISABLED') {
896
+ res.writeHead(302, { Location: '/login?error=registration_disabled&provider=apple' });
897
+ }
898
+ else if (code === 'IDENTITY_ALREADY_EXISTS') {
899
+ res.writeHead(302, { Location: `${returnTo}?error=provider_already_linked&provider=apple` });
900
+ }
901
+ else if (code === 'INVITE_TOKEN_EXPIRED' || code === 'INVITE_TOKEN_REVOKED' || code === 'INVITE_TOKEN_USED' || code === 'INVITE_TOKEN_INVALID') {
902
+ res.writeHead(302, { Location: '/login?error=invite_invalid&provider=apple' });
903
+ }
904
+ else {
905
+ res.writeHead(302, { Location: '/login?error=oauth_failed&provider=apple' });
906
+ }
907
+ res.end();
908
+ }
909
+ });
910
+ // GET /api/taskforce/account/login-methods
911
+ // Returns the list of active login identities for the authenticated user.
912
+ addRoute('GET', '/api/taskforce/account/login-methods', async (req, res) => {
913
+ if (!requireCloudRuntime(res, 'Login methods'))
914
+ return;
915
+ const authConfig = context.authConfig;
916
+ if (!authConfig?.enabled || !hasValidSession(req, authConfig)) {
917
+ res.writeHead(401, { 'Content-Type': 'application/json' });
918
+ res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
919
+ return;
920
+ }
921
+ const access = resolveRequestAccess(req, authConfig);
922
+ const userId = access?.userId || '';
923
+ if (!userId) {
924
+ res.writeHead(401, { 'Content-Type': 'application/json' });
925
+ res.end(JSON.stringify({ success: false, error: 'Unable to resolve user.' }));
926
+ return;
927
+ }
928
+ const identities = core.listLoginMethodsForUser(userId);
929
+ res.writeHead(200, { 'Content-Type': 'application/json' });
930
+ res.end(JSON.stringify({
931
+ success: true,
932
+ methods: identities.map(id => ({
933
+ provider: id.provider,
934
+ providerEmail: id.providerEmail || null,
935
+ displayNameSnapshot: id.displayNameSnapshot || null,
936
+ avatarUrlSnapshot: id.avatarUrlSnapshot || null,
937
+ linkedAt: id.linkedAt || id.createdAt,
938
+ lastLoginAt: id.lastLoginAt || null,
939
+ }))
940
+ }));
941
+ });
942
+ // DELETE /api/taskforce/account/login-methods/:provider
943
+ // Unlinks a provider identity from the authenticated user.
944
+ // Rejects with 409 if it would be the last active login method.
945
+ addRoute('DELETE', '/api/taskforce/account/login-methods/:provider', async (req, res, params) => {
946
+ if (!requireCloudRuntime(res, 'Login methods'))
947
+ return;
948
+ const authConfig = context.authConfig;
949
+ if (!authConfig?.enabled || !hasValidSession(req, authConfig)) {
950
+ res.writeHead(401, { 'Content-Type': 'application/json' });
951
+ res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
952
+ return;
953
+ }
954
+ const access = resolveRequestAccess(req, authConfig);
955
+ const userId = access?.userId || '';
956
+ if (!userId) {
957
+ res.writeHead(401, { 'Content-Type': 'application/json' });
958
+ res.end(JSON.stringify({ success: false, error: 'Unable to resolve user.' }));
959
+ return;
960
+ }
961
+ const provider = String(params.provider || '').toLowerCase();
962
+ if (!provider) {
963
+ res.writeHead(400, { 'Content-Type': 'application/json' });
964
+ res.end(JSON.stringify({ success: false, error: 'Provider is required.' }));
965
+ return;
966
+ }
967
+ try {
968
+ core.unlinkLoginMethod(userId, provider);
969
+ auditAuthAction(req, 'account-login-method-unlinked', { userId, provider });
970
+ res.writeHead(200, { 'Content-Type': 'application/json' });
971
+ res.end(JSON.stringify({ success: true }));
972
+ }
973
+ catch (err) {
974
+ const code = err?.code || 'UNLINK_ERROR';
975
+ const status = code === 'LAST_IDENTITY_REQUIRED' ? 409 : resolveErrorStatusCode(err);
976
+ res.writeHead(status, { 'Content-Type': 'application/json' });
977
+ res.end(JSON.stringify({ success: false, error: err?.message || 'Unlink failed.', code }));
978
+ }
979
+ });
980
+ // POST /api/taskforce/account/login-methods/password
981
+ // Adds (or replaces) a password identity for the authenticated user.
982
+ // Social-only accounts use this to add a password login method.
983
+ addRoute('POST', '/api/taskforce/account/login-methods/password', async (req, res) => {
984
+ if (!requireCloudRuntime(res, 'Login methods'))
985
+ return;
986
+ const authConfig = context.authConfig;
987
+ if (!authConfig?.enabled || !hasValidSession(req, authConfig)) {
988
+ res.writeHead(401, { 'Content-Type': 'application/json' });
989
+ res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
990
+ return;
991
+ }
992
+ const access = resolveRequestAccess(req, authConfig);
993
+ const userId = access?.userId || '';
994
+ if (!userId) {
995
+ res.writeHead(401, { 'Content-Type': 'application/json' });
996
+ res.end(JSON.stringify({ success: false, error: 'Unable to resolve user.' }));
997
+ return;
998
+ }
999
+ let body;
1000
+ try {
1001
+ body = await parseJsonBody(req);
1002
+ }
1003
+ catch {
1004
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1005
+ res.end(JSON.stringify({ success: false, error: 'Invalid JSON body.' }));
1006
+ return;
1007
+ }
1008
+ const password = String(body?.password || '');
1009
+ try {
1010
+ core.addPasswordToAccount(userId, password);
1011
+ auditAuthAction(req, 'account-password-added', { userId });
1012
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1013
+ res.end(JSON.stringify({ success: true }));
1014
+ }
1015
+ catch (err) {
1016
+ const code = err?.code || 'ADD_PASSWORD_ERROR';
1017
+ const status = resolveErrorStatusCode(err);
1018
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1019
+ res.end(JSON.stringify({ success: false, error: err?.message || 'Failed to add password.', code }));
1020
+ }
1021
+ });
391
1022
  // GET /api/taskforce/auth/session - Return current auth/session state
392
1023
  addRoute('GET', '/api/taskforce/auth/session', async (req, res) => {
1024
+ const routeStartedAt = getRouteTimingNow();
1025
+ const traceId = requestBootstrapTraceId(req);
393
1026
  const authConfig = context.authConfig;
394
1027
  const access = authConfig ? resolveRequestAccess(req, authConfig) : null;
395
1028
  const authenticated = Boolean(authConfig && hasValidSession(req, authConfig));
1029
+ const authResolvedAt = getRouteTimingNow();
396
1030
  let role = access?.role || authConfig?.defaultRole || 'member';
397
1031
  let workspaceId = String(access?.workspaceId || authConfig?.defaultWorkspaceId || '').trim();
398
1032
  let userId = access?.userId || 'anonymous';
@@ -400,11 +1034,16 @@ export function registerAuthRoutes(deps) {
400
1034
  let displayName = null;
401
1035
  let avatarUrl = null;
402
1036
  let betaAccess = true;
1037
+ let betaAccessResolvedAt = authResolvedAt;
1038
+ let userAccessResolvedAt = authResolvedAt;
1039
+ let identityResolvedAt = authResolvedAt;
403
1040
  if (authenticated && access?.userId) {
404
1041
  // Keep the session endpoint fast on reload; avatar draft cleanup is best-effort housekeeping.
405
1042
  void cleanupStaleAvatarDrafts(access.userId).catch(() => { });
406
1043
  betaAccess = core.getUserBetaAccess(access.userId);
1044
+ betaAccessResolvedAt = getRouteTimingNow();
407
1045
  const resolved = core.resolveUserAccess(access.userId, workspaceId, role);
1046
+ userAccessResolvedAt = getRouteTimingNow();
408
1047
  if (resolved) {
409
1048
  role = resolved.role;
410
1049
  workspaceId = resolved.workspaceId;
@@ -419,9 +1058,9 @@ export function registerAuthRoutes(deps) {
419
1058
  email = identity?.email || null;
420
1059
  displayName = identity?.displayName || null;
421
1060
  avatarUrl = identity?.avatarUrl || null;
1061
+ identityResolvedAt = getRouteTimingNow();
422
1062
  }
423
- res.writeHead(200, { 'Content-Type': 'application/json' });
424
- res.end(JSON.stringify({
1063
+ const payload = {
425
1064
  authenticated,
426
1065
  userId,
427
1066
  workspaceId,
@@ -430,7 +1069,34 @@ export function registerAuthRoutes(deps) {
430
1069
  displayName,
431
1070
  avatarUrl,
432
1071
  betaAccess
433
- }));
1072
+ };
1073
+ const serialized = serializeJsonWithTiming(payload, identityResolvedAt);
1074
+ const totalDurationMs = getRouteTimingNow() - routeStartedAt;
1075
+ setServerTimingHeader(res, [
1076
+ { name: 'auth-session', durationMs: totalDurationMs },
1077
+ { name: 'auth-session-cookie', durationMs: authResolvedAt - routeStartedAt },
1078
+ { name: 'auth-session-beta-access', durationMs: betaAccessResolvedAt - authResolvedAt },
1079
+ { name: 'auth-session-user-access', durationMs: userAccessResolvedAt - betaAccessResolvedAt },
1080
+ { name: 'auth-session-identity', durationMs: identityResolvedAt - userAccessResolvedAt },
1081
+ { name: 'auth-session-json', durationMs: serialized.durationMs },
1082
+ ]);
1083
+ logSlowBootstrapRoute({
1084
+ route: '/api/taskforce/auth/session',
1085
+ workspaceId: workspaceId || access?.workspaceId || authConfig?.defaultWorkspaceId || 'default',
1086
+ traceId,
1087
+ durationMs: totalDurationMs,
1088
+ details: {
1089
+ authenticated,
1090
+ payloadBytes: serialized.byteLength,
1091
+ cookieMs: authResolvedAt - routeStartedAt,
1092
+ betaAccessMs: betaAccessResolvedAt - authResolvedAt,
1093
+ userAccessMs: userAccessResolvedAt - betaAccessResolvedAt,
1094
+ identityMs: identityResolvedAt - userAccessResolvedAt,
1095
+ serializeMs: serialized.durationMs,
1096
+ }
1097
+ });
1098
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1099
+ res.end(serialized.body);
434
1100
  });
435
1101
  // GET /api/taskforce/auth/admin-session - Return global admin auth/session state
436
1102
  addRoute('GET', '/api/taskforce/auth/admin-session', async (req, res) => {
@@ -586,6 +1252,63 @@ export function registerAuthRoutes(deps) {
586
1252
  path: `/api/taskforce/context/${encodeURIComponent(relativePath)}`
587
1253
  }));
588
1254
  });
1255
+ addRoute('POST', '/api/taskforce/auth/profile/avatar/upload', async (req, res) => {
1256
+ if (!requireCloudRuntime(res, 'Authentication'))
1257
+ return;
1258
+ const authConfig = context.authConfig;
1259
+ if (!authConfig) {
1260
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1261
+ res.end(JSON.stringify({ success: false, error: 'Auth context unavailable.' }));
1262
+ return;
1263
+ }
1264
+ const access = resolveRequestAccess(req, authConfig);
1265
+ const userId = String(access.userId || '').trim();
1266
+ if (!access.authenticated || !userId || userId === 'anonymous') {
1267
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1268
+ res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
1269
+ return;
1270
+ }
1271
+ const draftId = String(requestHeader(req, 'x-taskforce-avatar-draft-id', '') || '').trim();
1272
+ if (!draftId) {
1273
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1274
+ res.end(JSON.stringify({ success: false, error: 'Avatar draft id is required.', code: 'AVATAR_DRAFT_REQUIRED' }));
1275
+ return;
1276
+ }
1277
+ await cleanupStaleAvatarDrafts(userId);
1278
+ const draft = core.getUserAvatarUploadDraft(userId, draftId);
1279
+ if (!draft || draft.discardedAt) {
1280
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1281
+ res.end(JSON.stringify({ success: false, error: 'Avatar draft is invalid or expired.', code: 'AVATAR_DRAFT_INVALID' }));
1282
+ return;
1283
+ }
1284
+ const objectStorageClient = getObjectStorageClient();
1285
+ const effectiveObjectStorage = resolveEffectiveObjectStorageConfig();
1286
+ if (!objectStorageClient || effectiveObjectStorage.provider !== 'r2') {
1287
+ res.writeHead(501, { 'Content-Type': 'application/json' });
1288
+ res.end(JSON.stringify({ success: false, error: 'Avatar uploads require object storage.' }));
1289
+ return;
1290
+ }
1291
+ try {
1292
+ const body = await parseBinaryBody(req, MAX_AVATAR_UPLOAD_BYTES);
1293
+ if (body.length === 0) {
1294
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1295
+ res.end(JSON.stringify({ success: false, error: 'Avatar upload body is empty.', code: 'AVATAR_UPLOAD_EMPTY' }));
1296
+ return;
1297
+ }
1298
+ await objectStorageClient.putObject(draft.storageKey, body, draft.mimeType || 'application/octet-stream');
1299
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1300
+ res.end(JSON.stringify({ success: true, draftId, relativePath: draft.storageKey }));
1301
+ }
1302
+ catch (error) {
1303
+ const statusCode = resolveErrorStatusCode(error, 400);
1304
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
1305
+ res.end(JSON.stringify({
1306
+ success: false,
1307
+ error: String(error?.message || 'Failed to upload avatar.'),
1308
+ code: error?.code
1309
+ }));
1310
+ }
1311
+ });
589
1312
  addRoute('POST', '/api/taskforce/auth/profile/avatar/finalize', async (req, res) => {
590
1313
  if (!requireCloudRuntime(res, 'Authentication'))
591
1314
  return;
@@ -705,7 +1428,7 @@ export function registerAuthRoutes(deps) {
705
1428
  const workspaceId = workspaceIdCandidate || 'default';
706
1429
  // Password flow (preferred)
707
1430
  if (email && password) {
708
- const authBehavior = getAuthBehavior();
1431
+ const authBehavior = buildAuthFlowBehavior(core);
709
1432
  let authResult = core.authenticatePasswordUserDetailed({
710
1433
  email,
711
1434
  password,
@@ -757,10 +1480,11 @@ export function registerAuthRoutes(deps) {
757
1480
  };
758
1481
  }
759
1482
  }
760
- const cookie = createSessionTokenCookie(`pwd:${access.userId}`, authConfig, {
1483
+ const cookie = createSessionTokenCookie(`usr:${access.userId}`, authConfig, {
761
1484
  role: access.role,
762
1485
  workspaceId: access.workspaceId,
763
1486
  userId: access.userId,
1487
+ identityId: authResult.access?.identityId,
764
1488
  ...resolveSessionCookieAttributesForRequest(req, authConfig)
765
1489
  });
766
1490
  auditAuthAction(req, 'auth-login-success', {
@@ -802,7 +1526,7 @@ export function registerAuthRoutes(deps) {
802
1526
  const selectedPlanVersionId = typeof body?.planVersionId === 'string'
803
1527
  ? body.planVersionId.trim()
804
1528
  : '';
805
- const authBehavior = getAuthBehavior();
1529
+ const authBehavior = buildAuthFlowBehavior(core);
806
1530
  if (!authBehavior.allowSelfRegistration) {
807
1531
  res.writeHead(403, { 'Content-Type': 'application/json' });
808
1532
  res.end(JSON.stringify({ success: false, error: 'Self-registration is currently disabled.' }));
@@ -875,12 +1599,14 @@ export function registerAuthRoutes(deps) {
875
1599
  && authBehavior.requireVerifiedEmail
876
1600
  && email
877
1601
  && !core.isEmailVerified(email)) {
878
- let emailSent = true;
1602
+ let emailSent = false;
1603
+ let deliveryAttempted = false;
879
1604
  let emailError;
880
1605
  let verificationToken;
881
1606
  const verification = core.requestEmailVerification({ email });
882
1607
  verificationToken = verification.token || null;
883
1608
  if (verification.token) {
1609
+ deliveryAttempted = true;
884
1610
  const delivery = await deliverAuthEmail('verify-email', email, verification.token, req);
885
1611
  emailSent = delivery.ok;
886
1612
  emailError = delivery.error;
@@ -898,6 +1624,7 @@ export function registerAuthRoutes(deps) {
898
1624
  code: 'EMAIL_ALREADY_EXISTS_UNVERIFIED',
899
1625
  verificationRequired: true,
900
1626
  emailSent,
1627
+ deliveryAttempted,
901
1628
  emailError,
902
1629
  verificationToken: shouldExposeDevAuthTokens()
903
1630
  ? (verificationToken || null)
@@ -930,10 +1657,12 @@ export function registerAuthRoutes(deps) {
930
1657
  return;
931
1658
  }
932
1659
  try {
933
- let emailSent = true;
1660
+ let emailSent = false;
1661
+ let deliveryAttempted = false;
934
1662
  let emailError;
935
1663
  const verification = core.requestEmailVerification({ email });
936
1664
  if (verification.token) {
1665
+ deliveryAttempted = true;
937
1666
  const delivery = await deliverAuthEmail('verify-email', email, verification.token, req);
938
1667
  emailSent = delivery.ok;
939
1668
  emailError = delivery.error;
@@ -946,6 +1675,7 @@ export function registerAuthRoutes(deps) {
946
1675
  res.end(JSON.stringify({
947
1676
  success: true,
948
1677
  emailSent,
1678
+ deliveryAttempted,
949
1679
  emailError,
950
1680
  verificationToken: shouldExposeDevAuthTokens() ? (verification.token || null) : undefined
951
1681
  }));
@@ -1018,10 +1748,12 @@ export function registerAuthRoutes(deps) {
1018
1748
  return;
1019
1749
  }
1020
1750
  try {
1021
- let emailSent = true;
1751
+ let emailSent = false;
1752
+ let deliveryAttempted = false;
1022
1753
  let emailError;
1023
1754
  const reset = core.requestPasswordReset({ email });
1024
1755
  if (reset.token) {
1756
+ deliveryAttempted = true;
1025
1757
  const delivery = await deliverAuthEmail('password-reset', email, reset.token, req);
1026
1758
  emailSent = delivery.ok;
1027
1759
  emailError = delivery.error;
@@ -1034,6 +1766,7 @@ export function registerAuthRoutes(deps) {
1034
1766
  res.end(JSON.stringify({
1035
1767
  success: true,
1036
1768
  emailSent,
1769
+ deliveryAttempted,
1037
1770
  emailError,
1038
1771
  resetToken: shouldExposeDevAuthTokens() ? (reset.token || null) : undefined
1039
1772
  }));
@@ -1056,7 +1789,7 @@ export function registerAuthRoutes(deps) {
1056
1789
  const password = String(body?.password || '');
1057
1790
  if (!token || !password) {
1058
1791
  res.writeHead(400, { 'Content-Type': 'application/json' });
1059
- res.end(JSON.stringify({ success: false, error: 'Token and password are required.' }));
1792
+ res.end(JSON.stringify({ success: false, error: 'Token and password are required.', code: 'TOKEN_PASSWORD_REQUIRED' }));
1060
1793
  return;
1061
1794
  }
1062
1795
  try {
@@ -1081,7 +1814,7 @@ export function registerAuthRoutes(deps) {
1081
1814
  statusCode: resolveErrorStatusCode(error, 400)
1082
1815
  });
1083
1816
  res.writeHead(resolveErrorStatusCode(error, 400), { 'Content-Type': 'application/json' });
1084
- res.end(JSON.stringify({ success: false, error: String(error?.message || 'Unable to reset password') }));
1817
+ res.end(JSON.stringify({ success: false, error: String(error?.message || 'Unable to reset password'), code: error?.code }));
1085
1818
  }
1086
1819
  });
1087
1820
  // === SECTION: Invite flows ===
@@ -1105,6 +1838,18 @@ export function registerAuthRoutes(deps) {
1105
1838
  res.end(JSON.stringify({ success: false, state: details.state, error: 'Invalid or expired invite token.' }));
1106
1839
  return;
1107
1840
  }
1841
+ // Augment availableMethods with enabled OAuth providers for pending_setup invitees
1842
+ const oauthProviders = core.getGlobalSettings().oauthProviders || {};
1843
+ const enabledOAuthProviders = [];
1844
+ if (oauthProviders.google?.enabled)
1845
+ enabledOAuthProviders.push('google');
1846
+ if (oauthProviders.github?.enabled)
1847
+ enabledOAuthProviders.push('github');
1848
+ if (oauthProviders.apple?.enabled)
1849
+ enabledOAuthProviders.push('apple');
1850
+ const augmentedMethods = details.inviteeState === 'pending_setup'
1851
+ ? [...(details.availableMethods || []), ...enabledOAuthProviders.filter(p => !(details.availableMethods || []).includes(p))]
1852
+ : (details.availableMethods || []);
1108
1853
  auditAuthAction(req, 'auth-invite-opened', {
1109
1854
  userId: details.userId,
1110
1855
  email: details.email,
@@ -1118,6 +1863,8 @@ export function registerAuthRoutes(deps) {
1118
1863
  state: details.state,
1119
1864
  email: details.email,
1120
1865
  workspaceId: details.workspaceId,
1866
+ inviteeState: details.inviteeState,
1867
+ availableMethods: augmentedMethods,
1121
1868
  inviteeKind: details.inviteeKind,
1122
1869
  passwordRequired: details.passwordRequired === true
1123
1870
  }));
@@ -1147,10 +1894,11 @@ export function registerAuthRoutes(deps) {
1147
1894
  }
1148
1895
  try {
1149
1896
  const accepted = core.acceptInviteWithToken({ token, password });
1150
- const cookie = createSessionTokenCookie(`pwd:${accepted.userId}`, authConfig, {
1897
+ const cookie = createSessionTokenCookie(`usr:${accepted.userId}`, authConfig, {
1151
1898
  role: accepted.role,
1152
1899
  workspaceId: accepted.workspaceId,
1153
1900
  userId: accepted.userId,
1901
+ identityId: accepted.identityId,
1154
1902
  ...resolveSessionCookieAttributesForRequest(req, authConfig)
1155
1903
  });
1156
1904
  res.setHeader('Set-Cookie', cookie);
@@ -1203,11 +1951,12 @@ export function registerAuthRoutes(deps) {
1203
1951
  }
1204
1952
  try {
1205
1953
  const accepted = core.joinInviteWithAuthenticatedUser({ token, userId: access.userId });
1206
- const sessionId = String(access.sessionId || `pwd:${accepted.userId}`).trim() || `pwd:${accepted.userId}`;
1954
+ const sessionId = String(access.sessionId || `usr:${accepted.userId}`).trim() || `usr:${accepted.userId}`;
1207
1955
  const cookie = createSessionTokenCookie(sessionId, authConfig, {
1208
1956
  role: accepted.role,
1209
1957
  workspaceId: accepted.workspaceId,
1210
1958
  userId: accepted.userId,
1959
+ identityId: access.identityId || undefined,
1211
1960
  ...resolveSessionCookieAttributesForRequest(req, authConfig)
1212
1961
  });
1213
1962
  res.setHeader('Set-Cookie', cookie);
@@ -1276,7 +2025,7 @@ export function registerAuthRoutes(deps) {
1276
2025
  return;
1277
2026
  }
1278
2027
  try {
1279
- const userId = requestUserId(req);
2028
+ const userId = resolveAuthenticatedUserId(req);
1280
2029
  const payload = core.getAccountAccessStatus(userId);
1281
2030
  res.writeHead(200, { 'Content-Type': 'application/json' });
1282
2031
  res.end(JSON.stringify(payload));
@@ -1389,7 +2138,7 @@ export function registerAuthRoutes(deps) {
1389
2138
  seatUsage,
1390
2139
  upgradeHints: canInvite
1391
2140
  ? []
1392
- : ['Team management requires a TEAM plan in active/trialing/grace state.'],
2141
+ : ['Team management requires an active plan with collaboration access.'],
1393
2142
  supportLink: 'mailto:support@taskforcehq.ai'
1394
2143
  }));
1395
2144
  }
@@ -1399,6 +2148,31 @@ export function registerAuthRoutes(deps) {
1399
2148
  res.end(JSON.stringify({ error: String(error?.message || 'Unable to build plan preview'), code: error?.code, details: error?.details }));
1400
2149
  }
1401
2150
  });
2151
+ // GET /api/taskforce/account/ai-profile-seat-usage - Read cloud-authoritative AI seat usage for the current workspace.
2152
+ addRoute('GET', '/api/taskforce/account/ai-profile-seat-usage', async (req, res) => {
2153
+ if (!ensureCloudPlanData(req, res))
2154
+ return;
2155
+ if (!ensureAuthenticatedUser(req)) {
2156
+ res.writeHead(401, { 'Content-Type': 'application/json' });
2157
+ res.end(JSON.stringify({ error: 'Authentication required.' }));
2158
+ return;
2159
+ }
2160
+ try {
2161
+ const wid = requestWorkspaceId(req);
2162
+ const aiProfileSeatUsage = core.countAiProfileUsage(wid);
2163
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2164
+ res.end(JSON.stringify({ aiProfileSeatUsage }));
2165
+ }
2166
+ catch (error) {
2167
+ const statusCode = resolveErrorStatusCode(error, 400);
2168
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
2169
+ res.end(JSON.stringify({
2170
+ error: String(error?.message || 'Unable to build AI profile seat usage'),
2171
+ code: error?.code,
2172
+ details: error?.details
2173
+ }));
2174
+ }
2175
+ });
1402
2176
  // === SECTION: Workspace team management (workspace admin) ===
1403
2177
  // GET /api/taskforce/auth/workspace-members - Auth-backed workspace member list for local runtime identity hydration.
1404
2178
  addRoute('GET', '/api/taskforce/auth/workspace-members', async (req, res) => {
@@ -1423,12 +2197,17 @@ export function registerAuthRoutes(deps) {
1423
2197
  res.end(JSON.stringify({ error: 'Authentication required.' }));
1424
2198
  return;
1425
2199
  }
2200
+ const startedAt = getRouteTimingNow();
1426
2201
  const wid = requestWorkspaceId(req);
2202
+ const traceId = requestBootstrapTraceId(req);
2203
+ const adminRoleStartedAt = getRouteTimingNow();
1427
2204
  const includeProfileToken = context.authConfig?.enabled !== true || ensureWorkspaceAdminRole(req);
1428
- const workspaceUsers = core
1429
- .listWorkspaceUsers(wid)
1430
- .filter((user) => String(user.status || '').trim().toLowerCase() === 'active' && user.disabled !== true)
1431
- .map((user) => ({
2205
+ const adminRoleResolvedAt = getRouteTimingNow();
2206
+ const usersStartedAt = getRouteTimingNow();
2207
+ const activeWorkspaceUsers = core.listWorkspaceUsers(wid)
2208
+ .filter((user) => String(user.status || '').trim().toLowerCase() === 'active' && user.disabled !== true);
2209
+ const usersLoadedAt = getRouteTimingNow();
2210
+ const workspaceUsers = activeWorkspaceUsers.map((user) => ({
1432
2211
  value: user.userId,
1433
2212
  label: String(user.displayName || user.email || user.userId).trim() || user.userId,
1434
2213
  icon: 'User',
@@ -1437,11 +2216,15 @@ export function registerAuthRoutes(deps) {
1437
2216
  userId: user.userId,
1438
2217
  email: user.email,
1439
2218
  displayName: user.displayName ?? null,
2219
+ avatarUrl: user.avatarUrl ?? null,
1440
2220
  status: user.status,
1441
2221
  disabled: user.disabled === true
1442
2222
  }));
2223
+ const usersMappedAt = getRouteTimingNow();
2224
+ const aiProfileDiagnostics = { metadataBackfillCount: 0 };
2225
+ const aiProfilesStartedAt = getRouteTimingNow();
1443
2226
  const aiProfiles = core
1444
- .listAiProfiles(wid)
2227
+ .listAiProfiles(wid, { diagnostics: aiProfileDiagnostics })
1445
2228
  .map((profile) => ({
1446
2229
  value: profile.id,
1447
2230
  label: profile.name,
@@ -1449,19 +2232,90 @@ export function registerAuthRoutes(deps) {
1449
2232
  username: profile.username,
1450
2233
  icon: profile.icon,
1451
2234
  color: profile.color,
2235
+ avatarUrl: profile.avatarUrl ?? null,
2236
+ avatarSourceUrl: profile.avatarSourceUrl ?? null,
1452
2237
  kind: 'agent',
1453
2238
  description: profile.description ?? null,
1454
2239
  role: profile.role ?? null,
1455
2240
  provider: profile.provider ?? null,
1456
2241
  model: profile.model ?? null,
1457
2242
  surfaceType: profile.surfaceType ?? null,
2243
+ seatScope: profile.seatScope ?? null,
2244
+ archivedAt: profile.archivedAt ?? null,
1458
2245
  createdAt: profile.createdAt,
1459
2246
  updatedAt: profile.updatedAt,
1460
2247
  lastActiveAt: profile.lastActiveAt ?? null
1461
2248
  }));
2249
+ const aiProfilesLoadedAt = getRouteTimingNow();
2250
+ const seatUsageStartedAt = getRouteTimingNow();
2251
+ const aiProfileSeatUsage = core.countAiProfileUsage(wid);
2252
+ const seatUsageLoadedAt = getRouteTimingNow();
1462
2253
  const assignees = [...workspaceUsers, ...aiProfiles];
2254
+ const serializeStartedAt = getRouteTimingNow();
2255
+ const serialized = serializeJsonWithTiming({ assignees, aiProfileSeatUsage }, serializeStartedAt);
2256
+ const totalDurationMs = Math.max(0, getRouteTimingNow() - startedAt);
2257
+ setServerTimingHeader(res, [
2258
+ { name: 'assignee-options', durationMs: totalDurationMs },
2259
+ { name: 'assignee-options-admin-role', durationMs: adminRoleResolvedAt - adminRoleStartedAt },
2260
+ { name: 'assignee-options-users', durationMs: usersLoadedAt - usersStartedAt },
2261
+ { name: 'assignee-options-users-map', durationMs: usersMappedAt - usersLoadedAt },
2262
+ { name: 'assignee-options-ai-profiles', durationMs: aiProfilesLoadedAt - aiProfilesStartedAt },
2263
+ { name: 'assignee-options-seat-usage', durationMs: seatUsageLoadedAt - seatUsageStartedAt },
2264
+ { name: 'assignee-options-json', durationMs: serialized.durationMs },
2265
+ ]);
2266
+ logSlowBootstrapRoute({
2267
+ route: '/api/taskforce/workspace/assignee-options',
2268
+ workspaceId: wid,
2269
+ traceId,
2270
+ durationMs: totalDurationMs,
2271
+ details: {
2272
+ humanAssigneeCount: workspaceUsers.length,
2273
+ workspaceUserCount: activeWorkspaceUsers.length,
2274
+ aiProfileCount: aiProfiles.length,
2275
+ metadataBackfillCount: aiProfileDiagnostics.metadataBackfillCount,
2276
+ responseBytes: serialized.byteLength,
2277
+ adminRoleMs: Math.max(0, adminRoleResolvedAt - adminRoleStartedAt),
2278
+ listWorkspaceUsersMs: Math.max(0, usersLoadedAt - usersStartedAt),
2279
+ workspaceUsersMapMs: Math.max(0, usersMappedAt - usersLoadedAt),
2280
+ listAiProfilesMs: Math.max(0, aiProfilesLoadedAt - aiProfilesStartedAt),
2281
+ countAiProfileUsageMs: Math.max(0, seatUsageLoadedAt - seatUsageStartedAt),
2282
+ jsonSerializeMs: serialized.durationMs,
2283
+ },
2284
+ });
1463
2285
  res.writeHead(200, { 'Content-Type': 'application/json' });
1464
- res.end(JSON.stringify({ assignees }));
2286
+ res.end(serialized.body);
2287
+ });
2288
+ // POST /api/taskforce/workspace/ai-profiles/avatar - Assign or clear an AI profile avatar image.
2289
+ addRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', async (req, res) => {
2290
+ if (!ensureWorkspaceAdminRole(req)) {
2291
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2292
+ res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
2293
+ return;
2294
+ }
2295
+ const body = await parseJsonBody(req);
2296
+ const wid = requestWorkspaceId(req);
2297
+ const profileId = String(body?.profileId || '').trim();
2298
+ const avatarUrl = body?.avatarUrl === null ? null : String(body?.avatarUrl || '').trim();
2299
+ const avatarSourceUrl = body?.avatarSourceUrl === undefined
2300
+ ? undefined
2301
+ : body?.avatarSourceUrl === null
2302
+ ? null
2303
+ : String(body?.avatarSourceUrl || '').trim();
2304
+ if (!profileId) {
2305
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2306
+ res.end(JSON.stringify({ error: 'profileId is required.' }));
2307
+ return;
2308
+ }
2309
+ try {
2310
+ const profile = core.updateAiProfileAvatar({ workspaceId: wid, profileId, avatarUrl, avatarSourceUrl });
2311
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2312
+ res.end(JSON.stringify({ success: true, profile }));
2313
+ }
2314
+ catch (err) {
2315
+ const status = err?.statusCode ?? 500;
2316
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2317
+ res.end(JSON.stringify({ error: err?.message ?? 'Failed to update AI profile avatar.' }));
2318
+ }
1465
2319
  });
1466
2320
  // POST /api/taskforce/workspace/ai-profiles/merge - Merge a duplicate AI profile into another, reassigning all references.
1467
2321
  addRoute('POST', '/api/taskforce/workspace/ai-profiles/merge', async (req, res) => {
@@ -1490,6 +2344,41 @@ export function registerAuthRoutes(deps) {
1490
2344
  res.end(JSON.stringify({ error: err?.message ?? 'Merge failed.' }));
1491
2345
  }
1492
2346
  });
2347
+ // POST /api/taskforce/workspace/ai-profiles/archive - Remove an AI profile from the active roster without deleting its history.
2348
+ addRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', async (req, res) => {
2349
+ if (!ensureWorkspaceAdminRole(req)) {
2350
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2351
+ res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
2352
+ return;
2353
+ }
2354
+ const body = await parseJsonBody(req);
2355
+ const wid = requestWorkspaceId(req);
2356
+ const profileId = String(body.profileId || '').trim();
2357
+ const reason = String(body.reason || '').trim() || 'manual_archive';
2358
+ if (!profileId) {
2359
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2360
+ res.end(JSON.stringify({ error: 'profileId is required.' }));
2361
+ return;
2362
+ }
2363
+ try {
2364
+ const profile = core.archiveAiProfile({
2365
+ workspaceId: wid,
2366
+ profileId,
2367
+ archivedBy: requestUserId(req),
2368
+ reason
2369
+ });
2370
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2371
+ res.end(JSON.stringify({ profile }));
2372
+ }
2373
+ catch (err) {
2374
+ const status = err?.statusCode ?? 500;
2375
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2376
+ res.end(JSON.stringify({
2377
+ error: err?.message ?? 'Failed to archive AI profile.',
2378
+ details: err?.details ?? null
2379
+ }));
2380
+ }
2381
+ });
1493
2382
  // GET /api/taskforce/admin/users - List workspace users for team management.
1494
2383
  addRoute('GET', '/api/taskforce/admin/users', async (req, res) => {
1495
2384
  if (!ensureWorkspaceAdminRole(req)) {
@@ -1498,8 +2387,9 @@ export function registerAuthRoutes(deps) {
1498
2387
  return;
1499
2388
  }
1500
2389
  const wid = requestWorkspaceId(req);
2390
+ const actorUserId = resolveAuthenticatedUserId(req);
1501
2391
  const gate = core.evaluateFeatureAccess({
1502
- userId: requestUserId(req),
2392
+ userId: actorUserId,
1503
2393
  workspaceId: wid,
1504
2394
  featureKey: 'collaboration.team_management'
1505
2395
  });
@@ -1514,13 +2404,14 @@ export function registerAuthRoutes(deps) {
1514
2404
  });
1515
2405
  // POST /api/taskforce/admin/users/invite - Invite a user into a workspace.
1516
2406
  addRoute('POST', '/api/taskforce/admin/users/invite', async (req, res) => {
1517
- if (!ensureWorkspaceAdminRole(req)) {
2407
+ const body = await parseJsonBody(req);
2408
+ const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2409
+ if (!ensureWorkspaceAdminRole(req, wid)) {
1518
2410
  res.writeHead(403, { 'Content-Type': 'application/json' });
1519
2411
  res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
1520
2412
  return;
1521
2413
  }
1522
- const body = await parseJsonBody(req);
1523
- const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2414
+ const actorUserId = resolveAuthenticatedUserId(req);
1524
2415
  const email = String(body?.email || '').trim().toLowerCase();
1525
2416
  const role = String(body?.role || 'member').trim().toLowerCase();
1526
2417
  const permissionMode = String(body?.permissionMode || 'read-write').trim().toLowerCase();
@@ -1530,7 +2421,7 @@ export function registerAuthRoutes(deps) {
1530
2421
  return;
1531
2422
  }
1532
2423
  const inviteGate = core.evaluateFeatureAccess({
1533
- userId: requestUserId(req),
2424
+ userId: actorUserId,
1534
2425
  workspaceId: wid,
1535
2426
  featureKey: 'collaboration.invites'
1536
2427
  });
@@ -1553,8 +2444,8 @@ export function registerAuthRoutes(deps) {
1553
2444
  let inviteEmailError = null;
1554
2445
  if (inviteToken.token) {
1555
2446
  const workspace = core.getWorkspaceProfile(wid);
1556
- const actorIdentity = core.resolveUserIdentity(requestUserId(req));
1557
- const inviterEmailOrLabel = String(actorIdentity?.email || requestUserId(req) || 'Taskforce').trim();
2447
+ const actorIdentity = core.resolveUserIdentity(actorUserId);
2448
+ const inviterEmailOrLabel = String(actorIdentity?.email || actorUserId || 'Taskforce').trim();
1558
2449
  const delivery = await deliverInviteEmail(email, inviteToken.token, req, {
1559
2450
  workspaceName: String(workspace?.name || wid),
1560
2451
  inviterEmailOrLabel
@@ -1569,7 +2460,7 @@ export function registerAuthRoutes(deps) {
1569
2460
  role,
1570
2461
  permissionMode: role === 'member' ? (permissionMode === 'read-only' ? 'read-only' : 'read-write') : 'read-write',
1571
2462
  inviteEmailSent
1572
- });
2463
+ }, wid);
1573
2464
  res.writeHead(200, { 'Content-Type': 'application/json' });
1574
2465
  res.end(JSON.stringify({
1575
2466
  success: true,
@@ -1586,13 +2477,14 @@ export function registerAuthRoutes(deps) {
1586
2477
  });
1587
2478
  // PATCH /api/taskforce/admin/users/:id/role - Update workspace role for a user.
1588
2479
  addRoute('PATCH', '/api/taskforce/admin/users/:id/role', async (req, res, params) => {
1589
- if (!ensureWorkspaceAdminRole(req)) {
2480
+ const body = await parseJsonBody(req);
2481
+ const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2482
+ if (!ensureWorkspaceAdminRole(req, wid)) {
1590
2483
  res.writeHead(403, { 'Content-Type': 'application/json' });
1591
2484
  res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
1592
2485
  return;
1593
2486
  }
1594
- const body = await parseJsonBody(req);
1595
- const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2487
+ const actorUserId = resolveAuthenticatedUserId(req);
1596
2488
  const role = String(body?.role || '').trim().toLowerCase();
1597
2489
  if (role !== 'owner' && role !== 'admin' && role !== 'member') {
1598
2490
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -1600,7 +2492,7 @@ export function registerAuthRoutes(deps) {
1600
2492
  return;
1601
2493
  }
1602
2494
  const gate = core.evaluateFeatureAccess({
1603
- userId: requestUserId(req),
2495
+ userId: actorUserId,
1604
2496
  workspaceId: wid,
1605
2497
  featureKey: 'collaboration.team_management'
1606
2498
  });
@@ -1616,7 +2508,7 @@ export function registerAuthRoutes(deps) {
1616
2508
  res.end(JSON.stringify({ error: 'User membership not found.' }));
1617
2509
  return;
1618
2510
  }
1619
- auditAuthAction(req, 'update-workspace-user-role', { workspaceId: wid, targetUserId: params.id, role });
2511
+ auditAuthAction(req, 'update-workspace-user-role', { workspaceId: wid, targetUserId: params.id, role }, wid);
1620
2512
  res.writeHead(200, { 'Content-Type': 'application/json' });
1621
2513
  res.end(JSON.stringify({ success: true }));
1622
2514
  }
@@ -1628,13 +2520,14 @@ export function registerAuthRoutes(deps) {
1628
2520
  });
1629
2521
  // PATCH /api/taskforce/admin/users/:id/permission-mode - Update member permission mode.
1630
2522
  addRoute('PATCH', '/api/taskforce/admin/users/:id/permission-mode', async (req, res, params) => {
1631
- if (!ensureWorkspaceAdminRole(req)) {
2523
+ const body = await parseJsonBody(req);
2524
+ const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2525
+ if (!ensureWorkspaceAdminRole(req, wid)) {
1632
2526
  res.writeHead(403, { 'Content-Type': 'application/json' });
1633
2527
  res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
1634
2528
  return;
1635
2529
  }
1636
- const body = await parseJsonBody(req);
1637
- const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2530
+ const actorUserId = resolveAuthenticatedUserId(req);
1638
2531
  const mode = String(body?.mode || '').trim().toLowerCase();
1639
2532
  if (mode !== 'read-only' && mode !== 'read-write') {
1640
2533
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -1642,7 +2535,7 @@ export function registerAuthRoutes(deps) {
1642
2535
  return;
1643
2536
  }
1644
2537
  const gate = core.evaluateFeatureAccess({
1645
- userId: requestUserId(req),
2538
+ userId: actorUserId,
1646
2539
  workspaceId: wid,
1647
2540
  featureKey: 'collaboration.team_management'
1648
2541
  });
@@ -1658,7 +2551,7 @@ export function registerAuthRoutes(deps) {
1658
2551
  res.end(JSON.stringify({ error: 'User membership not found.' }));
1659
2552
  return;
1660
2553
  }
1661
- auditAuthAction(req, 'update-workspace-user-permission-mode', { workspaceId: wid, targetUserId: params.id, mode });
2554
+ auditAuthAction(req, 'update-workspace-user-permission-mode', { workspaceId: wid, targetUserId: params.id, mode }, wid);
1662
2555
  res.writeHead(200, { 'Content-Type': 'application/json' });
1663
2556
  res.end(JSON.stringify({ success: true }));
1664
2557
  }
@@ -1670,16 +2563,17 @@ export function registerAuthRoutes(deps) {
1670
2563
  });
1671
2564
  // PATCH /api/taskforce/admin/users/:id/disable - Disable or reactivate a workspace user.
1672
2565
  addRoute('PATCH', '/api/taskforce/admin/users/:id/disable', async (req, res, params) => {
1673
- if (!ensureWorkspaceAdminRole(req)) {
2566
+ const body = await parseJsonBody(req);
2567
+ const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2568
+ if (!ensureWorkspaceAdminRole(req, wid)) {
1674
2569
  res.writeHead(403, { 'Content-Type': 'application/json' });
1675
2570
  res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
1676
2571
  return;
1677
2572
  }
1678
- const body = await parseJsonBody(req);
1679
- const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2573
+ const actorUserId = resolveAuthenticatedUserId(req);
1680
2574
  const disabled = body?.disabled === true;
1681
2575
  const gate = core.evaluateFeatureAccess({
1682
- userId: requestUserId(req),
2576
+ userId: actorUserId,
1683
2577
  workspaceId: wid,
1684
2578
  featureKey: 'collaboration.team_management'
1685
2579
  });
@@ -1695,7 +2589,7 @@ export function registerAuthRoutes(deps) {
1695
2589
  res.end(JSON.stringify({ error: 'User membership not found.' }));
1696
2590
  return;
1697
2591
  }
1698
- auditAuthAction(req, 'set-workspace-user-disabled', { workspaceId: wid, targetUserId: params.id, disabled });
2592
+ auditAuthAction(req, 'set-workspace-user-disabled', { workspaceId: wid, targetUserId: params.id, disabled }, wid);
1699
2593
  res.writeHead(200, { 'Content-Type': 'application/json' });
1700
2594
  res.end(JSON.stringify({ success: true }));
1701
2595
  }
@@ -1707,15 +2601,16 @@ export function registerAuthRoutes(deps) {
1707
2601
  });
1708
2602
  // POST /api/taskforce/admin/users/:id/invite/revoke - Revoke invite acceptance token.
1709
2603
  addRoute('POST', '/api/taskforce/admin/users/:id/invite/revoke', async (req, res, params) => {
1710
- if (!ensureWorkspaceAdminRole(req)) {
2604
+ const body = await parseJsonBody(req);
2605
+ const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2606
+ if (!ensureWorkspaceAdminRole(req, wid)) {
1711
2607
  res.writeHead(403, { 'Content-Type': 'application/json' });
1712
2608
  res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
1713
2609
  return;
1714
2610
  }
1715
- const body = await parseJsonBody(req);
1716
- const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
2611
+ const actorUserId = resolveAuthenticatedUserId(req);
1717
2612
  const gate = core.evaluateFeatureAccess({
1718
- userId: requestUserId(req),
2613
+ userId: actorUserId,
1719
2614
  workspaceId: wid,
1720
2615
  featureKey: 'collaboration.team_management'
1721
2616
  });
@@ -1725,21 +2620,22 @@ export function registerAuthRoutes(deps) {
1725
2620
  return;
1726
2621
  }
1727
2622
  const revoked = core.revokeInviteAcceptanceForUser(params.id);
1728
- auditAuthAction(req, 'revoke-invite', { workspaceId: wid, targetUserId: params.id, revoked: revoked.revoked });
2623
+ auditAuthAction(req, 'revoke-invite', { workspaceId: wid, targetUserId: params.id, revoked: revoked.revoked }, wid);
1729
2624
  res.writeHead(200, { 'Content-Type': 'application/json' });
1730
2625
  res.end(JSON.stringify({ success: true, revoked: revoked.revoked }));
1731
2626
  });
1732
2627
  // DELETE /api/taskforce/admin/users/:id - Remove workspace membership.
1733
2628
  addRoute('DELETE', '/api/taskforce/admin/users/:id', async (req, res, params) => {
1734
- if (!ensureWorkspaceAdminRole(req)) {
2629
+ const reqUrl = new URL(req.url || '/api/taskforce/admin/users/:id', 'http://localhost');
2630
+ const wid = String(reqUrl.searchParams.get('workspaceId') || requestWorkspaceId(req)).trim() || 'default';
2631
+ if (!ensureWorkspaceAdminRole(req, wid)) {
1735
2632
  res.writeHead(403, { 'Content-Type': 'application/json' });
1736
2633
  res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
1737
2634
  return;
1738
2635
  }
1739
- const reqUrl = new URL(req.url || '/api/taskforce/admin/users/:id', 'http://localhost');
1740
- const wid = String(reqUrl.searchParams.get('workspaceId') || requestWorkspaceId(req)).trim() || 'default';
2636
+ const actorUserId = resolveAuthenticatedUserId(req);
1741
2637
  const gate = core.evaluateFeatureAccess({
1742
- userId: requestUserId(req),
2638
+ userId: actorUserId,
1743
2639
  workspaceId: wid,
1744
2640
  featureKey: 'collaboration.team_management'
1745
2641
  });
@@ -1755,7 +2651,7 @@ export function registerAuthRoutes(deps) {
1755
2651
  res.end(JSON.stringify({ error: 'User membership not found.' }));
1756
2652
  return;
1757
2653
  }
1758
- auditAuthAction(req, 'remove-workspace-user', { workspaceId: wid, targetUserId: params.id });
2654
+ auditAuthAction(req, 'remove-workspace-user', { workspaceId: wid, targetUserId: params.id }, wid);
1759
2655
  res.writeHead(200, { 'Content-Type': 'application/json' });
1760
2656
  res.end(JSON.stringify({ success: true }));
1761
2657
  }
@@ -1773,8 +2669,9 @@ export function registerAuthRoutes(deps) {
1773
2669
  return;
1774
2670
  }
1775
2671
  const wid = requestWorkspaceId(req);
2672
+ const actorUserId = resolveAuthenticatedUserId(req);
1776
2673
  const gate = core.evaluateFeatureAccess({
1777
- userId: requestUserId(req),
2674
+ userId: actorUserId,
1778
2675
  workspaceId: wid,
1779
2676
  featureKey: 'collaboration.team_management'
1780
2677
  });