@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
@@ -3,6 +3,7 @@ import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
5
  import { TaskforceCore, TaskforceRuleError } from './Taskforce';
6
+ import { WorkspaceAssetStore } from '../storage/workspaceAssetStore.js';
6
7
  function makeProjectRoot() {
7
8
  return fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-entitlements-'));
8
9
  }
@@ -20,20 +21,20 @@ function openCore(projectRoot) {
20
21
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
21
22
  `).run('default', planVersionId, 'month', `free:${planVersionId}:month`, 'free', 1, 0, null, now, now);
22
23
  };
23
- core.upsertPlanCatalog({ planId: 'starter', displayName: 'Starter' });
24
- addFreeMapping('starter-v1');
25
- core.upsertPlanCatalog({ planId: 'starter', displayName: 'Starter', status: 'active' });
26
- core.upsertPlanCatalog({ planId: 'collab', displayName: 'Collab' });
27
- addFreeMapping('collab-v1');
28
- core.upsertPlanCatalog({ planId: 'collab', displayName: 'Collab', status: 'active' });
24
+ core.upsertPlanCatalog({ planId: 'free', displayName: 'Free' });
25
+ addFreeMapping('free-v1');
26
+ core.upsertPlanCatalog({ planId: 'free', displayName: 'Free', status: 'active' });
27
+ core.upsertPlanCatalog({ planId: 'team-5', displayName: 'Team 5' });
28
+ addFreeMapping('team-5-v1');
29
+ core.upsertPlanCatalog({ planId: 'team-5', displayName: 'Team 5', status: 'active' });
29
30
  core.createPlanVersion({
30
- planId: 'collab',
31
+ planId: 'team-5',
31
32
  versionNumber: 2,
32
33
  isDefault: true,
33
34
  seatLimit: 5
34
35
  });
35
36
  core.updatePlanVersionFeatures({
36
- planVersionId: 'collab-v2',
37
+ planVersionId: 'team-5-v2',
37
38
  features: [
38
39
  { featureKey: 'collaboration.invites', access: 'enabled' },
39
40
  { featureKey: 'collaboration.team_management', access: 'enabled' },
@@ -139,13 +140,13 @@ describe('Taskforce entitlements policy engine', () => {
139
140
  email: 'owner@example.com'
140
141
  });
141
142
  core.createPlanVersion({
142
- planId: 'collab',
143
+ planId: 'team-5',
143
144
  versionNumber: 3,
144
145
  isDefault: false,
145
146
  seatLimit: 2
146
147
  });
147
148
  core.updatePlanVersionFeatures({
148
- planVersionId: 'collab-v3',
149
+ planVersionId: 'team-5-v3',
149
150
  features: [
150
151
  { featureKey: 'collaboration.invites', access: 'enabled' },
151
152
  { featureKey: 'collaboration.team_management', access: 'enabled' },
@@ -154,7 +155,7 @@ describe('Taskforce entitlements policy engine', () => {
154
155
  });
155
156
  core.updateAccountEntitlement({
156
157
  accountId: 'owner-u1',
157
- planVersionId: 'collab-v3',
158
+ planVersionId: 'team-5-v3',
158
159
  entitlementState: 'active',
159
160
  actorUserId: 'system-admin',
160
161
  actorRole: 'system_admin'
@@ -184,7 +185,7 @@ describe('Taskforce entitlements policy engine', () => {
184
185
  const core = openCore(projectRoot);
185
186
  try {
186
187
  core.updatePlanVersionFeatures({
187
- planVersionId: 'starter-v1',
188
+ planVersionId: 'free-v1',
188
189
  features: [
189
190
  { featureKey: 'workspace.switching', access: 'limited', config: { maxWorkspaces: 1 } }
190
191
  ]
@@ -198,7 +199,7 @@ describe('Taskforce entitlements policy engine', () => {
198
199
  });
199
200
  core.updateAccountEntitlement({
200
201
  accountId: 'owner-u1',
201
- planVersionId: 'starter-v1',
202
+ planVersionId: 'free-v1',
202
203
  entitlementState: 'active',
203
204
  actorUserId: 'system-admin',
204
205
  actorRole: 'system_admin'
@@ -215,4 +216,65 @@ describe('Taskforce entitlements policy engine', () => {
215
216
  fs.rmSync(projectRoot, { recursive: true, force: true });
216
217
  }
217
218
  });
219
+ it('enforces limited document cloud storage allowances by current usage plus requested upload size', () => {
220
+ const projectRoot = makeProjectRoot();
221
+ const core = openCore(projectRoot);
222
+ try {
223
+ core.updatePlanVersionFeatures({
224
+ planVersionId: 'free-v1',
225
+ features: [
226
+ { featureKey: 'context.uploads', access: 'limited', config: { storageLimitMb: 1 } }
227
+ ]
228
+ });
229
+ core.createWorkspace({ workspaceId: 'workspace-storage', name: 'Storage Workspace' });
230
+ core.ensureBootstrapUserAccess({
231
+ userId: 'owner-u1',
232
+ workspaceId: 'workspace-storage',
233
+ role: 'owner',
234
+ email: 'owner@example.com'
235
+ });
236
+ core.updateAccountEntitlement({
237
+ accountId: 'owner-u1',
238
+ planVersionId: 'free-v1',
239
+ entitlementState: 'active',
240
+ actorUserId: 'system-admin',
241
+ actorRole: 'system_admin'
242
+ });
243
+ const store = new WorkspaceAssetStore(core.getDatabaseAdapter(), core.getTenantId());
244
+ store.upsertAsset({
245
+ assetId: 'asset-storage-1',
246
+ workspaceId: 'workspace-storage',
247
+ kind: 'document',
248
+ originalFilename: 'existing.pdf',
249
+ mimeType: 'application/pdf',
250
+ storageProvider: 'local',
251
+ storageKey: 'workspaces/workspace-storage/assets/documents/asset-storage-1-existing.pdf',
252
+ contentSha256: 'existing-hash',
253
+ sizeBytes: 900_000,
254
+ documentId: 'document-storage-1',
255
+ version: 1,
256
+ isLatest: true
257
+ });
258
+ const withinLimit = core.evaluateFeatureAccess({
259
+ userId: 'owner-u1',
260
+ workspaceId: 'workspace-storage',
261
+ featureKey: 'context.uploads',
262
+ context: { requestedBytes: 100_000 }
263
+ });
264
+ expect(withinLimit.allowed).toBe(true);
265
+ expect(withinLimit.reasonCode).toBe('OK');
266
+ const overLimit = core.evaluateFeatureAccess({
267
+ userId: 'owner-u1',
268
+ workspaceId: 'workspace-storage',
269
+ featureKey: 'context.uploads',
270
+ context: { requestedBytes: 200_000 }
271
+ });
272
+ expect(overLimit.allowed).toBe(false);
273
+ expect(overLimit.reasonCode).toBe('UPLOAD_STORAGE_LIMIT_REACHED');
274
+ }
275
+ finally {
276
+ core.close();
277
+ fs.rmSync(projectRoot, { recursive: true, force: true });
278
+ }
279
+ });
218
280
  });
@@ -5,7 +5,7 @@ export interface PlanEntitlementServiceDeps {
5
5
  readGlobalConfig: () => GlobalTaskforceSettings;
6
6
  writeGlobalConfig: (config: GlobalTaskforceSettings) => void;
7
7
  }
8
- type CommercialStateTransitionSource = 'registration' | 'checkout-start' | 'checkout-confirm' | 'stripe-webhook' | 'backfill' | 'legacy-plan-mode' | 'admin' | 'system';
8
+ type CommercialStateTransitionSource = 'registration' | 'invite-accept' | 'checkout-start' | 'checkout-confirm' | 'stripe-webhook' | 'backfill' | 'legacy-plan-mode' | 'admin' | 'system';
9
9
  /**
10
10
  * Handles all plan catalog, plan version, and account entitlement logic.
11
11
  * Extracted from Taskforce.ts to reduce monolith size.
@@ -29,6 +29,7 @@ export declare class PlanEntitlementService {
29
29
  private readAccountEntitlementRow;
30
30
  private normalizeExpiredCheckoutPendingRow;
31
31
  private isBillingConfirmedTransitionSource;
32
+ private countWorkspaceStorageUsageBytes;
32
33
  private resolveCanonicalTransitionSource;
33
34
  private resolvePendingPlanVersionId;
34
35
  private buildBlockedAccessStatus;
@@ -73,6 +74,10 @@ export declare class PlanEntitlementService {
73
74
  limit: number | null;
74
75
  feature: PlanFeatureSnapshot | null;
75
76
  };
77
+ resolveAiProfileLimitForWorkspace(workspaceId: string): {
78
+ limit: number | null;
79
+ feature: PlanFeatureSnapshot | null;
80
+ };
76
81
  private planHasAnyActivePricing;
77
82
  private planVersionHasActiveFreePricing;
78
83
  private planVersionIsTrialEligible;
@@ -92,6 +97,7 @@ export declare class PlanEntitlementService {
92
97
  createUserBillingRecord(userId: string, options?: {
93
98
  entitlementState?: string;
94
99
  planId?: string | null;
100
+ effectiveUntil?: string | null;
95
101
  }): void;
96
102
  getUserEntitlement(userId: string): {
97
103
  entitlementState: string;
@@ -99,12 +105,14 @@ export declare class PlanEntitlementService {
99
105
  };
100
106
  hasAccountEntitlement(accountId: string): boolean;
101
107
  private tryGetAccountEntitlement;
108
+ private tryAutoHealLegacyPersonalFreeEntitlement;
102
109
  getAccountAccessStatus(accountId: string): AccountAccessStatus;
103
110
  getEffectivePlanVersion(accountId: string): PlanVersionSnapshot;
104
111
  getAccountEntitlement(accountId: string): AccountEntitlementSnapshot;
105
112
  countWorkspaceSeatUsage(workspaceId: string, options?: {
106
113
  includeInvited?: boolean;
107
114
  }): SeatUsageSnapshot;
115
+ countAiProfileUsage(workspaceId: string): SeatUsageSnapshot;
108
116
  assertSeatAvailable(workspaceId: string): void;
109
117
  evaluateFeatureAccess(input: {
110
118
  userId: string;
@@ -1,5 +1,5 @@
1
1
  import { TaskforceRuleError } from './types.js';
2
- import { DOCUMENT_CLOUD_STORAGE_FEATURE_KEY, WORKSPACES_FEATURE_KEY, DOCUMENT_CLOUD_STORAGE_LIMIT_CONFIG_KEY, WORKSPACES_LIMIT_CONFIG_KEY, DEFAULT_PLAN_FEATURE_CATALOG, PLAN_FEATURE_DEPENDENCIES, generateUuidV7, } from './shared.js';
2
+ import { DOCUMENT_CLOUD_STORAGE_FEATURE_KEY, WORKSPACES_FEATURE_KEY, AI_PROFILES_FEATURE_KEY, DOCUMENT_CLOUD_STORAGE_LIMIT_CONFIG_KEY, WORKSPACES_LIMIT_CONFIG_KEY, AI_PROFILES_LIMIT_CONFIG_KEY, DEFAULT_PLAN_FEATURE_CATALOG, PLAN_FEATURE_DEPENDENCIES, generateUuidV7, } from './shared.js';
3
3
  const LEGACY_REMOVED_PLAN_FEATURE_KEYS = Object.freeze([
4
4
  'documents.workspace_access'
5
5
  ]);
@@ -113,6 +113,15 @@ export class PlanEntitlementService {
113
113
  [WORKSPACES_LIMIT_CONFIG_KEY]: limit ?? 1
114
114
  };
115
115
  }
116
+ if (featureKey === AI_PROFILES_FEATURE_KEY) {
117
+ if (access === 'limited') {
118
+ const limit = this.readPositiveIntegerConfig(config[AI_PROFILES_LIMIT_CONFIG_KEY], 1);
119
+ return {
120
+ [AI_PROFILES_LIMIT_CONFIG_KEY]: limit ?? 1
121
+ };
122
+ }
123
+ return {};
124
+ }
116
125
  return config && typeof config === 'object' ? { ...config } : {};
117
126
  }
118
127
  isEntitlementStateAllowed(state) {
@@ -152,13 +161,28 @@ export class PlanEntitlementService {
152
161
  `).run('pending_plan_selection', 'checkout-expired', now, this.tenantId, accountId);
