fraim-framework 2.0.179 → 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.
- package/dist/src/ai-hub/desktop-main.js +2 -2
- package/dist/src/api/admin/payments.js +33 -0
- package/dist/src/api/admin/sales-leads.js +21 -0
- package/dist/src/api/payment/create-session.js +338 -0
- package/dist/src/api/payment/dashboard-link.js +149 -0
- package/dist/src/api/payment/session-details.js +31 -0
- package/dist/src/api/payment/webhook.js +587 -0
- package/dist/src/api/personas/me.js +29 -0
- package/dist/src/api/pricing/get-config.js +25 -0
- package/dist/src/api/sales/contact.js +44 -0
- package/dist/src/cli/distribution/marketplace-bundles.js +5 -1
- package/dist/src/db/payment-repository.js +61 -0
- package/dist/src/fraim/config-loader.js +11 -0
- package/dist/src/fraim/db-service.js +2387 -0
- package/dist/src/fraim/issues.js +152 -0
- package/dist/src/fraim/template-processor.js +184 -0
- package/dist/src/fraim/utils/request-utils.js +23 -0
- package/dist/src/middleware/auth.js +266 -0
- package/dist/src/middleware/cors-config.js +111 -0
- package/dist/src/middleware/logger.js +116 -0
- package/dist/src/middleware/rate-limit.js +110 -0
- package/dist/src/middleware/reject-query-api-key.js +45 -0
- package/dist/src/middleware/security-headers.js +41 -0
- package/dist/src/middleware/telemetry.js +134 -0
- package/dist/src/models/payment.js +2 -0
- package/dist/src/routes/analytics.js +1447 -0
- package/dist/src/routes/app-routes.js +32 -0
- package/dist/src/routes/auth-routes.js +505 -0
- package/dist/src/routes/oauth-routes.js +325 -0
- package/dist/src/routes/payment-routes.js +186 -0
- package/dist/src/routes/persona-catalog-routes.js +84 -0
- package/dist/src/services/admin-service.js +229 -0
- package/dist/src/services/audit-log-persistence.js +60 -0
- package/dist/src/services/audit-log.js +69 -0
- package/dist/src/services/cookie-service.js +129 -0
- package/dist/src/services/dashboard-access.js +27 -0
- package/dist/src/services/demo-seed-service.js +139 -0
- package/dist/src/services/email-code.js +23 -0
- package/dist/src/services/email-service-clean.js +782 -0
- package/dist/src/services/email-service.js +951 -0
- package/dist/src/services/installer-service.js +131 -0
- package/dist/src/services/mcp-oauth-store.js +33 -0
- package/dist/src/services/mcp-service.js +823 -0
- package/dist/src/services/oauth-helpers.js +127 -0
- package/dist/src/services/org-service.js +89 -0
- package/dist/src/services/persona-entitlement-service.js +288 -0
- package/dist/src/services/provider-service.js +215 -0
- package/dist/src/services/registry-service.js +628 -0
- package/dist/src/services/session-service.js +86 -0
- package/dist/src/services/trial-reminder-service.js +120 -0
- package/dist/src/services/usage-analytics-service.js +419 -0
- package/dist/src/services/workspace-identity.js +21 -0
- package/dist/src/types/analytics.js +2 -0
- package/dist/src/utils/payment-calculator.js +52 -0
- package/extensions/office-word/favicon.ico +0 -0
- package/extensions/office-word/icon-64.png +0 -0
- package/extensions/office-word/manifest.xml +33 -0
- package/extensions/office-word/taskpane.html +242 -0
- package/package.json +12 -2
|
@@ -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
|
+
}
|