@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
@@ -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
  });
@@ -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;
@@ -80,7 +85,6 @@ export declare class PlanEntitlementService {
80
85
  private getActiveDefaultPlanVersions;
81
86
  private getDefaultPlanVersionByMode;
82
87
  private tryGetDefaultPlanVersionByMode;
83
- private tryGetImplicitFreeOnboardingPlanVersion;
84
88
  getOnboardingPolicy(): OnboardingPolicy;
85
89
  validateOnboardingPolicy(input?: Partial<OnboardingPolicy>): OnboardingPolicyValidationResult;
86
90
  resolveRegistrationOnboardingOutcome(selectedPlanVersionIdRaw?: string): {
@@ -101,12 +105,14 @@ export declare class PlanEntitlementService {
101
105
  };
102
106
  hasAccountEntitlement(accountId: string): boolean;
103
107
  private tryGetAccountEntitlement;
108
+ private tryAutoHealLegacyPersonalFreeEntitlement;
104
109
  getAccountAccessStatus(accountId: string): AccountAccessStatus;
105
110
  getEffectivePlanVersion(accountId: string): PlanVersionSnapshot;
106
111
  getAccountEntitlement(accountId: string): AccountEntitlementSnapshot;
107
112
  countWorkspaceSeatUsage(workspaceId: string, options?: {
108
113
  includeInvited?: boolean;
109
114
  }): SeatUsageSnapshot;
115
+ countAiProfileUsage(workspaceId: string): SeatUsageSnapshot;
110
116
  assertSeatAvailable(workspaceId: string): void;
111
117
  evaluateFeatureAccess(input: {
112
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) {
@@ -160,6 +169,20 @@ export class PlanEntitlementService {
160
169
  isBillingConfirmedTransitionSource(source) {
161
170
  return source === 'checkout-confirm' || source === 'stripe-webhook';
162
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
+ }
163
186
  resolveCanonicalTransitionSource(updatedBy) {
164
187
  if (updatedBy === 'registration'
165
188
  || updatedBy === 'checkout-start'
@@ -534,6 +557,32 @@ export class PlanEntitlementService {
534
557
  }
535
558
  return { limit: null, feature };
536
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
+ }
537
586
  planHasAnyActivePricing(planIdRaw) {
538
587
  const planId = String(planIdRaw || '').trim().toLowerCase();
539
588
  if (!planId)
@@ -629,33 +678,6 @@ export class PlanEntitlementService {
629
678
  throw error;
630
679
  }
631
680
  }
632
- tryGetImplicitFreeOnboardingPlanVersion() {
633
- const rows = this.db.prepare(`
634
- SELECT DISTINCT v.plan_version_id
635
- FROM plan_versions v
636
- JOIN plan_catalog p
637
- ON p.tenant_id = v.tenant_id
638
- AND p.plan_id = v.plan_id
639
- JOIN billing_price_mappings bpm
640
- ON bpm.tenant_id = v.tenant_id
641
- AND bpm.plan_version_id = v.plan_version_id
642
- WHERE v.tenant_id = ?
643
- AND COALESCE(p.status, 'active') = 'active'
644
- AND bpm.active = 1
645
- AND bpm.pricing_type = 'free'
646
- ORDER BY v.is_default DESC, p.updated_at DESC, p.created_at DESC, v.version_number DESC, v.created_at DESC
647
- `).all(this.tenantId);
648
- for (const row of rows) {
649
- const planVersionId = String(row.plan_version_id || '').trim();
650
- if (!planVersionId)
651
- continue;
652
- const version = this.getPlanVersionSnapshot(planVersionId);
653
- if (!this.isTeamPlanVersion(version)) {
654
- return version;
655
- }
656
- }
657
- return null;
658
- }
659
681
  getOnboardingPolicy() {
660
682
  const billing = this.deps.readGlobalConfig().billing || {};
661
683
  const policy = billing.onboardingPolicy && typeof billing.onboardingPolicy === 'object'
@@ -671,13 +693,6 @@ export class PlanEntitlementService {
671
693
  : null
672
694
  };
673
695
  }
674
- const implicitFreeVersion = this.tryGetImplicitFreeOnboardingPlanVersion();
675
- if (implicitFreeVersion) {
676
- return {
677
- mode: 'auto_grant_free',
678
- planVersionId: implicitFreeVersion.planVersionId
679
- };
680
- }
681
696
  return {
682
697
  mode: 'require_plan_selection',
683
698
  planVersionId: null
@@ -755,7 +770,12 @@ export class PlanEntitlementService {
755
770
  }
756
771
  resolveRegistrationOnboardingOutcome(selectedPlanVersionIdRaw) {
757
772
  const selectedPlanVersionId = String(selectedPlanVersionIdRaw || '').trim();
758
- 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) {
759
779
  const planVersion = this.getPlanVersionSnapshot(selectedPlanVersionId);
760
780
  const commercialState = this.planVersionHasActiveFreePricing(planVersion.planVersionId)
761
781
  ? 'active'
@@ -768,11 +788,6 @@ export class PlanEntitlementService {
768
788
  planId: planVersion.planId
769
789
  };
770
790
  }
771
- const onboardingPolicy = this.getOnboardingPolicy();
772
- const validation = this.validateOnboardingPolicy(onboardingPolicy);
773
- if (!validation.valid) {
774
- throw new TaskforceRuleError(validation.message || 'Onboarding policy is misconfigured.', 500, validation.code || 'ONBOARDING_POLICY_INVALID', validation.details);
775
- }
776
791
  if (onboardingPolicy.mode === 'require_plan_selection') {
777
792
  return {
778
793
  requiresPlanSelection: true,
@@ -844,6 +859,34 @@ export class PlanEntitlementService {
844
859
  throw error;
845
860
  }
846
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
+ }
847
890
  getAccountAccessStatus(accountId) {
848
891
  const normalizedAccountId = String(accountId || '').trim();
849
892
  if (!normalizedAccountId) {
@@ -851,7 +894,10 @@ export class PlanEntitlementService {
851
894
  }
852
895
  const onboardingPolicy = this.getOnboardingPolicy();
853
896
  const validation = this.validateOnboardingPolicy(onboardingPolicy);
854
- const entitlement = this.tryGetAccountEntitlement(normalizedAccountId);
897
+ const currentEntitlement = this.tryGetAccountEntitlement(normalizedAccountId);
898
+ const entitlement = currentEntitlement
899
+ ? (this.tryAutoHealLegacyPersonalFreeEntitlement(normalizedAccountId, currentEntitlement) || currentEntitlement)
900
+ : null;
855
901
  if (!entitlement) {
856
902
  return !validation.valid
857
903
  ? this.buildBlockedAccessStatus({
@@ -989,6 +1035,22 @@ export class PlanEntitlementService {
989
1035
  const remaining = limit === null ? null : Math.max(0, limit - used);
990
1036
  return { used, limit, remaining };
991
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
+ }
992
1054
  assertSeatAvailable(workspaceId) {
993
1055
  const seatUsage = this.countWorkspaceSeatUsage(workspaceId, { includeInvited: true });
994
1056
  if (seatUsage.limit !== null && seatUsage.used >= seatUsage.limit) {
@@ -1026,6 +1088,19 @@ export class PlanEntitlementService {
1026
1088
  }
1027
1089
  return { allowed: true, reasonCode: 'OK', state: entitlement.state, seatUsage, feature };
1028
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
+ }
1029
1104
  return { allowed: true, reasonCode: 'OK', state: entitlement.state, feature };
1030
1105
  }
1031
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();