@taskforcehq/taskforce 0.3.302 → 0.3.303

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 (77) hide show
  1. package/dist/Taskforce.module.css +11 -0
  2. package/dist/TaskforceCore.js +59 -9
  3. package/dist/TaskforceCore.test.js +717 -14
  4. package/dist/components/task/TaskKanban.test.js +17 -0
  5. package/dist/components/views/PlansPage.d.ts +3 -1
  6. package/dist/components/views/PlansPage.js +110 -51
  7. package/dist/components/views/PlansPage.test.d.ts +1 -0
  8. package/dist/components/views/PlansPage.test.js +100 -0
  9. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  10. package/dist/components/views/StandaloneLayout.js +106 -151
  11. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +8 -3
  12. package/dist/components/views/standalone/modals/AccountHubModal.js +16 -2
  13. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  14. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  15. package/dist/core/EntitlementsPolicy.test.js +2 -2
  16. package/dist/core/GlobalSettingsService.js +78 -18
  17. package/dist/core/PlanEntitlementService.d.ts +29 -5
  18. package/dist/core/PlanEntitlementService.js +297 -219
  19. package/dist/core/SignupMonetizationSettings.test.js +173 -49
  20. package/dist/core/SystemAdmin.test.js +186 -96
  21. package/dist/core/Taskforce.d.ts +51 -10
  22. package/dist/core/Taskforce.js +218 -49
  23. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  24. package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
  25. package/dist/core/types.d.ts +11 -12
  26. package/dist/hooks/useSyncOrchestrator.js +17 -3
  27. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
  28. package/dist/hooks/useTaskforce.d.ts +3 -0
  29. package/dist/hooks/useTaskforce.js +9 -4
  30. package/dist/mcp/runtime.js +90 -29
  31. package/dist/mcp/runtime.test.js +272 -81
  32. package/dist/migrations/billingSchemaParity.test.js +3 -0
  33. package/dist/migrations/taskSchemaMigrations.js +15 -0
  34. package/dist/server/index.cookieProxy.test.js +1 -0
  35. package/dist/server/index.js +2 -0
  36. package/dist/server/index.test.js +2 -1
  37. package/dist/server/mockStripe.d.ts +1 -0
  38. package/dist/server/mockStripe.js +2 -0
  39. package/dist/server/routes/admin.js +80 -23
  40. package/dist/server/routes/auth.d.ts +1 -0
  41. package/dist/server/routes/auth.js +194 -53
  42. package/dist/server/routes/billing.js +506 -23
  43. package/dist/server/routes/billing.test.js +937 -45
  44. package/dist/server/routes/shared.js +5 -3
  45. package/dist/server/routes/sync.integration.test.js +160 -0
  46. package/dist/server/routes/sync.js +15 -5
  47. package/dist/server/routes.js +3 -3
  48. package/dist/server/routes.test.js +287 -76
  49. package/dist/sync/cloudSyncApi.d.ts +1 -0
  50. package/dist/sync/contentSyncState.d.ts +10 -0
  51. package/dist/sync/contentSyncState.js +277 -16
  52. package/dist/sync/syncService.d.ts +1 -0
  53. package/dist/sync/syncService.js +21 -8
  54. package/dist/sync/workspacePullFeed.d.ts +10 -0
  55. package/dist/sync/workspacePullFeed.js +111 -63
  56. package/dist/sync/workspacePullFeed.test.js +112 -0
  57. package/dist/ui/assets/{AgentsModule-JG5jc3he.js → AgentsModule-CpsTmrIW.js} +1 -1
  58. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
  59. package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-C3GyzrWm.js} +1 -1
  60. package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-4-Rh2K2n.js} +1 -1
  61. package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
  62. package/dist/ui/assets/{TaskSettings-Bqd4cCzW.js → TaskSettings-BOC5F6Ag.js} +1 -1
  63. package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
  64. package/dist/ui/assets/index-CLIMgR6g.js +6 -0
  65. package/dist/ui/assets/index-DneETxcu.css +1 -0
  66. package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-pWocEipT.js} +1 -1
  67. package/dist/ui/index.html +3 -3
  68. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  69. package/dist/utils/accountProfileSummaryCache.js +97 -0
  70. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  71. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  72. package/package.json +3 -3
  73. package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
  74. package/dist/ui/assets/index-MXeWoebC.css +0 -1
  75. package/dist/ui/assets/index-YJ4k5QNa.js +0 -6
  76. package/dist/utils/billingStatusCache.d.ts +0 -21
  77. package/dist/utils/billingStatusCache.js +0 -70
@@ -1175,7 +1175,6 @@ export class TaskforceCore {
1175
1175
  writeGlobalConfig: (config) => this.writeGlobalConfig(config),
1176
1176
  });
1177
1177
  this.planEntitlementService.repairPlanCatalogDefaultsIfNeeded();
1178
- this.planEntitlementService.repairSignupMonetizationSettingsIfNeeded();
1179
1178
  this.planEntitlementService.normalizeLegacyPlanDataIfNeeded();
