diff --git a/ROADMAP.md b/ROADMAP.md
index cc6bfe96e..563559563 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -606,6 +606,28 @@ Objects now declare `namespace: 'sys'` and a short `name` (e.g., `name: 'user'`)
- [x] Metadata-driven deploy pipeline — `system/deploy-bundle.zod.ts`: `DeployBundleSchema`, `MigrationPlanSchema`, `DeployDiffSchema`; `contracts/deploy-pipeline-service.ts`: `IDeployPipelineService`
- [x] App marketplace installation protocol — `system/app-install.zod.ts`: `AppManifestSchema`, `AppInstallResultSchema`, `AppCompatibilityCheckSchema`; `contracts/app-lifecycle-service.ts`: `IAppLifecycleService`
- [ ] Cross-tenant data sharing policies
+- [x] **Phase 1: Multi-Tenant Protocol & Minimal Prototype (v3.4)** — ✅ Complete (2026-04-17)
+ - [x] UUID-based tenant database naming (not org-slug, for immutability)
+ - [x] Tenant registry schema — `cloud/tenant.zod.ts`: `TenantDatabaseSchema`, `PackageInstallationSchema`, `TenantContextSchema`, `TenantRoutingConfigSchema`, `ProvisionTenantRequestSchema`, `ProvisionTenantResponseSchema`
+ - [x] `@objectstack/service-tenant` package — Tenant context service, multi-tenant router integration, UUID-based naming enforcement
+ - [x] Tenant identification strategies — Subdomain, custom domain, HTTP header, JWT claim, session
+ - [x] TenantContextService — Tenant context resolution with caching and multiple identification sources
+ - [x] TenantProvisioningService skeleton — Minimal prototype for tenant database provisioning (Turso Platform API integration pending)
+ - [x] Multi-tenant router documentation updates — UUID naming conventions and examples
+ - [x] Test coverage — TenantContextService identification and caching tests
+- [x] **Phase 2: Turso Platform API Integration (v3.5)** — ✅ Complete (2026-04-17)
+ - [x] Turso Platform API client implementation
+ - [x] Automated tenant database creation
+ - [x] Tenant-specific auth token generation
+ - [x] Global control plane database setup (sys_tenant_registry, sys_package_installation)
+ - [x] Tenant database schema initialization
+ - [x] Package installation per tenant
+- [ ] **Phase 3: Production Hardening (v4.0)** — 🟡 Partially Complete
+ - [x] Tenant lifecycle management (suspend, archive, restore)
+ - [ ] Multi-region tenant migration
+ - [ ] Tenant usage tracking and quota enforcement
+ - [ ] Cross-tenant data sharing policies
+ - [ ] Tenant-specific RBAC and permissions
### 6.3 Observability
diff --git a/content/docs/references/cloud/index.mdx b/content/docs/references/cloud/index.mdx
index be8e13d49..a56138e72 100644
--- a/content/docs/references/cloud/index.mdx
+++ b/content/docs/references/cloud/index.mdx
@@ -10,4 +10,5 @@ This section contains all protocol schemas for the cloud layer of ObjectStack.
+
diff --git a/content/docs/references/cloud/meta.json b/content/docs/references/cloud/meta.json
index 0eab2b0b1..2b894a323 100644
--- a/content/docs/references/cloud/meta.json
+++ b/content/docs/references/cloud/meta.json
@@ -4,6 +4,8 @@
"app-store",
"developer-portal",
"marketplace",
- "marketplace-admin"
+ "marketplace-admin",
+ "provisioning",
+ "tenant"
]
}
\ No newline at end of file
diff --git a/content/docs/references/cloud/provisioning.mdx b/content/docs/references/cloud/provisioning.mdx
new file mode 100644
index 000000000..d1634cc5d
--- /dev/null
+++ b/content/docs/references/cloud/provisioning.mdx
@@ -0,0 +1,36 @@
+---
+title: Provisioning
+description: Provisioning protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}
+
+
+**Source:** `packages/spec/src/cloud/provisioning.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { TenantPlan } from '@objectstack/spec/cloud';
+import type { TenantPlan } from '@objectstack/spec/cloud';
+
+// Validate data
+const result = TenantPlan.parse(data);
+```
+
+---
+
+## TenantPlan
+
+### Allowed Values
+
+* `free`
+* `starter`
+* `pro`
+* `enterprise`
+* `custom`
+
+
+---
+
diff --git a/content/docs/references/cloud/tenant.mdx b/content/docs/references/cloud/tenant.mdx
new file mode 100644
index 000000000..bafa1b676
--- /dev/null
+++ b/content/docs/references/cloud/tenant.mdx
@@ -0,0 +1,182 @@
+---
+title: Tenant
+description: Tenant protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}
+
+Multi-Tenant Architecture Schema
+
+Defines the schema for managing multi-tenant architecture with:
+
+- Global control plane: Single database for auth, org management, tenant registry
+
+- Tenant data plane: Isolated databases per organization (UUID-based naming)
+
+Design decisions:
+
+- Database naming: \{uuid\}.turso.io (not org-slug, since slugs can be modified)
+
+- Each tenant has its own Turso database for complete data isolation
+
+- Global database stores user auth, organizations, and tenant metadata
+
+
+**Source:** `packages/spec/src/cloud/tenant.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { PackageInstallation, PackageInstallationStatus, ProvisionTenantRequest, ProvisionTenantResponse, TenantContext, TenantDatabase, TenantDatabaseStatus, TenantIdentificationSource, TenantRoutingConfig } from '@objectstack/spec/cloud';
+import type { PackageInstallation, PackageInstallationStatus, ProvisionTenantRequest, ProvisionTenantResponse, TenantContext, TenantDatabase, TenantDatabaseStatus, TenantIdentificationSource, TenantRoutingConfig } from '@objectstack/spec/cloud';
+
+// Validate data
+const result = PackageInstallation.parse(data);
+```
+
+---
+
+## PackageInstallation
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Unique installation identifier |
+| **tenantId** | `string` | ✅ | Tenant database ID |
+| **packageId** | `string` | ✅ | Package identifier |
+| **version** | `string` | ✅ | Installed package version |
+| **status** | `Enum<'installing' \| 'active' \| 'disabled' \| 'uninstalling' \| 'failed'>` | ✅ | Installation status |
+| **installedAt** | `string` | ✅ | Installation timestamp |
+| **installedBy** | `string` | ✅ | User ID who installed the package |
+| **config** | `Record` | optional | Package-specific configuration |
+| **updatedAt** | `string` | ✅ | Last update timestamp |
+
+
+---
+
+## PackageInstallationStatus
+
+### Allowed Values
+
+* `installing`
+* `active`
+* `disabled`
+* `uninstalling`
+* `failed`
+
+
+---
+
+## ProvisionTenantRequest
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **organizationId** | `string` | ✅ | Organization ID |
+| **region** | `string` | optional | Deployment region preference |
+| **plan** | `Enum<'free' \| 'starter' \| 'pro' \| 'enterprise' \| 'custom'>` | ✅ | Tenant plan tier |
+| **storageLimitMb** | `integer` | optional | Storage limit in megabytes |
+| **metadata** | `Record` | optional | Custom tenant metadata |
+
+
+---
+
+## ProvisionTenantResponse
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **tenant** | `Object` | ✅ | Provisioned tenant database |
+| **durationMs** | `number` | ✅ | Provisioning duration in milliseconds |
+| **warnings** | `string[]` | optional | Provisioning warnings |
+
+
+---
+
+## TenantContext
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **tenantId** | `string` | ✅ | Current tenant database ID |
+| **organizationId** | `string` | ✅ | Current organization ID |
+| **organizationSlug** | `string` | optional | Organization slug |
+| **databaseUrl** | `string` | ✅ | Tenant database URL |
+| **plan** | `Enum<'free' \| 'starter' \| 'pro' \| 'enterprise' \| 'custom'>` | ✅ | Tenant plan tier |
+| **metadata** | `Record` | optional | Custom tenant metadata |
+
+
+---
+
+## TenantDatabase
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Unique tenant database identifier (UUID) |
+| **organizationId** | `string` | ✅ | Organization ID (foreign key to sys_organization) |
+| **databaseName** | `string` | ✅ | Database name (UUID-based) |
+| **databaseUrl** | `string` | ✅ | Full database URL |
+| **authToken** | `string` | ✅ | Encrypted tenant-specific auth token |
+| **status** | `Enum<'provisioning' \| 'active' \| 'suspended' \| 'archived' \| 'failed'>` | ✅ | Database status |
+| **region** | `string` | ✅ | Deployment region |
+| **plan** | `Enum<'free' \| 'starter' \| 'pro' \| 'enterprise' \| 'custom'>` | ✅ | Tenant plan tier |
+| **storageLimitMb** | `integer` | ✅ | Storage limit in megabytes |
+| **createdAt** | `string` | ✅ | Database creation timestamp |
+| **updatedAt** | `string` | ✅ | Last update timestamp |
+| **lastAccessedAt** | `string` | optional | Last accessed timestamp |
+| **metadata** | `Record` | optional | Custom tenant configuration |
+
+
+---
+
+## TenantDatabaseStatus
+
+### Allowed Values
+
+* `provisioning`
+* `active`
+* `suspended`
+* `archived`
+* `failed`
+
+
+---
+
+## TenantIdentificationSource
+
+### Allowed Values
+
+* `subdomain`
+* `custom_domain`
+* `header`
+* `jwt_claim`
+* `session`
+* `default`
+
+
+---
+
+## TenantRoutingConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **enabled** | `boolean` | ✅ | Enable multi-tenant mode |
+| **identificationSources** | `Enum<'subdomain' \| 'custom_domain' \| 'header' \| 'jwt_claim' \| 'session' \| 'default'>[]` | ✅ | Tenant identification strategy (in order of precedence) |
+| **defaultTenantId** | `string` | optional | Default tenant ID |
+| **subdomainPattern** | `string` | optional | Subdomain pattern for tenant extraction |
+| **customDomainMapping** | `Record` | optional | Custom domain to tenant ID mapping |
+| **tenantHeaderName** | `string` | ✅ | Header name for tenant ID |
+| **jwtOrganizationClaim** | `string` | ✅ | JWT claim name for organization ID |
+
+
+---
+
diff --git a/packages/plugins/driver-turso/src/multi-tenant.ts b/packages/plugins/driver-turso/src/multi-tenant.ts
index bfe762d7d..dafe906fb 100644
--- a/packages/plugins/driver-turso/src/multi-tenant.ts
+++ b/packages/plugins/driver-turso/src/multi-tenant.ts
@@ -5,9 +5,14 @@
*
* Manages per-tenant TursoDriver instances with TTL-based caching.
* Uses a URL template with `{tenant}` placeholder that is replaced
- * with the tenantId at runtime.
+ * with the tenantId (UUID) at runtime.
*
- * Serverless-safe: no global intervals, no leaked state. Expired
+ * **UUID-Based Tenant Naming:**
+ * - Tenant IDs are UUIDs, not organization slugs
+ * - Ensures database URLs remain stable even if organization names change
+ * - Example: `550e8400-e29b-41d4-a716-446655440000.turso.io`
+ *
+ * **Serverless-safe:** no global intervals, no leaked state. Expired
* entries are evicted lazily on next access.
*/
@@ -18,20 +23,32 @@ import { TursoDriver, type TursoDriverConfig } from './turso-driver.js';
/**
* Configuration for the multi-tenant router.
*
+ * **UUID-Based Tenant Naming:**
+ * The `{tenant}` placeholder is replaced with a UUID, not an organization slug.
+ * This ensures database URLs remain stable even if organization names change.
+ *
* @example
* ```typescript
* const router = createMultiTenantRouter({
- * urlTemplate: 'file:./data/{tenant}.db',
+ * // UUID-based URL template
+ * urlTemplate: 'libsql://{tenant}.turso.io',
+ * groupAuthToken: process.env.TURSO_GROUP_TOKEN,
* clientCacheTTL: 300_000, // 5 minutes
* });
*
- * const driver = await router.getDriverForTenant('acme');
+ * // Tenant ID is a UUID
+ * const driver = await router.getDriverForTenant('550e8400-e29b-41d4-a716-446655440000');
* ```
*/
export interface MultiTenantConfig {
/**
- * URL template with `{tenant}` placeholder.
- * Example: `'file:./data/{tenant}.db'`
+ * URL template with `{tenant}` placeholder (replaced with UUID at runtime).
+ *
+ * Examples:
+ * - Remote: `'libsql://{tenant}.turso.io'`
+ * - Local: `'file:./data/{tenant}.db'`
+ *
+ * **Important:** Use UUID for tenant ID, not organization slug.
*/
urlTemplate: string;
diff --git a/packages/services/service-tenant/README.md b/packages/services/service-tenant/README.md
new file mode 100644
index 000000000..2fc42efb3
--- /dev/null
+++ b/packages/services/service-tenant/README.md
@@ -0,0 +1,232 @@
+# @objectstack/service-tenant
+
+Multi-tenant context management and routing service for ObjectStack.
+
+## Overview
+
+This service provides comprehensive multi-tenant infrastructure for ObjectStack deployments, including:
+
+- Tenant identification and context resolution
+- Turso Platform API integration for automated database provisioning
+- Tenant database schema initialization
+- Global control plane management
+- Package installation per tenant
+
+## Features
+
+### Phase 1 (Complete)
+- **Multiple Identification Sources**: Subdomain, custom domain, HTTP headers, JWT claims, session
+- **UUID-Based Tenant Naming**: Immutable tenant identifiers (not organization slugs)
+- **Tenant Context Caching**: Performance optimization for frequently accessed tenants
+- **Flexible Configuration**: Priority-based identification source ordering
+
+### Phase 2 (Complete)
+- **Turso Platform API Integration**: Automated database creation via Turso Platform API
+- **Tenant-Specific Auth Tokens**: Secure, database-specific authentication
+- **Global Control Plane**: System objects for tenant registry and package installations
+- **Schema Initialization**: Automated tenant database schema setup
+- **Package Management**: Per-tenant package installation and schema migration
+
+## Installation
+
+```bash
+pnpm add @objectstack/service-tenant
+```
+
+## Usage
+
+### Basic Setup (Tenant Routing)
+
+```typescript
+import { createTenantPlugin } from '@objectstack/service-tenant';
+import { ObjectKernel } from '@objectstack/core';
+
+const kernel = new ObjectKernel();
+
+// Create tenant plugin with routing configuration
+const tenantPlugin = createTenantPlugin({
+ routing: {
+ enabled: true,
+ identificationSources: ['header', 'custom_domain', 'jwt_claim'],
+ tenantHeaderName: 'X-Tenant-ID',
+ customDomainMapping: {
+ 'app.acme.com': '550e8400-e29b-41d4-a716-446655440000',
+ },
+ },
+ registerSystemObjects: true, // Register control plane objects
+});
+
+await kernel.use(tenantPlugin);
+await kernel.bootstrap();
+```
+
+### Tenant Provisioning
+
+```typescript
+import {
+ TenantProvisioningService,
+ TursoPlatformClient,
+} from '@objectstack/service-tenant';
+
+// Production mode: with Turso Platform API
+const provisioningService = new TenantProvisioningService({
+ turso: {
+ apiToken: process.env.TURSO_API_TOKEN!,
+ organization: 'my-org',
+ },
+ controlPlaneDriver: globalDriver, // Global control plane driver
+ defaultRegion: 'us-east-1',
+ databaseGroup: 'production-tenants',
+});
+
+// Provision a new tenant
+const result = await provisioningService.provisionTenant({
+ organizationId: 'org-123',
+ plan: 'pro',
+ region: 'us-west-2',
+ storageLimitMb: 5120,
+});
+
+console.log('Tenant provisioned:', result.tenant);
+// {
+// id: '550e8400-e29b-41d4-a716-446655440000',
+// databaseUrl: 'libsql://550e8400-e29b-41d4-a716-446655440000.turso.io',
+// authToken: '',
+// status: 'active',
+// ...
+// }
+```
+
+### Development Mode (No Turso API)
+
+```typescript
+// Development/Mock mode: no Turso Platform API required
+const devService = new TenantProvisioningService({
+ defaultRegion: 'us-east-1',
+});
+
+const result = await devService.provisionTenant({
+ organizationId: 'org-123',
+ plan: 'free',
+});
+
+// Returns mock tenant with warnings
+console.log(result.warnings);
+// ['Running in mock mode - Turso Platform API credentials not configured']
+```
+
+### Schema Initialization
+
+```typescript
+import { TenantSchemaInitializer } from '@objectstack/service-tenant';
+
+const initializer = new TenantSchemaInitializer();
+
+// Initialize tenant database with base schema
+await initializer.initializeTenantSchema(
+ tenant.databaseUrl,
+ tenant.authToken,
+ baseObjects, // Optional: base objects to create
+);
+
+// Install package schema
+await initializer.installPackageSchema(
+ tenant.databaseUrl,
+ tenant.authToken,
+ packageObjects,
+);
+```
+
+### Resolving Tenant Context
+
+```typescript
+import { TenantContextService } from '@objectstack/service-tenant';
+
+const service = kernel.getService('tenant');
+
+const context = await service.resolveTenantContext({
+ hostname: 'app.acme.com',
+ headers: {
+ 'X-Tenant-ID': '550e8400-e29b-41d4-a716-446655440000',
+ },
+ jwt: {
+ organizationId: 'org-123',
+ },
+});
+
+console.log(context);
+// {
+// tenantId: '550e8400-e29b-41d4-a716-446655440000',
+// organizationId: 'org-123',
+// databaseUrl: 'libsql://550e8400-e29b-41d4-a716-446655440000.turso.io',
+// plan: 'pro'
+// }
+```
+
+## Architecture
+
+### Tenant Identification Flow
+
+```
+Request → TenantContextService → Identification Sources (in order)
+ ↓
+ 1. Subdomain
+ 2. Custom Domain
+ 3. HTTP Header
+ 4. JWT Claim
+ 5. Session
+ 6. Default Tenant
+ ↓
+ Tenant Context
+```
+
+### UUID-Based Naming
+
+Tenant databases use UUID naming instead of organization slugs:
+
+- **Why**: Organization slugs can be modified, UUIDs are immutable
+- **Format**: `{uuid}.turso.io` (e.g., `550e8400-e29b-41d4-a716-446655440000.turso.io`)
+- **Benefit**: Stable database URLs regardless of organization name changes
+
+## Configuration
+
+### TenantRoutingConfig
+
+```typescript
+interface TenantRoutingConfig {
+ // Enable multi-tenant mode
+ enabled: boolean;
+
+ // Identification strategy (in order of precedence)
+ identificationSources: TenantIdentificationSource[];
+
+ // Default tenant ID (for single-tenant or fallback)
+ defaultTenantId?: string;
+
+ // Subdomain pattern for tenant extraction
+ subdomainPattern?: string;
+
+ // Custom domain to tenant ID mapping
+ customDomainMapping?: Record;
+
+ // Header name for tenant ID
+ tenantHeaderName: string; // Default: 'X-Tenant-ID'
+
+ // JWT claim name for organization ID
+ jwtOrganizationClaim: string; // Default: 'organizationId'
+}
+```
+
+## Testing
+
+```bash
+# Run tests
+pnpm test
+
+# Run tests in watch mode
+pnpm test:watch
+```
+
+## License
+
+Apache-2.0
diff --git a/packages/services/service-tenant/package.json b/packages/services/service-tenant/package.json
new file mode 100644
index 000000000..c4f282f36
--- /dev/null
+++ b/packages/services/service-tenant/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@objectstack/service-tenant",
+ "version": "0.1.0",
+ "description": "ObjectStack Multi-Tenant Service - Tenant context management and routing",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@objectstack/spec": "workspace:^",
+ "@objectstack/core": "workspace:^",
+ "@objectstack/driver-turso": "workspace:^"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.5",
+ "tsup": "^8.3.5",
+ "typescript": "^5.7.3",
+ "vitest": "^2.1.8"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/objectstack-ai/framework.git",
+ "directory": "packages/services/service-tenant"
+ },
+ "keywords": [
+ "objectstack",
+ "multi-tenant",
+ "saas",
+ "tenant-routing"
+ ],
+ "author": "ObjectStack Team",
+ "license": "Apache-2.0"
+}
diff --git a/packages/services/service-tenant/src/index.ts b/packages/services/service-tenant/src/index.ts
new file mode 100644
index 000000000..793ce7b2e
--- /dev/null
+++ b/packages/services/service-tenant/src/index.ts
@@ -0,0 +1,8 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+export * from './tenant-context.js';
+export * from './tenant-plugin.js';
+export * from './tenant-provisioning.js';
+export * from './turso-platform-client.js';
+export * from './tenant-schema-initializer.js';
+export * from './objects/index.js';
diff --git a/packages/services/service-tenant/src/objects/index.ts b/packages/services/service-tenant/src/objects/index.ts
new file mode 100644
index 000000000..7850ddcbd
--- /dev/null
+++ b/packages/services/service-tenant/src/objects/index.ts
@@ -0,0 +1,4 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+export * from './sys-tenant-database.object';
+export * from './sys-package-installation.object';
diff --git a/packages/services/service-tenant/src/objects/sys-package-installation.object.ts b/packages/services/service-tenant/src/objects/sys-package-installation.object.ts
new file mode 100644
index 000000000..d043ddb3a
--- /dev/null
+++ b/packages/services/service-tenant/src/objects/sys-package-installation.object.ts
@@ -0,0 +1,111 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { ObjectSchema, Field } from '@objectstack/spec/data';
+
+/**
+ * sys_package_installation — Package Installation Registry
+ *
+ * Tracks which packages are installed in which tenant.
+ * Stored in the global control plane database.
+ *
+ * @namespace sys
+ */
+export const SysPackageInstallation = ObjectSchema.create({
+ namespace: 'sys',
+ name: 'package_installation',
+ label: 'Package Installation',
+ pluralLabel: 'Package Installations',
+ icon: 'package',
+ isSystem: true,
+ description: 'Per-tenant package installation registry',
+ titleFormat: '{package_id} @ {tenant_id}',
+ compactLayout: ['package_id', 'tenant_id', 'version', 'status'],
+
+ fields: {
+ id: Field.text({
+ label: 'Installation ID',
+ required: true,
+ readonly: true,
+ description: 'UUID-based installation identifier',
+ }),
+
+ created_at: Field.datetime({
+ label: 'Created At',
+ defaultValue: 'NOW()',
+ readonly: true,
+ }),
+
+ updated_at: Field.datetime({
+ label: 'Updated At',
+ defaultValue: 'NOW()',
+ readonly: true,
+ }),
+
+ tenant_id: Field.text({
+ label: 'Tenant ID',
+ required: true,
+ description: 'Foreign key to tenant_database',
+ }),
+
+ package_id: Field.text({
+ label: 'Package ID',
+ required: true,
+ maxLength: 255,
+ description: 'Package identifier (e.g., @objectstack/crm)',
+ }),
+
+ version: Field.text({
+ label: 'Version',
+ required: true,
+ maxLength: 50,
+ description: 'Installed package version (semver)',
+ }),
+
+ status: Field.picklist({
+ label: 'Status',
+ required: true,
+ options: [
+ { value: 'installing', label: 'Installing' },
+ { value: 'active', label: 'Active' },
+ { value: 'disabled', label: 'Disabled' },
+ { value: 'uninstalling', label: 'Uninstalling' },
+ { value: 'failed', label: 'Failed' },
+ ],
+ defaultValue: 'installing',
+ }),
+
+ installed_at: Field.datetime({
+ label: 'Installed At',
+ required: true,
+ defaultValue: 'NOW()',
+ }),
+
+ installed_by: Field.text({
+ label: 'Installed By',
+ required: true,
+ description: 'User ID who installed the package',
+ }),
+
+ config: Field.textarea({
+ label: 'Configuration',
+ required: false,
+ description: 'JSON-serialized package-specific configuration',
+ }),
+ },
+
+ indexes: [
+ { fields: ['tenant_id', 'package_id'], unique: true },
+ { fields: ['tenant_id'] },
+ { fields: ['package_id'] },
+ { fields: ['status'] },
+ ],
+
+ enable: {
+ trackHistory: true,
+ searchable: true,
+ apiEnabled: true,
+ apiMethods: ['get', 'list', 'create', 'update', 'delete'],
+ trash: true,
+ mru: true,
+ },
+});
diff --git a/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts b/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts
new file mode 100644
index 000000000..535e7dd38
--- /dev/null
+++ b/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts
@@ -0,0 +1,138 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { ObjectSchema, Field } from '@objectstack/spec/data';
+
+/**
+ * sys_tenant_database — Global Tenant Registry Object
+ *
+ * Stores tenant database information in the global control plane.
+ * Each tenant has its own isolated Turso database with UUID-based naming.
+ *
+ * @namespace sys
+ */
+export const SysTenantDatabase = ObjectSchema.create({
+ namespace: 'sys',
+ name: 'tenant_database',
+ label: 'Tenant Database',
+ pluralLabel: 'Tenant Databases',
+ icon: 'database',
+ isSystem: true,
+ description: 'Tenant database registry for multi-tenant architecture',
+ titleFormat: '{database_name}',
+ compactLayout: ['database_name', 'organization_id', 'status', 'plan'],
+
+ fields: {
+ id: Field.text({
+ label: 'Tenant ID',
+ required: true,
+ readonly: true,
+ description: 'UUID-based tenant identifier',
+ }),
+
+ created_at: Field.datetime({
+ label: 'Created At',
+ defaultValue: 'NOW()',
+ readonly: true,
+ }),
+
+ updated_at: Field.datetime({
+ label: 'Updated At',
+ defaultValue: 'NOW()',
+ readonly: true,
+ }),
+
+ organization_id: Field.text({
+ label: 'Organization ID',
+ required: true,
+ description: 'Foreign key to sys_organization',
+ }),
+
+ database_name: Field.text({
+ label: 'Database Name',
+ required: true,
+ maxLength: 255,
+ description: 'UUID-based database name (immutable)',
+ }),
+
+ database_url: Field.url({
+ label: 'Database URL',
+ required: true,
+ description: 'Full database connection URL (e.g., libsql://{uuid}.turso.io)',
+ }),
+
+ auth_token: Field.text({
+ label: 'Auth Token',
+ required: true,
+ maxLength: 2000,
+ description: 'Encrypted database-specific auth token',
+ }),
+
+ status: Field.picklist({
+ label: 'Status',
+ required: true,
+ options: [
+ { value: 'provisioning', label: 'Provisioning' },
+ { value: 'active', label: 'Active' },
+ { value: 'suspended', label: 'Suspended' },
+ { value: 'archived', label: 'Archived' },
+ { value: 'failed', label: 'Failed' },
+ ],
+ defaultValue: 'provisioning',
+ }),
+
+ region: Field.text({
+ label: 'Region',
+ required: true,
+ maxLength: 100,
+ description: 'Deployment region (e.g., us-east-1, eu-west-1)',
+ }),
+
+ plan: Field.picklist({
+ label: 'Plan',
+ required: true,
+ options: [
+ { value: 'free', label: 'Free' },
+ { value: 'starter', label: 'Starter' },
+ { value: 'pro', label: 'Pro' },
+ { value: 'enterprise', label: 'Enterprise' },
+ { value: 'custom', label: 'Custom' },
+ ],
+ defaultValue: 'free',
+ }),
+
+ storage_limit_mb: Field.number({
+ label: 'Storage Limit (MB)',
+ required: true,
+ defaultValue: 1024,
+ description: 'Maximum storage allowed in megabytes',
+ }),
+
+ last_accessed_at: Field.datetime({
+ label: 'Last Accessed At',
+ required: false,
+ description: 'Last time the tenant database was accessed',
+ }),
+
+ metadata: Field.textarea({
+ label: 'Metadata',
+ required: false,
+ description: 'JSON-serialized custom tenant configuration',
+ }),
+ },
+
+ indexes: [
+ { fields: ['database_name'], unique: true },
+ { fields: ['organization_id'] },
+ { fields: ['status'] },
+ { fields: ['plan'] },
+ ],
+
+ enable: {
+ trackHistory: true,
+ searchable: true,
+ apiEnabled: true,
+ apiMethods: ['get', 'list', 'create', 'update'],
+ trash: false, // Don't allow soft delete - use archive instead
+ mru: true,
+ },
+});
diff --git a/packages/services/service-tenant/src/tenant-context.test.ts b/packages/services/service-tenant/src/tenant-context.test.ts
new file mode 100644
index 000000000..162291ea7
--- /dev/null
+++ b/packages/services/service-tenant/src/tenant-context.test.ts
@@ -0,0 +1,102 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { TenantContextService } from '../src/tenant-context';
+import type { TenantRoutingConfig } from '@objectstack/spec/cloud';
+
+describe('TenantContextService', () => {
+ let service: TenantContextService;
+
+ beforeEach(() => {
+ const config: TenantRoutingConfig = {
+ enabled: true,
+ identificationSources: ['header', 'custom_domain'],
+ tenantHeaderName: 'X-Tenant-ID',
+ customDomainMapping: {
+ 'app.acme.com': '550e8400-e29b-41d4-a716-446655440000',
+ },
+ };
+ service = new TenantContextService(config);
+ });
+
+ describe('resolveTenantContext', () => {
+ it('should extract tenant from header', async () => {
+ const context = await service.resolveTenantContext({
+ headers: {
+ 'X-Tenant-ID': '550e8400-e29b-41d4-a716-446655440000',
+ },
+ });
+
+ expect(context).toBeDefined();
+ expect(context?.tenantId).toBe('550e8400-e29b-41d4-a716-446655440000');
+ expect(context?.databaseUrl).toBe('libsql://550e8400-e29b-41d4-a716-446655440000.turso.io');
+ });
+
+ it('should extract tenant from custom domain', async () => {
+ const context = await service.resolveTenantContext({
+ hostname: 'app.acme.com',
+ });
+
+ expect(context).toBeDefined();
+ expect(context?.tenantId).toBe('550e8400-e29b-41d4-a716-446655440000');
+ });
+
+ it('should return null when multi-tenant is disabled', async () => {
+ const disabledConfig: TenantRoutingConfig = {
+ enabled: false,
+ identificationSources: [],
+ };
+ const disabledService = new TenantContextService(disabledConfig);
+
+ const context = await disabledService.resolveTenantContext({
+ headers: { 'X-Tenant-ID': '123' },
+ });
+
+ expect(context).toBeNull();
+ });
+
+ it('should cache tenant contexts', async () => {
+ const context1 = await service.resolveTenantContext({
+ headers: { 'X-Tenant-ID': 'tenant-123' },
+ });
+
+ const context2 = await service.resolveTenantContext({
+ headers: { 'X-Tenant-ID': 'tenant-123' },
+ });
+
+ expect(context1).toBe(context2); // Same object reference from cache
+ });
+ });
+
+ describe('cache management', () => {
+ it('should clear cache', async () => {
+ await service.resolveTenantContext({
+ headers: { 'X-Tenant-ID': 'tenant-123' },
+ });
+
+ service.clearCache();
+
+ // After clearing cache, should create new context
+ const context = await service.resolveTenantContext({
+ headers: { 'X-Tenant-ID': 'tenant-123' },
+ });
+
+ expect(context).toBeDefined();
+ });
+
+ it('should invalidate specific tenant', async () => {
+ await service.resolveTenantContext({
+ headers: { 'X-Tenant-ID': 'tenant-123' },
+ });
+
+ service.invalidateTenant('tenant-123');
+
+ // Should still work after invalidation
+ const context = await service.resolveTenantContext({
+ headers: { 'X-Tenant-ID': 'tenant-123' },
+ });
+
+ expect(context).toBeDefined();
+ });
+ });
+});
diff --git a/packages/services/service-tenant/src/tenant-context.ts b/packages/services/service-tenant/src/tenant-context.ts
new file mode 100644
index 000000000..999870de0
--- /dev/null
+++ b/packages/services/service-tenant/src/tenant-context.ts
@@ -0,0 +1,224 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type {
+ TenantContext,
+ TenantIdentificationSource,
+ TenantRoutingConfig,
+} from '@objectstack/spec/cloud';
+
+/**
+ * Tenant Context Service
+ *
+ * Manages tenant identification and context resolution from HTTP requests.
+ * Supports multiple identification strategies:
+ * - Subdomain extraction (e.g., acme.objectstack.app)
+ * - Custom domain mapping
+ * - HTTP headers (X-Tenant-ID)
+ * - JWT claims (organizationId)
+ * - Session data
+ */
+export class TenantContextService {
+ private config: TenantRoutingConfig;
+ private tenantCache = new Map();
+
+ constructor(config: TenantRoutingConfig) {
+ this.config = config;
+ }
+
+ /**
+ * Extract tenant context from request
+ *
+ * @param request - HTTP request object with headers, hostname, etc.
+ * @returns Tenant context or null if multi-tenant is disabled
+ */
+ async resolveTenantContext(request: {
+ hostname?: string;
+ headers?: Record;
+ session?: { organizationId?: string };
+ jwt?: Record;
+ }): Promise {
+ if (!this.config.enabled) {
+ return null;
+ }
+
+ // Try each identification source in order of precedence
+ for (const source of this.config.identificationSources) {
+ const tenantId = await this.extractTenantId(request, source);
+ if (tenantId) {
+ return this.getTenantContextById(tenantId, source);
+ }
+ }
+
+ // Fallback to default tenant if configured
+ if (this.config.defaultTenantId) {
+ return this.getTenantContextById(this.config.defaultTenantId, 'default');
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract tenant ID from request using specific identification source
+ */
+ private async extractTenantId(
+ request: {
+ hostname?: string;
+ headers?: Record;
+ session?: { organizationId?: string };
+ jwt?: Record;
+ },
+ source: TenantIdentificationSource,
+ ): Promise {
+ switch (source) {
+ case 'subdomain':
+ return this.extractFromSubdomain(request.hostname);
+
+ case 'custom_domain':
+ return this.extractFromCustomDomain(request.hostname);
+
+ case 'header':
+ return this.extractFromHeader(request.headers);
+
+ case 'jwt_claim':
+ return this.extractFromJWT(request.jwt);
+
+ case 'session':
+ return this.extractFromSession(request.session);
+
+ case 'default':
+ return this.config.defaultTenantId || null;
+
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Extract tenant from subdomain
+ * Example: "acme.objectstack.app" -> resolve to tenant with org slug "acme"
+ */
+ private extractFromSubdomain(hostname?: string): string | null {
+ if (!hostname || !this.config.subdomainPattern) {
+ return null;
+ }
+
+ // Extract tenant slug from subdomain
+ // Pattern: "{tenant}.objectstack.app"
+ const parts = hostname.split('.');
+ if (parts.length < 2) {
+ return null;
+ }
+
+ const tenantSlug = parts[0];
+ // In real implementation, lookup tenant ID by organization slug
+ // For now, return null (needs database integration)
+ return null;
+ }
+
+ /**
+ * Extract tenant from custom domain mapping
+ * Example: "app.acme.com" -> "550e8400-e29b-41d4-a716-446655440000"
+ */
+ private extractFromCustomDomain(hostname?: string): string | null {
+ if (!hostname || !this.config.customDomainMapping) {
+ return null;
+ }
+
+ return this.config.customDomainMapping[hostname] || null;
+ }
+
+ /**
+ * Extract tenant from HTTP header
+ */
+ private extractFromHeader(headers?: Record): string | null {
+ if (!headers) {
+ return null;
+ }
+
+ const headerValue = headers[this.config.tenantHeaderName];
+ if (typeof headerValue === 'string') {
+ return headerValue;
+ }
+
+ if (Array.isArray(headerValue) && headerValue.length > 0) {
+ return headerValue[0];
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract tenant from JWT claim
+ */
+ private extractFromJWT(jwt?: Record): string | null {
+ if (!jwt) {
+ return null;
+ }
+
+ const organizationId = jwt[this.config.jwtOrganizationClaim];
+ if (typeof organizationId === 'string') {
+ // In real implementation, lookup tenant ID by organization ID
+ // For now, return the organization ID as tenant ID
+ return organizationId;
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract tenant from session
+ */
+ private extractFromSession(session?: { organizationId?: string }): string | null {
+ if (!session?.organizationId) {
+ return null;
+ }
+
+ // In real implementation, lookup tenant ID by organization ID
+ // For now, return the organization ID as tenant ID
+ return session.organizationId;
+ }
+
+ /**
+ * Get tenant context by ID
+ * In real implementation, this would query the global database
+ * for tenant registry information
+ */
+ private async getTenantContextById(
+ tenantId: string,
+ source: TenantIdentificationSource,
+ ): Promise {
+ // Check cache first
+ const cached = this.tenantCache.get(tenantId);
+ if (cached) {
+ return cached;
+ }
+
+ // In real implementation, query global database for tenant info
+ // For now, return a minimal context
+ const context: TenantContext = {
+ tenantId,
+ organizationId: tenantId, // Placeholder
+ databaseUrl: `libsql://${tenantId}.turso.io`,
+ plan: 'free',
+ };
+
+ // Cache the context
+ this.tenantCache.set(tenantId, context);
+
+ return context;
+ }
+
+ /**
+ * Clear tenant cache
+ */
+ clearCache(): void {
+ this.tenantCache.clear();
+ }
+
+ /**
+ * Invalidate specific tenant from cache
+ */
+ invalidateTenant(tenantId: string): void {
+ this.tenantCache.delete(tenantId);
+ }
+}
diff --git a/packages/services/service-tenant/src/tenant-integration.test.ts b/packages/services/service-tenant/src/tenant-integration.test.ts
new file mode 100644
index 000000000..0cd6153c7
--- /dev/null
+++ b/packages/services/service-tenant/src/tenant-integration.test.ts
@@ -0,0 +1,172 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { TenantProvisioningService } from '../src/tenant-provisioning';
+import { TenantSchemaInitializer } from '../src/tenant-schema-initializer';
+import type { ProvisionTenantRequest } from '@objectstack/spec/cloud';
+
+describe('TenantProvisioningService', () => {
+ describe('provisionTenant', () => {
+ it('should provision tenant in mock mode', async () => {
+ const service = new TenantProvisioningService({
+ defaultRegion: 'us-west-2',
+ defaultStorageLimitMb: 2048,
+ });
+
+ const request: ProvisionTenantRequest = {
+ organizationId: 'org-123',
+ plan: 'pro',
+ };
+
+ const response = await service.provisionTenant(request);
+
+ expect(response.tenant).toBeDefined();
+ expect(response.tenant.organizationId).toBe('org-123');
+ expect(response.tenant.plan).toBe('pro');
+ expect(response.tenant.region).toBe('us-west-2');
+ expect(response.tenant.storageLimitMb).toBe(2048);
+ expect(response.tenant.status).toBe('active');
+ expect(response.tenant.databaseName).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
+ ); // UUID format
+ expect(response.tenant.databaseUrl).toContain('libsql://');
+ expect(response.durationMs).toBeGreaterThanOrEqual(0);
+ expect(response.warnings).toBeDefined();
+ expect(response.warnings).toContain(
+ 'Running in mock mode - Turso Platform API credentials not configured',
+ );
+ });
+
+ it('should use custom region and storage limit', async () => {
+ const service = new TenantProvisioningService();
+
+ const request: ProvisionTenantRequest = {
+ organizationId: 'org-456',
+ region: 'eu-central-1',
+ storageLimitMb: 5120,
+ };
+
+ const response = await service.provisionTenant(request);
+
+ expect(response.tenant.region).toBe('eu-central-1');
+ expect(response.tenant.storageLimitMb).toBe(5120);
+ });
+
+ it('should generate UUID-based database names', async () => {
+ const service = new TenantProvisioningService();
+
+ const request: ProvisionTenantRequest = {
+ organizationId: 'org-789',
+ };
+
+ const response1 = await service.provisionTenant(request);
+ const response2 = await service.provisionTenant(request);
+
+ // Each tenant should have unique UUID
+ expect(response1.tenant.id).not.toBe(response2.tenant.id);
+ expect(response1.tenant.databaseName).not.toBe(response2.tenant.databaseName);
+
+ // Database name should match tenant ID (UUID-based)
+ expect(response1.tenant.databaseName).toBe(response1.tenant.id);
+ expect(response2.tenant.databaseName).toBe(response2.tenant.id);
+ });
+ });
+
+ describe('lifecycle operations', () => {
+ it('should suspend tenant with control plane driver', async () => {
+ const mockDriver = {
+ update: vi.fn().mockResolvedValue(undefined),
+ };
+
+ const service = new TenantProvisioningService({
+ controlPlaneDriver: mockDriver as any,
+ });
+
+ await service.suspendTenant('tenant-123');
+
+ expect(mockDriver.update).toHaveBeenCalledWith('tenant_database', 'tenant-123', {
+ status: 'suspended',
+ updated_at: expect.any(String),
+ });
+ });
+
+ it('should archive tenant and optionally delete from platform', async () => {
+ const mockDriver = {
+ findById: vi.fn().mockResolvedValue({
+ id: 'tenant-123',
+ database_name: 'db-name',
+ }),
+ update: vi.fn().mockResolvedValue(undefined),
+ };
+
+ const service = new TenantProvisioningService({
+ controlPlaneDriver: mockDriver as any,
+ });
+
+ await service.archiveTenant('tenant-123');
+
+ expect(mockDriver.findById).toHaveBeenCalledWith('tenant_database', 'tenant-123');
+ expect(mockDriver.update).toHaveBeenCalledWith('tenant_database', 'tenant-123', {
+ status: 'archived',
+ updated_at: expect.any(String),
+ });
+ });
+
+ it('should restore suspended tenant', async () => {
+ const mockDriver = {
+ findById: vi.fn().mockResolvedValue({
+ id: 'tenant-123',
+ status: 'suspended',
+ }),
+ update: vi.fn().mockResolvedValue(undefined),
+ };
+
+ const service = new TenantProvisioningService({
+ controlPlaneDriver: mockDriver as any,
+ });
+
+ await service.restoreTenant('tenant-123');
+
+ expect(mockDriver.update).toHaveBeenCalledWith('tenant_database', 'tenant-123', {
+ status: 'active',
+ updated_at: expect.any(String),
+ });
+ });
+
+ it('should not restore archived tenant', async () => {
+ const mockDriver = {
+ findById: vi.fn().mockResolvedValue({
+ id: 'tenant-123',
+ status: 'archived',
+ }),
+ };
+
+ const service = new TenantProvisioningService({
+ controlPlaneDriver: mockDriver as any,
+ });
+
+ await expect(service.restoreTenant('tenant-123')).rejects.toThrow(
+ 'Cannot restore archived tenant',
+ );
+ });
+ });
+});
+
+describe('TenantSchemaInitializer', () => {
+ it('should create instance', () => {
+ const initializer = new TenantSchemaInitializer();
+ expect(initializer).toBeDefined();
+ });
+
+ // Note: Full integration tests would require actual Turso database
+ // These are placeholders for future implementation
+ it.skip('should initialize tenant schema with base tables', async () => {
+ const initializer = new TenantSchemaInitializer();
+ // TODO: Implement with test database
+ });
+
+ it.skip('should install package schema', async () => {
+ const initializer = new TenantSchemaInitializer();
+ // TODO: Implement with test database
+ });
+});
diff --git a/packages/services/service-tenant/src/tenant-plugin.ts b/packages/services/service-tenant/src/tenant-plugin.ts
new file mode 100644
index 000000000..5865abb59
--- /dev/null
+++ b/packages/services/service-tenant/src/tenant-plugin.ts
@@ -0,0 +1,77 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { Plugin, PluginContext } from '@objectstack/spec';
+import type { TenantRoutingConfig } from '@objectstack/spec/cloud';
+import { TenantContextService } from './tenant-context';
+import { SysTenantDatabase, SysPackageInstallation } from './objects';
+
+/**
+ * Tenant Plugin Configuration
+ */
+export interface TenantPluginConfig {
+ /**
+ * Tenant routing configuration
+ */
+ routing?: TenantRoutingConfig;
+
+ /**
+ * Register system objects (for global control plane)
+ * Default: true
+ */
+ registerSystemObjects?: boolean;
+}
+
+/**
+ * Tenant Plugin
+ *
+ * Registers the tenant context service with the ObjectKernel.
+ * Provides multi-tenant routing and context management.
+ * Optionally registers system objects for the global control plane.
+ */
+export function createTenantPlugin(config: TenantPluginConfig = {}): Plugin {
+ let service: TenantContextService | null = null;
+
+ return {
+ name: '@objectstack/service-tenant',
+ version: '0.2.0',
+
+ objects: config.registerSystemObjects !== false
+ ? [SysTenantDatabase, SysPackageInstallation]
+ : [],
+
+ async init(ctx: PluginContext) {
+ // Create tenant context service if routing is configured
+ if (config.routing) {
+ service = new TenantContextService(config.routing);
+
+ // Register service
+ ctx.kernel.registerService('tenant', service, {
+ lifecycle: 'SINGLETON',
+ });
+
+ ctx.logger.info('[TenantPlugin] Tenant routing initialized', {
+ enabled: config.routing.enabled,
+ sources: config.routing.identificationSources,
+ });
+ }
+
+ // Register system objects if enabled
+ if (config.registerSystemObjects !== false) {
+ ctx.logger.info('[TenantPlugin] System objects registered', {
+ objects: ['sys_tenant_database', 'sys_package_installation'],
+ });
+ }
+ },
+
+ async start(ctx: PluginContext) {
+ ctx.logger.info('[TenantPlugin] Started');
+ },
+
+ async destroy(ctx: PluginContext) {
+ if (service) {
+ service.clearCache();
+ }
+ ctx.logger.info('[TenantPlugin] Destroyed');
+ },
+ };
+}
diff --git a/packages/services/service-tenant/src/tenant-provisioning.ts b/packages/services/service-tenant/src/tenant-provisioning.ts
new file mode 100644
index 000000000..c040156f9
--- /dev/null
+++ b/packages/services/service-tenant/src/tenant-provisioning.ts
@@ -0,0 +1,300 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type {
+ ProvisionTenantRequest,
+ ProvisionTenantResponse,
+ TenantDatabase,
+} from '@objectstack/spec/cloud';
+import type { IDataDriver } from '@objectstack/spec';
+import { randomUUID } from 'node:crypto';
+import { TursoPlatformClient, type TursoPlatformConfig } from './turso-platform-client';
+
+/**
+ * Tenant Provisioning Service Configuration
+ */
+export interface TenantProvisioningConfig {
+ /**
+ * Turso Platform API configuration
+ * If not provided, runs in mock mode (for development/testing)
+ */
+ turso?: TursoPlatformConfig;
+
+ /**
+ * Global control plane data driver
+ * Used to store tenant registry and package installations
+ */
+ controlPlaneDriver?: IDataDriver;
+
+ /**
+ * Default region for new tenant databases
+ */
+ defaultRegion?: string;
+
+ /**
+ * Database group name for tenant databases
+ * Optional: groups share configuration like location
+ */
+ databaseGroup?: string;
+
+ /**
+ * Default storage limit in MB for free tier
+ */
+ defaultStorageLimitMb?: number;
+
+ /**
+ * Auth token encryption key
+ * Used to encrypt tenant auth tokens before storing in control plane
+ */
+ encryptionKey?: string;
+}
+
+/**
+ * Tenant Provisioning Service
+ *
+ * Handles tenant database provisioning operations:
+ * - Create new tenant databases via Turso Platform API
+ * - Generate tenant-specific auth tokens
+ * - Register tenants in global control plane
+ * - Initialize tenant database schema
+ */
+export class TenantProvisioningService {
+ private config: TenantProvisioningConfig;
+ private tursoClient?: TursoPlatformClient;
+
+ constructor(config: TenantProvisioningConfig = {}) {
+ this.config = config;
+
+ // Initialize Turso Platform client if credentials provided
+ if (config.turso) {
+ this.tursoClient = new TursoPlatformClient(config.turso);
+ }
+ }
+
+ /**
+ * Provision a new tenant database
+ *
+ * Production flow:
+ * 1. Call Turso Platform API to create database
+ * 2. Generate tenant-specific auth token
+ * 3. Store tenant record in global control plane database
+ * 4. Initialize tenant database with base schema
+ * 5. Apply any pre-installed packages
+ *
+ * @param request - Provisioning request
+ * @returns Provisioning result with tenant database info
+ */
+ async provisionTenant(request: ProvisionTenantRequest): Promise {
+ const startTime = Date.now();
+ const warnings: string[] = [];
+
+ // Generate UUID for tenant database
+ const tenantId = randomUUID();
+ const databaseName = tenantId; // UUID-based naming
+
+ // Determine region
+ const region = request.region || this.config.defaultRegion || 'us-east-1';
+
+ let databaseUrl: string;
+ let authToken: string;
+
+ if (this.tursoClient) {
+ // Production mode: Use Turso Platform API
+ try {
+ // Step 1: Create database via Platform API
+ const createDbResponse = await this.tursoClient.createDatabase({
+ name: databaseName,
+ group: this.config.databaseGroup,
+ });
+
+ // Step 2: Generate database-specific auth token
+ const tokenResponse = await this.tursoClient.createDatabaseToken(databaseName, {
+ authorization: 'full-access',
+ });
+
+ databaseUrl = `libsql://${createDbResponse.database.Hostname}`;
+ authToken = this.encryptAuthToken(tokenResponse.jwt);
+ } catch (error) {
+ throw new Error(
+ `Failed to provision tenant database: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ } else {
+ // Development/Mock mode: Generate placeholder values
+ databaseUrl = `libsql://${databaseName}.turso.io`;
+ authToken = this.encryptAuthToken(`mock-token-${tenantId}`);
+ warnings.push('Running in mock mode - Turso Platform API credentials not configured');
+ }
+
+ // Step 3: Create tenant database record
+ const tenant: TenantDatabase = {
+ id: tenantId,
+ organizationId: request.organizationId,
+ databaseName,
+ databaseUrl,
+ authToken,
+ status: 'active',
+ region,
+ plan: request.plan || 'free',
+ storageLimitMb: request.storageLimitMb || this.config.defaultStorageLimitMb || 1024,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ metadata: request.metadata,
+ };
+
+ // Step 4: Store tenant record in global control plane
+ if (this.config.controlPlaneDriver) {
+ try {
+ await this.config.controlPlaneDriver.create('tenant_database', {
+ id: tenant.id,
+ organization_id: tenant.organizationId,
+ database_name: tenant.databaseName,
+ database_url: tenant.databaseUrl,
+ auth_token: tenant.authToken,
+ status: tenant.status,
+ region: tenant.region,
+ plan: tenant.plan,
+ storage_limit_mb: tenant.storageLimitMb,
+ created_at: tenant.createdAt,
+ updated_at: tenant.updatedAt,
+ metadata: tenant.metadata,
+ });
+ } catch (error) {
+ warnings.push(
+ `Failed to store tenant record in control plane: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ } else {
+ warnings.push('Control plane driver not configured - tenant record not persisted');
+ }
+
+ // Step 5: Initialize tenant database with base schema
+ // TODO: This will be implemented when we have the schema initialization service
+
+ const durationMs = Date.now() - startTime;
+
+ return {
+ tenant,
+ durationMs,
+ warnings: warnings.length > 0 ? warnings : undefined,
+ };
+ }
+
+ /**
+ * Suspend a tenant database
+ *
+ * Makes the database read-only or inaccessible.
+ */
+ async suspendTenant(tenantId: string): Promise {
+ // Step 1: Update tenant status in control plane
+ if (this.config.controlPlaneDriver) {
+ await this.config.controlPlaneDriver.update('tenant_database', tenantId, {
+ status: 'suspended',
+ updated_at: new Date().toISOString(),
+ });
+ }
+
+ // Step 2: Platform API doesn't have suspend endpoint yet
+ // This would typically set database to read-only mode
+ // For now, we just update the status in control plane
+ }
+
+ /**
+ * Archive a tenant database
+ *
+ * Preserves data but makes it inaccessible.
+ */
+ async archiveTenant(tenantId: string): Promise {
+ if (!this.config.controlPlaneDriver) {
+ throw new Error('Control plane driver required for archive operation');
+ }
+
+ // Get tenant info
+ const tenant = (await this.config.controlPlaneDriver.findById(
+ 'tenant_database',
+ tenantId,
+ )) as any;
+
+ if (!tenant) {
+ throw new Error(`Tenant ${tenantId} not found`);
+ }
+
+ // Update status to archived
+ await this.config.controlPlaneDriver.update('tenant_database', tenantId, {
+ status: 'archived',
+ updated_at: new Date().toISOString(),
+ });
+
+ // Optionally delete from Turso Platform
+ if (this.tursoClient) {
+ try {
+ await this.tursoClient.deleteDatabase(tenant.database_name);
+ } catch (error) {
+ // Log but don't fail if database doesn't exist
+ console.warn(`Failed to delete database from Turso Platform: ${error}`);
+ }
+ }
+ }
+
+ /**
+ * Restore a suspended or archived tenant
+ *
+ * Makes the database active again.
+ */
+ async restoreTenant(tenantId: string): Promise {
+ if (!this.config.controlPlaneDriver) {
+ throw new Error('Control plane driver required for restore operation');
+ }
+
+ // Get tenant info
+ const tenant = (await this.config.controlPlaneDriver.findById(
+ 'tenant_database',
+ tenantId,
+ )) as any;
+
+ if (!tenant) {
+ throw new Error(`Tenant ${tenantId} not found`);
+ }
+
+ if (tenant.status === 'archived') {
+ throw new Error('Cannot restore archived tenant - create a new tenant instead');
+ }
+
+ // Update status to active
+ await this.config.controlPlaneDriver.update('tenant_database', tenantId, {
+ status: 'active',
+ updated_at: new Date().toISOString(),
+ });
+ }
+
+ /**
+ * Migrate tenant to a different region
+ *
+ * Creates replica in target region and updates routing configuration.
+ */
+ async migrateTenantRegion(tenantId: string, targetRegion: string): Promise {
+ // TODO: Implementation
+ // 1. Create replica in target region via Platform API
+ // 2. Wait for sync to complete
+ // 3. Update tenant record with new region
+ // 4. Switch traffic to new region
+ // 5. Delete old replica
+ throw new Error('Region migration not yet implemented');
+ }
+
+ /**
+ * Encrypt auth token before storing
+ */
+ private encryptAuthToken(token: string): string {
+ // TODO: Implement proper encryption using this.config.encryptionKey
+ // For now, just return the token (in production, use crypto to encrypt)
+ return token;
+ }
+
+ /**
+ * Decrypt auth token for use
+ */
+ private decryptAuthToken(encryptedToken: string): string {
+ // TODO: Implement proper decryption using this.config.encryptionKey
+ return encryptedToken;
+ }
+}
diff --git a/packages/services/service-tenant/src/tenant-schema-initializer.ts b/packages/services/service-tenant/src/tenant-schema-initializer.ts
new file mode 100644
index 000000000..f6cfe0a34
--- /dev/null
+++ b/packages/services/service-tenant/src/tenant-schema-initializer.ts
@@ -0,0 +1,202 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { IDataDriver, ObjectDefinition } from '@objectstack/spec';
+import { TursoDriver } from '@objectstack/driver-turso';
+
+/**
+ * Tenant Schema Initialization Service
+ *
+ * Initializes tenant databases with:
+ * - Base system schema (metadata tables)
+ * - System objects (sys_user_preference, sys_api_key, etc.)
+ * - Package-specific objects
+ */
+export class TenantSchemaInitializer {
+ /**
+ * Initialize a tenant database with base schema
+ *
+ * @param tenantDatabaseUrl - Tenant database URL
+ * @param tenantAuthToken - Tenant database auth token
+ * @param baseObjects - Base objects to create (optional)
+ */
+ async initializeTenantSchema(
+ tenantDatabaseUrl: string,
+ tenantAuthToken: string,
+ baseObjects: ObjectDefinition[] = [],
+ ): Promise {
+ // Create driver for tenant database
+ const driver = new TursoDriver({
+ url: tenantDatabaseUrl,
+ authToken: tenantAuthToken,
+ });
+
+ try {
+ // Connect to tenant database
+ await driver.connect();
+
+ // Step 1: Create metadata system tables
+ await this.createMetadataTables(driver);
+
+ // Step 2: Create base objects
+ for (const objectDef of baseObjects) {
+ await driver.syncSchema(objectDef);
+ }
+
+ // Step 3: Initialize system metadata
+ await this.initializeSystemMetadata(driver);
+ } finally {
+ // Always disconnect
+ await driver.disconnect();
+ }
+ }
+
+ /**
+ * Create metadata system tables
+ *
+ * These tables store the tenant's metadata (objects, fields, views, etc.)
+ */
+ private async createMetadataTables(driver: IDataDriver): Promise {
+ // Create sys_metadata table for storing metadata items
+ const metadataObject: ObjectDefinition = {
+ name: 'sys_metadata',
+ label: 'System Metadata',
+ pluralLabel: 'System Metadata',
+ fields: {
+ id: {
+ name: 'id',
+ label: 'ID',
+ type: 'text',
+ required: true,
+ readonly: true,
+ },
+ type: {
+ name: 'type',
+ label: 'Type',
+ type: 'text',
+ required: true,
+ },
+ name: {
+ name: 'name',
+ label: 'Name',
+ type: 'text',
+ required: true,
+ },
+ data: {
+ name: 'data',
+ label: 'Data',
+ type: 'textarea',
+ required: true,
+ },
+ package_id: {
+ name: 'package_id',
+ label: 'Package ID',
+ type: 'text',
+ required: false,
+ },
+ version: {
+ name: 'version',
+ label: 'Version',
+ type: 'number',
+ required: true,
+ defaultValue: 1,
+ },
+ created_at: {
+ name: 'created_at',
+ label: 'Created At',
+ type: 'datetime',
+ required: true,
+ defaultValue: 'NOW()',
+ },
+ updated_at: {
+ name: 'updated_at',
+ label: 'Updated At',
+ type: 'datetime',
+ required: true,
+ defaultValue: 'NOW()',
+ },
+ },
+ enable: {
+ apiEnabled: true,
+ apiMethods: ['get', 'list', 'create', 'update', 'delete'],
+ },
+ };
+
+ await driver.syncSchema(metadataObject);
+ }
+
+ /**
+ * Initialize system metadata
+ *
+ * Creates initial metadata records for the tenant
+ */
+ private async initializeSystemMetadata(driver: IDataDriver): Promise {
+ // This would typically:
+ // 1. Create default views
+ // 2. Create default dashboards
+ // 3. Set up navigation structure
+ // 4. Configure default settings
+
+ // For now, this is a placeholder
+ // TODO: Implement system metadata initialization
+ }
+
+ /**
+ * Install package schema into tenant database
+ *
+ * @param tenantDatabaseUrl - Tenant database URL
+ * @param tenantAuthToken - Tenant database auth token
+ * @param packageObjects - Package objects to install
+ */
+ async installPackageSchema(
+ tenantDatabaseUrl: string,
+ tenantAuthToken: string,
+ packageObjects: ObjectDefinition[],
+ ): Promise {
+ const driver = new TursoDriver({
+ url: tenantDatabaseUrl,
+ authToken: tenantAuthToken,
+ });
+
+ try {
+ await driver.connect();
+
+ // Install each package object
+ for (const objectDef of packageObjects) {
+ await driver.syncSchema(objectDef);
+ }
+ } finally {
+ await driver.disconnect();
+ }
+ }
+
+ /**
+ * Uninstall package schema from tenant database
+ *
+ * @param tenantDatabaseUrl - Tenant database URL
+ * @param tenantAuthToken - Tenant database auth token
+ * @param packageObjectNames - Package object names to uninstall
+ */
+ async uninstallPackageSchema(
+ tenantDatabaseUrl: string,
+ tenantAuthToken: string,
+ packageObjectNames: string[],
+ ): Promise {
+ const driver = new TursoDriver({
+ url: tenantDatabaseUrl,
+ authToken: tenantAuthToken,
+ });
+
+ try {
+ await driver.connect();
+
+ // Drop each package object table
+ for (const objectName of packageObjectNames) {
+ // Note: This requires implementation of dropTable in IDataDriver
+ // For now, this is a placeholder
+ // TODO: Implement table dropping
+ }
+ } finally {
+ await driver.disconnect();
+ }
+ }
+}
diff --git a/packages/services/service-tenant/src/turso-platform-client.ts b/packages/services/service-tenant/src/turso-platform-client.ts
new file mode 100644
index 000000000..e056c929c
--- /dev/null
+++ b/packages/services/service-tenant/src/turso-platform-client.ts
@@ -0,0 +1,282 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Turso Platform API Client
+ *
+ * Interacts with the Turso Platform API to manage databases programmatically.
+ * API Documentation: https://docs.turso.tech/api-reference/platform
+ *
+ * Features:
+ * - Create databases with regional placement
+ * - Generate database-specific auth tokens
+ * - Manage database lifecycle (suspend, delete, restore)
+ * - Create database groups for shared configuration
+ */
+
+import type { TenantDatabase } from '@objectstack/spec/cloud';
+
+/**
+ * Turso Platform API Configuration
+ */
+export interface TursoPlatformConfig {
+ /**
+ * Turso Platform API token
+ * Generate at: https://turso.tech/app/settings/tokens
+ */
+ apiToken: string;
+
+ /**
+ * Organization name (slug)
+ */
+ organization: string;
+
+ /**
+ * API base URL
+ * Default: https://api.turso.tech
+ */
+ apiBaseUrl?: string;
+
+ /**
+ * Request timeout in milliseconds
+ */
+ timeout?: number;
+}
+
+/**
+ * Database creation request
+ */
+export interface CreateDatabaseRequest {
+ /**
+ * Database name (must be unique within organization)
+ * For multi-tenant: use UUID
+ */
+ name: string;
+
+ /**
+ * Database group name (optional)
+ * Groups share configuration like location and schema
+ */
+ group?: string;
+
+ /**
+ * Seed data from an existing database (optional)
+ */
+ seed?: {
+ /**
+ * Type of seed operation
+ */
+ type: 'database' | 'dump';
+
+ /**
+ * Source database name (for type: 'database')
+ */
+ name?: string;
+
+ /**
+ * Timestamp to seed from (for type: 'database')
+ */
+ timestamp?: string;
+
+ /**
+ * URL to SQL dump file (for type: 'dump')
+ */
+ url?: string;
+ };
+
+ /**
+ * Enable block reads (optional)
+ */
+ is_schema?: boolean;
+}
+
+/**
+ * Database creation response
+ */
+export interface CreateDatabaseResponse {
+ database: {
+ Name: string;
+ DbId: string;
+ Hostname: string;
+ IsSchema: boolean;
+ block_reads: boolean;
+ block_writes: boolean;
+ allow_attach: boolean;
+ regions: string[];
+ primaryRegion: string;
+ type: string;
+ version: string;
+ group: string;
+ sleeping: boolean;
+ };
+}
+
+/**
+ * Create database token request
+ */
+export interface CreateTokenRequest {
+ /**
+ * Token permissions
+ */
+ permissions?: {
+ read_attach?: {
+ databases: string[];
+ };
+ };
+
+ /**
+ * Token authorization level
+ * - 'full-access': Read and write access
+ * - 'read-only': Read-only access
+ */
+ authorization?: 'full-access' | 'read-only';
+
+ /**
+ * Token expiration time (optional)
+ * Format: duration string like '1h', '7d', '30d'
+ */
+ expiration?: string;
+}
+
+/**
+ * Create database token response
+ */
+export interface CreateTokenResponse {
+ jwt: string;
+}
+
+/**
+ * Turso Platform API Client
+ */
+export class TursoPlatformClient {
+ private config: Required;
+
+ constructor(config: TursoPlatformConfig) {
+ this.config = {
+ apiToken: config.apiToken,
+ organization: config.organization,
+ apiBaseUrl: config.apiBaseUrl || 'https://api.turso.tech',
+ timeout: config.timeout || 30000,
+ };
+ }
+
+ /**
+ * Create a new database
+ */
+ async createDatabase(request: CreateDatabaseRequest): Promise {
+ const url = `${this.config.apiBaseUrl}/v1/organizations/${this.config.organization}/databases`;
+
+ const response = await this.fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(request),
+ });
+
+ return response as CreateDatabaseResponse;
+ }
+
+ /**
+ * Create a database-specific auth token
+ */
+ async createDatabaseToken(
+ databaseName: string,
+ request: CreateTokenRequest = {},
+ ): Promise {
+ const url = `${this.config.apiBaseUrl}/v1/organizations/${this.config.organization}/databases/${databaseName}/auth/tokens`;
+
+ // Default to full-access if not specified
+ const body = {
+ authorization: request.authorization || 'full-access',
+ ...request,
+ };
+
+ const response = await this.fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ return response as CreateTokenResponse;
+ }
+
+ /**
+ * Delete a database
+ */
+ async deleteDatabase(databaseName: string): Promise {
+ const url = `${this.config.apiBaseUrl}/v1/organizations/${this.config.organization}/databases/${databaseName}`;
+
+ await this.fetch(url, {
+ method: 'DELETE',
+ });
+ }
+
+ /**
+ * Get database information
+ */
+ async getDatabase(databaseName: string): Promise {
+ const url = `${this.config.apiBaseUrl}/v1/organizations/${this.config.organization}/databases/${databaseName}`;
+
+ const response = await this.fetch(url, {
+ method: 'GET',
+ });
+
+ return (response as any).database;
+ }
+
+ /**
+ * List all databases in the organization
+ */
+ async listDatabases(): Promise {
+ const url = `${this.config.apiBaseUrl}/v1/organizations/${this.config.organization}/databases`;
+
+ const response = await this.fetch(url, {
+ method: 'GET',
+ });
+
+ return (response as any).databases || [];
+ }
+
+ /**
+ * Internal fetch wrapper with auth and error handling
+ */
+ private async fetch(url: string, options: RequestInit = {}): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
+
+ try {
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ Authorization: `Bearer ${this.config.apiToken}`,
+ ...options.headers,
+ },
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `Turso Platform API error (${response.status}): ${errorText || response.statusText}`,
+ );
+ }
+
+ // Handle empty responses (e.g., DELETE)
+ const contentType = response.headers.get('content-type');
+ if (!contentType || !contentType.includes('application/json')) {
+ return {};
+ }
+
+ return await response.json();
+ } catch (error) {
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw new Error(`Turso Platform API request timeout after ${this.config.timeout}ms`);
+ }
+ throw error;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ }
+}
diff --git a/packages/services/service-tenant/tsconfig.json b/packages/services/service-tenant/tsconfig.json
new file mode 100644
index 000000000..2be4d1f47
--- /dev/null
+++ b/packages/services/service-tenant/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
+}
diff --git a/packages/services/service-tenant/tsup.config.ts b/packages/services/service-tenant/tsup.config.ts
new file mode 100644
index 000000000..9106135df
--- /dev/null
+++ b/packages/services/service-tenant/tsup.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ entry: ['src/index.ts'],
+ format: ['esm'],
+ dts: false, // Temporarily disabled due to type resolution issues
+ sourcemap: true,
+ clean: true,
+ target: 'node18',
+ outDir: 'dist',
+ external: ['@objectstack/driver-turso'],
+});
diff --git a/packages/services/service-tenant/vitest.config.ts b/packages/services/service-tenant/vitest.config.ts
new file mode 100644
index 000000000..8e730d505
--- /dev/null
+++ b/packages/services/service-tenant/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ },
+});
diff --git a/packages/spec/src/cloud/index.ts b/packages/spec/src/cloud/index.ts
index 4fa2e5249..0e31ab8ab 100644
--- a/packages/spec/src/cloud/index.ts
+++ b/packages/spec/src/cloud/index.ts
@@ -9,9 +9,11 @@
* - Developer Portal (developer registration, API keys, publishing analytics)
* - Marketplace Administration (review workflow, curation, governance)
* - App Store (customer experience: reviews, recommendations, subscriptions)
+ * - Multi-Tenancy (tenant registry, provisioning, routing)
* - Future: Composer, Space, Hub Federation
*/
export * from './marketplace.zod';
export * from './developer-portal.zod';
export * from './marketplace-admin.zod';
export * from './app-store.zod';
+export * from './tenant.zod';
diff --git a/packages/spec/src/cloud/tenant.zod.ts b/packages/spec/src/cloud/tenant.zod.ts
new file mode 100644
index 000000000..b2d9cabcc
--- /dev/null
+++ b/packages/spec/src/cloud/tenant.zod.ts
@@ -0,0 +1,355 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { z } from 'zod';
+
+/**
+ * Multi-Tenant Architecture Schema
+ *
+ * Defines the schema for managing multi-tenant architecture with:
+ * - Global control plane: Single database for auth, org management, tenant registry
+ * - Tenant data plane: Isolated databases per organization (UUID-based naming)
+ *
+ * Design decisions:
+ * - Database naming: {uuid}.turso.io (not org-slug, since slugs can be modified)
+ * - Each tenant has its own Turso database for complete data isolation
+ * - Global database stores user auth, organizations, and tenant metadata
+ */
+
+/**
+ * Tenant Database Status
+ */
+export const TenantDatabaseStatusSchema = z.enum([
+ 'provisioning', // Database is being created
+ 'active', // Database is active and accepting connections
+ 'suspended', // Database is suspended (read-only or inaccessible)
+ 'archived', // Database is archived (data preserved but not accessible)
+ 'failed', // Provisioning or migration failed
+]);
+
+export type TenantDatabaseStatus = z.infer;
+
+/**
+ * Tenant Plan Tier
+ */
+export const TenantPlanSchema = z.enum([
+ 'free',
+ 'starter',
+ 'pro',
+ 'enterprise',
+ 'custom',
+]);
+
+export type TenantPlan = z.infer;
+
+/**
+ * Tenant Database Registry Entry
+ *
+ * Tracks each tenant's dedicated database instance.
+ * Stored in the global control plane database.
+ */
+export const TenantDatabaseSchema = z.object({
+ /**
+ * Unique tenant database identifier (UUID)
+ */
+ id: z.string().uuid().describe('Unique tenant database identifier (UUID)'),
+
+ /**
+ * Organization ID (foreign key to sys_organization)
+ */
+ organizationId: z.string().describe('Organization ID (foreign key to sys_organization)'),
+
+ /**
+ * Database name (UUID-based for immutability)
+ * Example: "550e8400-e29b-41d4-a716-446655440000"
+ */
+ databaseName: z.string().describe('Database name (UUID-based)'),
+
+ /**
+ * Full database URL
+ * Example: "libsql://550e8400-e29b-41d4-a716-446655440000.turso.io"
+ */
+ databaseUrl: z.string().url().describe('Full database URL'),
+
+ /**
+ * Encrypted tenant-specific auth token
+ */
+ authToken: z.string().describe('Encrypted tenant-specific auth token'),
+
+ /**
+ * Database provisioning and runtime status
+ */
+ status: TenantDatabaseStatusSchema.default('provisioning').describe('Database status'),
+
+ /**
+ * Deployment region
+ * Example: "us-east-1", "eu-west-1", "ap-southeast-1"
+ */
+ region: z.string().describe('Deployment region'),
+
+ /**
+ * Tenant plan tier
+ */
+ plan: TenantPlanSchema.default('free').describe('Tenant plan tier'),
+
+ /**
+ * Storage limit in megabytes
+ */
+ storageLimitMb: z.number().int().positive().describe('Storage limit in megabytes'),
+
+ /**
+ * Database creation timestamp
+ */
+ createdAt: z.string().datetime().describe('Database creation timestamp'),
+
+ /**
+ * Last update timestamp
+ */
+ updatedAt: z.string().datetime().describe('Last update timestamp'),
+
+ /**
+ * Last accessed timestamp (for usage tracking)
+ */
+ lastAccessedAt: z.string().datetime().optional().describe('Last accessed timestamp'),
+
+ /**
+ * Custom tenant configuration
+ * Can store additional metadata like feature flags, quotas, etc.
+ */
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Custom tenant configuration'),
+});
+
+export type TenantDatabase = z.infer;
+
+/**
+ * Package Installation Status
+ */
+export const PackageInstallationStatusSchema = z.enum([
+ 'installing', // Package is being installed
+ 'active', // Package is active and running
+ 'disabled', // Package is disabled (soft delete)
+ 'uninstalling', // Package is being uninstalled
+ 'failed', // Installation failed
+]);
+
+export type PackageInstallationStatus = z.infer;
+
+/**
+ * Package Installation Record
+ *
+ * Tracks which packages are installed in which tenant.
+ * Stored in the global control plane database.
+ */
+export const PackageInstallationSchema = z.object({
+ /**
+ * Unique installation identifier
+ */
+ id: z.string().uuid().describe('Unique installation identifier'),
+
+ /**
+ * Tenant database ID (foreign key to tenant_database)
+ */
+ tenantId: z.string().uuid().describe('Tenant database ID'),
+
+ /**
+ * Package identifier
+ * Example: "@objectstack/crm", "@acme/custom-plugin"
+ */
+ packageId: z.string().describe('Package identifier'),
+
+ /**
+ * Installed package version (semver)
+ */
+ version: z.string().describe('Installed package version'),
+
+ /**
+ * Installation status
+ */
+ status: PackageInstallationStatusSchema.default('installing').describe('Installation status'),
+
+ /**
+ * Installation timestamp
+ */
+ installedAt: z.string().datetime().describe('Installation timestamp'),
+
+ /**
+ * User ID who installed the package
+ */
+ installedBy: z.string().describe('User ID who installed the package'),
+
+ /**
+ * Package-specific configuration
+ */
+ config: z.record(z.string(), z.unknown()).optional().describe('Package-specific configuration'),
+
+ /**
+ * Last update timestamp
+ */
+ updatedAt: z.string().datetime().describe('Last update timestamp'),
+});
+
+export type PackageInstallation = z.infer;
+
+/**
+ * Tenant Context
+ *
+ * Runtime context containing current tenant information.
+ * Extracted from request (subdomain, header, JWT claim, etc.)
+ */
+export const TenantContextSchema = z.object({
+ /**
+ * Current tenant database ID
+ */
+ tenantId: z.string().uuid().describe('Current tenant database ID'),
+
+ /**
+ * Current organization ID
+ */
+ organizationId: z.string().describe('Current organization ID'),
+
+ /**
+ * Organization slug (for display purposes)
+ */
+ organizationSlug: z.string().optional().describe('Organization slug'),
+
+ /**
+ * Tenant database URL
+ */
+ databaseUrl: z.string().url().describe('Tenant database URL'),
+
+ /**
+ * Tenant plan tier
+ */
+ plan: TenantPlanSchema.describe('Tenant plan tier'),
+
+ /**
+ * Custom tenant metadata
+ */
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Custom tenant metadata'),
+});
+
+export type TenantContext = z.infer;
+
+/**
+ * Tenant Identification Source
+ *
+ * How the tenant was identified from the request
+ */
+export const TenantIdentificationSourceSchema = z.enum([
+ 'subdomain', // Extracted from subdomain (e.g., acme.objectstack.app)
+ 'custom_domain', // Extracted from custom domain (e.g., app.acme.com)
+ 'header', // Extracted from X-Tenant-ID header
+ 'jwt_claim', // Extracted from JWT organizationId claim
+ 'session', // Extracted from session data
+ 'default', // Default/fallback tenant
+]);
+
+export type TenantIdentificationSource = z.infer;
+
+/**
+ * Tenant Routing Configuration
+ *
+ * Configuration for tenant identification and routing
+ */
+export const TenantRoutingConfigSchema = z.object({
+ /**
+ * Enable multi-tenant mode
+ */
+ enabled: z.boolean().default(false).describe('Enable multi-tenant mode'),
+
+ /**
+ * Tenant identification strategy (in order of precedence)
+ */
+ identificationSources: z.array(TenantIdentificationSourceSchema)
+ .default(['subdomain', 'header', 'jwt_claim'])
+ .describe('Tenant identification strategy (in order of precedence)'),
+
+ /**
+ * Default tenant ID (for single-tenant deployments or fallback)
+ */
+ defaultTenantId: z.string().uuid().optional().describe('Default tenant ID'),
+
+ /**
+ * Subdomain pattern for tenant extraction
+ * Example: "{tenant}.objectstack.app"
+ */
+ subdomainPattern: z.string().optional().describe('Subdomain pattern for tenant extraction'),
+
+ /**
+ * Custom domain mapping
+ * Maps custom domains to tenant IDs
+ * Example: { "app.acme.com": "550e8400-e29b-41d4-a716-446655440000" }
+ */
+ customDomainMapping: z.record(z.string(), z.string().uuid()).optional()
+ .describe('Custom domain to tenant ID mapping'),
+
+ /**
+ * Header name for tenant ID
+ */
+ tenantHeaderName: z.string().default('X-Tenant-ID').describe('Header name for tenant ID'),
+
+ /**
+ * JWT claim name for organization ID
+ */
+ jwtOrganizationClaim: z.string().default('organizationId')
+ .describe('JWT claim name for organization ID'),
+});
+
+export type TenantRoutingConfig = z.infer;
+
+/**
+ * Tenant Provisioning Request
+ *
+ * Request to provision a new tenant database
+ */
+export const ProvisionTenantRequestSchema = z.object({
+ /**
+ * Organization ID to provision database for
+ */
+ organizationId: z.string().describe('Organization ID'),
+
+ /**
+ * Deployment region preference
+ */
+ region: z.string().optional().describe('Deployment region preference'),
+
+ /**
+ * Tenant plan tier
+ */
+ plan: TenantPlanSchema.default('free').describe('Tenant plan tier'),
+
+ /**
+ * Storage limit in megabytes
+ */
+ storageLimitMb: z.number().int().positive().optional().describe('Storage limit in megabytes'),
+
+ /**
+ * Custom tenant metadata
+ */
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Custom tenant metadata'),
+});
+
+export type ProvisionTenantRequest = z.infer;
+
+/**
+ * Tenant Provisioning Response
+ *
+ * Result of tenant provisioning operation
+ */
+export const ProvisionTenantResponseSchema = z.object({
+ /**
+ * Provisioned tenant database
+ */
+ tenant: TenantDatabaseSchema.describe('Provisioned tenant database'),
+
+ /**
+ * Provisioning duration in milliseconds
+ */
+ durationMs: z.number().describe('Provisioning duration in milliseconds'),
+
+ /**
+ * Any warnings or notes from provisioning
+ */
+ warnings: z.array(z.string()).optional().describe('Provisioning warnings'),
+});
+
+export type ProvisionTenantResponse = z.infer;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a18bef4ae..357d006ea 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1366,6 +1366,31 @@ importers:
specifier: ^4.1.4
version: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@20.9.0)(msw@2.13.3(@types/node@25.6.0)(typescript@6.0.2))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
+ packages/services/service-tenant:
+ dependencies:
+ '@objectstack/core':
+ specifier: workspace:^
+ version: link:../../core
+ '@objectstack/driver-turso':
+ specifier: workspace:^
+ version: link:../../plugins/driver-turso
+ '@objectstack/spec':
+ specifier: workspace:^
+ version: link:../../spec
+ devDependencies:
+ '@types/node':
+ specifier: ^22.10.5
+ version: 22.19.17
+ tsup:
+ specifier: ^8.3.5
+ version: 8.5.1(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
+ typescript:
+ specifier: ^5.7.3
+ version: 5.9.3
+ vitest:
+ specifier: ^2.1.8
+ version: 2.1.9(@types/node@22.19.17)(happy-dom@20.9.0)(lightningcss@1.32.0)(msw@2.13.3(@types/node@22.19.17)(typescript@5.9.3))
+
packages/spec:
dependencies:
ai:
@@ -1751,6 +1776,12 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+ '@esbuild/aix-ppc64@0.21.5':
+ resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+
'@esbuild/aix-ppc64@0.24.2':
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
engines: {node: '>=18'}
@@ -1769,6 +1800,12 @@ packages:
cpu: [ppc64]
os: [aix]
+ '@esbuild/android-arm64@0.21.5':
+ resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
'@esbuild/android-arm64@0.24.2':
resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
engines: {node: '>=18'}
@@ -1787,6 +1824,12 @@ packages:
cpu: [arm64]
os: [android]
+ '@esbuild/android-arm@0.21.5':
+ resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
'@esbuild/android-arm@0.24.2':
resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
engines: {node: '>=18'}
@@ -1805,6 +1848,12 @@ packages:
cpu: [arm]
os: [android]
+ '@esbuild/android-x64@0.21.5':
+ resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
'@esbuild/android-x64@0.24.2':
resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
engines: {node: '>=18'}
@@ -1823,6 +1872,12 @@ packages:
cpu: [x64]
os: [android]
+ '@esbuild/darwin-arm64@0.21.5':
+ resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
'@esbuild/darwin-arm64@0.24.2':
resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
engines: {node: '>=18'}
@@ -1841,6 +1896,12 @@ packages:
cpu: [arm64]
os: [darwin]
+ '@esbuild/darwin-x64@0.21.5':
+ resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
'@esbuild/darwin-x64@0.24.2':
resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
engines: {node: '>=18'}
@@ -1859,6 +1920,12 @@ packages:
cpu: [x64]
os: [darwin]
+ '@esbuild/freebsd-arm64@0.21.5':
+ resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
'@esbuild/freebsd-arm64@0.24.2':
resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
engines: {node: '>=18'}
@@ -1877,6 +1944,12 @@ packages:
cpu: [arm64]
os: [freebsd]
+ '@esbuild/freebsd-x64@0.21.5':
+ resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
'@esbuild/freebsd-x64@0.24.2':
resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
engines: {node: '>=18'}
@@ -1895,6 +1968,12 @@ packages:
cpu: [x64]
os: [freebsd]
+ '@esbuild/linux-arm64@0.21.5':
+ resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
'@esbuild/linux-arm64@0.24.2':
resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
engines: {node: '>=18'}
@@ -1913,6 +1992,12 @@ packages:
cpu: [arm64]
os: [linux]
+ '@esbuild/linux-arm@0.21.5':
+ resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
'@esbuild/linux-arm@0.24.2':
resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
engines: {node: '>=18'}
@@ -1931,6 +2016,12 @@ packages:
cpu: [arm]
os: [linux]
+ '@esbuild/linux-ia32@0.21.5':
+ resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
'@esbuild/linux-ia32@0.24.2':
resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
engines: {node: '>=18'}
@@ -1949,6 +2040,12 @@ packages:
cpu: [ia32]
os: [linux]
+ '@esbuild/linux-loong64@0.21.5':
+ resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/linux-loong64@0.24.2':
resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
engines: {node: '>=18'}
@@ -1967,6 +2064,12 @@ packages:
cpu: [loong64]
os: [linux]
+ '@esbuild/linux-mips64el@0.21.5':
+ resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
'@esbuild/linux-mips64el@0.24.2':
resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
engines: {node: '>=18'}
@@ -1985,6 +2088,12 @@ packages:
cpu: [mips64el]
os: [linux]
+ '@esbuild/linux-ppc64@0.21.5':
+ resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
'@esbuild/linux-ppc64@0.24.2':
resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
engines: {node: '>=18'}
@@ -2003,6 +2112,12 @@ packages:
cpu: [ppc64]
os: [linux]
+ '@esbuild/linux-riscv64@0.21.5':
+ resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
'@esbuild/linux-riscv64@0.24.2':
resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
engines: {node: '>=18'}
@@ -2021,6 +2136,12 @@ packages:
cpu: [riscv64]
os: [linux]
+ '@esbuild/linux-s390x@0.21.5':
+ resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
'@esbuild/linux-s390x@0.24.2':
resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
engines: {node: '>=18'}
@@ -2039,6 +2160,12 @@ packages:
cpu: [s390x]
os: [linux]
+ '@esbuild/linux-x64@0.21.5':
+ resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
'@esbuild/linux-x64@0.24.2':
resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
engines: {node: '>=18'}
@@ -2075,6 +2202,12 @@ packages:
cpu: [arm64]
os: [netbsd]
+ '@esbuild/netbsd-x64@0.21.5':
+ resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
'@esbuild/netbsd-x64@0.24.2':
resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
engines: {node: '>=18'}
@@ -2111,6 +2244,12 @@ packages:
cpu: [arm64]
os: [openbsd]
+ '@esbuild/openbsd-x64@0.21.5':
+ resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
'@esbuild/openbsd-x64@0.24.2':
resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
engines: {node: '>=18'}
@@ -2141,6 +2280,12 @@ packages:
cpu: [arm64]
os: [openharmony]
+ '@esbuild/sunos-x64@0.21.5':
+ resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
'@esbuild/sunos-x64@0.24.2':
resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
engines: {node: '>=18'}
@@ -2159,6 +2304,12 @@ packages:
cpu: [x64]
os: [sunos]
+ '@esbuild/win32-arm64@0.21.5':
+ resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
'@esbuild/win32-arm64@0.24.2':
resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
engines: {node: '>=18'}
@@ -2177,6 +2328,12 @@ packages:
cpu: [arm64]
os: [win32]
+ '@esbuild/win32-ia32@0.21.5':
+ resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
'@esbuild/win32-ia32@0.24.2':
resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
engines: {node: '>=18'}
@@ -2195,6 +2352,12 @@ packages:
cpu: [ia32]
os: [win32]
+ '@esbuild/win32-x64@0.21.5':
+ resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
'@esbuild/win32-x64@0.24.2':
resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
engines: {node: '>=18'}
@@ -4016,6 +4179,9 @@ packages:
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
+ '@types/node@22.19.17':
+ resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
+
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
@@ -4099,9 +4265,23 @@ packages:
'@vitest/browser':
optional: true
+ '@vitest/expect@2.1.9':
+ resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
+
'@vitest/expect@4.1.4':
resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==}
+ '@vitest/mocker@2.1.9':
+ resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
'@vitest/mocker@4.1.4':
resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==}
peerDependencies:
@@ -4113,18 +4293,33 @@ packages:
vite:
optional: true
+ '@vitest/pretty-format@2.1.9':
+ resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
+
'@vitest/pretty-format@4.1.4':
resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==}
+ '@vitest/runner@2.1.9':
+ resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
+
'@vitest/runner@4.1.4':
resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==}
+ '@vitest/snapshot@2.1.9':
+ resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
+
'@vitest/snapshot@4.1.4':
resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==}
+ '@vitest/spy@2.1.9':
+ resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
+
'@vitest/spy@4.1.4':
resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==}
+ '@vitest/utils@2.1.9':
+ resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
+
'@vitest/utils@4.1.4':
resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==}
@@ -4506,6 +4701,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+ chai@5.3.3:
+ resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+ engines: {node: '>=18'}
+
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@@ -4533,6 +4732,10 @@ packages:
chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
+ check-error@2.1.3:
+ resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+ engines: {node: '>= 16'}
+
cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
@@ -4729,6 +4932,10 @@ packages:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
+ deep-eql@5.0.2:
+ resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+ engines: {node: '>=6'}
+
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
@@ -4894,6 +5101,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
+ es-module-lexer@1.7.0:
+ resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
es-module-lexer@2.0.0:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
@@ -4911,6 +5121,11 @@ packages:
esast-util-from-js@2.0.1:
resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==}
+ esbuild@0.21.5:
+ resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
+ engines: {node: '>=12'}
+ hasBin: true
+
esbuild@0.24.2:
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
engines: {node: '>=18'}
@@ -5875,6 +6090,9 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+ loupe@3.2.1:
+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -6509,9 +6727,16 @@ packages:
resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==}
engines: {node: '>=18'}
+ pathe@1.1.2:
+ resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ pathval@2.0.1:
+ resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+ engines: {node: '>= 14.16'}
+
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@@ -7047,6 +7272,9 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ std-env@3.10.0:
+ resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
std-env@4.0.0:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
@@ -7220,10 +7448,22 @@ packages:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
+ tinypool@1.1.1:
+ resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+
+ tinyrainbow@1.2.0:
+ resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
+ engines: {node: '>=14.0.0'}
+
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
+ tinyspy@3.0.2:
+ resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
+ engines: {node: '>=14.0.0'}
+
tldts-core@7.0.28:
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
@@ -7346,6 +7586,11 @@ packages:
typed-rest-client@1.8.11:
resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==}
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
typescript@6.0.2:
resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==}
engines: {node: '>=14.17'}
@@ -7371,6 +7616,9 @@ packages:
underscore@1.13.8:
resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
@@ -7497,6 +7745,42 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+ vite-node@2.1.9:
+ resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+
+ vite@5.4.21:
+ resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || >=20.0.0
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.4.0
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+
vite@8.0.8:
resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -7548,6 +7832,31 @@ packages:
vite:
optional: true
+ vitest@2.1.9:
+ resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@types/node': ^18.0.0 || >=20.0.0
+ '@vitest/browser': 2.1.9
+ '@vitest/ui': 2.1.9
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
vitest@4.1.4:
resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -8208,6 +8517,9 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@esbuild/aix-ppc64@0.21.5':
+ optional: true
+
'@esbuild/aix-ppc64@0.24.2':
optional: true
@@ -8217,6 +8529,9 @@ snapshots:
'@esbuild/aix-ppc64@0.28.0':
optional: true
+ '@esbuild/android-arm64@0.21.5':
+ optional: true
+
'@esbuild/android-arm64@0.24.2':
optional: true
@@ -8226,6 +8541,9 @@ snapshots:
'@esbuild/android-arm64@0.28.0':
optional: true
+ '@esbuild/android-arm@0.21.5':
+ optional: true
+
'@esbuild/android-arm@0.24.2':
optional: true
@@ -8235,6 +8553,9 @@ snapshots:
'@esbuild/android-arm@0.28.0':
optional: true
+ '@esbuild/android-x64@0.21.5':
+ optional: true
+
'@esbuild/android-x64@0.24.2':
optional: true
@@ -8244,6 +8565,9 @@ snapshots:
'@esbuild/android-x64@0.28.0':
optional: true
+ '@esbuild/darwin-arm64@0.21.5':
+ optional: true
+
'@esbuild/darwin-arm64@0.24.2':
optional: true
@@ -8253,6 +8577,9 @@ snapshots:
'@esbuild/darwin-arm64@0.28.0':
optional: true
+ '@esbuild/darwin-x64@0.21.5':
+ optional: true
+
'@esbuild/darwin-x64@0.24.2':
optional: true
@@ -8262,6 +8589,9 @@ snapshots:
'@esbuild/darwin-x64@0.28.0':
optional: true
+ '@esbuild/freebsd-arm64@0.21.5':
+ optional: true
+
'@esbuild/freebsd-arm64@0.24.2':
optional: true
@@ -8271,6 +8601,9 @@ snapshots:
'@esbuild/freebsd-arm64@0.28.0':
optional: true
+ '@esbuild/freebsd-x64@0.21.5':
+ optional: true
+
'@esbuild/freebsd-x64@0.24.2':
optional: true
@@ -8280,6 +8613,9 @@ snapshots:
'@esbuild/freebsd-x64@0.28.0':
optional: true
+ '@esbuild/linux-arm64@0.21.5':
+ optional: true
+
'@esbuild/linux-arm64@0.24.2':
optional: true
@@ -8289,6 +8625,9 @@ snapshots:
'@esbuild/linux-arm64@0.28.0':
optional: true
+ '@esbuild/linux-arm@0.21.5':
+ optional: true
+
'@esbuild/linux-arm@0.24.2':
optional: true
@@ -8298,6 +8637,9 @@ snapshots:
'@esbuild/linux-arm@0.28.0':
optional: true
+ '@esbuild/linux-ia32@0.21.5':
+ optional: true
+
'@esbuild/linux-ia32@0.24.2':
optional: true
@@ -8307,6 +8649,9 @@ snapshots:
'@esbuild/linux-ia32@0.28.0':
optional: true
+ '@esbuild/linux-loong64@0.21.5':
+ optional: true
+
'@esbuild/linux-loong64@0.24.2':
optional: true
@@ -8316,6 +8661,9 @@ snapshots:
'@esbuild/linux-loong64@0.28.0':
optional: true
+ '@esbuild/linux-mips64el@0.21.5':
+ optional: true
+
'@esbuild/linux-mips64el@0.24.2':
optional: true
@@ -8325,6 +8673,9 @@ snapshots:
'@esbuild/linux-mips64el@0.28.0':
optional: true
+ '@esbuild/linux-ppc64@0.21.5':
+ optional: true
+
'@esbuild/linux-ppc64@0.24.2':
optional: true
@@ -8334,6 +8685,9 @@ snapshots:
'@esbuild/linux-ppc64@0.28.0':
optional: true
+ '@esbuild/linux-riscv64@0.21.5':
+ optional: true
+
'@esbuild/linux-riscv64@0.24.2':
optional: true
@@ -8343,6 +8697,9 @@ snapshots:
'@esbuild/linux-riscv64@0.28.0':
optional: true
+ '@esbuild/linux-s390x@0.21.5':
+ optional: true
+
'@esbuild/linux-s390x@0.24.2':
optional: true
@@ -8352,6 +8709,9 @@ snapshots:
'@esbuild/linux-s390x@0.28.0':
optional: true
+ '@esbuild/linux-x64@0.21.5':
+ optional: true
+
'@esbuild/linux-x64@0.24.2':
optional: true
@@ -8370,6 +8730,9 @@ snapshots:
'@esbuild/netbsd-arm64@0.28.0':
optional: true
+ '@esbuild/netbsd-x64@0.21.5':
+ optional: true
+
'@esbuild/netbsd-x64@0.24.2':
optional: true
@@ -8388,6 +8751,9 @@ snapshots:
'@esbuild/openbsd-arm64@0.28.0':
optional: true
+ '@esbuild/openbsd-x64@0.21.5':
+ optional: true
+
'@esbuild/openbsd-x64@0.24.2':
optional: true
@@ -8403,6 +8769,9 @@ snapshots:
'@esbuild/openharmony-arm64@0.28.0':
optional: true
+ '@esbuild/sunos-x64@0.21.5':
+ optional: true
+
'@esbuild/sunos-x64@0.24.2':
optional: true
@@ -8412,6 +8781,9 @@ snapshots:
'@esbuild/sunos-x64@0.28.0':
optional: true
+ '@esbuild/win32-arm64@0.21.5':
+ optional: true
+
'@esbuild/win32-arm64@0.24.2':
optional: true
@@ -8421,6 +8793,9 @@ snapshots:
'@esbuild/win32-arm64@0.28.0':
optional: true
+ '@esbuild/win32-ia32@0.21.5':
+ optional: true
+
'@esbuild/win32-ia32@0.24.2':
optional: true
@@ -8430,6 +8805,9 @@ snapshots:
'@esbuild/win32-ia32@0.28.0':
optional: true
+ '@esbuild/win32-x64@0.21.5':
+ optional: true
+
'@esbuild/win32-x64@0.24.2':
optional: true
@@ -8587,6 +8965,14 @@ snapshots:
'@inquirer/ansi@1.0.2': {}
+ '@inquirer/confirm@5.1.21(@types/node@22.19.17)':
+ dependencies:
+ '@inquirer/core': 10.3.2(@types/node@22.19.17)
+ '@inquirer/type': 3.0.10(@types/node@22.19.17)
+ optionalDependencies:
+ '@types/node': 22.19.17
+ optional: true
+
'@inquirer/confirm@5.1.21(@types/node@25.6.0)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@25.6.0)
@@ -8594,6 +8980,20 @@ snapshots:
optionalDependencies:
'@types/node': 25.6.0
+ '@inquirer/core@10.3.2(@types/node@22.19.17)':
+ dependencies:
+ '@inquirer/ansi': 1.0.2
+ '@inquirer/figures': 1.0.15
+ '@inquirer/type': 3.0.10(@types/node@22.19.17)
+ cli-width: 4.1.0
+ mute-stream: 2.0.0
+ signal-exit: 4.1.0
+ wrap-ansi: 6.2.0
+ yoctocolors-cjs: 2.1.3
+ optionalDependencies:
+ '@types/node': 22.19.17
+ optional: true
+
'@inquirer/core@10.3.2(@types/node@25.6.0)':
dependencies:
'@inquirer/ansi': 1.0.2
@@ -8616,6 +9016,11 @@ snapshots:
'@inquirer/figures@1.0.15': {}
+ '@inquirer/type@3.0.10(@types/node@22.19.17)':
+ optionalDependencies:
+ '@types/node': 22.19.17
+ optional: true
+
'@inquirer/type@3.0.10(@types/node@25.6.0)':
optionalDependencies:
'@types/node': 25.6.0
@@ -10178,6 +10583,10 @@ snapshots:
'@types/node@12.20.55': {}
+ '@types/node@22.19.17':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/node@25.6.0':
dependencies:
undici-types: 7.19.2
@@ -10254,6 +10663,13 @@ snapshots:
tinyrainbow: 3.1.0
vitest: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@20.9.0)(msw@2.13.3(@types/node@25.6.0)(typescript@6.0.2))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
+ '@vitest/expect@2.1.9':
+ dependencies:
+ '@vitest/spy': 2.1.9
+ '@vitest/utils': 2.1.9
+ chai: 5.3.3
+ tinyrainbow: 1.2.0
+
'@vitest/expect@4.1.4':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -10263,6 +10679,15 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
+ '@vitest/mocker@2.1.9(msw@2.13.3(@types/node@22.19.17)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.17)(lightningcss@1.32.0))':
+ dependencies:
+ '@vitest/spy': 2.1.9
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ msw: 2.13.3(@types/node@22.19.17)(typescript@5.9.3)
+ vite: 5.4.21(@types/node@22.19.17)(lightningcss@1.32.0)
+
'@vitest/mocker@4.1.4(msw@2.13.3(@types/node@25.6.0)(typescript@6.0.2))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.4
@@ -10272,15 +10697,30 @@ snapshots:
msw: 2.13.3(@types/node@25.6.0)(typescript@6.0.2)
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)
+ '@vitest/pretty-format@2.1.9':
+ dependencies:
+ tinyrainbow: 1.2.0
+
'@vitest/pretty-format@4.1.4':
dependencies:
tinyrainbow: 3.1.0
+ '@vitest/runner@2.1.9':
+ dependencies:
+ '@vitest/utils': 2.1.9
+ pathe: 1.1.2
+
'@vitest/runner@4.1.4':
dependencies:
'@vitest/utils': 4.1.4
pathe: 2.0.3
+ '@vitest/snapshot@2.1.9':
+ dependencies:
+ '@vitest/pretty-format': 2.1.9
+ magic-string: 0.30.21
+ pathe: 1.1.2
+
'@vitest/snapshot@4.1.4':
dependencies:
'@vitest/pretty-format': 4.1.4
@@ -10288,8 +10728,18 @@ snapshots:
magic-string: 0.30.21
pathe: 2.0.3
+ '@vitest/spy@2.1.9':
+ dependencies:
+ tinyspy: 3.0.2
+
'@vitest/spy@4.1.4': {}
+ '@vitest/utils@2.1.9':
+ dependencies:
+ '@vitest/pretty-format': 2.1.9
+ loupe: 3.2.1
+ tinyrainbow: 1.2.0
+
'@vitest/utils@4.1.4':
dependencies:
'@vitest/pretty-format': 4.1.4
@@ -10656,6 +11106,14 @@ snapshots:
ccount@2.0.1: {}
+ chai@5.3.3:
+ dependencies:
+ assertion-error: 2.0.1
+ check-error: 2.1.3
+ deep-eql: 5.0.2
+ loupe: 3.2.1
+ pathval: 2.0.1
+
chai@6.2.2: {}
chalk@4.1.2:
@@ -10675,6 +11133,8 @@ snapshots:
chardet@2.1.1: {}
+ check-error@2.1.3: {}
+
cheerio-select@2.1.0:
dependencies:
boolbase: 1.0.0
@@ -10853,6 +11313,8 @@ snapshots:
dependencies:
mimic-response: 3.1.0
+ deep-eql@5.0.2: {}
+
deep-extend@0.6.0: {}
deepmerge@4.3.1: {}
@@ -10983,6 +11445,8 @@ snapshots:
es-errors@1.3.0: {}
+ es-module-lexer@1.7.0: {}
+
es-module-lexer@2.0.0: {}
es-object-atoms@1.1.1:
@@ -11010,6 +11474,32 @@ snapshots:
esast-util-from-estree: 2.0.0
vfile-message: 4.0.3
+ esbuild@0.21.5:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.21.5
+ '@esbuild/android-arm': 0.21.5
+ '@esbuild/android-arm64': 0.21.5
+ '@esbuild/android-x64': 0.21.5
+ '@esbuild/darwin-arm64': 0.21.5
+ '@esbuild/darwin-x64': 0.21.5
+ '@esbuild/freebsd-arm64': 0.21.5
+ '@esbuild/freebsd-x64': 0.21.5
+ '@esbuild/linux-arm': 0.21.5
+ '@esbuild/linux-arm64': 0.21.5
+ '@esbuild/linux-ia32': 0.21.5
+ '@esbuild/linux-loong64': 0.21.5
+ '@esbuild/linux-mips64el': 0.21.5
+ '@esbuild/linux-ppc64': 0.21.5
+ '@esbuild/linux-riscv64': 0.21.5
+ '@esbuild/linux-s390x': 0.21.5
+ '@esbuild/linux-x64': 0.21.5
+ '@esbuild/netbsd-x64': 0.21.5
+ '@esbuild/openbsd-x64': 0.21.5
+ '@esbuild/sunos-x64': 0.21.5
+ '@esbuild/win32-arm64': 0.21.5
+ '@esbuild/win32-ia32': 0.21.5
+ '@esbuild/win32-x64': 0.21.5
+
esbuild@0.24.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.24.2
@@ -12115,6 +12605,8 @@ snapshots:
longest-streak@3.1.0: {}
+ loupe@3.2.1: {}
+
lru-cache@10.4.3: {}
lru-cache@11.3.5: {}
@@ -12667,6 +13159,32 @@ snapshots:
ms@2.1.3: {}
+ msw@2.13.3(@types/node@22.19.17)(typescript@5.9.3):
+ dependencies:
+ '@inquirer/confirm': 5.1.21(@types/node@22.19.17)
+ '@mswjs/interceptors': 0.41.3
+ '@open-draft/deferred-promise': 2.2.0
+ '@types/statuses': 2.0.6
+ cookie: 1.1.1
+ graphql: 16.13.2
+ headers-polyfill: 4.0.3
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ path-to-regexp: 6.3.0
+ picocolors: 1.1.1
+ rettime: 0.11.7
+ statuses: 2.0.2
+ strict-event-emitter: 0.5.1
+ tough-cookie: 6.0.1
+ type-fest: 5.5.0
+ until-async: 3.0.2
+ yargs: 17.7.2
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@types/node'
+ optional: true
+
msw@2.13.3(@types/node@25.6.0)(typescript@6.0.2):
dependencies:
'@inquirer/confirm': 5.1.21(@types/node@25.6.0)
@@ -12912,8 +13430,12 @@ snapshots:
path-type@6.0.0: {}
+ pathe@1.1.2: {}
+
pathe@2.0.3: {}
+ pathval@2.0.1: {}
+
pend@1.2.0: {}
pg-connection-string@2.6.2: {}
@@ -13587,6 +14109,8 @@ snapshots:
statuses@2.0.2: {}
+ std-env@3.10.0: {}
+
std-env@4.0.0: {}
strict-event-emitter@0.5.1: {}
@@ -13770,8 +14294,14 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
+ tinypool@1.1.1: {}
+
+ tinyrainbow@1.2.0: {}
+
tinyrainbow@3.1.0: {}
+ tinyspy@3.0.2: {}
+
tldts-core@7.0.28: {}
tldts@7.0.28:
@@ -13830,6 +14360,34 @@ snapshots:
tslib@2.8.1: {}
+ tsup@8.5.1(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
+ dependencies:
+ bundle-require: 5.1.0(esbuild@0.27.7)
+ cac: 6.7.14
+ chokidar: 4.0.3
+ consola: 3.4.2
+ debug: 4.4.3(supports-color@8.1.1)
+ esbuild: 0.27.7
+ fix-dts-default-cjs-exports: 1.0.1
+ joycon: 3.1.1
+ picocolors: 1.1.1
+ postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(yaml@2.8.3)
+ resolve-from: 5.0.0
+ rollup: 4.60.1
+ source-map: 0.7.6
+ sucrase: 3.35.1
+ tinyexec: 0.3.2
+ tinyglobby: 0.2.16
+ tree-kill: 1.2.2
+ optionalDependencies:
+ postcss: 8.5.9
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - jiti
+ - supports-color
+ - tsx
+ - yaml
+
tsup@8.5.1(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.7)
@@ -13900,6 +14458,8 @@ snapshots:
tunnel: 0.0.6
underscore: 1.13.8
+ typescript@5.9.3: {}
+
typescript@6.0.2: {}
uc.micro@2.1.0: {}
@@ -13916,6 +14476,8 @@ snapshots:
underscore@1.13.8: {}
+ undici-types@6.21.0: {}
+
undici-types@7.19.2: {}
undici@7.25.0: {}
@@ -14040,6 +14602,34 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ vite-node@2.1.9(@types/node@22.19.17)(lightningcss@1.32.0):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3(supports-color@8.1.1)
+ es-module-lexer: 1.7.0
+ pathe: 1.1.2
+ vite: 5.4.21(@types/node@22.19.17)(lightningcss@1.32.0)
+ transitivePeerDependencies:
+ - '@types/node'
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
+ vite@5.4.21(@types/node@22.19.17)(lightningcss@1.32.0):
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.5.9
+ rollup: 4.60.1
+ optionalDependencies:
+ '@types/node': 22.19.17
+ fsevents: 2.3.3
+ lightningcss: 1.32.0
+
vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
@@ -14059,6 +14649,42 @@ snapshots:
optionalDependencies:
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)
+ vitest@2.1.9(@types/node@22.19.17)(happy-dom@20.9.0)(lightningcss@1.32.0)(msw@2.13.3(@types/node@22.19.17)(typescript@5.9.3)):
+ dependencies:
+ '@vitest/expect': 2.1.9
+ '@vitest/mocker': 2.1.9(msw@2.13.3(@types/node@22.19.17)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.17)(lightningcss@1.32.0))
+ '@vitest/pretty-format': 2.1.9
+ '@vitest/runner': 2.1.9
+ '@vitest/snapshot': 2.1.9
+ '@vitest/spy': 2.1.9
+ '@vitest/utils': 2.1.9
+ chai: 5.3.3
+ debug: 4.4.3(supports-color@8.1.1)
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ pathe: 1.1.2
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinypool: 1.1.1
+ tinyrainbow: 1.2.0
+ vite: 5.4.21(@types/node@22.19.17)(lightningcss@1.32.0)
+ vite-node: 2.1.9(@types/node@22.19.17)(lightningcss@1.32.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 22.19.17
+ happy-dom: 20.9.0
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@20.9.0)(msw@2.13.3(@types/node@25.6.0)(typescript@6.0.2))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.4