chainlesschain 0.47.8 → 0.49.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/bin/chainlesschain.js +0 -0
- package/package.json +10 -8
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/{AppLayout-6SPt_8Y_.js → AppLayout-Rvi759IS.js} +1 -1
- package/src/assets/web-panel/assets/Dashboard-BS-tzGNj.css +1 -0
- package/src/assets/web-panel/assets/{Dashboard-Br7kCwKJ.js → Dashboard-DBhFxXYQ.js} +2 -2
- package/src/assets/web-panel/assets/{index-tN-8TosE.js → index-uL0cZ8N_.js} +2 -2
- package/src/assets/web-panel/index.html +2 -2
- package/src/commands/activitypub.js +533 -0
- package/src/commands/codegen.js +303 -0
- package/src/commands/collab.js +482 -0
- package/src/commands/compliance.js +597 -6
- package/src/commands/crosschain.js +382 -0
- package/src/commands/dbevo.js +388 -0
- package/src/commands/dev.js +411 -0
- package/src/commands/federation.js +427 -0
- package/src/commands/fusion.js +332 -0
- package/src/commands/governance.js +505 -0
- package/src/commands/hardening.js +110 -0
- package/src/commands/incentive.js +373 -0
- package/src/commands/inference.js +304 -0
- package/src/commands/infra.js +361 -0
- package/src/commands/kg.js +371 -0
- package/src/commands/marketplace.js +326 -0
- package/src/commands/matrix.js +283 -0
- package/src/commands/mcp.js +441 -18
- package/src/commands/nlprog.js +329 -0
- package/src/commands/nostr.js +196 -7
- package/src/commands/ops.js +408 -0
- package/src/commands/perception.js +385 -0
- package/src/commands/pqc.js +34 -0
- package/src/commands/privacy.js +345 -0
- package/src/commands/quantization.js +280 -0
- package/src/commands/recommend.js +336 -0
- package/src/commands/reputation.js +349 -0
- package/src/commands/runtime.js +500 -0
- package/src/commands/sla.js +352 -0
- package/src/commands/social.js +265 -0
- package/src/commands/stress.js +252 -0
- package/src/commands/tech.js +268 -0
- package/src/commands/tenant.js +576 -0
- package/src/commands/trust.js +366 -0
- package/src/harness/mcp-client.js +330 -54
- package/src/index.js +114 -0
- package/src/lib/activitypub-bridge.js +623 -0
- package/src/lib/aiops.js +523 -0
- package/src/lib/autonomous-developer.js +524 -0
- package/src/lib/code-agent.js +442 -0
- package/src/lib/collaboration-governance.js +556 -0
- package/src/lib/community-governance.js +649 -0
- package/src/lib/compliance-framework-reporter.js +600 -0
- package/src/lib/content-recommendation.js +600 -0
- package/src/lib/cross-chain.js +669 -0
- package/src/lib/dbevo.js +669 -0
- package/src/lib/decentral-infra.js +445 -0
- package/src/lib/federation-hardening.js +587 -0
- package/src/lib/hardening-manager.js +409 -0
- package/src/lib/inference-network.js +407 -0
- package/src/lib/knowledge-graph.js +530 -0
- package/src/lib/matrix-bridge.js +252 -0
- package/src/lib/mcp-client.js +3 -0
- package/src/lib/mcp-registry.js +347 -0
- package/src/lib/mcp-scaffold.js +385 -0
- package/src/lib/multimodal.js +698 -0
- package/src/lib/nl-programming.js +595 -0
- package/src/lib/nostr-bridge.js +214 -38
- package/src/lib/perception.js +500 -0
- package/src/lib/pqc-manager.js +141 -9
- package/src/lib/privacy-computing.js +575 -0
- package/src/lib/protocol-fusion.js +535 -0
- package/src/lib/quantization.js +362 -0
- package/src/lib/reputation-optimizer.js +509 -0
- package/src/lib/skill-marketplace.js +397 -0
- package/src/lib/sla-manager.js +484 -0
- package/src/lib/social-graph.js +408 -0
- package/src/lib/stix-parser.js +167 -0
- package/src/lib/stress-tester.js +383 -0
- package/src/lib/tech-learning-engine.js +651 -0
- package/src/lib/tenant-saas.js +831 -0
- package/src/lib/threat-intel.js +268 -0
- package/src/lib/token-incentive.js +513 -0
- package/src/lib/topic-classifier.js +400 -0
- package/src/lib/trust-security.js +473 -0
- package/src/lib/ueba.js +403 -0
- package/src/lib/universal-runtime.js +771 -0
- package/src/assets/web-panel/assets/Dashboard-CKeMmCoT.css +0 -1
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant SaaS — CLI port of Phase 97 多租户SaaS引擎
|
|
3
|
+
* (docs/design/modules/62_多租户SaaS引擎.md).
|
|
4
|
+
*
|
|
5
|
+
* The Desktop build drives multi-tenancy with database-level isolation
|
|
6
|
+
* (separate SQLite file per tenant under `db_path`), subdomain routing,
|
|
7
|
+
* and a billing UI integrated with payment gateways. The CLI can't host
|
|
8
|
+
* subdomain routing or payment collection, so this port ships the
|
|
9
|
+
* tractable scaffolding:
|
|
10
|
+
*
|
|
11
|
+
* - TenantStore: create/configure/list/show/delete (soft by default).
|
|
12
|
+
* - UsageStore: record + aggregate by period + metric.
|
|
13
|
+
* - SubscriptionStore: create / get-active / cancel / list.
|
|
14
|
+
* - Plan catalog: 4 tiers (free / starter / pro / enterprise) with quotas.
|
|
15
|
+
* - Quota check: compares current-period usage against active-plan limits.
|
|
16
|
+
* - Import/Export: JSON snapshot of tenant + subs + usage.
|
|
17
|
+
* - Stats: per-plan distribution, totals per metric.
|
|
18
|
+
*
|
|
19
|
+
* What does NOT port: physical per-tenant database files (Desktop uses
|
|
20
|
+
* `db_path` — CLI uses logical `tenant_id` columns instead), subdomain
|
|
21
|
+
* routing, payment gateway integration, tenant admin dashboard.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import crypto from "crypto";
|
|
25
|
+
|
|
26
|
+
/* ── Plan Catalog ──────────────────────────────────────────── */
|
|
27
|
+
|
|
28
|
+
// Quotas copied from design doc §2.2. Enterprise uses Number.POSITIVE_INFINITY
|
|
29
|
+
// to mean "unlimited" — exported as null in JSON-facing results.
|
|
30
|
+
export const PLANS = Object.freeze({
|
|
31
|
+
free: Object.freeze({
|
|
32
|
+
id: "free",
|
|
33
|
+
name: "Free",
|
|
34
|
+
monthlyFee: 0,
|
|
35
|
+
quotas: Object.freeze({
|
|
36
|
+
api_calls: 1000,
|
|
37
|
+
storage_bytes: 100 * 1024 * 1024, // 100 MB
|
|
38
|
+
ai_requests: 50,
|
|
39
|
+
}),
|
|
40
|
+
features: Object.freeze(["basic"]),
|
|
41
|
+
}),
|
|
42
|
+
starter: Object.freeze({
|
|
43
|
+
id: "starter",
|
|
44
|
+
name: "Starter",
|
|
45
|
+
monthlyFee: 99,
|
|
46
|
+
quotas: Object.freeze({
|
|
47
|
+
api_calls: 10000,
|
|
48
|
+
storage_bytes: 1024 * 1024 * 1024, // 1 GB
|
|
49
|
+
ai_requests: 500,
|
|
50
|
+
}),
|
|
51
|
+
features: Object.freeze(["basic", "collaboration"]),
|
|
52
|
+
}),
|
|
53
|
+
pro: Object.freeze({
|
|
54
|
+
id: "pro",
|
|
55
|
+
name: "Pro",
|
|
56
|
+
monthlyFee: 399,
|
|
57
|
+
quotas: Object.freeze({
|
|
58
|
+
api_calls: 100000,
|
|
59
|
+
storage_bytes: 10 * 1024 * 1024 * 1024, // 10 GB
|
|
60
|
+
ai_requests: 5000,
|
|
61
|
+
}),
|
|
62
|
+
features: Object.freeze([
|
|
63
|
+
"basic",
|
|
64
|
+
"collaboration",
|
|
65
|
+
"advanced_analytics",
|
|
66
|
+
"custom_domain",
|
|
67
|
+
]),
|
|
68
|
+
}),
|
|
69
|
+
enterprise: Object.freeze({
|
|
70
|
+
id: "enterprise",
|
|
71
|
+
name: "Enterprise",
|
|
72
|
+
monthlyFee: null, // custom
|
|
73
|
+
quotas: Object.freeze({
|
|
74
|
+
api_calls: Number.POSITIVE_INFINITY,
|
|
75
|
+
storage_bytes: Number.POSITIVE_INFINITY,
|
|
76
|
+
ai_requests: Number.POSITIVE_INFINITY,
|
|
77
|
+
}),
|
|
78
|
+
features: Object.freeze([
|
|
79
|
+
"basic",
|
|
80
|
+
"collaboration",
|
|
81
|
+
"advanced_analytics",
|
|
82
|
+
"custom_domain",
|
|
83
|
+
"sso",
|
|
84
|
+
"sla",
|
|
85
|
+
"priority_support",
|
|
86
|
+
]),
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const METRICS = Object.freeze({
|
|
91
|
+
api_calls: Object.freeze({
|
|
92
|
+
id: "api_calls",
|
|
93
|
+
name: "API Calls",
|
|
94
|
+
unit: "calls",
|
|
95
|
+
}),
|
|
96
|
+
storage_bytes: Object.freeze({
|
|
97
|
+
id: "storage_bytes",
|
|
98
|
+
name: "Storage",
|
|
99
|
+
unit: "bytes",
|
|
100
|
+
}),
|
|
101
|
+
ai_requests: Object.freeze({
|
|
102
|
+
id: "ai_requests",
|
|
103
|
+
name: "AI Requests",
|
|
104
|
+
unit: "requests",
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const TENANT_STATUS = Object.freeze({
|
|
109
|
+
ACTIVE: "active",
|
|
110
|
+
SUSPENDED: "suspended",
|
|
111
|
+
DELETED: "deleted",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const SUBSCRIPTION_STATUS = Object.freeze({
|
|
115
|
+
ACTIVE: "active",
|
|
116
|
+
CANCELLED: "cancelled",
|
|
117
|
+
EXPIRED: "expired",
|
|
118
|
+
PAST_DUE: "past_due",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/* ── State ─────────────────────────────────────────────────── */
|
|
122
|
+
|
|
123
|
+
const _tenants = new Map(); // id → tenant
|
|
124
|
+
const _subscriptions = new Map(); // id → subscription
|
|
125
|
+
const _usage = new Map(); // id → usage record
|
|
126
|
+
// Secondary indexes
|
|
127
|
+
const _slugIndex = new Map(); // slug → tenantId
|
|
128
|
+
const _tenantSubs = new Map(); // tenantId → Set<subscriptionId>
|
|
129
|
+
const _tenantUsage = new Map(); // tenantId → Set<usageId>
|
|
130
|
+
let _seq = 0;
|
|
131
|
+
|
|
132
|
+
/* ── Schema ────────────────────────────────────────────────── */
|
|
133
|
+
|
|
134
|
+
export function ensureTenantTables(db) {
|
|
135
|
+
if (!db) return;
|
|
136
|
+
db.exec(`
|
|
137
|
+
CREATE TABLE IF NOT EXISTS saas_tenants (
|
|
138
|
+
id TEXT PRIMARY KEY,
|
|
139
|
+
name TEXT NOT NULL,
|
|
140
|
+
slug TEXT UNIQUE,
|
|
141
|
+
config TEXT,
|
|
142
|
+
status TEXT DEFAULT 'active',
|
|
143
|
+
plan TEXT DEFAULT 'free',
|
|
144
|
+
db_path TEXT,
|
|
145
|
+
owner_id TEXT,
|
|
146
|
+
created_at INTEGER NOT NULL,
|
|
147
|
+
updated_at INTEGER NOT NULL,
|
|
148
|
+
deleted_at INTEGER
|
|
149
|
+
)
|
|
150
|
+
`);
|
|
151
|
+
db.exec(`
|
|
152
|
+
CREATE TABLE IF NOT EXISTS saas_usage (
|
|
153
|
+
id TEXT PRIMARY KEY,
|
|
154
|
+
tenant_id TEXT NOT NULL,
|
|
155
|
+
metric TEXT NOT NULL,
|
|
156
|
+
value REAL NOT NULL,
|
|
157
|
+
period TEXT NOT NULL,
|
|
158
|
+
recorded_at INTEGER NOT NULL
|
|
159
|
+
)
|
|
160
|
+
`);
|
|
161
|
+
db.exec(`
|
|
162
|
+
CREATE TABLE IF NOT EXISTS saas_subscriptions (
|
|
163
|
+
id TEXT PRIMARY KEY,
|
|
164
|
+
tenant_id TEXT NOT NULL,
|
|
165
|
+
plan TEXT NOT NULL,
|
|
166
|
+
status TEXT DEFAULT 'active',
|
|
167
|
+
started_at INTEGER NOT NULL,
|
|
168
|
+
expires_at INTEGER,
|
|
169
|
+
cancelled_at INTEGER,
|
|
170
|
+
payment_method TEXT,
|
|
171
|
+
amount REAL,
|
|
172
|
+
created_at INTEGER NOT NULL
|
|
173
|
+
)
|
|
174
|
+
`);
|
|
175
|
+
db.exec(
|
|
176
|
+
`CREATE INDEX IF NOT EXISTS idx_saas_tenants_slug ON saas_tenants(slug)`,
|
|
177
|
+
);
|
|
178
|
+
db.exec(
|
|
179
|
+
`CREATE INDEX IF NOT EXISTS idx_saas_tenants_status ON saas_tenants(status)`,
|
|
180
|
+
);
|
|
181
|
+
db.exec(
|
|
182
|
+
`CREATE INDEX IF NOT EXISTS idx_saas_usage_tenant ON saas_usage(tenant_id, period)`,
|
|
183
|
+
);
|
|
184
|
+
db.exec(
|
|
185
|
+
`CREATE INDEX IF NOT EXISTS idx_saas_usage_metric ON saas_usage(metric, period)`,
|
|
186
|
+
);
|
|
187
|
+
db.exec(
|
|
188
|
+
`CREATE INDEX IF NOT EXISTS idx_saas_sub_tenant ON saas_subscriptions(tenant_id)`,
|
|
189
|
+
);
|
|
190
|
+
db.exec(
|
|
191
|
+
`CREATE INDEX IF NOT EXISTS idx_saas_sub_status ON saas_subscriptions(status)`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* ── Catalogs ──────────────────────────────────────────────── */
|
|
196
|
+
|
|
197
|
+
function _cloneQuotas(quotas) {
|
|
198
|
+
const out = {};
|
|
199
|
+
for (const [key, value] of Object.entries(quotas)) {
|
|
200
|
+
// Infinity is not JSON-safe — emit as null for external consumers.
|
|
201
|
+
out[key] = value === Number.POSITIVE_INFINITY ? null : value;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function listPlans() {
|
|
207
|
+
return Object.values(PLANS).map((p) => ({
|
|
208
|
+
id: p.id,
|
|
209
|
+
name: p.name,
|
|
210
|
+
monthlyFee: p.monthlyFee,
|
|
211
|
+
quotas: _cloneQuotas(p.quotas),
|
|
212
|
+
features: [...p.features],
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function getPlan(planId) {
|
|
217
|
+
const p = PLANS[planId];
|
|
218
|
+
if (!p) return null;
|
|
219
|
+
return {
|
|
220
|
+
id: p.id,
|
|
221
|
+
name: p.name,
|
|
222
|
+
monthlyFee: p.monthlyFee,
|
|
223
|
+
quotas: _cloneQuotas(p.quotas),
|
|
224
|
+
features: [...p.features],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function listMetrics() {
|
|
229
|
+
return Object.values(METRICS).map((m) => ({ ...m }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function _strip(row) {
|
|
233
|
+
const { _seq: _omit, ...rest } = row;
|
|
234
|
+
void _omit;
|
|
235
|
+
return rest;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _periodForNow(now) {
|
|
239
|
+
const d = new Date(now);
|
|
240
|
+
const year = d.getUTCFullYear();
|
|
241
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
242
|
+
return `${year}-${month}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ── Tenants ───────────────────────────────────────────────── */
|
|
246
|
+
|
|
247
|
+
function _persistTenant(db, tenant) {
|
|
248
|
+
if (!db) return;
|
|
249
|
+
db.prepare(
|
|
250
|
+
`INSERT OR REPLACE INTO saas_tenants
|
|
251
|
+
(id, name, slug, config, status, plan, db_path, owner_id,
|
|
252
|
+
created_at, updated_at, deleted_at)
|
|
253
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
254
|
+
).run(
|
|
255
|
+
tenant.id,
|
|
256
|
+
tenant.name,
|
|
257
|
+
tenant.slug,
|
|
258
|
+
tenant.config ? JSON.stringify(tenant.config) : null,
|
|
259
|
+
tenant.status,
|
|
260
|
+
tenant.plan,
|
|
261
|
+
tenant.dbPath || null,
|
|
262
|
+
tenant.ownerId || null,
|
|
263
|
+
tenant.createdAt,
|
|
264
|
+
tenant.updatedAt,
|
|
265
|
+
tenant.deletedAt || null,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function createTenant(db, config = {}) {
|
|
270
|
+
const name = String(config.name || "").trim();
|
|
271
|
+
if (!name) throw new Error("tenant name is required");
|
|
272
|
+
|
|
273
|
+
const slug = String(config.slug || "").trim();
|
|
274
|
+
if (!slug) throw new Error("tenant slug is required");
|
|
275
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
"tenant slug must be lowercase alphanumeric with optional hyphens",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (_slugIndex.has(slug)) {
|
|
281
|
+
throw new Error(`Tenant slug already exists: ${slug}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const plan = String(config.plan || "free");
|
|
285
|
+
if (!PLANS[plan]) {
|
|
286
|
+
throw new Error(`Unknown plan: ${plan}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const now = Number(config.now ?? Date.now());
|
|
290
|
+
const id = config.id || crypto.randomUUID();
|
|
291
|
+
|
|
292
|
+
if (_tenants.has(id)) {
|
|
293
|
+
throw new Error(`Tenant already exists: ${id}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const tenant = {
|
|
297
|
+
id,
|
|
298
|
+
name,
|
|
299
|
+
slug,
|
|
300
|
+
config: config.config || null,
|
|
301
|
+
status: TENANT_STATUS.ACTIVE,
|
|
302
|
+
plan,
|
|
303
|
+
dbPath: config.dbPath || null,
|
|
304
|
+
ownerId: config.ownerId || null,
|
|
305
|
+
createdAt: now,
|
|
306
|
+
updatedAt: now,
|
|
307
|
+
deletedAt: null,
|
|
308
|
+
_seq: ++_seq,
|
|
309
|
+
};
|
|
310
|
+
_tenants.set(id, tenant);
|
|
311
|
+
_slugIndex.set(slug, id);
|
|
312
|
+
_tenantSubs.set(id, new Set());
|
|
313
|
+
_tenantUsage.set(id, new Set());
|
|
314
|
+
_persistTenant(db, tenant);
|
|
315
|
+
return _strip(tenant);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function configureTenant(db, id, updates = {}) {
|
|
319
|
+
const tenant = _tenants.get(String(id || ""));
|
|
320
|
+
if (!tenant) throw new Error(`Tenant not found: ${id}`);
|
|
321
|
+
if (tenant.status === TENANT_STATUS.DELETED) {
|
|
322
|
+
throw new Error(`Cannot configure deleted tenant: ${id}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const now = Number(updates.now ?? Date.now());
|
|
326
|
+
if ("config" in updates) {
|
|
327
|
+
tenant.config = updates.config || null;
|
|
328
|
+
}
|
|
329
|
+
if ("status" in updates && updates.status !== undefined) {
|
|
330
|
+
const status = String(updates.status);
|
|
331
|
+
if (!Object.values(TENANT_STATUS).includes(status)) {
|
|
332
|
+
throw new Error(`Unknown tenant status: ${status}`);
|
|
333
|
+
}
|
|
334
|
+
tenant.status = status;
|
|
335
|
+
}
|
|
336
|
+
if ("plan" in updates && updates.plan !== undefined) {
|
|
337
|
+
const plan = String(updates.plan);
|
|
338
|
+
if (!PLANS[plan]) throw new Error(`Unknown plan: ${plan}`);
|
|
339
|
+
tenant.plan = plan;
|
|
340
|
+
}
|
|
341
|
+
if ("name" in updates && updates.name !== undefined) {
|
|
342
|
+
const name = String(updates.name).trim();
|
|
343
|
+
if (!name) throw new Error("tenant name cannot be empty");
|
|
344
|
+
tenant.name = name;
|
|
345
|
+
}
|
|
346
|
+
tenant.updatedAt = now;
|
|
347
|
+
_persistTenant(db, tenant);
|
|
348
|
+
return _strip(tenant);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function getTenant(id) {
|
|
352
|
+
const t = _tenants.get(String(id || ""));
|
|
353
|
+
return t ? _strip(t) : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function getTenantBySlug(slug) {
|
|
357
|
+
const id = _slugIndex.get(String(slug || ""));
|
|
358
|
+
return id ? getTenant(id) : null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function listTenants(options = {}) {
|
|
362
|
+
const rows = Array.from(_tenants.values());
|
|
363
|
+
let filtered = rows;
|
|
364
|
+
if (options.status) {
|
|
365
|
+
filtered = filtered.filter((t) => t.status === options.status);
|
|
366
|
+
}
|
|
367
|
+
if (options.plan) {
|
|
368
|
+
filtered = filtered.filter((t) => t.plan === options.plan);
|
|
369
|
+
}
|
|
370
|
+
if (options.ownerSubstr) {
|
|
371
|
+
const needle = String(options.ownerSubstr).toLowerCase();
|
|
372
|
+
filtered = filtered.filter((t) =>
|
|
373
|
+
(t.ownerId || "").toLowerCase().includes(needle),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
filtered.sort((a, b) => a._seq - b._seq);
|
|
377
|
+
const limit =
|
|
378
|
+
Number.isInteger(options.limit) && options.limit > 0
|
|
379
|
+
? options.limit
|
|
380
|
+
: filtered.length;
|
|
381
|
+
return filtered.slice(0, limit).map(_strip);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function deleteTenant(db, id, options = {}) {
|
|
385
|
+
const tenant = _tenants.get(String(id || ""));
|
|
386
|
+
if (!tenant) throw new Error(`Tenant not found: ${id}`);
|
|
387
|
+
|
|
388
|
+
const now = Number(options.now ?? Date.now());
|
|
389
|
+
if (options.hardDelete) {
|
|
390
|
+
_tenants.delete(tenant.id);
|
|
391
|
+
_slugIndex.delete(tenant.slug);
|
|
392
|
+
// Cascade remove subscriptions + usage
|
|
393
|
+
const subs = _tenantSubs.get(tenant.id) || new Set();
|
|
394
|
+
for (const sid of subs) _subscriptions.delete(sid);
|
|
395
|
+
_tenantSubs.delete(tenant.id);
|
|
396
|
+
const usage = _tenantUsage.get(tenant.id) || new Set();
|
|
397
|
+
for (const uid of usage) _usage.delete(uid);
|
|
398
|
+
_tenantUsage.delete(tenant.id);
|
|
399
|
+
if (db) {
|
|
400
|
+
db.prepare("DELETE FROM saas_tenants WHERE id = ?").run(tenant.id);
|
|
401
|
+
db.prepare("DELETE FROM saas_subscriptions WHERE tenant_id = ?").run(
|
|
402
|
+
tenant.id,
|
|
403
|
+
);
|
|
404
|
+
db.prepare("DELETE FROM saas_usage WHERE tenant_id = ?").run(tenant.id);
|
|
405
|
+
}
|
|
406
|
+
return { deleted: true, hard: true, tenantId: tenant.id };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
tenant.status = TENANT_STATUS.DELETED;
|
|
410
|
+
tenant.deletedAt = now;
|
|
411
|
+
tenant.updatedAt = now;
|
|
412
|
+
_persistTenant(db, tenant);
|
|
413
|
+
return { deleted: true, hard: false, tenantId: tenant.id, deletedAt: now };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* ── Usage ─────────────────────────────────────────────────── */
|
|
417
|
+
|
|
418
|
+
function _persistUsage(db, record) {
|
|
419
|
+
if (!db) return;
|
|
420
|
+
db.prepare(
|
|
421
|
+
`INSERT OR REPLACE INTO saas_usage
|
|
422
|
+
(id, tenant_id, metric, value, period, recorded_at)
|
|
423
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
424
|
+
).run(
|
|
425
|
+
record.id,
|
|
426
|
+
record.tenantId,
|
|
427
|
+
record.metric,
|
|
428
|
+
record.value,
|
|
429
|
+
record.period,
|
|
430
|
+
record.recordedAt,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function recordUsage(db, tenantId, metric, value, options = {}) {
|
|
435
|
+
const tenant = _tenants.get(String(tenantId || ""));
|
|
436
|
+
if (!tenant) throw new Error(`Tenant not found: ${tenantId}`);
|
|
437
|
+
|
|
438
|
+
if (!METRICS[metric]) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Unknown metric: ${metric} (expected ${Object.keys(METRICS).join(" | ")})`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const numeric = Number(value);
|
|
444
|
+
if (!Number.isFinite(numeric) || numeric < 0) {
|
|
445
|
+
throw new Error(`Usage value must be a non-negative finite number`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const now = Number(options.now ?? Date.now());
|
|
449
|
+
const period = options.period || _periodForNow(now);
|
|
450
|
+
const id = options.id || crypto.randomUUID();
|
|
451
|
+
|
|
452
|
+
const record = {
|
|
453
|
+
id,
|
|
454
|
+
tenantId: tenant.id,
|
|
455
|
+
metric,
|
|
456
|
+
value: numeric,
|
|
457
|
+
period,
|
|
458
|
+
recordedAt: now,
|
|
459
|
+
_seq: ++_seq,
|
|
460
|
+
};
|
|
461
|
+
_usage.set(id, record);
|
|
462
|
+
if (!_tenantUsage.has(tenant.id)) _tenantUsage.set(tenant.id, new Set());
|
|
463
|
+
_tenantUsage.get(tenant.id).add(id);
|
|
464
|
+
_persistUsage(db, record);
|
|
465
|
+
return _strip(record);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function getUsage(tenantId, options = {}) {
|
|
469
|
+
const tenant = _tenants.get(String(tenantId || ""));
|
|
470
|
+
if (!tenant) throw new Error(`Tenant not found: ${tenantId}`);
|
|
471
|
+
|
|
472
|
+
const ids = _tenantUsage.get(tenant.id) || new Set();
|
|
473
|
+
const rows = Array.from(ids)
|
|
474
|
+
.map((uid) => _usage.get(uid))
|
|
475
|
+
.filter(Boolean);
|
|
476
|
+
|
|
477
|
+
const filtered = rows.filter((r) => {
|
|
478
|
+
if (options.period && r.period !== options.period) return false;
|
|
479
|
+
if (options.metric && r.metric !== options.metric) return false;
|
|
480
|
+
return true;
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Aggregate by metric
|
|
484
|
+
const byMetric = {};
|
|
485
|
+
for (const metricId of Object.keys(METRICS)) {
|
|
486
|
+
byMetric[metricId] = 0;
|
|
487
|
+
}
|
|
488
|
+
for (const r of filtered) {
|
|
489
|
+
byMetric[r.metric] = (byMetric[r.metric] || 0) + r.value;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
tenantId: tenant.id,
|
|
494
|
+
period: options.period || null,
|
|
495
|
+
byMetric,
|
|
496
|
+
recordCount: filtered.length,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function listUsage(options = {}) {
|
|
501
|
+
const rows = Array.from(_usage.values());
|
|
502
|
+
let filtered = rows;
|
|
503
|
+
if (options.tenantId) {
|
|
504
|
+
filtered = filtered.filter((r) => r.tenantId === options.tenantId);
|
|
505
|
+
}
|
|
506
|
+
if (options.metric) {
|
|
507
|
+
filtered = filtered.filter((r) => r.metric === options.metric);
|
|
508
|
+
}
|
|
509
|
+
if (options.period) {
|
|
510
|
+
filtered = filtered.filter((r) => r.period === options.period);
|
|
511
|
+
}
|
|
512
|
+
filtered.sort((a, b) => b.recordedAt - a.recordedAt);
|
|
513
|
+
const limit =
|
|
514
|
+
Number.isInteger(options.limit) && options.limit > 0
|
|
515
|
+
? options.limit
|
|
516
|
+
: filtered.length;
|
|
517
|
+
return filtered.slice(0, limit).map(_strip);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/* ── Subscriptions ─────────────────────────────────────────── */
|
|
521
|
+
|
|
522
|
+
function _persistSubscription(db, sub) {
|
|
523
|
+
if (!db) return;
|
|
524
|
+
db.prepare(
|
|
525
|
+
`INSERT OR REPLACE INTO saas_subscriptions
|
|
526
|
+
(id, tenant_id, plan, status, started_at, expires_at, cancelled_at,
|
|
527
|
+
payment_method, amount, created_at)
|
|
528
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
529
|
+
).run(
|
|
530
|
+
sub.id,
|
|
531
|
+
sub.tenantId,
|
|
532
|
+
sub.plan,
|
|
533
|
+
sub.status,
|
|
534
|
+
sub.startedAt,
|
|
535
|
+
sub.expiresAt || null,
|
|
536
|
+
sub.cancelledAt || null,
|
|
537
|
+
sub.paymentMethod ? JSON.stringify(sub.paymentMethod) : null,
|
|
538
|
+
sub.amount ?? null,
|
|
539
|
+
sub.createdAt,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function subscribe(db, tenantId, plan, options = {}) {
|
|
544
|
+
const tenant = _tenants.get(String(tenantId || ""));
|
|
545
|
+
if (!tenant) throw new Error(`Tenant not found: ${tenantId}`);
|
|
546
|
+
if (tenant.status === TENANT_STATUS.DELETED) {
|
|
547
|
+
throw new Error(`Cannot subscribe deleted tenant: ${tenantId}`);
|
|
548
|
+
}
|
|
549
|
+
if (!PLANS[plan]) throw new Error(`Unknown plan: ${plan}`);
|
|
550
|
+
|
|
551
|
+
const now = Number(options.now ?? Date.now());
|
|
552
|
+
// Cancel any existing active subscription on this tenant (one-at-a-time).
|
|
553
|
+
const existing = getActiveSubscription(tenant.id);
|
|
554
|
+
if (existing) {
|
|
555
|
+
const existingSub = _subscriptions.get(existing.id);
|
|
556
|
+
if (existingSub) {
|
|
557
|
+
existingSub.status = SUBSCRIPTION_STATUS.CANCELLED;
|
|
558
|
+
existingSub.cancelledAt = now;
|
|
559
|
+
_persistSubscription(db, existingSub);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const id = options.id || crypto.randomUUID();
|
|
564
|
+
const amount =
|
|
565
|
+
options.amount !== undefined
|
|
566
|
+
? Number(options.amount)
|
|
567
|
+
: PLANS[plan].monthlyFee;
|
|
568
|
+
const durationMs =
|
|
569
|
+
options.durationMs !== undefined
|
|
570
|
+
? Number(options.durationMs)
|
|
571
|
+
: 30 * 24 * 60 * 60 * 1000; // 30 days default
|
|
572
|
+
const expiresAt = durationMs > 0 ? now + durationMs : null;
|
|
573
|
+
|
|
574
|
+
const sub = {
|
|
575
|
+
id,
|
|
576
|
+
tenantId: tenant.id,
|
|
577
|
+
plan,
|
|
578
|
+
status: SUBSCRIPTION_STATUS.ACTIVE,
|
|
579
|
+
startedAt: now,
|
|
580
|
+
expiresAt,
|
|
581
|
+
cancelledAt: null,
|
|
582
|
+
paymentMethod: options.paymentMethod || null,
|
|
583
|
+
amount,
|
|
584
|
+
createdAt: now,
|
|
585
|
+
_seq: ++_seq,
|
|
586
|
+
};
|
|
587
|
+
_subscriptions.set(id, sub);
|
|
588
|
+
if (!_tenantSubs.has(tenant.id)) _tenantSubs.set(tenant.id, new Set());
|
|
589
|
+
_tenantSubs.get(tenant.id).add(id);
|
|
590
|
+
|
|
591
|
+
// Keep tenant.plan in sync with the active subscription.
|
|
592
|
+
tenant.plan = plan;
|
|
593
|
+
tenant.updatedAt = now;
|
|
594
|
+
_persistTenant(db, tenant);
|
|
595
|
+
_persistSubscription(db, sub);
|
|
596
|
+
return _strip(sub);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function getActiveSubscription(tenantId) {
|
|
600
|
+
const ids = _tenantSubs.get(String(tenantId || "")) || new Set();
|
|
601
|
+
const active = Array.from(ids)
|
|
602
|
+
.map((sid) => _subscriptions.get(sid))
|
|
603
|
+
.filter((s) => s && s.status === SUBSCRIPTION_STATUS.ACTIVE)
|
|
604
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
605
|
+
return active.length ? _strip(active[0]) : null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function cancelSubscription(db, tenantId, options = {}) {
|
|
609
|
+
const active = getActiveSubscription(tenantId);
|
|
610
|
+
if (!active) {
|
|
611
|
+
throw new Error(`No active subscription for tenant: ${tenantId}`);
|
|
612
|
+
}
|
|
613
|
+
const sub = _subscriptions.get(active.id);
|
|
614
|
+
const now = Number(options.now ?? Date.now());
|
|
615
|
+
sub.status = SUBSCRIPTION_STATUS.CANCELLED;
|
|
616
|
+
sub.cancelledAt = now;
|
|
617
|
+
_persistSubscription(db, sub);
|
|
618
|
+
return _strip(sub);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function listSubscriptions(options = {}) {
|
|
622
|
+
const rows = Array.from(_subscriptions.values());
|
|
623
|
+
let filtered = rows;
|
|
624
|
+
if (options.tenantId) {
|
|
625
|
+
filtered = filtered.filter((s) => s.tenantId === options.tenantId);
|
|
626
|
+
}
|
|
627
|
+
if (options.status) {
|
|
628
|
+
filtered = filtered.filter((s) => s.status === options.status);
|
|
629
|
+
}
|
|
630
|
+
if (options.plan) {
|
|
631
|
+
filtered = filtered.filter((s) => s.plan === options.plan);
|
|
632
|
+
}
|
|
633
|
+
filtered.sort((a, b) => b.startedAt - a.startedAt);
|
|
634
|
+
const limit =
|
|
635
|
+
Number.isInteger(options.limit) && options.limit > 0
|
|
636
|
+
? options.limit
|
|
637
|
+
: filtered.length;
|
|
638
|
+
return filtered.slice(0, limit).map(_strip);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/* ── Quota Check ───────────────────────────────────────────── */
|
|
642
|
+
|
|
643
|
+
export function checkQuota(tenantId, metric, options = {}) {
|
|
644
|
+
const tenant = _tenants.get(String(tenantId || ""));
|
|
645
|
+
if (!tenant) throw new Error(`Tenant not found: ${tenantId}`);
|
|
646
|
+
if (!METRICS[metric]) {
|
|
647
|
+
throw new Error(`Unknown metric: ${metric}`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const plan = PLANS[tenant.plan] || PLANS.free;
|
|
651
|
+
const limit = plan.quotas[metric];
|
|
652
|
+
const now = Number(options.now ?? Date.now());
|
|
653
|
+
const period = options.period || _periodForNow(now);
|
|
654
|
+
|
|
655
|
+
const usage = getUsage(tenant.id, { period, metric });
|
|
656
|
+
const used = usage.byMetric[metric] || 0;
|
|
657
|
+
|
|
658
|
+
const isUnlimited = limit === Number.POSITIVE_INFINITY;
|
|
659
|
+
return {
|
|
660
|
+
tenantId: tenant.id,
|
|
661
|
+
plan: tenant.plan,
|
|
662
|
+
metric,
|
|
663
|
+
period,
|
|
664
|
+
limit: isUnlimited ? null : limit,
|
|
665
|
+
used,
|
|
666
|
+
remaining: isUnlimited ? null : Math.max(0, limit - used),
|
|
667
|
+
unlimited: isUnlimited,
|
|
668
|
+
exceeded: isUnlimited ? false : used > limit,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* ── Stats ─────────────────────────────────────────────────── */
|
|
673
|
+
|
|
674
|
+
export function getSaasStats() {
|
|
675
|
+
const tenants = Array.from(_tenants.values());
|
|
676
|
+
const byStatus = {};
|
|
677
|
+
for (const status of Object.values(TENANT_STATUS)) byStatus[status] = 0;
|
|
678
|
+
const byPlan = {};
|
|
679
|
+
for (const p of Object.keys(PLANS)) byPlan[p] = 0;
|
|
680
|
+
for (const t of tenants) {
|
|
681
|
+
byStatus[t.status] = (byStatus[t.status] || 0) + 1;
|
|
682
|
+
byPlan[t.plan] = (byPlan[t.plan] || 0) + 1;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const byMetric = {};
|
|
686
|
+
for (const metricId of Object.keys(METRICS)) byMetric[metricId] = 0;
|
|
687
|
+
for (const record of _usage.values()) {
|
|
688
|
+
byMetric[record.metric] = (byMetric[record.metric] || 0) + record.value;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const activeSubs = Array.from(_subscriptions.values()).filter(
|
|
692
|
+
(s) => s.status === SUBSCRIPTION_STATUS.ACTIVE,
|
|
693
|
+
).length;
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
tenantCount: tenants.length,
|
|
697
|
+
byStatus,
|
|
698
|
+
byPlan,
|
|
699
|
+
subscriptionCount: _subscriptions.size,
|
|
700
|
+
activeSubscriptions: activeSubs,
|
|
701
|
+
usageRecordCount: _usage.size,
|
|
702
|
+
totalUsage: byMetric,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/* ── Import / Export ───────────────────────────────────────── */
|
|
707
|
+
|
|
708
|
+
export function exportTenant(tenantId) {
|
|
709
|
+
const tenant = _tenants.get(String(tenantId || ""));
|
|
710
|
+
if (!tenant) throw new Error(`Tenant not found: ${tenantId}`);
|
|
711
|
+
return {
|
|
712
|
+
tenant: _strip(tenant),
|
|
713
|
+
subscriptions: listSubscriptions({ tenantId: tenant.id }),
|
|
714
|
+
usage: listUsage({ tenantId: tenant.id }),
|
|
715
|
+
exportedAt: Date.now(),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function importTenant(db, data) {
|
|
720
|
+
if (!data || typeof data !== "object") {
|
|
721
|
+
throw new Error("import data must be an object");
|
|
722
|
+
}
|
|
723
|
+
const { tenant, subscriptions = [], usage = [] } = data;
|
|
724
|
+
if (!tenant || !tenant.name || !tenant.slug) {
|
|
725
|
+
throw new Error("import data must include tenant with name + slug");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
let tenantId = tenant.id;
|
|
729
|
+
let skippedTenant = false;
|
|
730
|
+
if (tenantId && _tenants.has(tenantId)) {
|
|
731
|
+
// Tenant already exists — import is idempotent at tenant level.
|
|
732
|
+
skippedTenant = true;
|
|
733
|
+
} else if (_slugIndex.has(tenant.slug)) {
|
|
734
|
+
// Slug collision — skip tenant, skip all related data.
|
|
735
|
+
return {
|
|
736
|
+
tenantId: null,
|
|
737
|
+
importedSubscriptions: 0,
|
|
738
|
+
importedUsage: 0,
|
|
739
|
+
skippedSubscriptions: subscriptions.length,
|
|
740
|
+
skippedUsage: usage.length,
|
|
741
|
+
skippedTenant: true,
|
|
742
|
+
reason: "slug_collision",
|
|
743
|
+
};
|
|
744
|
+
} else {
|
|
745
|
+
const created = createTenant(db, {
|
|
746
|
+
id: tenantId,
|
|
747
|
+
name: tenant.name,
|
|
748
|
+
slug: tenant.slug,
|
|
749
|
+
plan: tenant.plan || "free",
|
|
750
|
+
config: tenant.config || null,
|
|
751
|
+
ownerId: tenant.ownerId || null,
|
|
752
|
+
});
|
|
753
|
+
tenantId = created.id;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
let importedSubs = 0;
|
|
757
|
+
let skippedSubs = 0;
|
|
758
|
+
for (const sub of Array.isArray(subscriptions) ? subscriptions : []) {
|
|
759
|
+
if (!sub || !sub.plan) {
|
|
760
|
+
skippedSubs++;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (!PLANS[sub.plan]) {
|
|
764
|
+
skippedSubs++;
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
const s = subscribe(db, tenantId, sub.plan, {
|
|
769
|
+
amount: sub.amount,
|
|
770
|
+
paymentMethod: sub.paymentMethod,
|
|
771
|
+
durationMs:
|
|
772
|
+
sub.expiresAt && sub.startedAt
|
|
773
|
+
? sub.expiresAt - sub.startedAt
|
|
774
|
+
: undefined,
|
|
775
|
+
});
|
|
776
|
+
if (sub.status && sub.status !== SUBSCRIPTION_STATUS.ACTIVE) {
|
|
777
|
+
const persisted = _subscriptions.get(s.id);
|
|
778
|
+
if (persisted) {
|
|
779
|
+
persisted.status = sub.status;
|
|
780
|
+
persisted.cancelledAt = sub.cancelledAt || null;
|
|
781
|
+
_persistSubscription(db, persisted);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
importedSubs++;
|
|
785
|
+
} catch {
|
|
786
|
+
skippedSubs++;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
let importedUsage = 0;
|
|
791
|
+
let skippedUsage = 0;
|
|
792
|
+
for (const record of Array.isArray(usage) ? usage : []) {
|
|
793
|
+
if (!record || !record.metric || record.value === undefined) {
|
|
794
|
+
skippedUsage++;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (!METRICS[record.metric]) {
|
|
798
|
+
skippedUsage++;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
try {
|
|
802
|
+
recordUsage(db, tenantId, record.metric, record.value, {
|
|
803
|
+
period: record.period,
|
|
804
|
+
});
|
|
805
|
+
importedUsage++;
|
|
806
|
+
} catch {
|
|
807
|
+
skippedUsage++;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return {
|
|
812
|
+
tenantId,
|
|
813
|
+
skippedTenant,
|
|
814
|
+
importedSubscriptions: importedSubs,
|
|
815
|
+
skippedSubscriptions: skippedSubs,
|
|
816
|
+
importedUsage,
|
|
817
|
+
skippedUsage,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/* ── Test Helpers ──────────────────────────────────────────── */
|
|
822
|
+
|
|
823
|
+
export function _resetState() {
|
|
824
|
+
_tenants.clear();
|
|
825
|
+
_subscriptions.clear();
|
|
826
|
+
_usage.clear();
|
|
827
|
+
_slugIndex.clear();
|
|
828
|
+
_tenantSubs.clear();
|
|
829
|
+
_tenantUsage.clear();
|
|
830
|
+
_seq = 0;
|
|
831
|
+
}
|