1180
1179
  this.planEntitlementService.backfillAccountEntitlements();
1181
1180
  this.repairDetachedAnnotatedAttachmentSessions();
@@ -1209,11 +1208,14 @@ export class TaskforceCore {
1209
1208
  upsertCanonicalEntitlementRecord(input) {
1210
1209
  return this.planEntitlementService.upsertCanonicalEntitlementRecord(input);
1211
1210
  }
1211
+ transitionCanonicalCommercialState(input) {
1212
+ return this.planEntitlementService.transitionCanonicalCommercialState(input);
1213
+ }
1212
1214
  resolveWorkspaceLimitForUser(userId) {
1213
1215
  return this.planEntitlementService.resolveWorkspaceLimitForUser(userId);
1214
1216
  }
1215
- resolveRegistrationPlanAssignment(selectedPlanVersionIdRaw) {
1216
- return this.planEntitlementService.resolveRegistrationPlanAssignment(selectedPlanVersionIdRaw);
1217
+ resolveRegistrationOnboardingOutcome(selectedPlanVersionIdRaw) {
1218
+ return this.planEntitlementService.resolveRegistrationOnboardingOutcome(selectedPlanVersionIdRaw);
1217
1219
  }
1218
1220
  createUserBillingRecord(userId, options) {
1219
1221
  return this.planEntitlementService.createUserBillingRecord(userId, options);
@@ -2598,11 +2600,11 @@ export class TaskforceCore {
2598
2600
  const config = this.readConfig(workspaceId, userId) || {};
2599
2601
  return typeof config.jsonBackupEnabled === 'boolean';
2600
2602
  }
2601
- getSignupMonetizationSettings() {
2602
- return this.planEntitlementService.getSignupMonetizationSettings();
2603
+ getOnboardingPolicy() {
2604
+ return this.planEntitlementService.getOnboardingPolicy();
2603
2605
  }
2604
- validateSignupMonetizationSettings(input) {
2605
- return this.planEntitlementService.validateSignupMonetizationSettings(input);
2606
+ validateOnboardingPolicy(input) {
2607
+ return this.planEntitlementService.validateOnboardingPolicy(input);
2606
2608
  }
2607
2609
  updateGlobalSettings(settings) {
2608
2610
  updateGlobalSettingsService(this.getGlobalSettingsServiceDeps(), settings);
@@ -4375,6 +4377,193 @@ export class TaskforceCore {
4375
4377
  `).run(enabled ? 1 : 0, new Date().toISOString(), this.tenantId, normalizedUserId);
4376
4378
  return Number(result?.changes || 0) > 0;
4377
4379
  }
4380
+ purgeSystemUser(input) {
4381
+ const targetUserId = String(input.targetUserId || '').trim();
4382
+ const actorUserId = String(input.actorUserId || '').trim();
4383
+ const emailConfirmation = String(input.emailConfirmation || '').trim().toLowerCase();
4384
+ if (!targetUserId) {
4385
+ throw new TaskforceRuleError('targetUserId is required', 400, 'TARGET_USER_ID_REQUIRED');
4386
+ }
4387
+ if (!actorUserId || actorUserId === 'anonymous') {
4388
+ throw new TaskforceRuleError('actorUserId is required', 400, 'ACTOR_USER_ID_REQUIRED');
4389
+ }
4390
+ if (!emailConfirmation) {
4391
+ throw new TaskforceRuleError('Email confirmation is required', 400, 'EMAIL_CONFIRMATION_REQUIRED');
4392
+ }
4393
+ if (targetUserId === actorUserId) {
4394
+ throw new TaskforceRuleError('Cannot purge the current system admin session user', 400, 'CURRENT_SYSTEM_USER_PURGE_FORBIDDEN');
4395
+ }
4396
+ return this.db.transaction(() => {
4397
+ const user = this.db.prepare(`
4398
+ SELECT
4399
+ id,
4400
+ email,
4401
+ COALESCE(system_role, 'user') AS system_role,
4402
+ COALESCE(is_disabled, 0) AS is_disabled
4403
+ FROM users
4404
+ WHERE tenant_id = ? AND id = ?
4405
+ LIMIT 1
4406
+ `).get(this.tenantId, targetUserId);
4407
+ if (!user?.id || !user.email) {
4408
+ throw new TaskforceRuleError('User not found', 404, 'USER_NOT_FOUND');
4409
+ }
4410
+ const normalizedEmail = String(user.email).trim().toLowerCase();
4411
+ if (normalizedEmail !== emailConfirmation) {
4412
+ throw new TaskforceRuleError('Email confirmation did not match the target user', 400, 'EMAIL_CONFIRMATION_MISMATCH');
4413
+ }
4414
+ const currentRole = this.normalizeSystemRole(user.system_role, 'user');
4415
+ const isDisabled = Boolean(user.is_disabled);
4416
+ if (currentRole === 'system_admin' && !isDisabled && this.countSystemAdmins() <= 1) {
4417
+ throw new TaskforceRuleError('Cannot purge the last enabled system admin', 400, 'LAST_SYSTEM_ADMIN_REQUIRED');
4418
+ }
4419
+ const elevatedWorkspaceMemberships = this.db.prepare(`
4420
+ SELECT
4421
+ m.workspace_id AS workspace_id,
4422
+ COALESCE(m.role, 'member') AS role,
4423
+ w.name AS workspace_name
4424
+ FROM workspace_memberships m
4425
+ LEFT JOIN workspaces w
4426
+ ON w.tenant_id = m.tenant_id
4427
+ AND w.id = m.workspace_id
4428
+ WHERE m.tenant_id = ?
4429
+ AND m.user_id = ?
4430
+ AND COALESCE(m.status, 'active') = 'active'
4431
+ AND COALESCE(m.role, 'member') IN ('owner', 'admin')
4432
+ ORDER BY COALESCE(w.updated_at, w.created_at) DESC, w.name ASC
4433
+ LIMIT 10
4434
+ `).all(this.tenantId, targetUserId);
4435
+ if (elevatedWorkspaceMemberships.length > 0) {
4436
+ throw new TaskforceRuleError('Cannot purge a user with active owner/admin workspace memberships', 409, 'SYSTEM_USER_PURGE_BLOCKED_ACTIVE_WORKSPACE_ROLE', {
4437
+ memberships: elevatedWorkspaceMemberships.map((membership) => ({
4438
+ workspaceId: membership.workspace_id ? String(membership.workspace_id) : null,
4439
+ workspaceName: membership.workspace_name ? String(membership.workspace_name) : null,
4440
+ role: membership.role ? String(membership.role) : null
4441
+ }))
4442
+ });
4443
+ }
4444
+ const billingRow = this.db.prepare(`
4445
+ SELECT
4446
+ stripe_customer_id,
4447
+ stripe_subscription_id,
4448
+ stripe_price_id,
4449
+ stripe_status,
4450
+ billing_interval
4451
+ FROM user_billing
4452
+ WHERE tenant_id = ? AND user_id = ?
4453
+ LIMIT 1
4454
+ `).get(this.tenantId, targetUserId);
4455
+ const hasCommercialBillingState = [
4456
+ billingRow?.stripe_customer_id,
4457
+ billingRow?.stripe_subscription_id,
4458
+ billingRow?.stripe_price_id,
4459
+ billingRow?.stripe_status,
4460
+ billingRow?.billing_interval
4461
+ ].some((value) => String(value || '').trim().length > 0);
4462
+ if (hasCommercialBillingState) {
4463
+ throw new TaskforceRuleError('Cannot purge a user with active commercial billing state', 409, 'SYSTEM_USER_PURGE_BLOCKED_BILLING_STATE');
4464
+ }
4465
+ const now = new Date().toISOString();
4466
+ const runCount = (sql, ...params) => Number(this.db.prepare(sql).run(...params)?.changes || 0);
4467
+ const unassignedTaskCount = runCount(`
4468
+ UPDATE tasks
4469
+ SET assignee = 'unassigned',
4470
+ updated_at = ?
4471
+ WHERE tenant_id = ?
4472
+ AND assignee = ?
4473
+ AND is_archived = 0
4474
+ `, now, this.tenantId, targetUserId);
4475
+ const clearedInitiativeOwnerCount = runCount(`
4476
+ UPDATE initiatives
4477
+ SET owner_id = NULL,
4478
+ updated_at = ?
4479
+ WHERE tenant_id = ?
4480
+ AND owner_id = ?
4481
+ `, now, this.tenantId, targetUserId);
4482
+ const clearedWorkstreamOwnerCount = runCount(`
4483
+ UPDATE workstreams
4484
+ SET owner_id = NULL,
4485
+ updated_at = ?
4486
+ WHERE tenant_id = ?
4487
+ AND owner_id = ?
4488
+ `, now, this.tenantId, targetUserId);
4489
+ const deletedEntitlementCount = runCount(`
4490
+ DELETE FROM account_entitlements
4491
+ WHERE tenant_id = ?
4492
+ AND account_id = ?
4493
+ `, this.tenantId, targetUserId);
4494
+ const deletedWorkspaceMembershipCount = runCount(`
4495
+ DELETE FROM workspace_memberships
4496
+ WHERE tenant_id = ?
4497
+ AND user_id = ?
4498
+ `, this.tenantId, targetUserId);
4499
+ const deletedAuthTokenCount = runCount(`
4500
+ DELETE FROM auth_tokens
4501
+ WHERE tenant_id = ?
4502
+ AND user_id = ?
4503
+ `, this.tenantId, targetUserId);
4504
+ const deletedAvatarDraftCount = runCount(`
4505
+ DELETE FROM user_avatar_upload_drafts
4506
+ WHERE tenant_id = ?
4507
+ AND user_id = ?
4508
+ `, this.tenantId, targetUserId);
4509
+ const deletedMcpAccessTokenCount = runCount(`
4510
+ DELETE FROM mcp_access_tokens
4511
+ WHERE tenant_id = ?
4512
+ AND user_id = ?
4513
+ `, this.tenantId, targetUserId);
4514
+ const deletedMcpOauthAuthCodeCount = runCount(`
4515
+ DELETE FROM mcp_oauth_auth_codes
4516
+ WHERE tenant_id = ?
4517
+ AND user_id = ?
4518
+ `, this.tenantId, targetUserId);
4519
+ const deletedMcpOauthConnectorGrantCount = runCount(`
4520
+ DELETE FROM mcp_oauth_connector_grants
4521
+ WHERE tenant_id = ?
4522
+ AND user_id = ?
4523
+ `, this.tenantId, targetUserId);
4524
+ const deletedUiStateCount = runCount(`
4525
+ DELETE FROM ui_state
4526
+ WHERE tenant_id = ?
4527
+ AND user_id = ?
4528
+ `, this.tenantId, targetUserId);
4529
+ const deletedUserPreferenceCount = runCount(`
4530
+ DELETE FROM user_preferences
4531
+ WHERE tenant_id = ?
4532
+ AND user_id = ?
4533
+ `, this.tenantId, targetUserId);
4534
+ const deletedUserBillingCount = runCount(`
4535
+ DELETE FROM user_billing
4536
+ WHERE tenant_id = ?
4537
+ AND user_id = ?
4538
+ `, this.tenantId, targetUserId);
4539
+ const deletedUserCount = runCount(`
4540
+ DELETE FROM users
4541
+ WHERE tenant_id = ?
4542
+ AND id = ?
4543
+ `, this.tenantId, targetUserId);
4544
+ if (deletedUserCount !== 1) {
4545
+ throw new TaskforceRuleError('User not found', 404, 'USER_NOT_FOUND');
4546
+ }
4547
+ return {
4548
+ targetUserId,
4549
+ email: String(user.email),
4550
+ unassignedTaskCount,
4551
+ clearedInitiativeOwnerCount,
4552
+ clearedWorkstreamOwnerCount,
4553
+ deletedWorkspaceMembershipCount,
4554
+ deletedAuthTokenCount,
4555
+ deletedAvatarDraftCount,
4556
+ deletedMcpAccessTokenCount,
4557
+ deletedMcpOauthAuthCodeCount,
4558
+ deletedMcpOauthConnectorGrantCount,
4559
+ deletedUiStateCount,
4560
+ deletedUserPreferenceCount,
4561
+ deletedEntitlementCount,
4562
+ deletedUserBillingCount,
4563
+ deletedUserCount
4564
+ };
4565
+ })();
4566
+ }
4378
4567
  clearActiveAssignmentsForUnavailableActor(actorId, workspaceId, updatedAt) {
4379
4568
  const normalizedActorId = String(actorId || '').trim();
4380
4569
  const normalizedWorkspaceId = String(workspaceId || 'default').trim() || 'default';
@@ -5329,7 +5518,20 @@ export class TaskforceCore {
5329
5518
  const assignFirstSystemAdmin = !this.hasAnySystemAdmin();
5330
5519
  const betaAccess = input.betaAccess !== false;
5331
5520
  const requestedWorkspaceId = String(input.workspaceId || '').trim();
5332
- const registrationPlanAssignment = this.resolveRegistrationPlanAssignment(input.planVersionId);
5521
+ const onboardingOutcome = this.resolveRegistrationOnboardingOutcome(input.planVersionId);
5522
+ const applyRegistrationCommercialState = (accountId, now) => {
5523
+ this.transitionCanonicalCommercialState({
5524
+ accountId,
5525
+ planVersionId: onboardingOutcome.planVersionId,
5526
+ state: onboardingOutcome.commercialState,
5527
+ effectiveAt: now,
5528
+ billingMirrorPlanId: onboardingOutcome.planId,
5529
+ source: 'registration',
5530
+ updatedBy: 'registration',
5531
+ createdAt: now,
5532
+ updatedAt: now
5533
+ });
5534
+ };
5333
5535
  if (!requestedWorkspaceId || requestedWorkspaceId.toLowerCase() === 'default') {
5334
5536
  const now = new Date().toISOString();
5335
5537
  const userId = this.generateEntityId('user');
@@ -5338,32 +5540,14 @@ export class TaskforceCore {
5338
5540
  INSERT INTO users (id, tenant_id, email, display_name, auth_provider, external_auth_id, password_hash, system_role, beta_access, is_disabled, created_at, updated_at)
5339
5541
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
5340
5542
  `).run(userId, this.tenantId, email, displayName, 'password', email, hashPassword(password), assignFirstSystemAdmin ? 'system_admin' : 'user', betaAccess ? 1 : 0, now, now);
5341
- if (registrationPlanAssignment.requiresPlanSelection) {
5342
- this.createUserBillingRecord(userId, {
5343
- entitlementState: 'pending_plan_selection',
5344
- planId: null
5345
- });
5346
- }
5347
- if (!registrationPlanAssignment.requiresPlanSelection && registrationPlanAssignment.planVersionId && registrationPlanAssignment.planId) {
5348
- this.createUserBillingRecord(userId, {
5349
- entitlementState: 'active',
5350
- planId: registrationPlanAssignment.planId
5351
- });
5352
- this.upsertCanonicalEntitlementRecord({
5353
- accountId: userId,
5354
- planVersionId: registrationPlanAssignment.planVersionId,
5355
- state: 'active',
5356
- effectiveAt: now,
5357
- updatedBy: 'registration',
5358
- createdAt: now,
5359
- updatedAt: now
5360
- });
5361
- }
5543
+ applyRegistrationCommercialState(userId, now);
5362
5544
  return {
5363
5545
  userId,
5364
5546
  workspaceId: null,
5365
5547
  role: 'member',
5366
- planSelectionRequired: registrationPlanAssignment.requiresPlanSelection
5548
+ planSelectionRequired: onboardingOutcome.requiresPlanSelection,
5549
+ checkoutPending: onboardingOutcome.checkoutPending,
5550
+ commercialState: onboardingOutcome.commercialState
5367
5551
  };
5368
5552
  }
5369
5553
  const workspaceId = requestedWorkspaceId;
@@ -5381,27 +5565,12 @@ export class TaskforceCore {
5381
5565
  WHERE tenant_id = ? AND id = ?
5382
5566
  `).run(new Date().toISOString(), this.tenantId, created.userId);
5383
5567
  }
5384
- if (registrationPlanAssignment.requiresPlanSelection) {
5385
- this.createUserBillingRecord(created.userId, {
5386
- entitlementState: 'pending_plan_selection',
5387
- planId: null
5388
- });
5389
- }
5390
- if (!registrationPlanAssignment.requiresPlanSelection && registrationPlanAssignment.planVersionId && registrationPlanAssignment.planId) {
5391
- this.createUserBillingRecord(created.userId, {
5392
- entitlementState: 'active',
5393
- planId: registrationPlanAssignment.planId
5394
- });
5395
- this.upsertCanonicalEntitlementRecord({
5396
- accountId: created.userId,
5397
- planVersionId: registrationPlanAssignment.planVersionId,
5398
- state: 'active',
5399
- updatedBy: 'registration'
5400
- });
5401
- }
5568
+ applyRegistrationCommercialState(created.userId);
5402
5569
  return {
5403
5570
  ...created,
5404
- planSelectionRequired: registrationPlanAssignment.requiresPlanSelection
5571
+ planSelectionRequired: onboardingOutcome.requiresPlanSelection,
5572
+ checkoutPending: onboardingOutcome.checkoutPending,
5573
+ commercialState: onboardingOutcome.commercialState
5405
5574
  };
5406
5575
  }
5407
5576
  createOneTimeAuthToken(userId, purpose, ttlSeconds, workspaceId) {
@@ -27,7 +27,6 @@ function setupWorkspace(core, userId = 'owner-1', workspaceId = 'workspace-1') {
27
27
  hasEntitlement: true,
28
28
  canAccessApp: true,
29
29
  canAccessPlans: true,
30
- signupMonetizationStrategy: 'none',
31
30
  planSelectionRequired: false,
32
31
  message: null,
33
32
  entitlement: null
@@ -328,7 +327,6 @@ describe('TaskforceCore MCP auth invalidation', () => {
328
327
  hasEntitlement: false,
329
328
  canAccessApp: false,
330
329
  canAccessPlans: true,
331
- signupMonetizationStrategy: 'none',
332
330
  planSelectionRequired: false,
333
331
  message: 'Suspended for test',
334
332
  entitlement: null
@@ -13,10 +13,19 @@ function seedPlans(core) {
13
13
  versionNumber: 1,
14
14
  seatLimit: 1
15
15
  });
16
+ const db = core.db;
17
+ const now = new Date().toISOString();
18
+ db.prepare(`
19
+ INSERT OR IGNORE INTO billing_price_mappings (
20
+ tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
21
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
22
+ `).run(core.getTenantId(), 'starter-v1', 'month', 'free:starter-v1:month', 'free', 1, 0, null, now, now);
16
23
  core.updateGlobalSettings({
17
- auth: {
18
- signupMonetizationStrategy: 'auto_assign_default',
19
- defaultSignupPlanVersionId: 'starter-v1'
24
+ billing: {
25
+ onboardingPolicy: {
26
+ mode: 'auto_grant_free',
27
+ planVersionId: 'starter-v1'
28
+ }
20
29
  }
21
30
  });
22
31
  }
@@ -268,8 +268,9 @@ export interface GlobalTaskforceSettings {
268
268
  authRateLimitEnabled?: boolean;
269
269
  authRateLimitMaxRequests?: number;
270
270
  authRateLimitWindowMs?: number;
271
- signupMonetizationStrategy?: 'auto_assign_default' | 'plan_selection_required';
272
- defaultSignupPlanVersionId?: string | null;
271
+ };
272
+ billing?: {
273
+ onboardingPolicy?: OnboardingPolicy;
273
274
  };
274
275
  site?: {
275
276
  showRunTaskforceLocally?: boolean;
@@ -299,14 +300,14 @@ export interface UserGlobalSyncSettings {
299
300
  weekStartsOn?: 'sunday' | 'monday';
300
301
  };
301
302
  }
302
- export type SignupMonetizationStrategy = 'auto_assign_default' | 'plan_selection_required';
303
- export interface SignupMonetizationSettings {
304
- strategy: SignupMonetizationStrategy;
305
- defaultSignupPlanVersionId: string | null;
303
+ export type OnboardingPolicyMode = 'require_plan_selection' | 'auto_grant_free' | 'auto_start_trial';
304
+ export interface OnboardingPolicy {
305
+ mode: OnboardingPolicyMode;
306
+ planVersionId: string | null;
306
307
  }
307
- export interface SignupMonetizationValidationResult {
308
+ export interface OnboardingPolicyValidationResult {
308
309
  valid: boolean;
309
- code?: 'SIGNUP_PLAN_VERSION_REQUIRED' | 'SIGNUP_PLAN_VERSION_NOT_FOUND' | 'SIGNUP_PLAN_NOT_ENABLED';
310
+ code?: 'ONBOARDING_PLAN_VERSION_REQUIRED' | 'ONBOARDING_PLAN_VERSION_NOT_FOUND' | 'ONBOARDING_PLAN_NOT_ENABLED' | 'ONBOARDING_PLAN_MUST_BE_FREE' | 'ONBOARDING_PLAN_TRIAL_REQUIRED';
310
311
  message?: string;
311
312
  details?: Record<string, unknown>;
312
313
  }
@@ -441,7 +442,7 @@ export interface McpAuditLogRecord {
441
442
  reasonCode: string | null;
442
443
  details: Record<string, unknown>;
443
444
  }
444
- export type EntitlementState = 'active' | 'trialing' | 'grace' | 'suspended' | 'canceled';
445
+ export type EntitlementState = 'pending_plan_selection' | 'checkout_pending' | 'active' | 'trialing' | 'grace' | 'suspended' | 'canceled';
445
446
  export type PlanFeatureAccess = 'enabled' | 'disabled' | 'limited';
446
447
  export interface PlanFeatureSnapshot {
447
448
  featureKey: string;
@@ -499,17 +500,15 @@ export interface AccountAccessStatus_Ok {
499
500
  hasEntitlement: true;
500
501
  canAccessApp: true;
501
502
  canAccessPlans: true;
502
- signupMonetizationStrategy: SignupMonetizationStrategy;
503
503
  planSelectionRequired: false;
504
504
  message: null;
505
505
  entitlement: AccountEntitlementSnapshot;
506
506
  }
507
507
  export interface AccountAccessStatus_Blocked {
508
- gate: 'plan_selection_required' | 'misconfigured' | 'missing_entitlement';
508
+ gate: 'plan_selection_required' | 'checkout_pending' | 'misconfigured' | 'missing_entitlement';
509
509
  hasEntitlement: boolean;
510
510
  canAccessApp: false;
511
511
  canAccessPlans: true;
512
- signupMonetizationStrategy: SignupMonetizationStrategy;
513
512
  planSelectionRequired: boolean;
514
513
  message: string;
515
514
  entitlement: AccountEntitlementSnapshot | null;
@@ -1571,7 +1571,12 @@ export function useSyncOrchestrator(input) {
1571
1571
  status: 'error',
1572
1572
  statusCode: pullResult.status,
1573
1573
  errorMessage: message,
1574
- requestMs: pullResult.pullRequestMs
1574
+ requestMs: pullResult.pullRequestMs,
1575
+ details: {
1576
+ pages: pullResult.pages,
1577
+ applyMs: pullResult.applyMs,
1578
+ ...(pullResult.serverDiagnostics ? { serverPullDiagnostics: pullResult.serverDiagnostics } : {})
1579
+ }
1575
1580
  });
1576
1581
  if (pullResult.transient || pullResult.hasTransientApplyFailure || isTransientWorkspaceSyncStatus(pullResult.status)) {
1577
1582
  scheduleWorkspaceSyncRetry(pullResult.retryAfterMs);
@@ -1638,7 +1643,12 @@ export function useSyncOrchestrator(input) {
1638
1643
  eventType: workspaceSyncPhase === 'attach-cloud' ? 'bootstrap' : 'pull',
1639
1644
  status: 'success',
1640
1645
  changeCount: pullResult.appliedChanges,
1641
- requestMs: pullResult.pullRequestMs
1646
+ requestMs: pullResult.pullRequestMs,
1647
+ details: {
1648
+ pages: pullResult.pages,
1649
+ applyMs: pullResult.applyMs,
1650
+ ...(pullResult.serverDiagnostics ? { serverPullDiagnostics: pullResult.serverDiagnostics } : {})
1651
+ }
1642
1652
  });
1643
1653
  if (workspaceSyncPhase === 'attach-cloud') {
1644
1654
  await refreshWorkspaceSyncMarkdownDocumentsSnapshot();
@@ -1727,8 +1737,11 @@ export function useSyncOrchestrator(input) {
1727
1737
  const eventId = String(signal?.eventId || '').trim();
1728
1738
  if (eventId && realtimeSuppressedEventIdsRef.current.delete(eventId))
1729
1739
  return;
1730
- if (isWorkspaceRetryPending() || workspacePullInFlightRef.current)
1740
+ if (workspacePullInFlightRef.current)
1731
1741
  return;
1742
+ if (isWorkspaceRetryPending()) {
1743
+ clearWorkspaceRetry();
1744
+ }
1732
1745
  if (realtimePullDebounceRef.current !== null) {
1733
1746
  window.clearTimeout(realtimePullDebounceRef.current);
1734
1747
  }
@@ -1742,6 +1755,7 @@ export function useSyncOrchestrator(input) {
1742
1755
  workspaceCloudSyncEnabled,
1743
1756
  workspaceSyncPhase,
1744
1757
  isWorkspaceRetryPending,
1758
+ clearWorkspaceRetry,
1745
1759
  workspacePullInFlightRef,
1746
1760
  pullWorkspaceChangesFromCloud
1747
1761
  ]);
@@ -3,19 +3,44 @@ import { act, renderHook, waitFor } from '@testing-library/react';
3
3
  import { useSyncOrchestrator } from './useSyncOrchestrator';
4
4
  class MockWebSocket {
5
5
  static instances = [];
6
+ static OPEN = 1;
7
+ static CLOSED = 3;
6
8
  url;
9
+ readyState = 0;
10
+ sent = [];
11
+ listeners = new Map();
7
12
  constructor(url) {
8
13
  this.url = url;
9
14
  MockWebSocket.instances.push(this);
10
15
  }
11
16
  close() {
12
- // noop for orchestration gating tests
17
+ this.readyState = MockWebSocket.CLOSED;
18
+ this.emit('close', {});
13
19
  }
14
- addEventListener() {
15
- // noop for orchestration gating tests
20
+ addEventListener(type, listener) {
21
+ const bucket = this.listeners.get(type) || new Set();
22
+ bucket.add(listener);
23
+ this.listeners.set(type, bucket);
16
24
  }
17
- send() {
18
- // noop for orchestration gating tests
25
+ send(payload) {
26
+ if (typeof payload === 'string') {
27
+ this.sent.push(payload);
28
+ }
29
+ }
30
+ emit(type, event) {
31
+ const bucket = this.listeners.get(type);
32
+ if (!bucket)
33
+ return;
34
+ for (const listener of bucket) {
35
+ listener(event);
36
+ }
37
+ }
38
+ open() {
39
+ this.readyState = MockWebSocket.OPEN;
40
+ this.emit('open', {});
41
+ }
42
+ message(payload) {
43
+ this.emit('message', { data: JSON.stringify(payload) });
19
44
  }
20
45
  }
21
46
  const { ensureCloudWorkspaceReadyForSyncServiceMock, runWorkspacePullSyncServiceMock, runWorkspacePushSyncServiceMock, syncUserGlobalSettingsServiceMock } = vi.hoisted(() => ({
@@ -206,6 +231,73 @@ describe('useSyncOrchestrator', () => {
206
231
  expect(MockWebSocket.instances[0]?.url).toBe('wss://api.example.com/taskforce-ws');
207
232
  unmount();
208
233
  });
234
+ it('lets a realtime signal break transient retry backoff and pull immediately', async () => {
235
+ vi.useFakeTimers();
236
+ vi.stubGlobal('WebSocket', MockWebSocket);
237
+ runWorkspacePullSyncServiceMock
238
+ .mockResolvedValueOnce({
239
+ success: false,
240
+ kind: 'pull_http',
241
+ status: 502,
242
+ transient: true,
243
+ retryAfterMs: 60_000,
244
+ cursor: null,
245
+ pages: 0,
246
+ appliedChanges: 0,
247
+ pullRequestMs: 1,
248
+ applyMs: 0,
249
+ pulledDeleteTaskIds: new Set(),
250
+ pulledActiveUpserts: false,
251
+ pulledArchivedUpserts: false,
252
+ documentDecryptFailures: []
253
+ })
254
+ .mockResolvedValueOnce({
255
+ success: true,
256
+ syncedAt: '2026-03-15T18:05:00.000Z',
257
+ cursor: null,
258
+ pages: 1,
259
+ appliedChanges: 1,
260
+ pullRequestMs: 1,
261
+ applyMs: 0,
262
+ pulledDeleteTaskIds: new Set(),
263
+ pulledActiveUpserts: true,
264
+ pulledArchivedUpserts: false,
265
+ documentDecryptFailures: []
266
+ });
267
+ const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
268
+ initialProps: buildInput({ realtimeSyncEnabled: true })
269
+ });
270
+ act(() => {
271
+ result.current.applyWorkspaceSyncStateSnapshot({
272
+ enabled: true,
273
+ phase: 'active',
274
+ pullCursor: null
275
+ });
276
+ });
277
+ await act(async () => {
278
+ await Promise.resolve();
279
+ await Promise.resolve();
280
+ });
281
+ expect(result.current.workspaceRetryAt).not.toBeNull();
282
+ expect(MockWebSocket.instances).toHaveLength(1);
283
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(1);
284
+ act(() => {
285
+ MockWebSocket.instances[0]?.open();
286
+ MockWebSocket.instances[0]?.message({
287
+ type: 'taskforce:update',
288
+ workspaceId: 'workspace-local-active'
289
+ });
290
+ });
291
+ await act(async () => {
292
+ await vi.advanceTimersByTimeAsync(300);
293
+ await Promise.resolve();
294
+ await Promise.resolve();
295
+ });
296
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(2);
297
+ expect(result.current.workspaceRetryAt).toBeNull();
298
+ expect(result.current.workspaceLastPullAt).toBe('2026-03-15T18:05:00.000Z');
299
+ unmount();
300
+ });
209
301
  it('retries transient push failures automatically', async () => {
210
302
  runWorkspacePushSyncServiceMock
211
303
  .mockResolvedValueOnce({
@@ -249,6 +249,9 @@ export declare function useTaskforce({ config, initialTaskId, onTaskCountChange,
249
249
  emailSent?: boolean;
250
250
  emailError?: string;
251
251
  workspaceSetupRequired?: boolean;
252
+ planSelectionRequired?: boolean;
253
+ checkoutPending?: boolean;
254
+ commercialState?: string | null;
252
255
  }>;
253
256
  updateCurrentUserProfile: (input: {
254
257
  displayName: string;
@@ -229,6 +229,8 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
229
229
  if (!path.startsWith('/'))
230
230
  return path;
231
231
  const isCloudScopedPath = path.startsWith('/api/taskforce/auth/')
232
+ || path.startsWith('/api/taskforce/account/')
233
+ || path.startsWith('/api/taskforce/billing/')
232
234
  || path.startsWith('/api/taskforce/sync/')
233
235
  || path.startsWith('/api/taskforce/settings/mcp/');
234
236
  if (!isCloudHost && isCloudScopedPath && (!runtimeConfigReady || runtimeConfigOverride.cloudAuthViaLocalProxy)) {
@@ -1999,7 +2001,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
1999
2001
  const retryBootstrapChecks = useCallback(async () => {
2000
2002
  setBootstrapError(null);
2001
2003
  markBootstrapPhase('auth');
2002
- const authenticated = await checkAuthSession();
2004
+ const authenticated = await checkAuthSession({ force: true });
2003
2005
  if (workspaceSwitchingEnabled && authenticated) {
2004
2006
  const workspaceResolution = await resolveWorkspaceAfterAuth();
2005
2007
  if (!workspaceResolution.success || workspaceResolution.workspaceSetupRequired) {
@@ -2066,7 +2068,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
2066
2068
  if (!res.ok || data?.success === false) {
2067
2069
  return { success: false, error: data?.error || `Failed to delete workspace (${res.status})`, code: data?.code };
2068
2070
  }
2069
- await checkAuthSession();
2071
+ await checkAuthSession({ force: true });
2070
2072
  await Promise.all([fetchConfig(), fetchWorkspaces()]);
2071
2073
  const nextWorkspaceId = typeof data?.nextWorkspaceId === 'string' ? data.nextWorkspaceId.trim() : '';
2072
2074
  if (nextWorkspaceId) {
@@ -2168,7 +2170,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
2168
2170
  return { success: false, error: data?.error || 'Sign in failed.', code: data?.code };
2169
2171
  }
2170
2172
  setError('');
2171
- await checkAuthSession();
2173
+ await checkAuthSession({ force: true });
2172
2174
  const workspaceResolution = await resolveWorkspaceAfterAuth();
2173
2175
  if (!workspaceResolution.success) {
2174
2176
  return {
@@ -2229,6 +2231,9 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
2229
2231
  return {
2230
2232
  success: true,
2231
2233
  workspaceSetupRequired: Boolean(data?.workspaceSetupRequired),
2234
+ planSelectionRequired: data?.planSelectionRequired === true,
2235
+ checkoutPending: data?.checkoutPending === true,
2236
+ commercialState: typeof data?.commercialState === 'string' ? data.commercialState : null,
2232
2237
  verificationRequired: Boolean(data?.verificationRequired),
2233
2238
  verificationToken: typeof data?.verificationToken === 'string' ? data.verificationToken : undefined,
2234
2239
  emailSent: data?.emailSent !== false,
@@ -2443,7 +2448,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
2443
2448
  return { success: false, error: data?.error || 'Invite acceptance failed.', code: data?.code };
2444
2449
  }
2445
2450
  setError('');
2446
- await checkAuthSession();
2451
+ await checkAuthSession({ force: true });
2447
2452
  const preferredWorkspaceId = typeof data?.workspaceId === 'string' ? data.workspaceId : null;
2448
2453
  const workspaceResolution = await resolveWorkspaceAfterAuth({ preferredWorkspaceId });
2449
2454
  if (!workspaceResolution.success) {