@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
@@ -1415,7 +1415,7 @@ describe('Billing Webhook Routes', () => {
1415
1415
  stripe_subscription_id: 'sub_email_match'
1416
1416
  }));
1417
1417
  });
1418
- it('updates the existing paid subscription instead of creating a second checkout', async () => {
1418
+ it('updates the existing paid subscription and keeps canonical entitlement aligned with the new plan', async () => {
1419
1419
  const now = new Date().toISOString();
1420
1420
  db.prepare(`
1421
1421
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1476,12 +1476,26 @@ describe('Billing Webhook Routes', () => {
1476
1476
  planVersionId: 'team-v1'
1477
1477
  }));
1478
1478
  const entitlement = db.prepare(`
1479
- SELECT plan_version_id
1479
+ SELECT plan_version_id, entitlement_state, updated_by
1480
1480
  FROM account_entitlements
1481
1481
  WHERE tenant_id = 'default' AND account_id = 'u1'
1482
1482
  LIMIT 1
1483
1483
  `).get();
1484
- expect(entitlement?.plan_version_id).toBe('team-v1');
1484
+ expect(entitlement).toEqual(expect.objectContaining({
1485
+ plan_version_id: 'team-v1',
1486
+ entitlement_state: 'active',
1487
+ updated_by: 'checkout-confirm'
1488
+ }));
1489
+ const billingRow = db.prepare(`
1490
+ SELECT stripe_price_id, billing_interval
1491
+ FROM user_billing
1492
+ WHERE tenant_id = 'default' AND user_id = 'u1'
1493
+ LIMIT 1
1494
+ `).get();
1495
+ expect(billingRow).toEqual(expect.objectContaining({
1496
+ stripe_price_id: 'price_team_month',
1497
+ billing_interval: 'month'
1498
+ }));
1485
1499
  });
1486
1500
  it('updates the existing subscription when only the interval changes on the same plan', async () => {
1487
1501
  const now = new Date().toISOString();
@@ -1544,7 +1558,7 @@ describe('Billing Webhook Routes', () => {
1544
1558
  planVersionId: 'pro-v1'
1545
1559
  }));
1546
1560
  });
1547
- it('confirms a successful checkout session and updates the entitlement immediately', async () => {
1561
+ it('confirms a successful checkout session, activates canonical entitlement, and closes the checkout attempt', async () => {
1548
1562
  const now = new Date().toISOString();
1549
1563
  db.prepare(`
1550
1564
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1554,6 +1568,11 @@ describe('Billing Webhook Routes', () => {
1554
1568
  INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1555
1569
  VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1556
1570
  `).run(now, now);
1571
+ db.prepare(`
1572
+ INSERT INTO billing_checkout_attempts (
1573
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1574
+ ) VALUES ('cs_success_123', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
1575
+ `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1557
1576
  const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1558
1577
  expect(confirmHandler).toBeTypeOf('function');
1559
1578
  const req = createMockReq(JSON.stringify({
@@ -1570,14 +1589,36 @@ describe('Billing Webhook Routes', () => {
1570
1589
  }));
1571
1590
  expect(mockSubscriptionRetrieve).toHaveBeenCalledWith('sub_123');
1572
1591
  const entitlement = db.prepare(`
1573
- SELECT plan_version_id, entitlement_state
1592
+ SELECT plan_version_id, entitlement_state, updated_by
1574
1593
  FROM account_entitlements
1575
1594
  WHERE tenant_id = 'default' AND account_id = 'u1'
1576
1595
  LIMIT 1
1577
1596
  `).get();
1578
1597
  expect(entitlement).toEqual(expect.objectContaining({
1579
1598
  plan_version_id: 'pro-v1',
1580
- entitlement_state: 'active'
1599
+ entitlement_state: 'active',
1600
+ updated_by: 'checkout-confirm'
1601
+ }));
1602
+ const billingRow = db.prepare(`
1603
+ SELECT stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status
1604
+ FROM user_billing
1605
+ WHERE tenant_id = 'default' AND user_id = 'u1'
1606
+ LIMIT 1
1607
+ `).get();
1608
+ expect(billingRow).toEqual(expect.objectContaining({
1609
+ stripe_customer_id: 'cus_123',
1610
+ stripe_subscription_id: 'sub_123',
1611
+ stripe_price_id: 'price_pro_month',
1612
+ stripe_status: 'active'
1613
+ }));
1614
+ const attempt = db.prepare(`
1615
+ SELECT status
1616
+ FROM billing_checkout_attempts
1617
+ WHERE tenant_id = 'default' AND session_id = 'cs_success_123'
1618
+ LIMIT 1
1619
+ `).get();
1620
+ expect(attempt).toEqual(expect.objectContaining({
1621
+ status: 'confirmed'
1581
1622
  }));
1582
1623
  });
1583
1624
  it('does not activate entitlement when the checkout session is still incomplete', async () => {
@@ -1631,6 +1672,156 @@ describe('Billing Webhook Routes', () => {
1631
1672
  updated_by: 'checkout-start'
1632
1673
  }));
1633
1674
  });
1675
+ it('keeps checkout confirmation retryable until Stripe attaches the subscription to the completed session', async () => {
1676
+ const now = new Date().toISOString();
1677
+ db.prepare(`
1678
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1679
+ VALUES ('free-v1', 'default', 'free', 1, 1, '{}', ?, ?)
1680
+ `).run(now, now);
1681
+ db.prepare(`
1682
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1683
+ VALUES ('pro-v1', 'default', 'pro', 1, 0, '{}', ?, ?)
1684
+ `).run(now, now);
1685
+ db.prepare(`
1686
+ INSERT INTO account_entitlements (
1687
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1688
+ ) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'registration', ?, ?)
1689
+ `).run(now, now, now);
1690
+ db.prepare(`
1691
+ INSERT INTO billing_checkout_attempts (
1692
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1693
+ ) VALUES ('cs_pending_subscription', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
1694
+ `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1695
+ mockCheckoutRetrieve.mockResolvedValueOnce({
1696
+ id: 'cs_pending_subscription',
1697
+ mode: 'subscription',
1698
+ status: 'complete',
1699
+ payment_status: 'paid',
1700
+ customer: 'cus_123',
1701
+ subscription: null,
1702
+ metadata: { userId: 'u1', planVersionId: 'pro-v1' }
1703
+ });
1704
+ const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1705
+ const req = createMockReq(JSON.stringify({
1706
+ sessionId: 'cs_pending_subscription'
1707
+ }), {
1708
+ 'x-taskforce-runtime-mode': 'cloud',
1709
+ host: 'app.taskforcehq.ai'
1710
+ });
1711
+ const res = createMockRes();
1712
+ await confirmHandler(req, res, {});
1713
+ expect(res.statusCode).toBe(409);
1714
+ expect(JSON.parse(res.body)).toEqual({
1715
+ error: 'Checkout session is still finalizing your subscription.',
1716
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
1717
+ });
1718
+ expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
1719
+ const entitlement = db.prepare(`
1720
+ SELECT plan_version_id, entitlement_state
1721
+ FROM account_entitlements
1722
+ WHERE tenant_id = 'default' AND account_id = 'u1'
1723
+ LIMIT 1
1724
+ `).get();
1725
+ expect(entitlement).toEqual(expect.objectContaining({
1726
+ plan_version_id: 'free-v1',
1727
+ entitlement_state: 'active'
1728
+ }));
1729
+ const attempt = db.prepare(`
1730
+ SELECT status
1731
+ FROM billing_checkout_attempts
1732
+ WHERE tenant_id = 'default' AND session_id = 'cs_pending_subscription'
1733
+ LIMIT 1
1734
+ `).get();
1735
+ expect(attempt).toEqual(expect.objectContaining({
1736
+ status: 'open'
1737
+ }));
1738
+ });
1739
+ it('keeps checkout confirmation retryable until the subscription settles into an active commercial state', async () => {
1740
+ const now = new Date().toISOString();
1741
+ db.prepare(`
1742
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1743
+ VALUES ('free-v1', 'default', 'free', 1, 1, '{}', ?, ?)
1744
+ `).run(now, now);
1745
+ db.prepare(`
1746
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1747
+ VALUES ('pro-v1', 'default', 'pro', 1, 0, '{}', ?, ?)
1748
+ `).run(now, now);
1749
+ db.prepare(`
1750
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1751
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1752
+ `).run(now, now);
1753
+ db.prepare(`
1754
+ INSERT INTO user_billing (
1755
+ user_id, tenant_id, tier, entitlement_state, created_at, updated_at
1756
+ ) VALUES ('u1', 'default', 'free', 'active', ?, ?)
1757
+ `).run(now, now);
1758
+ db.prepare(`
1759
+ INSERT INTO account_entitlements (
1760
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1761
+ ) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'registration', ?, ?)
1762
+ `).run(now, now, now);
1763
+ db.prepare(`
1764
+ INSERT INTO billing_checkout_attempts (
1765
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1766
+ ) VALUES ('cs_wait_for_active', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
1767
+ `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1768
+ mockCheckoutRetrieve.mockResolvedValueOnce({
1769
+ id: 'cs_wait_for_active',
1770
+ mode: 'subscription',
1771
+ status: 'complete',
1772
+ payment_status: 'paid',
1773
+ customer: 'cus_123',
1774
+ subscription: 'sub_123',
1775
+ metadata: { userId: 'u1', planVersionId: 'pro-v1' }
1776
+ });
1777
+ mockSubscriptionRetrieve.mockResolvedValueOnce({
1778
+ id: 'sub_123',
1779
+ customer: 'cus_123',
1780
+ status: 'incomplete',
1781
+ trial_end: null,
1782
+ metadata: { userId: 'u1', planVersionId: 'pro-v1' },
1783
+ items: {
1784
+ data: [{
1785
+ id: 'si_123',
1786
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
1787
+ price: { id: 'price_pro_month', recurring: { interval: 'month' } }
1788
+ }]
1789
+ }
1790
+ });
1791
+ const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1792
+ const req = createMockReq(JSON.stringify({
1793
+ sessionId: 'cs_wait_for_active'
1794
+ }), {
1795
+ 'x-taskforce-runtime-mode': 'cloud',
1796
+ host: 'app.taskforcehq.ai'
1797
+ });
1798
+ const res = createMockRes();
1799
+ await confirmHandler(req, res, {});
1800
+ expect(res.statusCode).toBe(409);
1801
+ expect(JSON.parse(res.body)).toEqual({
1802
+ error: 'Checkout session is still finalizing your plan.',
1803
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
1804
+ });
1805
+ const entitlement = db.prepare(`
1806
+ SELECT plan_version_id, entitlement_state
1807
+ FROM account_entitlements
1808
+ WHERE tenant_id = 'default' AND account_id = 'u1'
1809
+ LIMIT 1
1810
+ `).get();
1811
+ expect(entitlement).toEqual(expect.objectContaining({
1812
+ plan_version_id: 'free-v1',
1813
+ entitlement_state: 'active'
1814
+ }));
1815
+ const attempt = db.prepare(`
1816
+ SELECT status
1817
+ FROM billing_checkout_attempts
1818
+ WHERE tenant_id = 'default' AND session_id = 'cs_wait_for_active'
1819
+ LIMIT 1
1820
+ `).get();
1821
+ expect(attempt).toEqual(expect.objectContaining({
1822
+ status: 'open'
1823
+ }));
1824
+ });
1634
1825
  it('rejects checkout confirmation when a newer pending checkout superseded the session plan', async () => {
1635
1826
  const now = new Date().toISOString();
1636
1827
  db.prepare(`
@@ -1928,14 +2119,7 @@ describe('Billing Webhook Routes', () => {
1928
2119
  `).get();
1929
2120
  expect(purged).toBeUndefined();
1930
2121
  });
1931
- it('lists stripe price options filtered by interval for system admin', async () => {
1932
- mockPricesList.mockResolvedValueOnce({
1933
- data: [
1934
- { id: 'price_month_1', active: true, currency: 'usd', unit_amount: 1200, recurring: { interval: 'month' }, nickname: 'Monthly', product: 'prod_1' },
1935
- { id: 'price_year_1', active: true, currency: 'usd', unit_amount: 12000, recurring: { interval: 'year' }, nickname: 'Yearly', product: 'prod_1' }
1936
- ],
1937
- has_more: false
1938
- });
2122
+ it('rejects manual stripe price option listing now that pricing is import-only', async () => {
1939
2123
  const route = routeHandlers.get('GET /api/taskforce/admin/billing/stripe-price-options');
1940
2124
  expect(route).toBeTypeOf('function');
1941
2125
  const req = createMockReq('', {
@@ -1944,16 +2128,11 @@ describe('Billing Webhook Routes', () => {
1944
2128
  req.url = '/api/taskforce/admin/billing/stripe-price-options?interval=month';
1945
2129
  const res = createMockRes();
1946
2130
  await route(req, res, {});
1947
- expect(res.statusCode).toBe(200);
2131
+ expect(res.statusCode).toBe(410);
1948
2132
  const body = JSON.parse(res.body);
1949
- expect(Array.isArray(body.options)).toBe(true);
1950
- expect(body.options.length).toBe(1);
1951
- expect(body.options[0]).toEqual(expect.objectContaining({
1952
- stripePriceId: 'price_month_1',
1953
- interval: 'month'
1954
- }));
2133
+ expect(body.code).toBe('BILLING_PRICE_MANUAL_MAPPING_REMOVED');
1955
2134
  });
1956
- it('keeps active manual price mappings during Stripe import when the price is still active but lacks plan metadata', async () => {
2135
+ it('keeps active imported price mappings during Stripe import when the Stripe price is still active but lacks plan metadata', async () => {
1957
2136
  const now = new Date().toISOString();
1958
2137
  db.prepare(`
1959
2138
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1992,23 +2171,12 @@ describe('Billing Webhook Routes', () => {
1992
2171
  `).get();
1993
2172
  expect(row.active).toBe(1);
1994
2173
  });
1995
- it('upserts price mappings and deactivates prior active mapping for same version interval', async () => {
2174
+ it('rejects manual price mapping upserts now that pricing is import-only', async () => {
1996
2175
  const now = new Date().toISOString();
1997
2176
  db.prepare(`
1998
2177
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1999
2178
  VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
2000
2179
  `).run(now, now);
2001
- db.prepare(`
2002
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
2003
- VALUES ('default', 'collab-v1', 'month', 'price_old', 'stripe', 1, 1100, 'usd', ?, ?)
2004
- `).run(now, now);
2005
- mockPriceRetrieve.mockResolvedValueOnce({
2006
- id: 'price_new',
2007
- active: true,
2008
- currency: 'usd',
2009
- unit_amount: 1500,
2010
- recurring: { interval: 'month' }
2011
- });
2012
2180
  const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2013
2181
  expect(upsertRoute).toBeTypeOf('function');
2014
2182
  const upsertReq = createMockReq(JSON.stringify({
@@ -2019,19 +2187,12 @@ describe('Billing Webhook Routes', () => {
2019
2187
  }), { 'x-taskforce-runtime-mode': 'cloud' });
2020
2188
  const upsertRes = createMockRes();
2021
2189
  await upsertRoute(upsertReq, upsertRes, {});
2022
- expect(upsertRes.statusCode).toBe(200);
2023
- const rows = db.prepare(`
2024
- SELECT stripe_price_id, active
2025
- FROM billing_price_mappings
2026
- WHERE tenant_id = 'default' AND plan_version_id = 'collab-v1' AND billing_interval = 'month'
2027
- ORDER BY stripe_price_id ASC
2028
- `).all();
2029
- expect(rows).toEqual([
2030
- { stripe_price_id: 'price_new', active: 1 },
2031
- { stripe_price_id: 'price_old', active: 0 }
2032
- ]);
2190
+ expect(upsertRes.statusCode).toBe(410);
2191
+ expect(JSON.parse(upsertRes.body)).toEqual(expect.objectContaining({
2192
+ code: 'BILLING_PRICE_MANUAL_MAPPING_REMOVED'
2193
+ }));
2033
2194
  });
2034
- it('allows the same stripe price id to be assigned to multiple plan versions', async () => {
2195
+ it('rejects manual reuse of a Stripe price id across multiple plan versions', async () => {
2035
2196
  const now = new Date().toISOString();
2036
2197
  db.prepare(`
2037
2198
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -2041,13 +2202,6 @@ describe('Billing Webhook Routes', () => {
2041
2202
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2042
2203
  VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
2043
2204
  `).run(now, now);
2044
- mockPriceRetrieve.mockResolvedValue({
2045
- id: 'price_shared',
2046
- active: true,
2047
- currency: 'usd',
2048
- unit_amount: 1900,
2049
- recurring: { interval: 'month' }
2050
- });
2051
2205
  const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2052
2206
  const firstReq = createMockReq(JSON.stringify({
2053
2207
  planVersionId: 'collab-v1',
@@ -2057,28 +2211,9 @@ describe('Billing Webhook Routes', () => {
2057
2211
  }), { 'x-taskforce-runtime-mode': 'cloud' });
2058
2212
  const firstRes = createMockRes();
2059
2213
  await upsertRoute(firstReq, firstRes, {});
2060
- expect(firstRes.statusCode).toBe(200);
2061
- const secondReq = createMockReq(JSON.stringify({
2062
- planVersionId: 'team-v1',
2063
- interval: 'month',
2064
- stripePriceId: 'price_shared',
2065
- active: true
2066
- }), { 'x-taskforce-runtime-mode': 'cloud' });
2067
- const secondRes = createMockRes();
2068
- await upsertRoute(secondReq, secondRes, {});
2069
- expect(secondRes.statusCode).toBe(200);
2070
- const rows = db.prepare(`
2071
- SELECT plan_version_id, stripe_price_id, active
2072
- FROM billing_price_mappings
2073
- WHERE tenant_id = 'default' AND stripe_price_id = 'price_shared'
2074
- ORDER BY plan_version_id ASC
2075
- `).all();
2076
- expect(rows).toEqual([
2077
- { plan_version_id: 'collab-v1', stripe_price_id: 'price_shared', active: 1 },
2078
- { plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
2079
- ]);
2214
+ expect(firstRes.statusCode).toBe(410);
2080
2215
  });
2081
- it('keeps reused mappings for other plan versions during Stripe import', async () => {
2216
+ it('keeps reused active mappings during Stripe import when the shared Stripe price is still active', async () => {
2082
2217
  const now = new Date().toISOString();
2083
2218
  db.prepare(`
2084
2219
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -2125,7 +2260,7 @@ describe('Billing Webhook Routes', () => {
2125
2260
  { plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
2126
2261
  ]);
2127
2262
  });
2128
- it('upserts explicit free pricing without requiring a Stripe price id', async () => {
2263
+ it('rejects manual free price mapping upserts now that pricing is import-only', async () => {
2129
2264
  const now = new Date().toISOString();
2130
2265
  db.prepare(`
2131
2266
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -2141,17 +2276,10 @@ describe('Billing Webhook Routes', () => {
2141
2276
  }), { 'x-taskforce-runtime-mode': 'cloud' });
2142
2277
  const upsertRes = createMockRes();
2143
2278
  await upsertRoute(upsertReq, upsertRes, {});
2144
- expect(upsertRes.statusCode).toBe(200);
2145
- const row = db.prepare(`
2146
- SELECT stripe_price_id, pricing_type, active, unit_amount
2147
- FROM billing_price_mappings
2148
- WHERE tenant_id = 'default' AND plan_version_id = 'starter-v1' AND billing_interval = 'month'
2149
- LIMIT 1
2150
- `).get();
2151
- expect(row.stripe_price_id).toBe('free:starter-v1:month');
2152
- expect(row.pricing_type).toBe('free');
2153
- expect(row.active).toBe(1);
2154
- expect(row.unit_amount).toBe(0);
2279
+ expect(upsertRes.statusCode).toBe(410);
2280
+ expect(JSON.parse(upsertRes.body)).toEqual(expect.objectContaining({
2281
+ code: 'BILLING_PRICE_MANUAL_MAPPING_REMOVED'
2282
+ }));
2155
2283
  });
2156
2284
  it('rejects webhook failure diagnostics for non-admin', async () => {
2157
2285
  const nonAdminHandlers = buildBillingRouteHandlers(db, { isAdmin: false });
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'http';
2
2
  import type { TaskforceCore } from '../../core/Taskforce.js';
3
- import type { RouteHandler } from '../routes.js';
3
+ import { type RouteHandler } from './primitives.js';
4
4
  export interface DocumentReviewRouteDependencies {
5
5
  addRoute: (method: string, path: string, handler: RouteHandler) => void;
6
6
  core: TaskforceCore;
@@ -1,4 +1,4 @@
1
- import { parseJsonBody } from '../routes.js';
1
+ import { parseJsonBody } from './primitives.js';
2
2
  import { resolveErrorStatusCode } from './shared.js';
3
3
  function writeJson(res, statusCode, payload) {
4
4
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
@@ -28,7 +28,7 @@ import { ObjectStorageClient } from '../../storage/objectStorageClient.js';
28
28
  import type { DocumentRegistryEntry } from '../../storage/documentRegistry.js';
29
29
  import { TaskAssetStore } from '../../storage/taskAssetStore.js';
30
30
  import { WorkspaceAssetStore, type WorkspaceAssetEntry, type WorkspaceAssetKind } from '../../storage/workspaceAssetStore.js';
31
- import type { RouteContext, RouteHandler } from '../routes.js';
31
+ import { type RouteContext, type RouteHandler } from './primitives.js';
32
32
  type AssetMetadata = DocumentRegistryEntry & {
33
33
  quarantineStatus?: 'none' | 'quarantined' | 'released';
34
34
  workspaceId?: string;
@@ -51,7 +51,9 @@ export interface DocumentRouteDependencies {
51
51
  resolveEffectiveObjectStorageConfig: () => ResolvedObjectStorageConfig;
52
52
  getObjectStorageClient: () => ObjectStorageClient | null;
53
53
  resolveStorageWorkspaceId: (req: IncomingMessage, body?: any) => string | null;
54
- ensureContextUploadAllowed: (req: IncomingMessage, res: ServerResponse, workspaceId: string) => boolean;
54
+ ensureContextUploadAllowed: (req: IncomingMessage, res: ServerResponse, workspaceId: string, options?: {
55
+ requestedBytes?: number | null;
56
+ }) => boolean;
55
57
  parseStorageFolderSegments: (folderPathRaw: unknown) => string[] | null;
56
58
  buildCanonicalWorkspaceAssetRelativePath: (params: {
57
59
  workspaceIdRaw: unknown;
@@ -29,21 +29,53 @@ import { purgeExpiredDocuments } from '../../storage/documentPurge.js';
29
29
  import { parsePlan } from '../../utils/planParser.js';
30
30
  import { getCanonicalDocumentName, getReadableDocumentName, isGeneratedDocumentName } from '../../utils/documentNames.js';
31
31
  import { requestRuntimeMode } from './shared.js';
32
- import { parseJsonBody } from '../routes.js';
32
+ import { parseJsonBody } from './primitives.js';
33
33
  export function registerDocumentRoutes(deps) {
34
34
  const { addRoute, core, context, requestWorkspaceId, ensureAdminRole, resolveEffectiveObjectStorageConfig, getObjectStorageClient, resolveStorageWorkspaceId, ensureContextUploadAllowed, parseStorageFolderSegments, buildCanonicalWorkspaceAssetRelativePath, inferAssetKindFromPath, appendStorageTelemetry, scanAndRecordDocument, buildTaskAttachmentPayload, workspaceAssetStore, taskAssetStore, normalizeDocumentPathInput, getAssetMetadata, getCanonicalAssetMetadata, documentRetentionDays, appendDocumentPurgeHistory, markAssetDeleted, isAssetDeleted, clearAssetDeleted, ensureWithinDir, sanitizeDownloadFilename, contentTypeForExt, inferInlineDocumentFromPath, resolveCanonicalDocumentRead, inferWorkspaceIdFromStoragePath, inferTaskIdFromStoragePath, } = deps;
35
+ const removeDocumentAttachmentFromTaskAttachments = (attachmentsRaw, asset) => {
36
+ const canonicalDocumentPath = `/api/taskforce/documents/${encodeURIComponent(asset.assetId)}/content`;
37
+ const legacyContextPath = `/api/taskforce/context/${encodeURIComponent(asset.storageKey)}`;
38
+ if (!Array.isArray(attachmentsRaw))
39
+ return [];
40
+ return attachmentsRaw.filter((attachment) => {
41
+ if (typeof attachment === 'string') {
42
+ const normalized = attachment.trim();
43
+ if (!normalized)
44
+ return false;
45
+ return normalized !== canonicalDocumentPath
46
+ && normalized !== legacyContextPath
47
+ && normalized !== asset.storageKey;
48
+ }
49
+ if (!attachment || typeof attachment !== 'object')
50
+ return false;
51
+ const candidate = attachment;
52
+ const candidatePath = String(candidate.path || '').trim();
53
+ const candidateFsPath = String(candidate.fsPath || '').trim();
54
+ const candidateAssetId = String(candidate.assetId || '').trim();
55
+ if (candidateAssetId && candidateAssetId === asset.assetId)
56
+ return false;
57
+ if (candidatePath === canonicalDocumentPath || candidatePath === legacyContextPath)
58
+ return false;
59
+ if (candidateFsPath && candidateFsPath === asset.storageKey)
60
+ return false;
61
+ return !!candidatePath;
62
+ });
63
+ };
35
64
  addRoute('POST', '/api/taskforce/context-upload/init', async (req, res) => {
36
65
  const resolvedObjectStorage = resolveEffectiveObjectStorageConfig();
37
66
  const objectStorageClient = getObjectStorageClient();
38
67
  const body = await parseJsonBody(req);
39
68
  const { originalName, mimeType, taskId, folderPath } = body || {};
40
69
  const workspaceId = resolveStorageWorkspaceId(req, body);
70
+ const requestedBytes = Number(body?.size);
41
71
  if (!workspaceId) {
42
72
  res.writeHead(400, { 'Content-Type': 'application/json' });
43
73
  res.end(JSON.stringify({ error: 'workspaceId is required and cannot be default for uploads.' }));
44
74
  return;
45
75
  }
46
- if (!ensureContextUploadAllowed(req, res, workspaceId))
76
+ if (!ensureContextUploadAllowed(req, res, workspaceId, {
77
+ requestedBytes: Number.isFinite(requestedBytes) ? requestedBytes : null
78
+ }))
47
79
  return;
48
80
  const folderSegments = parseStorageFolderSegments(folderPath);
49
81
  if (folderSegments === null) {
@@ -144,6 +176,10 @@ export function registerDocumentRoutes(deps) {
144
176
  res.end(JSON.stringify({ error: 'Uploaded file not found.' }));
145
177
  return;
146
178
  }
179
+ if (!ensureContextUploadAllowed(req, res, workspaceId, {
180
+ requestedBytes: object.body.length
181
+ }))
182
+ return;
147
183
  try {
148
184
  const taskId = typeof body?.taskId === 'string' && body.taskId.trim().length > 0
149
185
  ? body.taskId.trim()
@@ -191,8 +227,6 @@ export function registerDocumentRoutes(deps) {
191
227
  res.end(JSON.stringify({ error: 'workspaceId is required and cannot be default for uploads.' }));
192
228
  return;
193
229
  }
194
- if (!ensureContextUploadAllowed(req, res, workspaceId))
195
- return;
196
230
  const folderSegments = parseStorageFolderSegments(folderPath);
197
231
  if (folderSegments === null) {
198
232
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -233,6 +267,10 @@ export function registerDocumentRoutes(deps) {
233
267
  const actualFilename = `${safeBase}-${Date.now()}${extension}`;
234
268
  const { basePath, projectRoot } = core.getPaths();
235
269
  const buffer = Buffer.from(base64Data, 'base64');
270
+ if (!ensureContextUploadAllowed(req, res, workspaceId, {
271
+ requestedBytes: buffer.length
272
+ }))
273
+ return;
236
274
  const relativeToStorage = buildCanonicalWorkspaceAssetRelativePath({
237
275
  workspaceIdRaw: workspaceId,
238
276
  kind: mimeType.startsWith('image/') ? 'image' : inferAssetKindFromPath(actualFilename),
@@ -771,6 +809,8 @@ export function registerDocumentRoutes(deps) {
771
809
  addRoute('DELETE', '/api/taskforce/documents/:assetId', async (req, res, params) => {
772
810
  const workspaceId = requestWorkspaceId(req);
773
811
  const assetId = String(params.assetId || '').trim();
812
+ const body = await parseJsonBody(req);
813
+ const forceDetach = body?.forceDetach === true;
774
814
  if (!workspaceAssetStore || !assetId) {
775
815
  res.writeHead(404, { 'Content-Type': 'application/json' });
776
816
  res.end(JSON.stringify({ error: 'Document not found' }));
@@ -786,14 +826,39 @@ export function registerDocumentRoutes(deps) {
786
826
  .filter((link) => !link.deletedAt)
787
827
  .filter((link) => !!core.getTask(link.taskId, workspaceId));
788
828
  if (activeLinks.length > 0) {
789
- res.writeHead(409, { 'Content-Type': 'application/json' });
790
- res.end(JSON.stringify({ error: 'Document is still attached to one or more tasks.' }));
791
- return;
829
+ if (!forceDetach) {
830
+ res.writeHead(409, { 'Content-Type': 'application/json' });
831
+ res.end(JSON.stringify({
832
+ error: 'Document is still attached to one or more tasks.',
833
+ activeAttachmentCount: activeLinks.length,
834
+ requiresForceDetach: true
835
+ }));
836
+ return;
837
+ }
838
+ for (const link of activeLinks) {
839
+ const task = core.getTask(link.taskId, workspaceId);
840
+ if (!task)
841
+ continue;
842
+ const updatedAttachments = removeDocumentAttachmentFromTaskAttachments(task.attachments, asset);
843
+ core.updateTask(link.taskId, { attachments: updatedAttachments }, workspaceId, {
844
+ actorRef: 'user',
845
+ saveSource: 'manual'
846
+ });
847
+ }
792
848
  }
793
849
  const nowIso = new Date().toISOString();
794
850
  const purgeAfter = new Date(Date.now() + documentRetentionDays * 24 * 60 * 60 * 1000).toISOString();
795
851
  const metadata = markAssetDeleted(asset.storageKey);
796
852
  workspaceAssetStore.markDeleted(asset.assetId, workspaceId, nowIso, purgeAfter);
853
+ const links = workspaceAssetStore.listLinksForAsset(asset.assetId, workspaceId, { includeDeleted: true });
854
+ for (const link of links) {
855
+ workspaceAssetStore.deleteLink({
856
+ workspaceId,
857
+ assetId: asset.assetId,
858
+ taskId: link.taskId,
859
+ role: link.role
860
+ });
861
+ }
797
862
  appendStorageTelemetry('delete.soft', {
798
863
  path: asset.storageKey,
799
864
  status: 'soft-deleted',
@@ -803,6 +868,7 @@ export function registerDocumentRoutes(deps) {
803
868
  res.end(JSON.stringify({
804
869
  success: true,
805
870
  softDeleted: true,
871
+ detachedTaskCount: activeLinks.length,
806
872
  retentionDays: documentRetentionDays,
807
873
  purgeAfter: metadata?.purgeAfter || purgeAfter
808
874
  }));
@@ -0,0 +1,11 @@
1
+ import { IncomingMessage, ServerResponse } from 'http';
2
+ import type { TaskforceAuthConfig } from '../auth.js';
3
+ import type { ResolvedObjectStorageConfig } from '../../storage/objectStorage.js';
4
+ export type RouteHandler = (req: IncomingMessage, res: ServerResponse, params: Record<string, string>) => Promise<void>;
5
+ export interface RouteContext {
6
+ authConfig?: TaskforceAuthConfig;
7
+ objectStorage?: ResolvedObjectStorageConfig;
8
+ }
9
+ export declare function parseJsonBody(req: IncomingMessage, maxBytes?: number): Promise<any>;
10
+ export declare function parseFormBody(req: IncomingMessage, maxBytes?: number): Promise<Record<string, string>>;
11
+ export declare function escapeHtml(value: unknown): string;