@taskforcehq/taskforce 0.3.301 → 0.3.303

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 (171) hide show
  1. package/dist/Taskforce.module.css +37 -2
  2. package/dist/TaskforceCore.filter-persistence.test.js +1 -1
  3. package/dist/TaskforceCore.js +72 -33
  4. package/dist/TaskforceCore.test.js +848 -130
  5. package/dist/components/features/TaskSettings.js +14 -82
  6. package/dist/components/features/TaskSettings.test.js +33 -70
  7. package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
  8. package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
  9. package/dist/components/task/TaskActionHeader.js +6 -3
  10. package/dist/components/task/TaskCard.js +6 -3
  11. package/dist/components/task/TaskForm.js +2 -2
  12. package/dist/components/task/TaskForm.test.js +41 -2
  13. package/dist/components/task/TaskKanban.test.js +17 -0
  14. package/dist/components/ui/ReferenceBadgeButton.js +1 -1
  15. package/dist/components/views/PlansPage.d.ts +3 -1
  16. package/dist/components/views/PlansPage.js +109 -49
  17. package/dist/components/views/PlansPage.test.d.ts +1 -0
  18. package/dist/components/views/PlansPage.test.js +100 -0
  19. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  20. package/dist/components/views/StandaloneLayout.js +473 -256
  21. package/dist/components/views/panels/PlanningDrawer.js +18 -1
  22. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +48 -0
  23. package/dist/components/views/standalone/modals/AccountHubModal.js +36 -0
  24. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  25. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  26. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
  27. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
  28. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
  29. package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
  30. package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
  31. package/dist/components/views/standalone/modals/HelpModal.js +8 -0
  32. package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
  33. package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
  34. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
  35. package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
  36. package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
  37. package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
  38. package/dist/components/views/standalone/modals/types.d.ts +2 -0
  39. package/dist/components/views/standalone/modals/types.js +1 -0
  40. package/dist/components/views/teamManagementAccess.d.ts +1 -1
  41. package/dist/components/views/teamManagementAccess.js +2 -2
  42. package/dist/components/views/teamManagementAccess.test.js +3 -3
  43. package/dist/config/deployment.js +2 -2
  44. package/dist/config/deployment.test.js +5 -3
  45. package/dist/core/AiProfileService.js +7 -2
  46. package/dist/core/AiProfiles.test.js +19 -0
  47. package/dist/core/EntitlementsPolicy.test.js +2 -2
  48. package/dist/core/GlobalSettingsService.js +78 -18
  49. package/dist/core/PlanEntitlementService.d.ts +29 -5
  50. package/dist/core/PlanEntitlementService.js +297 -219
  51. package/dist/core/SignupMonetizationSettings.test.js +173 -49
  52. package/dist/core/SyncReconciliationService.d.ts +2 -0
  53. package/dist/core/SyncReconciliationService.js +3 -0
  54. package/dist/core/SystemAdmin.test.js +186 -96
  55. package/dist/core/TaskAuditTimeline.test.js +40 -0
  56. package/dist/core/Taskforce.d.ts +100 -10
  57. package/dist/core/Taskforce.ids.test.js +133 -0
  58. package/dist/core/Taskforce.js +459 -127
  59. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  60. package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
  61. package/dist/core/UserProfileAvatarDrafts.test.js +111 -0
  62. package/dist/core/taskReferencePolicy.d.ts +28 -0
  63. package/dist/core/taskReferencePolicy.js +189 -0
  64. package/dist/core/types.d.ts +13 -12
  65. package/dist/hooks/tasks/useTaskFormActions.js +3 -0
  66. package/dist/hooks/useSyncOrchestrator.js +17 -3
  67. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
  68. package/dist/hooks/useTaskData.js +3 -2
  69. package/dist/hooks/useTaskData.test.js +66 -0
  70. package/dist/hooks/useTaskforce.d.ts +8 -0
  71. package/dist/hooks/useTaskforce.js +72 -19
  72. package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
  73. package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
  74. package/dist/mcp/aiProfileBootstrap.js +80 -0
  75. package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
  76. package/dist/mcp/aiProfileBootstrap.test.js +68 -0
  77. package/dist/mcp/clientIntegrations.d.ts +2 -8
  78. package/dist/mcp/clientIntegrations.js +21 -40
  79. package/dist/mcp/clientIntegrations.test.js +28 -2
  80. package/dist/mcp/clientProfileDefaults.d.ts +12 -0
  81. package/dist/mcp/clientProfileDefaults.js +37 -0
  82. package/dist/mcp/runtime.d.ts +10 -7
  83. package/dist/mcp/runtime.js +131 -48
  84. package/dist/mcp/runtime.test.js +321 -83
  85. package/dist/migrations/billingSchemaParity.test.js +3 -0
  86. package/dist/migrations/taskSchemaMigrations.js +68 -1
  87. package/dist/qaOpen.test.d.ts +1 -0
  88. package/dist/qaOpen.test.js +56 -0
  89. package/dist/server/index.cookieProxy.test.js +2 -0
  90. package/dist/server/index.d.ts +1 -0
  91. package/dist/server/index.js +17 -1
  92. package/dist/server/index.test.js +15 -1
  93. package/dist/server/mockStripe.d.ts +1 -0
  94. package/dist/server/mockStripe.js +2 -0
  95. package/dist/server/routes/admin.js +105 -45
  96. package/dist/server/routes/auth.d.ts +6 -0
  97. package/dist/server/routes/auth.js +505 -30
  98. package/dist/server/routes/billing.js +517 -24
  99. package/dist/server/routes/billing.test.js +967 -45
  100. package/dist/server/routes/documents.js +6 -1
  101. package/dist/server/routes/shared.d.ts +1 -1
  102. package/dist/server/routes/shared.js +16 -6
  103. package/dist/server/routes/sync.integration.test.js +290 -0
  104. package/dist/server/routes/sync.js +234 -1922
  105. package/dist/server/routes/tasks.js +18 -4
  106. package/dist/server/routes.js +29 -22
  107. package/dist/server/routes.test.js +639 -82
  108. package/dist/shared/runtimeContract.d.ts +1 -1
  109. package/dist/sync/cloudSyncApi.d.ts +1 -0
  110. package/dist/sync/collaborationSyncPayload.d.ts +17 -0
  111. package/dist/sync/collaborationSyncPayload.js +131 -0
  112. package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
  113. package/dist/sync/collaborationSyncPayload.test.js +104 -0
  114. package/dist/sync/collaborationSyncState.d.ts +15 -0
  115. package/dist/sync/collaborationSyncState.js +60 -0
  116. package/dist/sync/contentSyncPayload.d.ts +45 -0
  117. package/dist/sync/contentSyncPayload.js +104 -0
  118. package/dist/sync/contentSyncPayload.test.d.ts +1 -0
  119. package/dist/sync/contentSyncPayload.test.js +104 -0
  120. package/dist/sync/contentSyncState.d.ts +98 -0
  121. package/dist/sync/contentSyncState.js +1004 -0
  122. package/dist/sync/planningSyncPayload.d.ts +9 -0
  123. package/dist/sync/planningSyncPayload.js +82 -0
  124. package/dist/sync/planningSyncPayload.test.d.ts +1 -0
  125. package/dist/sync/planningSyncPayload.test.js +68 -0
  126. package/dist/sync/syncApplyHandlers.d.ts +83 -0
  127. package/dist/sync/syncApplyHandlers.js +445 -0
  128. package/dist/sync/syncService.d.ts +1 -0
  129. package/dist/sync/syncService.js +21 -8
  130. package/dist/sync/taskSyncPayload.d.ts +33 -0
  131. package/dist/sync/taskSyncPayload.js +126 -0
  132. package/dist/sync/taskSyncPayload.test.d.ts +1 -0
  133. package/dist/sync/taskSyncPayload.test.js +54 -0
  134. package/dist/sync/workspacePullFeed.d.ts +38 -0
  135. package/dist/sync/workspacePullFeed.js +349 -0
  136. package/dist/sync/workspacePullFeed.test.d.ts +1 -0
  137. package/dist/sync/workspacePullFeed.test.js +278 -0
  138. package/dist/sync/workspaceSyncModel.d.ts +4 -1
  139. package/dist/sync/workspaceSyncModel.js +9 -2
  140. package/dist/sync/workspaceSyncModel.test.js +29 -0
  141. package/dist/test/setup.js +1 -1
  142. package/dist/types.d.ts +1 -0
  143. package/dist/ui/assets/{AgentsModule-Bdq3T1Ts.js → AgentsModule-CpsTmrIW.js} +1 -1
  144. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-jKUVo-PJ.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
  145. package/dist/ui/assets/{DocumentWorkspace-DuQa-uIC.js → DocumentWorkspace-C3GyzrWm.js} +2 -2
  146. package/dist/ui/assets/{InitiativesModule-C8Pi2VTk.js → InitiativesModule-4-Rh2K2n.js} +1 -1
  147. package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
  148. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +8 -0
  149. package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
  150. package/dist/ui/assets/{WorkflowsModule-b6TLuHwJ.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
  151. package/dist/ui/assets/index-CLIMgR6g.js +6 -0
  152. package/dist/ui/assets/index-DneETxcu.css +1 -0
  153. package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-pWocEipT.js} +1 -1
  154. package/dist/ui/index.html +3 -3
  155. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  156. package/dist/utils/accountProfileSummaryCache.js +97 -0
  157. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  158. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  159. package/dist/utils/taskActivity.js +17 -4
  160. package/dist/utils/taskActivity.test.js +27 -0
  161. package/dist/utils/taskNormalization.js +10 -2
  162. package/dist/utils/taskReferences.d.ts +11 -1
  163. package/dist/utils/taskReferences.js +33 -1
  164. package/package.json +4 -3
  165. package/scripts/qa-open-lib.mjs +175 -0
  166. package/scripts/qa-open-lib.mjs.d.ts +49 -0
  167. package/scripts/qa-open.mjs +352 -0
  168. package/dist/ui/assets/PlansPage-B6BDECSi.js +0 -1
  169. package/dist/ui/assets/TaskSettings-DUJgRTPU.js +0 -8
  170. package/dist/ui/assets/index-BiiF1cLQ.css +0 -1
  171. package/dist/ui/assets/index-xBWLPOEb.js +0 -6
