@taskforcehq/taskforce 0.3.303 → 0.3.305

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 (237) hide show
  1. package/README.md +140 -247
  2. package/dist/Taskforce.module.css +102 -5
  3. package/dist/TaskforceCore.js +635 -238
  4. package/dist/TaskforceCore.test.js +2243 -495
  5. package/dist/annotatedAttachments/service.d.ts +1 -1
  6. package/dist/annotatedAttachments/types.d.ts +1 -1
  7. package/dist/compat/workspaceSyncCompat.js +6 -0
  8. package/dist/components/features/AgentsModule.js +1 -1
  9. package/dist/components/features/AgentsModule.test.js +1 -1
  10. package/dist/components/features/DocumentIndex.d.ts +1 -1
  11. package/dist/components/features/DocumentIndex.js +1 -1
  12. package/dist/components/features/DocumentViewer.d.ts +1 -1
  13. package/dist/components/features/DocumentViewer.js +32 -9
  14. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  15. package/dist/components/features/DocumentWorkspace.js +12 -155
  16. package/dist/components/features/DocumentWorkspace.test.js +193 -6
  17. package/dist/components/features/TaskSettings.js +87 -41
  18. package/dist/components/features/TaskSettings.test.js +180 -40
  19. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  20. package/dist/components/features/documentWorkspaceModel.js +57 -0
  21. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  22. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  23. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  24. package/dist/components/task/TaskActionHeader.js +3 -3
  25. package/dist/components/task/TaskActionHeader.test.js +8 -0
  26. package/dist/components/task/TaskCard.js +16 -1
  27. package/dist/components/task/TaskCard.test.js +31 -0
  28. package/dist/components/task/TaskForm.d.ts +9 -8
  29. package/dist/components/task/TaskForm.js +374 -219
  30. package/dist/components/task/TaskForm.test.js +482 -361
  31. package/dist/components/task/TaskKanban.d.ts +1 -1
  32. package/dist/components/task/TaskKanban.js +33 -161
  33. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  34. package/dist/components/task/TaskStatusActions.js +8 -3
  35. package/dist/components/task/TaskStatusActions.test.js +9 -0
  36. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  37. package/dist/components/task/taskKanbanDropRules.js +187 -0
  38. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  39. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  40. package/dist/components/ui/Modal.d.ts +1 -1
  41. package/dist/components/ui/Modal.js +1 -0
  42. package/dist/components/views/AppShellHeader.js +5 -1
  43. package/dist/components/views/AppShellHeader.test.js +4 -0
  44. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  45. package/dist/components/views/PlanComparisonPage.js +9 -10
  46. package/dist/components/views/PlanComparisonPage.test.js +55 -41
  47. package/dist/components/views/PlansPage.d.ts +5 -1
  48. package/dist/components/views/PlansPage.js +150 -81
  49. package/dist/components/views/PlansPage.test.js +367 -41
  50. package/dist/components/views/StandaloneLayout.js +135 -54
  51. package/dist/components/views/WidgetView.js +11 -2
  52. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  53. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
  54. package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
  55. package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
  56. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  57. package/dist/config/envSchema.js +1 -1
  58. package/dist/core/AiProfileService.d.ts +6 -1
  59. package/dist/core/AiProfileService.js +161 -16
  60. package/dist/core/AiProfileService.test.js +3 -1
  61. package/dist/core/AiProfiles.test.js +200 -0
  62. package/dist/core/AuthTokenService.test.d.ts +1 -0
  63. package/dist/core/AuthTokenService.test.js +78 -0
  64. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  65. package/dist/core/EntitlementsPolicy.test.js +75 -13
  66. package/dist/core/PlanEntitlementService.d.ts +9 -1
  67. package/dist/core/PlanEntitlementService.js +144 -19
  68. package/dist/core/PlanVersionPolicy.test.js +19 -26
  69. package/dist/core/SignupMonetizationSettings.test.js +75 -15
  70. package/dist/core/SystemAdmin.test.js +212 -56
  71. package/dist/core/TaskActivityService.d.ts +4 -1
  72. package/dist/core/TaskActivityService.js +45 -0
  73. package/dist/core/TaskAuditTimeline.test.js +52 -1
  74. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  75. package/dist/core/Taskforce.d.ts +18 -11
  76. package/dist/core/Taskforce.js +93 -12
  77. package/dist/core/WorkspacePlanMode.test.js +73 -8
  78. package/dist/core/shared.d.ts +2 -0
  79. package/dist/core/shared.js +11 -0
  80. package/dist/documentReviews/service.d.ts +1 -1
  81. package/dist/documentReviews/types.d.ts +1 -1
  82. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  83. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  84. package/dist/hooks/sync/auth.d.ts +37 -0
  85. package/dist/hooks/sync/auth.js +63 -0
  86. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  87. package/dist/hooks/sync/bootstrap.js +52 -0
  88. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  89. package/dist/hooks/sync/orchestratorShared.js +209 -0
  90. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  91. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  92. package/dist/hooks/sync/realtime.d.ts +25 -0
  93. package/dist/hooks/sync/realtime.js +60 -0
  94. package/dist/hooks/sync/recovery.d.ts +69 -0
  95. package/dist/hooks/sync/recovery.js +118 -0
  96. package/dist/hooks/sync/transfers.d.ts +123 -0
  97. package/dist/hooks/sync/transfers.js +435 -0
  98. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  99. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  100. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  101. package/dist/hooks/ui/useResourceExport.js +5 -2
  102. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  103. package/dist/hooks/useRealtimeSync.js +2 -1
  104. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  105. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  106. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  107. package/dist/hooks/useSyncOrchestrator.d.ts +12 -8
  108. package/dist/hooks/useSyncOrchestrator.js +388 -879
  109. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +641 -6
  110. package/dist/hooks/useTaskMutations.d.ts +2 -1
  111. package/dist/hooks/useTaskMutations.js +57 -7
  112. package/dist/hooks/useTaskMutations.test.js +176 -0
  113. package/dist/hooks/useTaskforce.d.ts +13 -4
  114. package/dist/hooks/useTaskforce.js +302 -817
  115. package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
  116. package/dist/hooks/useWorkspaceSyncController.d.ts +4 -1
  117. package/dist/hooks/useWorkspaceSyncController.js +33 -11
  118. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  119. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  120. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  121. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  122. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  123. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  124. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  125. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  126. package/dist/localization/locales/en-US.js +2 -2
  127. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  128. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  129. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  130. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  131. package/dist/mcp/clientIntegrations.d.ts +12 -1
  132. package/dist/mcp/clientIntegrations.js +161 -6
  133. package/dist/mcp/clientIntegrations.test.js +76 -4
  134. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  135. package/dist/mcp/collaborationRegistrar.js +71 -0
  136. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  137. package/dist/mcp/documentAssetRegistrar.js +346 -0
  138. package/dist/mcp/documentHelpers.d.ts +100 -0
  139. package/dist/mcp/documentHelpers.js +161 -0
  140. package/dist/mcp/runtime.js +2455 -3925
  141. package/dist/mcp/runtime.test.js +240 -8
  142. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  143. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  144. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  145. package/dist/mcp/taskAttachmentTypes.js +1 -0
  146. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  147. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  148. package/dist/mcp/toolCatalog.d.ts +35 -0
  149. package/dist/mcp/toolCatalog.js +3 -0
  150. package/dist/plugins/vite.js +1 -1
  151. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  152. package/dist/runtime/appGateDefinitions.js +1 -1
  153. package/dist/server/index.d.ts +1 -0
  154. package/dist/server/index.js +46 -3
  155. package/dist/server/index.test.js +12 -1
  156. package/dist/server/routes/admin.d.ts +1 -5
  157. package/dist/server/routes/admin.js +151 -80
  158. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  159. package/dist/server/routes/annotatedAttachments.js +1 -1
  160. package/dist/server/routes/auth.d.ts +1 -4
  161. package/dist/server/routes/auth.js +59 -64
  162. package/dist/server/routes/authSupport.d.ts +30 -0
  163. package/dist/server/routes/authSupport.js +81 -0
  164. package/dist/server/routes/billing.d.ts +1 -1
  165. package/dist/server/routes/billing.js +122 -255
  166. package/dist/server/routes/billing.test.js +257 -86
  167. package/dist/server/routes/documentReviews.d.ts +1 -1
  168. package/dist/server/routes/documentReviews.js +1 -1
  169. package/dist/server/routes/documents.d.ts +4 -2
  170. package/dist/server/routes/documents.js +73 -7
  171. package/dist/server/routes/primitives.d.ts +11 -0
  172. package/dist/server/routes/primitives.js +73 -0
  173. package/dist/server/routes/resources.d.ts +1 -1
  174. package/dist/server/routes/resources.js +1 -1
  175. package/dist/server/routes/shared.d.ts +31 -1
  176. package/dist/server/routes/shared.js +26 -0
  177. package/dist/server/routes/sync.d.ts +1 -1
  178. package/dist/server/routes/sync.integration.test.js +20 -0
  179. package/dist/server/routes/sync.js +111 -238
  180. package/dist/server/routes/tasks.d.ts +1 -1
  181. package/dist/server/routes/tasks.js +50 -8
  182. package/dist/server/routes/workspaces.d.ts +1 -1
  183. package/dist/server/routes/workspaces.js +1 -1
  184. package/dist/server/routes.d.ts +2 -9
  185. package/dist/server/routes.js +84 -181
  186. package/dist/server/routes.test.js +912 -32
  187. package/dist/shared/taskActor.d.ts +10 -0
  188. package/dist/shared/taskActor.js +1 -0
  189. package/dist/sync/collaborationSyncPayload.js +4 -0
  190. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  191. package/dist/sync/collaborationSyncState.d.ts +3 -1
  192. package/dist/sync/collaborationSyncState.js +19 -0
  193. package/dist/sync/contentSyncState.js +28 -17
  194. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  195. package/dist/sync/syncApplyHandlers.js +370 -8
  196. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  197. package/dist/sync/syncApplyHandlers.test.js +379 -0
  198. package/dist/sync/syncService.d.ts +9 -1
  199. package/dist/sync/syncService.js +44 -2
  200. package/dist/sync/syncService.test.js +62 -0
  201. package/dist/sync/workspacePullFeed.d.ts +1 -0
  202. package/dist/sync/workspacePullFeed.js +66 -59
  203. package/dist/sync/workspacePullFeed.test.js +72 -4
  204. package/dist/sync/workspaceSyncModel.d.ts +25 -1
  205. package/dist/sync/workspaceSyncModel.js +77 -3
  206. package/dist/sync/workspaceSyncModel.test.js +153 -0
  207. package/dist/sync/workspaceSyncState.d.ts +2 -0
  208. package/dist/sync/workspaceSyncState.js +1 -0
  209. package/dist/types.d.ts +2 -10
  210. package/dist/ui/assets/AgentsModule-N2MnQBZk.js +1 -0
  211. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-C_TmXZwA.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
  212. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
  213. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
  214. package/dist/ui/assets/{InitiativesModule-4-Rh2K2n.js → InitiativesModule-Dhm7M8e7.js} +1 -1
  215. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  216. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
  217. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
  218. package/dist/ui/assets/{WorkflowsModule-CGk9s3NJ.js → WorkflowsModule-BcS4afRn.js} +1 -1
  219. package/dist/ui/assets/index-C2-OXZV7.css +1 -0
  220. package/dist/ui/assets/index-DcSI5F86.js +6 -0
  221. package/dist/ui/assets/{vendor-icons-pWocEipT.js → vendor-icons-BSUaRwF8.js} +1 -1
  222. package/dist/ui/index.html +4 -4
  223. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  224. package/dist/utils/commercialLifecyclePresentation.js +196 -0
  225. package/dist/utils/taskActivity.js +4 -0
  226. package/dist/utils/taskActivity.test.js +25 -1
  227. package/package.json +10 -1
  228. package/scripts/check-cloud-mcp.mjs +83 -0
  229. package/scripts/playwright-auth.sh +9 -1
  230. package/scripts/run-billing-performance-smoke.sh +24 -0
  231. package/dist/ui/assets/AgentsModule-CpsTmrIW.js +0 -1
  232. package/dist/ui/assets/DocumentWorkspace-C3GyzrWm.js +0 -250
  233. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  234. package/dist/ui/assets/PlansPage-BP7AYOqr.js +0 -1
  235. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +0 -8
  236. package/dist/ui/assets/index-CLIMgR6g.js +0 -6
  237. package/dist/ui/assets/index-DneETxcu.css +0 -1
