@taskforcehq/taskforce 0.3.304 → 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 (138) hide show
  1. package/README.md +140 -247
  2. package/dist/TaskforceCore.js +165 -70
  3. package/dist/TaskforceCore.test.js +799 -90
  4. package/dist/compat/workspaceSyncCompat.js +6 -0
  5. package/dist/components/features/DocumentIndex.d.ts +1 -1
  6. package/dist/components/features/DocumentIndex.js +1 -1
  7. package/dist/components/features/DocumentViewer.d.ts +1 -1
  8. package/dist/components/features/DocumentViewer.js +32 -9
  9. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  10. package/dist/components/features/DocumentWorkspace.js +12 -155
  11. package/dist/components/features/DocumentWorkspace.test.js +193 -6
  12. package/dist/components/features/TaskSettings.js +29 -3
  13. package/dist/components/features/TaskSettings.test.js +42 -1
  14. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  15. package/dist/components/features/documentWorkspaceModel.js +57 -0
  16. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  17. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  18. package/dist/components/task/TaskForm.d.ts +8 -8
  19. package/dist/components/task/TaskForm.js +358 -221
  20. package/dist/components/task/TaskForm.test.js +456 -381
  21. package/dist/components/task/TaskKanban.js +2 -1
  22. package/dist/components/views/PlanComparisonPage.js +5 -8
  23. package/dist/components/views/PlanComparisonPage.test.js +51 -41
  24. package/dist/components/views/PlansPage.js +126 -84
  25. package/dist/components/views/PlansPage.test.js +308 -39
  26. package/dist/components/views/StandaloneLayout.js +28 -22
  27. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  28. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
  29. package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
  30. package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
  31. package/dist/config/envSchema.js +1 -1
  32. package/dist/core/AiProfileService.d.ts +6 -1
  33. package/dist/core/AiProfileService.js +161 -16
  34. package/dist/core/AiProfileService.test.js +3 -1
  35. package/dist/core/AiProfiles.test.js +200 -0
  36. package/dist/core/AuthTokenService.test.d.ts +1 -0
  37. package/dist/core/AuthTokenService.test.js +78 -0
  38. package/dist/core/EntitlementsPolicy.test.js +75 -13
  39. package/dist/core/PlanEntitlementService.d.ts +7 -1
  40. package/dist/core/PlanEntitlementService.js +117 -42
  41. package/dist/core/PlanVersionPolicy.test.js +19 -26
  42. package/dist/core/SignupMonetizationSettings.test.js +46 -21
  43. package/dist/core/SystemAdmin.test.js +212 -56
  44. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  45. package/dist/core/Taskforce.d.ts +3 -0
  46. package/dist/core/Taskforce.js +50 -10
  47. package/dist/core/WorkspacePlanMode.test.js +12 -12
  48. package/dist/core/shared.d.ts +2 -0
  49. package/dist/core/shared.js +11 -0
  50. package/dist/hooks/sync/auth.js +3 -1
  51. package/dist/hooks/sync/transfers.d.ts +2 -1
  52. package/dist/hooks/sync/transfers.js +8 -4
  53. package/dist/hooks/useSyncOrchestrator.d.ts +7 -4
  54. package/dist/hooks/useSyncOrchestrator.js +75 -7
  55. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +80 -10
  56. package/dist/hooks/useTaskMutations.d.ts +2 -1
  57. package/dist/hooks/useTaskMutations.js +12 -1
  58. package/dist/hooks/useTaskMutations.test.js +30 -1
  59. package/dist/hooks/useTaskforce.d.ts +5 -2
  60. package/dist/hooks/useTaskforce.js +17 -5
  61. package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
  62. package/dist/hooks/useWorkspaceSyncController.d.ts +3 -1
  63. package/dist/hooks/useWorkspaceSyncController.js +11 -1
  64. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  65. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  66. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  67. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  68. package/dist/mcp/collaborationRegistrar.js +71 -0
  69. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  70. package/dist/mcp/documentAssetRegistrar.js +346 -0
  71. package/dist/mcp/runtime.js +2362 -3530
  72. package/dist/mcp/runtime.test.js +159 -0
  73. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  74. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  75. package/dist/mcp/toolCatalog.d.ts +35 -0
  76. package/dist/mcp/toolCatalog.js +3 -0
  77. package/dist/server/routes/admin.d.ts +1 -5
  78. package/dist/server/routes/admin.js +138 -61
  79. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  80. package/dist/server/routes/annotatedAttachments.js +1 -1
  81. package/dist/server/routes/auth.d.ts +1 -4
  82. package/dist/server/routes/auth.js +23 -63
  83. package/dist/server/routes/authSupport.d.ts +30 -0
  84. package/dist/server/routes/authSupport.js +81 -0
  85. package/dist/server/routes/billing.d.ts +1 -1
  86. package/dist/server/routes/billing.js +104 -188
  87. package/dist/server/routes/billing.test.js +214 -86
  88. package/dist/server/routes/documentReviews.d.ts +1 -1
  89. package/dist/server/routes/documentReviews.js +1 -1
  90. package/dist/server/routes/documents.d.ts +4 -2
  91. package/dist/server/routes/documents.js +73 -7
  92. package/dist/server/routes/primitives.d.ts +11 -0
  93. package/dist/server/routes/primitives.js +73 -0
  94. package/dist/server/routes/resources.d.ts +1 -1
  95. package/dist/server/routes/resources.js +1 -1
  96. package/dist/server/routes/shared.d.ts +1 -1
  97. package/dist/server/routes/sync.d.ts +1 -1
  98. package/dist/server/routes/sync.js +1 -1
  99. package/dist/server/routes/tasks.d.ts +1 -1
  100. package/dist/server/routes/tasks.js +1 -1
  101. package/dist/server/routes/workspaces.d.ts +1 -1
  102. package/dist/server/routes/workspaces.js +1 -1
  103. package/dist/server/routes.d.ts +2 -9
  104. package/dist/server/routes.js +76 -162
  105. package/dist/server/routes.test.js +672 -30
  106. package/dist/sync/contentSyncState.js +16 -7
  107. package/dist/sync/syncApplyHandlers.test.js +2 -0
  108. package/dist/sync/syncService.d.ts +1 -0
  109. package/dist/sync/syncService.js +8 -0
  110. package/dist/sync/syncService.test.js +14 -0
  111. package/dist/sync/workspaceSyncModel.d.ts +3 -0
  112. package/dist/sync/workspaceSyncModel.js +12 -3
  113. package/dist/sync/workspaceSyncModel.test.js +34 -0
  114. package/dist/sync/workspaceSyncState.d.ts +2 -0
  115. package/dist/sync/workspaceSyncState.js +1 -0
  116. package/dist/ui/assets/{AgentsModule-BLWVbuKP.js → AgentsModule-N2MnQBZk.js} +1 -1
  117. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
  118. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
  119. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
  120. package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-Dhm7M8e7.js} +1 -1
  121. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
  122. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
  123. package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BcS4afRn.js} +1 -1
  124. package/dist/ui/assets/{index-Ii0B0rTO.css → index-C2-OXZV7.css} +1 -1
  125. package/dist/ui/assets/index-DcSI5F86.js +6 -0
  126. package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-BSUaRwF8.js} +1 -1
  127. package/dist/ui/index.html +3 -3
  128. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  129. package/dist/utils/commercialLifecyclePresentation.js +196 -0
  130. package/package.json +8 -1
  131. package/scripts/check-cloud-mcp.mjs +83 -0
  132. package/scripts/playwright-auth.sh +5 -1
  133. package/scripts/run-billing-performance-smoke.sh +24 -0
  134. package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
  135. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  136. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
  137. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
  138. package/dist/ui/assets/index-DXUdtH1B.js +0 -6