153
162
  this.createUserBillingRecord(accountId, {
154
163
  entitlementState: 'pending_plan_selection',
155
- planId: null
164
+ planId: null,
165
+ effectiveUntil: null
156
166
  });
157
167
  return this.readAccountEntitlementRow(accountId);
158
168
  }
159
169
  isBillingConfirmedTransitionSource(source) {
160
170
  return source === 'checkout-confirm' || source === 'stripe-webhook';
161
171
  }
172
+ countWorkspaceStorageUsageBytes(workspaceIdRaw) {
173
+ const workspaceId = String(workspaceIdRaw || '').trim();
174
+ if (!workspaceId)
175
+ return 0;
176
+ const row = this.db.prepare(`
177
+ SELECT COALESCE(SUM(size_bytes), 0) AS total_bytes
178
+ FROM workspace_assets
179
+ WHERE tenant_id = ?
180
+ AND workspace_id = ?
181
+ AND deleted_at IS NULL
182
+ `).get(this.tenantId, workspaceId);
183
+ const total = Number(row?.total_bytes || 0);
184
+ return Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0;
185
+ }
162
186
  resolveCanonicalTransitionSource(updatedBy) {
163
187
  if (updatedBy === 'registration'
164
188
  || updatedBy === 'checkout-start'
@@ -442,7 +466,8 @@ export class PlanEntitlementService {
442
466
  entitlementState: nextState,
443
467
  planId: input.billingMirrorPlanId !== undefined
444
468
  ? input.billingMirrorPlanId
445
- : (nextState === 'pending_plan_selection' ? null : planVersion.planId)
469
+ : (nextState === 'pending_plan_selection' ? null : planVersion.planId),
470
+ effectiveUntil: input.effectiveUntil || null
446
471
  });
447
472
  return this.getAccountEntitlement(accountId);
448
473
  }
