Reference
Remy Reference/Guide/Auth & Roles
Chapter 06

Auth & Roles

Remy apps can have their own users. Auth is opt-in: configure it in the manifest, define a user table, and build your own login UI. The platform handles verification codes (email/SMS), cookie-based sessions, and role enforcement.

Apps without auth config use anonymous guest sessions. Add auth only when the app needs to identify users or restrict access.


#Manifest Config

json
{
  "auth": {
    "enabled": true,
    "methods": ["email-code", "sms-code"],
    "table": {
      "name": "users",
      "columns": {
        "email": "email",
        "phone": "phone",
        "roles": "roles"
      }
    }
  },
  "roles": [
    { "id": "vendor", "name": "Vendor" },
    { "id": "buyer", "name": "Buyer" },
    { "id": "admin", "name": "Admin" }
  ]
}

#Auth Fields

FieldTypeRequiredDescription
auth.enabledbooleanYestrue to enable auth
auth.methodsstring[]Yes"email-code", "sms-code", and/or "remy" (platform-delegated sign-in — see below). At least one.
auth.table.namestringYesName of the defineTable table for user records
auth.table.columns.emailstringIf email-codeColumn name for email (read-only from code)
auth.table.columns.phonestringIf sms-codeColumn name for phone (read-only from code)
auth.table.columns.rolesstringNoColumn name for roles array (bidirectional sync)

#Roles

FieldTypeRequiredDescription
idstringYesKebab-case identifier (used in code: auth.requireRole('admin'))
namestringNoDisplay name
descriptionstringNoWhat this role can do

#Auth Table

The user table is defined with defineTable like any other table. The platform manages the auth-mapped columns; everything else is yours.

typescript
import { db } from '@mindstudio-ai/agent';

