Architecture - nself-org/nchat GitHub Wiki
Version: 1.0.9 Last Updated: 2026-04-18
Ι³Chat (nself-chat) is a FOSS team communication platform built as a reference implementation of what can be achieved with the nSelf CLI backend infrastructure. This document describes the architectural decisions, patterns, and setup instructions for both standalone and monorepo deployments.
- Core Principles
- System Architecture
- Deployment Models
- Per-App RBAC/ACL
- Authentication Architecture
- Database Schema
- Frontend Architecture
- Backend Services
- Security Model
- Monorepo Setup Guide
Ι³Chat uses nSelf CLI exclusively for all backend operations:
- β Database: PostgreSQL via nSelf
- β GraphQL: Hasura via nSelf
- β Authentication: Nhost Auth via nSelf
- β Storage: MinIO/S3 via nSelf
- β Search: MeiliSearch via nSelf
- β Real-time: WebSocket subscriptions via Hasura
We do NOT use: Custom Express servers, Firebase, Supabase Auth, or any non-nSelf backend services.
The application supports two deployment models:
Standalone
user@host:~$ git clone nself-chat
user@host:~$ cd nself-chat
user@host:~$ nself start
user@host:~$ pnpm dev
One app, one backend, independent deployment.
Monorepo ("One of Many")
monorepo/
βββ backend/ # Shared nSelf backend
βββ nchat/ # This app
βββ ntv/ # Another app
βββ nfamily/ # Another app
Multiple apps, one backend, shared authentication, per-app roles.
In monorepo deployments, users authenticate once but can have different roles in different apps:
- Admin in Ι³Chat, regular user in Ι³TV
- Owner in Ι³Family, guest in Ι³Chat
- Moderator in Ι³TV, member in Ι³Family
This is implemented via three PostgreSQL tables:
-
apps- Registry of all applications -
app_user_roles- User roles per app -
app_role_permissions- Permissions per role per app
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Frontend Layer β
β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β
β β Web β β Mobile β β Desktop β β Admin β β
β β Next.js β βCapacitor β β Electron β β Panel β β
β ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ β
β β β β β β
β βββββββββββββββ΄βββββββββββββββ΄ββββββββββββββ β
β β β
ββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββΌβββββββββββ
β Nginx Reverse β
β Proxy β
ββββββββββββ¬βββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββββ
β β β
βββββββββΌβββββββββ ββββββββΌβββββββ ββββββββββΌβββββββββ
β Hasura β β Nhost Auth β β MinIO Storage β
β GraphQL API β β Service β β (S3-compat) β
βββββββββ¬βββββββββ ββββββββ¬βββββββ ββββββββββ¬βββββββββ
β β β
ββββββββββββββββββββΌβββββββββββββββββββ
β
βββββββββΌβββββββββ
β PostgreSQL β
β Database β
ββββββββββββββββββ
Frontend
- Framework: Next.js 15.1.6 (App Router)
- UI: React 19 + Radix UI + Tailwind CSS
- State: Zustand + Apollo Client
- Real-time: GraphQL subscriptions + Socket.io
- Editor: TipTap 2.11.2
- Forms: React Hook Form + Zod
Backend (via nSelf CLI)
- Database: PostgreSQL 15+ with 60+ extensions
- GraphQL: Hasura Engine
- Auth: Nhost Authentication
- Storage: MinIO (S3-compatible)
- Search: MeiliSearch
- Cache: Redis
- Monitoring: Grafana + Prometheus + Loki
Use Case: Independent installation, single team/organization.
Directory Structure:
nself-chat/
βββ backend/ # nSelf CLI project
β βββ migrations/ # DB migrations
β βββ docker-compose.yml
β βββ .env
βββ frontend/ # Next.js app
β βββ src/
β βββ platforms/
β βββ package.json
βββ README.md
Setup:
# 1. Initialize backend
cd backend
nself init
nself start
# 2. Start frontend
cd ../frontend
pnpm install
pnpm devEnvironment:
# frontend/.env.local
NEXT_PUBLIC_APP_ID=nchat
NEXT_PUBLIC_GRAPHQL_URL=http://api.localhost/v1/graphql
NEXT_PUBLIC_AUTH_URL=http://auth.localhost/v1/auth
NEXT_PUBLIC_STORAGE_URL=http://storage.localhost/v1/storageUse Case: Multiple applications sharing authentication and users.
Directory Structure:
monorepo/
βββ backend/ # Shared nSelf backend
β βββ migrations/
β β βββ 001_create_users.sql
β β βββ 002_add_nchat_tables.sql
β β βββ 003_add_ntv_tables.sql
β β βββ 004_add_per_app_rbac.sql
β βββ docker-compose.yml
βββ nchat/ # Ι³Chat app
β βββ frontend/
β βββ package.json
βββ ntv/ # Ι³TV app (example)
β βββ frontend/
β βββ package.json
βββ nfamily/ # Ι³Family app (example)
βββ frontend/
βββ package.json
Setup:
# 1. Initialize shared backend
cd backend
nself init --demo
nself start
# 2. Run migrations for all apps
nself exec postgres psql -U postgres -d nself < migrations/001_create_users.sql
nself exec postgres psql -U postgres -d nself < migrations/002_add_nchat_tables.sql
nself exec postgres psql -U postgres -d nself < migrations/003_add_ntv_tables.sql
nself exec postgres psql -U postgres -d nself < migrations/004_add_per_app_rbac.sql
# 3. Start each app
cd ../nchat/frontend && pnpm dev
cd ../ntv/frontend && pnpm dev --port 3001
cd ../nfamily/frontend && pnpm dev --port 3002App Environment Configuration:
# nchat/frontend/.env.local
NEXT_PUBLIC_APP_ID=nchat
NEXT_PUBLIC_APP_NAME=Ι³Chat
NEXT_PUBLIC_GRAPHQL_URL=http://api.localhost/v1/graphql
NEXT_PUBLIC_AUTH_URL=http://auth.localhost/v1/auth
# ntv/frontend/.env.local
NEXT_PUBLIC_APP_ID=ntv
NEXT_PUBLIC_APP_NAME=Ι³TV
NEXT_PUBLIC_GRAPHQL_URL=http://api.localhost/v1/graphql
NEXT_PUBLIC_AUTH_URL=http://auth.localhost/v1/auth
# nfamily/frontend/.env.local
NEXT_PUBLIC_APP_ID=nfamily
NEXT_PUBLIC_APP_NAME=Ι³Family
NEXT_PUBLIC_GRAPHQL_URL=http://api.localhost/v1/graphql
NEXT_PUBLIC_AUTH_URL=http://auth.localhost/v1/authThe per-app RBAC system allows users to have different roles in different applications while sharing a single user account and authentication session.
apps Table
CREATE TABLE public.apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id TEXT UNIQUE NOT NULL, -- 'nchat', 'ntv', 'nfamily'
app_name TEXT NOT NULL, -- 'Ι³Chat', 'Ι³TV', 'Ι³Family'
app_url TEXT, -- 'https://chat.nself.org'
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);app_user_roles Table
CREATE TABLE public.app_user_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id TEXT NOT NULL REFERENCES apps(app_id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- 'owner', 'admin', 'moderator', 'member', 'guest'
granted_by UUID REFERENCES auth.users(id),
granted_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- Optional expiration
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(app_id, user_id, role)
);app_role_permissions Table
CREATE TABLE public.app_role_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id TEXT NOT NULL REFERENCES apps(app_id) ON DELETE CASCADE,
role TEXT NOT NULL,
permission TEXT NOT NULL, -- 'channels.create', 'messages.delete'
resource TEXT, -- Optional: specific resource ID
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(app_id, role, permission, resource)
);| Role | Description | Example Permissions |
|---|---|---|
| owner | Complete control | All permissions, billing, settings |
| admin | User/channel management | Manage users, create/delete channels, moderation |
| moderator | Content moderation | Delete messages, warn/timeout users, pin content |
| member | Standard user | Send messages, join channels, upload files |
| guest | Limited access | View channels, read messages (no posting) |
Permissions use dot notation:
-
app.admin- Full app administration -
users.manage- User management -
users.ban- Ban users -
channels.create- Create channels -
channels.delete- Delete channels -
messages.send- Send messages -
messages.delete- Delete own messages -
messages.delete.any- Delete any message -
files.upload- Upload files -
settings.manage- Manage app settings -
billing.manage- Manage billing
-- Check if user has role in app
SELECT user_has_app_role(
'user-uuid',
'nchat',
'admin'
);
-- Check if user has permission in app
SELECT user_has_app_permission(
'user-uuid',
'nchat',
'channels.delete',
NULL -- resource ID (optional)
);
-- Get all user roles for app
SELECT * FROM get_user_app_roles(
'user-uuid',
'nchat'
);Hook Usage:
import { useAppPermissions } from '@/hooks/use-app-permissions'
function DeleteChannelButton({ channelId }: { channelId: string }) {
const { hasPermission, isAdmin, loading } = useAppPermissions()
if (loading) return <Skeleton />
if (!hasPermission('channels.delete')) return null
return (
<button onClick={() => deleteChannel(channelId)}>
Delete Channel
</button>
)
}Context Access:
import { useAuth } from '@/contexts/auth-context'
function UserProfile() {
const { user } = useAuth()
return (
<div>
<h2>{user.displayName}</h2>
<p>Roles in {process.env.NEXT_PUBLIC_APP_NAME}:</p>
<ul>
{user.appRoles?.map(role => (
<li key={role}>{role}</li>
))}
</ul>
</div>
)
}# Get user's roles in current app
query GetUserAppRoles($userId: uuid!, $appId: String!) {
app_user_roles(
where: {
user_id: { _eq: $userId }
app_id: { _eq: $appId }
_or: [
{ expires_at: { _is_null: true } }
{ expires_at: { _gt: "now()" } }
]
}
) {
role
granted_at
expires_at
}
}
# Grant a role to a user
mutation GrantUserRole(
$appId: String!
$userId: uuid!
$role: String!
$grantedBy: uuid
) {
insert_app_user_roles_one(
object: {
app_id: $appId
user_id: $userId
role: $role
granted_by: $grantedBy
}
) {
id
role
}
}When multiple apps share a backend:
- User logs in to Ι³Chat β Nhost creates session
- User visits Ι³TV β Session is valid (same auth service)
- User visits Ι³Family β Session is valid (same auth service)
All apps use the same:
-
auth.userstable - Nhost JWT tokens
- Session storage
While authentication is shared, app context is unique:
// User logs in once
user.id = "123"
user.email = "[email protected]"
// Context varies by app
// In Ι³Chat:
user.appRoles = ["admin"]
user.appContext.permissions = ["channels.delete", "users.ban"]
// In Ι³TV (same user):
user.appRoles = ["member"]
user.appContext.permissions = ["videos.view", "videos.upload"]
// In Ι³Family (same user):
user.appRoles = ["owner"]
user.appContext.permissions = ["*"] // All permissionsFor local development, Ι³Chat supports a test authentication mode:
# Enable test auth (8 predefined users)
NEXT_PUBLIC_USE_DEV_AUTH=trueTest users:
-
[email protected](owner role) -
[email protected](admin role) -
[email protected](moderator role) -
[email protected](member role) -
[email protected](guest role)
Password for all: password123
IMPORTANT: Never use NEXT_PUBLIC_USE_DEV_AUTH=true in production!
| Table | Purpose | Rows (typical) |
|---|---|---|
auth.users |
User accounts | 1K-1M+ |
nchat_channels |
Chat channels | 10-10K |
nchat_messages |
Messages | 100K-10M+ |
nchat_roles |
Role definitions | 5-20 |
nchat_role_permissions |
RBAC permissions | 50-200 |
apps |
App registry | 1-50 |
app_user_roles |
Per-app roles | 1K-1M+ |
app_role_permissions |
Per-app permissions | 100-1K |
Standalone:
cd backend
nself exec postgres psql -U postgres -d nself < migrations/init.sqlMonorepo:
# Order matters - run in sequence
cd backend
nself exec postgres psql -U postgres -d nself < migrations/001_create_users.sql
nself exec postgres psql -U postgres -d nself < migrations/002_add_per_app_rbac.sql
nself exec postgres psql -U postgres -d nself < migrations/003_add_nchat_tables.sql
nself exec postgres psql -U postgres -d nself < migrations/004_add_ntv_tables.sqlAll tables use RLS policies:
-- Example: Messages are viewable by channel members
CREATE POLICY "Users can view messages in their channels"
ON nchat_messages FOR SELECT
USING (
channel_id IN (
SELECT channel_id FROM nchat_channel_members
WHERE user_id = auth.uid()
)
);
-- Example: Apps are viewable by active app users
CREATE POLICY "Users can view their app roles"
ON app_user_roles FOR SELECT
USING (user_id = auth.uid());frontend/
βββ src/
β βββ app/ # Next.js App Router
β β βββ api/ # API routes
β β βββ auth/ # Auth pages
β β βββ chat/ # Main chat UI
β β βββ setup/ # Setup wizard
β β βββ settings/ # User settings
β βββ components/
β β βββ chat/ # Chat components
β β βββ ui/ # Radix UI wrappers
β β βββ layout/ # Header, Sidebar
β βββ contexts/
β β βββ auth-context.tsx # Auth state
β β βββ app-config-context.tsx
β βββ hooks/
β β βββ use-channels.ts
β β βββ use-messages.ts
β β βββ use-app-permissions.ts # RBAC hook
β βββ graphql/
β β βββ queries/
β β βββ mutations/
β β βββ app-rbac.ts # RBAC queries
β βββ types/
β β βββ app-rbac.ts # RBAC types
β β βββ index.ts
β βββ lib/
β βββ apollo-client.ts
β βββ utils.ts
βββ platforms/
β βββ mobile/ # Capacitor (iOS/Android)
β βββ desktop/ # Electron/Tauri
β βββ README.md
βββ public/ # Static assets
βββ tests/ # Jest + Playwright
βββ package.json
Global State (Zustand):
- User preferences
- Theme settings
- UI state (modals, sidebars)
Server State (Apollo Client):
- Channels
- Messages
- Users
- App configuration
Real-time (GraphQL Subscriptions):
subscription OnNewMessage($channelId: uuid!) {
nchat_messages(
where: { channel_id: { _eq: $channelId } }
order_by: { created_at: desc }
limit: 1
) {
id
content
user {
id
displayName
avatarUrl
}
created_at
}
}| Service | Port | Purpose |
|---|---|---|
| Hasura | 8080 | GraphQL API |
| Auth | 4000 | Authentication |
| PostgreSQL | 5432 | Database |
| MinIO | 9000/9001 | S3 storage |
| MeiliSearch | 7700 | Full-text search |
| Redis | 6379 | Cache/sessions |
| Grafana | 3000 | Monitoring |
| Prometheus | 9090 | Metrics |
| Loki | 3100 | Logs |
Frontend ββ¬β> Hasura (8080) ββββ> PostgreSQL (5432)
ββ> Auth (4000) βββββββ> PostgreSQL (5432)
ββ> MinIO (9000) ββββββ> S3 buckets
ββ> MeiliSearch (7700) β> Search indices
All services run via Docker Compose generated by nSelf CLI:
cd backend
nself start # Start all services
nself stop # Stop all services
nself status # Show status
nself logs hasura # View logs
nself urls # List all service URLs- User submits credentials β Frontend sends to Nhost Auth
- Nhost validates β Returns JWT access token + refresh token
- Frontend stores tokens β Secure HTTP-only cookies (production)
- Subsequent requests β Include JWT in Authorization header
- Hasura validates JWT β Decodes user ID, enforces RLS
Layer 1: Hasura JWT Validation
- All requests include JWT
- Hasura verifies signature
- Extracts
x-hasura-user-idandx-hasura-role
Layer 2: Row-Level Security (RLS)
- PostgreSQL policies enforce data access
- Uses
auth.uid()from JWT claims - Cannot be bypassed by GraphQL
Layer 3: Application Permissions (RBAC)
- Frontend checks
app_user_rolesandapp_role_permissions - UI hides/disables unauthorized actions
- Backend validates via RLS policies
# Hasura console > API Limits
api_limits:
depth_limit:
global: 10
per_role:
user: 7
anonymous: 5
node_limit:
global: 50
per_role:
user: 30
anonymous: 10
rate_limit:
global:
max_reqs_per_min: 120
unique_params: IP
per_role:
user:
max_reqs_per_min: 60
unique_params: ["x-hasura-user-id"]mkdir -p monorepo/{backend,nchat,ntv,nfamily}
cd monorepocd backend
nself init --demo
# Follow prompts:
# - Enable: PostgreSQL, Hasura, Auth, MinIO, MeiliSearch, Redis
# - Enable: Monitoring (Grafana + Prometheus + Loki)
# - Set admin password
# - Configure domain (e.g., api.localhost)cd ..
git clone https://github.com/nself/nself-chat.git nchat
git clone https://github.com/nself/nself-tv.git ntv
git clone https://github.com/nself/nself-family.git nfamilycd backend
# Core tables (users, auth)
nself exec postgres psql -U postgres -d nself < ../nchat/backend/db/migrations/20260212_add_per_app_rbac.sql
# App-specific tables
nself exec postgres psql -U postgres -d nself < ../nchat/backend/db/migrations/init_nchat.sql
nself exec postgres psql -U postgres -d nself < ../ntv/backend/db/migrations/init_ntv.sql
nself exec postgres psql -U postgres -d nself < ../nfamily/backend/db/migrations/init_nfamily.sql# Ι³Chat
cd ../nchat/frontend
cp .env.example .env.local
# Edit NEXT_PUBLIC_APP_ID=nchat
# Ι³TV
cd ../../ntv/frontend
cp .env.example .env.local
# Edit NEXT_PUBLIC_APP_ID=ntv
# Ι³Family
cd ../../nfamily/frontend
cp .env.example .env.local
# Edit NEXT_PUBLIC_APP_ID=nfamily# Terminal 1: Backend
cd backend
nself start
# Terminal 2: Ι³Chat
cd nchat/frontend
pnpm dev
# Terminal 3: Ι³TV
cd ntv/frontend
pnpm dev --port 3001
# Terminal 4: Ι³Family
cd nfamily/frontend
pnpm dev --port 3002Visit Hasura console (http://localhost:8080) and run:
INSERT INTO public.apps (app_id, app_name, app_url, is_active) VALUES
('nchat', 'Ι³Chat', 'http://localhost:3000', true),
('ntv', 'Ι³TV', 'http://localhost:3001', true),
('nfamily', 'Ι³Family', 'http://localhost:3002', true);-- Make first user an owner in all apps
INSERT INTO public.app_user_roles (app_id, user_id, role) VALUES
('nchat', 'user-uuid-here', 'owner'),
('ntv', 'user-uuid-here', 'owner'),
('nfamily', 'user-uuid-here', 'owner');Solution: Assign default role to new users via trigger:
CREATE OR REPLACE FUNCTION assign_default_app_role()
RETURNS TRIGGER AS $$
BEGIN
-- Assign 'member' role in all active apps
INSERT INTO public.app_user_roles (app_id, user_id, role)
SELECT app_id, NEW.id, 'member'
FROM public.apps
WHERE is_active = true;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER on_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION assign_default_app_role();Solution: Grant Hasura access:
GRANT SELECT ON public.app_user_roles TO hasura;
GRANT SELECT ON public.app_role_permissions TO hasura;
GRANT EXECUTE ON FUNCTION public.user_has_app_role TO hasura;
GRANT EXECUTE ON FUNCTION public.user_has_app_permission TO hasura;Solution: Track tables in Hasura:
cd backend
nself exec hasura hasura-cli metadata reloadOr via console:
- Visit http://localhost:8080
- Data tab β Schema β public
- Track tables:
apps,app_user_roles,app_role_permissions
v0.9.2 (February 12, 2026)
- Added per-app RBAC/ACL system
- Updated README with FOSS mission
- Created ARCHITECTURE.md
- Added monorepo setup documentation
v0.9.0 (February 6, 2026)
- Restructured frontend to nself-family pattern
- Fixed all TypeScript errors
- Achieved 98%+ test pass rate
- Production-ready build
MIT License - See LICENSE file for details.
Questions? Open an issue on GitHub.