Dillo example

Client Onboarding

Invite-only. No passwords.

What this demonstrates

How 29 Court onboards clients. Operators create organizations and send invites. Clients sign in with a magic link — no passwords, no friction. Every access event is logged. This is the invite-only pattern: tight control, smooth experience.

How it works

The permissions are the app. This is the whole data model — schema.ts, the first of the app's three files (its import line, not shown, brings these names in from "create-dillo/schema"):

schema.ts
// The schema the live backend serves today (span2/live-schemas filing, re-verified at deploy).
export default schema({
  AccessEvent: table({
    action: string().min(1).max(80),
    actorUserId: relation.user().optional().nullable(),
    detail: string().max(4000).optional().nullable(),
    orgId: relation.team(),
    requestId: string().max(200).optional().nullable(),
    subject: string().min(1).max(200),
  }).permissions({ view: members, edit: nobody }),

  ClientProfile: table({
    company: string().max(160).optional().nullable(),
    goalsFirstWeek: string().max(2000).optional().nullable(),
    goalsNeverWithoutMe: string().max(2000).optional().nullable(),
    goalsOffPlate: string().max(2000).optional().nullable(),
    orgId: relation.team(),
    preferredName: string().min(1).max(120),
    role: string().max(160).optional().nullable(),
    timezone: string().max(64).optional().nullable(),
    userId: relation.user(),
    writingStyle: string().max(64).optional().nullable(),
  }).permissions({ view: members, edit: recordOwner.or(roles('admin', 'owner')) }),

  GuardrailPref: table({
    actionClass: string().min(1).max(64),
    level: integer().min(0).max(3),
    orgId: relation.team(),
    userId: relation.user(),
    version: integer().min(1).max(10000),
  }).permissions({ view: members, edit: recordOwner.or(roles('admin', 'owner')) }),

  Invite: table({
    acceptedAt: string().datetime().optional().nullable(),
    company: string().max(160).optional().nullable(),
    dataKind: string().max(32).optional().nullable(),
    email: string().email(),
    expiresAt: string().datetime(),
    invitedName: string().max(120).optional().nullable(),
    orgId: relation.team(),
    revokedAt: string().datetime().optional().nullable(),
    state: string().min(1).max(32),
    token: string().max(200).optional().nullable(),
    tokenUsed: boolean().optional().nullable(),
  }).permissions({ view: members, edit: roles('admin', 'owner') }),

  Organization: table({
    dataKind: string().max(32).optional().nullable(),
    name: string().min(1).max(160),
    orgId: relation.team(),
    status: string().min(1).max(32),
  }).permissions({ view: members, edit: roles('admin', 'owner') }),

  SetupSession: table({
    assistantChoice: string().max(64).optional().nullable(),
    orgId: relation.team(),
    promptAccepted: string().max(16).optional().nullable(),
    status: string().min(1).max(32),
    step: string().min(1).max(48),
    userId: relation.user(),
  }).permissions({ view: members, edit: recordOwner.or(roles('admin', 'owner')) }),
});

Try it

  1. Open the live demo
  2. Create an organization as an operator
  3. Send yourself an invite
  4. Sign in with the magic link (no password)