oneie
Version:
Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.
1,360 lines (1,148 loc) • 36.9 kB
Markdown
title: Multitenant
dimension: connections
category: multitenant.md
tags: agent, ai, architecture, backend, connections, frontend, ontology
related_dimensions: events, groups, knowledge, people
scope: global
created: 2025-11-03
updated: 2025-11-03
version: 1.0.0
ai_context: |
This document is part of the connections dimension in the multitenant.md category.
Location: one/connections/multitenant.md
Purpose: Documents multitenant.md - multi-tenant architecture
Related dimensions: events, groups, knowledge, people
For AI agents: Read this to understand multitenant.
# Multitenant.md - Multi-Tenant Architecture
## Overview
**Multi-tenancy** enables multiple organizations to use the ONE Platform while maintaining complete data isolation, custom branding, and independent configuration. This architecture leverages the 6-dimension ontology to provide:
1. **Organization Isolation**: Each org's data is completely separated
2. **Shared Infrastructure**: Single Hono API + Convex backend serves all orgs
3. **Custom Frontends**: Each org can deploy custom-branded Astro frontend
4. **Flexible Billing**: Per-org limits, quotas, and subscriptions
5. **Role-Based Access**: Granular permissions within each organization
**Key Principle:** Organizations are first-class entities in the ontology. All resources (tokens, agents, content) belong to an organization via connections.
## Architecture Vision
```
┌─────────────────────────────────────────────────────────────┐
│ MULTI-TENANT FRONTENDS │
├─────────────────────────────────────────────────────────────┤
│ Org A Frontend Org B Frontend Org C Frontend │
│ orgA.oneie.com orgB.oneie.com orgC.oneie.com │
│ - Custom theme - Custom theme - Custom theme │
│ - Custom components - Custom layout - Mobile-first │
└────────────────┬─────────────────┬──────────────────┬────────┘
│ │ │
└────────┬────────┴──────────┬───────┘
│ Shared API │
▼ │
┌─────────────────────────────────────────────▼───────────────┐
│ SHARED HONO API BACKEND │
│ - Organization middleware (extract orgId from subdomain) │
│ - Role-based access control (RBAC) │
│ - Per-org rate limiting │
│ - Per-org billing/quotas │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SHARED CONVEX BACKEND │
│ - All orgs in single database │
│ - Data filtered by orgId (automatic isolation) │
│ - Organizations table (entities with type: 'organization') │
│ - Connections: user→org, org→resources │
│ - Events: per-org activity tracking │
└─────────────────────────────────────────────────────────────┘
```
## Data Model (6-Dimension Ontology)
### Entities
**Organization Entity:**
```typescript
{
_id: "org_xyz",
type: "organization",
name: "Acme Corp",
properties: {
slug: "acme-corp", // Unique slug for subdomain
domain: "acme-corp.oneie.com", // Custom domain
plan: "pro", // Subscription tier
settings: {
branding: {
logo: "https://...",
primaryColor: "#FF5733",
theme: "dark"
},
features: {
aiClone: true,
tokenEconomy: true,
customDomain: true
},
limits: {
maxUsers: 100,
maxAgents: 10,
maxTokens: 1000000
}
},
billing: {
stripeCustomerId: "cus_xxx",
subscriptionId: "sub_xxx",
currentPeriodEnd: 1735689600000
}
},
status: "active",
createdAt: 1704067200000,
updatedAt: 1704067200000
}
```
**User Entity (enhanced with org context):**
```typescript
{
_id: "user_abc",
type: "user",
name: "Sarah Johnson",
properties: {
email: "sarah@acmecorp.com",
role: "creator", // Global role
currentOrgId: "org_xyz" // Active organization
},
status: "active",
createdAt: 1704067200000,
updatedAt: 1704067200000
}
```
### Connections
**Organization Membership:**
```typescript
{
fromEntityId: "user_abc",
toEntityId: "org_xyz",
relationshipType: "member_of",
metadata: {
role: "admin", // Org-specific role: admin, member, viewer
permissions: [
"create_agents",
"manage_users",
"manage_billing"
],
joinedAt: 1704067200000,
invitedBy: "user_def"
},
createdAt: 1704067200000
}
```
**Resource Ownership (org owns resources):**
```typescript
// Organization → Token
{
fromEntityId: "org_xyz",
toEntityId: "token_123",
relationshipType: "owns",
createdAt: 1704067200000
}
// Organization → AI Clone
{
fromEntityId: "org_xyz",
toEntityId: "ai_clone_456",
relationshipType: "owns",
createdAt: 1704067200000
}
// Organization → Content
{
fromEntityId: "org_xyz",
toEntityId: "content_789",
relationshipType: "owns",
createdAt: 1704067200000
}
```
**User-Resource Access (user accesses org resources):**
```typescript
// User → Resource (scoped to org)
{
fromEntityId: "user_abc",
toEntityId: "token_123",
relationshipType: "has_access",
metadata: {
orgId: "org_xyz", // Scoped to organization
accessLevel: "edit" // read, edit, admin
},
createdAt: 1704067200000
}
```
### Events
**Organization Events:**
```typescript
{
entityId: "org_xyz",
eventType: "organization_created",
timestamp: 1704067200000,
actorType: "user",
actorId: "user_abc",
metadata: {
plan: "pro",
source: "web"
}
}
{
entityId: "org_xyz",
eventType: "user_invited",
timestamp: 1704067200000,
actorType: "user",
actorId: "user_abc",
metadata: {
invitedUserId: "user_def",
role: "member",
email: "john@acmecorp.com"
}
}
{
entityId: "org_xyz",
eventType: "plan_upgraded",
timestamp: 1704067200000,
actorType: "user",
actorId: "user_abc",
metadata: {
oldPlan: "starter",
newPlan: "pro",
price: 99
}
}
```
**Resource Events (scoped to org):**
```typescript
{
entityId: "token_123",
eventType: "tokens_purchased",
timestamp: 1704067200000,
actorType: "user",
actorId: "user_abc",
metadata: {
orgId: "org_xyz", // Organization context
amount: 1000,
price: 100,
paymentId: "pay_xxx"
}
}
```
### Tags
**Organization Tags:**
```typescript
{
name: "industry:saas",
category: "industry"
}
{
name: "size:enterprise",
category: "organization_size"
}
// Junction: organization → tags
{
entityId: "org_xyz",
tagId: "tag_industry_saas"
}
```
## Implementation Pattern
### 1. Organization Service (Effect.ts)
#### `convex/services/organizations/organization.ts`
```typescript
import { Effect } from 'effect';
import { ConvexDatabase } from '../providers/convex';
import type { Id } from '../../_generated/dataModel';
/**
* Organization business logic
* Handles org creation, membership, settings
*/
// Tagged errors
export class OrganizationNotFoundError {
readonly _tag = 'OrganizationNotFoundError';
constructor(readonly orgId: string) {}
}
export class DuplicateSlugError {
readonly _tag = 'DuplicateSlugError';
constructor(readonly slug: string) {}
}
export class InsufficientPermissionsError {
readonly _tag = 'InsufficientPermissionsError';
constructor(readonly userId: string, readonly permission: string) {}
}
export class QuotaExceededError {
readonly _tag = 'QuotaExceededError';
constructor(readonly resource: string, readonly limit: number) {}
}
export class OrganizationService extends Effect.Service<OrganizationService>()(
'OrganizationService',
{
effect: Effect.gen(function* () {
const db = yield* ConvexDatabase;
return {
/**
* Create new organization
*/
create: (args: {
name: string;
slug: string;
creatorId: Id<'entities'>;
plan?: string;
}) =>
Effect.gen(function* () {
// Validate slug uniqueness
const existing = yield* Effect.tryPromise(() =>
db.query('entities')
.withIndex('type_slug', (q) =>
q.eq('type', 'organization').eq('properties.slug', args.slug)
)
.first()
);
if (existing) {
return yield* Effect.fail(new DuplicateSlugError(args.slug));
}
// Create organization entity
const orgId = yield* Effect.tryPromise(() =>
db.insert('entities', {
type: 'organization',
name: args.name,
properties: {
slug: args.slug,
domain: `${args.slug}.oneie.com`,
plan: args.plan || 'starter',
settings: {
branding: {
primaryColor: '#3B82F6',
theme: 'light',
},
features: {
aiClone: args.plan === 'pro' || args.plan === 'enterprise',
tokenEconomy: true,
customDomain: args.plan === 'enterprise',
},
limits: {
maxUsers: args.plan === 'enterprise' ? -1 : args.plan === 'pro' ? 100 : 10,
maxAgents: args.plan === 'enterprise' ? -1 : args.plan === 'pro' ? 10 : 3,
maxTokens: args.plan === 'enterprise' ? -1 : 1000000,
},
},
},
status: 'active',
createdAt: Date.now(),
updatedAt: Date.now(),
})
);
// Add creator as admin
yield* Effect.tryPromise(() =>
db.insert('connections', {
fromEntityId: args.creatorId,
toEntityId: orgId,
relationshipType: 'member_of',
metadata: {
role: 'admin',
permissions: ['*'], // All permissions
joinedAt: Date.now(),
},
createdAt: Date.now(),
})
);
// Log event
yield* Effect.tryPromise(() =>
db.insert('events', {
entityId: orgId,
eventType: 'organization_created',
timestamp: Date.now(),
actorType: 'user',
actorId: args.creatorId,
metadata: {
plan: args.plan || 'starter',
source: 'web',
},
})
);
return { orgId, slug: args.slug };
}),
/**
* Add user to organization
*/
addMember: (args: {
orgId: Id<'entities'>;
userId: Id<'entities'>;
role: 'admin' | 'member' | 'viewer';
invitedBy: Id<'entities'>;
}) =>
Effect.gen(function* () {
// Check if inviter has permission
const inviterMembership = yield* Effect.tryPromise(() =>
db.query('connections')
.withIndex('from_type', (q) =>
q.eq('fromEntityId', args.invitedBy).eq('relationshipType', 'member_of')
)
.filter((q) => q.eq(q.field('toEntityId'), args.orgId))
.first()
);
if (!inviterMembership || inviterMembership.metadata?.role !== 'admin') {
return yield* Effect.fail(
new InsufficientPermissionsError(args.invitedBy, 'manage_users')
);
}
// Check org limits
const org = yield* Effect.tryPromise(() => db.get(args.orgId));
if (!org) {
return yield* Effect.fail(new OrganizationNotFoundError(args.orgId));
}
const memberCount = yield* Effect.tryPromise(() =>
db.query('connections')
.withIndex('to_type', (q) =>
q.eq('toEntityId', args.orgId).eq('relationshipType', 'member_of')
)
.collect()
).pipe(Effect.map((members) => members.length));
const maxUsers = org.properties.settings.limits.maxUsers;
if (maxUsers !== -1 && memberCount >= maxUsers) {
return yield* Effect.fail(new QuotaExceededError('users', maxUsers));
}
// Add member
const permissions =
args.role === 'admin'
? ['*']
: args.role === 'member'
? ['create_agents', 'create_content', 'view_analytics']
: ['view_content'];
yield* Effect.tryPromise(() =>
db.insert('connections', {
fromEntityId: args.userId,
toEntityId: args.orgId,
relationshipType: 'member_of',
metadata: {
role: args.role,
permissions,
joinedAt: Date.now(),
invitedBy: args.invitedBy,
},
createdAt: Date.now(),
})
);
// Log event
yield* Effect.tryPromise(() =>
db.insert('events', {
entityId: args.orgId,
eventType: 'user_invited',
timestamp: Date.now(),
actorType: 'user',
actorId: args.invitedBy,
metadata: {
invitedUserId: args.userId,
role: args.role,
},
})
);
return { success: true };
}),
/**
* Update organization settings
*/
updateSettings: (args: {
orgId: Id<'entities'>;
userId: Id<'entities'>;
settings: Record<string, any>;
}) =>
Effect.gen(function* () {
// Check permissions
const membership = yield* Effect.tryPromise(() =>
db.query('connections')
.withIndex('from_type', (q) =>
q.eq('fromEntityId', args.userId).eq('relationshipType', 'member_of')
)
.filter((q) => q.eq(q.field('toEntityId'), args.orgId))
.first()
);
if (!membership || membership.metadata?.role !== 'admin') {
return yield* Effect.fail(
new InsufficientPermissionsError(args.userId, 'manage_settings')
);
}
// Update org
const org = yield* Effect.tryPromise(() => db.get(args.orgId));
if (!org) {
return yield* Effect.fail(new OrganizationNotFoundError(args.orgId));
}
yield* Effect.tryPromise(() =>
db.patch(args.orgId, {
properties: {
...org.properties,
settings: {
...org.properties.settings,
...args.settings,
},
},
updatedAt: Date.now(),
})
);
// Log event
yield* Effect.tryPromise(() =>
db.insert('events', {
entityId: args.orgId,
eventType: 'settings_updated',
timestamp: Date.now(),
actorType: 'user',
actorId: args.userId,
metadata: {
updatedFields: Object.keys(args.settings),
},
})
);
return { success: true };
}),
/**
* Check if user has permission in org
*/
checkPermission: (args: {
userId: Id<'entities'>;
orgId: Id<'entities'>;
permission: string;
}) =>
Effect.gen(function* () {
const membership = yield* Effect.tryPromise(() =>
db.query('connections')
.withIndex('from_type', (q) =>
q.eq('fromEntityId', args.userId).eq('relationshipType', 'member_of')
)
.filter((q) => q.eq(q.field('toEntityId'), args.orgId))
.first()
);
if (!membership) {
return false;
}
const permissions = membership.metadata?.permissions || [];
return permissions.includes('*') || permissions.includes(args.permission);
}),
/**
* Get organization by slug
*/
getBySlug: (slug: string) =>
Effect.gen(function* () {
const org = yield* Effect.tryPromise(() =>
db.query('entities')
.withIndex('type_slug', (q) =>
q.eq('type', 'organization').eq('properties.slug', slug)
)
.first()
);
if (!org) {
return yield* Effect.fail(new OrganizationNotFoundError(slug));
}
return org;
}),
};
}),
dependencies: [ConvexDatabase.Default],
}
) {}
```
### 2. Organization Middleware (Hono)
#### `api/src/middleware/organization.ts`
```typescript
import { Context } from 'hono';
import { ConvexHttpClient } from 'convex/browser';
import { api } from '../../../convex/_generated/api';
/**
* Organization middleware
* Extracts orgId from subdomain or header
* Validates user membership
*/
export const organizationMiddleware = async (c: Context, next: Function) => {
const convex = new ConvexHttpClient(c.env.CONVEX_URL);
// Extract org slug from subdomain
const host = c.req.header('host') || '';
const subdomain = host.split('.')[0];
// Skip for main domain or API domain
if (subdomain === 'api' || subdomain === 'www' || !subdomain.includes('oneie')) {
// Try to get orgId from header (for API calls)
const orgId = c.req.header('x-organization-id');
if (orgId) {
c.set('orgId', orgId);
}
await next();
return;
}
try {
// Get organization by slug
const org = await convex.query(api.queries.organizations.getBySlug, {
slug: subdomain,
});
if (!org) {
return c.json({ error: 'Organization not found' }, 404);
}
// Check if org is active
if (org.status !== 'active') {
return c.json({ error: 'Organization is inactive' }, 403);
}
// Set org context
c.set('orgId', org._id);
c.set('org', org);
await next();
} catch (error) {
return c.json({ error: 'Invalid organization' }, 400);
}
};
/**
* Permission middleware
* Checks if user has required permission in org
*/
export const requirePermission = (permission: string) => {
return async (c: Context, next: Function) => {
const orgId = c.get('orgId');
const session = c.get('session'); // Set by auth middleware
if (!orgId || !session) {
return c.json({ error: 'Unauthorized' }, 401);
}
const convex = new ConvexHttpClient(c.env.CONVEX_URL);
const hasPermission = await convex.query(
api.queries.organizations.checkPermission,
{
userId: session.user.id,
orgId,
permission,
}
);
if (!hasPermission) {
return c.json({ error: 'Insufficient permissions' }, 403);
}
await next();
};
};
```
### 3. Organization Routes (Hono)
#### `api/src/routes/organizations.ts`
```typescript
import { Hono } from 'hono';
import { Effect } from 'effect';
import { convexMiddleware, ConvexService } from '../services/convex';
import { OrganizationService } from '../../../convex/services/organizations/organization';
import { auth } from '../auth';
import { requirePermission } from '../middleware/organization';
const app = new Hono();
app.use('*', convexMiddleware);
// POST /api/organizations - Create organization
app.post('/', async (c) => {
const session = await auth(c.env).api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { name, slug, plan } = await c.req.json();
const convex = c.get('convex') as ConvexService;
const orgService = new OrganizationService();
const program = orgService.create({
name,
slug,
creatorId: session.user.id,
plan,
});
const result = await Effect.runPromise(
program.pipe(
Effect.catchAll((error) => {
if (error._tag === 'DuplicateSlugError') {
return Effect.succeed({
error: 'Slug already taken',
code: 'DUPLICATE_SLUG',
});
}
return Effect.succeed({ error: 'Failed to create organization' });
})
)
);
if ('error' in result) {
return c.json(result, 400);
}
return c.json(result, 201);
});
// GET /api/organizations/:slug - Get organization
app.get('/:slug', async (c) => {
const convex = c.get('convex') as ConvexService;
const org = await convex.getOrganizationBySlug(c.req.param('slug'));
if (!org) {
return c.json({ error: 'Organization not found' }, 404);
}
return c.json(org);
});
// POST /api/organizations/:id/members - Add member
app.post('/:id/members', requirePermission('manage_users'), async (c) => {
const session = await auth(c.env).api.getSession({
headers: c.req.raw.headers,
});
const { userId, role } = await c.req.json();
const orgService = new OrganizationService();
const program = orgService.addMember({
orgId: c.req.param('id'),
userId,
role,
invitedBy: session.user.id,
});
const result = await Effect.runPromise(
program.pipe(
Effect.catchAll((error) => {
if (error._tag === 'QuotaExceededError') {
return Effect.succeed({
error: `User limit exceeded (${error.limit})`,
code: 'QUOTA_EXCEEDED',
});
}
if (error._tag === 'InsufficientPermissionsError') {
return Effect.succeed({
error: 'Insufficient permissions',
code: 'FORBIDDEN',
});
}
return Effect.succeed({ error: 'Failed to add member' });
})
)
);
if ('error' in result) {
return c.json(result, 400);
}
return c.json(result);
});
// PATCH /api/organizations/:id/settings - Update settings
app.patch('/:id/settings', requirePermission('manage_settings'), async (c) => {
const session = await auth(c.env).api.getSession({
headers: c.req.raw.headers,
});
const settings = await c.req.json();
const orgService = new OrganizationService();
const program = orgService.updateSettings({
orgId: c.req.param('id'),
userId: session.user.id,
settings,
});
const result = await Effect.runPromise(program);
return c.json(result);
});
export const organizationRoutes = app;
```
### 4. Scoped Queries (Convex)
#### `convex/queries/tokens.ts` (with org filtering)
```typescript
import { v } from 'convex/values';
import { query } from '../_generated/server';
/**
* Token queries scoped to organization
*/
export const list = query({
args: {
orgId: v.id('entities'),
},
handler: async (ctx, args) => {
// Get tokens owned by organization
const orgTokens = await ctx.db
.query('connections')
.withIndex('from_type', (q) =>
q.eq('fromEntityId', args.orgId).eq('relationshipType', 'owns')
)
.collect();
const tokenIds = orgTokens
.map((c) => c.toEntityId)
.filter((id) => id !== undefined);
const tokens = await Promise.all(
tokenIds.map(async (id) => {
const entity = await ctx.db.get(id);
if (entity && entity.type === 'token') {
return entity;
}
return null;
})
);
return tokens.filter((t) => t !== null);
},
});
export const getBalance = query({
args: {
userId: v.id('entities'),
tokenId: v.id('entities'),
orgId: v.id('entities'),
},
handler: async (ctx, args) => {
// Verify token belongs to org
const ownership = await ctx.db
.query('connections')
.withIndex('from_to', (q) =>
q.eq('fromEntityId', args.orgId).eq('toEntityId', args.tokenId)
)
.filter((q) => q.eq(q.field('relationshipType'), 'owns'))
.first();
if (!ownership) {
throw new Error('Token does not belong to organization');
}
// Get user's balance
const connection = await ctx.db
.query('connections')
.withIndex('from_to', (q) =>
q.eq('fromEntityId', args.userId).eq('toEntityId', args.tokenId)
)
.filter((q) => q.eq(q.field('relationshipType'), 'holds_tokens'))
.first();
return connection?.metadata?.balance || 0;
},
});
```
### 5. Frontend: Organization Context
#### `src/lib/organization-context.tsx`
```typescript
import { createContext, useContext, ReactNode } from 'react';
import { useQuery } from 'convex/react';
import { api } from '@/convex/_generated/api';
import type { Id } from '@/convex/_generated/dataModel';
interface Organization {
_id: Id<'entities'>;
name: string;
slug: string;
settings: {
branding: {
logo?: string;
primaryColor: string;
theme: 'light' | 'dark';
};
features: Record<string, boolean>;
limits: Record<string, number>;
};
}
interface OrganizationContextType {
organization: Organization | null;
isLoading: boolean;
hasFeature: (feature: string) => boolean;
hasPermission: (permission: string) => boolean;
}
const OrganizationContext = createContext<OrganizationContextType | null>(null);
export function OrganizationProvider({
children,
orgSlug,
}: {
children: ReactNode;
orgSlug: string;
}) {
const organization = useQuery(api.queries.organizations.getBySlug, {
slug: orgSlug,
});
const hasFeature = (feature: string) => {
return organization?.properties.settings.features[feature] || false;
};
const hasPermission = (permission: string) => {
// TODO: Check user's permissions in this org
return true;
};
return (
<OrganizationContext.Provider
value={{
organization: organization || null,
isLoading: organization === undefined,
hasFeature,
hasPermission,
}}
>
{children}
</OrganizationContext.Provider>
);
}
export function useOrganization() {
const context = useContext(OrganizationContext);
if (!context) {
throw new Error('useOrganization must be used within OrganizationProvider');
}
return context;
}
```
#### `src/pages/[org]/dashboard.astro`
```astro
import { ConvexHttpClient } from 'convex/browser';
import { api } from '@/convex/_generated/api';
import Layout from '@/layouts/Layout.astro';
import { OrganizationProvider } from '@/lib/organization-context';
import Dashboard from '@/components/dashboard/Dashboard';
// Extract org slug from URL
const orgSlug = Astro.params.org;
// Fetch org data server-side
const convex = new ConvexHttpClient(import.meta.env.PUBLIC_CONVEX_URL);
const org = await convex.query(api.queries.organizations.getBySlug, {
slug: orgSlug,
});
if (!org) {
return Astro.redirect('/404');
}
// Apply org branding
const primaryColor = org.properties.settings.branding.primaryColor;
<Layout title={`${org.name} - Dashboard`}>
<style define:vars={{ primaryColor }}>
:root {
--color-primary: var(--primaryColor);
}
</style>
<OrganizationProvider client:load orgSlug={orgSlug}>
<Dashboard client:load />
</OrganizationProvider>
</Layout>
```
### 6. Feature Gating
#### `src/components/features/FeatureGate.tsx`
```typescript
import { useOrganization } from '@/lib/organization-context';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface FeatureGateProps {
feature: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function FeatureGate({ feature, children, fallback }: FeatureGateProps) {
const { hasFeature, organization } = useOrganization();
if (!hasFeature(feature)) {
return (
fallback || (
<Alert>
<AlertDescription>
This feature is not available on your current plan.
<a href={`/${organization?.slug}/billing`} className="underline ml-1">
Upgrade now
</a>
</AlertDescription>
</Alert>
)
);
}
return <>{children}</>;
}
```
**Usage:**
```typescript
import { FeatureGate } from '@/components/features/FeatureGate';
import { AICloneCreator } from '@/components/ai-clone/AICloneCreator';
export function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<FeatureGate feature="aiClone">
<AICloneCreator />
</FeatureGate>
<FeatureGate feature="customDomain">
<CustomDomainSettings />
</FeatureGate>
</div>
);
}
```
## Subdomain Routing
### Cloudflare Pages Configuration
**`wrangler.toml`:**
```toml
name = "oneie-frontend"
compatibility_date = "2024-01-01"
[[routes]]
pattern = "*.oneie.com/*"
zone_name = "oneie.com"
custom_domain = true
[env.production]
routes = [
{ pattern = "*.oneie.com/*", custom_domain = true }
]
```
### Subdomain Extraction (Astro Middleware)
#### `src/middleware/organization.ts`
```typescript
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const host = context.request.headers.get('host') || '';
const subdomain = host.split('.')[0];
// Skip main domain
if (subdomain === 'www' || subdomain === 'oneie' || !host.includes('oneie.com')) {
return next();
}
// Store org slug in context
context.locals.orgSlug = subdomain;
return next();
});
```
**Usage in pages:**
```astro
const orgSlug = Astro.locals.orgSlug;
```
## Billing & Quotas
### Plan Configuration
#### `convex/config/plans.ts`
```typescript
export const PLANS = {
starter: {
name: 'Starter',
price: 0,
features: {
aiClone: false,
tokenEconomy: true,
customDomain: false,
analytics: 'basic',
},
limits: {
maxUsers: 10,
maxAgents: 3,
maxTokens: 100000,
maxContent: 100,
},
},
pro: {
name: 'Pro',
price: 99,
features: {
aiClone: true,
tokenEconomy: true,
customDomain: false,
analytics: 'advanced',
},
limits: {
maxUsers: 100,
maxAgents: 10,
maxTokens: 1000000,
maxContent: 1000,
},
},
enterprise: {
name: 'Enterprise',
price: 499,
features: {
aiClone: true,
tokenEconomy: true,
customDomain: true,
analytics: 'custom',
},
limits: {
maxUsers: -1, // Unlimited
maxAgents: -1,
maxTokens: -1,
maxContent: -1,
},
},
} as const;
```
### Quota Checking
```typescript
// In OrganizationService
checkQuota: (args: {
orgId: Id<'entities'>;
resource: 'users' | 'agents' | 'tokens' | 'content';
}) =>
Effect.gen(function* () {
const org = yield* Effect.tryPromise(() => db.get(args.orgId));
if (!org) {
return yield* Effect.fail(new OrganizationNotFoundError(args.orgId));
}
const limit = org.properties.settings.limits[`max${args.resource.charAt(0).toUpperCase() + args.resource.slice(1)}`];
// -1 means unlimited
if (limit === -1) {
return { allowed: true, limit: -1, current: 0 };
}
// Count current usage
const connections = yield* Effect.tryPromise(() =>
db.query('connections')
.withIndex('from_type', (q) =>
q.eq('fromEntityId', args.orgId).eq('relationshipType', 'owns')
)
.collect()
);
const current = connections.length;
if (current >= limit) {
return yield* Effect.fail(new QuotaExceededError(args.resource, limit));
}
return { allowed: true, limit, current };
})
```
## Multi-Tenant Benefits
### 1. Data Isolation
✅ **Complete separation**: Each org's data is isolated via `orgId` filtering
✅ **No cross-contamination**: Users can't access other orgs' resources
✅ **Audit trail**: All events tagged with `orgId`
### 2. Customization
✅ **Custom branding**: Logo, colors, theme per org
✅ **Feature flags**: Enable/disable features per org
✅ **Custom domains**: `acme-corp.oneie.com` or `app.acmecorp.com`
### 3. Scalability
✅ **Shared infrastructure**: Single API + database serves all orgs
✅ **Independent deployments**: Each org can have custom frontend
✅ **Cost efficiency**: Shared backend reduces operational costs
### 4. Flexibility
✅ **Per-org billing**: Different plans, pricing, quotas
✅ **Role-based access**: Granular permissions within each org
✅ **Multi-org users**: Users can belong to multiple organizations
## Migration from Single-Tenant
### Phase 1: Add Organization Entity
1. Create `organization` entity type in schema
2. Create default organization for existing users
3. Add `member_of` connections for all users
### Phase 2: Add Organization Ownership
1. Add `owns` connections: organization → resources
2. Update all resource creation to set ownership
3. Migrate existing resources to default org
### Phase 3: Add Organization Filtering
1. Update all queries to filter by `orgId`
2. Add organization middleware to Hono
3. Add organization context to frontend
### Phase 4: Enable Multi-Tenancy
1. Add subdomain routing
2. Enable custom branding per org
3. Add billing/quota system
4. Launch multi-org support
## Best Practices
### 1. Always Filter by orgId
```typescript
// ❌ Bad: No org filtering
const tokens = await ctx.db.query('entities').filter((q) => q.eq(q.field('type'), 'token')).collect();
// ✅ Good: Filtered by org
const orgTokens = await ctx.db
.query('connections')
.withIndex('from_type', (q) =>
q.eq('fromEntityId', orgId).eq('relationshipType', 'owns')
)
.collect();
```
### 2. Check Permissions
```typescript
// ❌ Bad: No permission check
app.delete('/api/agents/:id', async (c) => {
await convex.deleteAgent(c.req.param('id'));
});
// ✅ Good: Permission check
app.delete('/api/agents/:id', requirePermission('delete_agents'), async (c) => {
await convex.deleteAgent(c.req.param('id'));
});
```
### 3. Respect Quotas
```typescript
// ✅ Always check quotas before creating resources
const quota = await orgService.checkQuota({ orgId, resource: 'agents' });
if (!quota.allowed) {
throw new QuotaExceededError('agents', quota.limit);
}
```
### 4. Log Organization Events
```typescript
// ✅ All significant actions should be logged with orgId
await ctx.db.insert('events', {
entityId: agentId,
eventType: 'agent_created',
timestamp: Date.now(),
actorType: 'user',
actorId: userId,
metadata: {
orgId, // Include organization context
agentType: 'ai_clone',
},
});
```
## Next Steps
1. Add `organization` entity type to schema
2. Create OrganizationService (Effect.ts)
3. Add organization middleware to Hono
4. Implement subdomain routing
5. Add organization context to frontend
6. Implement feature gating
7. Add billing/subscription system
8. Enable custom domains
9. Migrate existing users to default org
10. Launch multi-tenant support
**Result:** A fully multi-tenant platform where organizations have complete data isolation, custom branding, and flexible billing, all powered by the 6-dimension ontology.