@auxiora/cloud 1.0.0 → 1.3.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/package.json +10 -4
- package/src/billing.ts +0 -142
- package/src/index.ts +0 -21
- package/src/isolation.ts +0 -69
- package/src/plans.ts +0 -84
- package/src/quotas.ts +0 -103
- package/src/tenant.ts +0 -140
- package/src/types.ts +0 -114
- package/tests/billing.test.ts +0 -126
- package/tests/isolation.test.ts +0 -95
- package/tests/plans.test.ts +0 -70
- package/tests/quotas.test.ts +0 -115
- package/tests/tenant.test.ts +0 -149
- package/tsconfig.json +0 -13
- package/tsconfig.tsbuildinfo +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@auxiora/cloud",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Multi-tenant cloud layer with isolation, billing, and quotas",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,13 +12,19 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@auxiora/core": "1.
|
|
16
|
-
"@auxiora/logger": "1.
|
|
17
|
-
"@auxiora/audit": "1.
|
|
15
|
+
"@auxiora/core": "1.3.0",
|
|
16
|
+
"@auxiora/logger": "1.3.0",
|
|
17
|
+
"@auxiora/audit": "1.3.0"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
20
|
"node": ">=22.0.0"
|
|
21
21
|
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist/"
|
|
27
|
+
],
|
|
22
28
|
"scripts": {
|
|
23
29
|
"build": "tsc",
|
|
24
30
|
"clean": "rm -rf dist",
|
package/src/billing.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import type { BillingInfo, StripeClient, TenantPlan } from './types.js';
|
|
2
|
-
import type { TenantManager } from './tenant.js';
|
|
3
|
-
import { getPlanDefinition } from './plans.js';
|
|
4
|
-
|
|
5
|
-
export interface BillingManagerOptions {
|
|
6
|
-
tenantManager: TenantManager;
|
|
7
|
-
stripe?: StripeClient;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* BillingManager handles Stripe integration for subscription management.
|
|
12
|
-
* Uses dependency injection for the Stripe SDK to enable easy testing.
|
|
13
|
-
*/
|
|
14
|
-
export class BillingManager {
|
|
15
|
-
private tenantManager: TenantManager;
|
|
16
|
-
private stripe?: StripeClient;
|
|
17
|
-
|
|
18
|
-
constructor(options: BillingManagerOptions) {
|
|
19
|
-
this.tenantManager = options.tenantManager;
|
|
20
|
-
this.stripe = options.stripe;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Create a Stripe customer for a tenant.
|
|
25
|
-
*/
|
|
26
|
-
async createCustomer(tenantId: string): Promise<string> {
|
|
27
|
-
if (!this.stripe) {
|
|
28
|
-
throw new Error('Stripe not configured');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const tenant = await this.tenantManager.get(tenantId);
|
|
32
|
-
const customer = await this.stripe.createCustomer(tenant.email, tenant.name);
|
|
33
|
-
|
|
34
|
-
await this.tenantManager.update(tenantId, {} as any);
|
|
35
|
-
// Store stripeCustomerId directly
|
|
36
|
-
const updated = await this.tenantManager.get(tenantId);
|
|
37
|
-
(updated as any).stripeCustomerId = customer.id;
|
|
38
|
-
|
|
39
|
-
return customer.id;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Create a subscription for a tenant.
|
|
44
|
-
*/
|
|
45
|
-
async createSubscription(tenantId: string, plan: TenantPlan): Promise<{ subscriptionId: string; status: string }> {
|
|
46
|
-
if (!this.stripe) {
|
|
47
|
-
throw new Error('Stripe not configured');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const tenant = await this.tenantManager.get(tenantId);
|
|
51
|
-
if (!tenant.stripeCustomerId) {
|
|
52
|
-
throw new Error('No Stripe customer ID. Call createCustomer first.');
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const planDef = getPlanDefinition(plan);
|
|
56
|
-
const priceId = `price_${plan}_monthly`;
|
|
57
|
-
|
|
58
|
-
const subscription = await this.stripe.createSubscription(tenant.stripeCustomerId, priceId);
|
|
59
|
-
|
|
60
|
-
// Update tenant plan
|
|
61
|
-
await this.tenantManager.update(tenantId, { plan });
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
subscriptionId: subscription.id,
|
|
65
|
-
status: subscription.status,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Cancel a tenant's subscription.
|
|
71
|
-
*/
|
|
72
|
-
async cancelSubscription(tenantId: string): Promise<void> {
|
|
73
|
-
if (!this.stripe) {
|
|
74
|
-
throw new Error('Stripe not configured');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const tenant = await this.tenantManager.get(tenantId);
|
|
78
|
-
if (!tenant.stripeSubscriptionId) {
|
|
79
|
-
throw new Error('No active subscription');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
await this.stripe.cancelSubscription(tenant.stripeSubscriptionId);
|
|
83
|
-
await this.tenantManager.update(tenantId, { plan: 'free' });
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Get billing info for a tenant.
|
|
88
|
-
*/
|
|
89
|
-
async getBillingInfo(tenantId: string): Promise<BillingInfo> {
|
|
90
|
-
const tenant = await this.tenantManager.get(tenantId);
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
tenantId,
|
|
94
|
-
plan: tenant.plan,
|
|
95
|
-
stripeCustomerId: tenant.stripeCustomerId,
|
|
96
|
-
stripeSubscriptionId: tenant.stripeSubscriptionId,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Get invoices for a tenant.
|
|
102
|
-
*/
|
|
103
|
-
async getInvoices(tenantId: string): Promise<Array<{ id: string; amount: number; status: string; created: string }>> {
|
|
104
|
-
if (!this.stripe) {
|
|
105
|
-
return [];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const tenant = await this.tenantManager.get(tenantId);
|
|
109
|
-
if (!tenant.stripeCustomerId) {
|
|
110
|
-
return [];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return this.stripe.getInvoices(tenant.stripeCustomerId);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Handle a Stripe webhook event.
|
|
118
|
-
*/
|
|
119
|
-
async handleWebhookEvent(eventType: string, data: Record<string, unknown>): Promise<void> {
|
|
120
|
-
switch (eventType) {
|
|
121
|
-
case 'customer.subscription.deleted': {
|
|
122
|
-
const customerId = data.customer as string;
|
|
123
|
-
// Find tenant by customer ID and downgrade
|
|
124
|
-
const tenants = await this.tenantManager.list();
|
|
125
|
-
const tenant = tenants.find(t => t.stripeCustomerId === customerId);
|
|
126
|
-
if (tenant) {
|
|
127
|
-
await this.tenantManager.update(tenant.id, { plan: 'free' });
|
|
128
|
-
}
|
|
129
|
-
break;
|
|
130
|
-
}
|
|
131
|
-
case 'invoice.payment_failed': {
|
|
132
|
-
const customerId = data.customer as string;
|
|
133
|
-
const tenants = await this.tenantManager.list();
|
|
134
|
-
const tenant = tenants.find(t => t.stripeCustomerId === customerId);
|
|
135
|
-
if (tenant) {
|
|
136
|
-
await this.tenantManager.suspend(tenant.id, 'Payment failed');
|
|
137
|
-
}
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
export { TenantManager, type TenantManagerOptions } from './tenant.js';
|
|
2
|
-
export { TenantIsolation, type TenantContext, type IsolationOptions } from './isolation.js';
|
|
3
|
-
export { QuotaEnforcer, type QuotaEnforcerOptions } from './quotas.js';
|
|
4
|
-
export { BillingManager, type BillingManagerOptions } from './billing.js';
|
|
5
|
-
export { getPlanDefinition, getPlanQuotas, getAllPlans, isValidPlan } from './plans.js';
|
|
6
|
-
export type {
|
|
7
|
-
Tenant,
|
|
8
|
-
TenantPlan,
|
|
9
|
-
TenantStatus,
|
|
10
|
-
TenantQuotas,
|
|
11
|
-
UsageRecord,
|
|
12
|
-
BillingInfo,
|
|
13
|
-
CloudConfig,
|
|
14
|
-
PlanDefinition,
|
|
15
|
-
StripeClient,
|
|
16
|
-
} from './types.js';
|
|
17
|
-
export {
|
|
18
|
-
QuotaExceededError,
|
|
19
|
-
TenantNotFoundError,
|
|
20
|
-
TenantSuspendedError,
|
|
21
|
-
} from './types.js';
|
package/src/isolation.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import type { Tenant, CloudConfig } from './types.js';
|
|
2
|
-
import { TenantNotFoundError, TenantSuspendedError } from './types.js';
|
|
3
|
-
import type { TenantManager } from './tenant.js';
|
|
4
|
-
|
|
5
|
-
export interface TenantContext {
|
|
6
|
-
tenantId: string;
|
|
7
|
-
tenant: Tenant;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface IsolationOptions {
|
|
11
|
-
tenantManager: TenantManager;
|
|
12
|
-
config: CloudConfig;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* TenantIsolation provides middleware-like extraction of tenant context
|
|
17
|
-
* from JWT tokens and scoping of all operations to the tenant's data.
|
|
18
|
-
*/
|
|
19
|
-
export class TenantIsolation {
|
|
20
|
-
private tenantManager: TenantManager;
|
|
21
|
-
|
|
22
|
-
constructor(options: IsolationOptions) {
|
|
23
|
-
this.tenantManager = options.tenantManager;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Extract tenant context from a JWT payload.
|
|
28
|
-
* Validates the tenant exists and is active.
|
|
29
|
-
*/
|
|
30
|
-
async extractTenant(jwtPayload: { tenantId?: string; sub?: string }): Promise<TenantContext> {
|
|
31
|
-
const tenantId = jwtPayload.tenantId || jwtPayload.sub;
|
|
32
|
-
if (!tenantId) {
|
|
33
|
-
throw new TenantNotFoundError('unknown');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const tenant = await this.tenantManager.get(tenantId);
|
|
37
|
-
|
|
38
|
-
if (tenant.status === 'suspended') {
|
|
39
|
-
throw new TenantSuspendedError(tenantId);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (tenant.status === 'deleted') {
|
|
43
|
-
throw new TenantNotFoundError(tenantId);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return { tenantId, tenant };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Scope a file path to the tenant's data directory.
|
|
51
|
-
* Prevents path traversal outside the tenant's sandbox.
|
|
52
|
-
*/
|
|
53
|
-
scopePath(tenant: Tenant, relativePath: string): string {
|
|
54
|
-
const resolved = new URL(relativePath, `file://${tenant.dataDir}/`).pathname;
|
|
55
|
-
|
|
56
|
-
if (!resolved.startsWith(tenant.dataDir)) {
|
|
57
|
-
throw new Error('Path traversal attempt detected');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return resolved;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Validate that a tenant can access a given resource.
|
|
65
|
-
*/
|
|
66
|
-
validateAccess(context: TenantContext, resourceTenantId: string): boolean {
|
|
67
|
-
return context.tenantId === resourceTenantId;
|
|
68
|
-
}
|
|
69
|
-
}
|
package/src/plans.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import type { PlanDefinition, TenantPlan, TenantQuotas } from './types.js';
|
|
2
|
-
|
|
3
|
-
const PLAN_DEFINITIONS: Record<TenantPlan, PlanDefinition> = {
|
|
4
|
-
free: {
|
|
5
|
-
plan: 'free',
|
|
6
|
-
name: 'Free',
|
|
7
|
-
priceMonthly: 0,
|
|
8
|
-
priceYearly: 0,
|
|
9
|
-
quotas: {
|
|
10
|
-
maxMessages: 100,
|
|
11
|
-
maxSessions: 3,
|
|
12
|
-
maxStorageMb: 50,
|
|
13
|
-
maxPlugins: 2,
|
|
14
|
-
maxBehaviors: 5,
|
|
15
|
-
maxChannels: 1,
|
|
16
|
-
maxAgents: 1,
|
|
17
|
-
},
|
|
18
|
-
features: ['Single channel', 'Basic vault', 'Community support'],
|
|
19
|
-
},
|
|
20
|
-
pro: {
|
|
21
|
-
plan: 'pro',
|
|
22
|
-
name: 'Pro',
|
|
23
|
-
priceMonthly: 19,
|
|
24
|
-
priceYearly: 190,
|
|
25
|
-
quotas: {
|
|
26
|
-
maxMessages: 5000,
|
|
27
|
-
maxSessions: 20,
|
|
28
|
-
maxStorageMb: 1024,
|
|
29
|
-
maxPlugins: 20,
|
|
30
|
-
maxBehaviors: 50,
|
|
31
|
-
maxChannels: 5,
|
|
32
|
-
maxAgents: 3,
|
|
33
|
-
},
|
|
34
|
-
features: ['Multi-channel', 'Cloud vault', 'Priority support', 'Custom behaviors'],
|
|
35
|
-
},
|
|
36
|
-
team: {
|
|
37
|
-
plan: 'team',
|
|
38
|
-
name: 'Team',
|
|
39
|
-
priceMonthly: 49,
|
|
40
|
-
priceYearly: 490,
|
|
41
|
-
quotas: {
|
|
42
|
-
maxMessages: 25000,
|
|
43
|
-
maxSessions: 100,
|
|
44
|
-
maxStorageMb: 5120,
|
|
45
|
-
maxPlugins: 50,
|
|
46
|
-
maxBehaviors: 200,
|
|
47
|
-
maxChannels: 20,
|
|
48
|
-
maxAgents: 10,
|
|
49
|
-
},
|
|
50
|
-
features: ['All Pro features', 'Team collaboration', 'Orchestration', 'Audit logs'],
|
|
51
|
-
},
|
|
52
|
-
enterprise: {
|
|
53
|
-
plan: 'enterprise',
|
|
54
|
-
name: 'Enterprise',
|
|
55
|
-
priceMonthly: 199,
|
|
56
|
-
priceYearly: 1990,
|
|
57
|
-
quotas: {
|
|
58
|
-
maxMessages: -1, // unlimited
|
|
59
|
-
maxSessions: -1,
|
|
60
|
-
maxStorageMb: -1,
|
|
61
|
-
maxPlugins: -1,
|
|
62
|
-
maxBehaviors: -1,
|
|
63
|
-
maxChannels: -1,
|
|
64
|
-
maxAgents: -1,
|
|
65
|
-
},
|
|
66
|
-
features: ['All Team features', 'Unlimited usage', 'SLA', 'Dedicated support', 'SSO'],
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export function getPlanDefinition(plan: TenantPlan): PlanDefinition {
|
|
71
|
-
return PLAN_DEFINITIONS[plan];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function getPlanQuotas(plan: TenantPlan): TenantQuotas {
|
|
75
|
-
return PLAN_DEFINITIONS[plan].quotas;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function getAllPlans(): PlanDefinition[] {
|
|
79
|
-
return Object.values(PLAN_DEFINITIONS);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function isValidPlan(plan: string): plan is TenantPlan {
|
|
83
|
-
return plan in PLAN_DEFINITIONS;
|
|
84
|
-
}
|
package/src/quotas.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import type { TenantQuotas, UsageRecord } from './types.js';
|
|
2
|
-
import { QuotaExceededError } from './types.js';
|
|
3
|
-
import { getPlanQuotas } from './plans.js';
|
|
4
|
-
import type { TenantManager } from './tenant.js';
|
|
5
|
-
|
|
6
|
-
export interface QuotaEnforcerOptions {
|
|
7
|
-
tenantManager: TenantManager;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* QuotaEnforcer tracks usage against plan limits
|
|
12
|
-
* and throws QuotaExceededError when limits are breached.
|
|
13
|
-
*/
|
|
14
|
-
export class QuotaEnforcer {
|
|
15
|
-
private usage = new Map<string, Map<string, number>>();
|
|
16
|
-
private tenantManager: TenantManager;
|
|
17
|
-
|
|
18
|
-
constructor(options: QuotaEnforcerOptions) {
|
|
19
|
-
this.tenantManager = options.tenantManager;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Check whether a metric increment would exceed the quota.
|
|
24
|
-
* Throws QuotaExceededError if the limit would be breached.
|
|
25
|
-
*/
|
|
26
|
-
async check(tenantId: string, metric: keyof TenantQuotas, increment = 1): Promise<void> {
|
|
27
|
-
const tenant = await this.tenantManager.get(tenantId);
|
|
28
|
-
const quotas = getPlanQuotas(tenant.plan);
|
|
29
|
-
const limit = quotas[metric];
|
|
30
|
-
|
|
31
|
-
// -1 means unlimited
|
|
32
|
-
if (limit === -1) return;
|
|
33
|
-
|
|
34
|
-
const current = this.getUsage(tenantId, metric);
|
|
35
|
-
if (current + increment > limit) {
|
|
36
|
-
throw new QuotaExceededError(metric, limit, current + increment);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Record usage for a metric.
|
|
42
|
-
*/
|
|
43
|
-
record(tenantId: string, metric: string, value = 1): UsageRecord {
|
|
44
|
-
let tenantUsage = this.usage.get(tenantId);
|
|
45
|
-
if (!tenantUsage) {
|
|
46
|
-
tenantUsage = new Map();
|
|
47
|
-
this.usage.set(tenantId, tenantUsage);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const current = tenantUsage.get(metric) ?? 0;
|
|
51
|
-
tenantUsage.set(metric, current + value);
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
tenantId,
|
|
55
|
-
metric,
|
|
56
|
-
value: current + value,
|
|
57
|
-
timestamp: new Date().toISOString(),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Get current usage for a specific metric.
|
|
63
|
-
*/
|
|
64
|
-
getUsage(tenantId: string, metric: string): number {
|
|
65
|
-
return this.usage.get(tenantId)?.get(metric) ?? 0;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Get all usage for a tenant.
|
|
70
|
-
*/
|
|
71
|
-
getAllUsage(tenantId: string): Record<string, number> {
|
|
72
|
-
const tenantUsage = this.usage.get(tenantId);
|
|
73
|
-
if (!tenantUsage) return {};
|
|
74
|
-
|
|
75
|
-
const result: Record<string, number> = {};
|
|
76
|
-
for (const [metric, value] of tenantUsage) {
|
|
77
|
-
result[metric] = value;
|
|
78
|
-
}
|
|
79
|
-
return result;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Reset usage for a tenant (e.g., at billing period start).
|
|
84
|
-
*/
|
|
85
|
-
resetUsage(tenantId: string): void {
|
|
86
|
-
this.usage.delete(tenantId);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Get usage as a percentage of the quota limit.
|
|
91
|
-
*/
|
|
92
|
-
async getUsagePercentage(tenantId: string, metric: keyof TenantQuotas): Promise<number> {
|
|
93
|
-
const tenant = await this.tenantManager.get(tenantId);
|
|
94
|
-
const quotas = getPlanQuotas(tenant.plan);
|
|
95
|
-
const limit = quotas[metric];
|
|
96
|
-
|
|
97
|
-
if (limit === -1) return 0;
|
|
98
|
-
if (limit === 0) return 100;
|
|
99
|
-
|
|
100
|
-
const current = this.getUsage(tenantId, metric);
|
|
101
|
-
return Math.round((current / limit) * 100);
|
|
102
|
-
}
|
|
103
|
-
}
|
package/src/tenant.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import * as fs from 'node:fs/promises';
|
|
2
|
-
import * as path from 'node:path';
|
|
3
|
-
import * as crypto from 'node:crypto';
|
|
4
|
-
import type { Tenant, TenantPlan, CloudConfig } from './types.js';
|
|
5
|
-
import { TenantNotFoundError } from './types.js';
|
|
6
|
-
import { getPlanQuotas } from './plans.js';
|
|
7
|
-
|
|
8
|
-
export interface TenantManagerOptions {
|
|
9
|
-
config: CloudConfig;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export class TenantManager {
|
|
13
|
-
private tenants = new Map<string, Tenant>();
|
|
14
|
-
private config: CloudConfig;
|
|
15
|
-
|
|
16
|
-
constructor(options: TenantManagerOptions) {
|
|
17
|
-
this.config = options.config;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async create(email: string, name: string, plan: TenantPlan = 'free'): Promise<Tenant> {
|
|
21
|
-
const id = `tenant-${crypto.randomUUID()}`;
|
|
22
|
-
const dataDir = path.join(this.config.baseDataDir, id);
|
|
23
|
-
|
|
24
|
-
await fs.mkdir(dataDir, { recursive: true });
|
|
25
|
-
|
|
26
|
-
const tenant: Tenant = {
|
|
27
|
-
id,
|
|
28
|
-
name,
|
|
29
|
-
email,
|
|
30
|
-
plan,
|
|
31
|
-
status: 'active',
|
|
32
|
-
dataDir,
|
|
33
|
-
createdAt: new Date().toISOString(),
|
|
34
|
-
updatedAt: new Date().toISOString(),
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
this.tenants.set(id, tenant);
|
|
38
|
-
await this.persistTenant(tenant);
|
|
39
|
-
return tenant;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async get(id: string): Promise<Tenant> {
|
|
43
|
-
const tenant = this.tenants.get(id);
|
|
44
|
-
if (!tenant) {
|
|
45
|
-
throw new TenantNotFoundError(id);
|
|
46
|
-
}
|
|
47
|
-
return tenant;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async getByEmail(email: string): Promise<Tenant | null> {
|
|
51
|
-
for (const tenant of this.tenants.values()) {
|
|
52
|
-
if (tenant.email === email) {
|
|
53
|
-
return tenant;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async update(id: string, updates: Partial<Pick<Tenant, 'name' | 'email' | 'plan'>>): Promise<Tenant> {
|
|
60
|
-
const tenant = await this.get(id);
|
|
61
|
-
|
|
62
|
-
if (updates.name !== undefined) tenant.name = updates.name;
|
|
63
|
-
if (updates.email !== undefined) tenant.email = updates.email;
|
|
64
|
-
if (updates.plan !== undefined) tenant.plan = updates.plan;
|
|
65
|
-
tenant.updatedAt = new Date().toISOString();
|
|
66
|
-
|
|
67
|
-
this.tenants.set(id, tenant);
|
|
68
|
-
await this.persistTenant(tenant);
|
|
69
|
-
return tenant;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async suspend(id: string, reason: string): Promise<Tenant> {
|
|
73
|
-
const tenant = await this.get(id);
|
|
74
|
-
tenant.status = 'suspended';
|
|
75
|
-
tenant.suspendedAt = new Date().toISOString();
|
|
76
|
-
tenant.suspendReason = reason;
|
|
77
|
-
tenant.updatedAt = new Date().toISOString();
|
|
78
|
-
|
|
79
|
-
this.tenants.set(id, tenant);
|
|
80
|
-
await this.persistTenant(tenant);
|
|
81
|
-
return tenant;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async reactivate(id: string): Promise<Tenant> {
|
|
85
|
-
const tenant = await this.get(id);
|
|
86
|
-
tenant.status = 'active';
|
|
87
|
-
tenant.suspendedAt = undefined;
|
|
88
|
-
tenant.suspendReason = undefined;
|
|
89
|
-
tenant.updatedAt = new Date().toISOString();
|
|
90
|
-
|
|
91
|
-
this.tenants.set(id, tenant);
|
|
92
|
-
await this.persistTenant(tenant);
|
|
93
|
-
return tenant;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async delete(id: string): Promise<boolean> {
|
|
97
|
-
const tenant = this.tenants.get(id);
|
|
98
|
-
if (!tenant) return false;
|
|
99
|
-
|
|
100
|
-
tenant.status = 'deleted';
|
|
101
|
-
tenant.updatedAt = new Date().toISOString();
|
|
102
|
-
this.tenants.set(id, tenant);
|
|
103
|
-
await this.persistTenant(tenant);
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async list(): Promise<Tenant[]> {
|
|
108
|
-
return Array.from(this.tenants.values()).filter(t => t.status !== 'deleted');
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
getQuotas(plan: TenantPlan) {
|
|
112
|
-
return getPlanQuotas(plan);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private async persistTenant(tenant: Tenant): Promise<void> {
|
|
116
|
-
const tenantFile = path.join(tenant.dataDir, 'tenant.json');
|
|
117
|
-
await fs.mkdir(path.dirname(tenantFile), { recursive: true });
|
|
118
|
-
await fs.writeFile(tenantFile, JSON.stringify(tenant, null, 2), 'utf-8');
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async loadFromDisk(): Promise<void> {
|
|
122
|
-
try {
|
|
123
|
-
const entries = await fs.readdir(this.config.baseDataDir, { withFileTypes: true });
|
|
124
|
-
for (const entry of entries) {
|
|
125
|
-
if (entry.isDirectory() && entry.name.startsWith('tenant-')) {
|
|
126
|
-
const tenantFile = path.join(this.config.baseDataDir, entry.name, 'tenant.json');
|
|
127
|
-
try {
|
|
128
|
-
const content = await fs.readFile(tenantFile, 'utf-8');
|
|
129
|
-
const tenant = JSON.parse(content) as Tenant;
|
|
130
|
-
this.tenants.set(tenant.id, tenant);
|
|
131
|
-
} catch {
|
|
132
|
-
// Skip corrupt tenant files
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
} catch {
|
|
137
|
-
// Base data dir doesn't exist yet
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/** Tenant plan tiers */
|
|
2
|
-
export type TenantPlan = 'free' | 'pro' | 'team' | 'enterprise';
|
|
3
|
-
|
|
4
|
-
/** Tenant status */
|
|
5
|
-
export type TenantStatus = 'active' | 'suspended' | 'pending' | 'deleted';
|
|
6
|
-
|
|
7
|
-
/** A cloud tenant */
|
|
8
|
-
export interface Tenant {
|
|
9
|
-
id: string;
|
|
10
|
-
name: string;
|
|
11
|
-
email: string;
|
|
12
|
-
plan: TenantPlan;
|
|
13
|
-
status: TenantStatus;
|
|
14
|
-
stripeCustomerId?: string;
|
|
15
|
-
stripeSubscriptionId?: string;
|
|
16
|
-
dataDir: string;
|
|
17
|
-
createdAt: string;
|
|
18
|
-
updatedAt: string;
|
|
19
|
-
suspendedAt?: string;
|
|
20
|
-
suspendReason?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Quota limits for a given plan */
|
|
24
|
-
export interface TenantQuotas {
|
|
25
|
-
maxMessages: number;
|
|
26
|
-
maxSessions: number;
|
|
27
|
-
maxStorageMb: number;
|
|
28
|
-
maxPlugins: number;
|
|
29
|
-
maxBehaviors: number;
|
|
30
|
-
maxChannels: number;
|
|
31
|
-
maxAgents: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** A single usage record */
|
|
35
|
-
export interface UsageRecord {
|
|
36
|
-
tenantId: string;
|
|
37
|
-
metric: string;
|
|
38
|
-
value: number;
|
|
39
|
-
timestamp: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Billing information */
|
|
43
|
-
export interface BillingInfo {
|
|
44
|
-
tenantId: string;
|
|
45
|
-
plan: TenantPlan;
|
|
46
|
-
stripeCustomerId?: string;
|
|
47
|
-
stripeSubscriptionId?: string;
|
|
48
|
-
currentPeriodStart?: string;
|
|
49
|
-
currentPeriodEnd?: string;
|
|
50
|
-
paymentMethodLast4?: string;
|
|
51
|
-
paymentMethodBrand?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Cloud-specific configuration */
|
|
55
|
-
export interface CloudConfig {
|
|
56
|
-
enabled: boolean;
|
|
57
|
-
baseDataDir: string;
|
|
58
|
-
jwtSecret: string;
|
|
59
|
-
stripeSecretKey?: string;
|
|
60
|
-
stripeWebhookSecret?: string;
|
|
61
|
-
domain: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Plan definition with pricing */
|
|
65
|
-
export interface PlanDefinition {
|
|
66
|
-
plan: TenantPlan;
|
|
67
|
-
name: string;
|
|
68
|
-
priceMonthly: number;
|
|
69
|
-
priceYearly: number;
|
|
70
|
-
quotas: TenantQuotas;
|
|
71
|
-
features: string[];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Stripe SDK interface for dependency injection */
|
|
75
|
-
export interface StripeClient {
|
|
76
|
-
createCustomer(email: string, name: string): Promise<{ id: string }>;
|
|
77
|
-
createSubscription(customerId: string, priceId: string): Promise<{ id: string; status: string }>;
|
|
78
|
-
cancelSubscription(subscriptionId: string): Promise<void>;
|
|
79
|
-
getInvoices(customerId: string): Promise<Array<{
|
|
80
|
-
id: string;
|
|
81
|
-
amount: number;
|
|
82
|
-
status: string;
|
|
83
|
-
created: string;
|
|
84
|
-
}>>;
|
|
85
|
-
createPaymentIntent(customerId: string, amount: number): Promise<{ clientSecret: string }>;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Quota exceeded error */
|
|
89
|
-
export class QuotaExceededError extends Error {
|
|
90
|
-
constructor(
|
|
91
|
-
public readonly metric: string,
|
|
92
|
-
public readonly limit: number,
|
|
93
|
-
public readonly current: number,
|
|
94
|
-
) {
|
|
95
|
-
super(`Quota exceeded for ${metric}: ${current}/${limit}`);
|
|
96
|
-
this.name = 'QuotaExceededError';
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Tenant not found error */
|
|
101
|
-
export class TenantNotFoundError extends Error {
|
|
102
|
-
constructor(tenantId: string) {
|
|
103
|
-
super(`Tenant not found: ${tenantId}`);
|
|
104
|
-
this.name = 'TenantNotFoundError';
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Tenant suspended error */
|
|
109
|
-
export class TenantSuspendedError extends Error {
|
|
110
|
-
constructor(tenantId: string) {
|
|
111
|
-
super(`Tenant is suspended: ${tenantId}`);
|
|
112
|
-
this.name = 'TenantSuspendedError';
|
|
113
|
-
}
|
|
114
|
-
}
|