Skip to content

Commit 82a7dbb

Browse files
Claudehotlong
andauthored
feat(multi-tenant): implement minimal prototype with UUID-based architecture
Implements Phase 1 of multi-tenant architecture following Airtable's multi-workspace design and better-auth's multi-tenancy patterns. **Architecture Decisions:** - Database naming: {uuid}.turso.io (not org-slug, for immutability) - Global control plane: Single database for auth and tenant registry - Tenant data plane: Isolated Turso databases per organization **New Schemas (packages/spec/src/cloud/tenant.zod.ts):** - TenantDatabaseSchema - Tenant database registry with UUID-based naming - PackageInstallationSchema - Per-tenant package installation tracking - TenantContextSchema - Runtime tenant context - TenantRoutingConfigSchema - Multi-tenant routing configuration - ProvisionTenantRequest/ResponseSchema - Tenant provisioning protocol **New Package (@objectstack/service-tenant):** - TenantContextService - Multi-source tenant identification and caching - Supports: subdomain, custom domain, headers, JWT claims, session - UUID-based tenant routing with context caching - TenantProvisioningService - Tenant database provisioning skeleton - Minimal prototype (Turso Platform API integration pending) - Lifecycle methods: provision, suspend, archive, restore - TenantPlugin - Kernel integration for tenant services - Test coverage: 8 tests for identification strategies and caching **Enhanced Multi-Tenant Router:** - Updated documentation with UUID naming conventions - Added examples for UUID-based tenant IDs - Clarified immutability benefits vs org-slug approach **Updated ROADMAP.md:** - Phase 1: Multi-Tenant Protocol & Minimal Prototype ✅ Complete (2026-04-17) - Phase 2: Turso Platform API Integration 🔴 Planned - Phase 3: Production Hardening 🔴 Planned **References:** - Airtable multi-workspace design - better-auth multi-tenancy plugin - Turso database-per-tenant strategy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 1147e48 commit 82a7dbb

14 files changed

Lines changed: 1129 additions & 6 deletions

File tree

ROADMAP.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,28 @@ Objects now declare `namespace: 'sys'` and a short `name` (e.g., `name: 'user'`)
606606
- [x] Metadata-driven deploy pipeline — `system/deploy-bundle.zod.ts`: `DeployBundleSchema`, `MigrationPlanSchema`, `DeployDiffSchema`; `contracts/deploy-pipeline-service.ts`: `IDeployPipelineService`
607607
- [x] App marketplace installation protocol — `system/app-install.zod.ts`: `AppManifestSchema`, `AppInstallResultSchema`, `AppCompatibilityCheckSchema`; `contracts/app-lifecycle-service.ts`: `IAppLifecycleService`
608608
- [ ] Cross-tenant data sharing policies
609+
- [x] **Phase 1: Multi-Tenant Protocol & Minimal Prototype (v3.4)** — ✅ Complete (2026-04-17)
610+
- [x] UUID-based tenant database naming (not org-slug, for immutability)
611+
- [x] Tenant registry schema — `cloud/tenant.zod.ts`: `TenantDatabaseSchema`, `PackageInstallationSchema`, `TenantContextSchema`, `TenantRoutingConfigSchema`, `ProvisionTenantRequestSchema`, `ProvisionTenantResponseSchema`
612+
- [x] `@objectstack/service-tenant` package — Tenant context service, multi-tenant router integration, UUID-based naming enforcement
613+
- [x] Tenant identification strategies — Subdomain, custom domain, HTTP header, JWT claim, session
614+
- [x] TenantContextService — Tenant context resolution with caching and multiple identification sources
615+
- [x] TenantProvisioningService skeleton — Minimal prototype for tenant database provisioning (Turso Platform API integration pending)
616+
- [x] Multi-tenant router documentation updates — UUID naming conventions and examples
617+
- [x] Test coverage — TenantContextService identification and caching tests
618+
- [ ] **Phase 2: Turso Platform API Integration (v3.5)** — 🔴 Planned
619+
- [ ] Turso Platform API client implementation
620+
- [ ] Automated tenant database creation
621+
- [ ] Tenant-specific auth token generation
622+
- [ ] Global control plane database setup (sys_tenant_registry, sys_package_installation)
623+
- [ ] Tenant database schema initialization
624+
- [ ] Package installation per tenant
625+
- [ ] **Phase 3: Production Hardening (v4.0)** — 🔴 Planned
626+
- [ ] Tenant lifecycle management (suspend, archive, restore)
627+
- [ ] Multi-region tenant migration
628+
- [ ] Tenant usage tracking and quota enforcement
629+
- [ ] Cross-tenant data sharing policies
630+
- [ ] Tenant-specific RBAC and permissions
609631

