@taskforcehq/taskforce 0.3.303 → 0.3.305

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +140 -247
  2. package/dist/Taskforce.module.css +102 -5
  3. package/dist/TaskforceCore.js +635 -238
  4. package/dist/TaskforceCore.test.js +2243 -495
  5. package/dist/annotatedAttachments/service.d.ts +1 -1
  6. package/dist/annotatedAttachments/types.d.ts +1 -1
  7. package/dist/compat/workspaceSyncCompat.js +6 -0
  8. package/dist/components/features/AgentsModule.js +1 -1
  9. package/dist/components/features/AgentsModule.test.js +1 -1
  10. package/dist/components/features/DocumentIndex.d.ts +1 -1
  11. package/dist/components/features/DocumentIndex.js +1 -1
  12. package/dist/components/features/DocumentViewer.d.ts +1 -1
  13. package/dist/components/features/DocumentViewer.js +32 -9
  14. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  15. package/dist/components/features/DocumentWorkspace.js +12 -155
  16. package/dist/components/features/DocumentWorkspace.test.js +193 -6
  17. package/dist/components/features/TaskSettings.js +87 -41
  18. package/dist/components/features/TaskSettings.test.js +180 -40
  19. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  20. package/dist/components/features/documentWorkspaceModel.js +57 -0
  21. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  22. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  23. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  24. package/dist/components/task/TaskActionHeader.js +3 -3
  25. package/dist/components/task/TaskActionHeader.test.js +8 -0
  26. package/dist/components/task/TaskCard.js +16 -1
  27. package/dist/components/task/TaskCard.test.js +31 -0
  28. package/dist/components/task/TaskForm.d.ts +9 -8
  29. package/dist/components/task/TaskForm.js +374 -219
  30. package/dist/components/task/TaskForm.test.js +482 -361
  31. package/dist/components/task/TaskKanban.d.ts +1 -1
  32. package/dist/components/task/TaskKanban.js +33 -161
  33. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  34. package/dist/components/task/TaskStatusActions.js +8 -3
  35. package/dist/components/task/TaskStatusActions.test.js +9 -0
  36. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  37. package/dist/components/task/taskKanbanDropRules.js +187 -0
  38. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  39. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  40. package/dist/components/ui/Modal.d.ts +1 -1
  41. package/dist/components/ui/Modal.js +1 -0
  42. package/dist/components/views/AppShellHeader.js +5 -1
  43. package/dist/components/views/AppShellHeader.test.js +4 -0
  44. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  45. package/dist/components/views/PlanComparisonPage.js +9 -10
  46. package/dist/components/views/PlanComparisonPage.test.js +55 -41
  47. package/dist/components/views/PlansPage.d.ts +5 -1
  48. package/dist/components/views/PlansPage.js +150 -81
  49. package/dist/components/views/PlansPage.test.js +367 -41
  50. package/dist/components/views/StandaloneLayout.js +135 -54
  51. package/dist/components/views/WidgetView.js +11 -2
  52. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  53. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
  54. package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
  55. package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
  56. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  57. package/dist/config/envSchema.js +1 -1
  58. package/dist/core/AiProfileService.d.ts +6 -1
  59. package/dist/core/AiProfileService.js +161 -16
  60. package/dist/core/AiProfileService.test.js +3 -1
  61. package/dist/core/AiProfiles.test.js +200 -0
  62. package/dist/core/AuthTokenService.test.d.ts +1 -0
  63. package/dist/core/AuthTokenService.test.js +78 -0
  64. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  65. package/dist/core/EntitlementsPolicy.test.js +75 -13
  66. package/dist/core/PlanEntitlementService.d.ts +9 -1
  67. package/dist/core/PlanEntitlementService.js +144 -19
  68. package/dist/core/PlanVersionPolicy.test.js +19 -26
  69. package/dist/core/SignupMonetizationSettings.test.js +75 -15
  70. package/dist/core/SystemAdmin.test.js +212 -56
  71. package/dist/core/TaskActivityService.d.ts +4 -1
  72. package/dist/core/TaskActivityService.js +45 -0
  73. package/dist/core/TaskAuditTimeline.test.js +52 -1
  74. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  75. package/dist/core/Taskforce.d.ts +18 -11
  76. package/dist/core/Taskforce.js +93 -12
  77. package/dist/core/WorkspacePlanMode.test.js +73 -8
  78. package/dist/core/shared.d.ts +2 -0
  79. package/dist/core/shared.js +11 -0
  80. package/dist/documentReviews/service.d.ts +1 -1
  81. package/dist/documentReviews/types.d.ts +1 -1
  82. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  83. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  84. package/dist/hooks/sync/auth.d.ts +37 -0
  85. package/dist/hooks/sync/auth.js +63 -0
  86. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  87. package/dist/hooks/sync/bootstrap.js +52 -0
  88. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  89. package/dist/hooks/sync/orchestratorShared.js +209 -0
  90. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  91. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  92. package/dist/hooks/sync/realtime.d.ts +25 -0
  93. package/dist/hooks/sync/realtime.js +60 -0
  94. package/dist/hooks/sync/recovery.d.ts +69 -0
  95. package/dist/hooks/sync/recovery.js +118 -0
  96. package/dist/hooks/sync/transfers.d.ts +123 -0
  97. package/dist/hooks/sync/transfers.js +435 -0
  98. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  99. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  100. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  101. package/dist/hooks/ui/useResourceExport.js +5 -2
  102. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  103. package/dist/hooks/useRealtimeSync.js +2 -1
  104. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  105. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  106. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  107. package/dist/hooks/useSyncOrchestrator.d.ts +12 -8
  108. package/dist/hooks/useSyncOrchestrator.js +388 -879
  109. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +641 -6
  110. package/dist/hooks/useTaskMutations.d.ts +2 -1
  111. package/dist/hooks/useTaskMutations.js +57 -7
  112. package/dist/hooks/useTaskMutations.test.js +176 -0
  113. package/dist/hooks/useTaskforce.d.ts +13 -4
  114. package/dist/hooks/useTaskforce.js +302 -817
  115. package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
  116. package/dist/hooks/useWorkspaceSyncController.d.ts +4 -1
  117. package/dist/hooks/useWorkspaceSyncController.js +33 -11
  118. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  119. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  120. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  121. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  122. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  123. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  124. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  125. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  126. package/dist/localization/locales/en-US.js +2 -2
  127. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  128. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  129. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  130. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  131. package/dist/mcp/clientIntegrations.d.ts +12 -1
  132. package/dist/mcp/clientIntegrations.js +161 -6
  133. package/dist/mcp/clientIntegrations.test.js +76 -4
  134. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  135. package/dist/mcp/collaborationRegistrar.js +71 -0
  136. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  137. package/dist/mcp/documentAssetRegistrar.js +346 -0
  138. package/dist/mcp/documentHelpers.d.ts +100 -0
  139. package/dist/mcp/documentHelpers.js +161 -0
  140. package/dist/mcp/runtime.js +2455 -3925
  141. package/dist/mcp/runtime.test.js +240 -8
  142. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  143. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  144. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  145. package/dist/mcp/taskAttachmentTypes.js +1 -0
  146. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  147. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  148. package/dist/mcp/toolCatalog.d.ts +35 -0
  149. package/dist/mcp/toolCatalog.js +3 -0
  150. package/dist/plugins/vite.js +1 -1
  151. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  152. package/dist/runtime/appGateDefinitions.js +1 -1
  153. package/dist/server/index.d.ts +1 -0
  154. package/dist/server/index.js +46 -3
  155. package/dist/server/index.test.js +12 -1
  156. package/dist/server/routes/admin.d.ts +1 -5
  157. package/dist/server/routes/admin.js +151 -80
  158. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  159. package/dist/server/routes/annotatedAttachments.js +1 -1
  160. package/dist/server/routes/auth.d.ts +1 -4
  161. package/dist/server/routes/auth.js +59 -64
  162. package/dist/server/routes/authSupport.d.ts +30 -0
  163. package/dist/server/routes/authSupport.js +81 -0
  164. package/dist/server/routes/billing.d.ts +1 -1
  165. package/dist/server/routes/billing.js +122 -255
  166. package/dist/server/routes/billing.test.js +257 -86
  167. package/dist/server/routes/documentReviews.d.ts +1 -1
  168. package/dist/server/routes/documentReviews.js +1 -1
  169. package/dist/server/routes/documents.d.ts +4 -2
  170. package/dist/server/routes/documents.js +73 -7
  171. package/dist/server/routes/primitives.d.ts +11 -0
  172. package/dist/server/routes/primitives.js +73 -0
  173. package/dist/server/routes/resources.d.ts +1 -1
  174. package/dist/server/routes/resources.js +1 -1
  175. package/dist/server/routes/shared.d.ts +31 -1
  176. package/dist/server/routes/shared.js +26 -0
  177. package/dist/server/routes/sync.d.ts +1 -1
  178. package/dist/server/routes/sync.integration.test.js +20 -0
  179. package/dist/server/routes/sync.js +111 -238
  180. package/dist/server/routes/tasks.d.ts +1 -1
  181. package/dist/server/routes/tasks.js +50 -8
  182. package/dist/server/routes/workspaces.d.ts +1 -1
  183. package/dist/server/routes/workspaces.js +1 -1
  184. package/dist/server/routes.d.ts +2 -9
  185. package/dist/server/routes.js +84 -181
  186. package/dist/server/routes.test.js +912 -32
  187. package/dist/shared/taskActor.d.ts +10 -0
  188. package/dist/shared/taskActor.js +1 -0
  189. package/dist/sync/collaborationSyncPayload.js +4 -0
  190. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  191. package/dist/sync/collaborationSyncState.d.ts +3 -1
  192. package/dist/sync/collaborationSyncState.js +19 -0
  193. package/dist/sync/contentSyncState.js +28 -17
  194. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  195. package/dist/sync/syncApplyHandlers.js +370 -8
  196. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  197. package/dist/sync/syncApplyHandlers.test.js +379 -0
  198. package/dist/sync/syncService.d.ts +9 -1
  199. package/dist/sync/syncService.js +44 -2
  200. package/dist/sync/syncService.test.js +62 -0
  201. package/dist/sync/workspacePullFeed.d.ts +1 -0
  202. package/dist/sync/workspacePullFeed.js +66 -59
  203. package/dist/sync/workspacePullFeed.test.js +72 -4
  204. package/dist/sync/workspaceSyncModel.d.ts +25 -1
  205. package/dist/sync/workspaceSyncModel.js +77 -3
  206. package/dist/sync/workspaceSyncModel.test.js +153 -0
  207. package/dist/sync/workspaceSyncState.d.ts +2 -0
  208. package/dist/sync/workspaceSyncState.js +1 -0
  209. package/dist/types.d.ts +2 -10
  210. package/dist/ui/assets/AgentsModule-N2MnQBZk.js +1 -0
  211. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-C_TmXZwA.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
  212. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
  213. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
  214. package/dist/ui/assets/{InitiativesModule-4-Rh2K2n.js → InitiativesModule-Dhm7M8e7.js} +1 -1
  215. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  216. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
  217. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
  218. package/dist/ui/assets/{WorkflowsModule-CGk9s3NJ.js → WorkflowsModule-BcS4afRn.js} +1 -1
  219. package/dist/ui/assets/index-C2-OXZV7.css +1 -0
  220. package/dist/ui/assets/index-DcSI5F86.js +6 -0
  221. package/dist/ui/assets/{vendor-icons-pWocEipT.js → vendor-icons-BSUaRwF8.js} +1 -1
  222. package/dist/ui/index.html +4 -4
  223. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  224. package/dist/utils/commercialLifecyclePresentation.js +196 -0
  225. package/dist/utils/taskActivity.js +4 -0
  226. package/dist/utils/taskActivity.test.js +25 -1
  227. package/package.json +10 -1
  228. package/scripts/check-cloud-mcp.mjs +83 -0
  229. package/scripts/playwright-auth.sh +9 -1
  230. package/scripts/run-billing-performance-smoke.sh +24 -0
  231. package/dist/ui/assets/AgentsModule-CpsTmrIW.js +0 -1
  232. package/dist/ui/assets/DocumentWorkspace-C3GyzrWm.js +0 -250
  233. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  234. package/dist/ui/assets/PlansPage-BP7AYOqr.js +0 -1
  235. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +0 -8
  236. package/dist/ui/assets/index-CLIMgR6g.js +0 -6
  237. package/dist/ui/assets/index-DneETxcu.css +0 -1
