fraim 2.0.352 → 2.0.354

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.
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.registerPublicAuthRoutes = registerPublicAuthRoutes;
37
37
  exports.registerProtectedAuthRoutes = registerProtectedAuthRoutes;
38
38
  exports.__resetAuthRouteRateLimitsForTests = __resetAuthRouteRateLimitsForTests;
39
+ const license_presentation_1 = require("../fraim/license-presentation");
39
40
  const crypto_1 = require("crypto");
40
41
  const email_service_1 = require("../services/email-service");
41
42
  const cookie_service_1 = require("../services/cookie-service");
@@ -63,16 +64,6 @@ function issueMobileExchangeCode(apiKey) {
63
64
  mobileCodeStore.set(code, { apiKey, expiresAt: Date.now() + MOBILE_CODE_TTL_MS });
64
65
  return code;
65
66
  }
66
- function serializeApiKeyLifecycle(apiKeyData) {
67
- const rawExpiresAt = apiKeyData?.expiresAt ?? null;
68
- const parsedExpiresAt = rawExpiresAt ? new Date(rawExpiresAt) : null;
69
- return {
70
- status: apiKeyData?.status ?? 'active',
71
- expiresAt: parsedExpiresAt && !Number.isNaN(parsedExpiresAt.getTime())
72
- ? parsedExpiresAt.toISOString()
73
- : null,
74
- };
75
- }
76
67
  const recentIpRequests = new Map();
77
68
  function pruneOlder(arr, cutoff) {
78
69
  return arr.filter(t => t >= cutoff);
@@ -317,7 +308,7 @@ function registerProtectedAuthRoutes(app, deps) {
317
308
  return res.status(200).json({
318
309
  ok: true,
319
310
  apiKey: apiKeyData.key,
320
- ...serializeApiKeyLifecycle(apiKeyData),
311
+ ...(0, license_presentation_1.serializeLicenseLifecycle)(apiKeyData),
321
312
  });
322
313
  }
323
314
  catch (err) {
@@ -506,13 +497,13 @@ function registerProtectedAuthRoutes(app, deps) {
506
497
  authMethod: signInMethod,
507
498
  source: req.authSource ?? 'api-key',
508
499
  isFirstSignIn,
509
- ...serializeApiKeyLifecycle(apiKeyData),
500
+ ...(0, license_presentation_1.serializeLicenseLifecycle)(apiKeyData),
510
501
  // Nested user object consumed by public/index.html
511
502
  user: {
512
503
  email: apiKeyData.userId,
513
504
  signInMethod,
514
505
  apiKey: apiKeyData.key,
515
- ...serializeApiKeyLifecycle(apiKeyData),
506
+ ...(0, license_presentation_1.serializeLicenseLifecycle)(apiKeyData),
516
507
  },
517
508
  });
518
509
  }
