Skip to content

Dashboard from schema

A host needs to create events and see who’s coming. In Kumiko that UI is derived from the entity schema. Screen definitions from chapter 6, rendered by the admin shell.

The host dashboard: the event list, rendered from the entity

An entityList or entityEdit screen is a plain object: which entity, which columns, which form layout. No component, no state, and no fetch. The framework supplies all of it:

export const eventEditScreen: EntityEditScreenDefinition = {
id: "event-edit",
type: "entityEdit",
entity: "event",
layout: {
sections: [
{ title: "showpony:section.event-basics", columns: 2,
fields: [{ field: "title", span: 2 }, "slug", "startsAt", "location", "guestLimit"] },
{ title: "showpony:section.event-details", columns: 1, fields: ["description"] },
],
},
};
From src/features/show-pony/register/screens.ts.
The auto-generated event form, with sections and typed fields
Editing an existing event, row click opens the same form, filled

The guest list is the same mechanism on rsvp:

The guest list, the anonymous RSVPs collected per host

Show Pony seeds two logins on the apex, on purpose they are not the same kind of user:

AccountWhoTenant membershipWorkspace
[email protected]Mira (demo host)demo, Admin + TenantAdminEvents only
[email protected]Platform operator_platform, User (login anchor only)Platform only

Early demos gave the operator a membership on demo so login had a tenant context. That leaked into the product UX:

  • Sysadmin appeared on Mira’s Team screen as a tenant admin.
  • The Events workspace showed up (because access.admin includes SystemAdmin).
  • Operators thought they were “also a host” instead of running the platform.

Fix (publicstatus pattern):

  1. Internal tenant _platform, membership anchor for login, not a customer subdomain.
  2. Global role SystemAdmin, tenants, users, audit, jobs.
  3. Host workspace uses tenant roles only (TenantAdmin, Admin), not the global access.admin preset that includes SystemAdmin.
const HOST_WORKSPACE_ROLES = access.roles("TenantAdmin", "Admin");
r.workspace({
id: "host",
access: { roles: HOST_WORKSPACE_ROLES },
// ...
});
r.workspace({
id: "platform",
access: { roles: access.systemAdmin },
// ...
});
From src/features/app-shell/feature.ts.

Sysadmin seed (dev + prod):

await seedAdmin(db, {
globalRoles: ["SystemAdmin"],
memberships: [{
tenantId: PLATFORM_TENANT.id,
tenantKey: "_platform",
roles: ["User"],
}],
});
From bin/server.ts · bin/demo-tenants.ts.

Fresh installs get this from boot seed (bin/server.ts / bin/main.ts + bin/demo-tenants.ts). No extra migration files needed for new clones.

Platform operator overview after logging in as sysadmin

Log in as sysadmin → you land in Platform (tenants, users, audit). Log in as Mira → Events only. Same apex URL, different story.

Guest PII on the list is covered in chapter 14.

Labels come from chapter 2 (src/features/show-pony/i18n/keys.ts). Here you only define which columns and sections appear. The framework resolves the strings.

createKumikoApp with the shell and client features takes three lines:

createKumikoApp({
shell: AppShell,
clientFeatures: [emailPasswordClient(), showPonyClient],
});
From src/client-admin.tsx.

emailPasswordClient() adds the login gate and session on top of the admin bundle.

Workspace split (host vs platform):

// show-pony workspaces — host tenant surface vs platform operator surface.
import {
access,
defineFeature,
type FeatureDefinition,
} from "@cosmicdrift/kumiko-framework/engine";
import { APP_SHELL_TRANSLATION_KEYS } from "./i18n";
const HOST_NAV_MEMBERS = [
"admin-shell:nav:tenant-overview",
"showpony:nav:events",
"showpony:nav:event-new",
"showpony:nav:guests",
"showpony:nav:appearance",
"showpony:nav:invite-branding",
"showpony:nav:account",
"showpony:nav:billing",
"tenant:nav:members",
] as const;
const PLATFORM_NAV_MEMBERS = [
"admin-shell:nav:platform-overview",
"admin-shell:nav:tenants",
"admin-shell:nav:tier-admin",
"config:nav:audience-system",
"app-shell:nav:users",
"audit:nav:audit-log",
"jobs:nav:job-runs",
] as const;
/** Tenant-scoped host tools — excludes global SystemAdmin (platform workspace). */
const HOST_WORKSPACE_ROLES = access.roles("TenantAdmin", "Admin");
export const appShellFeature: FeatureDefinition = defineFeature("app-shell", (r) => {
r.systemScope();
r.nav({
id: "users",
label: "app-shell:nav.users",
screen: "user:screen:user-list",
order: 20,
icon: "users",
access: { roles: access.systemAdmin },
});
r.translations({
keys: APP_SHELL_TRANSLATION_KEYS,
});
r.workspace({
id: "host",
label: "app-shell:workspace.host",
icon: "calendar",
order: 1,
access: { roles: HOST_WORKSPACE_ROLES },
nav: [...HOST_NAV_MEMBERS],
default: true,
});
r.workspace({
id: "platform",
label: "app-shell:workspace.platform",
icon: "shield",
order: 2,
access: { roles: access.systemAdmin },
nav: [...PLATFORM_NAV_MEMBERS],
});
});

