UNPKG

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
--- 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.