Skip to main content
Version: 2.0.0

Admin and moderator roles

Prerequisites

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_role table holds the role of each user who has one: Moderator or Admin. 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_role and remove_role reducers give and take away roles.
  • A my_role view 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_role has one row per user with a role. Its primary key is the user's identity.
  • message holds the chat messages, so moderators have something to moderate.
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;

Check the caller's role​

Add two helpers that reducers call before doing anything else:

  • require_moderator returns an error unless the caller is a moderator or an admin.
  • require_admin returns an error unless the caller is an admin.

Then add the chat's reducers:

  • Anyone can call send_message.
  • delete_message starts with require_moderator, so only moderators and admins can delete a message.
  • clear_messages starts with require_admin, so only admins can delete every message at once.
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();
});

Give and take away roles​

Add two reducers:

  • set_role gives a user a role, or changes the role they have.
  • remove_role takes a user's role away, which makes them a regular user again.
warning

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.

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);
});

Define the views​

Add two public views:

  • my_role returns the caller's row in user_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.
  • messages returns 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.

// 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
);

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.

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);
}

Give yourself a role​

To try the moderation tools, give your app's identity a role from the CLI:

  1. Run your app, and copy the identity it prints when it connects.
  2. Call set_role with that identity, prefixed with 0x. Role values are written in lowercase, moderator or admin:
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_role view 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_role and remove_role. Decide who can give roles, for example only admins, and check it at the start of both reducers, the same way clear_messages does.
  • Ban users. Add a private banned_user table that moderators fill, and check it at the start of send_message.
  • Keep a log of moderation actions. Insert a row into a private moderation_log table 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.