fraim 2.0.351 → 2.0.353
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.
- package/dist/src/api/payment/dashboard-link.js +42 -68
- package/dist/src/api/payment/webhook.js +63 -389
- package/dist/src/api/personas/me.js +4 -7
- package/dist/src/cli/commands/sync.js +3 -2
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +86 -9
- package/dist/src/cli/setup/user-level-sync.js +51 -7
- package/dist/src/cli/utils/managed-agent-paths.js +25 -9
- package/dist/src/core/handoff-contracts.js +8 -2
- package/dist/src/core/proposal-set-contract.js +105 -21
- package/dist/src/core/utils/server-startup.js +2 -2
- package/dist/src/fraim/current-license.js +51 -0
- package/dist/src/fraim/db-service.js +253 -30
- package/dist/src/fraim/license-presentation.js +41 -0
- package/dist/src/local-mcp-server/stdio-server.js +188 -73
- package/dist/src/local-mcp-server/usage-collector.js +20 -11
- package/dist/src/middleware/auth.js +44 -51
- package/dist/src/routes/analytics.js +3 -11
- package/dist/src/routes/auth-routes.js +4 -13
- package/dist/src/services/email-service.js +46 -32
- package/dist/src/services/mcp-service.js +17 -1
- package/dist/src/services/persona-entitlement-service.js +89 -31
- package/dist/src/services/subscription-payment.js +171 -0
- package/dist/src/services/subscription-provisioning.js +68 -0
- package/dist/src/services/subscription-reconciliation.js +147 -0
- package/package.json +1 -1
|
@@ -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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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,
|
|
126
|
-
const template = this.generateSubscriptionActivatedTemplate(plan, amountCents, billingCycle,
|
|
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
|
|
147
|
+
* Send renewal receipt with the verified paid-through deadline
|
|
148
148
|
*/
|
|
149
|
-
async sendRenewalReceipt(to, amount,
|
|
150
|
-
const template = this.generateRenewalReceiptTemplate(amount,
|
|
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,
|
|
168
|
-
const template = this.generatePaymentFailedTemplate(billingPortalUrl,
|
|
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,
|
|
186
|
-
const template = this.generatePaymentRestoredTemplate(
|
|
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>
|
|
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-
|
|
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,
|
|
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>
|
|
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-
|
|
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,
|
|
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>
|
|
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-
|
|
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,
|
|
616
|
-
const
|
|
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
|
-
|
|
655
|
+
${accessStatus}
|
|
646
656
|
</div>
|
|
647
657
|
|
|
648
658
|
<p><strong>What this means:</strong></p>
|
|
649
659
|
<ul>
|
|
650
|
-
<li>
|
|
651
|
-
<li>
|
|
652
|
-
<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>
|
|
659
|
-
<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:
|
|
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(
|
|
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>
|
|
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\
|
|
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>
|
|
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>
|
|
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\
|
|
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,15 @@ 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
|
+
const error = new Error('FRAIM session is missing, expired, or invalid. Call fraim_connect again.');
|
|
141
|
+
error.code = -32001;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
result = { valid: true };
|
|
145
|
+
}
|
|
137
146
|
else if (method === 'notifications/initialized') {
|
|
138
147
|
// Notifications don't need a response
|
|
139
148
|
return null;
|
|
@@ -640,9 +649,16 @@ class McpService {
|
|
|
640
649
|
return (0, quality_evidence_1.buildQualityRejectionMessage)(jobName, currentPhase, errors);
|
|
641
650
|
}
|
|
642
651
|
async maybeBuildPersonaLockResponse(jobName, userId, apiKey, consumeJobCredit = false) {
|
|
643
|
-
if (!
|
|
652
|
+
if (!userId) {
|
|
644
653
|
return null;
|
|
645
654
|
}
|
|
655
|
+
if (!(0, feature_flags_1.isPersonaEntitlementsEnabled)()) {
|
|
656
|
+
const key = apiKey
|
|
657
|
+
? await this.dbService.verifyApiKey(apiKey)
|
|
658
|
+
: await this.dbService.getApiKeyByUserId(userId, false);
|
|
659
|
+
if (key?.accessScope !== 'persona')
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
646
662
|
const decision = await (0, persona_entitlement_service_1.evaluatePersonaAccess)(this.dbService, userId, jobName, apiKey, `/fraim-brain?job=${encodeURIComponent(jobName)}`);
|
|
647
663
|
if (decision.allowed) {
|
|
648
664
|
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
|
|
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?.
|
|
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` (
|
|
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
|
|
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
|
-
//
|
|
51
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
220
|
-
const
|
|
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
|
|
269
|
+
featureFlagEnabled,
|
|
223
270
|
featureFlags: {
|
|
224
|
-
personaEntitlementsEnabled:
|
|
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:
|
|
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) => !
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
300
|
-
|
|
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 =
|
|
310
|
-
if (jobEntitlement &&
|
|
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
|
|
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
|
}
|