fraim 2.0.177 → 2.0.180

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/src/ai-hub/desktop-main.js +2 -2
  2. package/dist/src/ai-hub/server.js +50 -1
  3. package/dist/src/api/admin/payments.js +33 -0
  4. package/dist/src/api/admin/sales-leads.js +21 -0
  5. package/dist/src/api/payment/create-session.js +338 -0
  6. package/dist/src/api/payment/dashboard-link.js +149 -0
  7. package/dist/src/api/payment/session-details.js +31 -0
  8. package/dist/src/api/payment/webhook.js +587 -0
  9. package/dist/src/api/personas/me.js +29 -0
  10. package/dist/src/api/pricing/get-config.js +25 -0
  11. package/dist/src/api/sales/contact.js +44 -0
  12. package/dist/src/cli/commands/add-provider.js +74 -61
  13. package/dist/src/cli/commands/add-surface.js +128 -0
  14. package/dist/src/cli/commands/login.js +5 -69
  15. package/dist/src/cli/commands/setup.js +27 -347
  16. package/dist/src/cli/distribution/marketplace-bundles.js +580 -0
  17. package/dist/src/cli/fraim.js +2 -0
  18. package/dist/src/cli/mcp/ide-formats.js +5 -3
  19. package/dist/src/cli/mcp/mcp-server-registry.js +10 -3
  20. package/dist/src/cli/providers/local-provider-registry.js +2 -3
  21. package/dist/src/cli/setup/auto-mcp-setup.js +9 -32
  22. package/dist/src/cli/setup/ide-detector.js +34 -14
  23. package/dist/src/config/persona-capability-bundles.js +17 -13
  24. package/dist/src/db/payment-repository.js +61 -0
  25. package/dist/src/first-run/session-service.js +2 -2
  26. package/dist/src/fraim/config-loader.js +11 -0
  27. package/dist/src/fraim/db-service.js +2387 -0
  28. package/dist/src/fraim/issues.js +152 -0
  29. package/dist/src/fraim/template-processor.js +184 -0
  30. package/dist/src/fraim/utils/request-utils.js +23 -0
  31. package/dist/src/local-mcp-server/stdio-server.js +28 -4
  32. package/dist/src/local-mcp-server/usage-collector.js +24 -0
  33. package/dist/src/middleware/auth.js +266 -0
  34. package/dist/src/middleware/cors-config.js +111 -0
  35. package/dist/src/middleware/logger.js +116 -0
  36. package/dist/src/middleware/rate-limit.js +110 -0
  37. package/dist/src/middleware/reject-query-api-key.js +45 -0
  38. package/dist/src/middleware/security-headers.js +41 -0
  39. package/dist/src/middleware/telemetry.js +134 -0
  40. package/dist/src/models/payment.js +2 -0
  41. package/dist/src/routes/analytics.js +1447 -0
  42. package/dist/src/routes/app-routes.js +32 -0
  43. package/dist/src/routes/auth-routes.js +505 -0
  44. package/dist/src/routes/oauth-routes.js +325 -0
  45. package/dist/src/routes/payment-routes.js +186 -0
  46. package/dist/src/routes/persona-catalog-routes.js +84 -0
  47. package/dist/src/services/admin-service.js +229 -0
  48. package/dist/src/services/audit-log-persistence.js +60 -0
  49. package/dist/src/services/audit-log.js +69 -0
  50. package/dist/src/services/cookie-service.js +129 -0
  51. package/dist/src/services/dashboard-access.js +27 -0
  52. package/dist/src/services/demo-seed-service.js +139 -0
  53. package/dist/src/services/email-code.js +23 -0
  54. package/dist/src/services/email-service-clean.js +782 -0
  55. package/dist/src/services/email-service.js +951 -0
  56. package/dist/src/services/installer-service.js +131 -0
  57. package/dist/src/services/mcp-oauth-store.js +33 -0
  58. package/dist/src/services/mcp-service.js +823 -0
  59. package/dist/src/services/oauth-helpers.js +127 -0
  60. package/dist/src/services/org-service.js +89 -0
  61. package/dist/src/services/persona-entitlement-service.js +288 -0
  62. package/dist/src/services/provider-service.js +215 -0
  63. package/dist/src/services/registry-service.js +628 -0
  64. package/dist/src/services/session-service.js +86 -0
  65. package/dist/src/services/trial-reminder-service.js +120 -0
  66. package/dist/src/services/usage-analytics-service.js +419 -0
  67. package/dist/src/services/workspace-identity.js +21 -0
  68. package/dist/src/types/analytics.js +2 -0
  69. package/dist/src/utils/payment-calculator.js +52 -0
  70. package/extensions/office-word/favicon.ico +0 -0
  71. package/extensions/office-word/icon-64.png +0 -0
  72. package/extensions/office-word/manifest.xml +33 -0
  73. package/extensions/office-word/taskpane.html +242 -0
  74. package/package.json +14 -3
  75. package/public/ai-hub/index.html +14 -2
  76. package/public/ai-hub/script.js +340 -66
  77. package/public/ai-hub/styles.css +83 -0
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SUPPORTED_SURFACES = exports.SUPPORTED_OAUTH_PROVIDERS = void 0;
4
+ exports.isSupportedProvider = isSupportedProvider;
5
+ exports.generatePkceVerifier = generatePkceVerifier;
6
+ exports.pkceChallenge = pkceChallenge;
7
+ exports.generateStateNonce = generateStateNonce;
8
+ exports.generateSessionId = generateSessionId;
9
+ exports.generatePendingOAuthId = generatePendingOAuthId;
10
+ exports.safeRedirectPath = safeRedirectPath;
11
+ exports.isSupportedSurface = isSupportedSurface;
12
+ exports.defaultRedirectForSurface = defaultRedirectForSurface;
13
+ exports.pickSurfaceOrDefault = pickSurfaceOrDefault;
14
+ exports.isLikelyValidEmail = isLikelyValidEmail;
15
+ exports.clientIpFromReq = clientIpFromReq;
16
+ const crypto_1 = require("crypto");
17
+ exports.SUPPORTED_OAUTH_PROVIDERS = ['google', 'github'];
18
+ function isSupportedProvider(value) {
19
+ return exports.SUPPORTED_OAUTH_PROVIDERS.includes(value);
20
+ }
21
+ function base64UrlEncode(buf) {
22
+ return buf.toString('base64')
23
+ .replace(/\+/g, '-')
24
+ .replace(/\//g, '_')
25
+ .replace(/=+$/, '');
26
+ }
27
+ function generatePkceVerifier() {
28
+ return base64UrlEncode((0, crypto_1.randomBytes)(32));
29
+ }
30
+ function pkceChallenge(verifier) {
31
+ return base64UrlEncode((0, crypto_1.createHash)('sha256').update(verifier).digest());
32
+ }
33
+ function generateStateNonce() {
34
+ return (0, crypto_1.randomBytes)(32).toString('hex');
35
+ }
36
+ function generateSessionId() {
37
+ return (0, crypto_1.randomBytes)(32).toString('hex');
38
+ }
39
+ function generatePendingOAuthId() {
40
+ return (0, crypto_1.randomBytes)(16).toString('hex');
41
+ }
42
+ const ALLOWED_REDIRECT_PREFIXES = [
43
+ '/',
44
+ '/account',
45
+ '/analytics',
46
+ '/fraim-brain',
47
+ '/auth/recovery',
48
+ '/auth/error',
49
+ '/api/auth/mobile',
50
+ '/oauth/mcp',
51
+ ];
52
+ function safeRedirectPath(input, fallback = '/') {
53
+ if (!input || typeof input !== 'string')
54
+ return fallback;
55
+ if (input.includes('\n') || input.includes('\r'))
56
+ return fallback;
57
+ if (input.startsWith('//'))
58
+ return fallback;
59
+ if (!input.startsWith('/'))
60
+ return fallback;
61
+ let decoded;
62
+ try {
63
+ decoded = decodeURIComponent(input);
64
+ if (decoded.startsWith('//'))
65
+ return fallback;
66
+ }
67
+ catch {
68
+ return fallback;
69
+ }
70
+ // Path-traversal guard. Caught by issue #359 bug-bash: a value like
71
+ // `/account/../etc/passwd` would have matched the `/account/` prefix
72
+ // and been forwarded by the OAuth callback's res.redirect, after which
73
+ // the browser normalises it to `/etc/passwd` — escaping the whitelist.
74
+ // Reject any segment that traverses up. Also reject backslashes which
75
+ // some browsers (notably old IE/Edge) treat as forward slashes.
76
+ if (input.includes('\\') || decoded.includes('\\'))
77
+ return fallback;
78
+ const segments = input.split('?')[0].split('#')[0].split('/');
79
+ if (segments.some(seg => seg === '..' || seg === '%2e%2e' || seg.toLowerCase() === '%2e%2e'))
80
+ return fallback;
81
+ const pathOnly = input.split('?')[0].split('#')[0];
82
+ const matchesAllow = ALLOWED_REDIRECT_PREFIXES.some(prefix => pathOnly === prefix || pathOnly.startsWith(prefix + '/') || (prefix === '/' && pathOnly === '/'));
83
+ return matchesAllow ? input : fallback;
84
+ }
85
+ exports.SUPPORTED_SURFACES = ['analytics', 'brain', 'account', 'mobile'];
86
+ function isSupportedSurface(value) {
87
+ return exports.SUPPORTED_SURFACES.includes(value);
88
+ }
89
+ function defaultRedirectForSurface(surface) {
90
+ switch (surface) {
91
+ case 'analytics': return '/#analytics';
92
+ case 'brain': return '/#brain';
93
+ case 'account': return '/account/';
94
+ case 'mobile': return '/api/auth/mobile/claim';
95
+ }
96
+ }
97
+ function pickSurfaceOrDefault(input, fallback = 'analytics') {
98
+ if (typeof input === 'string' && isSupportedSurface(input))
99
+ return input;
100
+ return fallback;
101
+ }
102
+ /**
103
+ * Cheap email-shape check. Not RFC 5322 compliant — that's intentional. The
104
+ * provider verifies the email separately on the OAuth path; the email-code
105
+ * path doesn't reveal whether the email exists. The check exists to reject
106
+ * obvious junk (whitespace, missing @ / TLD) before a DB write.
107
+ */
108
+ function isLikelyValidEmail(value) {
109
+ if (typeof value !== 'string')
110
+ return false;
111
+ if (value.length > 254)
112
+ return false;
113
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
114
+ }
115
+ /**
116
+ * Best-effort client IP extraction. Honours `X-Forwarded-For` (first hop) when
117
+ * present — Express is configured with `trust proxy` 1 hop in the FRAIM server.
118
+ */
119
+ function clientIpFromReq(req) {
120
+ const fwd = req.headers['x-forwarded-for'];
121
+ if (typeof fwd === 'string') {
122
+ const first = fwd.split(',')[0];
123
+ if (first && first.trim())
124
+ return first.trim();
125
+ }
126
+ return req.ip || 'unknown';
127
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OrgService = void 0;
4
+ class OrgService {
5
+ constructor(dbService) {
6
+ this.dbService = dbService;
7
+ }
8
+ async getPack(auth) {
9
+ const artifacts = await this.dbService.getOrgArtifacts(auth.orgId);
10
+ const version = artifacts.reduce((max, a) => Math.max(max, a.revision || 0), 0);
11
+ return {
12
+ files: artifacts.map(a => ({ relativePath: a.relativePath, content: a.content })),
13
+ version: String(version)
14
+ };
15
+ }
16
+ async publishArtifacts(auth, artifacts, approval) {
17
+ const existing = await this.dbService.getOrgArtifacts(auth.orgId);
18
+ const nextRevision = existing.reduce((max, a) => Math.max(max, a.revision || 0), 0) + 1;
19
+ const now = new Date();
20
+ for (const artifact of artifacts) {
21
+ const record = {
22
+ orgId: auth.orgId,
23
+ relativePath: artifact.relativePath,
24
+ content: artifact.content,
25
+ revision: nextRevision,
26
+ updatedAt: now,
27
+ proposedBy: approval.proposedBy,
28
+ approvedBy: approval.approvedBy
29
+ };
30
+ await this.dbService.upsertOrgArtifact(record);
31
+ }
32
+ const auditEntry = {
33
+ orgId: auth.orgId,
34
+ action: 'publish',
35
+ relativePaths: artifacts.map(a => a.relativePath),
36
+ proposedBy: approval.proposedBy,
37
+ approvedBy: approval.approvedBy,
38
+ at: now
39
+ };
40
+ await this.dbService.appendOrgAuditEntry(auditEntry);
41
+ return { version: String(nextRevision) };
42
+ }
43
+ /** Complete artifact set for export (R6.4, AC10). */
44
+ async exportPack(auth) {
45
+ return this.getPack(auth);
46
+ }
47
+ /**
48
+ * Redact every occurrence of a named identifier from the org's artifacts
49
+ * (GDPR erasure, AC10). Leaves an explicit [redacted] placeholder so the
50
+ * surrounding learning stays readable.
51
+ */
52
+ async redactIdentifier(auth, identifier, approvedBy) {
53
+ const needle = identifier.trim();
54
+ if (!needle)
55
+ return { filesChanged: 0 };
56
+ const artifacts = await this.dbService.getOrgArtifacts(auth.orgId);
57
+ const changed = [];
58
+ const now = new Date();
59
+ for (const artifact of artifacts) {
60
+ if (!artifact.content.includes(needle))
61
+ continue;
62
+ changed.push({
63
+ ...artifact,
64
+ content: artifact.content.split(needle).join('[redacted]'),
65
+ updatedAt: now,
66
+ proposedBy: approvedBy,
67
+ approvedBy
68
+ });
69
+ }
70
+ for (const record of changed) {
71
+ await this.dbService.upsertOrgArtifact(record);
72
+ }
73
+ if (changed.length > 0) {
74
+ await this.dbService.appendOrgAuditEntry({
75
+ orgId: auth.orgId,
76
+ action: 'redact',
77
+ relativePaths: changed.map(c => c.relativePath),
78
+ proposedBy: approvedBy,
79
+ approvedBy,
80
+ at: now
81
+ });
82
+ }
83
+ return { filesChanged: changed.length };
84
+ }
85
+ async getAudit(auth) {
86
+ return this.dbService.getOrgAuditEntries(auth.orgId);
87
+ }
88
+ }
89
+ exports.OrgService = OrgService;
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildPersonaHireUrl = buildPersonaHireUrl;
4
+ exports.syncPersonaEntitlementPurchase = syncPersonaEntitlementPurchase;
5
+ exports.syncPersonaEntitlementFromCheckoutSession = syncPersonaEntitlementFromCheckoutSession;
6
+ exports.getWorkspacePersonaState = getWorkspacePersonaState;
7
+ exports.evaluatePersonaAccess = evaluatePersonaAccess;
8
+ const persona_hiring_1 = require("../config/persona-hiring");
9
+ const persona_capability_bundles_1 = require("../config/persona-capability-bundles");
10
+ const workspace_identity_1 = require("./workspace-identity");
11
+ const DEFAULT_JOB_CREDITS = 1;
12
+ function normalizeEmail(email) {
13
+ return email.trim().toLowerCase();
14
+ }
15
+ function mergeMetadata(existingMetadata, nextMetadata) {
16
+ if (!existingMetadata && !nextMetadata) {
17
+ return undefined;
18
+ }
19
+ return {
20
+ ...(existingMetadata || {}),
21
+ ...(nextMetadata || {})
22
+ };
23
+ }
24
+ function buildPersonaHireUrl(personaKey, hireMode = 'job', returnTo) {
25
+ const params = new URLSearchParams({
26
+ persona: personaKey,
27
+ mode: hireMode
28
+ });
29
+ if (returnTo) {
30
+ params.set('returnTo', returnTo);
31
+ }
32
+ return `/pricing?${params.toString()}`;
33
+ }
34
+ async function resolveWorkspaceContext(dbService, input) {
35
+ const normalizedUserId = normalizeEmail(input.userId);
36
+ let apiKey = input.workspaceId ? await dbService.getApiKeyByWorkspaceId(input.workspaceId, false) : null;
37
+ if (!apiKey && input.stripeCustomerId) {
38
+ apiKey = await dbService.getApiKeyByStripeCustomerId(input.stripeCustomerId);
39
+ }
40
+ if (!apiKey) {
41
+ apiKey = await dbService.getApiKeyByUserId(normalizedUserId, false);
42
+ }
43
+ const workspaceId = (0, workspace_identity_1.resolveWorkspaceId)({
44
+ workspaceId: apiKey?.workspaceId || input.workspaceId || undefined,
45
+ stripeCustomerId: input.stripeCustomerId || apiKey?.stripeCustomerId || undefined,
46
+ email: normalizedUserId
47
+ }) || `user:${normalizedUserId}`;
48
+ if (apiKey && apiKey.workspaceId !== workspaceId) {
49
+ await dbService.updateApiKey(apiKey.key, { workspaceId });
50
+ apiKey.workspaceId = workspaceId;
51
+ }
52
+ return {
53
+ workspaceId,
54
+ orgId: apiKey?.orgId || input.orgId || 'default',
55
+ apiKey
56
+ };
57
+ }
58
+ async function syncPersonaEntitlementPurchase(dbService, input) {
59
+ const persona = persona_hiring_1.PERSONA_HIRE_CATALOG[input.personaKey];
60
+ const normalizedUserId = normalizeEmail(input.userId);
61
+ const workspace = await resolveWorkspaceContext(dbService, {
62
+ userId: normalizedUserId,
63
+ orgId: input.orgId,
64
+ stripeCustomerId: input.stripeCustomerId
65
+ });
66
+ const existing = await dbService.getPersonaEntitlement(workspace.workspaceId, input.personaKey, input.hireMode);
67
+ const currentPurchaseCount = existing?.purchasedCount || 0;
68
+ const isNewPurchase = !existing || (input.stripeCheckoutSessionId &&
69
+ existing.stripeCheckoutSessionId !== input.stripeCheckoutSessionId) || (input.stripeSubscriptionId &&
70
+ existing.stripeSubscriptionId !== input.stripeSubscriptionId);
71
+ const jobCreditsIncluded = input.hireMode === 'job'
72
+ ? (existing?.jobCreditsIncluded || 0) + (isNewPurchase ? DEFAULT_JOB_CREDITS : 0)
73
+ : 0;
74
+ const jobCreditsRemaining = input.hireMode === 'job'
75
+ ? (existing?.jobCreditsRemaining || 0) + (isNewPurchase ? DEFAULT_JOB_CREDITS : 0)
76
+ : null;
77
+ const entitlement = await dbService.upsertPersonaEntitlement({
78
+ workspaceId: workspace.workspaceId,
79
+ userId: normalizedUserId,
80
+ orgId: workspace.orgId,
81
+ personaKey: input.personaKey,
82
+ personaName: persona.displayName,
83
+ personaRole: persona.role,
84
+ hireMode: input.hireMode,
85
+ status: input.status || 'active',
86
+ stripeCustomerId: input.stripeCustomerId || existing?.stripeCustomerId || null,
87
+ stripeSubscriptionId: input.stripeSubscriptionId || existing?.stripeSubscriptionId || null,
88
+ stripeCheckoutSessionId: input.stripeCheckoutSessionId || existing?.stripeCheckoutSessionId || null,
89
+ purchaseSource: input.purchaseSource,
90
+ purchasedCount: currentPurchaseCount + (isNewPurchase ? 1 : 0),
91
+ jobCreditsIncluded,
92
+ jobCreditsRemaining,
93
+ activatedAt: existing?.activatedAt || new Date(),
94
+ expiresAt: input.expiresAt ?? existing?.expiresAt ?? null,
95
+ canceledAt: input.status === 'expired' ? new Date() : null,
96
+ metadata: mergeMetadata(existing?.metadata, input.metadata)
97
+ });
98
+ // Transition a legacy workspace into the persona system on first purchase.
99
+ if (workspace.apiKey?.key && !workspace.apiKey.personaSystemActive) {
100
+ await dbService.updateApiKey(workspace.apiKey.key, { personaSystemActive: true }).catch(() => { });
101
+ }
102
+ return entitlement;
103
+ }
104
+ async function syncPersonaEntitlementFromCheckoutSession(dbService, session, purchaseSource) {
105
+ const personaKey = session.metadata?.personaKey;
106
+ const rawHireMode = session.metadata?.hireMode;
107
+ const email = session.customer_details?.email || session.customer_email;
108
+ if (!personaKey || !(0, persona_hiring_1.isPersonaHireKey)(personaKey) || !email || !rawHireMode || (rawHireMode !== 'job' && rawHireMode !== 'fulltime')) {
109
+ return null;
110
+ }
111
+ const customerId = !session.customer
112
+ ? null
113
+ : (typeof session.customer === 'string' ? session.customer : session.customer.id);
114
+ const subscriptionId = !session.subscription
115
+ ? null
116
+ : (typeof session.subscription === 'string' ? session.subscription : session.subscription.id);
117
+ return await syncPersonaEntitlementPurchase(dbService, {
118
+ userId: email,
119
+ stripeCustomerId: customerId,
120
+ stripeSubscriptionId: subscriptionId,
121
+ stripeCheckoutSessionId: session.id,
122
+ personaKey,
123
+ hireMode: rawHireMode,
124
+ purchaseSource,
125
+ metadata: session.metadata || {}
126
+ });
127
+ }
128
+ async function getWorkspacePersonaState(dbService, userId, apiKey) {
129
+ const normalizedUserId = normalizeEmail(userId);
130
+ let apiKeyData = null;
131
+ if (apiKey) {
132
+ apiKeyData = await dbService.getApiKeyByKey(apiKey);
133
+ }
134
+ if (!apiKeyData) {
135
+ apiKeyData = await dbService.getApiKeyByUserId(normalizedUserId, false);
136
+ }
137
+ const workspace = await resolveWorkspaceContext(dbService, {
138
+ userId: normalizedUserId,
139
+ orgId: apiKeyData?.orgId,
140
+ stripeCustomerId: apiKeyData?.stripeCustomerId,
141
+ workspaceId: apiKeyData?.workspaceId
142
+ });
143
+ let entitlements = await dbService.getPersonaEntitlementsByWorkspaceId(workspace.workspaceId, false);
144
+ // Fallback: when the local dev workspace has no API key the workspaceId may not
145
+ // match the cust: prefix written by the Stripe checkout sync. Look up by userId.
146
+ if (entitlements.length === 0) {
147
+ entitlements = await dbService.getPersonaEntitlementsByUserId(normalizedUserId, false);
148
+ }
149
+ const catalog = (0, persona_capability_bundles_1.listPersonaCapabilityBundles)().map((bundle) => ({
150
+ personaKey: bundle.personaKey,
151
+ personaName: bundle.catalogMetadata.displayName,
152
+ personaRole: bundle.catalogMetadata.role,
153
+ hireUrl: buildPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
154
+ protectedJobs: bundle.protectedJobs
155
+ }));
156
+ const knownPersonaKeys = new Set(entitlements.map((entitlement) => entitlement.personaKey));
157
+ // Subscribed = explicitly opted in via flag, OR has ever had an active entitlement.
158
+ const subscriptionActive = apiKeyData?.personaSystemActive === true ||
159
+ entitlements.some((e) => e.status === 'active');
160
+ return {
161
+ featureFlagEnabled: true,
162
+ featureFlags: {
163
+ personaEntitlementsEnabled: true
164
+ },
165
+ subscriptionActive,
166
+ workspaceId: workspace.workspaceId,
167
+ userId: normalizedUserId,
168
+ apiKey: apiKeyData?.key || apiKey || null,
169
+ entitlements: entitlements.map((entitlement) => ({
170
+ personaKey: entitlement.personaKey,
171
+ personaName: entitlement.personaName,
172
+ personaRole: entitlement.personaRole,
173
+ hireMode: entitlement.hireMode,
174
+ status: entitlement.status,
175
+ jobCreditsRemaining: entitlement.jobCreditsRemaining,
176
+ expiresAt: entitlement.expiresAt ? entitlement.expiresAt.toISOString() : null,
177
+ hireUrl: buildPersonaHireUrl(entitlement.personaKey, entitlement.hireMode),
178
+ jobs: (0, persona_hiring_1.isPersonaHireKey)(entitlement.personaKey)
179
+ ? (0, persona_capability_bundles_1.getPersonaCapabilityBundle)(entitlement.personaKey).protectedJobs
180
+ : []
181
+ })),
182
+ catalog,
183
+ lockedPersonas: catalog
184
+ .filter((persona) => !knownPersonaKeys.has(persona.personaKey))
185
+ .map((persona) => ({
186
+ ...persona,
187
+ status: 'locked'
188
+ }))
189
+ };
190
+ }
191
+ async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnTo) {
192
+ const normalizedUserId = normalizeEmail(userId);
193
+ let apiKeyData = null;
194
+ if (apiKey) {
195
+ apiKeyData = await dbService.getApiKeyByKey(apiKey);
196
+ }
197
+ if (!apiKeyData) {
198
+ apiKeyData = await dbService.getApiKeyByUserId(normalizedUserId, false);
199
+ }
200
+ const workspace = await resolveWorkspaceContext(dbService, {
201
+ userId: normalizedUserId,
202
+ orgId: apiKeyData?.orgId,
203
+ stripeCustomerId: apiKeyData?.stripeCustomerId,
204
+ workspaceId: apiKeyData?.workspaceId
205
+ });
206
+ const workspaceEntitlements = await dbService.getPersonaEntitlementsByWorkspaceId(workspace.workspaceId, false);
207
+ // Legacy bypass: if the workspace has never subscribed to the persona system,
208
+ // all jobs are freely accessible — no lock enforcement.
209
+ const subscriptionActive = apiKeyData?.personaSystemActive === true ||
210
+ workspaceEntitlements.some((e) => e.status === 'active');
211
+ if (!subscriptionActive) {
212
+ return {
213
+ allowed: true,
214
+ personaKey: null,
215
+ workspaceId: workspace.workspaceId,
216
+ ownedPersonaKeys: []
217
+ };
218
+ }
219
+ const ownedPersonaKeys = Array.from(new Set(workspaceEntitlements
220
+ .filter((entitlement) => entitlement.status === 'active')
221
+ .map((entitlement) => entitlement.personaKey)
222
+ .filter((personaKey) => (0, persona_hiring_1.isPersonaHireKey)(personaKey))));
223
+ const protectedPersonaKey = (0, persona_capability_bundles_1.getProtectedPersonaForJob)(jobName);
224
+ if (!protectedPersonaKey) {
225
+ return {
226
+ allowed: true,
227
+ personaKey: null,
228
+ workspaceId: workspace.workspaceId,
229
+ ownedPersonaKeys
230
+ };
231
+ }
232
+ const fulltimeEntitlement = await dbService.getPersonaEntitlement(workspace.workspaceId, protectedPersonaKey, 'fulltime');
233
+ if (fulltimeEntitlement && fulltimeEntitlement.status === 'active') {
234
+ return {
235
+ allowed: true,
236
+ personaKey: protectedPersonaKey,
237
+ workspaceId: workspace.workspaceId,
238
+ ownedPersonaKeys,
239
+ entitlement: fulltimeEntitlement
240
+ };
241
+ }
242
+ const jobEntitlement = await dbService.getPersonaEntitlement(workspace.workspaceId, protectedPersonaKey, 'job');
243
+ if (jobEntitlement && jobEntitlement.status === 'active' && (jobEntitlement.jobCreditsRemaining || 0) > 0) {
244
+ return {
245
+ allowed: true,
246
+ personaKey: protectedPersonaKey,
247
+ workspaceId: workspace.workspaceId,
248
+ ownedPersonaKeys,
249
+ entitlement: jobEntitlement
250
+ };
251
+ }
252
+ const bundle = (0, persona_capability_bundles_1.getPersonaCapabilityBundle)(protectedPersonaKey);
253
+ const persona = persona_hiring_1.PERSONA_HIRE_CATALOG[protectedPersonaKey];
254
+ const priorEntitlement = fulltimeEntitlement || jobEntitlement || null;
255
+ const ownedOtherPersonas = ownedPersonaKeys
256
+ .filter((personaKey) => personaKey !== protectedPersonaKey)
257
+ .map((personaKey) => persona_hiring_1.PERSONA_HIRE_CATALOG[personaKey].displayName);
258
+ let message = bundle?.lockCopy || `This job requires ${persona.displayName}. Hire them to unlock it.`;
259
+ let hireUrl = buildPersonaHireUrl(protectedPersonaKey, bundle?.defaultHireMode || 'job', returnTo);
260
+ let ctaLabel = `Hire ${persona.displayName}`;
261
+ if (priorEntitlement?.status === 'suspended') {
262
+ message = `${persona.displayName} is currently suspended for this workspace because billing needs attention. Update billing to restore ${persona.displayName} for this request.`;
263
+ hireUrl = process.env.STRIPE_BILLING_PORTAL_URL || '/billing';
264
+ ctaLabel = `Restore ${persona.displayName}`;
265
+ }
266
+ else if (priorEntitlement?.status === 'expired') {
267
+ message = `${persona.displayName} was previously active in this workspace but is no longer active. Reactivate ${persona.displayName} to resume this request.`;
268
+ ctaLabel = `Reactivate ${persona.displayName}`;
269
+ }
270
+ else if (ownedOtherPersonas.length > 0) {
271
+ message = `${persona.displayName} owns this job, and your workspace has not hired ${persona.displayName} yet. You currently have ${ownedOtherPersonas.join(' and ')} active. ${ctaLabel} to unlock this request.`;
272
+ }
273
+ return {
274
+ allowed: false,
275
+ personaKey: protectedPersonaKey,
276
+ workspaceId: workspace.workspaceId,
277
+ ownedPersonaKeys,
278
+ entitlement: priorEntitlement,
279
+ lock: {
280
+ personaKey: protectedPersonaKey,
281
+ personaName: persona.displayName,
282
+ personaRole: persona.role,
283
+ message,
284
+ hireUrl,
285
+ ctaLabel
286
+ }
287
+ };
288
+ }