610632
### 6.3 Observability
611633

packages/plugins/driver-turso/src/multi-tenant.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
*
66
* Manages per-tenant TursoDriver instances with TTL-based caching.
77
* Uses a URL template with `{tenant}` placeholder that is replaced
8-
* with the tenantId at runtime.
8+
* with the tenantId (UUID) at runtime.
99
*
10-
* Serverless-safe: no global intervals, no leaked state. Expired
10+
* **UUID-Based Tenant Naming:**
11+
* - Tenant IDs are UUIDs, not organization slugs
12+
* - Ensures database URLs remain stable even if organization names change
13+
* - Example: `550e8400-e29b-41d4-a716-446655440000.turso.io`
14+
*
15+
* **Serverless-safe:** no global intervals, no leaked state. Expired
1116
* entries are evicted lazily on next access.
1217
*/
1318

@@ -18,20 +23,32 @@ import { TursoDriver, type TursoDriverConfig } from './turso-driver.js';
1823
/**
1924
* Configuration for the multi-tenant router.
2025
*
26+
* **UUID-Based Tenant Naming:**
27+
* The `{tenant}` placeholder is replaced with a UUID, not an organization slug.
28+
* This ensures database URLs remain stable even if organization names change.
29+
*
2130
* @example
2231
* ```typescript
2332
* const router = createMultiTenantRouter({
24-
* urlTemplate: 'file:./data/{tenant}.db',
33+
* // UUID-based URL template
34+
* urlTemplate: 'libsql://{tenant}.turso.io',
35+
* groupAuthToken: process.env.TURSO_GROUP_TOKEN,
2536
* clientCacheTTL: 300_000, // 5 minutes
2637
* });
2738
*
28-
* const driver = await router.getDriverForTenant('acme');
39+
* // Tenant ID is a UUID
40+
* const driver = await router.getDriverForTenant('550e8400-e29b-41d4-a716-446655440000');
2941
* ```
3042
*/
3143
export interface MultiTenantConfig {
3244
/**
33-
* URL template with `{tenant}` placeholder.
34-
* Example: `'file:./data/{tenant}.db'`
45+
* URL template with `{tenant}` placeholder (replaced with UUID at runtime).
46+
*
47+
* Examples:
48+
* - Remote: `'libsql://{tenant}.turso.io'`
49+
* - Local: `'file:./data/{tenant}.db'`
50+
*
51+
* **Important:** Use UUID for tenant ID, not organization slug.
3552
*/
3653
urlTemplate: string;
3754

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# @objectstack/service-tenant
2+
3+
Multi-tenant context management and routing service for ObjectStack.
4+
5+
## Overview
6+
7+
This service provides tenant identification and context resolution for multi-tenant ObjectStack deployments. It supports multiple identification strategies and manages tenant-specific database routing.
8+
9+
## Features
10+
11+
- **Multiple Identification Sources**: Subdomain, custom domain, HTTP headers, JWT claims, session
12+
- **UUID-Based Tenant Naming**: Immutable tenant identifiers (not organization slugs)
13+
- **Tenant Context Caching**: Performance optimization for frequently accessed tenants
14+
- **Flexible Configuration**: Priority-based identification source ordering
15+
16+
## Installation
17+
18+
```bash
19+
pnpm add @objectstack/service-tenant
20+
```
21+
22+
## Usage
23+
24+
### Basic Setup
25+
26+
```typescript
27+
import { createTenantPlugin } from '@objectstack/service-tenant';
28+
import { ObjectKernel } from '@objectstack/core';
29+
30+
const kernel = new ObjectKernel();
31+
32+
// Create tenant plugin
33+
const tenantPlugin = createTenantPlugin({
34+
enabled: true,
35+
identificationSources: ['header', 'custom_domain', 'jwt_claim'],
36+
tenantHeaderName: 'X-Tenant-ID',
37+
customDomainMapping: {
38+
'app.acme.com': '550e8400-e29b-41d4-a716-446655440000',
39+
},
40+
});
41+
42+
await kernel.use(tenantPlugin);
43+
await kernel.bootstrap();
44+
```
45+
46+
### Resolving Tenant Context
47+
48+
```typescript
49+
import { TenantContextService } from '@objectstack/service-tenant';
50+
51+
const service = kernel.getService<TenantContextService>('tenant');
52+
53+
const context = await service.resolveTenantContext({
54+
hostname: 'app.acme.com',
55+
headers: {
56+
'X-Tenant-ID': '550e8400-e29b-41d4-a716-446655440000',
57+
},
58+
jwt: {
59+
organizationId: 'org-123',
60+
},
61+
});
62+
63+
console.log(context);
64+
// {
65+
// tenantId: '550e8400-e29b-41d4-a716-446655440000',
66+
// organizationId: 'org-123',
67+
// databaseUrl: 'libsql://550e8400-e29b-41d4-a716-446655440000.turso.io',
68+
// plan: 'pro'
69+
// }
70+
```
71+
72+
## Architecture
73+
74+
### Tenant Identification Flow
75+
76+
```
77+
Request → TenantContextService → Identification Sources (in order)
78+
79+
1. Subdomain
80+
2. Custom Domain
81+
3. HTTP Header
82+
4. JWT Claim
83+
5. Session
84+
6. Default Tenant
85+
86+
Tenant Context
87+
```
88+
89+
### UUID-Based Naming
90+
91+
Tenant databases use UUID naming instead of organization slugs:
92+
93+
- **Why**: Organization slugs can be modified, UUIDs are immutable
94+
- **Format**: `{uuid}.turso.io` (e.g., `550e8400-e29b-41d4-a716-446655440000.turso.io`)
95+
- **Benefit**: Stable database URLs regardless of organization name changes
96+
97+
## Configuration
98+
99+
### TenantRoutingConfig
100+
101+
```typescript
102+
interface TenantRoutingConfig {
103+
// Enable multi-tenant mode
104+
enabled: boolean;
105+
106+
// Identification strategy (in order of precedence)
107+
identificationSources: TenantIdentificationSource[];
108+
109+
// Default tenant ID (for single-tenant or fallback)
110+
defaultTenantId?: string;
111+
112+
// Subdomain pattern for tenant extraction
113+
subdomainPattern?: string;
114+
115+
// Custom domain to tenant ID mapping
116+
customDomainMapping?: Record<string, string>;
117+
118+
// Header name for tenant ID
119+
tenantHeaderName: string; // Default: 'X-Tenant-ID'
120+
121+
// JWT claim name for organization ID
122+
jwtOrganizationClaim: string; // Default: 'organizationId'
123+
}
124+
```
125+
126+
## Testing
127+
128+
```bash
129+
# Run tests
130+
pnpm test
131+
132+
# Run tests in watch mode
133+
pnpm test:watch
134+
```
135+
136+
## License
137+
138+
Apache-2.0
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "@objectstack/service-tenant",
3+
"version": "0.1.0",
4+
"description": "ObjectStack Multi-Tenant Service - Tenant context management and routing",
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+
}
13+
},
14+
"files": [
15+
"dist",
16+
"README.md"
17+
],
18+
"scripts": {
19+
"build": "tsup",
20+
"dev": "tsup --watch",
21+
"test": "vitest run",
22+
"test:watch": "vitest",
23+
"typecheck": "tsc --noEmit"
24+
},
25+
"dependencies": {
26+
"@objectstack/spec": "workspace:^",
27+
"@objectstack/core": "workspace:^"
28+
},
29+
"devDependencies": {
30+
"@types/node": "^22.10.5",
31+
"tsup": "^8.3.5",
32+
"typescript": "^5.7.3",
33+
"vitest": "^2.1.8"
34+
},
35+
"publishConfig": {
36+
"access": "public"
37+
},
38+
"repository": {
39+
"type": "git",
40+
"url": "https://github.com/objectstack-ai/framework.git",
41+
"directory": "packages/services/service-tenant"
42+
},
43+
"keywords": [
44+
"objectstack",
45+
"multi-tenant",
46+
"saas",
47+
"tenant-routing"
48+
],
49+
"author": "ObjectStack Team",
50+
"license": "Apache-2.0"
51+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
export * from './tenant-context';
4+
export * from './tenant-plugin';
5+
export * from './tenant-provisioning';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { TenantContextService } from '../src/tenant-context';
5+
import type { TenantRoutingConfig } from '@objectstack/spec/cloud';
6+
7+
describe('TenantContextService', () => {
8+
let service: TenantContextService;
9+
10+
beforeEach(() => {
11+
const config: TenantRoutingConfig = {
12+
enabled: true,
13+
identificationSources: ['header', 'custom_domain'],
14+
tenantHeaderName: 'X-Tenant-ID',
15+
customDomainMapping: {
16+
'app.acme.com': '550e8400-e29b-41d4-a716-446655440000',
17+
},
18+
};
19+
service = new TenantContextService(config);
20+
});
21+
22+
describe('resolveTenantContext', () => {
23+
it('should extract tenant from header', async () => {
24+
const context = await service.resolveTenantContext({
25+
headers: {
26+
'X-Tenant-ID': '550e8400-e29b-41d4-a716-446655440000',
27+
},
28+
});
29+
30+
expect(context).toBeDefined();
31+
expect(context?.tenantId).toBe('550e8400-e29b-41d4-a716-446655440000');
32+
expect(context?.databaseUrl).toBe('libsql://550e8400-e29b-41d4-a716-446655440000.turso.io');
33+
});
34+
35+
it('should extract tenant from custom domain', async () => {
36+
const context = await service.resolveTenantContext({
37+
hostname: 'app.acme.com',
38+
});
39+
40+
expect(context).toBeDefined();
41+
expect(context?.tenantId).toBe('550e8400-e29b-41d4-a716-446655440000');
42+
});
43+
44+
it('should return null when multi-tenant is disabled', async () => {
45+
const disabledConfig: TenantRoutingConfig = {
46+
enabled: false,
47+
identificationSources: [],
48+
};
49+
const disabledService = new TenantContextService(disabledConfig);
50+
51+
const context = await disabledService.resolveTenantContext({
52+
headers: { 'X-Tenant-ID': '123' },
53+
});
54+
55+
expect(context).toBeNull();
56+
});
57+
58+
it('should cache tenant contexts', async () => {
59+
const context1 = await service.resolveTenantContext({
60+
headers: { 'X-Tenant-ID': 'tenant-123' },
61+
});
62+
63+
const context2 = await service.resolveTenantContext({
64+
headers: { 'X-Tenant-ID': 'tenant-123' },
65+
});
66+
67+
expect(context1).toBe(context2); // Same object reference from cache
68+
});
69+
});
70+
71+
describe('cache management', () => {
72+
it('should clear cache', async () => {
73+
await service.resolveTenantContext({
74+
headers: { 'X-Tenant-ID': 'tenant-123' },
75+
});
76+
77+
service.clearCache();
78+
79+
// After clearing cache, should create new context
80+
const context = await service.resolveTenantContext({
81+
headers: { 'X-Tenant-ID': 'tenant-123' },
82+
});
83+
84+
expect(context).toBeDefined();
85+
});
86+
87+
it('should invalidate specific tenant', async () => {
88+
await service.resolveTenantContext({
89+
headers: { 'X-Tenant-ID': 'tenant-123' },
90+
});
91+
92+
service.invalidateTenant('tenant-123');
93+
94+
// Should still work after invalidation
95+
const context = await service.resolveTenantContext({
96+
headers: { 'X-Tenant-ID': 'tenant-123' },
97+
});
98+
99+
expect(context).toBeDefined();
100+
});
101+
});
102+
});

0 commit comments

Comments
 (0)