@@ -122,8 +122,8 @@ class EmailService {
122
122
  /**
123
123
  * Send subscription activated confirmation
124
124
  */
125
- async sendSubscriptionActivated(to, plan, amountCents, billingCycle, nextBillingDate, dashboardUrl, founderDiscount = false) {
126
- const template = this.generateSubscriptionActivatedTemplate(plan, amountCents, billingCycle, nextBillingDate, dashboardUrl, founderDiscount);
125
+ async sendSubscriptionActivated(to, plan, amountCents, billingCycle, paidThrough, dashboardUrl, founderDiscount = false) {
126
+ const template = this.generateSubscriptionActivatedTemplate(plan, amountCents, billingCycle, paidThrough, dashboardUrl, founderDiscount);
127
127
  try {
128
128
  const result = await this.sendWithResend({
129
129
  from: this.fromEmail,
@@ -144,10 +144,10 @@ class EmailService {
144
144
  }
145
145
  }
146
146
  /**
147
- * Send monthly renewal receipt
147
+ * Send renewal receipt with the verified paid-through deadline
148
148
  */
149
- async sendRenewalReceipt(to, amount, nextBillingDate, invoiceUrl) {
150
- const template = this.generateRenewalReceiptTemplate(amount, nextBillingDate, invoiceUrl);
149
+ async sendRenewalReceipt(to, amount, paidThrough, invoiceUrl) {
150
+ const template = this.generateRenewalReceiptTemplate(amount, paidThrough, invoiceUrl);
151
151
  const result = await this.sendWithResend({
152
152
  from: this.fromEmail,
153
153
  to,
@@ -164,8 +164,8 @@ class EmailService {
164
164
  /**
165
165
  * Send payment failed alert
166
166
  */
167
- async sendPaymentFailed(to, billingPortalUrl, gracePeriodEndsAt) {
168
- const template = this.generatePaymentFailedTemplate(billingPortalUrl, gracePeriodEndsAt);
167
+ async sendPaymentFailed(to, billingPortalUrl, validThrough) {
168
+ const template = this.generatePaymentFailedTemplate(billingPortalUrl, validThrough);
169
169
  const result = await this.sendWithResend({
170
170
  from: this.fromEmail,
171
171
  to,
@@ -182,8 +182,8 @@ class EmailService {
182
182
  /**
183
183
  * Send payment retry succeeded
184
184
  */
185
- async sendPaymentRestored(to, nextBillingDate) {
186
- const template = this.generatePaymentRestoredTemplate(nextBillingDate);
185
+ async sendPaymentRestored(to, paidThrough) {
186
+ const template = this.generatePaymentRestoredTemplate(paidThrough);
187
187
  const result = await this.sendWithResend({
188
188
  from: this.fromEmail,
189
189
  to,
@@ -216,6 +216,9 @@ class EmailService {
216
216
  console.log(`✅ Subscription cancelled email sent to ${to}`, { id: result.data?.id });
217
217
  }
218
218
  // ========== Email Template Generators ==========
219
+ formatValidityDate(date) {
220
+ return `${date.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '')} UTC`;
221
+ }
219
222
  generateEmailCodeTemplate(actionUrl, code, purpose) {
220
223
  const expiresInMinutes = 15;
221
224
  const isRequestAccess = purpose === 'request-access';
@@ -361,7 +364,7 @@ class EmailService {
361
364
 
362
365
  <h3>Upgrade to Pro for:</h3>
363
366
  <ul>
364
- <li>Unlimited usage (no expiration)</li>
367
+ <li>Subscription access through the verified paid-through date</li>
365
368
  <li>Priority support</li>
366
369
  <li>Advanced workflows</li>
367
370
  <li>Team collaboration features</li>
@@ -378,7 +381,7 @@ class EmailService {
378
381
  </body>
379
382
  </html>
380
383
  `,
381
- text: `How's FRAIM working for you?\n\nYou've been using FRAIM for a week now. We hope it's helping you manage your AI agents more effectively!\n\nYour trial ends in ${daysRemaining} days (${expiresAt.toLocaleDateString()})\n\nUpgrade to Pro for:\n- Unlimited usage (no expiration)\n- Priority support\n- Advanced workflows\n- Team collaboration features\n\nSpecial offer for early adopters: 90% off for pre-seed, pre-revenue founders!\n\nUpgrade: ${this.baseUrl}/upgrade`
384
+ text: `How's FRAIM working for you?\n\nYou've been using FRAIM for a week now. We hope it's helping you manage your AI agents more effectively!\n\nYour trial ends in ${daysRemaining} days (${expiresAt.toLocaleDateString()})\n\nUpgrade to Pro for:\n- Subscription access through the verified paid-through date\n- Priority support\n- Advanced workflows\n- Team collaboration features\n\nSpecial offer for early adopters: 90% off for pre-seed, pre-revenue founders!\n\nUpgrade: ${this.baseUrl}/upgrade`
382
385
  };
383
386
  }
384
387
  generateTrialExpiringTemplate(expiresAt, daysRemaining) {
@@ -493,7 +496,7 @@ class EmailService {
493
496
  text: `Your FRAIM trial has ended\n\nYour 14-day trial expired on ${expiredAt.toLocaleDateString()}.\n\nYour data is safe and preserved. Upgrade now to reactivate your account and continue using FRAIM.\n\nPricing plans:\n- Regular: $200/month or $2,000/year\n- Founders (90% off): $20/month or $200/year\n\nReactivate your account: ${this.baseUrl}/upgrade`
494
497
  };
495
498
  }
496
- generateSubscriptionActivatedTemplate(plan, amountCents, billingCycle, nextBillingDate, dashboardUrl, founderDiscount) {
499
+ generateSubscriptionActivatedTemplate(plan, amountCents, billingCycle, paidThrough, dashboardUrl, founderDiscount) {
497
500
  const formattedAmount = (amountCents / 100).toFixed(2);
498
501
  const displayAmount = `$${formattedAmount}`;
499
502
  const cycleText = billingCycle === 'monthly' ? 'month' : 'year';
@@ -541,7 +544,7 @@ class EmailService {
541
544
  <ul>
542
545
  <li><strong>Plan:</strong> ${plan}${founderDiscount ? ' (Founder Discount - 90% off)' : ''}</li>
543
546
  <li><strong>Amount:</strong> ${displayAmount}/${cycleText}</li>
544
- <li><strong>Next billing date:</strong> ${nextBillingDate.toLocaleDateString()}</li>
547
+ <li><strong>Verified paid-through:</strong> ${this.formatValidityDate(paidThrough)}</li>
545
548
  </ul>
546
549
 
547
550
  <p>${dashboardAccess.lead}</p>
@@ -561,7 +564,7 @@ class EmailService {
561
564
  </body>
562
565
  </html>
563
566
  `,
564
- text: `Welcome to FRAIM Pro!\n\nPayment confirmed! Your subscription is now active.\n\nSubscription Details:\n- Plan: ${plan}${founderDiscount ? ' (Founder Discount - 90% off)' : ''}\n- Amount: ${displayAmount}/${cycleText}\n- Next billing date: ${nextBillingDate.toLocaleDateString()}\n\n${dashboardAccess.lead}\n${dashboardAccess.buttonText}\nWatch onboarding videos: ${this.baseUrl}/resources.html#videos\n\nManage subscription: ${this.baseUrl}/billing`
567
+ text: `Welcome to FRAIM Pro!\n\nPayment confirmed! Your subscription is now active.\n\nSubscription Details:\n- Plan: ${plan}${founderDiscount ? ' (Founder Discount - 90% off)' : ''}\n- Amount: ${displayAmount}/${cycleText}\n- Verified paid-through: ${this.formatValidityDate(paidThrough)}\n\n${dashboardAccess.lead}\n${dashboardAccess.buttonText}\nWatch onboarding videos: ${this.baseUrl}/resources.html#videos\n\nManage subscription: ${this.baseUrl}/billing`
565
568
  };
566
569
  }
567
570
  generateDashboardAccessSection(dashboardUrl, ctaLabel) {
@@ -572,7 +575,7 @@ class EmailService {
572
575
  buttonText: `${ctaLabel}: ${dashboardUrl}`
573
576
  };
574
577
  }
575
- generateRenewalReceiptTemplate(amount, nextBillingDate, invoiceUrl) {
578
+ generateRenewalReceiptTemplate(amount, paidThrough, invoiceUrl) {
576
579
  const formattedAmount = (amount / 100).toFixed(2);
577
580
  return {
578
581
  subject: `Receipt for FRAIM Pro - ${formattedAmount}`,
@@ -595,7 +598,7 @@ class EmailService {
595
598
  <div class="receipt">
596
599
  <h3>Receipt Details:</h3>
597
600
  <p><strong>Amount charged:</strong> ${formattedAmount}</p>
598
- <p><strong>Next billing date:</strong> ${nextBillingDate.toLocaleDateString()}</p>
601
+ <p><strong>Verified paid-through:</strong> ${this.formatValidityDate(paidThrough)}</p>
599
602
  ${invoiceUrl ? `<p><a href="${invoiceUrl}">Download Invoice</a></p>` : ''}
600
603
  </div>
601
604
 
@@ -609,11 +612,18 @@ class EmailService {
609
612
  </body>
610
613
  </html>
611
614
  `,
612
- text: `Payment Receipt\n\nThank you for your continued subscription to FRAIM Pro!\n\nReceipt Details:\n- Amount charged: ${formattedAmount}\n- Next billing date: ${nextBillingDate.toLocaleDateString()}\n${invoiceUrl ? `- Invoice: ${invoiceUrl}\n` : ''}\nYour subscription remains active with no changes.\n\nManage subscription: ${this.baseUrl}/billing`
615
+ text: `Payment Receipt\n\nThank you for your continued subscription to FRAIM Pro!\n\nReceipt Details:\n- Amount charged: ${formattedAmount}\n- Verified paid-through: ${this.formatValidityDate(paidThrough)}\n${invoiceUrl ? `- Invoice: ${invoiceUrl}\n` : ''}\nYour subscription remains active with no changes.\n\nManage subscription: ${this.baseUrl}/billing`
613
616
  };
614
617
  }
615
- generatePaymentFailedTemplate(billingPortalUrl, gracePeriodEndsAt) {
616
- const daysRemaining = Math.ceil((gracePeriodEndsAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
618
+ generatePaymentFailedTemplate(billingPortalUrl, validThrough) {
619
+ const validity = validThrough === null
620
+ ? 'No deadline (authorized manual grant)'
621
+ : this.formatValidityDate(validThrough);
622
+ const accessStatus = validThrough === null
623
+ ? 'Your separately authorized manual grant remains in effect. It does not indicate a successful payment.'
624
+ : validThrough.getTime() > Date.now()
625
+ ? 'Your existing access remains available until the deadline shown below.'
626
+ : 'Your access deadline has passed. Renew successfully to restore subscription access.';
617
627
  return {
618
628
  subject: '🚨 FRAIM: Payment Failed - Action Required',
619
629
  html: `
@@ -642,21 +652,21 @@ class EmailService {
642
652
 
643
653
  <div class="alert">
644
654
  <strong>Your FRAIM subscription payment was declined.</strong><br>
645
- Your access has been suspended.
655
+ ${accessStatus}
646
656
  </div>
647
657
 
648
658
  <p><strong>What this means:</strong></p>
649
659
  <ul>
650
- <li>Your API key has been suspended and won't work</li>
651
- <li>You have ${daysRemaining} days to update your payment method</li>
652
- <li>After ${gracePeriodEndsAt.toLocaleDateString()}, your subscription will be cancelled</li>
660
+ <li><strong>Valid through:</strong> ${validity}</li>
661
+ <li>No automatic grace period applies after verified paid-through.</li>
662
+ <li>Any authorized manual extension is separate from payment and is included in this access deadline.</li>
653
663
  </ul>
654
664
 
655
665
  <p><strong>To restore access:</strong></p>
656
666
  <ol>
657
667
  <li>Click the button below to update your payment method</li>
658
- <li>Your subscription will be automatically renewed</li>
659
- <li>Your API key will be reactivated immediately</li>
668
+ <li>Complete a successful renewal payment</li>
669
+ <li>Subscription access updates when the paid service period is verified</li>
660
670
  </ol>
661
671
 
662
672
  <a href="${billingPortalUrl}" style="display:inline-block;padding:12px 24px;background-color:#dc3545;color:#ffffff !important;text-decoration:none;border-radius:5px;margin:20px 0;font-weight:bold">Update Payment Method</a>
@@ -669,10 +679,10 @@ class EmailService {
669
679
  </body>
670
680
  </html>
671
681
  `,
672
- text: `⚠️ Payment Failed\n\nYour FRAIM subscription payment was declined. Your access has been suspended.\n\nWhat this means:\n- Your API key has been suspended and won't work\n- You have ${daysRemaining} days to update your payment method\n- After ${gracePeriodEndsAt.toLocaleDateString()}, your subscription will be cancelled\n\nTo restore access:\n1. Update your payment method: ${billingPortalUrl}\n2. Your subscription will be automatically renewed\n3. Your API key will be reactivated immediately\n\nNeed help? Reply to this email.`
682
+ text: `Payment Failed\n\nYour FRAIM subscription payment was declined. ${accessStatus}\n\nValid through: ${validity}\nNo automatic grace period applies after verified paid-through.\nAny authorized manual extension is separate from payment and is included in this access deadline.\n\nTo restore subscription access:\n1. Update your payment method: ${billingPortalUrl}\n2. Complete a successful renewal payment\n3. Subscription access updates when the paid service period is verified\n\nNeed help? Reply to this email.`
673
683
  };
674
684
  }
675
- generatePaymentRestoredTemplate(nextBillingDate) {
685
+ generatePaymentRestoredTemplate(paidThrough) {
676
686
  return {
677
687
  subject: '✅ Payment successful - FRAIM access restored',
678
688
  html: `
@@ -705,7 +715,7 @@ class EmailService {
705
715
  </div>
706
716
 
707
717
  <p>Your API key is now active again and you can continue using FRAIM.</p>
708
- <p><strong>Next billing date:</strong> ${nextBillingDate.toLocaleDateString()}</p>
718
+ <p><strong>Verified paid-through:</strong> ${this.formatValidityDate(paidThrough)}</p>
709
719
 
710
720
  <a href="${this.baseUrl}/dashboard" style="display:inline-block;padding:12px 24px;background-color:#10b981;color:#ffffff !important;text-decoration:none;border-radius:5px;margin:20px 0;font-weight:bold">Continue Using FRAIM</a>
711
721
 
@@ -716,10 +726,13 @@ class EmailService {
716
726
  </body>
717
727
  </html>
718
728
  `,
719
- text: `✅ Access Restored!\n\nPayment successful! Your FRAIM subscription has been renewed.\n\nYour API key is now active again and you can continue using FRAIM.\n\nNext billing date: ${nextBillingDate.toLocaleDateString()}\n\nContinue using FRAIM: ${this.baseUrl}/dashboard`
729
+ text: `✅ Access Restored!\n\nPayment successful! Your FRAIM subscription has been renewed.\n\nYour API key is now active again and you can continue using FRAIM.\n\nVerified paid-through: ${this.formatValidityDate(paidThrough)}\n\nContinue using FRAIM: ${this.baseUrl}/dashboard`
720
730
  };
721
731
  }
722
732
  generateSubscriptionCancelledTemplate(endsAt) {
733
+ const accessStatus = endsAt.getTime() > Date.now()
734
+ ? 'Access remains available until this deadline.'
735
+ : 'Subscription access has ended.';
723
736
  return {
724
737
  subject: 'FRAIM subscription cancelled',
725
738
  html: `
@@ -748,7 +761,8 @@ class EmailService {
748
761
 
749
762
  <div class="info">
750
763
  <p><strong>Your subscription has been cancelled.</strong></p>
751
- <p>Your access remains active until: ${endsAt.toLocaleDateString()}</p>
764
+ <p>Valid through: ${this.formatValidityDate(endsAt)}</p>
765
+ <p>${accessStatus}</p>
752
766
  </div>
753
767
 
754
768
  <p>After this date:</p>
@@ -758,7 +772,7 @@ class EmailService {
758
772
  <li>Your data will be preserved for 30 days</li>
759
773
  </ul>
760
774
 
761
- <p><strong>Changed your mind?</strong> You can reactivate your subscription anytime before ${endsAt.toLocaleDateString()}.</p>
775
+ <p><strong>Changed your mind?</strong> Visit the billing portal to review your subscription options.</p>
762
776
 
763
777
  <a href="${this.baseUrl}/billing" style="display:inline-block;padding:12px 24px;background-color:#10b981;color:#ffffff !important;text-decoration:none;border-radius:5px;margin:20px 0;font-weight:bold">Reactivate Subscription</a>
764
778
 
@@ -770,7 +784,7 @@ class EmailService {
770
784
  </body>
771
785
  </html>
772
786
  `,
773
- text: `Subscription Cancelled\n\nYour subscription has been cancelled.\n\nYour access remains active until: ${endsAt.toLocaleDateString()}\n\nAfter this date:\n- Your API key will stop working\n- You won't be able to run workflows\n- Your data will be preserved for 30 days\n\nChanged your mind? You can reactivate your subscription anytime before ${endsAt.toLocaleDateString()}.\n\nReactivate: ${this.baseUrl}/billing\n\nWe're sorry to see you go. Reply to this email to let us know how we can improve.`
787
+ text: `Subscription Cancelled\n\nYour subscription has been cancelled.\n\nValid through: ${this.formatValidityDate(endsAt)}\n${accessStatus}\n\nAfter this date:\n- Your API key will stop working\n- You won't be able to run workflows\n- Your data will be preserved for 30 days\n\nChanged your mind? Visit the billing portal to review your subscription options.\n\nReactivate: ${this.baseUrl}/billing\n\nWe're sorry to see you go. Reply to this email to let us know how we can improve.`
774
788
  };
775
789
  }
776
790
  generateThankYouTemplate(customerName, issueTitle, improvements) {
@@ -134,6 +134,17 @@ class McpService {
134
134
  else if (method === 'ping') {
135
135
  result = {};
136
136
  }
137
+ else if (method === 'fraim/session/validate') {
138
+ const sessionId = typeof params?.sessionId === 'string' ? params.sessionId.trim() : '';
139
+ if (!sessionId || !context.userId || !(await this.dbService.getSessionByUserIdAndSessionId(context.userId, sessionId))) {
140
+ // Issue #2093 — sessions do not expire, so the rejection names the two
141
+ // causes that remain: the session is unknown, or it belongs elsewhere.
142
+ const error = new Error('FRAIM session is unknown or not accessible with this account. Call fraim_connect again.');
143
+ error.code = -32001;
144
+ throw error;
145
+ }
146
+ result = { valid: true };
147
+ }
137
148
  else if (method === 'notifications/initialized') {
138
149
  // Notifications don't need a response
139
150
  return null;
@@ -640,9 +651,16 @@ class McpService {
640
651
  return (0, quality_evidence_1.buildQualityRejectionMessage)(jobName, currentPhase, errors);
641
652
  }
642
653
  async maybeBuildPersonaLockResponse(jobName, userId, apiKey, consumeJobCredit = false) {
643
- if (!(0, feature_flags_1.isPersonaEntitlementsEnabled)() || !userId) {
654
+ if (!userId) {
644
655
  return null;
645
656
  }
657
+ if (!(0, feature_flags_1.isPersonaEntitlementsEnabled)()) {
658
+ const key = apiKey
659
+ ? await this.dbService.verifyApiKey(apiKey)
660
+ : await this.dbService.getApiKeyByUserId(userId, false);
661
+ if (key?.accessScope !== 'persona')
662
+ return null;
663
+ }
646
664
  const decision = await (0, persona_entitlement_service_1.evaluatePersonaAccess)(this.dbService, userId, jobName, apiKey, `/fraim-brain?job=${encodeURIComponent(jobName)}`);
647
665
  if (decision.allowed) {
648
666
  if (consumeJobCredit && decision.entitlement?.hireMode === 'job' && decision.personaKey) {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.computeSubscriptionActive = computeSubscriptionActive;
4
+ exports.hasUsablePersonaEntitlement = hasUsablePersonaEntitlement;
4
5
  exports.resolvePersonaAccessStatuses = resolvePersonaAccessStatuses;
5
6
  exports.buildUngatedPersonaWorkspaceState = buildUngatedPersonaWorkspaceState;
6
7
  exports.buildPersonaHireUrl = buildPersonaHireUrl;
@@ -10,25 +11,41 @@ exports.getWorkspacePersonaState = getWorkspacePersonaState;
10
11
  exports.evaluatePersonaAccess = evaluatePersonaAccess;
11
12
  const persona_hiring_1 = require("../config/persona-hiring");
12
13
  const persona_capability_bundles_1 = require("../config/persona-capability-bundles");
14
+ const current_license_1 = require("../fraim/current-license");
13
15
  const workspace_identity_1 = require("./workspace-identity");
14
16
  const DEFAULT_JOB_CREDITS = 1;
15
17
  // The one place the "is the persona system gating this workspace?" question is
16
18
  // answered. A workspace is subscribed if it explicitly opted in (personaSystemActive)
17
- // or has ever held an active entitlement. Shared by getWorkspacePersonaState and
19
+ // or has a recurring purchase record. Shared by getWorkspacePersonaState and
18
20
  // evaluatePersonaAccess so display and enforcement can never diverge.
19
21
  function computeSubscriptionActive(apiKeyData, entitlements) {
20
- return apiKeyData?.personaSystemActive === true || entitlements.some((e) => e.status === 'active');
22
+ return apiKeyData?.accessScope === 'persona' || apiKeyData?.personaSystemActive === true
23
+ || entitlements.some((e) => e.status === 'active' || !!e.stripeSubscriptionId);
24
+ }
25
+ /** Access and display share expiry and credit checks, including exact deadlines. */
26
+ function hasUsablePersonaEntitlement(entitlement, now = Date.now()) {
27
+ if (entitlement.status !== 'active')
28
+ return false;
29
+ if (entitlement.expiresAt && !(new Date(entitlement.expiresAt).getTime() > now))
30
+ return false;
31
+ if (entitlement.hireMode === 'fulltime' && entitlement.stripeSubscriptionId
32
+ && entitlement.purchaseSource !== 'manual' && !entitlement.expiresAt && !entitlement.billingAccessVerified)
33
+ return false;
34
+ return entitlement.hireMode !== 'job' || (entitlement.jobCreditsRemaining || 0) > 0;
21
35
  }
22
36
  // Single source of truth for per-persona access, mirroring evaluatePersonaAccess's
23
37
  // gating rules exactly:
24
- // - `ungated` (feature flag OFF, or legacy/never-subscribed workspace) => every
38
+ // - `ungated` (legacy/never-subscribed workspace) => every
25
39
  // persona is accessible ('hired').
26
40
  // - otherwise => 'hired' iff the workspace holds an active entitlement for it.
27
41
  // Pure and DB-free so it is trivially testable and callable from either code path.
28
42
  function resolvePersonaAccessStatuses(ungated, entitlements) {
43
+ // MAESTRO is the catalog's full-team hire; it owns no individual jobs.
44
+ const maestroActive = entitlements.some((entitlement) => entitlement.personaKey === 'maestro'
45
+ && entitlement.hireMode === 'fulltime' && hasUsablePersonaEntitlement(entitlement));
29
46
  const activeByKey = new Map();
30
47
  for (const entitlement of entitlements) {
31
- if (entitlement.status !== 'active')
48
+ if (!hasUsablePersonaEntitlement(entitlement))
32
49
  continue;
33
50
  const list = activeByKey.get(entitlement.personaKey) || [];
34
51
  list.push(entitlement);
@@ -42,16 +59,13 @@ function resolvePersonaAccessStatuses(ungated, entitlements) {
42
59
  personaName: bundle.catalogMetadata.displayName,
43
60
  personaRole: bundle.catalogMetadata.role,
44
61
  hireUrl: buildPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
45
- status: (ungated || active.length > 0) ? 'hired' : 'locked',
62
+ status: (ungated || maestroActive || active.length > 0) ? 'hired' : 'locked',
46
63
  seatCount,
47
64
  };
48
65
  });
49
66
  }
50
- // The persona-entitlements feature is OFF: there is no gating, so every persona is
51
- // accessible. Returned by /api/personas/me instead of a 404 so downstream consumers
52
- // (the Hub especially) inherit "flag off => all employees available" rather than
53
- // inventing their own fallback. Matches maybeBuildPersonaLockResponse, which also
54
- // short-circuits to "allowed" when the flag is off. No DB lookup is required.
67
+ // Legacy workspaces with the rollout flag off retain their ungated presentation.
68
+ // Purchased persona scope uses getWorkspacePersonaState even while rollout is off.
55
69
  function buildUngatedPersonaWorkspaceState(userId, apiKey) {
56
70
  return {
57
71
  featureFlagEnabled: false,
@@ -154,7 +168,7 @@ async function syncPersonaEntitlementPurchase(dbService, input) {
154
168
  jobCreditsIncluded,
155
169
  jobCreditsRemaining,
156
170
  activatedAt: existing?.activatedAt || new Date(),
157
- expiresAt: input.expiresAt ?? existing?.expiresAt ?? null,
171
+ expiresAt: input.expiresAt !== undefined ? input.expiresAt : existing?.expiresAt ?? null,
158
172
  canceledAt: input.status === 'expired' ? new Date() : null,
159
173
  metadata: mergeMetadata(existing?.metadata, input.metadata)
160
174
  });
@@ -188,11 +202,40 @@ async function syncPersonaEntitlementFromCheckoutSession(dbService, session, pur
188
202
  metadata: session.metadata || {}
189
203
  });
190
204
  }
191
- async function getWorkspacePersonaState(dbService, userId, apiKey) {
205
+ /** Recurring purchases inherit exact-subscription billing, never another hire's account access. */
206
+ async function hydratePersonaBilling(dbService, entitlements, scope) {
207
+ const subscriptions = new Map();
208
+ return Promise.all(entitlements.map(async (entitlement) => {
209
+ if (entitlement.hireMode !== 'fulltime' || entitlement.purchaseSource === 'manual')
210
+ return entitlement;
211
+ const deny = { ...entitlement, status: 'expired', expiresAt: new Date(0), billingAccessVerified: false };
212
+ const subscriptionId = entitlement.stripeSubscriptionId;
213
+ if (!subscriptionId)
214
+ return deny;
215
+ if (!subscriptions.has(subscriptionId)) {
216
+ subscriptions.set(subscriptionId, dbService.getApiKeyByStripeSubscriptionId(subscriptionId));
217
+ }
218
+ const key = await subscriptions.get(subscriptionId);
219
+ if (!key || key.stripeSubscriptionId !== subscriptionId
220
+ || (key.userId && normalizeEmail(key.userId) !== scope.userId)
221
+ || (entitlement.userId && normalizeEmail(entitlement.userId) !== scope.userId)
222
+ || (key.orgId && key.orgId !== scope.orgId)
223
+ || (entitlement.orgId && entitlement.orgId !== scope.orgId)
224
+ || (key.workspaceId && key.workspaceId !== scope.workspaceId)
225
+ || (entitlement.workspaceId && entitlement.workspaceId !== scope.workspaceId)
226
+ || (key.stripeCustomerId && entitlement.stripeCustomerId && key.stripeCustomerId !== entitlement.stripeCustomerId))
227
+ return deny;
228
+ const usable = (0, current_license_1.hasUsableLicense)(key);
229
+ return { ...entitlement, billingAccessVerified: usable,
230
+ status: usable ? 'active' : key.status === 'suspended' ? 'suspended' : 'expired',
231
+ expiresAt: (0, current_license_1.effectiveLicenseExpiry)(key) };
232
+ }));
233
+ }
234
+ async function getWorkspacePersonaState(dbService, userId, apiKey, featureFlagEnabled = true) {
192
235
  const normalizedUserId = normalizeEmail(userId);
193
236
  let apiKeyData = null;
194
237
  if (apiKey) {
195
- apiKeyData = await dbService.getApiKeyByKey(apiKey);
238
+ apiKeyData = await dbService.verifyApiKey(apiKey);
196
239
  }
197
240
  if (!apiKeyData) {
198
241
  apiKeyData = await dbService.getApiKeyByUserId(normalizedUserId, false);
@@ -206,9 +249,13 @@ async function getWorkspacePersonaState(dbService, userId, apiKey) {
206
249
  let entitlements = await dbService.getPersonaEntitlementsByWorkspaceId(workspace.workspaceId, false);
207
250
  // Fallback: when the local dev workspace has no API key the workspaceId may not
208
251
  // match the cust: prefix written by the Stripe checkout sync. Look up by userId.
209
- if (entitlements.length === 0) {
252
+ if (entitlements.length === 0 && !apiKeyData?.workspaceId && !apiKeyData?.stripeCustomerId) {
210
253
  entitlements = await dbService.getPersonaEntitlementsByUserId(normalizedUserId, false);
211
254
  }
255
+ const subscriptionActive = computeSubscriptionActive(apiKeyData, entitlements);
256
+ const hydratedEntitlements = await hydratePersonaBilling(dbService, entitlements, {
257
+ userId: normalizedUserId, orgId: workspace.orgId, workspaceId: workspace.workspaceId
258
+ });
212
259
  const catalog = (0, persona_capability_bundles_1.listPersonaCapabilityBundles)().map((bundle) => ({
213
260
  personaKey: bundle.personaKey,
214
261
  personaName: bundle.catalogMetadata.displayName,
@@ -216,23 +263,23 @@ async function getWorkspacePersonaState(dbService, userId, apiKey) {
216
263
  hireUrl: buildPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
217
264
  protectedJobs: bundle.protectedJobs
218
265
  }));
219
- const knownPersonaKeys = new Set(entitlements.map((entitlement) => entitlement.personaKey));
220
- const subscriptionActive = computeSubscriptionActive(apiKeyData, entitlements);
266
+ const personas = resolvePersonaAccessStatuses(!subscriptionActive, hydratedEntitlements);
267
+ const hiredPersonaKeys = new Set(personas.filter((persona) => persona.status === 'hired').map((persona) => persona.personaKey));
221
268
  return {
222
- featureFlagEnabled: true,
269
+ featureFlagEnabled,
223
270
  featureFlags: {
224
- personaEntitlementsEnabled: true
271
+ personaEntitlementsEnabled: featureFlagEnabled
225
272
  },
226
273
  subscriptionActive,
227
274
  workspaceId: workspace.workspaceId,
228
275
  userId: normalizedUserId,
229
276
  apiKey: apiKeyData?.key || apiKey || null,
230
- entitlements: entitlements.map((entitlement) => ({
277
+ entitlements: hydratedEntitlements.map((entitlement) => ({
231
278
  personaKey: entitlement.personaKey,
232
279
  personaName: entitlement.personaName,
233
280
  personaRole: entitlement.personaRole,
234
281
  hireMode: entitlement.hireMode,
235
- status: entitlement.status,
282
+ status: entitlement.status === 'active' && !hasUsablePersonaEntitlement(entitlement) ? 'expired' : entitlement.status,
236
283
  jobCreditsRemaining: entitlement.jobCreditsRemaining,
237
284
  expiresAt: entitlement.expiresAt ? entitlement.expiresAt.toISOString() : null,
238
285
  hireUrl: buildPersonaHireUrl(entitlement.personaKey, entitlement.hireMode),
@@ -242,21 +289,21 @@ async function getWorkspacePersonaState(dbService, userId, apiKey) {
242
289
  })),
243
290
  catalog,
244
291
  lockedPersonas: catalog
245
- .filter((persona) => !knownPersonaKeys.has(persona.personaKey))
292
+ .filter((persona) => !hiredPersonaKeys.has(persona.personaKey))
246
293
  .map((persona) => ({
247
294
  ...persona,
248
295
  status: 'locked'
249
296
  })),
250
297
  // Authoritative per-persona verdict: a non-subscribed (legacy) workspace is
251
298
  // ungated, so all personas are accessible; a subscribed one gates to owned.
252
- personas: resolvePersonaAccessStatuses(!subscriptionActive, entitlements),
299
+ personas,
253
300
  };
254
301
  }
255
302
  async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnTo) {
256
303
  const normalizedUserId = normalizeEmail(userId);
257
304
  let apiKeyData = null;
258
305
  if (apiKey) {
259
- apiKeyData = await dbService.getApiKeyByKey(apiKey);
306
+ apiKeyData = await dbService.verifyApiKey(apiKey);
260
307
  }
261
308
  if (!apiKeyData) {
262
309
  apiKeyData = await dbService.getApiKeyByUserId(normalizedUserId, false);
@@ -267,11 +314,11 @@ async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnT
267
314
  stripeCustomerId: apiKeyData?.stripeCustomerId,
268
315
  workspaceId: apiKeyData?.workspaceId
269
316
  });
270
- const workspaceEntitlements = await dbService.getPersonaEntitlementsByWorkspaceId(workspace.workspaceId, false);
317
+ const rawEntitlements = await dbService.getPersonaEntitlementsByWorkspaceId(workspace.workspaceId, false);
271
318
  // Legacy bypass: if the workspace has never subscribed to the persona system,
272
319
  // all jobs are freely accessible — no lock enforcement. Same rule the Hub
273
320
  // display inherits via resolvePersonaAccessStatuses(!subscriptionActive, ...).
274
- const subscriptionActive = computeSubscriptionActive(apiKeyData, workspaceEntitlements);
321
+ const subscriptionActive = computeSubscriptionActive(apiKeyData, rawEntitlements);
275
322
  if (!subscriptionActive) {
276
323
  return {
277
324
  allowed: true,
@@ -280,8 +327,11 @@ async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnT
280
327
  ownedPersonaKeys: []
281
328
  };
282
329
  }
330
+ const workspaceEntitlements = await hydratePersonaBilling(dbService, rawEntitlements, {
331
+ userId: normalizedUserId, orgId: workspace.orgId, workspaceId: workspace.workspaceId
332
+ });
283
333
  const ownedPersonaKeys = Array.from(new Set(workspaceEntitlements
284
- .filter((entitlement) => entitlement.status === 'active')
334
+ .filter((entitlement) => hasUsablePersonaEntitlement(entitlement))
285
335
  .map((entitlement) => entitlement.personaKey)
286
336
  .filter((personaKey) => (0, persona_hiring_1.isPersonaHireKey)(personaKey))));
287
337
  const protectedPersonaKey = (0, persona_capability_bundles_1.getProtectedPersonaForJob)(jobName);
@@ -296,8 +346,14 @@ async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnT
296
346
  ownedPersonaKeys
297
347
  };
298
348
  }
299
- const fulltimeEntitlement = await dbService.getPersonaEntitlement(workspace.workspaceId, protectedPersonaKey, 'fulltime');
300
- if (fulltimeEntitlement && fulltimeEntitlement.status === 'active') {
349
+ const maestroEntitlement = workspaceEntitlements.find((entitlement) => entitlement.personaKey === 'maestro'
350
+ && entitlement.hireMode === 'fulltime' && hasUsablePersonaEntitlement(entitlement));
351
+ if (maestroEntitlement) {
352
+ return { allowed: true, personaKey: protectedPersonaKey, workspaceId: workspace.workspaceId,
353
+ ownedPersonaKeys, entitlement: maestroEntitlement };
354
+ }
355
+ const fulltimeEntitlement = workspaceEntitlements.find(entitlement => entitlement.personaKey === protectedPersonaKey && entitlement.hireMode === 'fulltime');
356
+ if (fulltimeEntitlement && hasUsablePersonaEntitlement(fulltimeEntitlement)) {
301
357
  return {
302
358
  allowed: true,
303
359
  personaKey: protectedPersonaKey,
@@ -306,8 +362,8 @@ async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnT
306
362
  entitlement: fulltimeEntitlement
307
363
  };
308
364
  }
309
- const jobEntitlement = await dbService.getPersonaEntitlement(workspace.workspaceId, protectedPersonaKey, 'job');
310
- if (jobEntitlement && jobEntitlement.status === 'active' && (jobEntitlement.jobCreditsRemaining || 0) > 0) {
365
+ const jobEntitlement = workspaceEntitlements.find(entitlement => entitlement.personaKey === protectedPersonaKey && entitlement.hireMode === 'job');
366
+ if (jobEntitlement && hasUsablePersonaEntitlement(jobEntitlement)) {
311
367
  return {
312
368
  allowed: true,
313
369
  personaKey: protectedPersonaKey,
@@ -330,7 +386,9 @@ async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnT
330
386
  hireUrl = process.env.STRIPE_BILLING_PORTAL_URL || '/billing';
331
387
  ctaLabel = `Restore ${persona.displayName}`;
332
388
  }
333
- else if (priorEntitlement?.status === 'expired') {
389
+ else if (priorEntitlement && (priorEntitlement.status === 'expired'
390
+ || (priorEntitlement.expiresAt && new Date(priorEntitlement.expiresAt).getTime() <= Date.now())
391
+ || (priorEntitlement.hireMode === 'fulltime' && !hasUsablePersonaEntitlement(priorEntitlement)))) {
334
392
  message = `${persona.displayName} was previously active in this workspace but is no longer active. Reactivate ${persona.displayName} to resume this request.`;
335
393
  ctaLabel = `Reactivate ${persona.displayName}`;
336
394
  }