@douglance/stdb-rbac
v1.0.0
Published
Production-ready Role-Based Access Control (RBAC) for SpacetimeDB with React hooks
Downloads
12
Maintainers
Readme
@douglance/stdb-rbac
Production-ready Role-Based Access Control (RBAC) for SpacetimeDB with React hooks.
Eliminate 400+ lines of boilerplate per project with a standardized, type-safe RBAC system that works across your entire SpacetimeDB toolkit.
Features
- 🔐 Admin-only operations - Secure grantRole/revokeRole reducers
- 🚀 Zero-config bootstrap - First user automatically becomes admin
- ⚛️ React hooks -
useIsAdmin(),useHasRole(),useGrantRole() - 🔄 Real-time - Role changes propagate instantly to all clients
- 🛡️ Type-safe - Full TypeScript support
- 🧪 Idempotent - Duplicate role grants handled gracefully
- 📦 Composable - Import tables into your SpacetimeDB module
Installation
bun add @douglance/stdb-rbacQuick Start
1. Server-Side (SpacetimeDB Module)
// stdb-module/src/schema.ts
import { schema } from "spacetimedb/server";
import { rbacTables, registerRbacReducers } from "@douglance/stdb-rbac/module";
import { myGameTables } from "./tables";
// Compose RBAC tables with your game tables
const gameSchema = schema(...rbacTables, ...myGameTables);
// Register RBAC reducers (grantRole, revokeRole, clientConnected)
registerRbacReducers(gameSchema);
export default gameSchema;That's it! Your module now has:
Roletable (role_name, description)UserRoletable (user_identity, role_name)grantRolereducer (admin-only)revokeRolereducer (admin-only)- First-user-is-admin bootstrap
2. Client-Side (React App)
import { useIsAdmin, useGrantRole } from "@douglance/stdb-rbac";
import { useIdentity } from "@douglance/stdb-react-companion";
function AdminPanel() {
const identity = useIdentity();
const isAdmin = useIsAdmin(identity);
const { call: grantRole, loading } = useGrantRole();
if (!isAdmin) {
return <div>Access denied. Admins only.</div>;
}
const handleGrantModerator = async (userId) => {
await grantRole({
user_identity: userId,
role_name: "moderator",
});
};
return (
<div>
<h1>Admin Panel</h1>
<button onClick={() => handleGrantModerator(someUserId)} disabled={loading}>
Grant Moderator Role
</button>
</div>
);
}API Reference
Server-Side Exports
rbacTables
Array of RBAC table definitions to spread into your schema.
import { rbacTables } from "@douglance/stdb-rbac/module";
const gameSchema = schema(...rbacTables, ...myGameTables);registerRbacReducers(schema)
Registers all RBAC reducers on your schema:
clientConnected- Grants admin role to first usergrantRole(user_identity, role_name)- Admin-only role assignmentrevokeRole(user_identity, role_name)- Admin-only role revocation
import { registerRbacReducers } from "@douglance/stdb-rbac/module";
registerRbacReducers(gameSchema);Client-Side Hooks
useUserRoles(userId: Identity | null): string[]
Subscribe to all roles for a specific user.
const roles = useUserRoles(identity);
// roles = ["admin", "moderator"]useHasRole(userId: Identity | null, roleName: string): boolean
Check if user has a specific role.
const hasModeratorRole = useHasRole(identity, "moderator");
if (hasModeratorRole) {
return <ModeratorTools />;
}useIsAdmin(userId: Identity | null): boolean
Convenience hook to check for admin role.
const isAdmin = useIsAdmin(identity);
if (!isAdmin) {
return <div>Access denied</div>;
}useAllRoles(): Role[]
Subscribe to all registered roles in the system.
const roles = useAllRoles();
return (
<select>
{roles.map(r => (
<option key={r.role_name} value={r.role_name}>
{r.role_name} - {r.description}
</option>
))}
</select>
);useGrantRole()
Hook for calling the grantRole reducer.
const { call: grantRole, loading, error } = useGrantRole();
await grantRole({
user_identity: someUserId,
role_name: "moderator"
});useRevokeRole()
Hook for calling the revokeRole reducer.
const { call: revokeRole, loading, error } = useRevokeRole();
await revokeRole({
user_identity: someUserId,
role_name: "moderator"
});useAllUserRoles(): UserRole[]
Get all user-role assignments (admin use only).
const userRoles = useAllUserRoles();
return (
<table>
{userRoles.map(ur => (
<tr key={`${ur.user_identity}-${ur.role_name}`}>
<td>{ur.user_identity.toHexString()}</td>
<td>{ur.role_name}</td>
</tr>
))}
</table>
);Database Schema
Role Table
| Column | Type | Description |
|--------|------|-------------|
| role_name | string (PK) | Unique role name (e.g., "admin", "moderator") |
| description | string | Human-readable description |
UserRole Table
| Column | Type | Description |
|--------|------|-------------|
| id | u64 (PK, auto-inc) | Auto-generated ID |
| user_identity | Identity | User's SpacetimeDB Identity |
| role_name | string | Role name (foreign key to Role.role_name) |
How It Works
Bootstrap Process
First Connection: When the first user connects to your SpacetimeDB module:
clientConnectedhook checks if any admins exist- If none exist, creates "admin" role and grants it to the connecting user
Subsequent Connections: All other users connect without special privileges
Permission Model
- Admin-only operations:
grantRoleandrevokeRolecheck ifctx.senderhas "admin" role - Last admin protection: Cannot revoke admin role if only one admin exists
- Idempotent grants: Calling
grantRolemultiple times for the same user+role is safe
Custom Roles
Applications can create custom roles by calling grantRole with any role name:
await grantRole({ user_identity: userId, role_name: "vip_member" });
await grantRole({ user_identity: userId, role_name: "content_creator" });Roles are created automatically on first grant.
Example: Game Admin System
// Server module
import { rbacTables, registerRbacReducers } from "@douglance/stdb-rbac/module";
const gameSchema = schema(...rbacTables, GameEntity, PlayerStats);
registerRbacReducers(gameSchema);
// Admin-only reducer
gameSchema.reducer("spawnBoss", { bossType: t.string() }, (ctx, args) => {
// Check if caller is admin
let isAdmin = false;
for (const ur of ctx.db.UserRole.iter()) {
if (ur.user_identity.isEqual(ctx.sender) && ur.role_name === "admin") {
isAdmin = true;
break;
}
}
if (!isAdmin) {
throw new Error("Only admins can spawn bosses");
}
// Spawn boss...
});// Client app
function GameAdminPanel() {
const identity = useIdentity();
const isAdmin = useIsAdmin(identity);
const { call: spawnBoss } = useReducer("spawnBoss");
if (!isAdmin) return null;
return (
<div>
<h2>Admin Controls</h2>
<button onClick={() => spawnBoss({ bossType: "dragon" })}>
Spawn Dragon Boss
</button>
</div>
);
}Best Practices
1. Gate UI, Not Just Reducers
Always hide admin UI from non-admins:
const isAdmin = useIsAdmin(identity);
if (!isAdmin) {
return null; // Don't render admin UI at all
}2. Check Roles in Reducers
Even if UI is gated, always verify permissions in reducers:
gameSchema.reducer("deleteUser", { userId: t.identity() }, (ctx, args) => {
if (!hasRole(ctx, ctx.sender, "admin")) {
throw new Error("Permission denied");
}
// ... delete logic
});3. Use Specific Roles
Create roles for specific permissions instead of checking for "admin" everywhere:
// ✅ Good
const canModerate = useHasRole(identity, "moderator");
// ❌ Less flexible
const isAdmin = useIsAdmin(identity);4. Document Custom Roles
If your app uses custom roles, document them:
/**
* Custom roles:
* - admin: Full access
* - moderator: Can mute/kick users
* - vip: Can access premium features
* - content_creator: Can upload content
*/Troubleshooting
"Permission denied" when calling grantRole
Cause: You're not an admin.
Solution: The first user to connect becomes admin automatically. Reconnect with a fresh database, or have an existing admin grant you the role.
Roles not updating in real-time
Cause: Missing useTable subscription.
Solution: The hooks automatically subscribe to table changes. Ensure your component is mounted and SpacetimeDBProvider is properly configured.
Cannot revoke last admin
Cause: Safety check prevents removing the last admin.
Solution: Grant admin role to another user first, then revoke from the original admin.
Migration from DIY RBAC
If you have an existing RBAC implementation:
- Export existing roles: Query your current UserRole table
- Deploy stdb-rbac: Add to your module schema
- Import roles: Use
grantRolereducer to recreate role assignments - Remove old code: Delete your custom RBAC tables and reducers
- Update client: Replace custom hooks with stdb-rbac hooks
License
MIT
Contributing
Issues and PRs welcome at [GitHub repository URL]
