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, 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/feature.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 — 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";
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:workspace.host": { de: "Events", en: "Events" },
"app-shell:workspace.platform": { de: "Plattform", en: "Platform" },
"app-shell:nav.users": { de: "Nutzer", en: "Users" },
// tenant feature label is tenant:nav.members; tenantClient bundles tenant.nav.members.
"tenant:nav.members": { de: "Team", en: "Team" },
},
});
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 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)",
};

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