@kb-labs/core-tenant 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/README.md +606 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +139 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/index.ts +10 -0
- package/src/rate-limiter.ts +153 -0
- package/src/types.ts +97 -0
package/README.md
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
# @kb-labs/tenant
|
|
2
|
+
|
|
3
|
+
Multi-tenancy primitives for KB Labs ecosystem.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This package provides lightweight multi-tenancy support with:
|
|
8
|
+
|
|
9
|
+
- ✅ **Tenant Types & Quotas** - Pre-configured tiers (free, pro, enterprise)
|
|
10
|
+
- ✅ **Rate Limiting** - Per-tenant rate limiting using State Broker (no Redis required)
|
|
11
|
+
- ✅ **Zero Dependencies** - Works with in-memory State Broker out of the box
|
|
12
|
+
- ✅ **Backward Compatible** - Defaults to "default" tenant for single-tenant deployments
|
|
13
|
+
- ✅ **Scalable** - Designed to work with Redis backend when needed
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @kb-labs/tenant
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### 1. Basic Tenant Types
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import {
|
|
27
|
+
getDefaultTenantId,
|
|
28
|
+
getDefaultTenantTier,
|
|
29
|
+
getQuotasForTier
|
|
30
|
+
} from '@kb-labs/tenant';
|
|
31
|
+
|
|
32
|
+
// Get tenant from environment (defaults to "default")
|
|
33
|
+
const tenantId = getDefaultTenantId(); // "default" or KB_TENANT_ID
|
|
34
|
+
const tier = getDefaultTenantTier(); // "free" or KB_TENANT_DEFAULT_TIER
|
|
35
|
+
|
|
36
|
+
// Get quotas for tier
|
|
37
|
+
const quotas = getQuotasForTier('pro');
|
|
38
|
+
console.log(quotas);
|
|
39
|
+
// {
|
|
40
|
+
// apiRequestsPerMinute: 1000,
|
|
41
|
+
// workflowRunsPerDay: 1000,
|
|
42
|
+
// concurrentWorkflows: 10,
|
|
43
|
+
// storageMB: 10000,
|
|
44
|
+
// retentionDays: 30
|
|
45
|
+
// }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 2. Rate Limiting
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { TenantRateLimiter } from '@kb-labs/tenant';
|
|
52
|
+
import { createStateBroker } from '@kb-labs/state-broker';
|
|
53
|
+
|
|
54
|
+
// Create rate limiter with State Broker
|
|
55
|
+
const broker = createStateBroker();
|
|
56
|
+
const limiter = new TenantRateLimiter(broker);
|
|
57
|
+
|
|
58
|
+
// Check rate limit
|
|
59
|
+
const result = await limiter.checkLimit('acme-corp', 'api');
|
|
60
|
+
|
|
61
|
+
if (!result.allowed) {
|
|
62
|
+
console.log(`Rate limited. Retry after ${result.retryAfterMs}ms`);
|
|
63
|
+
// HTTP 429 with Retry-After header
|
|
64
|
+
} else {
|
|
65
|
+
console.log(`Allowed. Remaining: ${result.remaining}`);
|
|
66
|
+
// Process request
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 3. Custom Quotas
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { TenantRateLimiter, type TenantQuotas } from '@kb-labs/tenant';
|
|
74
|
+
|
|
75
|
+
// Define custom quotas
|
|
76
|
+
const customQuotas = new Map<string, TenantQuotas>();
|
|
77
|
+
customQuotas.set('startup-tier', {
|
|
78
|
+
apiRequestsPerMinute: 500,
|
|
79
|
+
workflowRunsPerDay: 200,
|
|
80
|
+
concurrentWorkflows: 5,
|
|
81
|
+
storageMB: 5000,
|
|
82
|
+
retentionDays: 14,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Create limiter with custom quotas
|
|
86
|
+
const limiter = new TenantRateLimiter(broker, customQuotas);
|
|
87
|
+
|
|
88
|
+
// Check with custom tier
|
|
89
|
+
const result = await limiter.checkLimit('startup-tenant', 'api');
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## API Reference
|
|
93
|
+
|
|
94
|
+
### Types
|
|
95
|
+
|
|
96
|
+
#### `TenantTier`
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
type TenantTier = 'free' | 'pro' | 'enterprise';
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
#### `TenantQuotas`
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
interface TenantQuotas {
|
|
106
|
+
/** API requests per minute */
|
|
107
|
+
apiRequestsPerMinute: number;
|
|
108
|
+
|
|
109
|
+
/** Workflow runs per day */
|
|
110
|
+
workflowRunsPerDay: number;
|
|
111
|
+
|
|
112
|
+
/** Maximum concurrent workflows */
|
|
113
|
+
concurrentWorkflows: number;
|
|
114
|
+
|
|
115
|
+
/** Storage limit in MB */
|
|
116
|
+
storageMB: number;
|
|
117
|
+
|
|
118
|
+
/** Data retention in days */
|
|
119
|
+
retentionDays: number;
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### `TenantConfig`
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
interface TenantConfig {
|
|
127
|
+
id: string;
|
|
128
|
+
tier: TenantTier;
|
|
129
|
+
quotas?: TenantQuotas;
|
|
130
|
+
metadata?: Record<string, unknown>;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
#### `RateLimitResource`
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
type RateLimitResource = 'api' | 'workflow' | 'storage';
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
#### `RateLimitResult`
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
interface RateLimitResult {
|
|
144
|
+
/** Whether request is allowed */
|
|
145
|
+
allowed: boolean;
|
|
146
|
+
|
|
147
|
+
/** Remaining quota in current window */
|
|
148
|
+
remaining?: number;
|
|
149
|
+
|
|
150
|
+
/** Milliseconds until quota resets */
|
|
151
|
+
retryAfterMs?: number;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Default Quotas
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
import { DEFAULT_QUOTAS } from '@kb-labs/tenant';
|
|
159
|
+
|
|
160
|
+
console.log(DEFAULT_QUOTAS);
|
|
161
|
+
// {
|
|
162
|
+
// free: {
|
|
163
|
+
// apiRequestsPerMinute: 100,
|
|
164
|
+
// workflowRunsPerDay: 50,
|
|
165
|
+
// concurrentWorkflows: 2,
|
|
166
|
+
// storageMB: 100,
|
|
167
|
+
// retentionDays: 7
|
|
168
|
+
// },
|
|
169
|
+
// pro: {
|
|
170
|
+
// apiRequestsPerMinute: 1000,
|
|
171
|
+
// workflowRunsPerDay: 1000,
|
|
172
|
+
// concurrentWorkflows: 10,
|
|
173
|
+
// storageMB: 10000,
|
|
174
|
+
// retentionDays: 30
|
|
175
|
+
// },
|
|
176
|
+
// enterprise: {
|
|
177
|
+
// apiRequestsPerMinute: 100000,
|
|
178
|
+
// workflowRunsPerDay: 100000,
|
|
179
|
+
// concurrentWorkflows: 1000,
|
|
180
|
+
// storageMB: 1000000,
|
|
181
|
+
// retentionDays: 365
|
|
182
|
+
// }
|
|
183
|
+
// }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Helper Functions
|
|
187
|
+
|
|
188
|
+
#### `getDefaultTenantId()`
|
|
189
|
+
|
|
190
|
+
Get default tenant ID from environment variable.
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
function getDefaultTenantId(): string
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Returns: `process.env.KB_TENANT_ID ?? 'default'`
|
|
197
|
+
|
|
198
|
+
**Example:**
|
|
199
|
+
```bash
|
|
200
|
+
KB_TENANT_ID=acme-corp node app.js
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
const tenantId = getDefaultTenantId(); // "acme-corp"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### `getDefaultTenantTier()`
|
|
208
|
+
|
|
209
|
+
Get default tenant tier from environment variable.
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
function getDefaultTenantTier(): TenantTier
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Returns: `process.env.KB_TENANT_DEFAULT_TIER ?? 'free'`
|
|
216
|
+
|
|
217
|
+
**Example:**
|
|
218
|
+
```bash
|
|
219
|
+
KB_TENANT_DEFAULT_TIER=pro node app.js
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
const tier = getDefaultTenantTier(); // "pro"
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### `getQuotasForTier(tier)`
|
|
227
|
+
|
|
228
|
+
Get quotas for a specific tier.
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
function getQuotasForTier(tier: TenantTier): TenantQuotas
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Example:**
|
|
235
|
+
```typescript
|
|
236
|
+
const quotas = getQuotasForTier('enterprise');
|
|
237
|
+
console.log(quotas.apiRequestsPerMinute); // 100000
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### `TenantRateLimiter`
|
|
241
|
+
|
|
242
|
+
Rate limiter using State Broker for distributed quota tracking.
|
|
243
|
+
|
|
244
|
+
#### Constructor
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
constructor(
|
|
248
|
+
broker: StateBroker,
|
|
249
|
+
quotas?: Map<string, TenantQuotas>
|
|
250
|
+
)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Parameters:**
|
|
254
|
+
- `broker` - State Broker instance (in-memory or HTTP)
|
|
255
|
+
- `quotas` - Optional custom quotas per tenant (defaults to DEFAULT_QUOTAS by tier)
|
|
256
|
+
|
|
257
|
+
#### Methods
|
|
258
|
+
|
|
259
|
+
##### `checkLimit(tenantId, resource)`
|
|
260
|
+
|
|
261
|
+
Check if tenant has remaining quota for resource.
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
async checkLimit(
|
|
265
|
+
tenantId: string,
|
|
266
|
+
resource: RateLimitResource
|
|
267
|
+
): Promise<RateLimitResult>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Parameters:**
|
|
271
|
+
- `tenantId` - Tenant identifier
|
|
272
|
+
- `resource` - Resource type ('api', 'workflow', 'storage')
|
|
273
|
+
|
|
274
|
+
**Returns:** Rate limit result with `allowed`, `remaining`, `retryAfterMs`
|
|
275
|
+
|
|
276
|
+
**Example:**
|
|
277
|
+
```typescript
|
|
278
|
+
const result = await limiter.checkLimit('acme-corp', 'api');
|
|
279
|
+
|
|
280
|
+
if (!result.allowed) {
|
|
281
|
+
throw new Error(`Rate limited. Retry after ${result.retryAfterMs}ms`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
console.log(`Remaining quota: ${result.remaining}`);
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
##### `getQuota(tenantId)`
|
|
288
|
+
|
|
289
|
+
Get quotas for a tenant.
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
getQuota(tenantId: string): TenantQuotas
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**Parameters:**
|
|
296
|
+
- `tenantId` - Tenant identifier
|
|
297
|
+
|
|
298
|
+
**Returns:** Tenant quotas (custom or default for tier)
|
|
299
|
+
|
|
300
|
+
**Example:**
|
|
301
|
+
```typescript
|
|
302
|
+
const quotas = limiter.getQuota('acme-corp');
|
|
303
|
+
console.log(quotas.apiRequestsPerMinute); // 1000
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
##### `setQuota(tenantId, quotas)`
|
|
307
|
+
|
|
308
|
+
Set custom quotas for a tenant.
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
setQuota(tenantId: string, quotas: TenantQuotas): void
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**Parameters:**
|
|
315
|
+
- `tenantId` - Tenant identifier
|
|
316
|
+
- `quotas` - Custom quotas
|
|
317
|
+
|
|
318
|
+
**Example:**
|
|
319
|
+
```typescript
|
|
320
|
+
limiter.setQuota('vip-customer', {
|
|
321
|
+
apiRequestsPerMinute: 50000,
|
|
322
|
+
workflowRunsPerDay: 10000,
|
|
323
|
+
concurrentWorkflows: 100,
|
|
324
|
+
storageMB: 500000,
|
|
325
|
+
retentionDays: 180,
|
|
326
|
+
});
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Integration Examples
|
|
330
|
+
|
|
331
|
+
### REST API Middleware
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
import { TenantRateLimiter } from '@kb-labs/tenant';
|
|
335
|
+
import { createStateBroker } from '@kb-labs/state-broker';
|
|
336
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
337
|
+
|
|
338
|
+
const broker = createStateBroker();
|
|
339
|
+
const limiter = new TenantRateLimiter(broker);
|
|
340
|
+
|
|
341
|
+
export async function rateLimitMiddleware(
|
|
342
|
+
request: FastifyRequest,
|
|
343
|
+
reply: FastifyReply
|
|
344
|
+
) {
|
|
345
|
+
// Extract tenant from header or env var
|
|
346
|
+
const tenantId =
|
|
347
|
+
(request.headers['x-tenant-id'] as string) ??
|
|
348
|
+
process.env.KB_TENANT_ID ??
|
|
349
|
+
'default';
|
|
350
|
+
|
|
351
|
+
// Check rate limit
|
|
352
|
+
const result = await limiter.checkLimit(tenantId, 'api');
|
|
353
|
+
|
|
354
|
+
if (!result.allowed) {
|
|
355
|
+
reply.code(429).header('Retry-After', String(result.retryAfterMs! / 1000));
|
|
356
|
+
return { error: 'Rate limit exceeded' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Add tenant to request context
|
|
360
|
+
request.tenantId = tenantId;
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Workflow Engine
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
import { TenantRateLimiter } from '@kb-labs/tenant';
|
|
368
|
+
import type { WorkflowRun } from '@kb-labs/workflow-contracts';
|
|
369
|
+
|
|
370
|
+
export async function executeWorkflow(run: WorkflowRun) {
|
|
371
|
+
const tenantId = run.tenantId ?? 'default';
|
|
372
|
+
|
|
373
|
+
// Check workflow quota
|
|
374
|
+
const result = await limiter.checkLimit(tenantId, 'workflow');
|
|
375
|
+
|
|
376
|
+
if (!result.allowed) {
|
|
377
|
+
throw new QuotaExceededError(
|
|
378
|
+
`Tenant ${tenantId} exceeded workflow quota. Retry after ${result.retryAfterMs}ms`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Execute workflow...
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Custom Tenant Service
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
import { TenantRateLimiter, type TenantConfig } from '@kb-labs/tenant';
|
|
390
|
+
|
|
391
|
+
export class TenantService {
|
|
392
|
+
constructor(private limiter: TenantRateLimiter) {}
|
|
393
|
+
|
|
394
|
+
async createTenant(config: TenantConfig): Promise<void> {
|
|
395
|
+
// Set custom quotas if provided
|
|
396
|
+
if (config.quotas) {
|
|
397
|
+
this.limiter.setQuota(config.id, config.quotas);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Store tenant config in database
|
|
401
|
+
await db.tenants.insert(config);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async upgradeTenant(tenantId: string, newTier: TenantTier): Promise<void> {
|
|
405
|
+
const quotas = getQuotasForTier(newTier);
|
|
406
|
+
this.limiter.setQuota(tenantId, quotas);
|
|
407
|
+
|
|
408
|
+
await db.tenants.update(tenantId, { tier: newTier });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## Environment Variables
|
|
414
|
+
|
|
415
|
+
| Variable | Description | Default |
|
|
416
|
+
|----------|-------------|---------|
|
|
417
|
+
| `KB_TENANT_ID` | Default tenant identifier | `"default"` |
|
|
418
|
+
| `KB_TENANT_DEFAULT_TIER` | Default tenant tier | `"free"` |
|
|
419
|
+
|
|
420
|
+
**Example `.env` file:**
|
|
421
|
+
```bash
|
|
422
|
+
KB_TENANT_ID=my-company
|
|
423
|
+
KB_TENANT_DEFAULT_TIER=pro
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
## State Broker Integration
|
|
427
|
+
|
|
428
|
+
Rate limiter uses State Broker with the following key pattern:
|
|
429
|
+
|
|
430
|
+
```
|
|
431
|
+
ratelimit:tenant:{tenantId}:{resource}:{window}
|
|
432
|
+
|
|
433
|
+
Examples:
|
|
434
|
+
ratelimit:tenant:default:api:1732896000
|
|
435
|
+
ratelimit:tenant:acme-corp:workflow:1732896060
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**TTL:** 60 seconds (automatic cleanup via State Broker)
|
|
439
|
+
|
|
440
|
+
**Backend Support:**
|
|
441
|
+
- ✅ **InMemoryStateBroker** - Works out of the box (single instance, 1K RPS)
|
|
442
|
+
- ✅ **HTTPStateBroker** - Connects to State Daemon (single instance, 1K RPS)
|
|
443
|
+
- 🔜 **RedisStateBroker** - Distributed quota tracking (multi-instance, 100K+ RPS)
|
|
444
|
+
|
|
445
|
+
## Error Handling
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
import {
|
|
449
|
+
QuotaExceededError,
|
|
450
|
+
RateLimitError,
|
|
451
|
+
PermissionDeniedError
|
|
452
|
+
} from '@kb-labs/state-broker';
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
const result = await limiter.checkLimit('tenant', 'api');
|
|
456
|
+
|
|
457
|
+
if (!result.allowed) {
|
|
458
|
+
throw new RateLimitError('Rate limit exceeded');
|
|
459
|
+
}
|
|
460
|
+
} catch (error) {
|
|
461
|
+
if (error instanceof RateLimitError) {
|
|
462
|
+
// Return 429 with Retry-After
|
|
463
|
+
reply.code(429).send({ error: error.message });
|
|
464
|
+
} else if (error instanceof QuotaExceededError) {
|
|
465
|
+
// Return 402 Payment Required
|
|
466
|
+
reply.code(402).send({ error: 'Upgrade required' });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## Performance
|
|
472
|
+
|
|
473
|
+
### In-Memory State Broker
|
|
474
|
+
|
|
475
|
+
- **Throughput:** ~1,000 requests/second
|
|
476
|
+
- **Latency:** <1ms
|
|
477
|
+
- **Memory:** ~100 bytes per active quota window
|
|
478
|
+
- **Use case:** Single instance deployments, development
|
|
479
|
+
|
|
480
|
+
### HTTP State Broker (Daemon)
|
|
481
|
+
|
|
482
|
+
- **Throughput:** ~1,000 requests/second
|
|
483
|
+
- **Latency:** ~1-2ms (local network)
|
|
484
|
+
- **Memory:** Shared across app instances
|
|
485
|
+
- **Use case:** Multi-instance deployments without Redis
|
|
486
|
+
|
|
487
|
+
### Redis State Broker (Future)
|
|
488
|
+
|
|
489
|
+
- **Throughput:** ~100,000 requests/second
|
|
490
|
+
- **Latency:** ~1-5ms
|
|
491
|
+
- **Memory:** Distributed, auto-scaling
|
|
492
|
+
- **Use case:** High-scale SaaS, multi-region
|
|
493
|
+
|
|
494
|
+
## Best Practices
|
|
495
|
+
|
|
496
|
+
### 1. Use HTTP State Daemon for Multi-Instance Deployments
|
|
497
|
+
|
|
498
|
+
```bash
|
|
499
|
+
# Start State Daemon
|
|
500
|
+
kb-state-daemon
|
|
501
|
+
|
|
502
|
+
# Configure apps to use HTTP backend
|
|
503
|
+
KB_STATE_BROKER_URL=http://localhost:7777 node app.js
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### 2. Set Custom Quotas for Special Tenants
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
// VIP customer with higher limits
|
|
510
|
+
limiter.setQuota('vip-tenant', {
|
|
511
|
+
apiRequestsPerMinute: 10000,
|
|
512
|
+
workflowRunsPerDay: 5000,
|
|
513
|
+
concurrentWorkflows: 50,
|
|
514
|
+
storageMB: 100000,
|
|
515
|
+
retentionDays: 90,
|
|
516
|
+
});
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### 3. Return Proper HTTP Status Codes
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
const result = await limiter.checkLimit(tenantId, 'api');
|
|
523
|
+
|
|
524
|
+
if (!result.allowed) {
|
|
525
|
+
// 429 Too Many Requests
|
|
526
|
+
reply.code(429)
|
|
527
|
+
.header('Retry-After', String(result.retryAfterMs! / 1000))
|
|
528
|
+
.send({ error: 'Rate limit exceeded' });
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### 4. Log Tenant Context
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
// Add tenant fields through child logger context
|
|
536
|
+
const tenantLogger = logger.child({ tenantId, tier });
|
|
537
|
+
|
|
538
|
+
tenantLogger.info('Processing request');
|
|
539
|
+
// Logs: { tenantId: "acme-corp", tier: "pro", message: "Processing request" }
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### 5. Monitor Tenant Metrics
|
|
543
|
+
|
|
544
|
+
Prometheus metrics are automatically tracked with tenant labels:
|
|
545
|
+
|
|
546
|
+
```prometheus
|
|
547
|
+
kb_tenant_request_total{tenant="default"} 1234
|
|
548
|
+
kb_tenant_request_errors_total{tenant="acme-corp"} 5
|
|
549
|
+
kb_tenant_request_duration_ms_avg{tenant="vip-tenant"} 23.4
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Query with PromQL:
|
|
553
|
+
```promql
|
|
554
|
+
# Requests per tenant
|
|
555
|
+
sum by (tenant) (rate(kb_tenant_request_total[5m]))
|
|
556
|
+
|
|
557
|
+
# Error rate per tenant
|
|
558
|
+
sum by (tenant) (rate(kb_tenant_request_errors_total[5m]))
|
|
559
|
+
/ sum by (tenant) (rate(kb_tenant_request_total[5m]))
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
## Migration from Single-Tenant
|
|
563
|
+
|
|
564
|
+
Existing single-tenant deployments work without changes:
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
// Old code (no tenant)
|
|
568
|
+
const result = await broker.get('mind:query-123');
|
|
569
|
+
|
|
570
|
+
// New code (backward compatible)
|
|
571
|
+
const result = await broker.get('mind:query-123'); // ← Still works!
|
|
572
|
+
// Internally treated as: tenant:default:mind:query-123
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
To enable multi-tenancy:
|
|
576
|
+
|
|
577
|
+
1. **Set environment variable:**
|
|
578
|
+
```bash
|
|
579
|
+
KB_TENANT_ID=my-tenant
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
2. **Or use new key format:**
|
|
583
|
+
```typescript
|
|
584
|
+
await broker.set('tenant:acme:mind:query-123', data);
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
3. **Or send header:**
|
|
588
|
+
```bash
|
|
589
|
+
curl -H "X-Tenant-ID: acme-corp" https://api.example.com/workflows
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
## License
|
|
593
|
+
|
|
594
|
+
MIT
|
|
595
|
+
|
|
596
|
+
## Related Documentation
|
|
597
|
+
|
|
598
|
+
- [ADR-0015: Multi-Tenancy Primitives](../../../kb-labs-workflow/docs/adr/0015-multi-tenancy-primitives.md)
|
|
599
|
+
- [State Broker README](../state-broker/README.md)
|
|
600
|
+
- [State Daemon README](../state-daemon/README.md)
|
|
601
|
+
- [Workflow Contracts](../../../kb-labs-workflow/packages/workflow-contracts/)
|
|
602
|
+
|
|
603
|
+
## Support
|
|
604
|
+
|
|
605
|
+
- Issues: [GitHub Issues](https://github.com/kb-labs/kb-labs/issues)
|
|
606
|
+
- Discussions: [GitHub Discussions](https://github.com/kb-labs/kb-labs/discussions)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { StateBroker } from '@kb-labs/core-state-broker';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module @kb-labs/tenant/types
|
|
5
|
+
* Multi-tenancy types and quota definitions
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Tenant tier levels
|
|
9
|
+
*/
|
|
10
|
+
type TenantTier = 'free' | 'pro' | 'enterprise';
|
|
11
|
+
/**
|
|
12
|
+
* Tenant resource quotas
|
|
13
|
+
*/
|
|
14
|
+
interface TenantQuotas {
|
|
15
|
+
/** Requests per minute limit */
|
|
16
|
+
requestsPerMinute: number;
|
|
17
|
+
/** Requests per day limit (-1 = unlimited) */
|
|
18
|
+
requestsPerDay: number;
|
|
19
|
+
/** Maximum concurrent workflows */
|
|
20
|
+
maxConcurrentWorkflows: number;
|
|
21
|
+
/** Maximum storage in MB */
|
|
22
|
+
maxStorageMB: number;
|
|
23
|
+
/** Plugin executions per day (-1 = unlimited) */
|
|
24
|
+
pluginExecutionsPerDay: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Tenant configuration
|
|
28
|
+
*/
|
|
29
|
+
interface TenantConfig {
|
|
30
|
+
/** Unique tenant identifier */
|
|
31
|
+
id: string;
|
|
32
|
+
/** Tenant tier */
|
|
33
|
+
tier: TenantTier;
|
|
34
|
+
/** Resource quotas */
|
|
35
|
+
quotas: TenantQuotas;
|
|
36
|
+
/** Creation timestamp */
|
|
37
|
+
createdAt: string;
|
|
38
|
+
/** Last update timestamp */
|
|
39
|
+
updatedAt: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Default quotas by tier
|
|
43
|
+
*/
|
|
44
|
+
declare const DEFAULT_QUOTAS: Record<TenantTier, TenantQuotas>;
|
|
45
|
+
/**
|
|
46
|
+
* Get default tenant ID from environment
|
|
47
|
+
* @returns Tenant ID (defaults to 'default' for single-tenant deployments)
|
|
48
|
+
*/
|
|
49
|
+
declare function getDefaultTenantId(): string;
|
|
50
|
+
/**
|
|
51
|
+
* Get default tenant tier from environment
|
|
52
|
+
* @returns Tenant tier (defaults to 'free')
|
|
53
|
+
*/
|
|
54
|
+
declare function getDefaultTenantTier(): TenantTier;
|
|
55
|
+
/**
|
|
56
|
+
* Get tenant quotas for a tier
|
|
57
|
+
* @param tier - Tenant tier
|
|
58
|
+
* @returns Quotas for the tier
|
|
59
|
+
*/
|
|
60
|
+
declare function getQuotasForTier(tier: TenantTier): TenantQuotas;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @module @kb-labs/tenant/rate-limiter
|
|
64
|
+
* Tenant rate limiter using existing State Broker infrastructure
|
|
65
|
+
*
|
|
66
|
+
* Benefits:
|
|
67
|
+
* - Zero new dependencies
|
|
68
|
+
* - TTL cleanup already implemented (30s interval)
|
|
69
|
+
* - Consistent with state management patterns
|
|
70
|
+
* - Same backend as plugin state (in-memory or HTTP daemon)
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rate limit check result
|
|
75
|
+
*/
|
|
76
|
+
interface RateLimitResult {
|
|
77
|
+
/** Whether the request is allowed */
|
|
78
|
+
allowed: boolean;
|
|
79
|
+
/** Remaining requests in current window */
|
|
80
|
+
remaining: number;
|
|
81
|
+
/** Timestamp when the limit resets (Unix ms) */
|
|
82
|
+
resetAt: number;
|
|
83
|
+
/** Limit for current window */
|
|
84
|
+
limit: number;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Rate limit resource types
|
|
88
|
+
*/
|
|
89
|
+
type RateLimitResource = 'requests' | 'workflows' | 'plugins';
|
|
90
|
+
/**
|
|
91
|
+
* Tenant rate limiter
|
|
92
|
+
* Uses State Broker for distributed rate limiting with TTL
|
|
93
|
+
*/
|
|
94
|
+
declare class TenantRateLimiter {
|
|
95
|
+
private broker;
|
|
96
|
+
private quotas;
|
|
97
|
+
constructor(broker: StateBroker, quotas?: Map<string, TenantQuotas>);
|
|
98
|
+
/**
|
|
99
|
+
* Check rate limit for a tenant
|
|
100
|
+
*
|
|
101
|
+
* @param tenantId - Tenant identifier
|
|
102
|
+
* @param resource - Resource type to rate limit
|
|
103
|
+
* @returns Rate limit check result
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* const result = await limiter.checkLimit('acme', 'requests');
|
|
107
|
+
* if (!result.allowed) {
|
|
108
|
+
* throw new Error(`Rate limit exceeded. Reset at ${result.resetAt}`);
|
|
109
|
+
* }
|
|
110
|
+
*/
|
|
111
|
+
checkLimit(tenantId: string, resource: RateLimitResource): Promise<RateLimitResult>;
|
|
112
|
+
/**
|
|
113
|
+
* Set quotas for a tenant
|
|
114
|
+
*
|
|
115
|
+
* @param tenantId - Tenant identifier
|
|
116
|
+
* @param quotas - Tenant quotas
|
|
117
|
+
*/
|
|
118
|
+
setQuotas(tenantId: string, quotas: TenantQuotas): void;
|
|
119
|
+
/**
|
|
120
|
+
* Set quotas for a tenant tier
|
|
121
|
+
*
|
|
122
|
+
* @param tenantId - Tenant identifier
|
|
123
|
+
* @param tier - Tenant tier
|
|
124
|
+
*/
|
|
125
|
+
setTier(tenantId: string, tier: TenantTier): void;
|
|
126
|
+
/**
|
|
127
|
+
* Get current window identifier (minute precision)
|
|
128
|
+
* @returns ISO timestamp truncated to minutes
|
|
129
|
+
*/
|
|
130
|
+
private getWindow;
|
|
131
|
+
/**
|
|
132
|
+
* Get limit for resource type
|
|
133
|
+
*
|
|
134
|
+
* @param quota - Tenant quotas
|
|
135
|
+
* @param resource - Resource type
|
|
136
|
+
* @returns Limit value
|
|
137
|
+
*/
|
|
138
|
+
private getLimit;
|
|
139
|
+
/**
|
|
140
|
+
* Get reset time for current window
|
|
141
|
+
* @returns Unix timestamp in milliseconds
|
|
142
|
+
*/
|
|
143
|
+
private getResetTime;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { DEFAULT_QUOTAS, type RateLimitResource, type RateLimitResult, type TenantConfig, type TenantQuotas, TenantRateLimiter, type TenantTier, getDefaultTenantId, getDefaultTenantTier, getQuotasForTier };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var DEFAULT_QUOTAS = {
|
|
3
|
+
free: {
|
|
4
|
+
requestsPerMinute: 10,
|
|
5
|
+
requestsPerDay: 1e3,
|
|
6
|
+
maxConcurrentWorkflows: 1,
|
|
7
|
+
maxStorageMB: 10,
|
|
8
|
+
pluginExecutionsPerDay: 100
|
|
9
|
+
},
|
|
10
|
+
pro: {
|
|
11
|
+
requestsPerMinute: 100,
|
|
12
|
+
requestsPerDay: 1e5,
|
|
13
|
+
maxConcurrentWorkflows: 10,
|
|
14
|
+
maxStorageMB: 1e3,
|
|
15
|
+
pluginExecutionsPerDay: 1e4
|
|
16
|
+
},
|
|
17
|
+
enterprise: {
|
|
18
|
+
requestsPerMinute: 1e3,
|
|
19
|
+
requestsPerDay: -1,
|
|
20
|
+
// unlimited
|
|
21
|
+
maxConcurrentWorkflows: 100,
|
|
22
|
+
maxStorageMB: 1e4,
|
|
23
|
+
pluginExecutionsPerDay: -1
|
|
24
|
+
// unlimited
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
function getDefaultTenantId() {
|
|
28
|
+
return process.env.KB_TENANT_ID || "default";
|
|
29
|
+
}
|
|
30
|
+
function getDefaultTenantTier() {
|
|
31
|
+
const tier = process.env.KB_TENANT_DEFAULT_TIER?.toLowerCase();
|
|
32
|
+
if (tier === "pro" || tier === "enterprise") {
|
|
33
|
+
return tier;
|
|
34
|
+
}
|
|
35
|
+
return "free";
|
|
36
|
+
}
|
|
37
|
+
function getQuotasForTier(tier) {
|
|
38
|
+
return { ...DEFAULT_QUOTAS[tier] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/rate-limiter.ts
|
|
42
|
+
var TenantRateLimiter = class {
|
|
43
|
+
constructor(broker, quotas = /* @__PURE__ */ new Map()) {
|
|
44
|
+
this.broker = broker;
|
|
45
|
+
this.quotas = quotas;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Check rate limit for a tenant
|
|
49
|
+
*
|
|
50
|
+
* @param tenantId - Tenant identifier
|
|
51
|
+
* @param resource - Resource type to rate limit
|
|
52
|
+
* @returns Rate limit check result
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* const result = await limiter.checkLimit('acme', 'requests');
|
|
56
|
+
* if (!result.allowed) {
|
|
57
|
+
* throw new Error(`Rate limit exceeded. Reset at ${result.resetAt}`);
|
|
58
|
+
* }
|
|
59
|
+
*/
|
|
60
|
+
async checkLimit(tenantId, resource) {
|
|
61
|
+
const quota = this.quotas.get(tenantId) ?? DEFAULT_QUOTAS.free;
|
|
62
|
+
const key = `ratelimit:tenant:${tenantId}:${resource}:${this.getWindow()}`;
|
|
63
|
+
const current = await this.broker.get(key) ?? 0;
|
|
64
|
+
const limit = this.getLimit(quota, resource);
|
|
65
|
+
if (current >= limit) {
|
|
66
|
+
return {
|
|
67
|
+
allowed: false,
|
|
68
|
+
remaining: 0,
|
|
69
|
+
resetAt: this.getResetTime(),
|
|
70
|
+
limit
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
await this.broker.set(key, current + 1, 60 * 1e3);
|
|
74
|
+
return {
|
|
75
|
+
allowed: true,
|
|
76
|
+
remaining: limit - current - 1,
|
|
77
|
+
resetAt: this.getResetTime(),
|
|
78
|
+
limit
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Set quotas for a tenant
|
|
83
|
+
*
|
|
84
|
+
* @param tenantId - Tenant identifier
|
|
85
|
+
* @param quotas - Tenant quotas
|
|
86
|
+
*/
|
|
87
|
+
setQuotas(tenantId, quotas) {
|
|
88
|
+
this.quotas.set(tenantId, quotas);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Set quotas for a tenant tier
|
|
92
|
+
*
|
|
93
|
+
* @param tenantId - Tenant identifier
|
|
94
|
+
* @param tier - Tenant tier
|
|
95
|
+
*/
|
|
96
|
+
setTier(tenantId, tier) {
|
|
97
|
+
this.quotas.set(tenantId, { ...DEFAULT_QUOTAS[tier] });
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get current window identifier (minute precision)
|
|
101
|
+
* @returns ISO timestamp truncated to minutes
|
|
102
|
+
*/
|
|
103
|
+
getWindow() {
|
|
104
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 16);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get limit for resource type
|
|
108
|
+
*
|
|
109
|
+
* @param quota - Tenant quotas
|
|
110
|
+
* @param resource - Resource type
|
|
111
|
+
* @returns Limit value
|
|
112
|
+
*/
|
|
113
|
+
getLimit(quota, resource) {
|
|
114
|
+
switch (resource) {
|
|
115
|
+
case "requests":
|
|
116
|
+
return quota.requestsPerMinute;
|
|
117
|
+
case "workflows":
|
|
118
|
+
return quota.maxConcurrentWorkflows;
|
|
119
|
+
case "plugins":
|
|
120
|
+
return quota.pluginExecutionsPerDay === -1 ? Number.MAX_SAFE_INTEGER : Math.ceil(quota.pluginExecutionsPerDay / 1440);
|
|
121
|
+
default:
|
|
122
|
+
return 100;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Get reset time for current window
|
|
127
|
+
* @returns Unix timestamp in milliseconds
|
|
128
|
+
*/
|
|
129
|
+
getResetTime() {
|
|
130
|
+
const now = /* @__PURE__ */ new Date();
|
|
131
|
+
now.setSeconds(0, 0);
|
|
132
|
+
now.setMinutes(now.getMinutes() + 1);
|
|
133
|
+
return now.getTime();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export { DEFAULT_QUOTAS, TenantRateLimiter, getDefaultTenantId, getDefaultTenantTier, getQuotasForTier };
|
|
138
|
+
//# sourceMappingURL=index.js.map
|
|
139
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types.ts","../src/rate-limiter.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @module @kb-labs/tenant/types\n * Multi-tenancy types and quota definitions\n */\n\n/**\n * Tenant tier levels\n */\nexport type TenantTier = 'free' | 'pro' | 'enterprise';\n\n/**\n * Tenant resource quotas\n */\nexport interface TenantQuotas {\n /** Requests per minute limit */\n requestsPerMinute: number;\n /** Requests per day limit (-1 = unlimited) */\n requestsPerDay: number;\n /** Maximum concurrent workflows */\n maxConcurrentWorkflows: number;\n /** Maximum storage in MB */\n maxStorageMB: number;\n /** Plugin executions per day (-1 = unlimited) */\n pluginExecutionsPerDay: number;\n}\n\n/**\n * Tenant configuration\n */\nexport interface TenantConfig {\n /** Unique tenant identifier */\n id: string;\n /** Tenant tier */\n tier: TenantTier;\n /** Resource quotas */\n quotas: TenantQuotas;\n /** Creation timestamp */\n createdAt: string;\n /** Last update timestamp */\n updatedAt: string;\n}\n\n/**\n * Default quotas by tier\n */\nexport const DEFAULT_QUOTAS: Record<TenantTier, TenantQuotas> = {\n free: {\n requestsPerMinute: 10,\n requestsPerDay: 1000,\n maxConcurrentWorkflows: 1,\n maxStorageMB: 10,\n pluginExecutionsPerDay: 100,\n },\n pro: {\n requestsPerMinute: 100,\n requestsPerDay: 100_000,\n maxConcurrentWorkflows: 10,\n maxStorageMB: 1000,\n pluginExecutionsPerDay: 10_000,\n },\n enterprise: {\n requestsPerMinute: 1000,\n requestsPerDay: -1, // unlimited\n maxConcurrentWorkflows: 100,\n maxStorageMB: 10_000,\n pluginExecutionsPerDay: -1, // unlimited\n },\n};\n\n/**\n * Get default tenant ID from environment\n * @returns Tenant ID (defaults to 'default' for single-tenant deployments)\n */\nexport function getDefaultTenantId(): string {\n return process.env.KB_TENANT_ID || 'default';\n}\n\n/**\n * Get default tenant tier from environment\n * @returns Tenant tier (defaults to 'free')\n */\nexport function getDefaultTenantTier(): TenantTier {\n const tier = process.env.KB_TENANT_DEFAULT_TIER?.toLowerCase();\n if (tier === 'pro' || tier === 'enterprise') {\n return tier;\n }\n return 'free';\n}\n\n/**\n * Get tenant quotas for a tier\n * @param tier - Tenant tier\n * @returns Quotas for the tier\n */\nexport function getQuotasForTier(tier: TenantTier): TenantQuotas {\n return { ...DEFAULT_QUOTAS[tier] };\n}\n","/**\n * @module @kb-labs/tenant/rate-limiter\n * Tenant rate limiter using existing State Broker infrastructure\n *\n * Benefits:\n * - Zero new dependencies\n * - TTL cleanup already implemented (30s interval)\n * - Consistent with state management patterns\n * - Same backend as plugin state (in-memory or HTTP daemon)\n */\n\nimport type { StateBroker } from '@kb-labs/core-state-broker';\nimport type { TenantQuotas, TenantTier } from './types';\nimport { DEFAULT_QUOTAS } from './types';\n\n/**\n * Rate limit check result\n */\nexport interface RateLimitResult {\n /** Whether the request is allowed */\n allowed: boolean;\n /** Remaining requests in current window */\n remaining: number;\n /** Timestamp when the limit resets (Unix ms) */\n resetAt: number;\n /** Limit for current window */\n limit: number;\n}\n\n/**\n * Rate limit resource types\n */\nexport type RateLimitResource = 'requests' | 'workflows' | 'plugins';\n\n/**\n * Tenant rate limiter\n * Uses State Broker for distributed rate limiting with TTL\n */\nexport class TenantRateLimiter {\n constructor(\n private broker: StateBroker,\n private quotas: Map<string, TenantQuotas> = new Map()\n ) {}\n\n /**\n * Check rate limit for a tenant\n *\n * @param tenantId - Tenant identifier\n * @param resource - Resource type to rate limit\n * @returns Rate limit check result\n *\n * @example\n * const result = await limiter.checkLimit('acme', 'requests');\n * if (!result.allowed) {\n * throw new Error(`Rate limit exceeded. Reset at ${result.resetAt}`);\n * }\n */\n async checkLimit(\n tenantId: string,\n resource: RateLimitResource\n ): Promise<RateLimitResult> {\n const quota = this.quotas.get(tenantId) ?? DEFAULT_QUOTAS.free;\n\n // Key pattern: ratelimit:tenant:default:requests:2025-01-15T10:30\n // Follows existing namespace:key pattern from State Broker\n const key = `ratelimit:tenant:${tenantId}:${resource}:${this.getWindow()}`;\n\n const current = (await this.broker.get<number>(key)) ?? 0;\n const limit = this.getLimit(quota, resource);\n\n if (current >= limit) {\n return {\n allowed: false,\n remaining: 0,\n resetAt: this.getResetTime(),\n limit,\n };\n }\n\n // Increment counter with 60s TTL\n // State Broker cleanup (30s interval) will remove expired entries\n await this.broker.set(key, current + 1, 60 * 1000);\n\n return {\n allowed: true,\n remaining: limit - current - 1,\n resetAt: this.getResetTime(),\n limit,\n };\n }\n\n /**\n * Set quotas for a tenant\n *\n * @param tenantId - Tenant identifier\n * @param quotas - Tenant quotas\n */\n setQuotas(tenantId: string, quotas: TenantQuotas): void {\n this.quotas.set(tenantId, quotas);\n }\n\n /**\n * Set quotas for a tenant tier\n *\n * @param tenantId - Tenant identifier\n * @param tier - Tenant tier\n */\n setTier(tenantId: string, tier: TenantTier): void {\n this.quotas.set(tenantId, { ...DEFAULT_QUOTAS[tier] });\n }\n\n /**\n * Get current window identifier (minute precision)\n * @returns ISO timestamp truncated to minutes\n */\n private getWindow(): string {\n return new Date().toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM\n }\n\n /**\n * Get limit for resource type\n *\n * @param quota - Tenant quotas\n * @param resource - Resource type\n * @returns Limit value\n */\n private getLimit(quota: TenantQuotas, resource: RateLimitResource): number {\n switch (resource) {\n case 'requests':\n return quota.requestsPerMinute;\n case 'workflows':\n return quota.maxConcurrentWorkflows;\n case 'plugins':\n // Convert daily limit to per-minute (1440 minutes in a day)\n return quota.pluginExecutionsPerDay === -1\n ? Number.MAX_SAFE_INTEGER\n : Math.ceil(quota.pluginExecutionsPerDay / 1440);\n default:\n return 100; // default fallback\n }\n }\n\n /**\n * Get reset time for current window\n * @returns Unix timestamp in milliseconds\n */\n private getResetTime(): number {\n const now = new Date();\n now.setSeconds(0, 0);\n now.setMinutes(now.getMinutes() + 1);\n return now.getTime();\n }\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kb-labs/core-tenant",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Multi-tenancy primitives for KB Labs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@kb-labs/core-state-broker": "1.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tsup": "^8.5.0",
|
|
25
|
+
"typescript": "^5.6.3",
|
|
26
|
+
"rimraf": "^6.0.1",
|
|
27
|
+
"@kb-labs/devkit": "link:../../../kb-labs-devkit",
|
|
28
|
+
"@types/node": "^24.3.3",
|
|
29
|
+
"vitest": "^3.2.4"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.18.0",
|
|
33
|
+
"pnpm": ">=9.0.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup",
|
|
40
|
+
"clean": "rimraf dist",
|
|
41
|
+
"dev": "tsup --watch",
|
|
42
|
+
"type-check": "tsc --noEmit",
|
|
43
|
+
"lint": "eslint src --ext .ts",
|
|
44
|
+
"lint:fix": "eslint . --fix",
|
|
45
|
+
"test": "vitest run --passWithNoTests",
|
|
46
|
+
"test:watch": "vitest"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @kb-labs/tenant
|
|
3
|
+
* Multi-tenancy primitives for KB Labs
|
|
4
|
+
*
|
|
5
|
+
* Provides tenant management, quotas, and rate limiting
|
|
6
|
+
* using existing infrastructure (State Broker, LogContext, Prometheus)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export * from './types';
|
|
10
|
+
export * from './rate-limiter';
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @kb-labs/tenant/rate-limiter
|
|
3
|
+
* Tenant rate limiter using existing State Broker infrastructure
|
|
4
|
+
*
|
|
5
|
+
* Benefits:
|
|
6
|
+
* - Zero new dependencies
|
|
7
|
+
* - TTL cleanup already implemented (30s interval)
|
|
8
|
+
* - Consistent with state management patterns
|
|
9
|
+
* - Same backend as plugin state (in-memory or HTTP daemon)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StateBroker } from '@kb-labs/core-state-broker';
|
|
13
|
+
import type { TenantQuotas, TenantTier } from './types';
|
|
14
|
+
import { DEFAULT_QUOTAS } from './types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Rate limit check result
|
|
18
|
+
*/
|
|
19
|
+
export interface RateLimitResult {
|
|
20
|
+
/** Whether the request is allowed */
|
|
21
|
+
allowed: boolean;
|
|
22
|
+
/** Remaining requests in current window */
|
|
23
|
+
remaining: number;
|
|
24
|
+
/** Timestamp when the limit resets (Unix ms) */
|
|
25
|
+
resetAt: number;
|
|
26
|
+
/** Limit for current window */
|
|
27
|
+
limit: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rate limit resource types
|
|
32
|
+
*/
|
|
33
|
+
export type RateLimitResource = 'requests' | 'workflows' | 'plugins';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Tenant rate limiter
|
|
37
|
+
* Uses State Broker for distributed rate limiting with TTL
|
|
38
|
+
*/
|
|
39
|
+
export class TenantRateLimiter {
|
|
40
|
+
constructor(
|
|
41
|
+
private broker: StateBroker,
|
|
42
|
+
private quotas: Map<string, TenantQuotas> = new Map()
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check rate limit for a tenant
|
|
47
|
+
*
|
|
48
|
+
* @param tenantId - Tenant identifier
|
|
49
|
+
* @param resource - Resource type to rate limit
|
|
50
|
+
* @returns Rate limit check result
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* const result = await limiter.checkLimit('acme', 'requests');
|
|
54
|
+
* if (!result.allowed) {
|
|
55
|
+
* throw new Error(`Rate limit exceeded. Reset at ${result.resetAt}`);
|
|
56
|
+
* }
|
|
57
|
+
*/
|
|
58
|
+
async checkLimit(
|
|
59
|
+
tenantId: string,
|
|
60
|
+
resource: RateLimitResource
|
|
61
|
+
): Promise<RateLimitResult> {
|
|
62
|
+
const quota = this.quotas.get(tenantId) ?? DEFAULT_QUOTAS.free;
|
|
63
|
+
|
|
64
|
+
// Key pattern: ratelimit:tenant:default:requests:2025-01-15T10:30
|
|
65
|
+
// Follows existing namespace:key pattern from State Broker
|
|
66
|
+
const key = `ratelimit:tenant:${tenantId}:${resource}:${this.getWindow()}`;
|
|
67
|
+
|
|
68
|
+
const current = (await this.broker.get<number>(key)) ?? 0;
|
|
69
|
+
const limit = this.getLimit(quota, resource);
|
|
70
|
+
|
|
71
|
+
if (current >= limit) {
|
|
72
|
+
return {
|
|
73
|
+
allowed: false,
|
|
74
|
+
remaining: 0,
|
|
75
|
+
resetAt: this.getResetTime(),
|
|
76
|
+
limit,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Increment counter with 60s TTL
|
|
81
|
+
// State Broker cleanup (30s interval) will remove expired entries
|
|
82
|
+
await this.broker.set(key, current + 1, 60 * 1000);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
allowed: true,
|
|
86
|
+
remaining: limit - current - 1,
|
|
87
|
+
resetAt: this.getResetTime(),
|
|
88
|
+
limit,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Set quotas for a tenant
|
|
94
|
+
*
|
|
95
|
+
* @param tenantId - Tenant identifier
|
|
96
|
+
* @param quotas - Tenant quotas
|
|
97
|
+
*/
|
|
98
|
+
setQuotas(tenantId: string, quotas: TenantQuotas): void {
|
|
99
|
+
this.quotas.set(tenantId, quotas);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Set quotas for a tenant tier
|
|
104
|
+
*
|
|
105
|
+
* @param tenantId - Tenant identifier
|
|
106
|
+
* @param tier - Tenant tier
|
|
107
|
+
*/
|
|
108
|
+
setTier(tenantId: string, tier: TenantTier): void {
|
|
109
|
+
this.quotas.set(tenantId, { ...DEFAULT_QUOTAS[tier] });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get current window identifier (minute precision)
|
|
114
|
+
* @returns ISO timestamp truncated to minutes
|
|
115
|
+
*/
|
|
116
|
+
private getWindow(): string {
|
|
117
|
+
return new Date().toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get limit for resource type
|
|
122
|
+
*
|
|
123
|
+
* @param quota - Tenant quotas
|
|
124
|
+
* @param resource - Resource type
|
|
125
|
+
* @returns Limit value
|
|
126
|
+
*/
|
|
127
|
+
private getLimit(quota: TenantQuotas, resource: RateLimitResource): number {
|
|
128
|
+
switch (resource) {
|
|
129
|
+
case 'requests':
|
|
130
|
+
return quota.requestsPerMinute;
|
|
131
|
+
case 'workflows':
|
|
132
|
+
return quota.maxConcurrentWorkflows;
|
|
133
|
+
case 'plugins':
|
|
134
|
+
// Convert daily limit to per-minute (1440 minutes in a day)
|
|
135
|
+
return quota.pluginExecutionsPerDay === -1
|
|
136
|
+
? Number.MAX_SAFE_INTEGER
|
|
137
|
+
: Math.ceil(quota.pluginExecutionsPerDay / 1440);
|
|
138
|
+
default:
|
|
139
|
+
return 100; // default fallback
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get reset time for current window
|
|
145
|
+
* @returns Unix timestamp in milliseconds
|
|
146
|
+
*/
|
|
147
|
+
private getResetTime(): number {
|
|
148
|
+
const now = new Date();
|
|
149
|
+
now.setSeconds(0, 0);
|
|
150
|
+
now.setMinutes(now.getMinutes() + 1);
|
|
151
|
+
return now.getTime();
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @kb-labs/tenant/types
|
|
3
|
+
* Multi-tenancy types and quota definitions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tenant tier levels
|
|
8
|
+
*/
|
|
9
|
+
export type TenantTier = 'free' | 'pro' | 'enterprise';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tenant resource quotas
|
|
13
|
+
*/
|
|
14
|
+
export interface TenantQuotas {
|
|
15
|
+
/** Requests per minute limit */
|
|
16
|
+
requestsPerMinute: number;
|
|
17
|
+
/** Requests per day limit (-1 = unlimited) */
|
|
18
|
+
requestsPerDay: number;
|
|
19
|
+
/** Maximum concurrent workflows */
|
|
20
|
+
maxConcurrentWorkflows: number;
|
|
21
|
+
/** Maximum storage in MB */
|
|
22
|
+
maxStorageMB: number;
|
|
23
|
+
/** Plugin executions per day (-1 = unlimited) */
|
|
24
|
+
pluginExecutionsPerDay: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tenant configuration
|
|
29
|
+
*/
|
|
30
|
+
export interface TenantConfig {
|
|
31
|
+
/** Unique tenant identifier */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Tenant tier */
|
|
34
|
+
tier: TenantTier;
|
|
35
|
+
/** Resource quotas */
|
|
36
|
+
quotas: TenantQuotas;
|
|
37
|
+
/** Creation timestamp */
|
|
38
|
+
createdAt: string;
|
|
39
|
+
/** Last update timestamp */
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Default quotas by tier
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_QUOTAS: Record<TenantTier, TenantQuotas> = {
|
|
47
|
+
free: {
|
|
48
|
+
requestsPerMinute: 10,
|
|
49
|
+
requestsPerDay: 1000,
|
|
50
|
+
maxConcurrentWorkflows: 1,
|
|
51
|
+
maxStorageMB: 10,
|
|
52
|
+
pluginExecutionsPerDay: 100,
|
|
53
|
+
},
|
|
54
|
+
pro: {
|
|
55
|
+
requestsPerMinute: 100,
|
|
56
|
+
requestsPerDay: 100_000,
|
|
57
|
+
maxConcurrentWorkflows: 10,
|
|
58
|
+
maxStorageMB: 1000,
|
|
59
|
+
pluginExecutionsPerDay: 10_000,
|
|
60
|
+
},
|
|
61
|
+
enterprise: {
|
|
62
|
+
requestsPerMinute: 1000,
|
|
63
|
+
requestsPerDay: -1, // unlimited
|
|
64
|
+
maxConcurrentWorkflows: 100,
|
|
65
|
+
maxStorageMB: 10_000,
|
|
66
|
+
pluginExecutionsPerDay: -1, // unlimited
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get default tenant ID from environment
|
|
72
|
+
* @returns Tenant ID (defaults to 'default' for single-tenant deployments)
|
|
73
|
+
*/
|
|
74
|
+
export function getDefaultTenantId(): string {
|
|
75
|
+
return process.env.KB_TENANT_ID || 'default';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get default tenant tier from environment
|
|
80
|
+
* @returns Tenant tier (defaults to 'free')
|
|
81
|
+
*/
|
|
82
|
+
export function getDefaultTenantTier(): TenantTier {
|
|
83
|
+
const tier = process.env.KB_TENANT_DEFAULT_TIER?.toLowerCase();
|
|
84
|
+
if (tier === 'pro' || tier === 'enterprise') {
|
|
85
|
+
return tier;
|
|
86
|
+
}
|
|
87
|
+
return 'free';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get tenant quotas for a tier
|
|
92
|
+
* @param tier - Tenant tier
|
|
93
|
+
* @returns Quotas for the tier
|
|
94
|
+
*/
|
|
95
|
+
export function getQuotasForTier(tier: TenantTier): TenantQuotas {
|
|
96
|
+
return { ...DEFAULT_QUOTAS[tier] };
|
|
97
|
+
}
|