export const Users = db.defineTable<{
  // Mapped to auth — platform keeps these in sync
  email: string;
  phone?: string;
  roles: string[];
  // Developer1free2pro3users');

#Platform-Managed Column Behavior

  • email / phone — read-only from code. Writing via push(), update(), or upsert() throws a MindStudioError ("Cannot write to email — this column is managed by auth. Use the auth API to change a user's email/phone."). The platform syncs these on auth events.
  • roles — read/write from both code and the dashboard. Users.update(userId, { roles: ['admin'] }) works and the platform syncs the change automatically. Dashboard role changes sync back to the table.
  • All other columns belong to the developer. Read, write, query as normal.

#Frontend Auth (Interface SDK)

The developer builds their own login/signup UI. The SDK provides methods that handle verification and session management. All auth state changes (verify, logout) update the SDK's internal config immediately — no page refresh needed.

typescript
import { auth } from '@mindstudio-ai/interface';

#User Shape

typescript
interface AppUser {
  id: string;
  email: string | null;
  phone: string | null;
  roles: string[];
  provider?: 'remy' | null;  // 1 = delegated sign-in (platform-managed); null/absent = app-verified
  createdAt: string;
}

#State

typescript
auth.getCurrentUser()         // AppUser | null (null = unauthenticated)
auth.currentUser              // AppUser | null (sync getter, same as getCurrentUser())
auth.isAuthenticated()        // boolean
auth.onAuthStateChanged(cb)   // fires immediately with current user, then on every
                              // auth transition (verify, confirm, logout).
                              // Returns an unsubscribe function.

Use onAuthStateChanged in React instead of reading currentUser once at render time:

typescript
function useAuth() {
  const [user, setUser] = useState<AppUser | null>(null);
  useEffect(() => auth.onAuthStateChanged(setUser), []);
  return user;
}

#Email Code Flow

typescript
const { verificationId } = await auth.sendEmailCode('user@example.com');
// Platform sends a 6-digit code to the email
// User enters the code in your UI
const user = await auth.verifyEmailCode(verificationId, '123456');
// Session is set — auth.getCurrentUser() now returns the AppUser

#SMS Code Flow

typescript
const { verificationId } = await auth.sendSmsCode('+15551234567');
// Phone must be E.164 format
const user = await auth.verifySmsCode(verificationId, '123456');

#Sign in with Remy (delegated)

If the app is owned by an organization that has delegated sign-in enabled, add "remy" to auth.methods and offer a "Continue with {Org}" button. The platform resolves who the user is (like "Sign in with Google") and whether they're allowed; the app starts the flow and reads the result. When the organization requires delegated sign-in, remy is the only permitted human method (email/SMS are blocked at the platform edge for its apps).

typescript
// 0 button — must be triggered by a user gesture (click).
<button onClick={() => auth.signInWithRemy()}>Continue with Acme</button>

// Call once on app load — completes sign-in on return from the handshake, or
// when the app is opened from the Remy dashboard. No-op when there1s safe on every mount.
useEffect(() => { auth.handleRemyRedirect(); }, []);
useEffect(() => auth.onAuthStateChanged(setUser), []);
  • auth.signInWithRemy(options?)Promise<AppUser | null>. Top-level apps redirect to the platform and back. The page navigates away, so the promise never settles: drive UI off onAuthStateChanged, not the return value. Apps embedded in a cross-origin iframe (the dev IDE preview) use a popup and the promise resolves. Options: redirectUri (default current URL), state (CSRF, auto-generated), mode: 'auto' | 'popup' | 'redirect' (default 'auto').
  • auth.handleRemyRedirect()Promise<AppUser | null>. Call once on load; handles both the button return and the dashboard-launch entry, updates the session in-place (fires onAuthStateChanged), and cleans the URL.
  • Delegated users have provider: 'remy'; their roles and email are platform-managed — enforce with requireRole/hasRole, but don't assign roles from app code.

#Email/Phone Changes (must be authenticated)

typescript
await auth.requestEmailChange('newemail@example.com');   // sends code to NEW email
const user = await auth.confirmEmailChange('newemail@example.com', '123456');

await auth.requestPhoneChange('+15559876543');
const user = await auth.confirmPhoneChange('+15559876543', '123456');

#Logout

typescript
await auth.logout();  // clears cookie and session

#Error Codes

All auth methods throw on failure with a code property:

CodeHTTPMeaning
rate_limited429Too many requests
invalid_code400Wrong verification code
verification_expired400Code has expired
max_attempts_exceeded400Too many failed attempts
not_authenticated401No active session
invalid_session401Session expired or invalid
auth_required401Agent/voice interface requires an authenticated user (interface auth block)
role_required403Agent/voice interface requires a role the user doesn't hold

#Phone Helpers (auth.phone)

Helpers for building phone input UIs with country code pickers:

typescript
auth.phone.countries          // ~180 countries: { code, dialCode, name, flag }
auth.phone.detectCountry()    // guess from timezone, e.g. 0
auth.phone.toE164('5551234567', 'US')  // 3
auth.phone.format('+15551234567')      // 5
auth.phone.isValid('+15551234567')     // true (E.164 format check)

#Email Helpers (auth.email)

typescript
auth.email.isValid('user@example.com')  // true (basic format check)

#Backend Auth (Agent SDK)

typescript
import { auth } from '@mindstudio-ai/agent';

#auth.requireRole(...roles)

Throws 401 (unauthenticated) if there is no current user. Throws 403 (forbidden) if the current user doesn't have any of the specified roles. Use at the top of methods to gate access.

typescript
auth.requireRole('admin');                // single role
auth.requireRole('admin', 'approver');    // any of these

#auth.hasRole(...roles)

Returns boolean. Same logic as requireRole but doesn't throw. Use for conditional behavior.

#auth.userId

The current user's ID (the row ID in the auth table), or null for unauthenticated requests.

#auth.roles

Array of role IDs assigned to the current user.

#auth.getUsersByRole(role)

Returns an array of user IDs with the specified role. Useful for "notify all admins."


#Login Page Example

tsx
import { useState, useEffect } from 'react';
import { auth } from '@mindstudio-ai/interface';
import { useLocation } from 'wouter';

function useAuth() {
  const [user, setUser] = useState<AppUser | null>(null);
  useEffect(() => auth.onAuthStateChanged(setUser), []);
  return user;
}

function LoginPage() {
  const user = useAuth();
  const [, navigate] = useLocation();
  const [email, setEmail] = useState('');
  const [code, setCode] = useState('');
  const [verificationId, setVerificationId] = useState('');
  const [error, setError] = useState('');

  // Redirect when authenticated (fires via onAuthStateChanged after verify)
  useEffect(() => { if (user) navigate('/dashboard'); }, [user]);

  const handleSendCode = async () => {
    try {
      const { verificationId } = await auth.sendEmailCode(email);
      setVerificationId(verificationId);
      setError('');
    } catch (err: any) {
      setError(err.code === 'rate_limited' ? 'Too many attempts. Try again later.' : err.message);
    }
  };

  const handleVerify = async () => {
    try {
      await auth.verifyEmailCode(verificationId, code);
      // onAuthStateChanged fires, useAuth updates, redirect happens
    } catch (err: any) {
      if (err.code === 'invalid_code') setError('Wrong code. Try again.');
      else if (err.code === 'verification_expired') setError('Code expired. Request a new one.');
      else if (err.code === 'max_attempts_exceeded') setError('Too many attempts. Request a new code.');
      else setError(err.message);
    }
  };

  if (!verificationId) {
    return (
      <div>
        <h1>Sign in</h1>
        <input placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
        <button onClick={handleSendCode}>Send code</button>
        {error && <p>{error}</p>}
      </div>
    );
  }

  return (
    <div>
      <p>Enter the code we sent to {email}</p>
      <input placeholder="123456" value={code} onChange={e => setCode(e.target.value)} />
      <button onClick={handleVerify}>Verify</button>
      <button onClick={() => setVerificationId('')}>Resend</button>
      {error && <p>{error}</p>}
    </div>
  );
}

#Backend Method Example

typescript
import { auth } from '@mindstudio-ai/agent';
import { Users } from './tables/users';

export async function getDashboard() {
  const user = auth.userId ? await Users.get(auth.userId) : null;

  if (auth.hasRole('admin')) {
    const allUsers = await Users.toArray();
    return { user, allUsers, isAdmin: true };
  }

  return { user, isAdmin: false };
}

export async function promoteToAdmin(input: { userId: string }) {
  auth.requireRole('admin');
  await Users.update(input.userId, { roles: ['admin'] });
  // SDK detects roles column write and syncs to platform automatically
}

#Roles

Roles are platform-managed and stored on the developer's user table.

  • Declared in manifestroles array with id and name
  • Stored as array — the mapped roles column holds ["vendor", "admin"]
  • Writable from codeUsers.update(userId, { roles: ['admin'] }) syncs automatically
  • Writable from dashboard — Remy dashboard shows app users and their roles
  • Backend enforcementauth.requireRole('admin') reads from the platform's role cache

#Interface-Level Auth (Agent + Voice)

Agent and voice interfaces declare auth in their config as well: a required auth key, { "requireUser": boolean, "requireRole"?: string[] }. One of those sessions can spend money without ever calling a backend method, so the platform checks access before any model or media spend.

  • requireRole uses the same manifest role ids with OR semantics, and requires requireUser: true.
  • Denials reach the frontend SDK as MindStudioInterfaceError codes auth_required (401) and role_required (403).
  • Dev preview is exempt. Older compiled apps without the block fall back to the manifest's auth.enabled.
  • Method-level auth.requireRole(...) checks still apply to every tool call inside the session.

See Interfaces for the full contract.


#Test User Roles (Dev Mode)

During development, test role-based behavior by assigning roles to the dev test user (remy@mindstudio.ai, the account the preview's sign-in helper auto-fills). This is a real write to the user's row, so auth.userId, requireRole, and role lookups behave exactly as in production:

text
POST /_internal/v2/apps/{appId}/dev/create-auth-session
Body: { "email": "remy@mindstudio.ai", "roles": ["ap"] }

Sign in as the test account to see the app from that role's perspective. Roles persist on the row until changed; pass "roles": [] to remove them all.

Scenarios assign roles automatically: each scenario declares which roles the test user gets after seeding. See Scenarios.


#Apps Without Auth

Apps without auth in the manifest use anonymous guest sessions. No login, no user identity, no roles. This is the default and works fine for single-user apps, internal tools, and simple utilities. (Agent/voice interfaces on such apps declare "auth": { "requireUser": false } explicitly — anonymous callers are scoped by a per-browser visitor identity.)


#End-to-End Example

  1. Add auth config to mindstudio.json:

    json
    {
      "auth": {
        "enabled": true,
        "methods": ["email-code"],
        "table": { "name": "users", "columns": { "email": "email", "roles": "roles" } }
      },
      "roles": [{ "id": "admin", "name": "Admin" }]
    }
  2. Define the user table (src/tables/users.ts):

    typescript
    export const Users = db.defineTable<{
      email: string;
      roles: string[];
      displayName: string;
    }>('users');
  3. Build a login page using auth.sendEmailCode() and auth.verifyEmailCode()

  4. Enforce roles in methods:

    typescript
    auth.requireRole('admin');
  5. Conditional render in frontend:

    typescript
    const { isAdmin } = await api.getUserContext();
    {isAdmin && <DeleteButton />}
  6. Test in dev by signing in as the test user, with roles set via scenarios or the Roles column