@@ -236,6 +236,7 @@ vi.mock('../core/Taskforce', () => {
236
236
  setSystemRole = vi.fn().mockReturnValue(true);
237
237
  setUserDisabledGlobal = vi.fn().mockReturnValue(true);
238
238
  setUserBetaAccessGlobal = vi.fn().mockReturnValue(true);
239
+ purgeSystemUser = vi.fn();
239
240
  updateWorkspaceUserRole = vi.fn();
240
241
  inviteWorkspaceUser = vi.fn();
241
242
  updateWorkspaceProfile = vi.fn().mockImplementation((workspaceId, input) => ({
@@ -262,17 +263,16 @@ vi.mock('../core/Taskforce', () => {
262
263
  revokeInviteAcceptanceForUser = vi.fn().mockReturnValue({ revoked: 1 });
263
264
  getWorkspacePlanMode = vi.fn().mockReturnValue('team');
264
265
  setWorkspacePlanMode = vi.fn().mockImplementation((_workspaceId, mode) => mode);
265
- getSignupMonetizationSettings = vi.fn().mockReturnValue({
266
- strategy: 'auto_assign_default',
267
- defaultSignupPlanVersionId: null
266
+ getOnboardingPolicy = vi.fn().mockReturnValue({
267
+ mode: 'require_plan_selection',
268
+ planVersionId: null
268
269
  });
269
- validateSignupMonetizationSettings = vi.fn().mockReturnValue({ valid: true });
270
+ validateOnboardingPolicy = vi.fn().mockReturnValue({ valid: true });
270
271
  getAccountAccessStatus = vi.fn().mockReturnValue({
271
272
  gate: 'ok',
272
273
  hasEntitlement: true,
273
274
  canAccessApp: true,
274
275
  canAccessPlans: true,
275
- signupMonetizationStrategy: 'auto_assign_default',
276
276
  planSelectionRequired: false,
277
277
  message: null,
278
278
  entitlement: {
@@ -462,10 +462,22 @@ vi.mock('../core/Taskforce', () => {
462
462
  }));
463
463
  resolveUserIdentity = vi.fn().mockImplementation((userId) => {
464
464
  if (userId === 'admin-user') {
465
- return { userId, email: 'admin@example.com' };
465
+ return { userId, email: 'admin@example.com', avatarUrl: null };
466
466
  }
467
467
  return null;
468
468
  });
469
+ updateUserProfile = vi.fn().mockImplementation((userId, input) => ({
470
+ userId,
471
+ email: `${userId}@example.com`,
472
+ displayName: input.displayName,
473
+ avatarUrl: input.avatarUrl === undefined ? null : input.avatarUrl
474
+ }));
475
+ createUserAvatarUploadDraft = vi.fn();
476
+ getUserAvatarUploadDraft = vi.fn().mockReturnValue(null);
477
+ markUserAvatarUploadDraftFinalized = vi.fn().mockReturnValue(null);
478
+ markUserAvatarUploadDraftConsumed = vi.fn().mockReturnValue(null);
479
+ discardUserAvatarUploadDraft = vi.fn().mockReturnValue(null);
480
+ cleanupStaleUserAvatarUploadDrafts = vi.fn().mockReturnValue([]);
469
481
  resolvePreferredWorkspaceId = vi.fn().mockImplementation((options) => (options?.allowDefault ? 'default' : null));
470
482
  resolvePreferredUserWorkspaceId = vi.fn().mockImplementation((_userId, options) => {
471
483
  const preferred = String(options?.preferredWorkspaceId || '').trim();
@@ -863,6 +875,8 @@ describe('Server Routes', () => {
863
875
  authorization: 'Bearer tfmcp_ai_header_token',
864
876
  'x-taskforce-ai-name': 'Automation Codex',
865
877
  'x-taskforce-ai-profile-token': 'tfp_automation_codex',
878
+ 'x-taskforce-ai-icon': 'Code2',
879
+ 'x-taskforce-ai-color': '#10a37f',
866
880
  'x-taskforce-ai-description': 'OpenAI Codex coding agent connected through MCP.',
867
881
  'x-taskforce-ai-role': 'Coding Agent',
868
882
  'x-taskforce-ai-provider': 'OpenAI',
@@ -3511,7 +3525,11 @@ describe('Server Routes', () => {
3511
3525
  const req = createMockReq('POST', '/api/taskforce/task', newTask);
3512
3526
  const res = createMockRes();
3513
3527
  await match?.handler(req, res, {});
3514
- expect(core.createTask).toHaveBeenCalledWith(newTask, { workspaceId: 'default', actorRef: 'user' });
3528
+ expect(core.createTask).toHaveBeenCalledWith(newTask, {
3529
+ workspaceId: 'default',
3530
+ actorRef: 'user',
3531
+ provisionalLocalReference: true
3532
+ });
3515
3533
  expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
3516
3534
  expect(res.end).toHaveBeenCalledWith(JSON.stringify(createdTask));
3517
3535
  });
@@ -3591,7 +3609,7 @@ describe('Server Routes', () => {
3591
3609
  const payload = JSON.parse(res.end.mock.calls[0][0]);
3592
3610
  expect(payload?.config?.baseUrl).toBe('http://127.0.0.1:4271');
3593
3611
  expect(payload?.config?.apiBaseUrl).toBe('http://127.0.0.1:4271');
3594
- expect(payload?.config?.cloudAuthBaseUrl).toBe('http://127.0.0.1:4271');
3612
+ expect(payload?.config?.cloudAuthBaseUrl).toBe('https://app.taskforcehq.ai');
3595
3613
  expect(payload?.config?.wsBaseUrl).toBe('http://127.0.0.1:4271');
3596
3614
  expect(payload?.config?.cloudMcpBaseUrl).toBe('https://mcp.taskforcehq.ai');
3597
3615
  }
@@ -3655,7 +3673,7 @@ describe('Server Routes', () => {
3655
3673
  process.env.VITE_TASKFORCE_API_BASE_URL = previousApiBaseUrl;
3656
3674
  }
3657
3675
  });
3658
- it('POST /api/taskforce/auth/register should create account and set session cookie', async () => {
3676
+ it('POST /api/taskforce/auth/register should create account without a session cookie when verification is required', async () => {
3659
3677
  const authConfig = {
3660
3678
  runtimeMode: 'cloud',
3661
3679
  enabled: true,
@@ -3688,6 +3706,7 @@ describe('Server Routes', () => {
3688
3706
  email: 'owner@example.com',
3689
3707
  role: 'owner'
3690
3708
  }));
3709
+ expect(res.setHeader).not.toHaveBeenCalledWith('Set-Cookie', expect.any(String));
3691
3710
  expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
3692
3711
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"verificationRequired":true'));
3693
3712
  });
@@ -3723,7 +3742,7 @@ describe('Server Routes', () => {
3723
3742
  workspaceId: undefined,
3724
3743
  role: 'member'
3725
3744
  }));
3726
- expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
3745
+ expect(res.setHeader).not.toHaveBeenCalledWith('Set-Cookie', expect.any(String));
3727
3746
  expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
3728
3747
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"workspaceSetupRequired":true'));
3729
3748
  });
@@ -3762,6 +3781,7 @@ describe('Server Routes', () => {
3762
3781
  const res = createMockRes();
3763
3782
  await match?.handler(req, res, {});
3764
3783
  expect(core.requestEmailVerification).not.toHaveBeenCalled();
3784
+ expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
3765
3785
  expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
3766
3786
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"verificationRequired":false'));
3767
3787
  });
@@ -3836,7 +3856,7 @@ describe('Server Routes', () => {
3836
3856
  code: 'EMAIL_ALREADY_EXISTS'
3837
3857
  }));
3838
3858
  });
3839
- it('POST /api/taskforce/auth/register should fail when signup monetization is misconfigured', async () => {
3859
+ it('POST /api/taskforce/auth/register should fail when the onboarding policy is misconfigured', async () => {
3840
3860
  const authConfig = {
3841
3861
  runtimeMode: 'cloud',
3842
3862
  enabled: true,
@@ -3849,7 +3869,7 @@ describe('Server Routes', () => {
3849
3869
  sessionTtlSeconds: 3600
3850
3870
  };
3851
3871
  routes = createRoutes(core, { authConfig });
3852
- const error = new Error('Configured default signup plan version "missing-v9" was not found.');
3872
+ const error = new Error('Configured onboarding plan version "missing-v9" was not found.');
3853
3873
  error.statusCode = 500;
3854
3874
  error.code = 'SIGNUP_PLAN_VERSION_NOT_FOUND';
3855
3875
  core.registerPasswordUser.mockImplementationOnce(() => {
@@ -3865,7 +3885,7 @@ describe('Server Routes', () => {
3865
3885
  expect(res.writeHead).toHaveBeenCalledWith(500, expect.any(Object));
3866
3886
  expect(res.end).toHaveBeenCalledWith(JSON.stringify({
3867
3887
  success: false,
3868
- error: 'Configured default signup plan version "missing-v9" was not found.',
3888
+ error: 'Configured onboarding plan version "missing-v9" was not found.',
3869
3889
  code: 'SIGNUP_PLAN_VERSION_NOT_FOUND'
3870
3890
  }));
3871
3891
  });
@@ -3882,12 +3902,19 @@ describe('Server Routes', () => {
3882
3902
  sessionTtlSeconds: 3600
3883
3903
  };
3884
3904
  routes = createRoutes(core, { authConfig });
3905
+ core.inspectOneTimeAuthToken = vi.fn().mockReturnValueOnce({
3906
+ state: 'valid',
3907
+ userId: 'u-verify',
3908
+ workspaceId: 'workspace-1'
3909
+ });
3885
3910
  core.verifyEmailWithToken.mockReturnValueOnce(true);
3886
3911
  const match = matchRoute('POST', '/api/taskforce/auth/verify-email/confirm', routes);
3887
3912
  const req = createMockReq('POST', '/api/taskforce/auth/verify-email/confirm', { token: 'tok1' });
3888
3913
  const res = createMockRes();
3889
3914
  await match?.handler(req, res, {});
3915
+ expect(core.inspectOneTimeAuthToken).toHaveBeenCalledWith('tok1', 'email_verification');
3890
3916
  expect(core.verifyEmailWithToken).toHaveBeenCalledWith('tok1');
3917
+ expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
3891
3918
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
3892
3919
  });
3893
3920
  it('POST /api/taskforce/auth/password-reset/confirm should set new password', async () => {
@@ -4172,6 +4199,44 @@ describe('Server Routes', () => {
4172
4199
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4173
4200
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
4174
4201
  });
4202
+ it('POST /api/taskforce/auth/login should treat string false as disabled verified-email enforcement', async () => {
4203
+ const authConfig = {
4204
+ runtimeMode: 'cloud',
4205
+ enabled: true,
4206
+ requiredForApi: true,
4207
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4208
+ defaultRole: 'member',
4209
+ defaultWorkspaceId: 'default',
4210
+ sessionCookieName: 'taskforce_session',
4211
+ sessionSecret: 'test-secret',
4212
+ sessionTtlSeconds: 3600
4213
+ };
4214
+ core.getGlobalSettings.mockReturnValue({
4215
+ auth: {
4216
+ allowSelfRegistration: true,
4217
+ requireVerifiedEmail: 'false'
4218
+ }
4219
+ });
4220
+ routes = createRoutes(core, { authConfig });
4221
+ core.resolvePreferredUserWorkspaceId.mockReturnValueOnce(null);
4222
+ core.authenticatePasswordUserDetailed.mockReturnValueOnce({
4223
+ ok: true,
4224
+ access: { userId: 'u2', workspaceId: 'default', role: 'member' }
4225
+ });
4226
+ const match = matchRoute('POST', '/api/taskforce/auth/login', routes);
4227
+ const req = createMockReq('POST', '/api/taskforce/auth/login', {
4228
+ email: 'user@example.com',
4229
+ password: 'password123'
4230
+ });
4231
+ const res = createMockRes();
4232
+ res.setHeader = vi.fn();
4233
+ await match?.handler(req, res, {});
4234
+ expect(core.authenticatePasswordUserDetailed).toHaveBeenCalledWith(expect.objectContaining({
4235
+ email: 'user@example.com',
4236
+ requireVerifiedEmail: false
4237
+ }));
4238
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4239
+ });
4175
4240
  it('POST /api/taskforce/auth/login should re-scope default session workspace to accessible membership', async () => {
4176
4241
  const authConfig = {
4177
4242
  runtimeMode: 'cloud',
@@ -4723,10 +4788,10 @@ describe('Server Routes', () => {
4723
4788
  }, { cookie });
4724
4789
  const res = createMockRes();
4725
4790
  await match?.handler(req, res, {});
4726
- expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith({
4727
- tasks: [{ id: 'task-1', title: 'Task 1' }],
4728
- archived: [{ id: 'task-2', title: 'Task 2', isArchived: true }]
4729
- }, 'workspace-1');
4791
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
4792
+ tasks: [expect.objectContaining({ id: 'task-1', title: 'Task 1' })],
4793
+ archived: [expect.objectContaining({ id: 'task-2', title: 'Task 2', isArchived: true })]
4794
+ }), 'workspace-1');
4730
4795
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4731
4796
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
4732
4797
  expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
@@ -6371,10 +6436,10 @@ describe('Server Routes', () => {
6371
6436
  const res = createMockRes();
6372
6437
  await match?.handler(req, res, {});
6373
6438
  expect(core.deleteTaskForSync).toHaveBeenCalledWith('task-2', 'workspace-local', { persistBackup: false });
6374
- expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith({
6375
- tasks: [{ id: 'task-1', title: 'Task 1' }],
6439
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
6440
+ tasks: [expect.objectContaining({ id: 'task-1', title: 'Task 1' })],
6376
6441
  archived: []
6377
- }, 'workspace-local');
6442
+ }), 'workspace-local');
6378
6443
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
6379
6444
  });
6380
6445
  it('POST /api/taskforce/sync/workspace/apply-local should apply document review session upserts and deletes', async () => {
@@ -6812,10 +6877,10 @@ describe('Server Routes', () => {
6812
6877
  });
6813
6878
  const res = createMockRes();
6814
6879
  await match?.handler(req, res, {});
6815
- expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith({
6816
- tasks: [{ id: 'task-shared-1', title: 'Shared task' }],
6880
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
6881
+ tasks: [expect.objectContaining({ id: 'task-shared-1', title: 'Shared task' })],
6817
6882
  archived: []
6818
- }, 'workspace-shared-local');
6883
+ }), 'workspace-shared-local');
6819
6884
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
6820
6885
  });
6821
6886
  it('POST /api/taskforce/sync/workspace/apply-local should route archived upserts and preserve canceledReason', async () => {
@@ -6842,17 +6907,17 @@ describe('Server Routes', () => {
6842
6907
  });
6843
6908
  const res = createMockRes();
6844
6909
  await match?.handler(req, res, {});
6845
- expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith({
6910
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
6846
6911
  tasks: [],
6847
- archived: [{
6912
+ archived: [expect.objectContaining({
6848
6913
  id: 'task-archived-cancelled',
6849
6914
  title: 'Cancelled + Archived',
6850
6915
  status: 'cancelled',
6851
6916
  canceledReason: 'Scope changed',
6852
6917
  workstreamId: 'workstream-archived-1',
6853
6918
  isArchived: true
6854
- }]
6855
- }, 'workspace-local');
6919
+ })]
6920
+ }), 'workspace-local');
6856
6921
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
6857
6922
  });
6858
6923
  it('POST /api/taskforce/sync/workspace/apply-local should prioritize delete over same-task upsert', async () => {
@@ -7519,10 +7584,10 @@ describe('Server Routes', () => {
7519
7584
  }, { cookie });
7520
7585
  const res1 = createMockRes();
7521
7586
  await match?.handler(req1, res1, {});
7522
- expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith({
7523
- tasks: [{ id: 'task-1', title: 'Task 1' }],
7587
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
7588
+ tasks: [expect.objectContaining({ id: 'task-1', title: 'Task 1' })],
7524
7589
  archived: []
7525
- }, 'workspace-1');
7590
+ }), 'workspace-1');
7526
7591
  expect(res1.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7527
7592
  const firstPayload = JSON.parse(String(res1.end.mock.calls.at(-1)?.[0] || '{}'));
7528
7593
  expect(Array.isArray(firstPayload.emittedEventIds)).toBe(true);
@@ -7724,10 +7789,10 @@ describe('Server Routes', () => {
7724
7789
  }, { cookie });
7725
7790
  const res = createMockRes();
7726
7791
  await match?.handler(req, res, {});
7727
- expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith({
7728
- tasks: [{ id: 'task-1', title: 'Task 1' }],
7792
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
7793
+ tasks: [expect.objectContaining({ id: 'task-1', title: 'Task 1' })],
7729
7794
  archived: []
7730
- }, 'workspace-local-active');
7795
+ }), 'workspace-local-active');
7731
7796
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
7732
7797
  expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
7733
7798
  });
@@ -8004,15 +8069,15 @@ describe('Server Routes', () => {
8004
8069
  }, { cookie });
8005
8070
  const res = createMockRes();
8006
8071
  await match?.handler(req, res, {});
8007
- expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith({
8008
- tasks: [{
8072
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
8073
+ tasks: [expect.objectContaining({
8009
8074
  id: 'task-cancelled-1',
8010
8075
  title: 'Cancelled Task',
8011
8076
  status: 'cancelled',
8012
8077
  canceledReason: 'No longer needed'
8013
- }],
8078
+ })],
8014
8079
  archived: []
8015
- }, 'workspace-1');
8080
+ }), 'workspace-1');
8016
8081
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8017
8082
  });
8018
8083
  it('POST /api/taskforce/sync/workspace/push should preserve workstreamId in upsert payload', async () => {
@@ -8053,15 +8118,15 @@ describe('Server Routes', () => {
8053
8118
  }, { cookie });
8054
8119
  const res = createMockRes();
8055
8120
  await match?.handler(req, res, {});
8056
- expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith({
8057
- tasks: [{
8121
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
8122
+ tasks: [expect.objectContaining({
8058
8123
  id: 'task-workstream-1',
8059
8124
  title: 'Linked task',
8060
8125
  status: 'task',
8061
8126
  workstreamId: 'workstream-1'
8062
- }],
8127
+ })],
8063
8128
  archived: []
8064
- }, 'workspace-1');
8129
+ }), 'workspace-1');
8065
8130
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8066
8131
  });