Sysadmin + demo tenant constants:

// Demo tenants for show-pony — shared by bin/server.ts and bin/main.ts.
//
// demo: Mira's host tenant (events, guest list, public subdomain).
// acme: second demo tenant for isolation proof (separate subdomain).
// _platform: internal anchor for sysadmin login only — not a customer subdomain.
import { seedAdmin } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
export type DemoTenant = {
readonly id: TenantId;
readonly tenantKey: string;
readonly name: string;
};
export const DEMO_TENANT: DemoTenant = {
id: "00000000-0000-4000-8000-0000000000a1" as TenantId,
tenantKey: "demo",
name: "Demo Host",
};
export const ACME_TENANT: DemoTenant = {
id: "00000000-0000-4000-8000-0000000000a2" as TenantId,
tenantKey: "acme",
name: "Acme Studios",
};
/** Sysadmin membership anchor — same pattern as publicstatus PLATFORM_TENANT. */
export const PLATFORM_TENANT: DemoTenant = {
id: "00000000-0000-4000-8000-000000000099" as TenantId,
tenantKey: "_platform",
name: "Platform (sysadmin only)",
};
/** Shared sysadmin-seed shape for bin/main.ts (prod) + bin/server.ts (dev) — same
* membership/role wiring, only the credential source differs (env vs. dev const). */
export async function seedSysadmin(
db: DbConnection,
credentials: { readonly email: string; readonly password: string },
): Promise<void> {
await seedAdmin(db, {
email: credentials.email,
password: credentials.password,
displayName: "Sysadmin",
globalRoles: ["SystemAdmin"],
memberships: [
{
tenantId: PLATFORM_TENANT.id,
tenantKey: PLATFORM_TENANT.tenantKey,
tenantName: PLATFORM_TENANT.name,
roles: ["User"],
},
],
});
}

i18n keys:

