Admin and moderator roles
You need a module and a client connected to it. If you don't have them yet, follow the quickstart for your language first.
In this guide, you'll add moderators and admins to a chat: moderators can delete any message, and admins can also clear the whole chat. You'll store each user's role in a private table, check it at the start of each reducer that needs it, and use a view to tell each client its own role.
How it works
- A private
user_roletable holds the role of each user who has one:ModeratororAdmin. Users without a row are regular users. - Each reducer that needs a role checks the caller's role first, and returns an error if it's missing. Admins can do everything moderators can.
- The
set_roleandremove_rolereducers give and take away roles. - A
my_roleview returns the caller's role, so the client can show moderation buttons only to the users who can use them.
The checks in the reducers are what keep regular users out. Hiding a button on the client only changes what the user sees: anyone can call your reducers directly, from a modified client or the CLI.
Define the tables
Add a Role type with two values, Moderator and Admin, and two private tables:
user_rolehas one row per user with a role. Its primary key is the user's identity.messageholds the chat messages, so moderators have something to moderate.
- TypeScript
- C#
- Rust
- C++
import { schema, table, t, SenderError, type ReducerCtx } from 'spacetimedb/server';
const role = t.enum('Role', ['Moderator', 'Admin']);
// The role of each user who has one. Users without a row are regular users.
const userRole = table(
{ name: 'user_role' },
{
identity: t.identity().primaryKey(),
role,
}
);
const message = table(
{ name: 'message' },
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
text: t.string(),
sentAt: t.timestamp(),
}
);
const spacetimedb = schema({ userRole, message });
export default spacetimedb;using SpacetimeDB;
public static partial class Module
{
[SpacetimeDB.Type]
public enum Role
{
Moderator,
Admin,
}
// The role of each user who has one. Users without a row are regular users.
[SpacetimeDB.Table(Accessor = "UserRole")]
public partial struct UserRole
{
[SpacetimeDB.PrimaryKey]
public Identity Identity;
public Role Role;
}
[SpacetimeDB.Table(Accessor = "Message")]
public partial struct Message
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public Identity Sender;
public string Text;
public Timestamp SentAt;
}
}use spacetimedb::{
reducer, table, view, AnonymousViewContext, Identity, Query, ReducerContext, SpacetimeType, Table, Timestamp,
ViewContext,
};
#[derive(SpacetimeType, Clone, Copy, PartialEq)]
pub enum Role {
Moderator,
Admin,
}
// The role of each user who has one. Users without a row are regular users.
#[table(accessor = user_role)]
pub struct UserRole {
#[primary_key]
identity: Identity,
role: Role,
}
#[table(accessor = message)]
pub struct Message {
#[primary_key]
#[auto_inc]
id: u64,
sender: Identity,
text: String,
sent_at: Timestamp,
}#include <spacetimedb.h>
using namespace SpacetimeDB;
SPACETIMEDB_ENUM(Role, Moderator, Admin)
// The role of each user who has one. Users without a row are regular users.
struct UserRole {
Identity identity;
Role role;
};
SPACETIMEDB_STRUCT(UserRole, identity, role)
SPACETIMEDB_TABLE(UserRole, user_role, Private)
FIELD_PrimaryKey(user_role, identity)
struct Message {
uint64_t id;
Identity sender;
std::string text;
Timestamp sent_at;
};
SPACETIMEDB_STRUCT(Message, id, sender, text, sent_at)
SPACETIMEDB_TABLE(Message, message, Private)
FIELD_PrimaryKeyAutoInc(message, id)Check the caller's role
Add two helpers that reducers call before doing anything else:
require_moderatorreturns an error unless the caller is a moderator or an admin.require_adminreturns an error unless the caller is an admin.
Then add the chat's reducers:
- Anyone can call
send_message. delete_messagestarts withrequire_moderator, so only moderators and admins can delete a message.clear_messagesstarts withrequire_admin, so only admins can delete every message at once.
- TypeScript
- C#
- Rust
- C++
function requireModerator(ctx: ReducerCtx<typeof spacetimedb.schemaType>) {
const row = ctx.db.userRole.identity.find(ctx.sender);
if (!row) {
throw new SenderError('Only moderators can do this');
}
}
function requireAdmin(ctx: ReducerCtx<typeof spacetimedb.schemaType>) {
const row = ctx.db.userRole.identity.find(ctx.sender);
if (row?.role.tag !== 'Admin') {
throw new SenderError('Only admins can do this');
}
}
export const sendMessage = spacetimedb.reducer({ text: t.string() }, (ctx, { text }) => {
if (text === '') {
throw new SenderError("A message can't be empty");
}
ctx.db.message.insert({ id: 0n, sender: ctx.sender, text, sentAt: ctx.timestamp });
});
export const deleteMessage = spacetimedb.reducer({ id: t.u64() }, (ctx, { id }) => {
requireModerator(ctx);
if (!ctx.db.message.id.delete(id)) {
throw new SenderError('No such message');
}
});
export const clearMessages = spacetimedb.reducer(ctx => {
requireAdmin(ctx);
ctx.db.message.clear();
});public static partial class Module
{
static void RequireModerator(ReducerContext ctx)
{
if (ctx.Db.UserRole.Identity.Find(ctx.Sender) is null)
{
throw new Exception("Only moderators can do this");
}
}
static void RequireAdmin(ReducerContext ctx)
{
if (ctx.Db.UserRole.Identity.Find(ctx.Sender) is not { Role: Role.Admin })
{
throw new Exception("Only admins can do this");
}
}
[SpacetimeDB.Reducer]
public static void SendMessage(ReducerContext ctx, string text)
{
if (text == "")
{
throw new Exception("A message can't be empty");
}
ctx.Db.Message.Insert(new Message { Id = 0, Sender = ctx.Sender, Text = text, SentAt = ctx.Timestamp });
}
[SpacetimeDB.Reducer]
public static void DeleteMessage(ReducerContext ctx, ulong id)
{
RequireModerator(ctx);
if (!ctx.Db.Message.Id.Delete(id))
{
throw new Exception("No such message");
}
}
[SpacetimeDB.Reducer]
public static void ClearMessages(ReducerContext ctx)
{
RequireAdmin(ctx);
ctx.Db.Message.Clear();
}
}fn require_moderator(ctx: &ReducerContext) -> Result<(), String> {
match ctx.db.user_role().identity().find(ctx.sender()) {
Some(_) => Ok(()),
None => Err("only moderators can do this".to_string()),
}
}
fn require_admin(ctx: &ReducerContext) -> Result<(), String> {
match ctx.db.user_role().identity().find(ctx.sender()) {
Some(row) if row.role == Role::Admin => Ok(()),
_ => Err("only admins can do this".to_string()),
}
}
#[reducer]
pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> {
if text.is_empty() {
return Err("a message can't be empty".to_string());
}
ctx.db.message().insert(Message {
id: 0,
sender: ctx.sender(),
text,
sent_at: ctx.timestamp,
});
Ok(())
}
#[reducer]
pub fn delete_message(ctx: &ReducerContext, id: u64) -> Result<(), String> {
require_moderator(ctx)?;
if !ctx.db.message().id().delete(id) {
return Err("no such message".to_string());
}
Ok(())
}
#[reducer]
pub fn clear_messages(ctx: &ReducerContext) -> Result<(), String> {
require_admin(ctx)?;
ctx.db.message().clear();
Ok(())
}Outcome<void> require_moderator(ReducerContext& ctx) {
if (!ctx.db[user_role_identity].find(ctx.sender())) {
return Err("only moderators can do this");
}
return Ok();
}
Outcome<void> require_admin(ReducerContext& ctx) {
auto row = ctx.db[user_role_identity].find(ctx.sender());
if (!row || row->role != Role::Admin) {
return Err("only admins can do this");
}
return Ok();
}
SPACETIMEDB_REDUCER(send_message, ReducerContext ctx, std::string text) {
if (text.empty()) {
return Err("a message can't be empty");
}
ctx.db[message].insert(Message{0, ctx.sender(), text, ctx.timestamp});
return Ok();
}
SPACETIMEDB_REDUCER(delete_message, ReducerContext ctx, uint64_t id) {
auto allowed = require_moderator(ctx);
if (allowed.is_err()) {
return allowed;
}
if (!ctx.db[message_id].delete_by_key(id)) {
return Err("no such message");
}
return Ok();
}
SPACETIMEDB_REDUCER(clear_messages, ReducerContext ctx) {
auto allowed = require_admin(ctx);
if (allowed.is_err()) {
return allowed;
}
ctx.db[message].clear();
return Ok();
}Give and take away roles
Add two reducers:
set_rolegives a user a role, or changes the role they have.remove_roletakes a user's role away, which makes them a regular user again.
To keep this guide short, anyone can call set_role and remove_role, so anyone can make themselves an admin. Before you ship, decide who can give roles, and check it at the start of both reducers.
- TypeScript
- C#
- Rust
- C++
export const setRole = spacetimedb.reducer({ identity: t.identity(), role }, (ctx, { identity, role }) => {
if (ctx.db.userRole.identity.find(identity)) {
ctx.db.userRole.identity.update({ identity, role });
} else {
ctx.db.userRole.insert({ identity, role });
}
});
export const removeRole = spacetimedb.reducer({ identity: t.identity() }, (ctx, { identity }) => {
ctx.db.userRole.identity.delete(identity);
});public static partial class Module
{
[SpacetimeDB.Reducer]
public static void SetRole(ReducerContext ctx, Identity identity, Role role)
{
var row = new UserRole { Identity = identity, Role = role };
if (ctx.Db.UserRole.Identity.Find(identity) is null)
{
ctx.Db.UserRole.Insert(row);
}
else
{
ctx.Db.UserRole.Identity.Update(row);
}
}
[SpacetimeDB.Reducer]
public static void RemoveRole(ReducerContext ctx, Identity identity)
{
ctx.Db.UserRole.Identity.Delete(identity);
}
}#[reducer]
pub fn set_role(ctx: &ReducerContext, identity: Identity, role: Role) {
let row = UserRole { identity, role };
if ctx.db.user_role().identity().find(identity).is_some() {
ctx.db.user_role().identity().update(row);
} else {
ctx.db.user_role().insert(row);
}
}
#[reducer]
pub fn remove_role(ctx: &ReducerContext, identity: Identity) {
ctx.db.user_role().identity().delete(identity);
}SPACETIMEDB_REDUCER(set_role, ReducerContext ctx, Identity identity, Role role) {
UserRole row{identity, role};
if (ctx.db[user_role_identity].find(identity)) {
ctx.db[user_role_identity].update(row);
} else {
ctx.db[user_role].insert(row);
}
return Ok();
}
SPACETIMEDB_REDUCER(remove_role, ReducerContext ctx, Identity identity) {
ctx.db[user_role_identity].delete_by_key(identity);
return Ok();
}Define the views
Add two public views:
my_rolereturns the caller's row inuser_role, so each client knows its own role. It depends on who's asking, so it isn't anonymous: each user gets their own result.messagesreturns every message. It's anonymous, so SpacetimeDB computes it once and shares it with every client.
Both return a query, which SpacetimeDB keeps up to date the same way as a subscription query: when a user's role changes, their my_role changes right away.
- TypeScript
- C#
- Rust
- C++
// The caller's role, if they have one.
export const myRole = spacetimedb.view({ name: 'my_role', public: true }, t.array(userRole.rowType), ctx =>
ctx.from.userRole.where(r => r.identity.eq(ctx.sender))
);
export const messages = spacetimedb.anonymousView({ name: 'messages', public: true }, t.array(message.rowType), ctx =>
ctx.from.message
);public static partial class Module
{
// The caller's role, if they have one.
[SpacetimeDB.View(Accessor = "MyRole", Public = true)]
public static IQuery<UserRole> MyRole(ViewContext ctx)
{
return ctx.From.UserRole().Where(r => r.Identity.Eq(ctx.Sender));
}
[SpacetimeDB.View(Accessor = "Messages", Public = true)]
public static IQuery<Message> Messages(AnonymousViewContext ctx)
{
return ctx.From.Message();
}
}// The caller's role, if they have one.
#[view(accessor = my_role, public)]
fn my_role(ctx: &ViewContext) -> impl Query<UserRole> {
let me = ctx.sender();
ctx.from.user_role().r#where(move |r| r.identity.eq(me))
}
#[view(accessor = messages, public)]
fn messages(ctx: &AnonymousViewContext) -> impl Query<Message> {
ctx.from.message()
}// The caller's role, if they have one.
SPACETIMEDB_VIEW(Query<UserRole>, my_role, Public, ViewContext ctx) {
Identity me = ctx.sender();
return ctx.from[user_role].where([me](const auto& r) {
return r.identity.eq(me);
});
}
SPACETIMEDB_VIEW(Query<Message>, messages, Public, AnonymousViewContext ctx) {
return ctx.from[message];
}Show moderation tools on the client
On the client, subscribe to my_role and messages. Show a delete button next to each message only to moderators and admins, and a button to clear the chat only to admins. When the user's role changes, my_role changes too, and the buttons appear or disappear without reconnecting.
- TypeScript
- C#
- Rust
import { DbConnection, tables } from './module_bindings';
const conn = DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('my-app')
.onConnect((ctx, identity) => {
console.log(`Connected as ${identity.toHexString()}`);
ctx.subscriptionBuilder().subscribe([tables.myRole, tables.messages]);
})
.build();
// The user's role: 'Moderator', 'Admin', or undefined for a regular user.
function myRole() {
for (const row of conn.db.myRole.iter()) {
return row.role.tag;
}
return undefined;
}
// Call this each frame to draw the chat, oldest message first.
function drawChat() {
const role = myRole();
if (role === 'Admin') {
console.log('[clear chat]');
}
const messages = Array.from(conn.db.messages.iter()).sort((a, b) => (a.id < b.id ? -1 : 1));
for (const message of messages) {
console.log(role ? `${message.text} [delete #${message.id}]` : message.text);
}
}
// Call this when a moderator clicks a message's delete button.
function deleteMessage(id: bigint) {
conn.reducers.deleteMessage({ id }).catch(console.error);
}
// Call this when an admin clicks the clear chat button.
function clearChat() {
conn.reducers.clearMessages({}).catch(console.error);
}using System;
using System.Collections.Generic;
using System.Linq;
using SpacetimeDB;
using SpacetimeDB.Types;
var conn = DbConnection.Builder()
.WithUri("http://localhost:3000")
.WithDatabaseName("my-app")
.OnConnect((conn, identity, token) =>
{
Console.WriteLine($"Connected as {identity}");
conn.SubscriptionBuilder()
.AddQuery(q => q.From.MyRole())
.AddQuery(q => q.From.Messages())
.Subscribe();
})
.Build();
// The user's role, or null for a regular user.
Role? MyRole()
{
foreach (var row in conn.Db.MyRole.Iter())
{
return row.Role;
}
return null;
}
// Call this each frame to draw the chat, oldest message first.
void DrawChat()
{
var role = MyRole();
if (role == Role.Admin)
{
Console.WriteLine("[clear chat]");
}
foreach (var message in conn.Db.Messages.Iter().OrderBy(m => m.Id))
{
Console.WriteLine(role != null ? $"{message.Text} [delete #{message.Id}]" : message.Text);
}
}
// Call this when a moderator clicks a message's delete button.
void DeleteMessage(ulong id) => conn.Reducers.DeleteMessage(id);
// Call this when an admin clicks the clear chat button.
void ClearChat() => conn.Reducers.ClearMessages();Remember to call conn.FrameTick() regularly, for example once per frame, so the callbacks run.
mod module_bindings;
use module_bindings::*;
use spacetimedb_sdk::{DbContext, Table};
// The user's role, or `None` for a regular user.
fn my_role(conn: &DbConnection) -> Option<Role> {
conn.db().my_role().iter().next().map(|row| row.role)
}
// Call this each frame to draw the chat, oldest message first.
fn draw_chat(conn: &DbConnection) {
let role = my_role(conn);
if role == Some(Role::Admin) {
println!("[clear chat]");
}
let mut messages: Vec<_> = conn.db().messages().iter().collect();
messages.sort_by_key(|message| message.id);
for message in messages {
if role.is_some() {
println!("{} [delete #{}]", message.text, message.id);
} else {
println!("{}", message.text);
}
}
}
// Call this when a moderator clicks a message's delete button.
fn delete_message(conn: &DbConnection, id: u64) {
conn.reducers().delete_message(id).unwrap();
}
// Call this when an admin clicks the clear chat button.
fn clear_chat(conn: &DbConnection) {
conn.reducers().clear_messages().unwrap();
}
fn main() {
let conn = DbConnection::builder()
.with_uri("http://localhost:3000")
.with_database_name("my-app")
.on_connect(|ctx, identity, _token| {
println!("Connected as {identity}");
ctx.subscription_builder()
.add_query(|q| q.from.my_role())
.add_query(|q| q.from.messages())
.subscribe();
})
.build()
.expect("failed to connect");
// Process messages from the database on a background thread.
conn.run_threaded();
// Your app's main loop runs here.
}Give yourself a role
To try the moderation tools, give your app's identity a role from the CLI:
- Run your app, and copy the identity it prints when it connects.
- Call
set_rolewith that identity, prefixed with0x. Role values are written in lowercase,moderatororadmin:
spacetime call my-app set_role 0x<your app identity> '{"admin": []}'
Your app's my_role changes as soon as the call succeeds, and the moderation tools appear.
What's next?
You now have moderators and admins in your app:
- Roles live in a private table, and each reducer that needs one checks it before doing anything else.
- A
my_roleview tells each client its own role, so the client only shows the tools the user can use.
To adapt it to your app:
- Protect
set_roleandremove_role. Decide who can give roles, for example only admins, and check it at the start of both reducers, the same wayclear_messagesdoes. - Ban users. Add a private
banned_usertable that moderators fill, and check it at the start ofsend_message. - Keep a log of moderation actions. Insert a row into a private
moderation_logtable in each moderation reducer, with who did what and when. - Show who the moderators are. Add an anonymous view that returns every row of
user_role, so clients can show a badge next to moderators' messages.
To learn more about the features used here, see Access Permissions, Reducers and Views.
Next, you can send an email from your module.