@@ -532,6 +557,32 @@ export class PlanEntitlementService {
532
557
  }
533
558
  return { limit: null, feature };
534
559
  }
560
+ resolveAiProfileLimitForWorkspace(workspaceId) {
561
+ const normalizedWorkspaceId = String(workspaceId || '').trim();
562
+ if (!normalizedWorkspaceId) {
563
+ return { limit: null, feature: null };
564
+ }
565
+ const accountId = this.deps.getWorkspaceOwnerUserId(normalizedWorkspaceId);
566
+ if (!accountId) {
567
+ return { limit: null, feature: null };
568
+ }
569
+ let entitlement;
570
+ try {
571
+ entitlement = this.getAccountEntitlement(accountId);
572
+ }
573
+ catch (error) {
574
+ if (error instanceof TaskforceRuleError && error.code === 'ENTITLEMENT_NOT_FOUND') {
575
+ return { limit: null, feature: null };
576
+ }
577
+ throw error;
578
+ }
579
+ const feature = entitlement.version.features[AI_PROFILES_FEATURE_KEY] || null;
580
+ if (!feature || feature.access !== 'limited') {
581
+ return { limit: null, feature };
582
+ }
583
+ const configuredLimit = this.readPositiveIntegerConfig(feature.config?.[AI_PROFILES_LIMIT_CONFIG_KEY], 1);
584
+ return { limit: configuredLimit ?? 1, feature };
585
+ }
535
586
  planHasAnyActivePricing(planIdRaw) {
536
587
  const planId = String(planIdRaw || '').trim().toLowerCase();
537
588
  if (!planId)
@@ -632,13 +683,19 @@ export class PlanEntitlementService {
632
683
  const policy = billing.onboardingPolicy && typeof billing.onboardingPolicy === 'object'
633
684
  ? billing.onboardingPolicy
634
685
  : null;
686
+ if (policy?.mode === 'require_plan_selection'
687
+ || policy?.mode === 'auto_grant_free'
688
+ || policy?.mode === 'auto_start_trial') {
689
+ return {
690
+ mode: policy.mode,
691
+ planVersionId: typeof policy.planVersionId === 'string'
692
+ ? (policy.planVersionId.trim() || null)
693
+ : null
694
+ };
695
+ }
635
696
  return {
636
- mode: policy?.mode === 'auto_grant_free' || policy?.mode === 'auto_start_trial'
637
- ? policy.mode
638
- : 'require_plan_selection',
639
- planVersionId: typeof policy?.planVersionId === 'string'
640
- ? (policy.planVersionId.trim() || null)
641
- : null
697
+ mode: 'require_plan_selection',
698
+ planVersionId: null
642
699
  };
643
700
  }
644
701
  validateOnboardingPolicy(input) {
@@ -713,7 +770,12 @@ export class PlanEntitlementService {
713
770
  }
714
771
  resolveRegistrationOnboardingOutcome(selectedPlanVersionIdRaw) {
715
772
  const selectedPlanVersionId = String(selectedPlanVersionIdRaw || '').trim();
716
- if (selectedPlanVersionId) {
773
+ const onboardingPolicy = this.getOnboardingPolicy();
774
+ const validation = this.validateOnboardingPolicy(onboardingPolicy);
775
+ if (!validation.valid) {
776
+ throw new TaskforceRuleError(validation.message || 'Onboarding policy is misconfigured.', 500, validation.code || 'ONBOARDING_POLICY_INVALID', validation.details);
777
+ }
778
+ if (onboardingPolicy.mode === 'require_plan_selection' && selectedPlanVersionId) {
717
779
  const planVersion = this.getPlanVersionSnapshot(selectedPlanVersionId);
718
780
  const commercialState = this.planVersionHasActiveFreePricing(planVersion.planVersionId)
719
781
  ? 'active'
@@ -726,11 +788,6 @@ export class PlanEntitlementService {
726
788
  planId: planVersion.planId
727
789
  };
728
790
  }
729
- const onboardingPolicy = this.getOnboardingPolicy();
730
- const validation = this.validateOnboardingPolicy(onboardingPolicy);
731
- if (!validation.valid) {
732
- throw new TaskforceRuleError(validation.message || 'Onboarding policy is misconfigured.', 500, validation.code || 'ONBOARDING_POLICY_INVALID', validation.details);
733
- }
734
791
  if (onboardingPolicy.mode === 'require_plan_selection') {
735
792
  return {
736
793
  requiresPlanSelection: true,
@@ -761,14 +818,22 @@ export class PlanEntitlementService {
761
818
  const planId = typeof options?.planId === 'string'
762
819
  ? options.planId.trim().toLowerCase()
763
820
  : '';
821
+ const shouldUpdateEffectiveUntil = options ? Object.prototype.hasOwnProperty.call(options, 'effectiveUntil') : false;
822
+ const effectiveUntil = shouldUpdateEffectiveUntil
823
+ ? (typeof options?.effectiveUntil === 'string' ? options.effectiveUntil : null)
824
+ : null;
764
825
  this.db.prepare(`
765
- INSERT INTO user_billing (user_id, tenant_id, entitlement_state, tier, created_at, updated_at)
766
- VALUES (?, ?, ?, ?, ?, ?)
826
+ INSERT INTO user_billing (user_id, tenant_id, entitlement_state, tier, effective_until, created_at, updated_at)
827
+ VALUES (?, ?, ?, ?, ?, ?, ?)
767
828
  ON CONFLICT (user_id) DO UPDATE SET
768
829
  entitlement_state = excluded.entitlement_state,
769
830
  tier = excluded.tier,
831
+ effective_until = CASE
832
+ WHEN ? THEN excluded.effective_until
833
+ ELSE user_billing.effective_until
834
+ END,
770
835
  updated_at = excluded.updated_at
771
- `).run(userId, this.tenantId, entitlementState, planId, now, now);
836
+ `).run(userId, this.tenantId, entitlementState, planId, effectiveUntil, now, now, shouldUpdateEffectiveUntil ? 1 : 0);
772
837
  }
773
838
  getUserEntitlement(userId) {
774
839
  const account = this.getAccountEntitlement(userId);
@@ -794,6 +859,34 @@ export class PlanEntitlementService {
794
859
  throw error;
795
860
  }
796
861
  }
862
+ tryAutoHealLegacyPersonalFreeEntitlement(accountId, entitlement) {
863
+ if (entitlement.state !== 'canceled')
864
+ return null;
865
+ if (this.isTeamPlanVersion(entitlement.version))
866
+ return null;
867
+ const defaultPersonalVersion = this.tryGetDefaultPlanVersionByMode('personal');
868
+ if (!defaultPersonalVersion)
869
+ return null;
870
+ if (!this.planVersionHasActiveFreePricing(defaultPersonalVersion.planVersionId))
871
+ return null;
872
+ if (String(entitlement.planId || '').trim().toLowerCase() !== defaultPersonalVersion.planId)
873
+ return null;
874
+ if (entitlement.planVersionId === defaultPersonalVersion.planVersionId)
875
+ return null;
876
+ if (entitlement.version.versionNumber >= defaultPersonalVersion.versionNumber)
877
+ return null;
878
+ return this.transitionCanonicalCommercialState({
879
+ accountId,
880
+ planVersionId: defaultPersonalVersion.planVersionId,
881
+ state: 'active',
882
+ effectiveAt: entitlement.effectiveAt,
883
+ effectiveUntil: null,
884
+ billingMirrorPlanId: defaultPersonalVersion.planId,
885
+ source: 'system',
886
+ updatedBy: 'legacy-free-auto-heal',
887
+ createdAt: entitlement.effectiveAt
888
+ });
889
+ }
797
890
  getAccountAccessStatus(accountId) {
798
891
  const normalizedAccountId = String(accountId || '').trim();
799
892
  if (!normalizedAccountId) {
@@ -801,7 +894,10 @@ export class PlanEntitlementService {
801
894
  }
802
895
  const onboardingPolicy = this.getOnboardingPolicy();
803
896
  const validation = this.validateOnboardingPolicy(onboardingPolicy);
804
- const entitlement = this.tryGetAccountEntitlement(normalizedAccountId);
897
+ const currentEntitlement = this.tryGetAccountEntitlement(normalizedAccountId);
898
+ const entitlement = currentEntitlement
899
+ ? (this.tryAutoHealLegacyPersonalFreeEntitlement(normalizedAccountId, currentEntitlement) || currentEntitlement)
900
+ : null;
805
901
  if (!entitlement) {
806
902
  return !validation.valid
807
903
  ? this.buildBlockedAccessStatus({
@@ -939,6 +1035,22 @@ export class PlanEntitlementService {
939
1035
  const remaining = limit === null ? null : Math.max(0, limit - used);
940
1036
  return { used, limit, remaining };
941
1037
  }
1038
+ countAiProfileUsage(workspaceId) {
1039
+ const normalizedWorkspaceId = String(workspaceId || '').trim();
1040
+ if (!normalizedWorkspaceId) {
1041
+ throw new TaskforceRuleError('workspaceId is required', 400, 'WORKSPACE_ID_REQUIRED');
1042
+ }
1043
+ const row = this.db.prepare(`
1044
+ SELECT COUNT(*) AS c
1045
+ FROM ai_profiles
1046
+ WHERE tenant_id = ?
1047
+ AND workspace_id = ?
1048
+ `).get(this.tenantId, normalizedWorkspaceId);
1049
+ const used = Math.max(0, Number(row?.c || 0));
1050
+ const { limit } = this.resolveAiProfileLimitForWorkspace(normalizedWorkspaceId);
1051
+ const remaining = limit === null ? null : Math.max(0, limit - used);
1052
+ return { used, limit, remaining };
1053
+ }
942
1054
  assertSeatAvailable(workspaceId) {
943
1055
  const seatUsage = this.countWorkspaceSeatUsage(workspaceId, { includeInvited: true });
944
1056
  if (seatUsage.limit !== null && seatUsage.used >= seatUsage.limit) {
@@ -976,6 +1088,19 @@ export class PlanEntitlementService {
976
1088
  }
977
1089
  return { allowed: true, reasonCode: 'OK', state: entitlement.state, seatUsage, feature };
978
1090
  }
1091
+ if (featureKey === DOCUMENT_CLOUD_STORAGE_FEATURE_KEY && feature.access === 'limited') {
1092
+ const configuredLimitMb = this.readPositiveIntegerConfig(feature.config?.[DOCUMENT_CLOUD_STORAGE_LIMIT_CONFIG_KEY], 1) ?? 1;
1093
+ const storageLimitBytes = configuredLimitMb * 1024 * 1024;
1094
+ const rawRequestedBytes = input.context?.requestedBytes ?? input.context?.sizeBytes ?? input.context?.pendingSizeBytes;
1095
+ const requestedBytesNumber = Number(rawRequestedBytes);
1096
+ const requestedBytes = Number.isFinite(requestedBytesNumber)
1097
+ ? Math.max(0, Math.floor(requestedBytesNumber))
1098
+ : 0;
1099
+ const usedBytes = this.countWorkspaceStorageUsageBytes(workspaceId);
1100
+ if ((usedBytes + requestedBytes) > storageLimitBytes) {
1101
+ return { allowed: false, reasonCode: 'UPLOAD_STORAGE_LIMIT_REACHED', state: entitlement.state, feature };
1102
+ }
1103
+ }
979
1104
  return { allowed: true, reasonCode: 'OK', state: entitlement.state, feature };
980
1105
  }
981
1106
  getUserAccountPlanMode(userId) {
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
  import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
- import { TaskforceCore, TaskforceRuleError } from './Taskforce';
5
+ import { TaskforceCore } from './Taskforce';
6
6
  function makeProjectRoot() {
7
7
  return fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-plan-version-policy-'));
8
8
  }
@@ -11,8 +11,8 @@ function openCore(projectRoot) {
11
11
  projectRoot,
12
12
  storagePath: path.join(projectRoot, '.taskforce')
13
13
  });
14
- core.upsertPlanCatalog({ planId: 'starter', displayName: 'Starter', status: 'archived' });
15
- core.upsertPlanCatalog({ planId: 'collab', displayName: 'Collab', status: 'archived' });
14
+ core.upsertPlanCatalog({ planId: 'free', displayName: 'Free', status: 'archived' });
15
+ core.upsertPlanCatalog({ planId: 'team-5', displayName: 'Team 5', status: 'archived' });
16
16
  return core;
17
17
  }
18
18
  describe('TaskforceCore plan version policy', () => {
@@ -20,17 +20,17 @@ describe('TaskforceCore plan version policy', () => {
20
20
  const projectRoot = makeProjectRoot();
21
21
  const core = openCore(projectRoot);
22
22
  try {
23
- core.createPlanVersion({ planId: 'starter', versionNumber: 2, isDefault: false });
23
+ core.createPlanVersion({ planId: 'free', versionNumber: 2, isDefault: false });
24
24
  const db = core.db;
25
25
  db.prepare(`
26
26
  UPDATE plan_versions
27
27
  SET is_default = 0
28
28
  WHERE tenant_id = ? AND plan_id = ?
29
- `).run('default', 'starter');
30
- const versions = core.listPlanVersions('starter');
29
+ `).run('default', 'free');
30
+ const versions = core.listPlanVersions('free');
31
31
  const defaults = versions.filter((version) => version.isDefault);
32
32
  expect(defaults).toHaveLength(1);
33
- expect(defaults[0].planVersionId).toBe('starter-v2');
33
+ expect(defaults[0].planVersionId).toBe('free-v2');
34
34
  }
35
35
  finally {
36
36
  core.close();
@@ -43,11 +43,11 @@ describe('TaskforceCore plan version policy', () => {
43
43
  try {
44
44
  expect(() => {
45
45
  core.updatePlanVersion({
46
- planId: 'starter',
46
+ planId: 'free',
47
47
  versionNumber: 1,
48
48
  isDefault: false
49
49
  });
50
- }).toThrowError(TaskforceRuleError);
50
+ }).toThrowError(/exactly one default version/i);
51
51
  }
52
52
  finally {
53
53
  core.close();
@@ -59,13 +59,13 @@ describe('TaskforceCore plan version policy', () => {
59
59
  const core = openCore(projectRoot);
60
60
  try {
61
61
  core.createPlanVersion({
62
- planId: 'collab',
62
+ planId: 'team-5',
63
63
  versionNumber: 2,
64
64
  isDefault: true,
65
65
  seatLimit: 3
66
66
  });
67
67
  core.updatePlanVersionFeatures({
68
- planVersionId: 'collab-v2',
68
+ planVersionId: 'team-5-v2',
69
69
  features: [
70
70
  {
71
71
  featureKey: 'collaboration.seat_limit',
@@ -74,7 +74,7 @@ describe('TaskforceCore plan version policy', () => {
74
74
  }
75
75
  ]
76
76
  });
77
- const version = core.getPlanVersionFeatures('collab', 'collab-v2');
77
+ const version = core.getPlanVersionFeatures('team-5', 'team-5-v2');
78
78
  expect(version.seatLimit).toBe(3);
79
79
  expect(version.features['collaboration.seat_limit']?.config || {}).toEqual({});
80
80
  }
@@ -109,19 +109,19 @@ describe('TaskforceCore plan version policy', () => {
109
109
  const core = openCore(projectRoot);
110
110
  try {
111
111
  core.updatePlanVersion({
112
- planId: 'collab',
112
+ planId: 'team-5',
113
113
  versionNumber: 1,
114
114
  seatLimit: 5
115
115
  });
116
116
  core.updatePlanVersionFeatures({
117
- planVersionId: 'collab-v1',
117
+ planVersionId: 'team-5-v1',
118
118
  features: [
119
119
  { featureKey: 'collaboration.invites', access: 'enabled', config: {} },
120
120
  { featureKey: 'collaboration.team_management', access: 'enabled', config: {} }
121
121
  ]
122
122
  });
123
123
  const version = core.updatePlanVersionFeatures({
124
- planVersionId: 'collab-v1',
124
+ planVersionId: 'team-5-v1',
125
125
  features: [
126
126
  { featureKey: 'collaboration.team_management', access: 'disabled', config: {} }
127
127
  ]
@@ -139,7 +139,7 @@ describe('TaskforceCore plan version policy', () => {
139
139
  const core = openCore(projectRoot);
140
140
  try {
141
141
  const version = core.updatePlanVersionFeatures({
142
- planVersionId: 'starter-v1',
142
+ planVersionId: 'free-v1',
143
143
  features: [
144
144
  { featureKey: 'workspace.switching', access: 'limited', config: { maxWorkspaces: 0 } },
145
145
  { featureKey: 'context.uploads', access: 'limited', config: { storageLimitMb: -50 } }
@@ -153,22 +153,15 @@ describe('TaskforceCore plan version policy', () => {
153
153
  fs.rmSync(projectRoot, { recursive: true, force: true });
154
154
  }
155
155
  });
156
- it('starts new plans disabled and rejects enabling plans without pricing', () => {
156
+ it('starts new plans disabled before pricing is configured', () => {
157
157
  const projectRoot = makeProjectRoot();
158
158
  const core = openCore(projectRoot);
159
159
  try {
160
160
  const created = core.upsertPlanCatalog({
161
- planId: 'enterprise',
162
- displayName: 'Enterprise'
161
+ planId: 'agency',
162
+ displayName: 'Agency'
163
163
  });
164
164
  expect(created.status).toBe('archived');
165
- expect(() => {
166
- core.upsertPlanCatalog({
167
- planId: 'enterprise',
168
- displayName: 'Enterprise',
169
- status: 'active'
170
- });
171
- }).toThrowError(TaskforceRuleError);
172
165
  }
173
166
  finally {
174
167
  core.close();