@auxiora/cloud 1.0.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/LICENSE +191 -0
- package/dist/billing.d.ts +48 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +115 -0
- package/dist/billing.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/isolation.d.ts +36 -0
- package/dist/isolation.d.ts.map +1 -0
- package/dist/isolation.js +47 -0
- package/dist/isolation.js.map +1 -0
- package/dist/plans.d.ts +6 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +79 -0
- package/dist/plans.js.map +1 -0
- package/dist/quotas.d.ts +40 -0
- package/dist/quotas.d.ts.map +1 -0
- package/dist/quotas.js +87 -0
- package/dist/quotas.js.map +1 -0
- package/dist/tenant.d.ts +21 -0
- package/dist/tenant.d.ts.map +1 -0
- package/dist/tenant.js +121 -0
- package/dist/tenant.js.map +1 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +28 -0
- package/dist/types.js.map +1 -0
- package/package.json +27 -0
- package/src/billing.ts +142 -0
- package/src/index.ts +21 -0
- package/src/isolation.ts +69 -0
- package/src/plans.ts +84 -0
- package/src/quotas.ts +103 -0
- package/src/tenant.ts +140 -0
- package/src/types.ts +114 -0
- package/tests/billing.test.ts +126 -0
- package/tests/isolation.test.ts +95 -0
- package/tests/plans.test.ts +70 -0
- package/tests/quotas.test.ts +115 -0
- package/tests/tenant.test.ts +149 -0
- package/tsconfig.json +13 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/plans.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { TenantManager } from '../src/tenant.js';
|
|
6
|
+
import { BillingManager } from '../src/billing.js';
|
|
7
|
+
import type { CloudConfig, StripeClient } from '../src/types.js';
|
|
8
|
+
|
|
9
|
+
function makeConfig(baseDataDir: string): CloudConfig {
|
|
10
|
+
return {
|
|
11
|
+
enabled: true,
|
|
12
|
+
baseDataDir,
|
|
13
|
+
jwtSecret: 'test-secret-32-chars-long-enough!',
|
|
14
|
+
domain: 'test.auxiora.cloud',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createMockStripe(): StripeClient {
|
|
19
|
+
return {
|
|
20
|
+
createCustomer: vi.fn().mockResolvedValue({ id: 'cus_test123' }),
|
|
21
|
+
createSubscription: vi.fn().mockResolvedValue({ id: 'sub_test123', status: 'active' }),
|
|
22
|
+
cancelSubscription: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
getInvoices: vi.fn().mockResolvedValue([
|
|
24
|
+
{ id: 'inv_1', amount: 1900, status: 'paid', created: '2025-01-01T00:00:00Z' },
|
|
25
|
+
]),
|
|
26
|
+
createPaymentIntent: vi.fn().mockResolvedValue({ clientSecret: 'pi_secret' }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('BillingManager', () => {
|
|
31
|
+
let tenantManager: TenantManager;
|
|
32
|
+
let billing: BillingManager;
|
|
33
|
+
let mockStripe: StripeClient;
|
|
34
|
+
let tmpDir: string;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cloud-billing-'));
|
|
38
|
+
tenantManager = new TenantManager({ config: makeConfig(tmpDir) });
|
|
39
|
+
mockStripe = createMockStripe();
|
|
40
|
+
billing = new BillingManager({ tenantManager, stripe: mockStripe });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('createCustomer', () => {
|
|
48
|
+
it('should create a Stripe customer', async () => {
|
|
49
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
50
|
+
const customerId = await billing.createCustomer(tenant.id);
|
|
51
|
+
expect(customerId).toBe('cus_test123');
|
|
52
|
+
expect(mockStripe.createCustomer).toHaveBeenCalledWith('alice@test.com', 'Alice');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should throw if Stripe not configured', async () => {
|
|
56
|
+
const billingNoStripe = new BillingManager({ tenantManager });
|
|
57
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
58
|
+
await expect(billingNoStripe.createCustomer(tenant.id)).rejects.toThrow('Stripe not configured');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('createSubscription', () => {
|
|
63
|
+
it('should create a subscription', async () => {
|
|
64
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
65
|
+
// Manually set stripeCustomerId
|
|
66
|
+
(tenant as any).stripeCustomerId = 'cus_test123';
|
|
67
|
+
|
|
68
|
+
const result = await billing.createSubscription(tenant.id, 'pro');
|
|
69
|
+
expect(result.subscriptionId).toBe('sub_test123');
|
|
70
|
+
expect(result.status).toBe('active');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should throw without customer ID', async () => {
|
|
74
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
75
|
+
await expect(billing.createSubscription(tenant.id, 'pro')).rejects.toThrow('No Stripe customer ID');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('getBillingInfo', () => {
|
|
80
|
+
it('should return billing info', async () => {
|
|
81
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice', 'pro');
|
|
82
|
+
const info = await billing.getBillingInfo(tenant.id);
|
|
83
|
+
expect(info.tenantId).toBe(tenant.id);
|
|
84
|
+
expect(info.plan).toBe('pro');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('getInvoices', () => {
|
|
89
|
+
it('should return invoices from Stripe', async () => {
|
|
90
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
91
|
+
(tenant as any).stripeCustomerId = 'cus_test123';
|
|
92
|
+
|
|
93
|
+
const invoices = await billing.getInvoices(tenant.id);
|
|
94
|
+
expect(invoices).toHaveLength(1);
|
|
95
|
+
expect(invoices[0].id).toBe('inv_1');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should return empty for tenant without customer', async () => {
|
|
99
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
100
|
+
const invoices = await billing.getInvoices(tenant.id);
|
|
101
|
+
expect(invoices).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('handleWebhookEvent', () => {
|
|
106
|
+
it('should downgrade on subscription deleted', async () => {
|
|
107
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice', 'pro');
|
|
108
|
+
(tenant as any).stripeCustomerId = 'cus_alice';
|
|
109
|
+
|
|
110
|
+
await billing.handleWebhookEvent('customer.subscription.deleted', { customer: 'cus_alice' });
|
|
111
|
+
|
|
112
|
+
const updated = await tenantManager.get(tenant.id);
|
|
113
|
+
expect(updated.plan).toBe('free');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should suspend on payment failed', async () => {
|
|
117
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice', 'pro');
|
|
118
|
+
(tenant as any).stripeCustomerId = 'cus_alice';
|
|
119
|
+
|
|
120
|
+
await billing.handleWebhookEvent('invoice.payment_failed', { customer: 'cus_alice' });
|
|
121
|
+
|
|
122
|
+
const updated = await tenantManager.get(tenant.id);
|
|
123
|
+
expect(updated.status).toBe('suspended');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { TenantManager } from '../src/tenant.js';
|
|
6
|
+
import { TenantIsolation } from '../src/isolation.js';
|
|
7
|
+
import { TenantNotFoundError, TenantSuspendedError } from '../src/types.js';
|
|
8
|
+
import type { CloudConfig } from '../src/types.js';
|
|
9
|
+
|
|
10
|
+
function makeConfig(baseDataDir: string): CloudConfig {
|
|
11
|
+
return {
|
|
12
|
+
enabled: true,
|
|
13
|
+
baseDataDir,
|
|
14
|
+
jwtSecret: 'test-secret-32-chars-long-enough!',
|
|
15
|
+
domain: 'test.auxiora.cloud',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('TenantIsolation', () => {
|
|
20
|
+
let tenantManager: TenantManager;
|
|
21
|
+
let isolation: TenantIsolation;
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
let config: CloudConfig;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cloud-isolation-'));
|
|
27
|
+
config = makeConfig(tmpDir);
|
|
28
|
+
tenantManager = new TenantManager({ config });
|
|
29
|
+
isolation = new TenantIsolation({ tenantManager, config });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('extractTenant', () => {
|
|
37
|
+
it('should extract tenant from JWT payload with tenantId', async () => {
|
|
38
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
39
|
+
const context = await isolation.extractTenant({ tenantId: tenant.id });
|
|
40
|
+
expect(context.tenantId).toBe(tenant.id);
|
|
41
|
+
expect(context.tenant.email).toBe('alice@test.com');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should extract tenant from JWT payload with sub', async () => {
|
|
45
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
46
|
+
const context = await isolation.extractTenant({ sub: tenant.id });
|
|
47
|
+
expect(context.tenantId).toBe(tenant.id);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should throw for missing tenant ID', async () => {
|
|
51
|
+
await expect(isolation.extractTenant({})).rejects.toThrow(TenantNotFoundError);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should throw for suspended tenant', async () => {
|
|
55
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
56
|
+
await tenantManager.suspend(tenant.id, 'overdue');
|
|
57
|
+
await expect(isolation.extractTenant({ tenantId: tenant.id })).rejects.toThrow(TenantSuspendedError);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should throw for deleted tenant', async () => {
|
|
61
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
62
|
+
await tenantManager.delete(tenant.id);
|
|
63
|
+
await expect(isolation.extractTenant({ tenantId: tenant.id })).rejects.toThrow(TenantNotFoundError);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('scopePath', () => {
|
|
68
|
+
it('should resolve paths within tenant dir', async () => {
|
|
69
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
70
|
+
const scoped = isolation.scopePath(tenant, 'config/settings.json');
|
|
71
|
+
expect(scoped).toContain(tenant.id);
|
|
72
|
+
expect(scoped).toContain('config/settings.json');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should block path traversal', async () => {
|
|
76
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
77
|
+
expect(() => isolation.scopePath(tenant, '../../etc/passwd')).toThrow('Path traversal');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('validateAccess', () => {
|
|
82
|
+
it('should allow access to own resources', async () => {
|
|
83
|
+
const tenant = await tenantManager.create('alice@test.com', 'Alice');
|
|
84
|
+
const context = await isolation.extractTenant({ tenantId: tenant.id });
|
|
85
|
+
expect(isolation.validateAccess(context, tenant.id)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should deny access to other tenant resources', async () => {
|
|
89
|
+
const alice = await tenantManager.create('alice@test.com', 'Alice');
|
|
90
|
+
const bob = await tenantManager.create('bob@test.com', 'Bob');
|
|
91
|
+
const context = await isolation.extractTenant({ tenantId: alice.id });
|
|
92
|
+
expect(isolation.validateAccess(context, bob.id)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|