@@ -10,6 +10,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
10
10
  import * as mcpRuntime from '../mcp/runtime';
11
11
  import { createDatabaseAdapter } from '../storage/createDatabaseAdapter.js';
12
12
  import { ensureDocumentReferenceSchema, ensureTaskAssetsSchema, ensureWorkspaceAssetsSchema } from '../migrations/taskSchemaMigrations.js';
13
+ import { ObjectStorageClient } from '../storage/objectStorageClient.js';
13
14
  import { WorkspaceAssetStore } from '../storage/workspaceAssetStore.js';
14
15
  // Mock TaskforceCore
15
16
  vi.mock('../core/Taskforce', () => {
@@ -159,10 +160,13 @@ vi.mock('../core/Taskforce', () => {
159
160
  listDocumentReviewSessionsForSync = vi.fn().mockReturnValue([]);
160
161
  listDocumentReviewCommentsForSync = vi.fn().mockReturnValue([]);
161
162
  listAnnotatedAttachmentSessionsForSync = vi.fn().mockReturnValue([]);
163
+ listTaskEventsForSync = vi.fn().mockReturnValue([]);
164
+ listTaskEvents = vi.fn().mockReturnValue([]);
162
165
  getDocumentReviewSession = vi.fn().mockReturnValue(null);
163
166
  upsertAiProfileForSync = vi.fn();
164
167
  upsertDocumentReviewSessionForSync = vi.fn();
165
168
  upsertDocumentReviewCommentForSync = vi.fn();
169
+ upsertTaskEventForSync = vi.fn();
166
170
  deleteDocumentReviewSessionForSync = vi.fn().mockReturnValue(true);
167
171
  deleteDocumentReviewCommentForSync = vi.fn().mockReturnValue(true);
168
172
  deleteAnnotatedAttachmentSessionForSync = vi.fn().mockReturnValue(true);
@@ -434,6 +438,7 @@ vi.mock('../core/Taskforce', () => {
434
438
  ]);
435
439
  hasWorkspaceAdmin = vi.fn().mockReturnValue(true);
436
440
  registerPasswordUser = vi.fn();
441
+ isEmailVerified = vi.fn().mockReturnValue(false);
437
442
  requestEmailVerification = vi.fn().mockReturnValue({});
438
443
  verifyEmailWithToken = vi.fn().mockReturnValue(false);
439
444
  markEmailVerified = vi.fn().mockReturnValue(true);
@@ -679,6 +684,22 @@ describe('Server Routes', () => {
679
684
  res.__getBody = () => Buffer.concat(bodyChunks).toString('utf8');
680
685
  return res;
681
686
  };
687
+ const withDevAuthTokenExposure = async (value, run) => {
688
+ const previous = process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
689
+ if (value === undefined)
690
+ delete process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
691
+ else
692
+ process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = value;
693
+ try {
694
+ await run();
695
+ }
696
+ finally {
697
+ if (previous === undefined)
698
+ delete process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
699
+ else
700
+ process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = previous;
701
+ }
702
+ };
682
703
  it('GET /api/taskforce/tasks should return tasks', async () => {
683
704
  const mockTasks = { tasks: [], config: {} };
684
705
  core.listTasks.mockReturnValue(mockTasks);
@@ -892,6 +913,8 @@ describe('Server Routes', () => {
892
913
  auth: expect.objectContaining({
893
914
  aiProfileName: 'Automation Codex',
894
915
  aiProfileToken: 'tfp_automation_codex',
916
+ aiProfileIcon: 'Code2',
917
+ aiProfileColor: '#10a37f',
895
918
  aiProfileDescription: 'OpenAI Codex coding agent connected through MCP.',
896
919
  aiProfileRole: 'Coding Agent',
897
920
  aiProfileProvider: 'OpenAI',
@@ -1600,6 +1623,17 @@ describe('Server Routes', () => {
1600
1623
  });
1601
1624
  expect(core.updateUiState).not.toHaveBeenCalledWith('workspace-denied', expect.anything(), expect.anything());
1602
1625
  });
1626
+ it('POST /api/taskforce/ui-state should stamp debug response headers', async () => {
1627
+ const match = matchRoute('POST', '/api/taskforce/ui-state', routes);
1628
+ const req = createMockReq('POST', '/api/taskforce/ui-state', {
1629
+ stateKey: 'app',
1630
+ patch: { activeWorkspaceModule: 'tasks' }
1631
+ });
1632
+ const res = createMockRes();
1633
+ await match?.handler(req, res, {});
1634
+ expect(res.setHeader).toHaveBeenCalledWith('x-taskforce-ui-state-route', 'tasks');
1635
+ expect(res.setHeader).toHaveBeenCalledWith('x-taskforce-ui-state-request-id', expect.stringMatching(/^ui-/));
1636
+ });
1603
1637
  it('POST /api/taskforce/workspace-profile should persist workspace name and description', async () => {
1604
1638
  const match = matchRoute('POST', '/api/taskforce/workspace-profile', routes);
1605
1639
  const req = createMockReq('POST', '/api/taskforce/workspace-profile', { workspaceId: 'default', name: 'Acme Project', description: 'Customer onboarding' }, { 'x-taskforce-workspace-id': 'default' });
@@ -1896,6 +1930,20 @@ describe('Server Routes', () => {
1896
1930
  });
1897
1931
  });
1898
1932
  it('POST /api/taskforce/settings/mcp/test-connection should validate the token against workspace and scopes', async () => {
1933
+ const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
1934
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
1935
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
1936
+ jsonrpc: '2.0',
1937
+ id: 'mcp-connection-test',
1938
+ result: {
1939
+ protocolVersion: '2025-03-26',
1940
+ serverInfo: { name: 'taskforce-cloud' }
1941
+ }
1942
+ }), {
1943
+ status: 200,
1944
+ headers: { 'Content-Type': 'application/json' }
1945
+ }));
1946
+ vi.stubGlobal('fetch', fetchMock);
1899
1947
  core.inspectMcpAccessToken.mockReturnValue({
1900
1948
  tokenId: 'mcp-token-1',
1901
1949
  tokenPrefix: 'tfmcp_abc123',
@@ -1922,12 +1970,136 @@ describe('Server Routes', () => {
1922
1970
  });
1923
1971
  const res = createMockRes();
1924
1972
  res.req = req;
1925
- await match?.handler(req, res, {});
1973
+ try {
1974
+ await match?.handler(req, res, {});
1975
+ }
1976
+ finally {
1977
+ vi.unstubAllGlobals();
1978
+ if (previousCloudEnvironment === undefined)
1979
+ delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
1980
+ else
1981
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
1982
+ }
1926
1983
  expect(core.inspectMcpAccessToken).toHaveBeenCalledWith('tfmcp_abc123_secret');
1984
+ expect(fetchMock).toHaveBeenCalledWith('https://performance-mcp.taskforcehq.ai/mcp', expect.objectContaining({
1985
+ method: 'POST',
1986
+ headers: expect.objectContaining({
1987
+ Authorization: 'Bearer tfmcp_abc123_secret'
1988
+ })
1989
+ }));
1927
1990
  const payload = JSON.parse(res.end.mock.calls[0][0]);
1928
1991
  expect(payload.success).toBe(true);
1929
1992
  expect(payload.result.workspaceName).toBe('Workspace One');
1930
1993
  expect(payload.result.scopes).toEqual(['tasks:read', 'tasks:write']);
1994
+ expect(payload.result.endpoint).toBe('https://performance-mcp.taskforcehq.ai/mcp');
1995
+ expect(payload.result.serverName).toBe('taskforce-cloud');
1996
+ expect(payload.result.protocolVersion).toBe('2025-03-26');
1997
+ });
1998
+ it('POST /api/taskforce/settings/mcp/test-connection should return a clear error when the dedicated MCP host returns a gateway failure', async () => {
1999
+ const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
2000
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
2001
+ const fetchMock = vi.fn(async () => new Response('Bad gateway', {
2002
+ status: 502,
2003
+ headers: { 'Content-Type': 'text/plain' }
2004
+ }));
2005
+ vi.stubGlobal('fetch', fetchMock);
2006
+ core.inspectMcpAccessToken.mockReturnValue({
2007
+ tokenId: 'mcp-token-1',
2008
+ tokenPrefix: 'tfmcp_abc123',
2009
+ workspaceId: 'workspace-1',
2010
+ userId: 'user-1',
2011
+ name: 'Workspace MCP',
2012
+ scopes: ['tasks:read', 'tasks:write'],
2013
+ status: 'active'
2014
+ });
2015
+ const match = matchRoute('POST', '/api/taskforce/settings/mcp/test-connection', routes);
2016
+ const req = createMockReq('POST', '/api/taskforce/settings/mcp/test-connection', {
2017
+ token: 'tfmcp_abc123_secret',
2018
+ requiredScopes: ['tasks:read', 'tasks:write']
2019
+ }, {
2020
+ 'x-taskforce-runtime-mode': 'cloud',
2021
+ 'x-taskforce-user-id': 'user-1',
2022
+ 'x-taskforce-workspace-id': 'workspace-1'
2023
+ });
2024
+ const res = createMockRes();
2025
+ res.req = req;
2026
+ try {
2027
+ await match?.handler(req, res, {});
2028
+ }
2029
+ finally {
2030
+ vi.unstubAllGlobals();
2031
+ if (previousCloudEnvironment === undefined)
2032
+ delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
2033
+ else
2034
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
2035
+ }
2036
+ expect(res.writeHead).toHaveBeenCalledWith(502, { 'Content-Type': 'application/json' });
2037
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
2038
+ expect(payload.success).toBe(false);
2039
+ expect(payload.code).toBe('MCP_ENDPOINT_GATEWAY_ERROR');
2040
+ expect(payload.error).toContain('https://performance-mcp.taskforcehq.ai/mcp');
2041
+ expect(payload.details).toEqual(expect.objectContaining({
2042
+ endpoint: 'https://performance-mcp.taskforcehq.ai/mcp',
2043
+ upstreamStatus: 502
2044
+ }));
2045
+ });
2046
+ it('POST /api/taskforce/settings/mcp/test-connection should prefer the dedicated MCP hostname when cloudMcpBaseUrl points at the app host', async () => {
2047
+ const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
2048
+ const previousCloudMcpBaseUrl = process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL;
2049
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
2050
+ process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL = 'https://performance-app.taskforcehq.ai';
2051
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
2052
+ jsonrpc: '2.0',
2053
+ id: 'mcp-connection-test',
2054
+ result: {
2055
+ serverInfo: { name: 'taskforce-cloud' }
2056
+ }
2057
+ }), {
2058
+ status: 200,
2059
+ headers: { 'Content-Type': 'application/json' }
2060
+ }));
2061
+ vi.stubGlobal('fetch', fetchMock);
2062
+ core.inspectMcpAccessToken.mockReturnValue({
2063
+ tokenId: 'mcp-token-1',
2064
+ tokenPrefix: 'tfmcp_abc123',
2065
+ workspaceId: 'workspace-1',
2066
+ userId: 'user-1',
2067
+ name: 'Workspace MCP',
2068
+ scopes: ['tasks:read', 'tasks:write'],
2069
+ status: 'active'
2070
+ });
2071
+ core.getWorkspaceProfile.mockReturnValue({
2072
+ id: 'workspace-1',
2073
+ slug: 'workspace-1',
2074
+ name: 'Workspace One',
2075
+ description: null
2076
+ });
2077
+ const match = matchRoute('POST', '/api/taskforce/settings/mcp/test-connection', routes);
2078
+ const req = createMockReq('POST', '/api/taskforce/settings/mcp/test-connection', {
2079
+ token: 'tfmcp_abc123_secret',
2080
+ requiredScopes: ['tasks:read']
2081
+ }, {
2082
+ 'x-taskforce-runtime-mode': 'cloud',
2083
+ 'x-taskforce-user-id': 'user-1',
2084
+ 'x-taskforce-workspace-id': 'workspace-1'
2085
+ });
2086
+ const res = createMockRes();
2087
+ res.req = req;
2088
+ try {
2089
+ await match?.handler(req, res, {});
2090
+ }
2091
+ finally {
2092
+ vi.unstubAllGlobals();
2093
+ if (previousCloudEnvironment === undefined)
2094
+ delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
2095
+ else
2096
+ process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
2097
+ if (previousCloudMcpBaseUrl === undefined)
2098
+ delete process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL;
2099
+ else
2100
+ process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL = previousCloudMcpBaseUrl;
2101
+ }
2102
+ expect(fetchMock).toHaveBeenCalledWith('https://performance-mcp.taskforcehq.ai/mcp', expect.any(Object));
1931
2103
  });
1932
2104
  it('POST /api/taskforce/settings/mcp/test-connection should report missing scopes', async () => {
1933
2105
  core.inspectMcpAccessToken.mockReturnValue({
@@ -2212,6 +2384,63 @@ describe('Server Routes', () => {
2212
2384
  expect(fetchMock).not.toHaveBeenCalled();
2213
2385
  vi.unstubAllGlobals();
2214
2386
  });
2387
+ it('GET /api/taskforce/documents/:assetId/content should fall back to object storage when local canonical metadata points at a missing file', async () => {
2388
+ const fetchMock = vi.fn().mockResolvedValue({
2389
+ status: 200,
2390
+ ok: true,
2391
+ headers: new Headers({ 'content-type': 'text/markdown' }),
2392
+ arrayBuffer: async () => Buffer.from('# Recovered canonical doc\n').buffer,
2393
+ });
2394
+ vi.stubGlobal('fetch', fetchMock);
2395
+ const routesWithStorage = createRoutes(core, {
2396
+ objectStorage: {
2397
+ provider: 'r2',
2398
+ r2: {
2399
+ accountId: 'acct-1',
2400
+ bucket: 'taskforce-docs',
2401
+ endpoint: 'https://example.r2.cloudflarestorage.com',
2402
+ accessKeyId: 'access-key-1',
2403
+ secretAccessKey: 'secret-key-1',
2404
+ keyPrefix: 'taskforce',
2405
+ signedUploadTtlSeconds: 600,
2406
+ signedDownloadTtlSeconds: 300
2407
+ }
2408
+ }
2409
+ });
2410
+ const store = new WorkspaceAssetStore(core.getDatabaseAdapter(), core.getTenantId());
2411
+ store.upsertAsset({
2412
+ assetId: 'asset-doc-content-fallback-1',
2413
+ workspaceId: 'default',
2414
+ kind: 'document',
2415
+ logicalName: 'Recovered canonical doc',
2416
+ originalFilename: 'recovered-canonical-doc.md',
2417
+ mimeType: 'text/markdown',
2418
+ storageProvider: 'local',
2419
+ storageKey: 'workspaces/default/assets/documents/asset-doc-content-fallback-1-recovered-canonical-doc.md',
2420
+ contentSha256: 'sha-canonical-doc-fallback-1',
2421
+ sizeBytes: 25,
2422
+ documentId: 'doc-content-fallback-1',
2423
+ version: 1,
2424
+ isLatest: true,
2425
+ scanStatus: 'clean',
2426
+ scanEngine: null,
2427
+ scanDetails: null,
2428
+ deletedAt: null,
2429
+ purgeAfter: null
2430
+ });
2431
+ const req = createMockReq('GET', '/api/taskforce/documents/asset-doc-content-fallback-1/content');
2432
+ const res = createMockRes();
2433
+ await matchRoute('GET', '/api/taskforce/documents/asset-doc-content-fallback-1/content', routesWithStorage)?.handler(req, res, {
2434
+ assetId: 'asset-doc-content-fallback-1'
2435
+ });
2436
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
2437
+ 'Content-Type': 'text/markdown',
2438
+ 'Content-Disposition': 'inline'
2439
+ }));
2440
+ expect(res.end).toHaveBeenCalledWith(expect.any(Buffer));
2441
+ expect(fetchMock).toHaveBeenCalled();
2442
+ vi.unstubAllGlobals();
2443
+ });
2215
2444
  it('GET /api/taskforce/documents/:assetId/content should reject non-document assets', async () => {
2216
2445
  const routesWithStorage = createRoutes(core, {
2217
2446
  objectStorage: {
@@ -3467,10 +3696,106 @@ describe('Server Routes', () => {
3467
3696
  const res = createMockRes();
3468
3697
  await match?.handler(req, res, { assetId: 'asset-doc-attached-1' });
3469
3698
  expect(res.writeHead).toHaveBeenCalledWith(409, { 'Content-Type': 'application/json' });
3699
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
3700
+ expect(payload).toEqual(expect.objectContaining({
3701
+ error: 'Document is still attached to one or more tasks.',
3702
+ activeAttachmentCount: 1,
3703
+ requiresForceDetach: true
3704
+ }));
3470
3705
  expect(store.get('asset-doc-attached-1', 'default')?.deletedAt).toBeNull();
3471
3706
  fs.rmSync(dbRoot, { recursive: true, force: true });
3472
3707
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3473
3708
  });
3709
+ it('DELETE /api/taskforce/documents/:assetId should unlink attached canonical documents when forceDetach is true', async () => {
3710
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-delete-force-attached-'));
3711
+ const basePath = path.join(tmpRoot, '.taskforce');
3712
+ const documentPath = path.join(basePath, 'workspaces', 'default', 'assets', 'documents', 'asset-doc-force-delete-1-notes.md');
3713
+ fs.mkdirSync(path.dirname(documentPath), { recursive: true });
3714
+ fs.writeFileSync(documentPath, '# attached\n', 'utf8');
3715
+ core.getPaths.mockReturnValue({
3716
+ projectRoot: tmpRoot,
3717
+ basePath,
3718
+ });
3719
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
3720
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
3721
+ core.getTenantId = vi.fn().mockReturnValue('default');
3722
+ store.upsertAsset({
3723
+ assetId: 'asset-doc-force-delete-1',
3724
+ workspaceId: 'default',
3725
+ kind: 'document',
3726
+ originalFilename: 'notes.md',
3727
+ mimeType: 'text/markdown',
3728
+ storageProvider: 'local',
3729
+ storageKey: 'workspaces/default/assets/documents/asset-doc-force-delete-1-notes.md',
3730
+ contentSha256: 'sha-doc-force-delete-1',
3731
+ sizeBytes: 16,
3732
+ documentId: 'doc-force-delete-1',
3733
+ version: 1,
3734
+ updatedAt: '2026-03-18T14:00:00.000Z'
3735
+ });
3736
+ store.upsertLink({
3737
+ workspaceId: 'default',
3738
+ assetId: 'asset-doc-force-delete-1',
3739
+ taskId: 'task-keep',
3740
+ role: 'attachment'
3741
+ });
3742
+ core.getTask.mockImplementation((id) => id === 'task-keep'
3743
+ ? {
3744
+ id: 'task-keep',
3745
+ title: 'Task Keep',
3746
+ attachments: [{
3747
+ path: '/api/taskforce/documents/asset-doc-force-delete-1/content',
3748
+ fsPath: 'workspaces/default/assets/documents/asset-doc-force-delete-1-notes.md',
3749
+ caption: 'notes.md',
3750
+ assetId: 'asset-doc-force-delete-1'
3751
+ }, {
3752
+ path: '/api/taskforce/context/other-file.md',
3753
+ fsPath: 'workspaces/default/assets/files/other-file.md',
3754
+ caption: 'other-file.md'
3755
+ }]
3756
+ }
3757
+ : null);
3758
+ core.updateTask.mockImplementation((_id, patch) => ({
3759
+ id: 'task-keep',
3760
+ attachments: patch.attachments
3761
+ }));
3762
+ const routesWithDb = createRoutes(core);
3763
+ const match = matchRoute('DELETE', '/api/taskforce/documents/asset-doc-force-delete-1', routesWithDb);
3764
+ const req = createMockReq('DELETE', '/api/taskforce/documents/asset-doc-force-delete-1', {
3765
+ forceDetach: true
3766
+ }, {
3767
+ 'x-taskforce-workspace-id': 'default'
3768
+ });
3769
+ const res = createMockRes();
3770
+ await match?.handler(req, res, { assetId: 'asset-doc-force-delete-1' });
3771
+ expect(core.updateTask).toHaveBeenCalledWith('task-keep', {
3772
+ attachments: [{
3773
+ path: '/api/taskforce/context/other-file.md',
3774
+ fsPath: 'workspaces/default/assets/files/other-file.md',
3775
+ caption: 'other-file.md'
3776
+ }]
3777
+ }, 'default', expect.objectContaining({ actorRef: 'user', saveSource: 'manual' }));
3778
+ expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
3779
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
3780
+ expect(payload).toEqual(expect.objectContaining({
3781
+ success: true,
3782
+ softDeleted: true,
3783
+ detachedTaskCount: 1
3784
+ }));
3785
+ expect(store.get('asset-doc-force-delete-1', 'default')).toEqual(expect.objectContaining({
3786
+ deletedAt: expect.any(String),
3787
+ purgeAfter: expect.any(String)
3788
+ }));
3789
+ expect(store.listLinksForAsset('asset-doc-force-delete-1', 'default', { includeDeleted: true })).toEqual([
3790
+ expect.objectContaining({
3791
+ taskId: 'task-keep',
3792
+ role: 'attachment',
3793
+ deletedAt: expect.any(String)
3794
+ })
3795
+ ]);
3796
+ fs.rmSync(dbRoot, { recursive: true, force: true });
3797
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3798
+ });
3474
3799
  it('PATCH /api/taskforce/documents/:assetId should update the shared display name without changing storage identity', async () => {
3475
3800
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-rename-'));
3476
3801
  core.getPaths.mockReturnValue({
@@ -3708,7 +4033,85 @@ describe('Server Routes', () => {
3708
4033
  }));
3709
4034
  expect(res.setHeader).not.toHaveBeenCalledWith('Set-Cookie', expect.any(String));
3710
4035
  expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
3711
- expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"verificationRequired":true'));
4036
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4037
+ expect(payload).toEqual(expect.objectContaining({
4038
+ success: true,
4039
+ verificationRequired: true
4040
+ }));
4041
+ expect(payload.verificationToken).toBeUndefined();
4042
+ });
4043
+ it('POST /api/taskforce/auth/register should not depend on onboarding policy lookups', async () => {
4044
+ const authConfig = {
4045
+ runtimeMode: 'cloud',
4046
+ enabled: true,
4047
+ requiredForApi: true,
4048
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4049
+ defaultRole: 'member',
4050
+ defaultWorkspaceId: 'default',
4051
+ sessionCookieName: 'taskforce_session',
4052
+ sessionSecret: 'test-secret',
4053
+ sessionTtlSeconds: 3600
4054
+ };
4055
+ core.getOnboardingPolicy.mockImplementationOnce(() => {
4056
+ throw new Error('for SELECT DISTINCT, ORDER BY expressions must appear in select list');
4057
+ });
4058
+ core.getGlobalSettings.mockReturnValue({
4059
+ auth: {
4060
+ allowSelfRegistration: true,
4061
+ requireVerifiedEmail: false
4062
+ }
4063
+ });
4064
+ routes = createRoutes(core, { authConfig });
4065
+ core.registerPasswordUser.mockReturnValueOnce({
4066
+ userId: 'u-new',
4067
+ workspaceId: 'default',
4068
+ role: 'member',
4069
+ planSelectionRequired: false
4070
+ });
4071
+ const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
4072
+ const req = createMockReq('POST', '/api/taskforce/auth/register', {
4073
+ email: 'newuser@example.com',
4074
+ password: 'password123'
4075
+ });
4076
+ const res = createMockRes();
4077
+ await match?.handler(req, res, {});
4078
+ expect(core.registerPasswordUser).toHaveBeenCalledWith(expect.objectContaining({
4079
+ email: 'newuser@example.com'
4080
+ }));
4081
+ expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
4082
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
4083
+ });
4084
+ it('POST /api/taskforce/auth/register should only expose verificationToken when explicitly enabled', async () => {
4085
+ const authConfig = {
4086
+ runtimeMode: 'cloud',
4087
+ enabled: true,
4088
+ requiredForApi: true,
4089
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4090
+ defaultRole: 'member',
4091
+ defaultWorkspaceId: 'default',
4092
+ sessionCookieName: 'taskforce_session',
4093
+ sessionSecret: 'test-secret',
4094
+ sessionTtlSeconds: 3600
4095
+ };
4096
+ await withDevAuthTokenExposure('true', async () => {
4097
+ routes = createRoutes(core, { authConfig });
4098
+ core.registerPasswordUser.mockReturnValueOnce({
4099
+ userId: 'user-opt-in',
4100
+ workspaceId: null,
4101
+ role: 'member',
4102
+ planSelectionRequired: false
4103
+ });
4104
+ core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-opt-in' });
4105
+ const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
4106
+ const req = createMockReq('POST', '/api/taskforce/auth/register', {
4107
+ email: 'opt-in@example.com',
4108
+ password: 'password123'
4109
+ });
4110
+ const res = createMockRes();
4111
+ await match?.handler(req, res, {});
4112
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4113
+ expect(payload.verificationToken).toBe('verify-token-opt-in');
4114
+ });
3712
4115
  });
3713
4116
  it('POST /api/taskforce/auth/register should allow first-time registration without workspaceId', async () => {
3714
4117
  const authConfig = {
@@ -3821,7 +4224,51 @@ describe('Server Routes', () => {
3821
4224
  }));
3822
4225
  expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
3823
4226
  });
3824
- it('POST /api/taskforce/auth/register should return conflict when email exists', async () => {
4227
+ it('POST /api/taskforce/auth/register should route duplicate unverified email into verification recovery', async () => {
4228
+ const authConfig = {
4229
+ runtimeMode: 'cloud',
4230
+ enabled: true,
4231
+ requiredForApi: true,
4232
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4233
+ defaultRole: 'member',
4234
+ defaultWorkspaceId: 'default',
4235
+ sessionCookieName: 'taskforce_session',
4236
+ sessionSecret: 'test-secret',
4237
+ sessionTtlSeconds: 3600
4238
+ };
4239
+ await withDevAuthTokenExposure('true', async () => {
4240
+ routes = createRoutes(core, { authConfig });
4241
+ core.hasWorkspaceAdmin.mockReturnValueOnce(true);
4242
+ core.isEmailVerified.mockReturnValueOnce(false);
4243
+ core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-existing' });
4244
+ const error = new Error('Email is already registered');
4245
+ error.statusCode = 409;
4246
+ error.code = 'EMAIL_ALREADY_EXISTS';
4247
+ core.registerPasswordUser.mockImplementationOnce(() => {
4248
+ throw error;
4249
+ });
4250
+ const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
4251
+ const req = createMockReq('POST', '/api/taskforce/auth/register', {
4252
+ email: 'owner@example.com',
4253
+ password: 'password123',
4254
+ workspaceId: 'workspace-1'
4255
+ });
4256
+ const res = createMockRes();
4257
+ await match?.handler(req, res, {});
4258
+ expect(core.isEmailVerified).toHaveBeenCalledWith('owner@example.com');
4259
+ expect(core.requestEmailVerification).toHaveBeenCalledWith({ email: 'owner@example.com' });
4260
+ expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
4261
+ const responsePayload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4262
+ expect(responsePayload).toEqual(expect.objectContaining({
4263
+ success: false,
4264
+ code: 'EMAIL_ALREADY_EXISTS_UNVERIFIED',
4265
+ verificationRequired: true,
4266
+ emailSent: true,
4267
+ verificationToken: 'verify-token-existing'
4268
+ }));
4269
+ });
4270
+ });
4271
+ it('POST /api/taskforce/auth/register should return conflict when verified email exists', async () => {
3825
4272
  const authConfig = {
3826
4273
  runtimeMode: 'cloud',
3827
4274
  enabled: true,
@@ -3835,6 +4282,7 @@ describe('Server Routes', () => {
3835
4282
  };
3836
4283
  routes = createRoutes(core, { authConfig });
3837
4284
  core.hasWorkspaceAdmin.mockReturnValueOnce(true);
4285
+ core.isEmailVerified.mockReturnValueOnce(true);
3838
4286
  const error = new Error('Email is already registered');
3839
4287
  error.statusCode = 409;
3840
4288
  error.code = 'EMAIL_ALREADY_EXISTS';
@@ -3849,14 +4297,134 @@ describe('Server Routes', () => {
3849
4297
  });
3850
4298
  const res = createMockRes();
3851
4299
  await match?.handler(req, res, {});
3852
- expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
3853
- expect(res.end).toHaveBeenCalledWith(JSON.stringify({
3854
- success: false,
3855
- error: 'Email is already registered',
3856
- code: 'EMAIL_ALREADY_EXISTS'
3857
- }));
4300
+ expect(core.isEmailVerified).toHaveBeenCalledWith('owner@example.com');
4301
+ expect(core.requestEmailVerification).not.toHaveBeenCalled();
4302
+ expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
4303
+ expect(res.end).toHaveBeenCalledWith(JSON.stringify({
4304
+ success: false,
4305
+ error: 'Email is already registered',
4306
+ code: 'EMAIL_ALREADY_EXISTS'
4307
+ }));
4308
+ });
4309
+ it('POST /api/taskforce/auth/register should fail when the onboarding policy is misconfigured', async () => {
4310
+ const authConfig = {
4311
+ runtimeMode: 'cloud',
4312
+ enabled: true,
4313
+ requiredForApi: true,
4314
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4315
+ defaultRole: 'member',
4316
+ defaultWorkspaceId: 'default',
4317
+ sessionCookieName: 'taskforce_session',
4318
+ sessionSecret: 'test-secret',
4319
+ sessionTtlSeconds: 3600
4320
+ };
4321
+ routes = createRoutes(core, { authConfig });
4322
+ const error = new Error('Configured onboarding plan version "missing-v9" was not found.');
4323
+ error.statusCode = 500;
4324
+ error.code = 'SIGNUP_PLAN_VERSION_NOT_FOUND';
4325
+ core.registerPasswordUser.mockImplementationOnce(() => {
4326
+ throw error;
4327
+ });
4328
+ const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
4329
+ const req = createMockReq('POST', '/api/taskforce/auth/register', {
4330
+ email: 'misconfigured@example.com',
4331
+ password: 'password123'
4332
+ });
4333
+ const res = createMockRes();
4334
+ await match?.handler(req, res, {});
4335
+ expect(res.writeHead).toHaveBeenCalledWith(500, expect.any(Object));
4336
+ expect(res.end).toHaveBeenCalledWith(JSON.stringify({
4337
+ success: false,
4338
+ error: 'Configured onboarding plan version "missing-v9" was not found.',
4339
+ code: 'SIGNUP_PLAN_VERSION_NOT_FOUND'
4340
+ }));
4341
+ });
4342
+ it('POST /api/taskforce/auth/verify-email/request should report no-send cases without claiming delivery', async () => {
4343
+ const authConfig = {
4344
+ runtimeMode: 'cloud',
4345
+ enabled: true,
4346
+ requiredForApi: true,
4347
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4348
+ defaultRole: 'member',
4349
+ defaultWorkspaceId: 'default',
4350
+ sessionCookieName: 'taskforce_session',
4351
+ sessionSecret: 'test-secret',
4352
+ sessionTtlSeconds: 3600
4353
+ };
4354
+ routes = createRoutes(core, { authConfig });
4355
+ core.requestEmailVerification.mockReturnValueOnce({});
4356
+ const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
4357
+ const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'nobody@example.com' });
4358
+ const res = createMockRes();
4359
+ await match?.handler(req, res, {});
4360
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4361
+ expect(payload).toMatchObject({
4362
+ success: true,
4363
+ emailSent: false,
4364
+ deliveryAttempted: false
4365
+ });
4366
+ expect(payload.emailError).toBeUndefined();
4367
+ });
4368
+ it('POST /api/taskforce/auth/verify-email/request should only expose verificationToken when explicitly enabled', async () => {
4369
+ const authConfig = {
4370
+ runtimeMode: 'cloud',
4371
+ enabled: true,
4372
+ requiredForApi: true,
4373
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4374
+ defaultRole: 'member',
4375
+ defaultWorkspaceId: 'default',
4376
+ sessionCookieName: 'taskforce_session',
4377
+ sessionSecret: 'test-secret',
4378
+ sessionTtlSeconds: 3600
4379
+ };
4380
+ await withDevAuthTokenExposure(undefined, async () => {
4381
+ routes = createRoutes(core, { authConfig });
4382
+ core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-hidden' });
4383
+ const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
4384
+ const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'owner@example.com' });
4385
+ const res = createMockRes();
4386
+ await match?.handler(req, res, {});
4387
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4388
+ expect(payload.verificationToken).toBeUndefined();
4389
+ });
4390
+ await withDevAuthTokenExposure('true', async () => {
4391
+ routes = createRoutes(core, { authConfig });
4392
+ core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-visible' });
4393
+ const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
4394
+ const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'owner@example.com' });
4395
+ const res = createMockRes();
4396
+ await match?.handler(req, res, {});
4397
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4398
+ expect(payload.verificationToken).toBe('verify-token-visible');
4399
+ });
4400
+ });
4401
+ it('POST /api/taskforce/auth/password-reset/request should report no-send cases without claiming delivery', async () => {
4402
+ const authConfig = {
4403
+ runtimeMode: 'cloud',
4404
+ enabled: true,
4405
+ requiredForApi: true,
4406
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4407
+ defaultRole: 'member',
4408
+ defaultWorkspaceId: 'default',
4409
+ sessionCookieName: 'taskforce_session',
4410
+ sessionSecret: 'test-secret',
4411
+ sessionTtlSeconds: 3600
4412
+ };
4413
+ routes = createRoutes(core, { authConfig });
4414
+ core.requestPasswordReset.mockReturnValueOnce({});
4415
+ const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
4416
+ const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'nobody@example.com' });
4417
+ const res = createMockRes();
4418
+ await match?.handler(req, res, {});
4419
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4420
+ expect(payload).toMatchObject({
4421
+ success: true,
4422
+ emailSent: false,
4423
+ deliveryAttempted: false
4424
+ });
4425
+ expect(payload.emailError).toBeUndefined();
3858
4426
  });
3859
- it('POST /api/taskforce/auth/register should fail when the onboarding policy is misconfigured', async () => {
4427
+ it('POST /api/taskforce/auth/password-reset/request should only expose resetToken when explicitly enabled', async () => {
3860
4428
  const authConfig = {
3861
4429
  runtimeMode: 'cloud',
3862
4430
  enabled: true,
@@ -3868,26 +4436,26 @@ describe('Server Routes', () => {
3868
4436
  sessionSecret: 'test-secret',
3869
4437
  sessionTtlSeconds: 3600
3870
4438
  };
3871
- routes = createRoutes(core, { authConfig });
3872
- const error = new Error('Configured onboarding plan version "missing-v9" was not found.');
3873
- error.statusCode = 500;
3874
- error.code = 'SIGNUP_PLAN_VERSION_NOT_FOUND';
3875
- core.registerPasswordUser.mockImplementationOnce(() => {
3876
- throw error;
4439
+ await withDevAuthTokenExposure(undefined, async () => {
4440
+ routes = createRoutes(core, { authConfig });
4441
+ core.requestPasswordReset.mockReturnValueOnce({ token: 'reset-token-hidden' });
4442
+ const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
4443
+ const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'owner@example.com' });
4444
+ const res = createMockRes();
4445
+ await match?.handler(req, res, {});
4446
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4447
+ expect(payload.resetToken).toBeUndefined();
3877
4448
  });
3878
- const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
3879
- const req = createMockReq('POST', '/api/taskforce/auth/register', {
3880
- email: 'misconfigured@example.com',
3881
- password: 'password123'
4449
+ await withDevAuthTokenExposure('true', async () => {
4450
+ routes = createRoutes(core, { authConfig });
4451
+ core.requestPasswordReset.mockReturnValueOnce({ token: 'reset-token-visible' });
4452
+ const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
4453
+ const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'owner@example.com' });
4454
+ const res = createMockRes();
4455
+ await match?.handler(req, res, {});
4456
+ const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
4457
+ expect(payload.resetToken).toBe('reset-token-visible');
3882
4458
  });
3883
- const res = createMockRes();
3884
- await match?.handler(req, res, {});
3885
- expect(res.writeHead).toHaveBeenCalledWith(500, expect.any(Object));
3886
- expect(res.end).toHaveBeenCalledWith(JSON.stringify({
3887
- success: false,
3888
- error: 'Configured onboarding plan version "missing-v9" was not found.',
3889
- code: 'SIGNUP_PLAN_VERSION_NOT_FOUND'
3890
- }));
3891
4459
  });
3892
4460
  it('POST /api/taskforce/auth/verify-email/confirm should verify token', async () => {
3893
4461
  const authConfig = {
@@ -4344,6 +4912,40 @@ describe('Server Routes', () => {
4344
4912
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4345
4913
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
4346
4914
  });
4915
+ it('POST /api/taskforce/auth/login should not depend on onboarding policy lookups', async () => {
4916
+ const authConfig = {
4917
+ runtimeMode: 'cloud',
4918
+ enabled: true,
4919
+ requiredForApi: true,
4920
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4921
+ defaultRole: 'member',
4922
+ defaultWorkspaceId: 'default',
4923
+ sessionCookieName: 'taskforce_session',
4924
+ sessionSecret: 'test-secret',
4925
+ sessionTtlSeconds: 3600
4926
+ };
4927
+ core.getOnboardingPolicy.mockImplementationOnce(() => {
4928
+ throw new Error('for SELECT DISTINCT, ORDER BY expressions must appear in select list');
4929
+ });
4930
+ routes = createRoutes(core, { authConfig });
4931
+ core.authenticatePasswordUserDetailed.mockReturnValueOnce({
4932
+ ok: false,
4933
+ code: 'INVALID_CREDENTIALS'
4934
+ });
4935
+ const match = matchRoute('POST', '/api/taskforce/auth/login', routes);
4936
+ const req = createMockReq('POST', '/api/taskforce/auth/login', {
4937
+ email: 'user@example.com',
4938
+ password: 'wrongpass'
4939
+ });
4940
+ const res = createMockRes();
4941
+ await match?.handler(req, res, {});
4942
+ expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
4943
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('Invalid email/password.'));
4944
+ expect(core.authenticatePasswordUserDetailed).toHaveBeenCalledWith(expect.objectContaining({
4945
+ email: 'user@example.com',
4946
+ requireVerifiedEmail: true
4947
+ }));
4948
+ });
4347
4949
  it('GET /api/taskforce/workspaces should list authenticated user workspaces', async () => {
4348
4950
  const authConfig = {
4349
4951
  runtimeMode: 'cloud',
@@ -5663,6 +6265,109 @@ describe('Server Routes', () => {
5663
6265
  ]));
5664
6266
  fs.rmSync(dbRoot, { recursive: true, force: true });
5665
6267
  });
6268
+ it('GET /api/taskforce/sync/workspace/pull should hydrate canonical markdown documents from object storage', async () => {
6269
+ const authConfig = {
6270
+ runtimeMode: 'cloud',
6271
+ enabled: true,
6272
+ requiredForApi: true,
6273
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
6274
+ defaultRole: 'member',
6275
+ defaultWorkspaceId: 'default',
6276
+ sessionCookieName: 'taskforce_session',
6277
+ sessionSecret: 'test-secret',
6278
+ sessionTtlSeconds: 3600
6279
+ };
6280
+ const fetchMock = vi.fn().mockResolvedValue({
6281
+ status: 200,
6282
+ ok: true,
6283
+ headers: new Headers({ 'content-type': 'text/markdown' }),
6284
+ arrayBuffer: async () => Uint8Array.from(Buffer.from('# Canonical R2 doc\n')).buffer,
6285
+ });
6286
+ vi.stubGlobal('fetch', fetchMock);
6287
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-pull-doc-r2-'));
6288
+ const basePath = path.join(tmpRoot, '.taskforce');
6289
+ fs.mkdirSync(basePath, { recursive: true });
6290
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
6291
+ store.upsertAsset({
6292
+ assetId: 'asset-doc-r2-1',
6293
+ workspaceId: 'workspace-1',
6294
+ kind: 'document',
6295
+ originalFilename: 'canonical-r2-doc.md',
6296
+ mimeType: 'text/markdown',
6297
+ storageProvider: 'r2',
6298
+ storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-r2-1-canonical-r2-doc.md',
6299
+ contentSha256: 'sha-doc-r2-1',
6300
+ sizeBytes: 19,
6301
+ documentId: 'doc-r2-1',
6302
+ referenceNumber: 201,
6303
+ version: 2,
6304
+ isLatest: true,
6305
+ createdAt: '2026-03-17T09:00:00.000Z',
6306
+ updatedAt: '2026-03-17T10:00:00.000Z'
6307
+ });
6308
+ store.upsertLink({
6309
+ workspaceId: 'workspace-1',
6310
+ assetId: 'asset-doc-r2-1',
6311
+ taskId: 'task-1',
6312
+ role: 'attachment',
6313
+ createdAt: '2026-03-17T10:00:00.000Z',
6314
+ updatedAt: '2026-03-17T10:00:00.000Z'
6315
+ });
6316
+ core.getPaths.mockReturnValue({
6317
+ projectRoot: tmpRoot,
6318
+ basePath,
6319
+ });
6320
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
6321
+ core.getTenantId = vi.fn().mockReturnValue('default');
6322
+ routes = createRoutes(core, {
6323
+ authConfig,
6324
+ objectStorage: {
6325
+ provider: 'r2',
6326
+ r2: {
6327
+ accountId: 'acct-1',
6328
+ bucket: 'taskforce-docs',
6329
+ endpoint: 'https://example.r2.cloudflarestorage.com',
6330
+ accessKeyId: 'access-key-1',
6331
+ secretAccessKey: 'secret-key-1',
6332
+ keyPrefix: 'taskforce',
6333
+ signedUploadTtlSeconds: 600,
6334
+ signedDownloadTtlSeconds: 300
6335
+ }
6336
+ }
6337
+ });
6338
+ core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
6339
+ const cookie = createSessionTokenCookie('session-sync-pull-doc-r2', authConfig, {
6340
+ role: 'owner',
6341
+ workspaceId: 'workspace-1',
6342
+ userId: 'user-1'
6343
+ });
6344
+ core.listTasks.mockReturnValueOnce({ tasks: [] });
6345
+ core.listArchive.mockReturnValueOnce({ archived: [] });
6346
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
6347
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=20', undefined, { cookie });
6348
+ const res = createMockRes();
6349
+ await match?.handler(req, res, {});
6350
+ const payloadRaw = res.end.mock.calls[0]?.[0];
6351
+ const payload = JSON.parse(String(payloadRaw || '{}'));
6352
+ expect(payload.changes).toEqual(expect.arrayContaining([
6353
+ expect.objectContaining({
6354
+ op: 'document-upsert',
6355
+ path: 'workspaces/workspace-1/assets/documents/asset-doc-r2-1-canonical-r2-doc.md',
6356
+ content: '# Canonical R2 doc\n',
6357
+ assetId: 'asset-doc-r2-1',
6358
+ documentId: 'doc-r2-1',
6359
+ referenceNumber: 201,
6360
+ version: 2,
6361
+ taskId: 'task-1',
6362
+ originalFilename: 'canonical-r2-doc.md',
6363
+ linkRole: 'attachment'
6364
+ })
6365
+ ]));
6366
+ expect(fetchMock).toHaveBeenCalled();
6367
+ vi.unstubAllGlobals();
6368
+ fs.rmSync(dbRoot, { recursive: true, force: true });
6369
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
6370
+ });
5666
6371
  it('GET /api/taskforce/sync/workspace/pull should include document review session upserts and deletes', async () => {
5667
6372
  const authConfig = {
5668
6373
  runtimeMode: 'cloud',
@@ -6531,6 +7236,77 @@ describe('Server Routes', () => {
6531
7236
  });
6532
7237
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
6533
7238
  });
7239
+ it('POST /api/taskforce/sync/workspace/apply-local should apply task event upserts after task snapshots', async () => {
7240
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
7241
+ core.getTask.mockImplementation((taskId) => (taskId === 'task-sync-activity-1'
7242
+ ? {
7243
+ id: 'task-sync-activity-1',
7244
+ title: 'Synced task',
7245
+ status: 'done',
7246
+ category: 'general',
7247
+ type: 'task',
7248
+ createdAt: '2026-03-24T10:00:00.000Z',
7249
+ updatedAt: '2026-03-24T10:05:00.000Z',
7250
+ taskEvents: []
7251
+ }
7252
+ : null));
7253
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
7254
+ const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
7255
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
7256
+ changes: [
7257
+ {
7258
+ op: 'upsert',
7259
+ archived: false,
7260
+ task: {
7261
+ id: 'task-sync-activity-1',
7262
+ title: 'Synced task',
7263
+ status: 'done',
7264
+ category: 'general',
7265
+ type: 'task',
7266
+ updatedAt: '2026-03-24T10:05:00.000Z'
7267
+ }
7268
+ },
7269
+ {
7270
+ op: 'task-event-upsert',
7271
+ event: {
7272
+ id: 'task-event-sync-1',
7273
+ taskId: 'task-sync-activity-1',
7274
+ workspaceId: 'workspace-local',
7275
+ action: 'task-status-changed',
7276
+ actor: 'ai-profile-1',
7277
+ actorType: 'ai',
7278
+ createdAt: '2026-03-24T10:05:00.000Z',
7279
+ details: {
7280
+ changes: {
7281
+ status: {
7282
+ from: 'in-progress',
7283
+ to: 'done'
7284
+ }
7285
+ }
7286
+ }
7287
+ }
7288
+ }
7289
+ ]
7290
+ }, {
7291
+ 'x-taskforce-workspace-id': 'workspace-local'
7292
+ });
7293
+ const res = createMockRes();
7294
+ await match?.handler(req, res, {});
7295
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
7296
+ tasks: [expect.objectContaining({ id: 'task-sync-activity-1' })],
7297
+ archived: []
7298
+ }), 'workspace-local');
7299
+ expect(core.upsertTaskEventForSync).toHaveBeenCalledWith(expect.objectContaining({
7300
+ id: 'task-event-sync-1',
7301
+ taskId: 'task-sync-activity-1',
7302
+ workspaceId: 'workspace-local',
7303
+ action: 'task-status-changed',
7304
+ actor: 'ai-profile-1',
7305
+ actorType: 'ai',
7306
+ createdAt: '2026-03-24T10:05:00.000Z'
7307
+ }));
7308
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7309
+ });
6534
7310
  it('POST /api/taskforce/sync/workspace/apply-local should fail when document review session persistence fails', async () => {
6535
7311
  core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
6536
7312
  const error = new Error('document review session write failed');
@@ -8771,6 +9547,73 @@ describe('Server Routes', () => {
8771
9547
  expect(payload.authenticated).toBe(true);
8772
9548
  expect(payload.avatarUrl).toBe('/api/taskforce/context/users%2Fu-avatar%2Fprofile%2Favatar%2Fdraft.png');
8773
9549
  });
9550
+ it('GET /api/taskforce/auth/session should not wait for avatar cleanup before responding', async () => {
9551
+ const authConfig = {
9552
+ runtimeMode: 'cloud',
9553
+ enabled: true,
9554
+ requiredForApi: true,
9555
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
9556
+ defaultRole: 'member',
9557
+ defaultWorkspaceId: 'default',
9558
+ sessionCookieName: 'taskforce_session',
9559
+ sessionSecret: 'test-secret',
9560
+ sessionTtlSeconds: 3600
9561
+ };
9562
+ const deleteObjectCompletion = { resolve: null };
9563
+ const deleteObjectSpy = vi.spyOn(ObjectStorageClient.prototype, 'deleteObject').mockImplementation(() => new Promise((resolve) => {
9564
+ deleteObjectCompletion.resolve = resolve;
9565
+ }));
9566
+ routes = createRoutes(core, {
9567
+ authConfig,
9568
+ objectStorage: {
9569
+ provider: 'r2',
9570
+ r2: {
9571
+ accountId: 'test-account',
9572
+ bucket: 'taskforce-test',
9573
+ endpoint: 'https://example.com',
9574
+ accessKeyId: 'test-key',
9575
+ secretAccessKey: 'test-secret',
9576
+ keyPrefix: '',
9577
+ signedUploadTtlSeconds: 60,
9578
+ signedDownloadTtlSeconds: 60
9579
+ }
9580
+ }
9581
+ });
9582
+ core.cleanupStaleUserAvatarUploadDrafts.mockReturnValueOnce([{
9583
+ draftId: 'avatar-draft-stale',
9584
+ userId: 'u-avatar',
9585
+ storageKey: 'users/u-avatar/profile/avatar/avatar-draft-stale.png',
9586
+ discardedAt: null,
9587
+ consumedAt: null,
9588
+ finalizedAt: null,
9589
+ createdAt: '2026-04-14T00:00:00.000Z',
9590
+ updatedAt: '2026-04-14T00:00:00.000Z'
9591
+ }]);
9592
+ const cookie = createSessionTokenCookie('session-avatar-cleanup', authConfig, {
9593
+ userId: 'u-avatar',
9594
+ role: 'member',
9595
+ workspaceId: 'workspace-1'
9596
+ });
9597
+ const match = matchRoute('GET', '/api/taskforce/auth/session', routes);
9598
+ const req = createMockReq('GET', '/api/taskforce/auth/session', undefined, { cookie });
9599
+ const res = createMockRes();
9600
+ try {
9601
+ const outcome = await Promise.race([
9602
+ match?.handler(req, res, {}).then(() => 'completed'),
9603
+ new Promise((resolve) => setTimeout(() => resolve('timeout'), 25))
9604
+ ]);
9605
+ expect(outcome).toBe('completed');
9606
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
9607
+ expect(payload.authenticated).toBe(true);
9608
+ expect(deleteObjectSpy).toHaveBeenCalledWith('users/u-avatar/profile/avatar/avatar-draft-stale.png');
9609
+ }
9610
+ finally {
9611
+ if (deleteObjectCompletion.resolve) {
9612
+ deleteObjectCompletion.resolve(true);
9613
+ }
9614
+ deleteObjectSpy.mockRestore();
9615
+ }
9616
+ });
8774
9617
  it('POST /api/taskforce/auth/profile should save avatar by validated draft id', async () => {
8775
9618
  const authConfig = {
8776
9619
  runtimeMode: 'cloud',
@@ -10300,6 +11143,43 @@ describe('Server Routes', () => {
10300
11143
  }
10301
11144
  });
10302
11145
  });
11146
+ it('POST /api/taskforce/admin/system-settings should preserve onboarding policy when onboarding fields are omitted', async () => {
11147
+ core.getOnboardingPolicy.mockReturnValue({
11148
+ mode: 'auto_grant_free',
11149
+ planVersionId: 'starter-v1'
11150
+ });
11151
+ const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
11152
+ const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
11153
+ allowSelfRegistration: false,
11154
+ requireVerifiedEmail: true,
11155
+ defaultNewUserBetaAccess: false,
11156
+ authRateLimitEnabled: true,
11157
+ authRateLimitMaxRequests: 10,
11158
+ authRateLimitWindowMs: 900000
11159
+ }, { 'x-taskforce-role': 'admin' });
11160
+ const res = createMockRes();
11161
+ await match?.handler(req, res, {});
11162
+ expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
11163
+ mode: 'auto_grant_free',
11164
+ planVersionId: 'starter-v1'
11165
+ });
11166
+ expect(core.updateGlobalSettings).toHaveBeenCalledWith({
11167
+ auth: {
11168
+ allowSelfRegistration: false,
11169
+ requireVerifiedEmail: true,
11170
+ defaultNewUserBetaAccess: false,
11171
+ authRateLimitEnabled: true,
11172
+ authRateLimitMaxRequests: 10,
11173
+ authRateLimitWindowMs: 900000
11174
+ },
11175
+ billing: {
11176
+ onboardingPolicy: {
11177
+ mode: 'auto_grant_free',
11178
+ planVersionId: 'starter-v1'
11179
+ }
11180
+ }
11181
+ });
11182
+ });
10303
11183
  it('GET /api/taskforce/admin/site-settings should expose effective website settings', async () => {
10304
11184
  core.getGlobalSettings.mockReturnValue({
10305
11185
  site: {
@@ -11571,7 +12451,7 @@ describe('Server Routes', () => {
11571
12451
  errors: []
11572
12452
  }));
11573
12453
  });
11574
- it('PATCH /api/taskforce/task/:id should pass autosave updates through as non-audited route metadata', async () => {
12454
+ it('PATCH /api/taskforce/task/:id should pass autosave metadata through to core.updateTask', async () => {
11575
12455
  const updatedTask = { id: '123', description: 'Draft body' };
11576
12456
  core.updateTask.mockReturnValue(updatedTask);
11577
12457
  const url = '/api/taskforce/task/123';
@@ -11581,12 +12461,12 @@ describe('Server Routes', () => {
11581
12461
  await match?.handler(req, res, { id: '123' });
11582
12462
  expect(core.updateTask).toHaveBeenCalledWith('123', { description: 'Draft body' }, 'default', {
11583
12463
  actorRef: 'user',
11584
- skipTaskEvents: true
12464
+ saveSource: 'autosave'
11585
12465
  });
11586
12466
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11587
12467
  expect(res.end).toHaveBeenCalledWith(JSON.stringify(updatedTask));
11588
12468
  });
11589
- it('PATCH /api/taskforce/task/:id should keep manual updates on the audited path', async () => {
12469
+ it('PATCH /api/taskforce/task/:id should pass manual save metadata through to core.updateTask', async () => {
11590
12470
  const updatedTask = { id: '123', description: 'Manual body' };
11591
12471
  core.updateTask.mockReturnValue(updatedTask);
11592
12472
  const url = '/api/taskforce/task/123';
@@ -11596,7 +12476,7 @@ describe('Server Routes', () => {
11596
12476
  await match?.handler(req, res, { id: '123' });
11597
12477
  expect(core.updateTask).toHaveBeenCalledWith('123', { description: 'Manual body' }, 'default', {
11598
12478
  actorRef: 'user',
11599
- skipTaskEvents: false
12479
+ saveSource: 'manual'
11600
12480
  });
11601
12481
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11602
12482
  expect(res.end).toHaveBeenCalledWith(JSON.stringify(updatedTask));