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.
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.FraimDbService = void 0;
37
37
  const mongodb_1 = require("mongodb");
38
+ const node_crypto_1 = require("node:crypto");
39
+ const current_license_1 = require("./current-license");
38
40
  const git_utils_1 = require("../core/utils/git-utils");
39
41
  const quality_evidence_1 = require("../core/quality-evidence");
40
42
  function sanitizeSessionRepoContext(repo) {
@@ -327,9 +329,20 @@ class FraimDbService {
327
329
  async verifyApiKey(key) {
328
330
  if (!this.keysCollection)
329
331
  throw new Error('DB not connected');
330
- // Return the key regardless of isActive/status — the auth middleware
331
- // is responsible for checking expiry and returning the right HTTP status.
332
- return await this.keysCollection.findOne({ key });
332
+ const credential = await this.keysCollection.findOne({ key });
333
+ if (!credential || credential.revokedAt)
334
+ return null;
335
+ if (credential.isActive === false) {
336
+ // Legacy expired/suspended records still report their lifecycle, but never inherit access.
337
+ return credential.status === 'expired' || credential.status === 'suspended' ? credential : null;
338
+ }
339
+ // A suspended trial is an explicit restriction, not an upgrade candidate.
340
+ if (credential.tier === 'trial' && credential.status === 'suspended')
341
+ return credential;
342
+ const records = await this.keysCollection.find({ userId: credential.userId }).toArray();
343
+ const paid = (0, current_license_1.selectCurrentLicense)(records.filter(record => record.tier === 'paid-subscription' && (0, current_license_1.sameLicenseScope)(credential, record)));
344
+ // Preserve the stored credential and its history; auth checks the current entitlement.
345
+ return paid || credential;
333
346
  }
334
347
  async getActiveSessionByApiKey(key) {
335
348
  if (!this.keysCollection || !this.sessionsCollection)
@@ -349,13 +362,13 @@ class FraimDbService {
349
362
  const apiKeyData = await this.verifyApiKey(key);
350
363
  if (!apiKeyData)
351
364
  return null;
365
+ return this.getSessionByUserIdAndSessionId(apiKeyData.userId, sessionId);
366
+ }
367
+ async getSessionByUserIdAndSessionId(userId, sessionId) {
368
+ if (!this.sessionsCollection)
369
+ throw new Error('DB not connected');
352
370
  const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
353
- const session = await this.sessionsCollection.findOne({
354
- sessionId,
355
- userId: apiKeyData.userId,
356
- lastActive: { $gt: dayAgo }
357
- });
358
- return session;
371
+ return this.sessionsCollection.findOne({ sessionId, userId, lastActive: { $gt: dayAgo } });
359
372
  }
360
373
  async createAuthSession(input) {
361
374
  if (!this.authSessionsCollection)
@@ -468,8 +481,9 @@ class FraimDbService {
468
481
  key: newKey,
469
482
  createdAt: new Date(),
470
483
  isActive: true,
484
+ revokedAt: null,
471
485
  });
472
- await this.keysCollection.updateOne({ key: currentKey }, { $set: { isActive: false, status: 'expired' } });
486
+ await this.keysCollection.updateOne({ key: currentKey }, { $set: { isActive: false, status: 'expired', revokedAt: new Date() } });
473
487
  return true;
474
488
  }
475
489
  async listApiKeys() {
@@ -489,7 +503,7 @@ class FraimDbService {
489
503
  async revokeApiKey(key) {
490
504
  if (!this.keysCollection)
491
505
  throw new Error('DB not connected');
492
- const result = await this.keysCollection.updateOne({ key }, { $set: { isActive: false } });
506
+ const result = await this.keysCollection.updateOne({ key }, { $set: { isActive: false, revokedAt: new Date() } });
493
507
  return result.matchedCount > 0;
494
508
  }
495
509
  buildApiKeyTargetQuery(keys, userIds, labels = []) {
@@ -534,10 +548,11 @@ class FraimDbService {
534
548
  const status = record.status === 'suspended' ? 'suspended' : nextStatus;
535
549
  const update = {
536
550
  expiresAt,
537
- status
551
+ status,
552
+ manualAccessOverride: { until: expiresAt, grantedAt: new Date(), reason: 'admin' }
538
553
  };
539
- if (record.tier === 'paid-subscription') {
540
- update.currentPeriodEnd = expiresAt;
554
+ if (record.stripeSubscriptionId && record.billingReconciledAt) {
555
+ Object.assign(update, (0, current_license_1.subscriptionAccessPatch)({ ...record, ...update }));
541
556
  }
542
557
  await this.keysCollection.updateOne({ key: record.key }, { $set: update });
543
558
  updatedKeys.push(record.key);
@@ -681,15 +696,16 @@ class FraimDbService {
681
696
  async getApiKeyByUserId(userId, activeOnly = true) {
682
697
  if (!this.db)
683
698
  throw new Error('DB not connected');
684
- const query = { userId };
685
- if (activeOnly) {
686
- // Match active status OR legacy isActive:true with no status field.
687
- query.$or = [
688
- { status: 'active' },
689
- { status: { $exists: false }, isActive: true }
690
- ];
691
- }
692
- return await this.db.collection('fraim_api_keys').findOne(query);
699
+ const records = await this.db.collection('fraim_api_keys')
700
+ .find({ userId: userId.trim().toLowerCase() }).toArray();
701
+ // Sort in memory: Cosmos Mongo does not support arbitrary compound order-by queries.
702
+ const current = (0, current_license_1.selectCurrentLicense)(records, activeOnly);
703
+ if (current || activeOnly || records.length === 0)
704
+ return current;
705
+ // Retain account identity for sign-in/recovery and prevent signup issuing a fresh trial
706
+ // when every credential was revoked. Revocation is never projected as usable access.
707
+ const revoked = [...records].sort((a, b) => a.key.localeCompare(b.key))[0];
708
+ return { ...revoked, status: 'expired' };
693
709
  }
694
710
  async createAccessToken(data) {
695
711
  if (!this.accessTokensCollection)
@@ -801,12 +817,145 @@ class FraimDbService {
801
817
  async getApiKeyByStripeSubscriptionId(subscriptionId) {
802
818
  if (!this.keysCollection)
803
819
  throw new Error('DB not connected');
804
- return await this.keysCollection.findOne({ stripeSubscriptionId: subscriptionId });
820
+ const rows = await this.keysCollection.find({ stripeSubscriptionId: subscriptionId }).toArray();
821
+ this.assertSubscriptionScope(rows);
822
+ // Billing must retain revoked subscription history; a checkout replay must not mint a replacement.
823
+ return (0, current_license_1.selectCurrentLicense)(rows) || rows.sort((a, b) => a.key.localeCompare(b.key))[0] || null;
805
824
  }
806
825
  async getApiKeyByStripeCustomerId(customerId) {
807
826
  if (!this.keysCollection)
808
827
  throw new Error('DB not connected');
809
- return await this.keysCollection.findOne({ stripeCustomerId: customerId });
828
+ return (0, current_license_1.selectCurrentLicense)(await this.keysCollection.find({ stripeCustomerId: customerId }).toArray());
829
+ }
830
+ assertSubscriptionScope(rows) {
831
+ if (rows.some(row => !(0, current_license_1.sameLicenseScope)(rows[0], row))) {
832
+ throw new Error('Subscription is associated with conflicting account identities');
833
+ }
834
+ if (new Set(rows.map(row => row.stripeCustomerId).filter(Boolean)).size > 1) {
835
+ throw new Error('Subscription is associated with conflicting customer identities');
836
+ }
837
+ }
838
+ /** Keep duplicate records of one subscription in agreement without deleting history or reviving revoked keys. */
839
+ async updateSubscriptionApiKeys(subscriptionId, update) {
840
+ if (!this.keysCollection)
841
+ throw new Error('DB not connected');
842
+ const rows = await this.keysCollection.find({ stripeSubscriptionId: subscriptionId }).toArray();
843
+ this.assertSubscriptionScope(rows);
844
+ if (update.stripeCustomerId && rows.some(row => row.stripeCustomerId && row.stripeCustomerId !== update.stripeCustomerId)) {
845
+ throw new Error('Subscription customer identity does not match');
846
+ }
847
+ for (const field of ['_id', 'key', 'userId', 'orgId', 'workspaceId', 'createdAt', 'isActive', 'revokedAt', 'stripeSubscriptionId']) {
848
+ if (field in update)
849
+ throw new Error('Subscription updates cannot change credential identity');
850
+ }
851
+ // Stripe terminal subscriptions cannot be resumed. Guard the write itself,
852
+ // since an active snapshot may have been fetched before cancellation won.
853
+ if (update.billingReconciledAt) {
854
+ let matched = false;
855
+ for (const original of rows) {
856
+ for (let attempt = 0; attempt < 4; attempt++) {
857
+ const row = await this.keysCollection.findOne({ key: original.key });
858
+ if (!row)
859
+ break;
860
+ if (row.stripeSubscriptionEndedAt && !update.stripeSubscriptionEndedAt)
861
+ break;
862
+ if (!update.stripeSubscriptionEndedAt && row.billingReconciledAt && row.billingReconciledAt > update.billingReconciledAt)
863
+ break;
864
+ const merged = this.mergeSubscriptionBilling(row, update);
865
+ const result = await this.keysCollection.updateOne({
866
+ key: row.key,
867
+ billingReconciledAt: row.billingReconciledAt || null,
868
+ stripeSubscriptionEndedAt: row.stripeSubscriptionEndedAt || null,
869
+ manualAccessOverride: row.manualAccessOverride || null
870
+ }, { $set: merged });
871
+ if (result.matchedCount) {
872
+ matched = true;
873
+ break;
874
+ }
875
+ if (attempt === 3)
876
+ throw new Error('Subscription billing update did not converge');
877
+ }
878
+ }
879
+ return matched;
880
+ }
881
+ const result = await this.keysCollection.updateMany({
882
+ stripeSubscriptionId: subscriptionId,
883
+ ...(!update.stripeSubscriptionEndedAt ? { stripeSubscriptionEndedAt: null } : {})
884
+ }, { $set: update });
885
+ return result.matchedCount > 0;
886
+ }
887
+ mergeSubscriptionBilling(row, update) {
888
+ const merged = { ...update };
889
+ if (row.stripeSubscriptionEndedAt && merged.stripeSubscriptionEndedAt
890
+ && row.stripeSubscriptionEndedAt < merged.stripeSubscriptionEndedAt) {
891
+ merged.stripeSubscriptionEndedAt = row.stripeSubscriptionEndedAt;
892
+ }
893
+ // Finite historical expiration edits may be support grants. Preserve their
894
+ // original deadline with explicit legacy provenance; never convert null
895
+ // subscription expirations into unlimited manual access.
896
+ if (!row.manualAccessOverride && !row.billingReconciledAt && row.expiresAt
897
+ && (row.tier === 'paid-subscription' || (row.createdAt
898
+ && row.expiresAt.getTime() > row.createdAt.getTime() + 14 * 86400000))) {
899
+ merged.manualAccessOverride = { until: row.expiresAt, grantedAt: new Date(), reason: 'legacy-expiration' };
900
+ }
901
+ if (row.paidThrough && (!merged.paidThrough || row.paidThrough > merged.paidThrough)) {
902
+ merged.paidThrough = row.paidThrough;
903
+ merged.paidPeriodStart = row.paidPeriodStart;
904
+ merged.paidInvoiceId = row.paidInvoiceId;
905
+ }
906
+ return { ...merged, ...(0, current_license_1.subscriptionAccessPatch)({ ...row, ...merged }) };
907
+ }
908
+ /** Reuse an expired trial in place. Subscription identity makes repeat/new-account provisioning idempotent. */
909
+ async provisionSubscriptionApiKey(userId, data) {
910
+ if (!this.keysCollection)
911
+ throw new Error('DB not connected');
912
+ const email = userId.trim().toLowerCase();
913
+ if (!email || !data.stripeSubscriptionId || !data.stripeCustomerId)
914
+ throw new Error('Subscription identity is required');
915
+ const patch = { ...data, tier: 'paid-subscription' };
916
+ for (let attempt = 0; attempt < 4; attempt++) {
917
+ const existing = await this.getApiKeyByStripeSubscriptionId(data.stripeSubscriptionId);
918
+ if (existing) {
919
+ if (existing.userId !== email || (existing.stripeCustomerId && existing.stripeCustomerId !== data.stripeCustomerId)) {
920
+ throw new Error('Subscription does not belong to this account');
921
+ }
922
+ const { stripeSubscriptionId, ...lifecycle } = patch;
923
+ await this.updateSubscriptionApiKeys(stripeSubscriptionId, lifecycle);
924
+ return (await this.getApiKeyByStripeSubscriptionId(stripeSubscriptionId));
925
+ }
926
+ const records = await this.keysCollection.find({ userId: email }).toArray();
927
+ const eligible = records.filter(record => record.isActive !== false && !record.revokedAt);
928
+ // Another checkout/webhook may have promoted the trial after the first lookup.
929
+ if (eligible.some(record => record.stripeSubscriptionId === data.stripeSubscriptionId))
930
+ continue;
931
+ if (eligible.some(record => !(0, current_license_1.sameLicenseScope)(eligible[0], record))) {
932
+ throw new Error('Checkout account scope is ambiguous');
933
+ }
934
+ const trial = (0, current_license_1.selectCurrentLicense)(eligible.filter(record => record.tier === 'trial'
935
+ && record.status !== 'suspended' && !record.stripeSubscriptionId));
936
+ if (trial) {
937
+ const result = await this.keysCollection.updateOne({ key: trial.key, tier: 'trial', stripeSubscriptionId: null, isActive: { $ne: false }, status: { $ne: 'suspended' },
938
+ expiresAt: trial.expiresAt, manualAccessOverride: trial.manualAccessOverride || null }, { $set: data.billingReconciledAt ? this.mergeSubscriptionBilling(trial, patch) : patch });
939
+ if (result.matchedCount)
940
+ return (await this.getApiKeyByKey(trial.key));
941
+ continue;
942
+ }
943
+ const _id = new mongodb_1.ObjectId((0, node_crypto_1.createHash)('sha256').update(`fraim-subscription:${data.stripeSubscriptionId}`).digest('hex').slice(0, 24));
944
+ const prior = eligible[0];
945
+ try {
946
+ await this.keysCollection.updateOne({ _id }, { $setOnInsert: {
947
+ _id, key: this.generateApiKey(email, prior?.orgId || 'default'), userId: email,
948
+ orgId: prior?.orgId || 'default', workspaceId: prior?.workspaceId || null,
949
+ createdAt: new Date(), isActive: true, apiCallCount: 0, lastUsedAt: null, ...patch
950
+ } }, { upsert: true });
951
+ }
952
+ catch (error) {
953
+ if (error.code !== 11000)
954
+ throw error;
955
+ }
956
+ // Re-read and validate the winner before applying lifecycle or returning a credential.
957
+ }
958
+ throw new Error('Subscription provisioning did not converge');
810
959
  }
811
960
  async getApiKeyByWorkspaceId(workspaceId, activeOnly = true) {
812
961
  if (!this.keysCollection)
@@ -866,6 +1015,63 @@ class FraimDbService {
866
1015
  async upsertPersonaEntitlement(entitlement) {
867
1016
  if (!this.personaEntitlementsCollection)
868
1017
  throw new Error('DB not connected');
1018
+ if (entitlement.hireMode === 'fulltime' && entitlement.stripeSubscriptionId && entitlement.purchaseSource !== 'manual') {
1019
+ const identity = { workspaceId: entitlement.workspaceId, personaKey: entitlement.personaKey, hireMode: entitlement.hireMode };
1020
+ for (let attempt = 0; attempt < 8; attempt++) {
1021
+ const existing = await this.getPersonaEntitlement(identity.workspaceId, identity.personaKey, identity.hireMode);
1022
+ if (existing && existing.stripeSubscriptionId !== entitlement.stripeSubscriptionId) {
1023
+ const incomingKey = await this.getApiKeyByStripeSubscriptionId(entitlement.stripeSubscriptionId);
1024
+ const existingKey = existing.stripeSubscriptionId
1025
+ ? await this.getApiKeyByStripeSubscriptionId(existing.stripeSubscriptionId) : null;
1026
+ const incomingCreated = incomingKey?.stripeSubscriptionCreatedAt?.getTime();
1027
+ const existingCreated = existingKey?.stripeSubscriptionCreatedAt?.getTime();
1028
+ // Cancellation affects its own subscription only. Credential
1029
+ // creation dates cannot order purchases: trials are reused.
1030
+ if (incomingKey?.stripeSubscriptionEndedAt || entitlement.status !== 'active')
1031
+ return existing;
1032
+ if (Number.isFinite(incomingCreated) && Number.isFinite(existingCreated)) {
1033
+ if (incomingCreated <= existingCreated)
1034
+ return existing;
1035
+ }
1036
+ else if (existingKey ? (0, current_license_1.hasUsableLicense)(existingKey)
1037
+ : existing.status === 'active' && (!existing.expiresAt || existing.expiresAt.getTime() > Date.now())) {
1038
+ // Unknown purchase chronology must not displace a valid
1039
+ // existing hire or an explicitly authorized manual grant.
1040
+ return existing;
1041
+ }
1042
+ }
1043
+ const now = new Date();
1044
+ if (!existing) {
1045
+ try {
1046
+ const inserted = await this.personaEntitlementsCollection.updateOne(identity, {
1047
+ $setOnInsert: { ...entitlement, createdAt: now, updatedAt: now }
1048
+ }, { upsert: true });
1049
+ if (!inserted.upsertedCount)
1050
+ continue;
1051
+ }
1052
+ catch (error) {
1053
+ if (error.code === 11000)
1054
+ continue;
1055
+ throw error;
1056
+ }
1057
+ }
1058
+ else {
1059
+ const written = await this.personaEntitlementsCollection.updateOne({
1060
+ ...identity, stripeSubscriptionId: existing.stripeSubscriptionId || null,
1061
+ updatedAt: existing.updatedAt, status: existing.status, expiresAt: existing.expiresAt
1062
+ }, { $set: { ...entitlement, updatedAt: now,
1063
+ purchasedCount: (existing.purchasedCount || 0)
1064
+ + Number(existing.stripeSubscriptionId !== entitlement.stripeSubscriptionId)
1065
+ } });
1066
+ if (!written.matchedCount)
1067
+ continue;
1068
+ }
1069
+ const stored = await this.getPersonaEntitlement(identity.workspaceId, identity.personaKey, identity.hireMode);
1070
+ if (stored)
1071
+ return stored;
1072
+ }
1073
+ throw new Error('Persona subscription ownership update did not converge');
1074
+ }
869
1075
  const now = new Date();
870
1076
  await this.personaEntitlementsCollection.updateOne({
871
1077
  workspaceId: entitlement.workspaceId,
@@ -894,7 +1100,8 @@ class FraimDbService {
894
1100
  personaKey,
895
1101
  hireMode: 'job',
896
1102
  status: 'active',
897
- jobCreditsRemaining: { $gt: 0 }
1103
+ jobCreditsRemaining: { $gt: 0 },
1104
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }]
898
1105
  }, {
899
1106
  $inc: { jobCreditsRemaining: -1 },
900
1107
  $set: { updatedAt: new Date() }
@@ -905,12 +1112,25 @@ class FraimDbService {
905
1112
  async recordWebhookEvent(event) {
906
1113
  if (!this.webhookEventsCollection)
907
1114
  throw new Error('DB not connected');
1115
+ const now = new Date();
908
1116
  try {
909
- await this.webhookEventsCollection.insertOne(event);
1117
+ await this.webhookEventsCollection.insertOne({ ...event, processingStartedAt: now });
910
1118
  }
911
1119
  catch (error) {
912
1120
  if (error.code === 11000) {
913
- // Duplicate event ID - already processed
1121
+ const reclaimed = await this.webhookEventsCollection.updateOne({
1122
+ eventId: event.eventId, processed: false,
1123
+ $or: [
1124
+ { lastError: { $type: 'string' } },
1125
+ { processingStartedAt: { $lt: new Date(now.getTime() - 5 * 60 * 1000) } },
1126
+ { processingStartedAt: { $exists: false } }
1127
+ ]
1128
+ }, { $set: { lastError: null, processingStartedAt: now } });
1129
+ if (reclaimed.matchedCount)
1130
+ return;
1131
+ if (!await this.isWebhookEventProcessed(event.eventId)) {
1132
+ throw new Error('Webhook event is processing');
1133
+ }
914
1134
  throw new Error('Webhook event already processed');
915
1135
  }
916
1136
  throw error;
@@ -928,7 +1148,9 @@ class FraimDbService {
928
1148
  const result = await this.webhookEventsCollection.updateOne({ eventId }, {
929
1149
  $set: {
930
1150
  processed: true,
931
- processedAt: new Date()
1151
+ processedAt: new Date(),
1152
+ processingStartedAt: null,
1153
+ lastError: null
932
1154
  }
933
1155
  });
934
1156
  return result.matchedCount > 0;
@@ -938,7 +1160,8 @@ class FraimDbService {
938
1160
  throw new Error('DB not connected');
939
1161
  const result = await this.webhookEventsCollection.updateOne({ eventId }, {
940
1162
  $set: {
941
- lastError: error
1163
+ lastError: error,
1164
+ processingStartedAt: null
942
1165
  },
943
1166
  $inc: {
944
1167
  retryCount: 1
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serializeLicenseLifecycle = serializeLicenseLifecycle;
4
+ const current_license_1 = require("./current-license");
5
+ function isoDate(value) {
6
+ if (!value)
7
+ return null;
8
+ const date = new Date(value);
9
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
10
+ }
11
+ /** Public billing metadata shared by account, session and manager responses. */
12
+ function serializeLicenseLifecycle(key) {
13
+ if (key?.stripeSubscriptionId && key.billingReconciledAt) {
14
+ key = { ...key, ...(0, current_license_1.subscriptionAccessPatch)(key) };
15
+ }
16
+ const recurring = Boolean(key?.stripeSubscriptionId);
17
+ const effectiveExpiry = key ? (0, current_license_1.effectiveLicenseExpiry)(key) : null;
18
+ const expiryUnknown = recurring && effectiveExpiry?.getTime() === 0 && !key?.paidThrough && !key?.manualAccessOverride;
19
+ return {
20
+ status: key ? key.status ?? 'active' : null,
21
+ expiresAt: expiryUnknown ? null : isoDate(effectiveExpiry),
22
+ tier: key?.tier ?? null,
23
+ billingSource: recurring ? 'stripe' : key?.tier === 'trial' ? 'trial'
24
+ : key?.manualAccessOverride ? 'manual' : 'unverified',
25
+ paidThrough: isoDate(key?.paidThrough),
26
+ paidPeriodStart: isoDate(key?.paidPeriodStart),
27
+ subscriptionStatus: key?.subscriptionStatus ?? null,
28
+ paymentStatus: key?.paymentStatus ?? (recurring ? 'unverified' : null),
29
+ billingInterval: key?.billingInterval ?? null,
30
+ billingIntervalCount: key?.billingIntervalCount ?? null,
31
+ currentPeriodEnd: isoDate(key?.currentPeriodEnd),
32
+ cancelAt: isoDate(key?.cancelAt),
33
+ billingReconciledAt: isoDate(key?.billingReconciledAt),
34
+ manualAccessOverride: key?.manualAccessOverride ? {
35
+ until: key.manualAccessOverride.until === null ? null
36
+ : isoDate(key.manualAccessOverride.until) ?? 'unverified',
37
+ grantedAt: isoDate(key.manualAccessOverride.grantedAt),
38
+ reason: key.manualAccessOverride.reason,
39
+ } : null,
40
+ };
41
+ }