@@ -211,6 +211,18 @@ function setupBillingSchema(db) {
211
211
  function buildBillingRouteHandlers(db, opts) {
212
212
  const routes = new Map();
213
213
  const tenantId = 'default';
214
+ const resolvePlanIdForVersion = (planVersionIdRaw) => {
215
+ const planVersionId = String(planVersionIdRaw || '').trim();
216
+ if (!planVersionId)
217
+ return '';
218
+ const row = db.prepare(`
219
+ SELECT plan_id
220
+ FROM plan_versions
221
+ WHERE tenant_id = ? AND plan_version_id = ?
222
+ LIMIT 1
223
+ `).get(tenantId, planVersionId);
224
+ return String(row?.plan_id || '').trim();
225
+ };
214
226
  const core = {
215
227
  getDatabaseAdapter: () => db,
216
228
  getTenantId: () => tenantId,
@@ -223,6 +235,37 @@ function buildBillingRouteHandlers(db, opts) {
223
235
  ON CONFLICT (user_id) DO NOTHING
224
236
  `).run(userId, tenantId, now, now);
225
237
  },
238
+ transitionCanonicalCommercialState: (input) => {
239
+ const now = new Date().toISOString();
240
+ const planVersionId = String(input.planVersionId || '').trim();
241
+ const effectiveAt = input.effectiveAt || now;
242
+ const effectiveUntil = input.effectiveUntil || null;
243
+ db.prepare(`
244
+ INSERT INTO account_entitlements (
245
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
246
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
247
+ ON CONFLICT (account_id) DO UPDATE SET
248
+ plan_version_id = excluded.plan_version_id,
249
+ entitlement_state = excluded.entitlement_state,
250
+ effective_at = excluded.effective_at,
251
+ effective_until = excluded.effective_until,
252
+ updated_by = excluded.updated_by,
253
+ updated_at = excluded.updated_at
254
+ `).run(input.accountId, tenantId, planVersionId, input.state, effectiveAt, effectiveUntil, input.updatedBy, input.createdAt || now, input.updatedAt || now);
255
+ const planId = typeof input.billingMirrorPlanId === 'string'
256
+ ? input.billingMirrorPlanId
257
+ : resolvePlanIdForVersion(planVersionId);
258
+ db.prepare(`
259
+ INSERT INTO user_billing (
260
+ user_id, tenant_id, tier, entitlement_state, effective_until, created_at, updated_at
261
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
262
+ ON CONFLICT (user_id) DO UPDATE SET
263
+ tier = excluded.tier,
264
+ entitlement_state = excluded.entitlement_state,
265
+ effective_until = excluded.effective_until,
266
+ updated_at = excluded.updated_at
267
+ `).run(input.accountId, tenantId, planId, input.state, effectiveUntil, now, now);
268
+ },
226
269
  listPlanCatalog: () => [
227
270
  { planId: 'pro', displayName: 'Pro Plan', status: 'active', createdAt: '', updatedAt: '' },
228
271
  { planId: 'inactive-plan', displayName: 'Inactive', status: 'archived', createdAt: '', updatedAt: '' },
@@ -1372,7 +1415,7 @@ describe('Billing Webhook Routes', () => {
1372
1415
  stripe_subscription_id: 'sub_email_match'
1373
1416
  }));
1374
1417
  });
1375
- 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 () => {
1376
1419
  const now = new Date().toISOString();
1377
1420
  db.prepare(`
1378
1421
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1433,12 +1476,26 @@ describe('Billing Webhook Routes', () => {
1433
1476
  planVersionId: 'team-v1'
1434
1477
  }));
1435
1478
  const entitlement = db.prepare(`
1436
- SELECT plan_version_id
1479
+ SELECT plan_version_id, entitlement_state, updated_by
1437
1480
  FROM account_entitlements
1438
1481
  WHERE tenant_id = 'default' AND account_id = 'u1'
1439
1482
  LIMIT 1
1440
1483
  `).get();
1441
- 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
+ }));
1442
1499
  });