// show-pony i18n keys. Convention: feature prefix `showpony:`
// + `entity:<e>:field:<name>` for field labels (columns + form),
// `screen:<id>.title` for page titles, `showpony:nav.<id>` for
// sidebar labels, `:option:<value>` for select values.
type LocaleEntry = { de: string; en: string };
export const showPonyTranslations = {
"showpony:nav.events": { de: "Events", en: "Events" },
"showpony:nav.event-new": { de: "Neues Event", en: "New event" },
"showpony:nav.guests": { de: "Gästeliste", en: "Guest list" },
"showpony:nav.appearance": { de: "Erscheinungsbild", en: "Appearance" },
"showpony:nav.invite-branding": { de: "Invite-Branding", en: "Invite branding" },
"showpony:nav.account": { de: "Konto", en: "Account" },
"showpony:nav.billing": { de: "Tarif & Abrechnung", en: "Plan & billing" },
"screen:event-list.title": { de: "Events", en: "Events" },
"screen:event-edit.title": { de: "Event bearbeiten", en: "Edit event" },
"screen:rsvp-list.title": { de: "Gästeliste", en: "Guest list" },
"screen:invite-branding-settings.title": {
de: "Invite-Branding",
en: "Invite branding",
},
"screen:billing.title": { de: "Tarif & Abrechnung", en: "Plan & billing" },
"showpony:branding.section.identity": { de: "Marke", en: "Brand" },
"showpony:branding.section.hero": { de: "Hero-Bild", en: "Hero image" },
"showpony:entity:__config-edit__:field:title": { de: "Markenname", en: "Brand name" },
"showpony:entity:__config-edit__:field:description": {
de: "Tagline / Beschreibung",
en: "Tagline / description",
},
"showpony:entity:__config-edit__:field:accentColor": {
de: "Akzentfarbe",
en: "Accent color",
},
"showpony:entity:__config-edit__:field:logoUrl": { de: "Logo-URL", en: "Logo URL" },
"showpony:entity:__config-edit__:field:heroImageUrl": {
de: "Hero-Bild-URL",
en: "Hero image URL",
},
"showpony:entity:__config-edit__:field:heroStyle": { de: "Hero-Layout", en: "Hero layout" },
"showpony:entity:__config-edit__:field:heroStyle:option:immersive": {
de: "Immersiv (Vollbild)",
en: "Immersive (full bleed)",
},
"showpony:entity:__config-edit__:field:heroStyle:option:split": {
de: "Geteilt (Text + Bild)",
en: "Split (text + image)",
},
"showpony:section.event-basics": { de: "Eckdaten", en: "Basics" },
"showpony:section.event-details": { de: "Beschreibung", en: "Description" },
"showpony:entity:event:field:title": { de: "Titel", en: "Title" },
"showpony:entity:event:field:slug": { de: "Link-Kürzel", en: "Link slug" },
"showpony:entity:event:field:startsAt": { de: "Beginn", en: "Starts at" },
"showpony:entity:event:field:location": { de: "Ort", en: "Location" },
"showpony:entity:event:field:description": { de: "Beschreibung", en: "Description" },
"showpony:entity:event:field:guestLimit": { de: "Gäste-Limit", en: "Guest limit" },
"showpony:entity:rsvp:field:name": { de: "Name", en: "Name" },
"showpony:entity:rsvp:field:email": { de: "E-Mail", en: "Email" },
"showpony:entity:rsvp:field:status": { de: "Status", en: "Status" },
"showpony:entity:rsvp:field:plusN": { de: "Begleitung", en: "Plus guests" },
"showpony:entity:rsvp:field:eventId": { de: "Event", en: "Event" },
"showpony:entity:rsvp:field:note": { de: "Notiz", en: "Note" },
"showpony:entity:rsvp:field:status:option:yes": { de: "Zusage", en: "Going" },
"showpony:entity:rsvp:field:status:option:no": { de: "Absage", en: "Not going" },
"showpony:entity:rsvp:field:status:option:maybe": { de: "Vielleicht", en: "Maybe" },
"showpony:public.event.missing": {
de: "Dieses Event gibt es nicht.",
en: "This event doesn't exist.",
},
"showpony:public.event.invited": { de: "Du bist eingeladen", en: "You're invited" },
"showpony:public.event.guest-limit": {
de: "Bis zu {limit} Gäste",
en: "Up to {limit} guests",
},
"showpony:public.event.add-to-calendar": {
de: "📅 Zum Kalender hinzufügen",
en: "📅 Add to calendar",
},
"showpony:public.rsvp.thanks": { de: "Danke, {name}!", en: "Thanks, {name}!" },
"showpony:public.rsvp.heading": { de: "RSVP", en: "RSVP" },
"showpony:public.rsvp.subheading": {
de: "Kein Account nötig — Name reicht.",
en: "No account needed — just your name.",
},
"showpony:public.rsvp.on-list": { de: "Du stehst auf der Liste.", en: "You're on the list." },
"showpony:public.rsvp.plus-guests": { de: "Begleitung?", en: "Bringing anyone?" },
"showpony:public.rsvp.name": { de: "Name", en: "Name" },
"showpony:public.rsvp.name-placeholder": { de: "Dein Name", en: "Your name" },
"showpony:public.rsvp.email": { de: "E-Mail (optional)", en: "Email (optional)" },
"showpony:public.rsvp.email-placeholder": {
de: "E-Mail (optional, für die Bestätigung)",
en: "Email (optional, for your confirmation)",
},
"showpony:public.rsvp.status.yes": { de: "Ich komme", en: "I'm in" },
"showpony:public.rsvp.status.maybe": { de: "Vielleicht", en: "Maybe" },
"showpony:public.rsvp.status.no": { de: "Kann leider nicht", en: "Can't make it" },
"showpony:public.rsvp.submit": { de: "RSVP senden", en: "Send RSVP" },
"showpony:public.rsvp.error": {
de: "Etwas ist schiefgelaufen ({reason}). Bitte nochmal versuchen.",
en: "Something went wrong ({reason}). Try again.",
},
"showpony:demo.login.title": { de: "Live-Demo", en: "Live demo" },
"showpony:demo.login.body": {
de: "Melde dich an, um Dashboard und Plattform-Workspace zu erkunden:",
en: "Sign in to explore the host dashboard and platform workspace:",
},
"showpony:demo.login.read-only": {
de: "Nur ansehen — Änderungen werden auf dieser Instanz nicht gespeichert.",
en: "Browse only — changes are not saved on this instance.",
},
"showpony:demo.banner": {
de: "Live-Demo (read-only). Zum Bauen lokal klonen —",
en: "Live demo (read-only). Clone locally to build your own —",
},
"showpony:demo.banner.link": { de: "Tutorial", en: "tutorial" },
"showpony:demo.public.title": { de: "Live-Demo", en: "Live demo" },
"showpony:demo.public.body": {
de: "RSVPs funktionieren nur in der lokalen Installation. Hier kannst du die Invite-Seite ansehen.",
en: "RSVPs work in the local install only. Here you can browse the invite page.",
},
"showpony:demo.public.host-link": { de: "Host-Login", en: "Host login" },
"showpony:demo.public.tutorial-link": { de: "Tutorial", en: "Tutorial" },
"showpony:demo.read-only.error": {
de: "Live-Demo ist read-only — lokal klonen zum Ausprobieren.",
en: "Live demo is read-only — clone locally to try writes.",
},
"showpony:caps.usageTitle": { de: "Verbrauch", en: "Usage" },
"showpony:caps.events": { de: "Events", en: "Events" },
"showpony:caps.guests": { de: "RSVPs", en: "RSVPs" },
"showpony:caps.unlimited": { de: "(unbegrenzt)", en: "(unlimited)" },
"showpony:caps.upgradeHint": { de: "Tarif upgraden", en: "Upgrade plan" },
"showpony:billing.title": { de: "Tarif & Abrechnung", en: "Plan & billing" },
"showpony:billing.currentTier": { de: "Aktueller Tarif", en: "Current plan" },
"showpony:billing.notConfigured": {
de: "Stripe-Checkout ist auf dieser Instanz nicht live (billingLive aus oder keine API-Keys).",
en: "Stripe checkout is not live on this instance (billingLive off or no API keys).",
},
"showpony:billing.upgradeTo": { de: "Wechseln zu", en: "Switch to" },
"showpony:billing.perMonth": { de: "€ / Monat", en: "€ / month" },
"showpony:billing.unlimited": { de: "Unbegrenzt", en: "Unlimited" },
"showpony:billing.benefit.events": { de: "Events", en: "events" },
"showpony:billing.benefit.guests": { de: "RSVPs gesamt", en: "total RSVPs" },
"showpony:billing.managePortal": { de: "Abrechnung verwalten", en: "Manage billing" },
"showpony:billing.portalHint": {
de: "Rechnungen und Zahlungsmittel verwaltest du im Stripe-Portal.",
en: "Manage invoices and payment methods in the Stripe portal.",
},
"showpony:billing.redirecting": {
de: "Weiterleitung zu Stripe …",
en: "Redirecting to Stripe …",
},
"showpony:billing.error": { de: "Checkout fehlgeschlagen", en: "Checkout failed" },
"showpony:billing.status.active": { de: "aktiv", en: "active" },
"showpony:billing.status.trialing": { de: "Testphase", en: "trial" },
"showpony:billing.status.past_due": { de: "Zahlung überfällig", en: "past due" },
"showpony:billing.status.canceled": { de: "gekündigt", en: "canceled" },
"showpony:billing.status.incomplete": { de: "unvollständig", en: "incomplete" },
"showpony:billing.status.incomplete_expired": {
de: "abgelaufen",
en: "expired",
},
"showpony:billing.status.unpaid": { de: "unbezahlt", en: "unpaid" },
"showpony:billing.status.paused": { de: "pausiert", en: "paused" },
"showpony:billing.switchInPortal": { de: "Im Portal wechseln zu", en: "Switch in portal to" },
"showpony:errors.eventLimitReached": {
de: "Event-Limit erreicht — Tarif upgraden.",
en: "Event limit reached — upgrade your plan.",
},
"showpony:errors.guestLimitReached": {
de: "Die Gästeliste ist voll.",
en: "The guest list is full.",
},
} as const satisfies Record<string, LocaleEntry>;

📄 On GitHub: src/features/app-shell/feature.ts · bin/demo-tenants.ts · src/features/show-pony/i18n/keys.ts