8067
8132
  it('POST /api/taskforce/sync/workspace/push should route archived upserts into archived snapshot list', async () => {
@@ -8102,15 +8167,15 @@ describe('Server Routes', () => {
8102
8167
  }, { cookie });
8103
8168
  const res = createMockRes();
8104
8169
  await match?.handler(req, res, {});
8105
- expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith({
8170
+ expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledWith(expect.objectContaining({
8106
8171
  tasks: [],
8107
- archived: [{
8172
+ archived: [expect.objectContaining({
8108
8173
  id: 'task-archived-1',
8109
8174
  title: 'Archived Task',
8110
8175
  workstreamId: 'workstream-archived-1',
8111
8176
  isArchived: true
8112
- }]
8113
- }, 'workspace-1');
8177
+ })]
8178
+ }), 'workspace-1');
8114
8179
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8115
8180
  });
8116
8181
  it('GET /api/taskforce/sync/workspace/pull should include canceledReason and archived marker in response payload', async () => {
@@ -8236,7 +8301,7 @@ describe('Server Routes', () => {
8236
8301
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8237
8302
  const response = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
8238
8303
  const changes = Array.isArray(response?.changes) ? response.changes : [];
8239
- expect(changes).toContainEqual({
8304
+ expect(changes).toContainEqual(expect.objectContaining({
8240
8305
  op: 'taxonomy-upsert',
8241
8306
  taxonomyState: {
8242
8307
  categories: [{ value: 'default', label: 'General' }],
@@ -8272,8 +8337,9 @@ describe('Server Routes', () => {
8272
8337
  filterEnabled: true,
8273
8338
  sortEnabled: true
8274
8339
  }],
8340
+ updatedAt: '2026-02-22T10:00:00.000Z',
8275
8341
  watermark: '2026-02-22T10:00:00.000Z'
8276
- });
8342
+ }));
8277
8343
  });
8278
8344
  it('POST /api/taskforce/workspaces should create workspace for authenticated user', async () => {
8279
8345
  const authConfig = {
@@ -8673,6 +8739,215 @@ describe('Server Routes', () => {
8673
8739
  expect(payload.betaAccess).toBe(false);
8674
8740
  expect(core.getUserBetaAccess).toHaveBeenCalledWith('u-no-workspace');
8675
8741
  });
8742
+ it('GET /api/taskforce/auth/session should expose avatarUrl from the resolved profile', async () => {
8743
+ const authConfig = {
8744
+ runtimeMode: 'cloud',
8745
+ enabled: true,
8746
+ requiredForApi: true,
8747
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
8748
+ defaultRole: 'member',
8749
+ defaultWorkspaceId: 'default',
8750
+ sessionCookieName: 'taskforce_session',
8751
+ sessionSecret: 'test-secret',
8752
+ sessionTtlSeconds: 3600
8753
+ };
8754
+ routes = createRoutes(core, { authConfig });
8755
+ core.resolveUserIdentity.mockReturnValueOnce({
8756
+ userId: 'u-avatar',
8757
+ email: 'avatar@example.com',
8758
+ displayName: 'Avatar User',
8759
+ avatarUrl: '/api/taskforce/context/users%2Fu-avatar%2Fprofile%2Favatar%2Fdraft.png'
8760
+ });
8761
+ const cookie = createSessionTokenCookie('session-avatar-auth', authConfig, {
8762
+ userId: 'u-avatar',
8763
+ role: 'member',
8764
+ workspaceId: 'workspace-1'
8765
+ });
8766
+ const match = matchRoute('GET', '/api/taskforce/auth/session', routes);
8767
+ const req = createMockReq('GET', '/api/taskforce/auth/session', undefined, { cookie });
8768
+ const res = createMockRes();
8769
+ await match?.handler(req, res, {});
8770
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
8771
+ expect(payload.authenticated).toBe(true);
8772
+ expect(payload.avatarUrl).toBe('/api/taskforce/context/users%2Fu-avatar%2Fprofile%2Favatar%2Fdraft.png');
8773
+ });
8774
+ it('POST /api/taskforce/auth/profile should save avatar by validated draft id', async () => {
8775
+ const authConfig = {
8776
+ runtimeMode: 'cloud',
8777
+ enabled: true,
8778
+ requiredForApi: true,
8779
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
8780
+ defaultRole: 'member',
8781
+ defaultWorkspaceId: 'default',
8782
+ sessionCookieName: 'taskforce_session',
8783
+ sessionSecret: 'test-secret',
8784
+ sessionTtlSeconds: 3600
8785
+ };
8786
+ routes = createRoutes(core, { authConfig });
8787
+ core.getUserAvatarUploadDraft.mockReturnValueOnce({
8788
+ draftId: 'avatar-draft-1',
8789
+ userId: 'u1',
8790
+ storageKey: 'users/u1/profile/avatar/avatar-draft-1-headshot.png',
8791
+ finalizedAt: '2026-04-09T00:00:00.000Z',
8792
+ discardedAt: null
8793
+ });
8794
+ core.resolveUserIdentity.mockReturnValueOnce({
8795
+ userId: 'u1',
8796
+ email: 'u1@example.com',
8797
+ displayName: 'Before',
8798
+ avatarUrl: null
8799
+ });
8800
+ core.updateUserProfile.mockReturnValueOnce({
8801
+ userId: 'u1',
8802
+ email: 'u1@example.com',
8803
+ displayName: 'Updated Name',
8804
+ avatarUrl: '/api/taskforce/context/users%2Fu1%2Fprofile%2Favatar%2Favatar-draft-1-headshot.png'
8805
+ });
8806
+ const cookie = createSessionTokenCookie('session-profile-auth', authConfig, {
8807
+ userId: 'u1',
8808
+ role: 'member',
8809
+ workspaceId: 'workspace-1'
8810
+ });
8811
+ const match = matchRoute('POST', '/api/taskforce/auth/profile', routes);
8812
+ const req = createMockReq('POST', '/api/taskforce/auth/profile', {
8813
+ displayName: 'Updated Name',
8814
+ avatarDraftId: 'avatar-draft-1'
8815
+ }, { cookie });
8816
+ const res = createMockRes();
8817
+ await match?.handler(req, res, {});
8818
+ expect(core.updateUserProfile).toHaveBeenCalledWith('u1', {
8819
+ displayName: 'Updated Name',
8820
+ avatarUrl: '/api/taskforce/context/users%2Fu1%2Fprofile%2Favatar%2Favatar-draft-1-headshot.png'
8821
+ });
8822
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
8823
+ action: 'auth-profile-updated',
8824
+ actorUserId: 'u1',
8825
+ actorRole: 'member'
8826
+ }));
8827
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
8828
+ expect(payload.profile.avatarUrl).toBe('/api/taskforce/context/users%2Fu1%2Fprofile%2Favatar%2Favatar-draft-1-headshot.png');
8829
+ });
8830
+ it('POST /api/taskforce/auth/profile/avatar/init should create a signed avatar upload draft', async () => {
8831
+ const authConfig = {
8832
+ runtimeMode: 'cloud',
8833
+ enabled: true,
8834
+ requiredForApi: true,
8835
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
8836
+ defaultRole: 'member',
8837
+ defaultWorkspaceId: 'default',
8838
+ sessionCookieName: 'taskforce_session',
8839
+ sessionSecret: 'test-secret',
8840
+ sessionTtlSeconds: 3600
8841
+ };
8842
+ const routesWithStorage = createRoutes(core, {
8843
+ authConfig,
8844
+ objectStorage: {
8845
+ provider: 'r2',
8846
+ r2: {
8847
+ accountId: 'acct-1',
8848
+ bucket: 'taskforce-docs',
8849
+ endpoint: 'https://example.r2.cloudflarestorage.com',
8850
+ accessKeyId: 'access-key-1',
8851
+ secretAccessKey: 'secret-key-1',
8852
+ keyPrefix: 'taskforce',
8853
+ signedUploadTtlSeconds: 600,
8854
+ signedDownloadTtlSeconds: 300
8855
+ }
8856
+ }
8857
+ });
8858
+ const cookie = createSessionTokenCookie('session-avatar-init', authConfig, {
8859
+ userId: 'u1',
8860
+ role: 'member',
8861
+ workspaceId: 'workspace-1'
8862
+ });
8863
+ const match = matchRoute('POST', '/api/taskforce/auth/profile/avatar/init', routesWithStorage);
8864
+ const req = createMockReq('POST', '/api/taskforce/auth/profile/avatar/init', {
8865
+ originalName: 'headshot.png',
8866
+ mimeType: 'image/png',
8867
+ size: 1024
8868
+ }, { cookie });
8869
+ const res = createMockRes();
8870
+ await match?.handler(req, res, {});
8871
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
8872
+ expect(payload.success).toBe(true);
8873
+ expect(payload.draftId).toMatch(/^avatar-draft-/);
8874
+ expect(payload.relativePath).toMatch(/^users\/u1\/profile\/avatar\/avatar-draft-[a-f0-9]{32}-headshot\.png$/);
8875
+ expect(payload.uploadUrl).toContain('X-Amz-Signature=');
8876
+ expect(core.createUserAvatarUploadDraft).toHaveBeenCalledWith(expect.objectContaining({
8877
+ userId: 'u1',
8878
+ draftId: payload.draftId,
8879
+ storageKey: payload.relativePath,
8880
+ mimeType: 'image/png'
8881
+ }));
8882
+ });
8883
+ it('POST /api/taskforce/auth/profile/avatar/finalize should validate and finalize the uploaded draft', async () => {
8884
+ const authConfig = {
8885
+ runtimeMode: 'cloud',
8886
+ enabled: true,
8887
+ requiredForApi: true,
8888
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
8889
+ defaultRole: 'member',
8890
+ defaultWorkspaceId: 'default',
8891
+ sessionCookieName: 'taskforce_session',
8892
+ sessionSecret: 'test-secret',
8893
+ sessionTtlSeconds: 3600
8894
+ };
8895
+ const fetchMock = vi.fn().mockResolvedValue({
8896
+ ok: true,
8897
+ status: 200,
8898
+ statusText: 'OK',
8899
+ headers: { get: () => 'image/png' },
8900
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer
8901
+ });
8902
+ vi.stubGlobal('fetch', fetchMock);
8903
+ const routesWithStorage = createRoutes(core, {
8904
+ authConfig,
8905
+ objectStorage: {
8906
+ provider: 'r2',
8907
+ r2: {
8908
+ accountId: 'acct-1',
8909
+ bucket: 'taskforce-docs',
8910
+ endpoint: 'https://example.r2.cloudflarestorage.com',
8911
+ accessKeyId: 'access-key-1',
8912
+ secretAccessKey: 'secret-key-1',
8913
+ keyPrefix: 'taskforce',
8914
+ signedUploadTtlSeconds: 600,
8915
+ signedDownloadTtlSeconds: 300
8916
+ }
8917
+ }
8918
+ });
8919
+ core.getUserAvatarUploadDraft.mockReturnValueOnce({
8920
+ draftId: 'avatar-draft-1',
8921
+ userId: 'u1',
8922
+ storageKey: 'users/u1/profile/avatar/avatar-draft-1-headshot.png',
8923
+ finalizedAt: null,
8924
+ discardedAt: null
8925
+ });
8926
+ core.markUserAvatarUploadDraftFinalized.mockReturnValueOnce({
8927
+ draftId: 'avatar-draft-1',
8928
+ userId: 'u1',
8929
+ storageKey: 'users/u1/profile/avatar/avatar-draft-1-headshot.png',
8930
+ finalizedAt: '2026-04-09T00:00:00.000Z',
8931
+ discardedAt: null
8932
+ });
8933
+ const cookie = createSessionTokenCookie('session-avatar-finalize', authConfig, {
8934
+ userId: 'u1',
8935
+ role: 'member',
8936
+ workspaceId: 'workspace-1'
8937
+ });
8938
+ const match = matchRoute('POST', '/api/taskforce/auth/profile/avatar/finalize', routesWithStorage);
8939
+ const req = createMockReq('POST', '/api/taskforce/auth/profile/avatar/finalize', {
8940
+ draftId: 'avatar-draft-1',
8941
+ relativePath: 'users/u1/profile/avatar/avatar-draft-1-headshot.png'
8942
+ }, { cookie });
8943
+ const res = createMockRes();
8944
+ await match?.handler(req, res, {});
8945
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
8946
+ expect(payload.success).toBe(true);
8947
+ expect(payload.avatarUrl).toBe('/api/taskforce/context/users%2Fu1%2Fprofile%2Favatar%2Favatar-draft-1-headshot.png');
8948
+ expect(core.markUserAvatarUploadDraftFinalized).toHaveBeenCalledWith('u1', 'avatar-draft-1');
8949
+ vi.unstubAllGlobals();
8950
+ });
8676
8951
  it('GET /api/taskforce/admin/system-users should require system admin role', async () => {
8677
8952
  const match = matchRoute('GET', '/api/taskforce/admin/system-users', routes);
8678
8953
  const req = createMockReq('GET', '/api/taskforce/admin/system-users', undefined, { 'x-taskforce-system-role': 'user' });
@@ -8701,13 +8976,116 @@ describe('Server Routes', () => {
8701
8976
  expect(payload.planId).toBe('collab');
8702
8977
  expect(payload.features['collaboration.invites'].allowed).toBe(true);
8703
8978
  });
8979
+ it('GET /api/taskforce/account/team-management-access should return unified role and team plan access', async () => {
8980
+ core.resolveUserAccess.mockReturnValueOnce({
8981
+ userId: 'u1',
8982
+ workspaceId: 'default',
8983
+ role: 'owner',
8984
+ disabled: false,
8985
+ betaAccess: true
8986
+ });
8987
+ const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
8988
+ const req = createMockReq('GET', '/api/taskforce/account/team-management-access', undefined, {
8989
+ 'x-taskforce-user-id': 'u1',
8990
+ 'x-taskforce-workspace-id': 'workspace-1',
8991
+ 'x-taskforce-role': 'owner'
8992
+ });
8993
+ const res = createMockRes();
8994
+ await match?.handler(req, res, {});
8995
+ expect(core.resolveUserAccess).toHaveBeenCalledWith('u1', 'default', 'member');
8996
+ expect(core.getAccountEntitlementsPayload).toHaveBeenCalledWith({ userId: 'u1', workspaceId: 'default' });
8997
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8998
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
8999
+ expect(payload).toMatchObject({
9000
+ workspaceId: 'default',
9001
+ role: 'owner',
9002
+ canManageWorkspace: true,
9003
+ teamPlanMode: 'team',
9004
+ allowed: true
9005
+ });
9006
+ });
9007
+ it('GET /api/taskforce/account/team-management-access should fall back to the hinted workspace role when local access cannot resolve the cloud user id', async () => {
9008
+ core.resolveUserAccess.mockReturnValueOnce(null);
9009
+ const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
9010
+ const req = createMockReq('GET', '/api/taskforce/account/team-management-access?workspaceId=workspace-1', undefined, {
9011
+ 'x-taskforce-user-id': 'u-cloud',
9012
+ 'x-taskforce-workspace-role': 'owner'
9013
+ });
9014
+ const res = createMockRes();
9015
+ await match?.handler(req, res, {});
9016
+ expect(core.resolveUserAccess).toHaveBeenCalledWith('u-cloud', 'workspace-1', 'member');
9017
+ expect(core.getAccountEntitlementsPayload).toHaveBeenCalledWith({ userId: 'u-cloud', workspaceId: 'workspace-1' });
9018
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
9019
+ expect(payload).toMatchObject({
9020
+ workspaceId: 'workspace-1',
9021
+ role: 'owner',
9022
+ canManageWorkspace: true,
9023
+ teamPlanMode: 'team',
9024
+ allowed: true
9025
+ });
9026
+ });
9027
+ it('GET /api/taskforce/account/team-management-access should still resolve access when billing tables are unavailable', async () => {
9028
+ core.resolveUserAccess.mockReturnValueOnce({
9029
+ userId: 'u1',
9030
+ workspaceId: 'workspace-1',
9031
+ role: 'owner',
9032
+ disabled: false,
9033
+ betaAccess: true
9034
+ });
9035
+ core.getAccountAccessStatus.mockReturnValueOnce({
9036
+ gate: 'ok',
9037
+ hasEntitlement: true,
9038
+ canAccessApp: true,
9039
+ canAccessPlans: true,
9040
+ planSelectionRequired: false,
9041
+ message: null,
9042
+ entitlement: {
9043
+ state: 'active',
9044
+ planId: 'collab',
9045
+ planVersionId: 'collab-v1'
9046
+ }
9047
+ });
9048
+ core.getAccountEntitlementsPayload.mockReturnValueOnce({
9049
+ state: 'active',
9050
+ planId: 'collab',
9051
+ features: {
9052
+ 'collaboration.team_management': { allowed: true }
9053
+ }
9054
+ });
9055
+ const baseDb = core.getDatabaseAdapter();
9056
+ core.getDatabaseAdapter = vi.fn().mockReturnValue({
9057
+ ...baseDb,
9058
+ prepare: vi.fn((sql) => {
9059
+ if (sql.includes('FROM user_billing') || sql.includes('FROM plan_catalog')) {
9060
+ throw new Error('no such table');
9061
+ }
9062
+ return baseDb.prepare(sql);
9063
+ })
9064
+ });
9065
+ const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
9066
+ const req = createMockReq('GET', '/api/taskforce/account/team-management-access?workspaceId=workspace-1', undefined, {
9067
+ 'x-taskforce-user-id': 'u1'
9068
+ });
9069
+ const res = createMockRes();
9070
+ await match?.handler(req, res, {});
9071
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9072
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
9073
+ expect(payload).toMatchObject({
9074
+ workspaceId: 'workspace-1',
9075
+ role: 'owner',
9076
+ canManageWorkspace: true,
9077
+ teamPlanMode: 'team',
9078
+ allowed: true,
9079
+ gate: 'ok',
9080
+ planSelectionRequired: false
9081
+ });
9082
+ });
8704
9083
  it('GET /api/taskforce/account/access-status should return pending plan-selection gate', async () => {
8705
9084
  core.getAccountAccessStatus.mockReturnValueOnce({
8706
9085
  gate: 'plan_selection_required',
8707
9086
  hasEntitlement: false,
8708
9087
  canAccessApp: false,
8709
9088
  canAccessPlans: true,
8710
- signupMonetizationStrategy: 'plan_selection_required',
8711
9089
  planSelectionRequired: true,
8712
9090
  message: 'Choose a plan to continue.',
8713
9091
  entitlement: null
@@ -8733,7 +9111,6 @@ describe('Server Routes', () => {
8733
9111
  hasEntitlement: false,
8734
9112
  canAccessApp: false,
8735
9113
  canAccessPlans: true,
8736
- signupMonetizationStrategy: 'plan_selection_required',
8737
9114
  planSelectionRequired: true,
8738
9115
  message: 'Choose a plan to continue.',
8739
9116
  entitlement: null
@@ -8748,6 +9125,87 @@ describe('Server Routes', () => {
8748
9125
  expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
8749
9126
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"PLAN_SELECTION_REQUIRED"'));
8750
9127
  });
9128
+ it('GET /api/taskforce/account/profile-summary should return checkout-pending recovery context', async () => {
9129
+ core.resolveUserAccess.mockReturnValueOnce({
9130
+ userId: 'u1',
9131
+ workspaceId: 'workspace-1',
9132
+ role: 'owner',
9133
+ disabled: false,
9134
+ betaAccess: true
9135
+ });
9136
+ core.getAccountAccessStatus.mockReturnValueOnce({
9137
+ gate: 'checkout_pending',
9138
+ hasEntitlement: true,
9139
+ canAccessApp: false,
9140
+ canAccessPlans: true,
9141
+ planSelectionRequired: false,
9142
+ message: 'Complete checkout to continue.',
9143
+ entitlement: {
9144
+ accountId: 'u1',
9145
+ planId: 'pro',
9146
+ planVersionId: 'pro-v1',
9147
+ state: 'checkout_pending'
9148
+ }
9149
+ });
9150
+ core.getAccountEntitlementsPayload.mockImplementationOnce(() => {
9151
+ const error = new Error('Entitlement not found');
9152
+ error.code = 'ENTITLEMENT_NOT_FOUND';
9153
+ throw error;
9154
+ });
9155
+ const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
9156
+ const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
9157
+ 'x-taskforce-user-id': 'u1'
9158
+ });
9159
+ const res = createMockRes();
9160
+ await match?.handler(req, res, {});
9161
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9162
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
9163
+ expect(payload).toMatchObject({
9164
+ planId: 'pro',
9165
+ planVersionId: 'pro-v1',
9166
+ entitlementState: 'checkout_pending',
9167
+ gate: 'checkout_pending',
9168
+ message: 'Complete checkout to continue.',
9169
+ billingConflictCode: null,
9170
+ planSelectionRequired: false,
9171
+ workspaceId: 'workspace-1',
9172
+ workspaceRole: 'owner',
9173
+ canManageWorkspace: true,
9174
+ teamPlanMode: 'personal',
9175
+ teamManagementAllowed: false
9176
+ });
9177
+ });
9178
+ it('GET /api/taskforce/account/entitlements should surface checkout-pending gate when billing is not complete', async () => {
9179
+ core.getAccountEntitlementsPayload.mockImplementationOnce(() => {
9180
+ const error = new Error('Entitlement not found');
9181
+ error.code = 'ENTITLEMENT_NOT_FOUND';
9182
+ throw error;
9183
+ });
9184
+ core.getAccountAccessStatus.mockReturnValueOnce({
9185
+ gate: 'checkout_pending',
9186
+ hasEntitlement: true,
9187
+ canAccessApp: false,
9188
+ canAccessPlans: true,
9189
+ planSelectionRequired: false,
9190
+ message: 'Complete checkout to continue.',
9191
+ entitlement: {
9192
+ accountId: 'u1',
9193
+ planId: 'pro',
9194
+ planVersionId: 'pro-v1',
9195
+ state: 'checkout_pending'
9196
+ }
9197
+ });
9198
+ const match = matchRoute('GET', '/api/taskforce/account/entitlements', routes);
9199
+ const req = createMockReq('GET', '/api/taskforce/account/entitlements', undefined, {
9200
+ 'x-taskforce-user-id': 'u1',
9201
+ 'x-taskforce-workspace-id': 'workspace-1'
9202
+ });
9203
+ const res = createMockRes();
9204
+ await match?.handler(req, res, {});
9205
+ expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
9206
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"CHECKOUT_PENDING"'));
9207
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"gate":"checkout_pending"'));
9208
+ });
8751
9209
  it('GET /api/taskforce/config should block authenticated users who must select a plan first', async () => {
8752
9210
  const authConfig = {
8753
9211
  runtimeMode: 'cloud',
@@ -8771,7 +9229,6 @@ describe('Server Routes', () => {
8771
9229
  hasEntitlement: false,
8772
9230
  canAccessApp: false,
8773
9231
  canAccessPlans: true,
8774
- signupMonetizationStrategy: 'plan_selection_required',
8775
9232
  planSelectionRequired: true,
8776
9233
  message: 'Choose a plan to continue.',
8777
9234
  entitlement: null
@@ -8810,7 +9267,6 @@ describe('Server Routes', () => {
8810
9267
  hasEntitlement: false,
8811
9268
  canAccessApp: false,
8812
9269
  canAccessPlans: true,
8813
- signupMonetizationStrategy: 'auto_assign_default',
8814
9270
  planSelectionRequired: false,
8815
9271
  message: 'No entitlement is linked to this account.',
8816
9272
  entitlement: null
@@ -9403,6 +9859,64 @@ describe('Server Routes', () => {
9403
9859
  }));
9404
9860
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9405
9861
  });
9862
+ it('DELETE /api/taskforce/admin/system-users/:id should purge a user in allowed cloud environments', async () => {
9863
+ const authConfig = { runtimeMode: 'cloud', enabled: false };
9864
+ routes = createRoutes(core, { authConfig });
9865
+ core.purgeSystemUser.mockReturnValue({
9866
+ targetUserId: 'u1',
9867
+ email: 'u1@example.com',
9868
+ unassignedTaskCount: 2,
9869
+ clearedInitiativeOwnerCount: 0,
9870
+ clearedWorkstreamOwnerCount: 0,
9871
+ deletedWorkspaceMembershipCount: 1,
9872
+ deletedAuthTokenCount: 1,
9873
+ deletedAvatarDraftCount: 0,
9874
+ deletedMcpAccessTokenCount: 1,
9875
+ deletedMcpOauthAuthCodeCount: 0,
9876
+ deletedMcpOauthConnectorGrantCount: 0,
9877
+ deletedUiStateCount: 1,
9878
+ deletedUserPreferenceCount: 1,
9879
+ deletedEntitlementCount: 1,
9880
+ deletedUserBillingCount: 1,
9881
+ deletedUserCount: 1
9882
+ });
9883
+ const match = matchRoute('DELETE', '/api/taskforce/admin/system-users/u1', routes);
9884
+ const req = createMockReq('DELETE', '/api/taskforce/admin/system-users/u1', { emailConfirmation: 'u1@example.com' }, {
9885
+ 'x-taskforce-system-role': 'system_admin',
9886
+ 'x-taskforce-user-id': 'admin-user',
9887
+ host: 'performance-app.taskforcehq.ai',
9888
+ 'x-forwarded-proto': 'https'
9889
+ });
9890
+ const res = createMockRes();
9891
+ await match?.handler(req, res, { id: 'u1' });
9892
+ expect(core.purgeSystemUser).toHaveBeenCalledWith({
9893
+ targetUserId: 'u1',
9894
+ actorUserId: 'admin-user',
9895
+ emailConfirmation: 'u1@example.com'
9896
+ });
9897
+ expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
9898
+ action: 'purge-system-user',
9899
+ actorRole: 'system_admin',
9900
+ workspaceId: 'system'
9901
+ }));
9902
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
9903
+ });
9904
+ it('DELETE /api/taskforce/admin/system-users/:id should reject production environment purges', async () => {
9905
+ const authConfig = { runtimeMode: 'cloud', enabled: false };
9906
+ routes = createRoutes(core, { authConfig });
9907
+ const match = matchRoute('DELETE', '/api/taskforce/admin/system-users/u1', routes);
9908
+ const req = createMockReq('DELETE', '/api/taskforce/admin/system-users/u1', { emailConfirmation: 'u1@example.com' }, {
9909
+ 'x-taskforce-system-role': 'system_admin',
9910
+ 'x-taskforce-user-id': 'admin-user',
9911
+ host: 'app.taskforcehq.ai',
9912
+ 'x-forwarded-proto': 'https'
9913
+ });
9914
+ const res = createMockRes();
9915
+ await match?.handler(req, res, { id: 'u1' });
9916
+ expect(core.purgeSystemUser).not.toHaveBeenCalled();
9917
+ expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
9918
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"SYSTEM_USER_PURGE_ENVIRONMENT_FORBIDDEN"'));
9919
+ });
9406
9920
  it('removed workspace-based plan route should not match', () => {
9407
9921
  expect(matchRoute('PATCH', '/api/taskforce/admin/workspace/plan', routes)).toBeNull();
9408
9922
  expect(matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes)).not.toBeNull();
@@ -9720,7 +10234,7 @@ describe('Server Routes', () => {
9720
10234
  await match?.handler(req, res, {});
9721
10235
  expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
9722
10236
  });
9723
- it('GET /api/taskforce/admin/system-settings should include signup monetization fields', async () => {
10237
+ it('GET /api/taskforce/admin/system-settings should include onboarding policy fields', async () => {
9724
10238
  core.getGlobalSettings.mockReturnValue({
9725
10239
  auth: {
9726
10240
  allowSelfRegistration: true,
@@ -9728,26 +10242,30 @@ describe('Server Routes', () => {
9728
10242
  defaultNewUserBetaAccess: false,
9729
10243
  authRateLimitEnabled: true,
9730
10244
  authRateLimitMaxRequests: 10,
9731
- authRateLimitWindowMs: 900000,
9732
- signupMonetizationStrategy: 'auto_assign_default',
9733
- defaultSignupPlanVersionId: 'starter-v1'
10245
+ authRateLimitWindowMs: 900000
10246
+ },
10247
+ billing: {
10248
+ onboardingPolicy: {
10249
+ mode: 'auto_grant_free',
10250
+ planVersionId: 'starter-v1'
10251
+ }
9734
10252
  }
9735
10253
  });
9736
- core.getSignupMonetizationSettings.mockReturnValue({
9737
- strategy: 'auto_assign_default',
9738
- defaultSignupPlanVersionId: 'starter-v1'
10254
+ core.getOnboardingPolicy.mockReturnValue({
10255
+ mode: 'auto_grant_free',
10256
+ planVersionId: 'starter-v1'
9739
10257
  });
9740
10258
  const match = matchRoute('GET', '/api/taskforce/admin/system-settings', routes);
9741
10259
  const req = createMockReq('GET', '/api/taskforce/admin/system-settings', undefined, { 'x-taskforce-role': 'admin' });
9742
10260
  const res = createMockRes();
9743
10261
  await match?.handler(req, res, {});
9744
10262
  const payload = JSON.parse(res.end.mock.calls[0][0]);
9745
- expect(payload.status.signupMonetizationStrategy).toBe('auto_assign_default');
9746
- expect(payload.status.defaultSignupPlanVersionId).toBe('starter-v1');
9747
- expect(payload.settings.signupMonetizationStrategy).toBe('auto_assign_default');
9748
- expect(payload.settings.defaultSignupPlanVersionId).toBe('starter-v1');
10263
+ expect(payload.status.onboardingPolicyMode).toBe('auto_grant_free');
10264
+ expect(payload.status.onboardingPlanVersionId).toBe('starter-v1');
10265
+ expect(payload.settings.onboardingPolicyMode).toBe('auto_grant_free');
10266
+ expect(payload.settings.onboardingPlanVersionId).toBe('starter-v1');
9749
10267
  });
9750
- it('POST /api/taskforce/admin/system-settings should persist signup monetization fields', async () => {
10268
+ it('POST /api/taskforce/admin/system-settings should persist onboarding policy fields', async () => {
9751
10269
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
9752
10270
  const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
9753
10271
  allowSelfRegistration: true,
@@ -9756,14 +10274,14 @@ describe('Server Routes', () => {
9756
10274
  authRateLimitEnabled: true,
9757
10275
  authRateLimitMaxRequests: 10,
9758
10276
  authRateLimitWindowMs: 900000,
9759
- signupMonetizationStrategy: 'auto_assign_default',
9760
- defaultSignupPlanVersionId: 'starter-v1'
10277
+ onboardingPolicyMode: 'auto_grant_free',
10278
+ onboardingPlanVersionId: 'starter-v1'
9761
10279
  }, { 'x-taskforce-role': 'admin' });
9762
10280
  const res = createMockRes();
9763
10281
  await match?.handler(req, res, {});
9764
- expect(core.validateSignupMonetizationSettings).toHaveBeenCalledWith({
9765
- strategy: 'auto_assign_default',
9766
- defaultSignupPlanVersionId: 'starter-v1'
10282
+ expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
10283
+ mode: 'auto_grant_free',
10284
+ planVersionId: 'starter-v1'
9767
10285
  });
9768
10286
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
9769
10287
  auth: {
@@ -9772,9 +10290,13 @@ describe('Server Routes', () => {
9772
10290
  defaultNewUserBetaAccess: false,
9773
10291
  authRateLimitEnabled: true,
9774
10292
  authRateLimitMaxRequests: 10,
9775
- authRateLimitWindowMs: 900000,
9776
- signupMonetizationStrategy: 'auto_assign_default',
9777
- defaultSignupPlanVersionId: 'starter-v1'
10293
+ authRateLimitWindowMs: 900000
10294
+ },
10295
+ billing: {
10296
+ onboardingPolicy: {
10297
+ mode: 'auto_grant_free',
10298
+ planVersionId: 'starter-v1'
10299
+ }
9778
10300
  }
9779
10301
  });
9780
10302
  });
@@ -9883,23 +10405,25 @@ describe('Server Routes', () => {
9883
10405
  expect(res.end.mock.calls[0][0]).toContain('window.__TASKFORCE_SITE_CONFIG__ = ');
9884
10406
  expect(res.end.mock.calls[0][0]).toContain('"pricingPageEnabled":false');
9885
10407
  });
9886
- it('POST /api/taskforce/admin/system-settings should reject invalid signup monetization settings', async () => {
9887
- core.validateSignupMonetizationSettings.mockReturnValueOnce({
10408
+ it('POST /api/taskforce/admin/system-settings should reject invalid onboarding settings', async () => {
10409
+ core.validateOnboardingPolicy.mockReturnValueOnce({
9888
10410
  valid: false,
9889
- code: 'SIGNUP_PLAN_VERSION_REQUIRED',
9890
- message: 'Default signup plan version is required when signup strategy is auto-assign default.'
10411
+ code: 'ONBOARDING_PLAN_VERSION_REQUIRED',
10412
+ message: 'An onboarding plan version is required for the selected onboarding mode.'
9891
10413
  });
9892
10414
  const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
9893
10415
  const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
9894
- signupMonetizationStrategy: 'auto_assign_default',
9895
- defaultSignupPlanVersionId: null
10416
+ onboardingPolicyMode: 'auto_grant_free',
10417
+ onboardingPlanVersionId: null
9896
10418
  }, { 'x-taskforce-role': 'admin' });
9897
10419
  const res = createMockRes();
9898
10420
  await match?.handler(req, res, {});
9899
10421
  expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
9900
10422
  expect(core.updateGlobalSettings).not.toHaveBeenCalledWith(expect.objectContaining({
9901
- auth: expect.objectContaining({
9902
- signupMonetizationStrategy: 'auto_assign_default'
10423
+ billing: expect.objectContaining({
10424
+ onboardingPolicy: expect.objectContaining({
10425
+ mode: 'auto_grant_free'
10426
+ })
9903
10427
  })
9904
10428
  }));
9905
10429
  });
@@ -10894,7 +11418,8 @@ describe('Server Routes', () => {
10894
11418
  expect(core.bulkCreateTasks).toHaveBeenCalledWith([newTask], {
10895
11419
  atomic: true,
10896
11420
  validateOnly: true,
10897
- workspaceId: 'default'
11421
+ workspaceId: 'default',
11422
+ provisionalLocalReference: true
10898
11423
  });
10899
11424
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10900
11425
  expect(res.end).toHaveBeenCalledWith(JSON.stringify({
@@ -10949,7 +11474,8 @@ describe('Server Routes', () => {
10949
11474
  expect(core.bulkCreateTasks).toHaveBeenCalledWith(inputs, {
10950
11475
  atomic: true,
10951
11476
  validateOnly: false,
10952
- workspaceId: 'default'
11477
+ workspaceId: 'default',
11478
+ provisionalLocalReference: true
10953
11479
  });
10954
11480
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
10955
11481
  expect(res.end).toHaveBeenCalledWith(JSON.stringify({
@@ -11023,7 +11549,8 @@ describe('Server Routes', () => {
11023
11549
  expect(core.bulkCreateTasks).toHaveBeenCalledWith(inputs, {
11024
11550
  atomic: true,
11025
11551
  validateOnly: true,
11026
- workspaceId: 'default'
11552
+ workspaceId: 'default',
11553
+ provisionalLocalReference: true
11027
11554
  });
11028
11555
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11029
11556
  expect(res.end).toHaveBeenCalledWith(JSON.stringify({
@@ -11044,6 +11571,36 @@ describe('Server Routes', () => {
11044
11571
  errors: []
11045
11572
  }));
11046
11573
  });
11574
+ it('PATCH /api/taskforce/task/:id should pass autosave updates through as non-audited route metadata', async () => {
11575
+ const updatedTask = { id: '123', description: 'Draft body' };
11576
+ core.updateTask.mockReturnValue(updatedTask);
11577
+ const url = '/api/taskforce/task/123';
11578
+ const match = matchRoute('PATCH', url, routes);
11579
+ const req = createMockReq('PATCH', url, { description: 'Draft body', saveSource: 'autosave' });
11580
+ const res = createMockRes();
11581
+ await match?.handler(req, res, { id: '123' });
11582
+ expect(core.updateTask).toHaveBeenCalledWith('123', { description: 'Draft body' }, 'default', {
11583
+ actorRef: 'user',
11584
+ skipTaskEvents: true
11585
+ });
11586
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11587
+ expect(res.end).toHaveBeenCalledWith(JSON.stringify(updatedTask));
11588
+ });
11589
+ it('PATCH /api/taskforce/task/:id should keep manual updates on the audited path', async () => {
11590
+ const updatedTask = { id: '123', description: 'Manual body' };
11591
+ core.updateTask.mockReturnValue(updatedTask);
11592
+ const url = '/api/taskforce/task/123';
11593
+ const match = matchRoute('PATCH', url, routes);
11594
+ const req = createMockReq('PATCH', url, { description: 'Manual body', saveSource: 'manual' });
11595
+ const res = createMockRes();
11596
+ await match?.handler(req, res, { id: '123' });
11597
+ expect(core.updateTask).toHaveBeenCalledWith('123', { description: 'Manual body' }, 'default', {
11598
+ actorRef: 'user',
11599
+ skipTaskEvents: false
11600
+ });
11601
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11602
+ expect(res.end).toHaveBeenCalledWith(JSON.stringify(updatedTask));
11603
+ });
11047
11604
  it('PATCH /api/taskforce/task/:id should return rule-error status/message', async () => {
11048
11605
  core.updateTask.mockImplementation(() => {
11049
11606
  const err = new Error('Task update blocked by rule check');