This plan outlines the implementation of a multi-tenant namespace system that allows multiple companies to use the same product instance with complete data isolation. Each namespace operates as an independent environment with its own users, roles, permissions, stores, products, orders, chat, and all other modules.
-- Namespaces (Companies/Tenants)
CREATE TABLE namespaces (
id SERIAL PRIMARY KEY,
uuid VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, -- Company name
slug VARCHAR(255) NOT NULL UNIQUE, -- URL-friendly identifier (e.g., "acme-corp")
domain VARCHAR(255), -- Custom domain (optional)
logo_url VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'active', -- active, suspended, inactive
plan VARCHAR(50) DEFAULT 'free', -- free, starter, professional, enterprise
settings JSONB DEFAULT '{}', -- Namespace-specific settings
max_users INTEGER DEFAULT 10,
max_stores INTEGER DEFAULT 5,
owner_user_id INTEGER, -- Super admin of this namespace
created_at TIMESTAMP,
updated_at TIMESTAMP,
CONSTRAINT namespaces_status_valid CHECK (status IN ('active', 'suspended', 'inactive')),
CONSTRAINT namespaces_slug_format CHECK (slug ~ '^[a-z0-9\-]+$')
);
-- User-Namespace Membership (Many-to-Many)
CREATE TABLE namespace_members (
id SERIAL PRIMARY KEY,
uuid VARCHAR(255) NOT NULL UNIQUE,
namespace_id INTEGER NOT NULL REFERENCES namespaces(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'active', -- active, invited, suspended
is_owner BOOLEAN DEFAULT false, -- Is this user the namespace owner
joined_at TIMESTAMP,
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
UNIQUE(namespace_id, user_id)
);
-- Namespace-Specific Roles (Roles scoped to namespace)
CREATE TABLE namespace_roles (
id SERIAL PRIMARY KEY,
uuid VARCHAR(255) NOT NULL UNIQUE,
namespace_id INTEGER NOT NULL REFERENCES namespaces(id) ON DELETE CASCADE,
role_name VARCHAR(255) NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false, -- Auto-assign to new members
permissions JSONB DEFAULT '{}', -- Module permissions
created_at TIMESTAMP,
updated_at TIMESTAMP,
UNIQUE(namespace_id, role_name)
);
-- User Roles within a Namespace
CREATE TABLE namespace_user_roles (
id SERIAL PRIMARY KEY,
uuid VARCHAR(255) NOT NULL UNIQUE,
namespace_member_id INTEGER NOT NULL REFERENCES namespace_members(id) ON DELETE CASCADE,
namespace_role_id INTEGER NOT NULL REFERENCES namespace_roles(id) ON DELETE CASCADE,
created_at TIMESTAMP,
updated_at TIMESTAMP,
UNIQUE(namespace_member_id, namespace_role_id)
);
-- Namespace Invitations
CREATE TABLE namespace_invitations (
id SERIAL PRIMARY KEY,
uuid VARCHAR(255) NOT NULL UNIQUE,
namespace_id INTEGER NOT NULL REFERENCES namespaces(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
role_id INTEGER REFERENCES namespace_roles(id) ON DELETE SET NULL,
token VARCHAR(255) NOT NULL UNIQUE,
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- pending, accepted, expired, revoked
invited_by INTEGER NOT NULL REFERENCES users(id),
expires_at TIMESTAMP NOT NULL,
accepted_at TIMESTAMP,
created_at TIMESTAMP,
updated_at TIMESTAMP
);All tenant-specific tables will have a namespace_id foreign key:
-- Add namespace_id to existing tables
ALTER TABLE stores ADD COLUMN namespace_id INTEGER REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE storeproducts ADD COLUMN namespace_id INTEGER REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE orders ADD COLUMN namespace_id INTEGER REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE customers ADD COLUMN namespace_id INTEGER REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE categories ADD COLUMN namespace_id INTEGER REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE chat_channels ADD COLUMN namespace_id INTEGER REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE delivery_partners ADD COLUMN namespace_id INTEGER REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE notifications ADD COLUMN namespace_id INTEGER REFERENCES namespaces(id) ON DELETE CASCADE;
-- ... (all other tenant-specific tables)
-- Create indexes for performance
CREATE INDEX idx_stores_namespace ON stores(namespace_id);
CREATE INDEX idx_orders_namespace ON orders(namespace_id);
CREATE INDEX idx_customers_namespace ON customers(namespace_id);
CREATE INDEX idx_chat_channels_namespace ON chat_channels(namespace_id);
-- ... (all other indexes){
"branding": {
"primaryColor": "#3B82F6",
"logoUrl": "https://...",
"faviconUrl": "https://..."
},
"features": {
"chat": true,
"deliveryPartners": true,
"multiCurrency": false,
"customDomain": false
},
"limits": {
"maxProducts": 1000,
"maxOrders": 10000,
"storageGB": 10
},
"notifications": {
"emailEnabled": true,
"slackWebhook": null
}
}Every request will resolve the namespace through one of these methods (in order):
- Subdomain:
acme.opsapi.com→ namespace: "acme" - Header:
X-Namespace-Id: uuidorX-Namespace-Slug: acme - URL Path:
/api/v2/ns/{namespace_slug}/... - JWT Token: Namespace included in token payload
# Namespace Management (Super Admin / Platform Level)
POST /api/v2/namespaces # Create namespace
GET /api/v2/namespaces # List namespaces (platform admin)
GET /api/v2/namespaces/:id # Get namespace details
PUT /api/v2/namespaces/:id # Update namespace
DELETE /api/v2/namespaces/:id # Delete namespace
# Namespace Switching (User Level)
GET /api/v2/user/namespaces # List user's namespaces
POST /api/v2/user/namespaces/:id/switch # Switch active namespace
GET /api/v2/user/current-namespace # Get current namespace context
# Namespace Members (Namespace Admin)
GET /api/v2/namespace/members # List members
POST /api/v2/namespace/members/invite # Invite user
DELETE /api/v2/namespace/members/:id # Remove member
PUT /api/v2/namespace/members/:id/role # Change member role
# Namespace Roles (Namespace Admin)
GET /api/v2/namespace/roles # List roles
POST /api/v2/namespace/roles # Create role
PUT /api/v2/namespace/roles/:id # Update role
DELETE /api/v2/namespace/roles/:id # Delete role
# All existing routes work within namespace context
GET /api/v2/stores # Returns stores for current namespace
GET /api/v2/orders # Returns orders for current namespace
GET /api/v2/chat/channels # Returns channels for current namespace
# ... all other existing routes
{
"userinfo": {
"uuid": "user-uuid",
"email": "user@example.com",
"name": "John Doe"
},
"namespace": {
"uuid": "namespace-uuid",
"slug": "acme-corp",
"role": "admin"
},
"exp": 1234567890
}lapis/queries/
├── NamespaceQueries.lua # CRUD for namespaces
├── NamespaceMemberQueries.lua # Member management
├── NamespaceRoleQueries.lua # Namespace-specific roles
└── NamespaceInvitationQueries.lua # Invitation management
-- middleware/namespace.lua
local NamespaceMiddleware = {}
function NamespaceMiddleware.resolveNamespace(handler)
return function(self)
-- Priority 1: Header
local ns_header = self.req.headers["x-namespace-id"] or
self.req.headers["x-namespace-slug"]
-- Priority 2: JWT Token
local ns_from_token = self.current_user and
self.current_user.namespace and
self.current_user.namespace.uuid
-- Priority 3: Subdomain (parsed from Host header)
local host = self.req.headers["host"]
local ns_from_subdomain = host and host:match("^([^.]+)%.") or nil
local namespace_id = ns_header or ns_from_token or ns_from_subdomain
if not namespace_id then
return { json = { error = "Namespace context required" }, status = 400 }
end
-- Validate namespace and user membership
local namespace = NamespaceQueries.findByIdOrSlug(namespace_id)
if not namespace then
return { json = { error = "Namespace not found" }, status = 404 }
end
-- Check user has access to this namespace
if self.current_user then
local membership = NamespaceMemberQueries.findByUserAndNamespace(
self.current_user.uuid, namespace.id
)
if not membership then
return { json = { error = "Access denied to namespace" }, status = 403 }
end
self.namespace_membership = membership
self.namespace_role = membership.role
end
self.namespace = namespace
return handler(self)
end
end
return NamespaceMiddlewarelapis/routes/
├── namespaces.lua # Namespace CRUD
├── namespace-members.lua # Member management
├── namespace-roles.lua # Role management
├── namespace-invitations.lua # Invitation handling
└── namespace-switch.lua # Namespace switching
opsapi-dashboard/
├── components/
│ └── namespace/
│ ├── NamespaceSwitcher.tsx # Dropdown to switch namespaces
│ ├── NamespaceSelector.tsx # Full page namespace selector
│ ├── NamespaceSettingsPanel.tsx # Namespace settings
│ ├── NamespaceMembersList.tsx # Members management
│ ├── NamespaceRoleManager.tsx # Role management
│ ├── NamespaceInviteModal.tsx # Invite users
│ ├── CreateNamespaceModal.tsx # Create new namespace
│ └── NamespaceBranding.tsx # Branding customization
├── contexts/
│ └── NamespaceContext.tsx # Namespace state & switching
├── hooks/
│ └── useNamespace.ts # Namespace hook
├── services/
│ └── namespace.service.ts # Namespace API calls
├── store/
│ └── namespace.store.ts # Zustand store for namespace
└── app/
└── dashboard/
└── namespace/
├── page.tsx # Namespace dashboard
├── settings/
│ └── page.tsx # Namespace settings
├── members/
│ └── page.tsx # Members management
└── roles/
└── page.tsx # Roles management
// contexts/NamespaceContext.tsx
interface NamespaceContextValue {
currentNamespace: Namespace | null;
userNamespaces: Namespace[];
isLoading: boolean;
switchNamespace: (namespaceId: string) => Promise<void>;
hasNamespacePermission: (permission: string) => boolean;
isNamespaceOwner: boolean;
isNamespaceAdmin: boolean;
}The namespace switcher will appear in the header, allowing users to:
- See current namespace name and logo
- Quick-switch between their namespaces
- Create a new namespace (if permitted)
- Access namespace settings
// DashboardLayout.tsx will wrap content with NamespaceProvider
<AuthProvider>
<NamespaceProvider>
<PermissionsProvider>
<DashboardContent />
</PermissionsProvider>
</NamespaceProvider>
</AuthProvider>- Create namespace tables
- Create default "system" namespace
- Add namespace_id to all existing tables (nullable initially)
- Migrate existing data to default namespace
- Make namespace_id NOT NULL
- Implement NamespaceQueries
- Add namespace middleware
- Update all existing queries to filter by namespace_id
- Update JWT token generation with namespace
- Create namespace API routes
- Create NamespaceContext
- Add NamespaceSwitcher to header
- Update API client to include namespace header
- Add namespace management pages
- Update all data fetching to use namespace context
- Test namespace isolation
- Test role-based access within namespaces
- Test namespace switching
- Performance testing with multiple namespaces
- Security audit for data leakage
- Every database query MUST include namespace_id filter
- Row-Level Security (RLS) policies as additional protection
- API middleware validates namespace access on every request
- User logs in (no namespace context yet)
- Backend returns list of user's namespaces
- User selects namespace (or auto-select if only one)
- New token issued with namespace context
- All subsequent requests use namespaced token
- Prevented by design
- Users can only see data from their current namespace
- Namespace switching requires re-authentication context
Each new namespace gets these default roles:
| Role | Description | Permissions |
|---|---|---|
| owner | Full control over namespace | All permissions |
| admin | Administrative access | All except delete namespace |
| manager | Manage daily operations | CRUD on most modules |
| member | Standard member access | Read most, write limited |
| viewer | Read-only access | Read only |
lapis/migrations/namespace-system.lua- Database migrationslapis/queries/NamespaceQueries.lua- Namespace CRUDlapis/queries/NamespaceMemberQueries.lua- Member managementlapis/queries/NamespaceRoleQueries.lua- Role managementlapis/queries/NamespaceInvitationQueries.lua- Invitationslapis/middleware/namespace.lua- Namespace resolutionlapis/routes/namespaces.lua- Namespace APIlapis/routes/namespace-members.lua- Members APIlapis/routes/namespace-roles.lua- Roles APIlapis/routes/namespace-switch.lua- Switching API
lapis/migrations.lua- Add namespace migrationslapis/routes/auth.lua- Include namespace in JWTlapis/middleware/auth.lua- Parse namespace from token- All existing query files - Add namespace filtering
opsapi-dashboard/contexts/NamespaceContext.tsxopsapi-dashboard/store/namespace.store.tsopsapi-dashboard/services/namespace.service.tsopsapi-dashboard/hooks/useNamespace.tsopsapi-dashboard/components/namespace/(all components)opsapi-dashboard/app/dashboard/namespace/(all pages)opsapi-dashboard/types/namespace.ts
opsapi-dashboard/components/layout/Header.tsx- Add namespace switcheropsapi-dashboard/components/layout/DashboardLayout.tsx- Add NamespaceProvideropsapi-dashboard/lib/api-client.ts- Add namespace headeropsapi-dashboard/store/auth.store.ts- Store active namespace- All service files - Pass namespace context
| Component | Estimated Lines | Complexity |
|---|---|---|
| Database Migrations | ~400 | Medium |
| Backend Queries (4 files) | ~800 | Medium |
| Backend Middleware | ~150 | Medium |
| Backend Routes (5 files) | ~1200 | Medium |
| Modify Existing Queries | ~500 | Low |
| Frontend Context/Store | ~400 | Medium |
| Frontend Components (8) | ~1500 | Medium |
| Frontend Pages (4) | ~1000 | Medium |
| Frontend Service | ~200 | Low |
| Tests | ~800 | Medium |
Total: ~7000 lines of new/modified code
-
Platform Admin: Should there be a "platform admin" role that can manage all namespaces (super admin across the entire system)?
-
Billing Integration: Will namespaces have different plans/billing? Should we integrate with Stripe for subscription management?
-
Custom Domains: Should each namespace be able to configure a custom domain (e.g.,
dashboard.acme.com)? -
Data Export: Should users be able to export their namespace data?
-
Namespace Deletion: What happens when a namespace is deleted? Soft delete with data retention period?
Please review this plan and confirm if you'd like me to proceed with implementation. I can start with:
- Option A: Database migrations first (safest, allows parallel development)
- Option B: Full backend implementation (namespace system + API routes)
- Option C: Full-stack implementation (backend + frontend together)
Which approach would you prefer?