@@ -684,6 +684,22 @@ describe('Server Routes', () => {
684
684
  res.__getBody = () => Buffer.concat(bodyChunks).toString('utf8');
685
685
  return res;
686
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
+ };
687
703
  it('GET /api/taskforce/tasks should return tasks', async () => {
688
704
  const mockTasks = { tasks: [], config: {} };
689
705
  core.listTasks.mockReturnValue(mockTasks);
@@ -1914,6 +1930,20 @@ describe('Server Routes', () => {
1914
1930
  });
1915
1931
  });
1916
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);
1917
1947
  core.inspectMcpAccessToken.mockReturnValue({
1918
1948
  tokenId: 'mcp-token-1',
1919
1949
  tokenPrefix: 'tfmcp_abc123',
@@ -1940,12 +1970,136 @@ describe('Server Routes', () => {
1940
1970
  });
1941
1971
  const res = createMockRes();
1942
1972
  res.req = req;
1943
- 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
+ }
1944
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
+ }));
1945
1990
  const payload = JSON.parse(res.end.mock.calls[0][0]);
1946
1991
  expect(payload.success).toBe(true);
1947
1992
  expect(payload.result.workspaceName).toBe('Workspace One');
1948
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));
1949
2103
  });
1950
2104
  it('POST /api/taskforce/settings/mcp/test-connection should report missing scopes', async () => {
1951
2105
  core.inspectMcpAccessToken.mockReturnValue({
@@ -2230,6 +2384,63 @@ describe('Server Routes', () => {
2230
2384
  expect(fetchMock).not.toHaveBeenCalled();
2231
2385
  vi.unstubAllGlobals();
2232
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
+ });
2233
2444
  it('GET /api/taskforce/documents/:assetId/content should reject non-document assets', async () => {
2234
2445
  const routesWithStorage = createRoutes(core, {
2235
2446
  objectStorage: {
@@ -3485,10 +3696,106 @@ describe('Server Routes', () => {
3485
3696
  const res = createMockRes();
3486
3697
  await match?.handler(req, res, { assetId: 'asset-doc-attached-1' });
3487
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
+ }));
3488
3705
  expect(store.get('asset-doc-attached-1', 'default')?.deletedAt).toBeNull();
3489
3706
  fs.rmSync(dbRoot, { recursive: true, force: true });
3490
3707
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3491
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
+ });
3492
3799
  it('PATCH /api/taskforce/documents/:assetId should update the shared display name without changing storage identity', async () => {
3493
3800
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-rename-'));
3494
3801
  core.getPaths.mockReturnValue({
@@ -3726,7 +4033,85 @@ describe('Server Routes', () => {
3726
4033
  }));
3727
4034
  expect(res.setHeader).not.toHaveBeenCalledWith('Set-Cookie', expect.any(String));
3728
4035
  expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
3729
- 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
+ });
3730
4115
  });
3731
4116
  it('POST /api/taskforce/auth/register should allow first-time registration without workspaceId', async () => {
3732
4117
  const authConfig = {
@@ -3851,35 +4236,37 @@ describe('Server Routes', () => {
3851
4236
  sessionSecret: 'test-secret',
3852
4237
  sessionTtlSeconds: 3600
3853
4238
  };
3854
- routes = createRoutes(core, { authConfig });
3855
- core.hasWorkspaceAdmin.mockReturnValueOnce(true);
3856
- core.isEmailVerified.mockReturnValueOnce(false);
3857
- core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-existing' });
3858
- const error = new Error('Email is already registered');
3859
- error.statusCode = 409;
3860
- error.code = 'EMAIL_ALREADY_EXISTS';
3861
- core.registerPasswordUser.mockImplementationOnce(() => {
3862
- throw error;
3863
- });
3864
- const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
3865
- const req = createMockReq('POST', '/api/taskforce/auth/register', {
3866
- email: 'owner@example.com',
3867
- password: 'password123',
3868
- workspaceId: 'workspace-1'
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
+ }));
3869
4269
  });
3870
- const res = createMockRes();
3871
- await match?.handler(req, res, {});
3872
- expect(core.isEmailVerified).toHaveBeenCalledWith('owner@example.com');
3873
- expect(core.requestEmailVerification).toHaveBeenCalledWith({ email: 'owner@example.com' });
3874
- expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
3875
- const responsePayload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
3876
- expect(responsePayload).toEqual(expect.objectContaining({
3877
- success: false,
3878
- code: 'EMAIL_ALREADY_EXISTS_UNVERIFIED',
3879
- verificationRequired: true,
3880
- emailSent: true,
3881
- verificationToken: 'verify-token-existing'
3882
- }));
3883
4270
  });
3884
4271
  it('POST /api/taskforce/auth/register should return conflict when verified email exists', async () => {
3885
4272
  const authConfig = {
@@ -3952,6 +4339,124 @@ describe('Server Routes', () => {
3952
4339
  code: 'SIGNUP_PLAN_VERSION_NOT_FOUND'
3953
4340
  }));
3954
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();
4426
+ });
4427
+ it('POST /api/taskforce/auth/password-reset/request should only expose resetToken when explicitly enabled', async () => {
4428
+ const authConfig = {
4429
+ runtimeMode: 'cloud',
4430
+ enabled: true,
4431
+ requiredForApi: true,
4432
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
4433
+ defaultRole: 'member',
4434
+ defaultWorkspaceId: 'default',
4435
+ sessionCookieName: 'taskforce_session',
4436
+ sessionSecret: 'test-secret',
4437
+ sessionTtlSeconds: 3600
4438
+ };
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();
4448
+ });
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');
4458
+ });
4459
+ });
3955
4460
  it('POST /api/taskforce/auth/verify-email/confirm should verify token', async () => {
3956
4461
  const authConfig = {
3957
4462
  runtimeMode: 'cloud',
@@ -4407,6 +4912,40 @@ describe('Server Routes', () => {
4407
4912
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
4408
4913
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
4409
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
+ });
4410
4949
  it('GET /api/taskforce/workspaces should list authenticated user workspaces', async () => {
4411
4950
  const authConfig = {
4412
4951
  runtimeMode: 'cloud',
@@ -5726,6 +6265,109 @@ describe('Server Routes', () => {
5726
6265
  ]));
5727
6266
  fs.rmSync(dbRoot, { recursive: true, force: true });
5728
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
+ });
5729
6371
  it('GET /api/taskforce/sync/workspace/pull should include document review session upserts and deletes', async () => {
5730
6372
  const authConfig = {
5731
6373
  runtimeMode: 'cloud',