1443
1500
  it('updates the existing subscription when only the interval changes on the same plan', async () => {
1444
1501
  const now = new Date().toISOString();
@@ -1501,7 +1558,7 @@ describe('Billing Webhook Routes', () => {
1501
1558
  planVersionId: 'pro-v1'
1502
1559
  }));
1503
1560
  });
1504
- 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 () => {
1505
1562
  const now = new Date().toISOString();
1506
1563
  db.prepare(`
1507
1564
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1511,6 +1568,11 @@ describe('Billing Webhook Routes', () => {
1511
1568
  INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1512
1569
  VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1513
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);
1514
1576
  const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1515
1577
  expect(confirmHandler).toBeTypeOf('function');
1516
1578
  const req = createMockReq(JSON.stringify({
@@ -1527,14 +1589,36 @@ describe('Billing Webhook Routes', () => {
1527
1589
  }));
1528
1590
  expect(mockSubscriptionRetrieve).toHaveBeenCalledWith('sub_123');
1529
1591
  const entitlement = db.prepare(`
1530
- SELECT plan_version_id, entitlement_state
1592
+ SELECT plan_version_id, entitlement_state, updated_by
1531
1593
  FROM account_entitlements
1532
1594
  WHERE tenant_id = 'default' AND account_id = 'u1'
1533
1595
  LIMIT 1
1534
1596
  `).get();
1535
1597
  expect(entitlement).toEqual(expect.objectContaining({
1536
1598
  plan_version_id: 'pro-v1',
1537
- 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'
1538
1622
  }));
1539
1623
  });
1540
1624
  it('does not activate entitlement when the checkout session is still incomplete', async () => {
@@ -1588,6 +1672,156 @@ describe('Billing Webhook Routes', () => {
1588
1672
  updated_by: 'checkout-start'
1589
1673
  }));
1590
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
+ });
1591
1825
  it('rejects checkout confirmation when a newer pending checkout superseded the session plan', async () => {
1592
1826
  const now = new Date().toISOString();
1593
1827
  db.prepare(`
@@ -1885,14 +2119,7 @@ describe('Billing Webhook Routes', () => {
1885
2119
  `).get();
1886
2120
  expect(purged).toBeUndefined();
1887
2121
  });
1888
- it('lists stripe price options filtered by interval for system admin', async () => {
1889
- mockPricesList.mockResolvedValueOnce({
1890
- data: [
1891
- { id: 'price_month_1', active: true, currency: 'usd', unit_amount: 1200, recurring: { interval: 'month' }, nickname: 'Monthly', product: 'prod_1' },
1892
- { id: 'price_year_1', active: true, currency: 'usd', unit_amount: 12000, recurring: { interval: 'year' }, nickname: 'Yearly', product: 'prod_1' }
1893
- ],
1894
- has_more: false
1895
- });
2122
+ it('rejects manual stripe price option listing now that pricing is import-only', async () => {
1896
2123
  const route = routeHandlers.get('GET /api/taskforce/admin/billing/stripe-price-options');
1897
2124
  expect(route).toBeTypeOf('function');
1898
2125
  const req = createMockReq('', {
@@ -1901,16 +2128,11 @@ describe('Billing Webhook Routes', () => {
1901
2128
  req.url = '/api/taskforce/admin/billing/stripe-price-options?interval=month';
1902
2129
  const res = createMockRes();
1903
2130
  await route(req, res, {});
1904
- expect(res.statusCode).toBe(200);
2131
+ expect(res.statusCode).toBe(410);
1905
2132
  const body = JSON.parse(res.body);
1906
- expect(Array.isArray(body.options)).toBe(true);
1907
- expect(body.options.length).toBe(1);
1908
- expect(body.options[0]).toEqual(expect.objectContaining({
1909
- stripePriceId: 'price_month_1',
1910
- interval: 'month'
1911
- }));
2133
+ expect(body.code).toBe('BILLING_PRICE_MANUAL_MAPPING_REMOVED');
1912
2134
  });
1913
- 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 () => {
1914
2136
  const now = new Date().toISOString();
1915
2137
  db.prepare(`
1916
2138
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1949,23 +2171,12 @@ describe('Billing Webhook Routes', () => {
1949
2171
  `).get();
1950
2172
  expect(row.active).toBe(1);
1951
2173
  });
1952
- 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 () => {
1953
2175
  const now = new Date().toISOString();
1954
2176
  db.prepare(`
1955
2177
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1956
2178
  VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1957
2179
  `).run(now, now);
1958
- db.prepare(`
1959
- 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)
1960
- VALUES ('default', 'collab-v1', 'month', 'price_old', 'stripe', 1, 1100, 'usd', ?, ?)
1961
- `).run(now, now);
1962
- mockPriceRetrieve.mockResolvedValueOnce({
1963
- id: 'price_new',
1964
- active: true,
1965
- currency: 'usd',
1966
- unit_amount: 1500,
1967
- recurring: { interval: 'month' }
1968
- });
1969
2180
  const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
1970
2181
  expect(upsertRoute).toBeTypeOf('function');
1971
2182
  const upsertReq = createMockReq(JSON.stringify({
@@ -1976,19 +2187,12 @@ describe('Billing Webhook Routes', () => {
1976
2187
  }), { 'x-taskforce-runtime-mode': 'cloud' });
1977
2188
  const upsertRes = createMockRes();
1978
2189
  await upsertRoute(upsertReq, upsertRes, {});
1979
- expect(upsertRes.statusCode).toBe(200);
1980
- const rows = db.prepare(`
1981
- SELECT stripe_price_id, active
1982
- FROM billing_price_mappings
1983
- WHERE tenant_id = 'default' AND plan_version_id = 'collab-v1' AND billing_interval = 'month'
1984
- ORDER BY stripe_price_id ASC
1985
- `).all();
1986
- expect(rows).toEqual([
1987
- { stripe_price_id: 'price_new', active: 1 },
1988
- { stripe_price_id: 'price_old', active: 0 }
1989
- ]);
2190
+ expect(upsertRes.statusCode).toBe(410);
2191
+ expect(JSON.parse(upsertRes.body)).toEqual(expect.objectContaining({
2192
+ code: 'BILLING_PRICE_MANUAL_MAPPING_REMOVED'
2193
+ }));
1990
2194
  });
1991
- 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 () => {
1992
2196
  const now = new Date().toISOString();
1993
2197
  db.prepare(`
1994
2198
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1998,13 +2202,6 @@ describe('Billing Webhook Routes', () => {
1998
2202
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1999
2203
  VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
2000
2204
  `).run(now, now);
2001
- mockPriceRetrieve.mockResolvedValue({
2002
- id: 'price_shared',
2003
- active: true,
2004
- currency: 'usd',
2005
- unit_amount: 1900,
2006
- recurring: { interval: 'month' }
2007
- });
2008
2205
  const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2009
2206
  const firstReq = createMockReq(JSON.stringify({
2010
2207
  planVersionId: 'collab-v1',
@@ -2014,28 +2211,9 @@ describe('Billing Webhook Routes', () => {
2014
2211
  }), { 'x-taskforce-runtime-mode': 'cloud' });
2015
2212
  const firstRes = createMockRes();
2016
2213
  await upsertRoute(firstReq, firstRes, {});
2017
- expect(firstRes.statusCode).toBe(200);
2018
- const secondReq = createMockReq(JSON.stringify({
2019
- planVersionId: 'team-v1',
2020
- interval: 'month',
2021
- stripePriceId: 'price_shared',
2022
- active: true
2023
- }), { 'x-taskforce-runtime-mode': 'cloud' });
2024
- const secondRes = createMockRes();
2025
- await upsertRoute(secondReq, secondRes, {});
2026
- expect(secondRes.statusCode).toBe(200);
2027
- const rows = db.prepare(`
2028
- SELECT plan_version_id, stripe_price_id, active
2029
- FROM billing_price_mappings
2030
- WHERE tenant_id = 'default' AND stripe_price_id = 'price_shared'
2031
- ORDER BY plan_version_id ASC
2032
- `).all();
2033
- expect(rows).toEqual([
2034
- { plan_version_id: 'collab-v1', stripe_price_id: 'price_shared', active: 1 },
2035
- { plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
2036
- ]);
2214
+ expect(firstRes.statusCode).toBe(410);
2037
2215
  });
2038
- 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 () => {
2039
2217
  const now = new Date().toISOString();
2040
2218
  db.prepare(`
2041
2219
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -2082,7 +2260,7 @@ describe('Billing Webhook Routes', () => {
2082
2260
  { plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
2083
2261
  ]);
2084
2262
  });
2085
- 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 () => {
2086
2264
  const now = new Date().toISOString();
2087
2265
  db.prepare(`
2088
2266
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -2098,17 +2276,10 @@ describe('Billing Webhook Routes', () => {
2098
2276
  }), { 'x-taskforce-runtime-mode': 'cloud' });
2099
2277
  const upsertRes = createMockRes();
2100
2278
  await upsertRoute(upsertReq, upsertRes, {});
2101
- expect(upsertRes.statusCode).toBe(200);
2102
- const row = db.prepare(`
2103
- SELECT stripe_price_id, pricing_type, active, unit_amount
2104
- FROM billing_price_mappings
2105
- WHERE tenant_id = 'default' AND plan_version_id = 'starter-v1' AND billing_interval = 'month'
2106
- LIMIT 1
2107
- `).get();
2108
- expect(row.stripe_price_id).toBe('free:starter-v1:month');
2109
- expect(row.pricing_type).toBe('free');
2110
- expect(row.active).toBe(1);
2111
- 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
+ }));
2112
2283
  });
2113
2284
  it('rejects webhook failure diagnostics for non-admin', async () => {
2114
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
  }));