@agentforge-io/core 2.0.24 → 2.1.0
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/factory.js +56 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -1
- package/dist/services/agent-runner.service.js +18 -4
- package/dist/services/agent.service.d.ts +21 -1
- package/dist/services/agent.service.js +42 -8
- package/dist/services/orchestrator.service.d.ts +40 -1
- package/dist/services/orchestrator.service.js +220 -0
- package/dist/types/agent.types.d.ts +31 -6
- package/package.json +1 -1
- package/dist/adapters/billing/billing-adapter.interface.d.ts +0 -41
- package/dist/adapters/billing/billing-adapter.interface.js +0 -5
- package/dist/adapters/billing/stripe/stripe.adapter.d.ts +0 -30
- package/dist/adapters/billing/stripe/stripe.adapter.js +0 -122
- package/dist/adapters/email/email-adapter.interface.d.ts +0 -25
- package/dist/adapters/email/email-adapter.interface.js +0 -6
- package/dist/adapters/email/noop.adapter.d.ts +0 -10
- package/dist/adapters/email/noop.adapter.js +0 -15
- package/dist/adapters/email/resend.adapter.d.ts +0 -8
- package/dist/adapters/email/resend.adapter.js +0 -39
- package/dist/adapters/upload/noop.adapter.d.ts +0 -9
- package/dist/adapters/upload/noop.adapter.js +0 -14
- package/dist/adapters/upload/s3.adapter.d.ts +0 -38
- package/dist/adapters/upload/s3.adapter.js +0 -69
- package/dist/adapters/upload/upload-adapter.interface.d.ts +0 -37
- package/dist/adapters/upload/upload-adapter.interface.js +0 -15
- package/dist/billing/index.d.ts +0 -12
- package/dist/billing/index.js +0 -28
- package/dist/domain/agent.d.ts +0 -59
- package/dist/domain/agent.js +0 -2
- package/dist/domain/api-key.d.ts +0 -28
- package/dist/domain/api-key.js +0 -2
- package/dist/domain/auth-identity.d.ts +0 -10
- package/dist/domain/auth-identity.js +0 -2
- package/dist/domain/email-token.d.ts +0 -11
- package/dist/domain/email-token.js +0 -2
- package/dist/domain/external-user.d.ts +0 -23
- package/dist/domain/external-user.js +0 -2
- package/dist/domain/plan.d.ts +0 -20
- package/dist/domain/plan.js +0 -2
- package/dist/domain/platform-secret.d.ts +0 -24
- package/dist/domain/platform-secret.js +0 -8
- package/dist/domain/refresh-token.d.ts +0 -15
- package/dist/domain/refresh-token.js +0 -2
- package/dist/domain/subscription.d.ts +0 -21
- package/dist/domain/subscription.js +0 -2
- package/dist/domain/tenant.d.ts +0 -21
- package/dist/domain/tenant.js +0 -2
- package/dist/domain/usage-record.d.ts +0 -15
- package/dist/domain/usage-record.js +0 -2
- package/dist/domain/user.d.ts +0 -43
- package/dist/domain/user.js +0 -2
- package/dist/services/agent-config.service.d.ts +0 -45
- package/dist/services/agent-config.service.js +0 -114
- package/dist/services/api-key.service.d.ts +0 -41
- package/dist/services/api-key.service.js +0 -80
- package/dist/services/auth.service.d.ts +0 -133
- package/dist/services/auth.service.js +0 -411
- package/dist/services/billing.service.d.ts +0 -67
- package/dist/services/billing.service.js +0 -254
- package/dist/services/email-templates.d.ts +0 -18
- package/dist/services/email-templates.js +0 -39
- package/dist/services/email.service.d.ts +0 -26
- package/dist/services/email.service.js +0 -42
- package/dist/services/errors.d.ts +0 -7
- package/dist/services/errors.js +0 -27
- package/dist/services/oauth.service.d.ts +0 -73
- package/dist/services/oauth.service.js +0 -174
- package/dist/services/plan.service.d.ts +0 -54
- package/dist/services/plan.service.js +0 -120
- package/dist/services/refresh-token.service.d.ts +0 -38
- package/dist/services/refresh-token.service.js +0 -73
- package/dist/services/secrets/crypto.d.ts +0 -37
- package/dist/services/secrets/crypto.js +0 -110
- package/dist/services/secrets/known-keys.d.ts +0 -38
- package/dist/services/secrets/known-keys.js +0 -50
- package/dist/services/secrets.service.d.ts +0 -91
- package/dist/services/secrets.service.js +0 -193
- package/dist/services/tenant-billing.service.d.ts +0 -121
- package/dist/services/tenant-billing.service.js +0 -290
- package/dist/services/tenant.service.d.ts +0 -54
- package/dist/services/tenant.service.js +0 -96
- package/dist/services/upload.service.d.ts +0 -37
- package/dist/services/upload.service.js +0 -84
- package/dist/services/usage.service.d.ts +0 -34
- package/dist/services/usage.service.js +0 -108
- package/dist/types/billing.types.d.ts +0 -82
- package/dist/types/billing.types.js +0 -3
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
// SecretsService — Phase A platform vault.
|
|
3
|
-
//
|
|
4
|
-
// Resolves secrets in this order (first hit wins):
|
|
5
|
-
// 1. process.env[KEY] — env vars always trump DB. Lets development /
|
|
6
|
-
// escape-hatch setups keep working, and protects against a corrupt
|
|
7
|
-
// DB row taking down the service.
|
|
8
|
-
// 2. af_platform_secrets — encrypted blob, decrypted with MASTER_KEY.
|
|
9
|
-
//
|
|
10
|
-
// Resolution is boot-time only: callers ask for a key, the service
|
|
11
|
-
// resolves once and caches the plaintext in memory. Mutating a row via
|
|
12
|
-
// upsert/delete invalidates that key's cache entry, but adapters that
|
|
13
|
-
// already used the old value (e.g. a Stripe client created at boot)
|
|
14
|
-
// keep running with it until the server restarts. That's by design for
|
|
15
|
-
// Phase A — runtime hot-reload is Phase B.
|
|
16
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.SecretsService = exports.SecretsError = void 0;
|
|
18
|
-
const crypto_1 = require("./secrets/crypto");
|
|
19
|
-
const known_keys_1 = require("./secrets/known-keys");
|
|
20
|
-
const STATUS = {
|
|
21
|
-
unknown_key: 400,
|
|
22
|
-
env_override_active: 409,
|
|
23
|
-
master_key_missing: 503,
|
|
24
|
-
};
|
|
25
|
-
class SecretsError extends Error {
|
|
26
|
-
constructor(code, message) {
|
|
27
|
-
super(message);
|
|
28
|
-
this.code = code;
|
|
29
|
-
this.name = 'SecretsError';
|
|
30
|
-
this.status = STATUS[code];
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
exports.SecretsError = SecretsError;
|
|
34
|
-
const noopLogger = {
|
|
35
|
-
log: () => { },
|
|
36
|
-
warn: () => { },
|
|
37
|
-
debug: () => { },
|
|
38
|
-
error: () => { },
|
|
39
|
-
};
|
|
40
|
-
class SecretsService {
|
|
41
|
-
constructor(repo, opts = {}) {
|
|
42
|
-
this.repo = repo;
|
|
43
|
-
this.cache = new Map();
|
|
44
|
-
this.logger = opts.logger ?? noopLogger;
|
|
45
|
-
this.env = opts.env ?? process.env;
|
|
46
|
-
this.masterKey = opts.masterKey;
|
|
47
|
-
if (!this.masterKey) {
|
|
48
|
-
this.logger.warn('[secrets] MASTER_KEY not set — DB-backed secrets are unavailable. ' +
|
|
49
|
-
'Set MASTER_KEY in the environment to enable the vault.');
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
// ─── Read path ───────────────────────────────────────────────────────────
|
|
53
|
-
/**
|
|
54
|
-
* Resolve a known key. Returns the plaintext or `undefined` when neither
|
|
55
|
-
* env nor DB has it set. Callers decide whether undefined is fatal (it
|
|
56
|
-
* is for ANTHROPIC_API_KEY; it's not for TAVILY_API_KEY).
|
|
57
|
-
*
|
|
58
|
-
* Cached in memory after first read. Cache survives until the next
|
|
59
|
-
* upsert/delete on the same key.
|
|
60
|
-
*/
|
|
61
|
-
async resolve(key) {
|
|
62
|
-
if (!(0, known_keys_1.isKnownSecretKey)(key)) {
|
|
63
|
-
// Don't throw — resolve() is on the hot path. Just log + miss.
|
|
64
|
-
this.logger.warn(`[secrets] resolve("${key}") — unknown key, ignoring`);
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
|
-
const cached = this.cache.get(key);
|
|
68
|
-
if (cached !== undefined)
|
|
69
|
-
return cached;
|
|
70
|
-
const envValue = this.env[key];
|
|
71
|
-
if (envValue) {
|
|
72
|
-
this.cache.set(key, envValue);
|
|
73
|
-
return envValue;
|
|
74
|
-
}
|
|
75
|
-
if (!this.masterKey)
|
|
76
|
-
return undefined;
|
|
77
|
-
const row = await this.repo.findByKey(key);
|
|
78
|
-
if (!row)
|
|
79
|
-
return undefined;
|
|
80
|
-
try {
|
|
81
|
-
const plaintext = (0, crypto_1.decrypt)(row.valueEncrypted, this.masterKey);
|
|
82
|
-
this.cache.set(key, plaintext);
|
|
83
|
-
return plaintext;
|
|
84
|
-
}
|
|
85
|
-
catch (err) {
|
|
86
|
-
const code = err instanceof crypto_1.SecretCryptoError ? err.code : 'unknown';
|
|
87
|
-
this.logger.error(`[secrets] decrypt("${key}") failed (${code}). ` +
|
|
88
|
-
`Master key was likely rotated without re-encrypting; ` +
|
|
89
|
-
`the operator must re-enter this secret.`);
|
|
90
|
-
return undefined;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* Bulk variant — resolve every known key at once. Useful at boot when
|
|
95
|
-
* the server wants to decide which adapters to wire up (Stripe on/off,
|
|
96
|
-
* which search provider, etc.). Skips logging on individual misses to
|
|
97
|
-
* keep the boot log readable.
|
|
98
|
-
*/
|
|
99
|
-
async resolveAll() {
|
|
100
|
-
const out = {};
|
|
101
|
-
for (const def of (0, known_keys_1.listKnownSecrets)()) {
|
|
102
|
-
const value = await this.resolve(def.key);
|
|
103
|
-
if (value)
|
|
104
|
-
out[def.key] = value;
|
|
105
|
-
}
|
|
106
|
-
return out;
|
|
107
|
-
}
|
|
108
|
-
// ─── Admin path ──────────────────────────────────────────────────────────
|
|
109
|
-
/**
|
|
110
|
-
* Admin-facing catalog. Returns one entry per known key — including
|
|
111
|
-
* the ones that aren't configured anywhere — so the UI can render the
|
|
112
|
-
* whole table in one shot.
|
|
113
|
-
*
|
|
114
|
-
* Never returns plaintext or ciphertext: only hint + metadata.
|
|
115
|
-
*/
|
|
116
|
-
async listForAdmin() {
|
|
117
|
-
const rows = this.masterKey ? await this.repo.listAll() : [];
|
|
118
|
-
const byKey = new Map(rows.map((r) => [r.key, r]));
|
|
119
|
-
return (0, known_keys_1.listKnownSecrets)().map((def) => {
|
|
120
|
-
const envSet = !!this.env[def.key];
|
|
121
|
-
const row = byKey.get(def.key);
|
|
122
|
-
const source = envSet
|
|
123
|
-
? 'env'
|
|
124
|
-
: row
|
|
125
|
-
? 'db'
|
|
126
|
-
: 'none';
|
|
127
|
-
return {
|
|
128
|
-
key: def.key,
|
|
129
|
-
description: def.description,
|
|
130
|
-
sensitivity: def.sensitivity,
|
|
131
|
-
configured: source !== 'none',
|
|
132
|
-
source,
|
|
133
|
-
valueHint: source === 'db' ? row?.valueHint : undefined,
|
|
134
|
-
updatedAt: source === 'db' ? row?.updatedAt : undefined,
|
|
135
|
-
updatedByUserId: source === 'db' ? row?.updatedByUserId : undefined,
|
|
136
|
-
};
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Upsert a known secret. The new ciphertext + hint hit the DB; the
|
|
141
|
-
* cache entry is evicted so the next `resolve` re-reads (and decrypts)
|
|
142
|
-
* fresh data. Returns the row metadata so the UI can echo the hint.
|
|
143
|
-
*
|
|
144
|
-
* Throws `unknown_key` for keys outside the catalog (no free-form
|
|
145
|
-
* inserts), `master_key_missing` when MASTER_KEY isn't configured.
|
|
146
|
-
*/
|
|
147
|
-
async upsert(input) {
|
|
148
|
-
if (!(0, known_keys_1.isKnownSecretKey)(input.key)) {
|
|
149
|
-
throw new SecretsError('unknown_key', `"${input.key}" is not a known platform secret. ` +
|
|
150
|
-
`Known keys: ${(0, known_keys_1.listKnownSecrets)().map((d) => d.key).join(', ')}.`);
|
|
151
|
-
}
|
|
152
|
-
if (!this.masterKey) {
|
|
153
|
-
throw new SecretsError('master_key_missing', 'Cannot write a secret without MASTER_KEY configured.');
|
|
154
|
-
}
|
|
155
|
-
const encrypted = (0, crypto_1.encrypt)(input.value, this.masterKey);
|
|
156
|
-
const hint = (0, crypto_1.valueHint)(input.value);
|
|
157
|
-
const row = await this.repo.upsert({
|
|
158
|
-
key: input.key,
|
|
159
|
-
valueEncrypted: encrypted,
|
|
160
|
-
valueHint: hint,
|
|
161
|
-
description: input.description ?? known_keys_1.KNOWN_SECRETS[input.key].description,
|
|
162
|
-
updatedByUserId: input.updatedByUserId,
|
|
163
|
-
});
|
|
164
|
-
this.cache.delete(input.key);
|
|
165
|
-
return row;
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Hard-delete a known secret. Refuses while the matching env var is
|
|
169
|
-
* set — otherwise the operator would see "still configured" in the UI
|
|
170
|
-
* after deleting, which is confusing.
|
|
171
|
-
*/
|
|
172
|
-
async delete(key) {
|
|
173
|
-
if (!(0, known_keys_1.isKnownSecretKey)(key)) {
|
|
174
|
-
throw new SecretsError('unknown_key', `Unknown key: ${key}`);
|
|
175
|
-
}
|
|
176
|
-
if (this.env[key]) {
|
|
177
|
-
throw new SecretsError('env_override_active', `Cannot delete "${key}" while the env var is set in the deployment. ` +
|
|
178
|
-
`Unset it in the .env (or restart with it removed) before deleting from the vault.`);
|
|
179
|
-
}
|
|
180
|
-
if (!this.masterKey) {
|
|
181
|
-
// Without MASTER_KEY the DB is unreadable; deleting is still safe.
|
|
182
|
-
// We just let it through.
|
|
183
|
-
}
|
|
184
|
-
await this.repo.deleteByKey(key);
|
|
185
|
-
this.cache.delete(key);
|
|
186
|
-
}
|
|
187
|
-
/** For tests + admin tools: drop everything from cache so the next
|
|
188
|
-
* resolve goes back to env/DB. */
|
|
189
|
-
clearCache() {
|
|
190
|
-
this.cache.clear();
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
exports.SecretsService = SecretsService;
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import type { SubscriptionRepository, TenantRepository, ExternalUserRepository, UsageRecordRepository } from '../repositories';
|
|
2
|
-
import type { IBillingAdapter } from '../adapters/billing/billing-adapter.interface';
|
|
3
|
-
import type { SubscriptionStatus } from '../types/billing.types';
|
|
4
|
-
import type { BillingConfig } from '../types/config.types';
|
|
5
|
-
import type { Plan } from '../domain/plan';
|
|
6
|
-
import type { PlanService } from './plan.service';
|
|
7
|
-
export interface TenantBillingOverview {
|
|
8
|
-
plan: {
|
|
9
|
-
id: string;
|
|
10
|
-
name: string;
|
|
11
|
-
features: string[];
|
|
12
|
-
limits: {
|
|
13
|
-
requestsPerMonth: number;
|
|
14
|
-
tokensPerMonth: number;
|
|
15
|
-
};
|
|
16
|
-
priceInCents?: number;
|
|
17
|
-
interval?: 'month' | 'year';
|
|
18
|
-
};
|
|
19
|
-
usage: {
|
|
20
|
-
period: string;
|
|
21
|
-
requestCount: number;
|
|
22
|
-
totalTokens: number;
|
|
23
|
-
requestLimit: number;
|
|
24
|
-
tokenLimit: number;
|
|
25
|
-
};
|
|
26
|
-
subscription: {
|
|
27
|
-
id: string;
|
|
28
|
-
planId: string;
|
|
29
|
-
status: SubscriptionStatus;
|
|
30
|
-
currentPeriodStart?: Date;
|
|
31
|
-
currentPeriodEnd?: Date;
|
|
32
|
-
cancelAtPeriodEnd: boolean;
|
|
33
|
-
} | null;
|
|
34
|
-
}
|
|
35
|
-
export interface TenantCheckoutResult {
|
|
36
|
-
checkoutUrl: string;
|
|
37
|
-
sessionId?: string;
|
|
38
|
-
}
|
|
39
|
-
export interface TenantBillingServiceOptions {
|
|
40
|
-
billing?: BillingConfig;
|
|
41
|
-
defaultLimits?: {
|
|
42
|
-
requestsPerMonth?: number;
|
|
43
|
-
tokensPerMonth?: number;
|
|
44
|
-
};
|
|
45
|
-
logger?: {
|
|
46
|
-
warn: (m: string) => void;
|
|
47
|
-
log: (m: string) => void;
|
|
48
|
-
};
|
|
49
|
-
/** Source of truth for plans (DB-backed in production). */
|
|
50
|
-
plans: PlanService;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Billing for the B2B path: a Tenant is the payer, its API keys consume the
|
|
54
|
-
* service, and quotas apply to the (aggregated) usage of all external users
|
|
55
|
-
* under that tenant. Mirrors the user-centric BillingService but scoped one
|
|
56
|
-
* level up.
|
|
57
|
-
*/
|
|
58
|
-
export declare class TenantBillingService {
|
|
59
|
-
private readonly tenants;
|
|
60
|
-
private readonly subscriptions;
|
|
61
|
-
private readonly usageRecords;
|
|
62
|
-
private readonly externalUsers;
|
|
63
|
-
private readonly adapter;
|
|
64
|
-
private readonly opts;
|
|
65
|
-
constructor(tenants: TenantRepository, subscriptions: SubscriptionRepository, usageRecords: UsageRecordRepository, externalUsers: ExternalUserRepository, adapter: IBillingAdapter, opts: TenantBillingServiceOptions);
|
|
66
|
-
getPlans(): Promise<Plan[]>;
|
|
67
|
-
getPlan(planId: string): Promise<Plan | undefined>;
|
|
68
|
-
getDefaultPlanId(): Promise<string>;
|
|
69
|
-
createCheckout(input: {
|
|
70
|
-
tenantId: string;
|
|
71
|
-
planId: string;
|
|
72
|
-
/** Override URLs per request (e.g. coming from a frontend on a custom domain). */
|
|
73
|
-
successUrl?: string;
|
|
74
|
-
cancelUrl?: string;
|
|
75
|
-
appUrl?: string;
|
|
76
|
-
}): Promise<TenantCheckoutResult>;
|
|
77
|
-
getPortalUrl(tenantId: string, returnUrl?: string): Promise<{
|
|
78
|
-
url: string;
|
|
79
|
-
}>;
|
|
80
|
-
getOverview(tenantId: string): Promise<TenantBillingOverview>;
|
|
81
|
-
cancelSubscription(tenantId: string, immediately?: boolean): Promise<void>;
|
|
82
|
-
/**
|
|
83
|
-
* Handle a webhook event whose `data.metadata.tenantId` indicates a tenant
|
|
84
|
-
* subscription. Returns true if handled. The caller should fall through to
|
|
85
|
-
* the user-centric handler when this returns false.
|
|
86
|
-
*/
|
|
87
|
-
handleWebhookEvent(event: {
|
|
88
|
-
type: string;
|
|
89
|
-
data: Record<string, unknown>;
|
|
90
|
-
}): Promise<boolean>;
|
|
91
|
-
private onCheckoutCompleted;
|
|
92
|
-
private onSubscriptionUpdated;
|
|
93
|
-
private onSubscriptionDeleted;
|
|
94
|
-
private onPaymentFailed;
|
|
95
|
-
/**
|
|
96
|
-
* Decide whether a tenant is still within its monthly quotas. Designed to be
|
|
97
|
-
* called on the hot path (every chat request) — does one aggregate query
|
|
98
|
-
* against `af_usage_records` and one tenant lookup.
|
|
99
|
-
*
|
|
100
|
-
* Limits of `-1` mean "unlimited" for that dimension (per plan definition
|
|
101
|
-
* convention). Returns `{ allowed: false, reason }` when any positive limit
|
|
102
|
-
* has been reached or exceeded.
|
|
103
|
-
*/
|
|
104
|
-
checkLimits(tenantId: string): Promise<{
|
|
105
|
-
allowed: boolean;
|
|
106
|
-
reason?: string;
|
|
107
|
-
usage: {
|
|
108
|
-
period: string;
|
|
109
|
-
requestCount: number;
|
|
110
|
-
totalTokens: number;
|
|
111
|
-
requestLimit: number;
|
|
112
|
-
tokenLimit: number;
|
|
113
|
-
};
|
|
114
|
-
}>;
|
|
115
|
-
private aggregateTenantUsage;
|
|
116
|
-
}
|
|
117
|
-
export declare class TenantBillingError extends Error {
|
|
118
|
-
status: number;
|
|
119
|
-
code: 'not_found' | 'plan_not_found' | 'plan_not_purchasable' | 'no_customer' | 'no_active_subscription';
|
|
120
|
-
constructor(code: 'not_found' | 'plan_not_found' | 'plan_not_purchasable' | 'no_customer' | 'no_active_subscription', message: string);
|
|
121
|
-
}
|
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.TenantBillingError = exports.TenantBillingService = void 0;
|
|
4
|
-
/**
|
|
5
|
-
* Billing for the B2B path: a Tenant is the payer, its API keys consume the
|
|
6
|
-
* service, and quotas apply to the (aggregated) usage of all external users
|
|
7
|
-
* under that tenant. Mirrors the user-centric BillingService but scoped one
|
|
8
|
-
* level up.
|
|
9
|
-
*/
|
|
10
|
-
class TenantBillingService {
|
|
11
|
-
constructor(tenants, subscriptions, usageRecords, externalUsers, adapter, opts) {
|
|
12
|
-
this.tenants = tenants;
|
|
13
|
-
this.subscriptions = subscriptions;
|
|
14
|
-
this.usageRecords = usageRecords;
|
|
15
|
-
this.externalUsers = externalUsers;
|
|
16
|
-
this.adapter = adapter;
|
|
17
|
-
this.opts = opts;
|
|
18
|
-
}
|
|
19
|
-
// ─── Plans ─────────────────────────────────────────────────────────────
|
|
20
|
-
async getPlans() {
|
|
21
|
-
return this.opts.plans.list();
|
|
22
|
-
}
|
|
23
|
-
async getPlan(planId) {
|
|
24
|
-
return this.opts.plans.getPlan(planId);
|
|
25
|
-
}
|
|
26
|
-
async getDefaultPlanId() {
|
|
27
|
-
return this.opts.plans.getDefaultId();
|
|
28
|
-
}
|
|
29
|
-
// ─── Checkout ──────────────────────────────────────────────────────────
|
|
30
|
-
async createCheckout(input) {
|
|
31
|
-
const tenant = await this.tenants.findById(input.tenantId);
|
|
32
|
-
if (!tenant)
|
|
33
|
-
throw new TenantBillingError('not_found', 'Tenant not found');
|
|
34
|
-
const plan = await this.getPlan(input.planId);
|
|
35
|
-
if (!plan)
|
|
36
|
-
throw new TenantBillingError('plan_not_found', `Plan "${input.planId}" not found`);
|
|
37
|
-
if (!plan.stripePriceId) {
|
|
38
|
-
throw new TenantBillingError('plan_not_purchasable', `Plan "${input.planId}" has no stripePriceId`);
|
|
39
|
-
}
|
|
40
|
-
// Lazy-create the Stripe customer for the tenant.
|
|
41
|
-
let customerId = tenant.providerCustomerId;
|
|
42
|
-
if (!customerId) {
|
|
43
|
-
customerId = await this.adapter.createCustomer({
|
|
44
|
-
email: undefined, // tenant doesn't have email; can be passed via metadata
|
|
45
|
-
name: tenant.name,
|
|
46
|
-
metadata: { tenantId: tenant.id, ownerUserId: tenant.ownerUserId },
|
|
47
|
-
});
|
|
48
|
-
await this.tenants.update(tenant.id, { providerCustomerId: customerId });
|
|
49
|
-
tenant.providerCustomerId = customerId;
|
|
50
|
-
}
|
|
51
|
-
const appUrl = input.appUrl ?? this.opts.billing?.appUrl ?? 'http://localhost:3030';
|
|
52
|
-
const successUrl = input.successUrl
|
|
53
|
-
?? this.opts.billing?.successUrl
|
|
54
|
-
?? `${appUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`;
|
|
55
|
-
const cancelUrl = input.cancelUrl
|
|
56
|
-
?? this.opts.billing?.cancelUrl
|
|
57
|
-
?? `${appUrl}/billing/cancel`;
|
|
58
|
-
const session = await this.adapter.createCheckoutSession({
|
|
59
|
-
userId: tenant.id, // adapter uses this as a key in metadata
|
|
60
|
-
planId: input.planId,
|
|
61
|
-
stripePriceId: plan.stripePriceId,
|
|
62
|
-
successUrl,
|
|
63
|
-
cancelUrl,
|
|
64
|
-
metadata: { tenantId: tenant.id, planId: input.planId },
|
|
65
|
-
});
|
|
66
|
-
return { checkoutUrl: session.url, sessionId: session.sessionId };
|
|
67
|
-
}
|
|
68
|
-
// ─── Portal ────────────────────────────────────────────────────────────
|
|
69
|
-
async getPortalUrl(tenantId, returnUrl) {
|
|
70
|
-
const tenant = await this.tenants.findById(tenantId);
|
|
71
|
-
if (!tenant)
|
|
72
|
-
throw new TenantBillingError('not_found', 'Tenant not found');
|
|
73
|
-
if (!tenant.providerCustomerId) {
|
|
74
|
-
throw new TenantBillingError('no_customer', 'Tenant has no payment account yet — create a checkout first');
|
|
75
|
-
}
|
|
76
|
-
const portalReturn = returnUrl
|
|
77
|
-
?? this.opts.billing?.portalReturnUrl
|
|
78
|
-
?? `${this.opts.billing?.appUrl ?? 'http://localhost:3030'}/billing`;
|
|
79
|
-
const url = await this.adapter.getPortalUrl(tenant.providerCustomerId, portalReturn);
|
|
80
|
-
return { url };
|
|
81
|
-
}
|
|
82
|
-
// ─── Overview ──────────────────────────────────────────────────────────
|
|
83
|
-
async getOverview(tenantId) {
|
|
84
|
-
const tenant = await this.tenants.findById(tenantId);
|
|
85
|
-
if (!tenant)
|
|
86
|
-
throw new TenantBillingError('not_found', 'Tenant not found');
|
|
87
|
-
const subscription = await this.subscriptions.findActiveForTenant(tenantId);
|
|
88
|
-
const planId = tenant.planId;
|
|
89
|
-
const plan = await this.getPlan(planId);
|
|
90
|
-
const usage = await this.aggregateTenantUsage(tenantId);
|
|
91
|
-
const requestLimit = plan?.limits.requestsPerMonth
|
|
92
|
-
?? this.opts.defaultLimits?.requestsPerMonth
|
|
93
|
-
?? -1;
|
|
94
|
-
const tokenLimit = plan?.limits.tokensPerMonth
|
|
95
|
-
?? this.opts.defaultLimits?.tokensPerMonth
|
|
96
|
-
?? -1;
|
|
97
|
-
return {
|
|
98
|
-
plan: {
|
|
99
|
-
id: planId,
|
|
100
|
-
name: plan?.name ?? planId,
|
|
101
|
-
features: plan?.features ?? [],
|
|
102
|
-
limits: {
|
|
103
|
-
requestsPerMonth: requestLimit,
|
|
104
|
-
tokensPerMonth: tokenLimit,
|
|
105
|
-
},
|
|
106
|
-
priceInCents: plan?.priceInCents,
|
|
107
|
-
interval: plan?.interval,
|
|
108
|
-
},
|
|
109
|
-
usage: {
|
|
110
|
-
period: currentBillingPeriod(),
|
|
111
|
-
requestCount: usage.requestCount,
|
|
112
|
-
totalTokens: usage.totalTokens,
|
|
113
|
-
requestLimit,
|
|
114
|
-
tokenLimit,
|
|
115
|
-
},
|
|
116
|
-
subscription: subscription
|
|
117
|
-
? {
|
|
118
|
-
id: subscription.id,
|
|
119
|
-
planId: subscription.planId,
|
|
120
|
-
status: subscription.status,
|
|
121
|
-
currentPeriodStart: subscription.currentPeriodStart,
|
|
122
|
-
currentPeriodEnd: subscription.currentPeriodEnd,
|
|
123
|
-
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
124
|
-
}
|
|
125
|
-
: null,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
// ─── Cancel ────────────────────────────────────────────────────────────
|
|
129
|
-
async cancelSubscription(tenantId, immediately = false) {
|
|
130
|
-
const subscription = await this.subscriptions.findActiveForTenant(tenantId);
|
|
131
|
-
if (!subscription?.providerSubscriptionId) {
|
|
132
|
-
throw new TenantBillingError('no_active_subscription', 'No active subscription found');
|
|
133
|
-
}
|
|
134
|
-
await this.adapter.cancelSubscription(subscription.providerSubscriptionId, !immediately);
|
|
135
|
-
await this.subscriptions.update(subscription.id, {
|
|
136
|
-
cancelAtPeriodEnd: !immediately,
|
|
137
|
-
status: immediately ? 'canceled' : subscription.status,
|
|
138
|
-
});
|
|
139
|
-
if (immediately) {
|
|
140
|
-
await this.tenants.update(tenantId, { planId: await this.getDefaultPlanId() });
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
// ─── Webhook (called by the public webhook handler) ────────────────────
|
|
144
|
-
/**
|
|
145
|
-
* Handle a webhook event whose `data.metadata.tenantId` indicates a tenant
|
|
146
|
-
* subscription. Returns true if handled. The caller should fall through to
|
|
147
|
-
* the user-centric handler when this returns false.
|
|
148
|
-
*/
|
|
149
|
-
async handleWebhookEvent(event) {
|
|
150
|
-
const data = event.data;
|
|
151
|
-
const tenantId = data.metadata?.tenantId;
|
|
152
|
-
if (!tenantId)
|
|
153
|
-
return false;
|
|
154
|
-
switch (event.type) {
|
|
155
|
-
case 'checkout.session.completed':
|
|
156
|
-
await this.onCheckoutCompleted(event.data, tenantId);
|
|
157
|
-
return true;
|
|
158
|
-
case 'customer.subscription.updated':
|
|
159
|
-
await this.onSubscriptionUpdated(event.data, tenantId);
|
|
160
|
-
return true;
|
|
161
|
-
case 'customer.subscription.deleted':
|
|
162
|
-
await this.onSubscriptionDeleted(event.data, tenantId);
|
|
163
|
-
return true;
|
|
164
|
-
case 'invoice.payment_failed':
|
|
165
|
-
await this.onPaymentFailed(event.data);
|
|
166
|
-
return true;
|
|
167
|
-
default:
|
|
168
|
-
return true; // unknown but tagged as tenant — swallow
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
async onCheckoutCompleted(data, tenantId) {
|
|
172
|
-
const { metadata, subscription: subscriptionId, customer } = data;
|
|
173
|
-
const planId = metadata?.planId;
|
|
174
|
-
if (!planId)
|
|
175
|
-
return;
|
|
176
|
-
let periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
177
|
-
if (subscriptionId) {
|
|
178
|
-
const sub = await this.adapter.getSubscription(subscriptionId);
|
|
179
|
-
periodEnd = sub.currentPeriodEnd;
|
|
180
|
-
}
|
|
181
|
-
const existing = await this.subscriptions.findByTenantAndPlan(tenantId, planId);
|
|
182
|
-
if (existing) {
|
|
183
|
-
await this.subscriptions.update(existing.id, {
|
|
184
|
-
status: 'active',
|
|
185
|
-
providerSubscriptionId: subscriptionId,
|
|
186
|
-
providerCustomerId: customer,
|
|
187
|
-
currentPeriodEnd: periodEnd,
|
|
188
|
-
cancelAtPeriodEnd: false,
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
else {
|
|
192
|
-
await this.subscriptions.create({
|
|
193
|
-
tenantId,
|
|
194
|
-
planId,
|
|
195
|
-
status: 'active',
|
|
196
|
-
providerSubscriptionId: subscriptionId,
|
|
197
|
-
providerCustomerId: customer,
|
|
198
|
-
currentPeriodStart: new Date(),
|
|
199
|
-
currentPeriodEnd: periodEnd,
|
|
200
|
-
cancelAtPeriodEnd: false,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
await this.tenants.update(tenantId, {
|
|
204
|
-
planId,
|
|
205
|
-
providerCustomerId: customer,
|
|
206
|
-
});
|
|
207
|
-
this.opts.logger?.log(`Tenant ${tenantId} activated plan "${planId}"`);
|
|
208
|
-
}
|
|
209
|
-
async onSubscriptionUpdated(data, _tenantId) {
|
|
210
|
-
const { id, status, current_period_end, cancel_at_period_end } = data;
|
|
211
|
-
const periodEnd = typeof current_period_end === 'number' && Number.isFinite(current_period_end)
|
|
212
|
-
? new Date(current_period_end * 1000)
|
|
213
|
-
: undefined;
|
|
214
|
-
await this.subscriptions.updateByProviderId(id, {
|
|
215
|
-
status: status,
|
|
216
|
-
cancelAtPeriodEnd: cancel_at_period_end ?? false,
|
|
217
|
-
...(periodEnd ? { currentPeriodEnd: periodEnd } : {}),
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
async onSubscriptionDeleted(data, tenantId) {
|
|
221
|
-
const { id } = data;
|
|
222
|
-
await this.subscriptions.updateByProviderId(id, { status: 'canceled' });
|
|
223
|
-
await this.tenants.update(tenantId, { planId: await this.getDefaultPlanId() });
|
|
224
|
-
}
|
|
225
|
-
async onPaymentFailed(data) {
|
|
226
|
-
const { subscription } = data;
|
|
227
|
-
if (subscription) {
|
|
228
|
-
await this.subscriptions.updateByProviderId(subscription, { status: 'past_due' });
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
// ─── Plan enforcement ──────────────────────────────────────────────────
|
|
232
|
-
/**
|
|
233
|
-
* Decide whether a tenant is still within its monthly quotas. Designed to be
|
|
234
|
-
* called on the hot path (every chat request) — does one aggregate query
|
|
235
|
-
* against `af_usage_records` and one tenant lookup.
|
|
236
|
-
*
|
|
237
|
-
* Limits of `-1` mean "unlimited" for that dimension (per plan definition
|
|
238
|
-
* convention). Returns `{ allowed: false, reason }` when any positive limit
|
|
239
|
-
* has been reached or exceeded.
|
|
240
|
-
*/
|
|
241
|
-
async checkLimits(tenantId) {
|
|
242
|
-
const tenant = await this.tenants.findById(tenantId);
|
|
243
|
-
if (!tenant)
|
|
244
|
-
throw new TenantBillingError('not_found', 'Tenant not found');
|
|
245
|
-
const plan = await this.getPlan(tenant.planId);
|
|
246
|
-
const requestLimit = plan?.limits.requestsPerMonth
|
|
247
|
-
?? this.opts.defaultLimits?.requestsPerMonth
|
|
248
|
-
?? -1;
|
|
249
|
-
const tokenLimit = plan?.limits.tokensPerMonth
|
|
250
|
-
?? this.opts.defaultLimits?.tokensPerMonth
|
|
251
|
-
?? -1;
|
|
252
|
-
const period = currentBillingPeriod();
|
|
253
|
-
const summed = await this.usageRecords.sumForTenantPeriod(tenantId, period);
|
|
254
|
-
const reasons = [];
|
|
255
|
-
if (requestLimit > 0 && summed.requestCount >= requestLimit) {
|
|
256
|
-
reasons.push(`Monthly request limit reached (${requestLimit} requests)`);
|
|
257
|
-
}
|
|
258
|
-
if (tokenLimit > 0 && summed.totalTokens >= tokenLimit) {
|
|
259
|
-
reasons.push(`Monthly token limit reached (${tokenLimit} tokens)`);
|
|
260
|
-
}
|
|
261
|
-
return {
|
|
262
|
-
allowed: reasons.length === 0,
|
|
263
|
-
reason: reasons.length > 0 ? reasons.join('; ') : undefined,
|
|
264
|
-
usage: {
|
|
265
|
-
period,
|
|
266
|
-
requestCount: summed.requestCount,
|
|
267
|
-
totalTokens: summed.totalTokens,
|
|
268
|
-
requestLimit,
|
|
269
|
-
tokenLimit,
|
|
270
|
-
},
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
// ─── Usage aggregation ─────────────────────────────────────────────────
|
|
274
|
-
async aggregateTenantUsage(tenantId) {
|
|
275
|
-
return this.usageRecords.sumForTenantPeriod(tenantId, currentBillingPeriod());
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
exports.TenantBillingService = TenantBillingService;
|
|
279
|
-
class TenantBillingError extends Error {
|
|
280
|
-
constructor(code, message) {
|
|
281
|
-
super(message);
|
|
282
|
-
this.code = code;
|
|
283
|
-
this.status = code === 'not_found' ? 404 : 400;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
exports.TenantBillingError = TenantBillingError;
|
|
287
|
-
function currentBillingPeriod() {
|
|
288
|
-
const d = new Date();
|
|
289
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
290
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import type { TenantRepository, TenantListOptions, ExternalUserRepository, UserRepository } from '../repositories';
|
|
2
|
-
import type { Tenant } from '../domain/tenant';
|
|
3
|
-
import type { ExternalUser } from '../domain/external-user';
|
|
4
|
-
export declare class TenantError extends Error {
|
|
5
|
-
status: number;
|
|
6
|
-
code: 'not_found' | 'forbidden';
|
|
7
|
-
constructor(code: 'not_found' | 'forbidden', message: string);
|
|
8
|
-
}
|
|
9
|
-
export declare class TenantService {
|
|
10
|
-
private readonly tenants;
|
|
11
|
-
private readonly externalUsers;
|
|
12
|
-
private readonly users;
|
|
13
|
-
constructor(tenants: TenantRepository, externalUsers: ExternalUserRepository, users: UserRepository);
|
|
14
|
-
create(input: {
|
|
15
|
-
name: string;
|
|
16
|
-
ownerUserId: string;
|
|
17
|
-
planId?: string;
|
|
18
|
-
}): Promise<Tenant>;
|
|
19
|
-
findById(id: string): Promise<Tenant | null>;
|
|
20
|
-
listForOwner(ownerUserId: string, opts?: TenantListOptions): Promise<{
|
|
21
|
-
items: Tenant[];
|
|
22
|
-
total: number;
|
|
23
|
-
}>;
|
|
24
|
-
/**
|
|
25
|
-
* List every tenant in the system. Controllers MUST gate this behind a
|
|
26
|
-
* platform-admin check before calling — the service intentionally doesn't,
|
|
27
|
-
* so it stays usable from contexts that have already authorized the call.
|
|
28
|
-
*/
|
|
29
|
-
listAll(opts?: TenantListOptions): Promise<{
|
|
30
|
-
items: Tenant[];
|
|
31
|
-
total: number;
|
|
32
|
-
}>;
|
|
33
|
-
/**
|
|
34
|
-
* Throws if the user isn't the owner. Used by admin endpoints.
|
|
35
|
-
*
|
|
36
|
-
* Pass `allowAnyOwner: true` to skip the owner check — controllers should
|
|
37
|
-
* set this when the caller is a platform admin, so super-admins can manage
|
|
38
|
-
* any tenant. The not-found path still fires when the tenantId is bogus.
|
|
39
|
-
*/
|
|
40
|
-
assertOwner(tenantId: string, userId: string, opts?: {
|
|
41
|
-
allowAnyOwner?: boolean;
|
|
42
|
-
}): Promise<Tenant>;
|
|
43
|
-
/**
|
|
44
|
-
* Look up (tenantId, externalId) → internal User. Creates the linkage on
|
|
45
|
-
* first use. Also creates a placeholder User row so the rest of the system
|
|
46
|
-
* (conversations, usage, billing) can reference it.
|
|
47
|
-
*/
|
|
48
|
-
ensureExternalUser(input: {
|
|
49
|
-
tenantId: string;
|
|
50
|
-
externalId: string;
|
|
51
|
-
email?: string;
|
|
52
|
-
name?: string;
|
|
53
|
-
}): Promise<ExternalUser>;
|
|
54
|
-
}
|