diff --git a/.changeset/api-playground-mode.md b/.changeset/api-playground-mode.md
new file mode 100644
index 00000000..7459c8a3
--- /dev/null
+++ b/.changeset/api-playground-mode.md
@@ -0,0 +1,6 @@
+---
+"@cloudhal/api": minor
+"@cloudhal/cli": minor
+---
+
+Playground mode (RFC-0036): `createApi({ playground: { info } })` answers every request as the fixed `PLAYGROUND_PRINCIPAL` (an Editor with site settings and nothing installation-wide or upload-related), refuses blocked routes with `403 {error: "playground"}` through the pure `isBlockedInPlayground(method, path)` guard (also exported for the playground Worker), limits request bodies, and reports `features.playground` in `/api/instance`. Every declared route is either blocked or allowed, and a route on neither list is refused. Studio shows a session bar (time left, Reset, Deploy to Cloudflare) and leaves out team, agent and account screens on a playground.
diff --git a/apps/studio/src/components/AppFrame.tsx b/apps/studio/src/components/AppFrame.tsx
index 9e3d0f0d..fe1f61f2 100644
--- a/apps/studio/src/components/AppFrame.tsx
+++ b/apps/studio/src/components/AppFrame.tsx
@@ -15,6 +15,7 @@ import { setThemePreference, THEME_PREFERENCES, useThemePreference } from "../li
import type { PaletteSearch } from "./CommandMenu.tsx";
import { LanguageSwitch } from "./LanguageSwitch.tsx";
import { NotificationsBell } from "./NotificationsBell.tsx";
+import { PlaygroundSessionBar } from "./PlaygroundSessionBar.tsx";
import { ThemeSwitch } from "./ThemeSwitch.tsx";
import { UserMenu } from "./UserMenu.tsx";
@@ -174,6 +175,8 @@ function AppFrameShell({
+ {/* RFC-0036 §5: only on a playground session. */}
+
{children}
diff --git a/apps/studio/src/components/PlaygroundSessionBar.tsx b/apps/studio/src/components/PlaygroundSessionBar.tsx
new file mode 100644
index 00000000..bbdc4915
--- /dev/null
+++ b/apps/studio/src/components/PlaygroundSessionBar.tsx
@@ -0,0 +1,50 @@
+import { PlaygroundBar } from "@cloudhal/ui";
+import { useEffect, useState } from "react";
+import { useT } from "../i18n/index.tsx";
+import { minutesLeft, usePlayground } from "./usePlayground.ts";
+
+/**
+ * The playground session bar (RFC-0036 §5): time left, Reset and "Deploy to Cloudflare", under the
+ * top bar on every screen. When the time is up, or after Reset, the browser goes back to the
+ * playground's landing page, which starts a new session.
+ */
+export function PlaygroundSessionBar() {
+ const t = useT();
+ const playground = usePlayground();
+ const [now, setNow] = useState(() => Date.now());
+ const [resetting, setResetting] = useState(false);
+
+ useEffect(() => {
+ if (!playground) return;
+ const timer = setInterval(() => setNow(Date.now()), 15_000);
+ return () => clearInterval(timer);
+ }, [playground]);
+
+ const left = playground ? minutesLeft(playground.expiresAt, now) : 1;
+ useEffect(() => {
+ if (playground && left === 0) globalThis.location.assign("/");
+ }, [playground, left]);
+
+ if (!playground) return null;
+ const reset = async () => {
+ setResetting(true);
+ await fetch(playground.resetUrl, { method: "POST", credentials: "same-origin" }).catch(
+ () => undefined,
+ );
+ globalThis.location.assign("/");
+ };
+ return (
+ void reset()}
+ resetting={resetting}
+ />
+ );
+}
diff --git a/apps/studio/src/components/PlaygroundUnavailable.tsx b/apps/studio/src/components/PlaygroundUnavailable.tsx
new file mode 100644
index 00000000..27f5ac2a
--- /dev/null
+++ b/apps/studio/src/components/PlaygroundUnavailable.tsx
@@ -0,0 +1,23 @@
+import { Empty, LinkButton } from "@cloudhal/ui";
+import { CloudArrowUp, LockSimple } from "@phosphor-icons/react";
+import { useT } from "../i18n/index.tsx";
+
+/**
+ * Shown in place of an account screen a playground refuses (RFC-0036 §5): team, agent keys and
+ * skills, account settings. They need sign-in or reach outside the session.
+ */
+export function PlaygroundUnavailable({ deployUrl }: { deployUrl: string }) {
+ const t = useT();
+ return (
+ }
+ title={t("playground.unavailableTitle")}
+ description={t("playground.unavailableDescription")}
+ contents={
+
+ {t("playground.deploy")}
+
+ }
+ />
+ );
+}
diff --git a/apps/studio/src/components/UserMenu.tsx b/apps/studio/src/components/UserMenu.tsx
index 4d6cb515..a1099f89 100644
--- a/apps/studio/src/components/UserMenu.tsx
+++ b/apps/studio/src/components/UserMenu.tsx
@@ -8,6 +8,7 @@ import { useThemePreference } from "../lib/theme.ts";
import { RoleBadge } from "./Roles.tsx";
import { ThemeRadioItems, themeIcon } from "./ThemeSwitch.tsx";
import { useMe } from "./useMe.ts";
+import { usePlayground } from "./usePlayground.ts";
/**
* The signed-in person's menu, at the right end of the top bar (DESIGN.md §1): an avatar button
@@ -20,6 +21,8 @@ export function UserMenu() {
const navigate = useNavigate();
const [theme] = useThemePreference();
const me = useMe();
+ // RFC-0036 §5: a playground has no account, team or sign-out.
+ const people = !usePlayground();
const name = me.data?.actor.name ?? t("nav.account");
const role = me.data?.role ?? undefined;
const signOut = async () => {
@@ -54,12 +57,16 @@ export function UserMenu() {
{role && }
- navigate({ to: "/account" })}>
- {t("nav.account")}
-
- navigate({ to: "/team" })}>
- {t("nav.team")}
-
+ {people && (
+ navigate({ to: "/account" })}>
+ {t("nav.account")}
+
+ )}
+ {people && (
+ navigate({ to: "/team" })}>
+ {t("nav.team")}
+
+ )}
navigate({ to: "/sites" })}>
{t("nav.allSites")}
@@ -85,10 +92,12 @@ export function UserMenu() {
-
-
- {t("common.signOut")}
-
+ {people && }
+ {people && (
+
+ {t("common.signOut")}
+
+ )}
);
diff --git a/apps/studio/src/components/usePlayground.ts b/apps/studio/src/components/usePlayground.ts
new file mode 100644
index 00000000..c22c4eee
--- /dev/null
+++ b/apps/studio/src/components/usePlayground.ts
@@ -0,0 +1,23 @@
+import { useQuery } from "@tanstack/react-query";
+import { api, type PlaygroundInfo } from "../lib/api.ts";
+
+/** RFC-0036 §5: the playground session (`features.playground`), when Studio runs on one. */
+export function usePlayground(): PlaygroundInfo | undefined {
+ const instance = useQuery({
+ queryKey: ["instance"],
+ queryFn: api.instance,
+ retry: false,
+ staleTime: 60_000,
+ });
+ return instance.data?.features.playground;
+}
+
+/** Account screens a playground refuses (people, agent keys and skills, sign-in settings). */
+export function isPlaygroundBlockedPath(pathname: string): boolean {
+ return /^\/(agents|team|account)(\/|$)/.test(pathname);
+}
+
+/** Whole minutes until `expiresAt`, never below zero. */
+export function minutesLeft(expiresAt: string, now: number): number {
+ return Math.max(0, Math.ceil((Date.parse(expiresAt) - now) / 60_000));
+}
diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts
index 01083193..4f6ab7a4 100644
--- a/apps/studio/src/i18n/ar.ts
+++ b/apps/studio/src/i18n/ar.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ar core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 00788bd78f14efa0
+// hash: 586c8d46bd63e844
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "سجّل الدخول لإدارة مواقعك.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, zero {لم يتبقَّ أي دقيقة} one {تتبقى دقيقة واحدة} two {تتبقى دقيقتان} few {تتبقى # دقائق} many {تتبقى # دقيقة} other {تتبقى # دقيقة}}. يُحذف كل شيء هنا عند انتهاء الجلسة، لذا لا تُدخل بيانات شخصية.",
+ reset: "إعادة تعيين",
+ deploy: "النشر على Cloudflare",
+ unavailableTitle: "متاح في تثبيتك الخاص",
+ unavailableDescription:
+ "يتطلب أعضاء الفريق ومفاتيح الوكلاء وإعدادات الحساب تسجيل الدخول، لذلك لا يتضمنها الـ playground. انشر Cloudhal على حسابك في Cloudflare لاستخدامها.",
+ },
common: {
save: "احفظ",
cancel: "ألغِ",
diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts
index 3466039f..2baba968 100644
--- a/apps/studio/src/i18n/de.ts
+++ b/apps/studio/src/i18n/de.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): de core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 60015e56e0e5cd97
+// hash: 8a6fa48d080c8dcb
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Melde dich an, um deine Websites zu verwalten.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {Noch # Minute} other {Noch # Minuten}}. Alles hier wird gelöscht, wenn die Sitzung endet – gib also keine persönlichen Daten ein.",
+ reset: "Zurücksetzen",
+ deploy: "Auf Cloudflare deployen",
+ unavailableTitle: "In deiner eigenen Installation verfügbar",
+ unavailableDescription:
+ "Teammitglieder, Agent-Schlüssel und Kontoeinstellungen brauchen eine Anmeldung, deshalb lässt der Playground sie weg. Deploye Cloudhal in dein Cloudflare-Konto, um sie zu nutzen.",
+ },
common: {
save: "Speichern",
cancel: "Abbrechen",
diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts
index c29b2f20..f39b63b7 100644
--- a/apps/studio/src/i18n/en.ts
+++ b/apps/studio/src/i18n/en.ts
@@ -1,6 +1,17 @@
/** English (source of truth for keys). Other locales are typed against this object. */
export const en = {
app: { name: "Cloudhal Studio", tagline: "Sign in to operate your sites." },
+ /** RFC-0036 §5: the playground session bar and the screens a playground does not offer. */
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {# minute left} other {# minutes left}}. Everything here is deleted when the session ends, so don't enter personal data.",
+ reset: "Reset",
+ deploy: "Deploy to Cloudflare",
+ unavailableTitle: "Available in your own install",
+ unavailableDescription:
+ "Team members, agent keys and account settings need sign-in, so the playground leaves them out. Deploy Cloudhal to your Cloudflare account to use them.",
+ },
common: {
save: "Save",
cancel: "Cancel",
diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts
index 3fd69900..e447357b 100644
--- a/apps/studio/src/i18n/es.ts
+++ b/apps/studio/src/i18n/es.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): es core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: eb9a3ca039b813b3
+// hash: b279cd0e5ba1d754
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Inicia sesión para gestionar tus sitios.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {Queda # minuto} many {Quedan # de minutos} other {Quedan # minutos}}. Todo se elimina al terminar la sesión, así que no introduzcas datos personales.",
+ reset: "Restablecer",
+ deploy: "Desplegar en Cloudflare",
+ unavailableTitle: "Disponible en tu propia instalación",
+ unavailableDescription:
+ "Los miembros del equipo, las claves de agente y los ajustes de la cuenta requieren iniciar sesión, por eso el playground no los incluye. Despliega Cloudhal en tu cuenta de Cloudflare para usarlos.",
+ },
common: {
save: "Guardar",
cancel: "Cancelar",
diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts
index e3bea5b6..994bfc0a 100644
--- a/apps/studio/src/i18n/fa.ts
+++ b/apps/studio/src/i18n/fa.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): fa core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 3d646b558dc42fa0
+// hash: 090d022080bd43f1
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "برای مدیریت سایتهایتان وارد شوید.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {# دقیقه مانده} other {# دقیقه مانده}}. با پایان جلسه همهچیز پاک میشود، پس اطلاعات شخصی وارد نکنید.",
+ reset: "بازنشانی",
+ deploy: "استقرار در Cloudflare",
+ unavailableTitle: "در نصب خودتان در دسترس است",
+ unavailableDescription:
+ "اعضای تیم، کلیدهای عامل و تنظیمات حساب به ورود نیاز دارند، برای همین playground آنها را ندارد. Cloudhal را در حساب Cloudflare خود مستقر کنید تا از آنها استفاده کنید.",
+ },
common: {
save: "ذخیره",
cancel: "لغو",
diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts
index dadce49d..ee48e6bc 100644
--- a/apps/studio/src/i18n/fr.ts
+++ b/apps/studio/src/i18n/fr.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): fr core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 75bc94d0e343c481
+// hash: 30ea644215e0e3d3
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Connectez-vous pour gérer vos sites.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {Encore # minute} many {Encore # de minutes} other {Encore # minutes}}. Tout est supprimé à la fin de la session : n’entrez pas de données personnelles.",
+ reset: "Réinitialiser",
+ deploy: "Déployer sur Cloudflare",
+ unavailableTitle: "Disponible dans votre propre installation",
+ unavailableDescription:
+ "Les membres de l’équipe, les clés d’agent et les paramètres du compte nécessitent une connexion ; le playground ne les propose donc pas. Déployez Cloudhal sur votre compte Cloudflare pour les utiliser.",
+ },
common: {
save: "Enregistrer",
cancel: "Annuler",
diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts
index 428cd3f6..305529c0 100644
--- a/apps/studio/src/i18n/generated/params.ts
+++ b/apps/studio/src/i18n/generated/params.ts
@@ -502,6 +502,7 @@ export interface MessageParams {
"pickers.tagHint": { max: string | number };
"pickers.under": { path: string | number };
"pickers.viaSynonym": { value: string | number };
+ "playground.description": { count: number };
"preview.frameTitle": { name: string | number };
"projects.waiting": { count: number };
"proposals.acceptSome": { count: string | number; total: string | number };
diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts
index 3ea73cb7..490ee800 100644
--- a/apps/studio/src/i18n/he.ts
+++ b/apps/studio/src/i18n/he.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): he core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: a15e8f76b61934df
+// hash: 1806f4748c6597c1
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "יש להתחבר כדי לנהל את האתרים.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {נותרה דקה אחת} two {נותרו שתי דקות} other {נותרו # דקות}}. הכול נמחק בסיום הסשן, לכן אל תזינו מידע אישי.",
+ reset: "איפוס",
+ deploy: "פריסה ל-Cloudflare",
+ unavailableTitle: "זמין בהתקנה שלכם",
+ unavailableDescription:
+ "חברי צוות, מפתחות סוכנים והגדרות חשבון דורשים התחברות, ולכן ה-playground לא כולל אותם. פרסו את Cloudhal לחשבון ה-Cloudflare שלכם כדי להשתמש בהם.",
+ },
common: {
save: "שמירה",
cancel: "ביטול",
diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts
index 1921ec78..c21f71be 100644
--- a/apps/studio/src/i18n/hi.ts
+++ b/apps/studio/src/i18n/hi.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): hi core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 17fc36df14eea135
+// hash: fade4988bcfe031b
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "अपनी साइटें चलाने के लिए साइन इन करें।",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {# मिनट बाकी} other {# मिनट बाकी}}. सत्र खत्म होने पर यहाँ का सब कुछ मिट जाता है, इसलिए निजी जानकारी न डालें।",
+ reset: "रीसेट करें",
+ deploy: "Cloudflare पर डिप्लॉय करें",
+ unavailableTitle: "आपके अपने इंस्टॉल में उपलब्ध",
+ unavailableDescription:
+ "टीम सदस्य, एजेंट कुंजियाँ और खाता सेटिंग्स के लिए साइन-इन ज़रूरी है, इसलिए playground में ये नहीं हैं। इन्हें इस्तेमाल करने के लिए Cloudhal को अपने Cloudflare खाते में डिप्लॉय करें।",
+ },
common: {
save: "सहेजें",
cancel: "रद्द करें",
diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts
index a642eb11..a125022d 100644
--- a/apps/studio/src/i18n/id.ts
+++ b/apps/studio/src/i18n/id.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): id core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 983422f32b48d9f8
+// hash: d2f9677c3a8222ad
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Masuk untuk mengelola situs Anda.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, other {Tersisa # menit}}. Semua di sini dihapus saat sesi berakhir, jadi jangan masukkan data pribadi.",
+ reset: "Atur ulang",
+ deploy: "Deploy ke Cloudflare",
+ unavailableTitle: "Tersedia di instalasi Anda sendiri",
+ unavailableDescription:
+ "Anggota tim, kunci agen, dan pengaturan akun memerlukan masuk, jadi playground tidak menyertakannya. Deploy Cloudhal ke akun Cloudflare Anda untuk menggunakannya.",
+ },
common: {
save: "Simpan",
cancel: "Batal",
diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts
index 01f2a135..0611fa73 100644
--- a/apps/studio/src/i18n/it.ts
+++ b/apps/studio/src/i18n/it.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): it core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 98d46828ae9fa54d
+// hash: 5b7a60035eb6f4d8
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Accedi per gestire i tuoi siti.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {Manca # minuto} many {Mancano # di minuti} other {Mancano # minuti}}. Tutto viene eliminato alla fine della sessione, quindi non inserire dati personali.",
+ reset: "Reimposta",
+ deploy: "Distribuisci su Cloudflare",
+ unavailableTitle: "Disponibile nella tua installazione",
+ unavailableDescription:
+ "Membri del team, chiavi agent e impostazioni dell’account richiedono l’accesso, quindi il playground li esclude. Distribuisci Cloudhal sul tuo account Cloudflare per usarli.",
+ },
common: {
save: "Salva",
cancel: "Annulla",
diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts
index 37ceb757..7d227ab4 100644
--- a/apps/studio/src/i18n/ja.ts
+++ b/apps/studio/src/i18n/ja.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ja core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 00baa78616d9fb47
+// hash: 30de2a7a9b1d4ea3
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "サインインしてサイトを運用しましょう。",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, other {残り # 分}}。セッションが終わるとここでの内容はすべて削除されるため、個人情報は入力しないでください。",
+ reset: "リセット",
+ deploy: "Cloudflare にデプロイ",
+ unavailableTitle: "ご自身の環境で利用できます",
+ unavailableDescription:
+ "チームメンバー、エージェントキー、アカウント設定にはサインインが必要なため、playground には含まれていません。利用するには Cloudhal をご自身の Cloudflare アカウントにデプロイしてください。",
+ },
common: {
save: "保存",
cancel: "キャンセル",
diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts
index 1476f63b..c40f63cf 100644
--- a/apps/studio/src/i18n/ko.ts
+++ b/apps/studio/src/i18n/ko.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ko core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 42f6b75a5559c9ed
+// hash: b1e4ce942e6ee0af
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "사이트를 운영하려면 로그인하세요.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, other {# 분 남음}}. 세션이 끝나면 여기의 모든 내용이 삭제되므로 개인 정보를 입력하지 마세요.",
+ reset: "초기화",
+ deploy: "Cloudflare에 배포",
+ unavailableTitle: "직접 설치한 환경에서 사용 가능",
+ unavailableDescription:
+ "팀 멤버, 에이전트 키, 계정 설정은 로그인이 필요해서 playground에는 없습니다. 사용하려면 Cloudhal을 내 Cloudflare 계정에 배포하세요.",
+ },
common: {
save: "저장",
cancel: "취소",
diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts
index c2370b96..b04a1cca 100644
--- a/apps/studio/src/i18n/nl.ts
+++ b/apps/studio/src/i18n/nl.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): nl core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 3309d680818aee27
+// hash: 8be96f549bf56662
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Log in om je sites te beheren.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {Nog # minuut} other {Nog # minuten}}. Alles wordt verwijderd als de sessie afloopt, dus voer geen persoonsgegevens in.",
+ reset: "Opnieuw beginnen",
+ deploy: "Deployen naar Cloudflare",
+ unavailableTitle: "Beschikbaar in je eigen installatie",
+ unavailableDescription:
+ "Teamleden, agent-sleutels en accountinstellingen vereisen inloggen, daarom laat de playground ze weg. Deploy Cloudhal naar je Cloudflare-account om ze te gebruiken.",
+ },
common: {
save: "Opslaan",
cancel: "Annuleren",
diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts
index 17c30a8d..892c6271 100644
--- a/apps/studio/src/i18n/pl.ts
+++ b/apps/studio/src/i18n/pl.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): pl core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 6ed50a7b491151d3
+// hash: 4e2f6e3f2cddb1f5
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Zaloguj się, aby zarządzać swoimi witrynami.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {Została # minuta} few {Zostały # minuty} many {Zostało # minut} other {Zostało # minuty}}. Wszystko zostanie usunięte po zakończeniu sesji, więc nie wpisuj danych osobowych.",
+ reset: "Resetuj",
+ deploy: "Wdróż w Cloudflare",
+ unavailableTitle: "Dostępne we własnej instalacji",
+ unavailableDescription:
+ "Członkowie zespołu, klucze agentów i ustawienia konta wymagają logowania, więc playground ich nie obejmuje. Wdróż Cloudhal na swoim koncie Cloudflare, aby z nich korzystać.",
+ },
common: {
save: "Zapisz",
cancel: "Anuluj",
diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts
index e0955840..eee417f5 100644
--- a/apps/studio/src/i18n/pt-BR.ts
+++ b/apps/studio/src/i18n/pt-BR.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): pt-BR core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 63975934a742cee6
+// hash: 74a4e5832d3dfe6a
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Entre para operar seus sites.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {Falta # minuto} many {Faltam # de minutos} other {Faltam # minutos}}. Tudo aqui é apagado quando a sessão termina, então não insira dados pessoais.",
+ reset: "Redefinir",
+ deploy: "Implantar no Cloudflare",
+ unavailableTitle: "Disponível na sua própria instalação",
+ unavailableDescription:
+ "Membros da equipe, chaves de agente e configurações da conta exigem login, por isso o playground os deixa de fora. Implante o Cloudhal na sua conta Cloudflare para usá-los.",
+ },
common: {
save: "Salvar",
cancel: "Cancelar",
diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts
index 56dd3405..4bed824c 100644
--- a/apps/studio/src/i18n/ru.ts
+++ b/apps/studio/src/i18n/ru.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ru core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 9e34b290d38a8259
+// hash: a973027c41496944
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Войдите, чтобы управлять своими сайтами.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {Осталась # минута} few {Осталось # минуты} many {Осталось # минут} other {Осталось # минуты}}. Всё удалится по окончании сеанса, поэтому не вводите личные данные.",
+ reset: "Сбросить",
+ deploy: "Развернуть в Cloudflare",
+ unavailableTitle: "Доступно в вашей установке",
+ unavailableDescription:
+ "Участникам команды, ключам агентов и настройкам аккаунта нужен вход, поэтому в playground их нет. Разверните Cloudhal в своём аккаунте Cloudflare, чтобы ими пользоваться.",
+ },
common: {
save: "Сохранить",
cancel: "Отмена",
diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts
index 7d10fc24..3248d19c 100644
--- a/apps/studio/src/i18n/tr.ts
+++ b/apps/studio/src/i18n/tr.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): tr core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 09baf80b31d816dd
+// hash: 9bc83d36add9c08f
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Sitelerini yönetmek için giriş yap.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {# dakika kaldı} other {# dakika kaldı}}. Oturum bitince buradaki her şey silinir; kişisel veri girme.",
+ reset: "Sıfırla",
+ deploy: "Cloudflare'e kur",
+ unavailableTitle: "Kendi kurulumunda kullanılabilir",
+ unavailableDescription:
+ "Ekip üyeleri, agent anahtarları ve hesap ayarları giriş gerektirdiği için playground'da yok. Kullanmak için Cloudhal'ı kendi Cloudflare hesabına kur.",
+ },
common: {
save: "Kaydet",
cancel: "Vazgeç",
diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts
index 964b2ff1..704bf7a6 100644
--- a/apps/studio/src/i18n/uk.ts
+++ b/apps/studio/src/i18n/uk.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): uk core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 201a1ecea7d6a936
+// hash: 0c806ebbb11d1657
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "Увійдіть, щоб керувати своїми сайтами.",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, one {Залишилася # хвилина} few {Залишилося # хвилини} many {Залишилося # хвилин} other {Залишилося # хвилини}}. Усе видаляється після завершення сеансу, тож не вводьте особисті дані.",
+ reset: "Скинути",
+ deploy: "Розгорнути в Cloudflare",
+ unavailableTitle: "Доступно у вашій інсталяції",
+ unavailableDescription:
+ "Учасникам команди, ключам агентів і налаштуванням облікового запису потрібен вхід, тому в playground їх немає. Розгорніть Cloudhal у своєму обліковому записі Cloudflare, щоб ними користуватися.",
+ },
common: {
save: "Зберегти",
cancel: "Скасувати",
diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts
index 2c54d286..379bf7b3 100644
--- a/apps/studio/src/i18n/zh-Hans.ts
+++ b/apps/studio/src/i18n/zh-Hans.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): zh-Hans core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: febd3deeadc5d99e
+// hash: b8ca4c457ba0bf27
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -8,6 +8,16 @@ export const messages: Messages = {
name: "Cloudhal Studio",
tagline: "登录以管理你的站点。",
},
+ playground: {
+ title: "Playground",
+ description:
+ "{count, plural, other {剩余 # 分钟}}。会话结束后这里的所有内容都会被删除,请勿输入个人信息。",
+ reset: "重置",
+ deploy: "部署到 Cloudflare",
+ unavailableTitle: "在你自己的安装中可用",
+ unavailableDescription:
+ "团队成员、Agent 密钥和账户设置需要登录,因此 playground 中没有这些功能。将 Cloudhal 部署到你的 Cloudflare 账户即可使用。",
+ },
common: {
save: "保存",
cancel: "取消",
diff --git a/apps/studio/src/lib/api.ts b/apps/studio/src/lib/api.ts
index 7a591983..fd6a6168 100644
--- a/apps/studio/src/lib/api.ts
+++ b/apps/studio/src/lib/api.ts
@@ -195,6 +195,7 @@ export type {
Member,
MemberRemoval,
NewInvite,
+ PlaygroundInfo,
ResetDetails,
Role,
RolesInfo,
diff --git a/apps/studio/src/lib/api/team.ts b/apps/studio/src/lib/api/team.ts
index b94ecd3a..32af4ffb 100644
--- a/apps/studio/src/lib/api/team.ts
+++ b/apps/studio/src/lib/api/team.ts
@@ -31,6 +31,8 @@ export interface InstanceInfo {
schedules?: SchedulesFeature;
/** In-editor AI (RFC-0016): how the server reaches Workers AI. Older servers omit it. */
ai?: AiTransportKind;
+ /** RFC-0036 §5: set only on a playground session (try.cloudhal.com). */
+ playground?: PlaygroundInfo;
};
/** The server's agent-mode rules (ADR-0015). Older servers omit it; Studio then uses its own copy. */
agentModes?: AgentModesInfo;
@@ -42,6 +44,16 @@ export interface InstanceInfo {
passwordReset?: ("admin" | "email")[];
}
+/** A playground session (`features.playground`, packages/api/src/playground.ts). */
+export interface PlaygroundInfo {
+ /** When the session's data is deleted (ISO 8601). */
+ expiresAt: string;
+ /** `POST` deletes the session at once. */
+ resetUrl: string;
+ /** Where "Deploy to Cloudflare" leads. */
+ deployUrl: string;
+}
+
// ---- staff roles, members and invites (RFC-0005, packages/api/src/routes/{members,invites}.ts) ----
/** Staff roles, highest rank first (`packages/agent-runtime/src/roles.ts`). */
@@ -213,7 +225,7 @@ export type SignUpPolicy = InstanceInfo["signUp"];
/** The on/off features (`analytics` is an object of its own). */
export type InstanceFeature = Exclude<
keyof InstanceInfo["features"],
- "analytics" | "comments" | "schedules" | "ai"
+ "analytics" | "comments" | "schedules" | "ai" | "playground"
>;
export const teamApi = {
diff --git a/apps/studio/src/router.tsx b/apps/studio/src/router.tsx
index cb7d9fe8..b6387761 100644
--- a/apps/studio/src/router.tsx
+++ b/apps/studio/src/router.tsx
@@ -10,6 +10,7 @@ import {
import { RouteError, RoutePending } from "./components/RouteStates.tsx";
import { LOCALE_LIST, withMessages } from "./i18n/index.tsx";
import { type AnalyticsSearch, validateAnalyticsSearch } from "./lib/analytics-search.ts";
+import { api } from "./lib/api.ts";
import { authClient } from "./lib/auth.ts";
import { isQueueTab, type QueueTab } from "./lib/comments-shell.ts";
import { AccountShell } from "./routes/AccountShell.tsx";
@@ -152,9 +153,20 @@ export interface RouterContext {
}
async function requireSession() {
- const { data } = await authClient.getSession();
- if (!data) throw redirect({ to: "/login" });
- return data;
+ const { data, error } = await authClient.getSession();
+ if (data) return data;
+ // RFC-0036 §5: a playground has no sign-in (its API refuses /api/auth with 403) and answers
+ // every call as its one fixed principal.
+ if (error?.status === 403 && (await isPlayground())) return null;
+ throw redirect({ to: "/login" });
+}
+
+async function isPlayground(): Promise {
+ try {
+ return (await api.instance()).features.playground !== undefined;
+ } catch {
+ return false;
+ }
}
const rootRoute = createRootRouteWithContext()({ component: () => });
diff --git a/apps/studio/src/routes/AccountShell.tsx b/apps/studio/src/routes/AccountShell.tsx
index 7c006bf2..6d381b96 100644
--- a/apps/studio/src/routes/AccountShell.tsx
+++ b/apps/studio/src/routes/AccountShell.tsx
@@ -3,7 +3,9 @@ import { Robot, SquaresFour, UserGear, UsersThree } from "@phosphor-icons/react"
import { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
import { AppFrame } from "../components/AppFrame.tsx";
+import { PlaygroundUnavailable } from "../components/PlaygroundUnavailable.tsx";
import { useMembers } from "../components/useMe.ts";
+import { isPlaygroundBlockedPath, usePlayground } from "../components/usePlayground.ts";
import { useT } from "../i18n/index.tsx";
import { api, isNotEnabled } from "../lib/api.ts";
@@ -12,14 +14,32 @@ export function AccountShell() {
const t = useT();
const navigate = useNavigate();
const { pathname } = useLocation();
+ // RFC-0036 §5: a playground has no people, agents or sign-in; their screens are not offered.
+ const playground = usePlayground();
+ const people = !playground;
const sites = useQuery({ queryKey: ["sites"], queryFn: api.sites.list });
const keys = useQuery({
queryKey: ["agent-keys"],
queryFn: api.agentKeys.list,
retry: (count, error) => !isNotEnabled(error) && count < 1,
+ enabled: people,
});
- const skills = useQuery({ queryKey: ["skills"], queryFn: api.skills.list });
- const members = useMembers();
+ const skills = useQuery({ queryKey: ["skills"], queryFn: api.skills.list, enabled: people });
+ const members = useMembers(people);
+ const accountCommands = [
+ { id: "agents", label: t("commands.goAgents"), run: () => navigate({ to: "/agents" }) },
+ {
+ id: "skills",
+ label: t("commands.goSkills"),
+ run: () => navigate({ to: "/agents/skills" }),
+ },
+ { id: "team", label: t("commands.goTeam"), run: () => navigate({ to: "/team" }) },
+ {
+ id: "account",
+ label: t("commands.goAccount"),
+ run: () => navigate({ to: "/account" }),
+ },
+ ];
return (
-
- {t("nav.automation")}
-
-
-
-
- {t("nav.agents")}
-
-
- }
- />
-
-
-
- {t("nav.agentKeys")}
- {keys.data && {keys.data.length}}
-
-
- {t("nav.agentSkills")}
- {skills.data && {skills.data.length}}
-
-
-
-
-
-
-
-
- {t("nav.settings")}
-
-
- {t("nav.team")}
- {members.data && {members.data.length}}
-
-
- {t("nav.account")}
-
-
-
+ {people && (
+
+ {t("nav.automation")}
+
+
+
+
+ {t("nav.agents")}
+
+
+ }
+ />
+
+
+
+ {t("nav.agentKeys")}
+ {keys.data && {keys.data.length}}
+
+
+ {t("nav.agentSkills")}
+ {skills.data && (
+ {skills.data.length}
+ )}
+
+
+
+
+
+
+
+ )}
+ {people && (
+
+ {t("nav.settings")}
+
+
+ {t("nav.team")}
+ {members.data && {members.data.length}}
+
+
+ {t("nav.account")}
+
+
+
+ )}
>
}
commands={[
{ id: "sites", label: t("nav.allSites"), run: () => navigate({ to: "/sites" }) },
- { id: "agents", label: t("commands.goAgents"), run: () => navigate({ to: "/agents" }) },
- {
- id: "skills",
- label: t("commands.goSkills"),
- run: () => navigate({ to: "/agents/skills" }),
- },
- { id: "team", label: t("commands.goTeam"), run: () => navigate({ to: "/team" }) },
- {
- id: "account",
- label: t("commands.goAccount"),
- run: () => navigate({ to: "/account" }),
- },
+ ...(people ? accountCommands : []),
]}
>
-
+ {playground && isPlaygroundBlockedPath(pathname) ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/apps/studio/test/playground.test.tsx b/apps/studio/test/playground.test.tsx
new file mode 100644
index 00000000..1d6ce33f
--- /dev/null
+++ b/apps/studio/test/playground.test.tsx
@@ -0,0 +1,80 @@
+/** RFC-0036 §5: Studio on a playground session — the session bar and the screens it leaves out. */
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { PlaygroundSessionBar } from "../src/components/PlaygroundSessionBar.tsx";
+import { PlaygroundUnavailable } from "../src/components/PlaygroundUnavailable.tsx";
+import { isPlaygroundBlockedPath, minutesLeft } from "../src/components/usePlayground.ts";
+import { INSTANCE, on, serve, wrap } from "./roles-fixtures.tsx";
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+const playground = (expiresAt: string) => ({
+ ...INSTANCE,
+ features: {
+ ...INSTANCE.features,
+ playground: {
+ expiresAt,
+ resetUrl: "/_playground/reset",
+ deployUrl: "https://example.test/deploy",
+ },
+ },
+});
+
+describe("playground helpers", () => {
+ it("counts whole minutes left, never below zero", () => {
+ const now = Date.parse("2026-10-20T18:00:00Z");
+ expect(minutesLeft("2026-10-20T18:41:00Z", now)).toBe(41);
+ expect(minutesLeft("2026-10-20T18:00:20Z", now)).toBe(1);
+ expect(minutesLeft("2026-10-20T17:59:00Z", now)).toBe(0);
+ });
+
+ it("knows the account screens a playground leaves out", () => {
+ for (const p of ["/team", "/agents", "/agents/skills", "/account"])
+ expect(isPlaygroundBlockedPath(p), p).toBe(true);
+ for (const p of ["/sites", "/sites/s1/team", "/accounts-x"])
+ expect(isPlaygroundBlockedPath(p), p).toBe(false);
+ });
+});
+
+describe("PlaygroundSessionBar", () => {
+ it("renders nothing outside a playground", async () => {
+ const fetch = serve(on("GET", "/api/instance", { status: 200, body: INSTANCE }));
+ render(wrap());
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ expect(screen.queryByTestId("playground-bar")).toBeNull();
+ });
+
+ it("shows the time left and Deploy, and Reset deletes the session", async () => {
+ const expires = new Date(Date.now() + 41 * 60_000 - 1000).toISOString();
+ const fetch = serve(
+ on("GET", "/api/instance", { status: 200, body: playground(expires) }),
+ on("POST", "/_playground/reset", { status: 204 }),
+ );
+ const assign = vi.fn();
+ vi.stubGlobal("location", { ...globalThis.location, assign });
+ render(wrap());
+ expect(await screen.findByText(/41 minutes left/)).toBeTruthy();
+ expect(screen.getByRole("link", { name: /Deploy to Cloudflare/ }).getAttribute("href")).toBe(
+ "https://example.test/deploy",
+ );
+ fireEvent.click(screen.getByRole("button", { name: /Reset/ }));
+ await waitFor(() => expect(assign).toHaveBeenCalledWith("/"));
+ expect(
+ fetch.mock.calls.some(
+ ([u, init]) => String(u) === "/_playground/reset" && init?.method === "POST",
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("PlaygroundUnavailable", () => {
+ it("says where the screen works", () => {
+ render(wrap());
+ expect(screen.getByText("Available in your own install")).toBeTruthy();
+ expect(screen.getByRole("link", { name: /Deploy to Cloudflare/ })).toBeTruthy();
+ });
+});
diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md
index 10cfcbe5..f51c8e8b 100644
--- a/docs/design/DESIGN.md
+++ b/docs/design/DESIGN.md
@@ -463,6 +463,11 @@ Site › Settings gets an **Appearance** section after Domains (`components/Appe
- **Feedback.** `design_lint` warnings show as an `alert` `Banner` ("Worth a look"). A conflict on save shows the shared conflict wording.
- **Hints.** A secondary line says agents propose changes that wait in Approvals. Below Administrator the section is read-only, with the gate's reason.
+### 2af. Playground session (RFC-0036 §5, ADR-0038)
+- Only when `/api/instance` reports `features.playground` (try.cloudhal.com). Studio has no sign-in there: `requireSession` accepts a 403 from `/api/auth` when the instance is a playground.
+- **Session bar**: `PlaygroundBar` under the top bar on every screen — a compact secondary `Banner` (`Timer`), title "Playground", the minutes left as an ICU plural with the privacy note, a ghost "Reset" (`POST resetUrl`, then the landing page) and a primary "Deploy to Cloudflare" `LinkButton` (external). At zero minutes the browser returns to the landing page.
+- **Left out**: the account surface's Agents group and Team/Account items (and their palette commands), the user menu's Account, Team and Sign out. A direct visit to `/agents`, `/team` or `/account` shows `PlaygroundUnavailable`: a bare `Empty` (`LockSimple`) "Available in your own install" with the Deploy button. Site screens are gated as everywhere else, by `features.*` and the principal's abilities (no uploads, analytics, comments, schedules, AI, preview links).
+
## 3. Sidebar specification
```tsx
@@ -574,6 +579,7 @@ Studio must use each component where it fits; "later" means the surface does not
| AnalyticsRangePicker (Cloudhal block) | the analytics range: preset `Tabs`, "Custom…" `Popover` with a lazy `DatePicker mode="range"` (UTC, Studio locale), optional compare `Switch`; controlled by `{preset}` or `{from, to}` (§2v) |
| BreakdownTable / BreakdownMeters (Cloudhal blocks) | ranked analytics breakdowns — top content, channels, referrers (never linked), campaigns, countries — with share `Meter`s and one folded "(other)" row; small breakdowns (devices, top channels) as a `Meter` list (§2v) |
| AnalyticsTimeseries / CountryMap / ReadersSummary (Cloudhal blocks, `@cloudhal/ui/charts`) | traffic over time with a legend and a data-table toggle (UTC throughout); the country choropleth with its top-countries table and no HTML tooltips; the editors' Readers card body (§2v) |
+| PlaygroundBar (Cloudhal block) | the playground session bar: time left, Reset and Deploy to Cloudflare in a compact secondary `Banner` (§2af) |
| AnalyticsQuotaBanner (Cloudhal block) | the Workers Free request warning with its estimate as a `Meter` (`shouldWarnFreePlan`, §2v) |
| AnalyticsBotBanner (Cloudhal block) | the possible-bot-traffic warning on the Analytics screen with its reasons in a `Collapsible` (§2v) |
| CommentText / CommentRow / CommentThread (Cloudhal blocks) | comment text as plain text with the public page's `https:`-only link rule (never HTML), one comment with its author, status, labels, entry, reply context and untrusted-text panel, and the two-level thread with folded replies and in-reply-to jumps (§2w) |
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 62f897cf..6a04c356 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -3,15 +3,17 @@ import {
type OwnershipResolver,
PEOPLE_ONLY_OWNERSHIP,
PolicyEngine,
+ type Principal,
} from "@cloudhal/agent-runtime";
import { ANALYTICS_OFF } from "@cloudhal/analytics/service";
import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http";
import { schedulesAvailable } from "@cloudhal/mcp-server/schedules";
import { defineTools, toolPolicies } from "@cloudhal/mcp-server/tools";
-import { Hono } from "hono";
+import { type Context, Hono } from "hono";
import { createBuildHub } from "./build.ts";
import type { ApiDeps, Env } from "./context.ts";
import { errorHandler, unauthorized } from "./errors.ts";
+import { PLAYGROUND_PRINCIPAL, playgroundGuard } from "./playground.ts";
import { activity, deploymentActivity } from "./routes/activity.ts";
import { adminRoutes } from "./routes/admin.ts";
import { agentKeys } from "./routes/agent-keys.ts";
@@ -132,6 +134,10 @@ export function createApi(deps: ApiDeps) {
return engine;
};
+ // RFC-0036 §5: a playground refuses blocked routes before anything else runs.
+ const playground = deps.playground;
+ if (playground) app.use("*", playgroundGuard(playground));
+
// DNS rebinding: a server without auth (local scripts) only answers its own host names.
if (deps.allowedHosts) {
const allowed = new Set(deps.allowedHosts.map((h) => h.toLowerCase()));
@@ -186,20 +192,15 @@ export function createApi(deps: ApiDeps) {
// Raw and decoded: whichever form Better Auth or Hono would dispatch on is fenced.
if (FENCED_AUTH_PATHS.test(new URL(c.req.url).pathname) || FENCED_AUTH_PATHS.test(c.req.path))
return c.json({ error: "not_found", message: "not found" }, 404);
- if (deps.resolvePrincipal) {
- const principal = await deps.resolvePrincipal(c);
- c.set("principal", principal);
- c.set("actor", principal?.actor);
- } else {
- const actor = deps.resolveActor ? await deps.resolveActor(c) : undefined;
- c.set("actor", actor);
- c.set("principal", actor ? { actor, scopes: defaultScopes(actor) } : undefined);
- }
+ const principal = await resolvePrincipal(c, deps);
+ c.set("principal", principal);
+ c.set("actor", principal?.actor);
await next();
});
// Default-deny REST (RFC-0005 I15): every route declares its requirement in `ROUTE_SCOPES`.
- app.use("/api/*", routeGuard({ requireAuth, membership: Boolean(deps.staff) }));
+ const membership = Boolean(deps.staff || playground); // a playground is checked like a role
+ app.use("/api/*", routeGuard({ requireAuth, membership }));
// API answers are per person and change with every write: a GET without its own caching header
// says `no-store`, because Workers Cache was seen caching header-less GET answers (RFC-0008 §13).
@@ -223,7 +224,7 @@ export function createApi(deps: ApiDeps) {
);
}
- app.get("/api/me", meHandler(Boolean(deps.staff)));
+ app.get("/api/me", meHandler(membership));
if (deps.mcp) {
const mcp = createCloudhalMcpHandler({
@@ -284,6 +285,7 @@ export function createApi(deps: ApiDeps) {
schedules,
schedulesAvailable(schedules) && (await gatesEnforcedEverywhere(deps.store)),
),
+ ...(playground ? { playground: await playground.info() } : {}), // RFC-0036 §5
},
agentModes: agentModesInfo(),
roles: rolesInfo(),
@@ -326,4 +328,12 @@ export function createApi(deps: ApiDeps) {
return app;
}
+/** Who is calling: the playground's one principal (RFC-0036 §5), else the deployment's resolver. */
+async function resolvePrincipal(c: Context, deps: ApiDeps): Promise {
+ if (deps.playground) return PLAYGROUND_PRINCIPAL;
+ if (deps.resolvePrincipal) return deps.resolvePrincipal(c);
+ const actor = deps.resolveActor ? await deps.resolveActor(c) : undefined;
+ return actor ? { actor, scopes: defaultScopes(actor) } : undefined;
+}
+
export type CloudhalApi = ReturnType;
diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts
index 86921198..542a44e2 100644
--- a/packages/api/src/context.ts
+++ b/packages/api/src/context.ts
@@ -17,6 +17,7 @@ import type { Context } from "hono";
import type { AgentKeyService, StaffService } from "./auth.ts";
import type { BuildHub } from "./build.ts";
import type { MailSender } from "./mail.ts";
+import type { PlaygroundOptions } from "./playground.ts";
import type { AiDeps } from "./routes/ai.ts";
import type { UploadSecret } from "./uploads.ts";
@@ -123,6 +124,13 @@ export interface ApiDeps {
* on Workers, `restTransport` from Node). Without a transport AI stays unavailable.
*/
readonly ai?: AiDeps;
+ /**
+ * Playground mode (RFC-0036 §5): every request acts as `PLAYGROUND_PRINCIPAL`, the playground
+ * guard refuses blocked routes (403 `playground`) and large bodies, scopes are checked as for a
+ * member, and `/api/instance` reports `features.playground`. Leave auth, MCP, mail, AI, analytics,
+ * comments and schedules unconfigured.
+ */
+ readonly playground?: PlaygroundOptions;
}
export type Env = {
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 465a2035..fa2aae27 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -42,4 +42,14 @@ export {
export type { ActorResolver, ApiDeps } from "./context.ts";
export type { ApiErrorBody } from "./errors.ts";
export { MailError, type MailMessage, type MailSender } from "./mail.ts";
+export {
+ isBlockedInPlayground,
+ PLAYGROUND_ALLOW,
+ PLAYGROUND_MAX_BODY_BYTES,
+ PLAYGROUND_PRINCIPAL,
+ PLAYGROUND_SCOPES,
+ type PlaygroundBlock,
+ type PlaygroundInfo,
+ type PlaygroundOptions,
+} from "./playground.ts";
export { releaseApprovalCheck, releaseRights } from "./schedules.ts";
diff --git a/packages/api/src/playground.ts b/packages/api/src/playground.ts
new file mode 100644
index 00000000..3c5a570f
--- /dev/null
+++ b/packages/api/src/playground.ts
@@ -0,0 +1,273 @@
+/**
+ * Playground mode (RFC-0036 §5): a disposable, account-less Cloudhal per visitor. One pure guard
+ * says which API routes a playground refuses; it runs in the playground Worker before forwarding
+ * and again as API middleware. Under both sits the fixed principal's scope check (RFC-0005 I15).
+ *
+ * The guard is a deny list with an allow list beside it. Every route in `ROUTE_SCOPES` must be on
+ * exactly one of them (`test/playground.test.ts` walks them all), and a route on neither is
+ * refused as `unclassified`, so a new route is never silently open in a playground.
+ */
+import { type Principal, ROLE_SCOPES, type Scope } from "@cloudhal/agent-runtime";
+import { createMiddleware } from "hono/factory";
+import type { Env } from "./context.ts";
+import { matchesRoute, routeFor } from "./scopes.ts";
+
+export type PlaygroundBlock =
+ | "auth"
+ | "people"
+ | "keys"
+ | "mcp"
+ | "uploads"
+ | "outbound"
+ | "transfer"
+ | "sites"
+ | "domains"
+ | "agent"
+ | "schedules"
+ | "unclassified";
+
+/** What Studio needs to show the session bar (`InstanceInfo.features.playground`). */
+export interface PlaygroundInfo {
+ /** When the session's data is deleted (ISO 8601). */
+ expiresAt: string;
+ /** `POST` here deletes the session at once; the next request starts a fresh one. */
+ resetUrl: string;
+ /** Where "Deploy to Cloudflare" leads. */
+ deployUrl: string;
+}
+
+export interface PlaygroundOptions {
+ /** The session's current info; read on every `/api/instance`. */
+ info(): PlaygroundInfo | Promise;
+ /** Largest request body the API accepts, in bytes (RFC-0036 §7: 200 KB per entry or section). */
+ maxBodyBytes?: number;
+}
+
+export const PLAYGROUND_MAX_BODY_BYTES = 200_000;
+
+const S = "/api/sites/:siteId";
+
+/** Refused in a playground: `[method or ALL, pattern, why]`. Patterns use `ROUTE_SCOPES` syntax. */
+const DENY: readonly (readonly [string, string, PlaygroundBlock])[] = [
+ ["ALL", "/api/auth/*", "auth"],
+ ["ALL", "/api/password-reset/*", "auth"],
+ ["ALL", "/api/members", "people"],
+ ["ALL", "/api/members/*", "people"],
+ ["ALL", "/api/invites", "people"],
+ ["ALL", "/api/invites/*", "people"],
+ ["ALL", "/api/agent-keys", "keys"],
+ ["ALL", "/api/agent-keys/*", "keys"],
+ ["ALL", "/mcp", "mcp"],
+ ["ALL", `${S}/approvals`, "agent"],
+ ["ALL", `${S}/approvals/*`, "agent"],
+ ["ALL", `${S}/ai/*`, "agent"],
+ ["GET", `${S}/pages/:pageId/build/events`, "agent"],
+ // Uploads and every change to the media library: a session's media are the read-only demo
+ // pack (no bytes enter a session, RFC-0036 §2 invariant 5).
+ ["ALL", `${S}/assets/uploads`, "uploads"],
+ ["ALL", `${S}/assets/uploads/*`, "uploads"],
+ ["PUT", `${S}/assets/:assetId`, "uploads"],
+ ["DELETE", `${S}/assets/:assetId`, "uploads"],
+ ["POST", `${S}/assets/:assetId/*`, "uploads"],
+ ["POST", `${S}/media/:sha256/lift-purge`, "uploads"],
+ // Bundles, media moves, site create and delete, domains, search reindex: the operator's routes.
+ ["PUT", "/api/admin/sites/:siteId/domains", "domains"],
+ ["ALL", "/api/admin/*", "transfer"],
+ ["POST", "/api/sites", "sites"],
+ // A signed preview link would show a session's work outside it (share links: RFC-0036 phase 5).
+ ["POST", `${S}/preview-links`, "outbound"],
+ ["ALL", `${S}/analytics/*`, "outbound"],
+ ["ALL", `${S}/comments`, "outbound"],
+ ["ALL", `${S}/comments/*`, "outbound"],
+ ["ALL", `${S}/comment-threads/*`, "outbound"],
+ ["ALL", `${S}/commenters/*`, "outbound"],
+ ["ALL", `${S}/comment-bans`, "outbound"],
+ ["ALL", `${S}/comment-bans/*`, "outbound"],
+ // Releases and automations need a ticker and may call out; a one-hour session has neither.
+ ["ALL", "/api/scheduler", "schedules"],
+ ["ALL", `${S}/schedule`, "schedules"],
+ ["ALL", `${S}/schedule-settings`, "schedules"],
+ ["ALL", `${S}/releases`, "schedules"],
+ ["ALL", `${S}/releases/*`, "schedules"],
+ ["ALL", `${S}/gates`, "schedules"],
+ ["ALL", `${S}/gates/*`, "schedules"],
+ ["ALL", `${S}/automations`, "schedules"],
+ ["ALL", `${S}/automations/*`, "schedules"],
+ ["ALL", `${S}/runs`, "schedules"],
+ ["ALL", `${S}/runs/*`, "schedules"],
+];
+
+/** Open in a playground: content, structure, design, snapshots, publish, search, previews. */
+export const PLAYGROUND_ALLOW: ReadonlySet = new Set([
+ "GET /api/health",
+ "GET /api/me",
+ "GET /api/instance",
+ "GET /api/skills",
+ "GET /api/notifications",
+ "GET /api/activity",
+ "GET /api/sites",
+ "GET /api/projects",
+ `GET ${S}`,
+ `PUT ${S}`,
+ `GET ${S}/pages`,
+ `POST ${S}/pages`,
+ `GET ${S}/pages/:pageId/preview`,
+ `POST ${S}/pages/:pageId/preview`,
+ `GET ${S}/pages/:pageId`,
+ `PUT ${S}/pages/:pageId`,
+ `DELETE ${S}/pages/:pageId`,
+ `PATCH ${S}/pages/:pageId/sections/:sectionId/body`,
+ `GET ${S}/entries`,
+ `POST ${S}/entries`,
+ `GET ${S}/entries/:entryId/preview`,
+ `POST ${S}/entries/:entryId/preview`,
+ `POST ${S}/entries/preview-fragment`,
+ `GET ${S}/entries/:entryId`,
+ `PUT ${S}/entries/:entryId`,
+ `DELETE ${S}/entries/:entryId`,
+ `PATCH ${S}/entries/:entryId/body`,
+ `GET ${S}/proposals`,
+ `POST ${S}/proposals/:proposalId/preview`,
+ `POST ${S}/proposals/:proposalId/accept`,
+ `POST ${S}/proposals/:proposalId/reject`,
+ `GET ${S}/block-types`,
+ `GET ${S}/theme-settings`,
+ `PUT ${S}/theme-settings`,
+ `GET ${S}/chrome`,
+ `PUT ${S}/chrome`,
+ `GET ${S}/design`,
+ `PUT ${S}/design`,
+ `GET ${S}/snapshots`,
+ `POST ${S}/snapshots`,
+ `GET ${S}/snapshots/:snapshotId`,
+ `GET ${S}/snapshots/:snapshotId/diff/:otherId`,
+ `GET ${S}/snapshots/:snapshotId/nodes/:nodeId`,
+ `POST ${S}/snapshots/:snapshotId/publish`,
+ `POST ${S}/snapshots/:snapshotId/rollback`,
+ `GET ${S}/activity`,
+ `GET ${S}/taxonomies`,
+ `POST ${S}/taxonomies`,
+ `GET ${S}/taxonomies/:taxonomyId`,
+ `PUT ${S}/taxonomies/:taxonomyId`,
+ `GET ${S}/terms`,
+ `POST ${S}/terms`,
+ `GET ${S}/terms/:termId/preview`,
+ `POST ${S}/terms/:termId/preview`,
+ `GET ${S}/terms/:termId`,
+ `PUT ${S}/terms/:termId`,
+ `POST ${S}/terms/:termId/rename`,
+ `POST ${S}/terms/:termId/merge`,
+ `POST ${S}/terms/:termId/retire`,
+ `POST ${S}/terms/:termId/parent`,
+ `POST ${S}/terms/:termId/visibility`,
+ `POST ${S}/terms/:termId/review`,
+ `GET ${S}/search`,
+ `GET ${S}/translations`,
+ `GET ${S}/translations/:nodeId`,
+ `POST ${S}/translations/:nodeId/review`,
+ `GET ${S}/glossary`,
+ `PUT ${S}/glossary`,
+ `GET ${S}/redirects`,
+ `POST ${S}/redirects`,
+ `GET ${S}/redirects/:redirectId`,
+ `PUT ${S}/redirects/:redirectId`,
+ `DELETE ${S}/redirects/:redirectId`,
+ `GET ${S}/menus`,
+ `POST ${S}/menus`,
+ `POST ${S}/menus/from-pages`,
+ `POST ${S}/menus/preview`,
+ `GET ${S}/menus/references`,
+ `GET ${S}/menus/:menuId`,
+ `PUT ${S}/menus/:menuId`,
+ `DELETE ${S}/menus/:menuId`,
+ `GET ${S}/menus/:menuId/resolve`,
+ `POST ${S}/menus/:menuId/ops`,
+ `GET ${S}/assets`,
+ `GET ${S}/assets/:assetId`,
+ `GET ${S}/assets/:assetId/content`,
+ `POST ${S}/migrations/tags-to-terms`,
+ `POST ${S}/migrations/tags-to-terms/revert`,
+]);
+
+/** The deny rule a declared route falls under, if any. */
+export function playgroundDenyRule(method: string, pattern: string): PlaygroundBlock | undefined {
+ for (const [m, rule, block] of DENY)
+ if ((m === "ALL" || method === "ALL" || m === method) && matchesRoute(rule, pattern))
+ return block;
+ return undefined;
+}
+
+/**
+ * Why `method path` is refused in playground mode, or undefined when it is allowed. Only API
+ * paths (`/api/*`, `/mcp`) are judged; anything else (Studio, the rendered site) is not the API's.
+ */
+export function isBlockedInPlayground(method: string, path: string): PlaygroundBlock | undefined {
+ const clean = path.replace(/\/+$/, "") || "/";
+ if (clean === "/mcp" || clean.startsWith("/mcp/")) return "mcp";
+ if (!clean.startsWith("/api/")) return undefined;
+ const m = method.toUpperCase() === "HEAD" ? "GET" : method.toUpperCase();
+ // Prefix rules first, so an undeclared path under a blocked area names its area.
+ const route = routeFor(m, clean);
+ const pattern = route?.[1] ?? clean;
+ const denied = playgroundDenyRule(route?.[0] ?? m, pattern);
+ if (denied) return denied;
+ if (route && PLAYGROUND_ALLOW.has(`${route[0]} ${route[1]}`)) return undefined;
+ return "unclassified";
+}
+
+/**
+ * The one principal of a playground session (RFC-0036 §5): an Editor with site settings, and
+ * nothing installation-wide. It also lacks the Editor's media, person-only and outward scopes
+ * (`asset:manage` implies `asset:upload`; inviting, commenter data, automations), so a route the
+ * guard missed still fails the scope check.
+ */
+const EDITOR_DROPPED: readonly Scope[] = [
+ "asset:manage",
+ "members:invite",
+ "commenters:data",
+ "automations:manage",
+];
+export const PLAYGROUND_SCOPES: readonly Scope[] = [
+ ...ROLE_SCOPES.editor.filter((s) => !EDITOR_DROPPED.includes(s)),
+ "site:settings",
+];
+
+export const PLAYGROUND_PRINCIPAL: Principal = {
+ actor: { kind: "human", id: "user_playground", name: "Playground Owner" },
+ scopes: [...PLAYGROUND_SCOPES],
+ person: { id: "user_playground", userId: "playground", role: "editor", name: "Playground Owner" },
+};
+
+/** API middleware: refuses blocked routes with `403 {error: "playground"}` and oversized bodies. */
+export function playgroundGuard(options: PlaygroundOptions) {
+ const max = options.maxBodyBytes ?? PLAYGROUND_MAX_BODY_BYTES;
+ return createMiddleware(async (c, next) => {
+ const block = isBlockedInPlayground(c.req.method, c.req.path);
+ if (block)
+ return c.json(
+ {
+ error: "playground",
+ block,
+ message: "This is not available in the playground. It works in your own install.",
+ },
+ 403,
+ );
+ const length = Number(c.req.header("content-length") ?? 0);
+ if (
+ length > max ||
+ (c.req.method !== "GET" && c.req.method !== "HEAD" && (await tooLarge(c.req.raw, max)))
+ )
+ return c.json(
+ { error: "payload_too_large", message: `request bodies are limited to ${max} bytes here` },
+ 413,
+ );
+ await next();
+ });
+}
+
+/** Without a content-length header, measure a clone of the body. */
+async function tooLarge(req: Request, max: number): Promise {
+ if (req.headers.has("content-length") || !req.body) return false;
+ const bytes = await req.clone().arrayBuffer();
+ return bytes.byteLength > max;
+}
diff --git a/packages/api/src/routes/instance.ts b/packages/api/src/routes/instance.ts
index 41f65e2a..d7b724b6 100644
--- a/packages/api/src/routes/instance.ts
+++ b/packages/api/src/routes/instance.ts
@@ -34,6 +34,7 @@ import {
} from "@cloudhal/schedules";
import { Hono } from "hono";
import type { Env } from "../context.ts";
+import type { PlaygroundInfo } from "../playground.ts";
export interface InstanceInfo {
signUp: "first-user" | "closed" | "open" | "unknown";
@@ -58,6 +59,8 @@ export interface InstanceInfo {
comments: CommentsFeature;
/** Schedules (RFC-0008 §13). */
schedules: SchedulesFeature;
+ /** RFC-0036 §5: set only in playground mode (the session's end, Reset and Deploy links). */
+ playground?: PlaygroundInfo;
};
/** The agent modes an API key can hold and what each defaults to (ADR-0015). */
agentModes: AgentModesInfo;
diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts
index f60537d4..f406d57a 100644
--- a/packages/api/src/scopes.ts
+++ b/packages/api/src/scopes.ts
@@ -312,7 +312,8 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement]
["POST", `${S}/migrations/tags-to-terms/revert`, "site:settings"],
];
-function matches(pattern: string, path: string): boolean {
+/** Whether `path` fits a `ROUTE_SCOPES` pattern (`:param` one segment, `*` the rest). */
+export function matchesRoute(pattern: string, path: string): boolean {
const p = pattern.split("/");
const s = path.replace(/\/+$/, "").split("/");
for (let i = 0; i < p.length; i++) {
@@ -335,12 +336,20 @@ const SPECIFICITY = [...ROUTE_SCOPES].sort((a, b) => {
return score(b[1]) - score(a[1]);
});
+/** The declared route a request to `method path` reaches, or undefined when there is none. */
+export function routeFor(
+ method: string,
+ path: string,
+): readonly [Method, string, RouteRequirement] | undefined {
+ const m = method === "HEAD" ? "GET" : method;
+ return SPECIFICITY.find(
+ ([rm, pattern]) => (rm === "ALL" || rm === m) && matchesRoute(pattern, path),
+ );
+}
+
/** What a request to `method path` requires, or undefined when no route is declared. */
export function requirementFor(method: string, path: string): RouteRequirement | undefined {
- const m = method === "HEAD" ? "GET" : method;
- for (const [rm, pattern, req] of SPECIFICITY)
- if ((rm === "ALL" || rm === m) && matches(pattern, path)) return req;
- return undefined;
+ return routeFor(method, path)?.[2];
}
/**
diff --git a/packages/api/test/playground.test.ts b/packages/api/test/playground.test.ts
new file mode 100644
index 00000000..d94d361d
--- /dev/null
+++ b/packages/api/test/playground.test.ts
@@ -0,0 +1,207 @@
+/**
+ * RFC-0036 §5: playground mode. Every declared route is either blocked or allowed (never both,
+ * never neither), allowed routes work for the playground principal, and the guard, the principal
+ * and `features.playground` hold end to end.
+ */
+import { holds } from "@cloudhal/agent-runtime";
+import { SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts";
+import { MemoryBlobStore } from "@cloudhal/storage";
+import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite";
+import { describe, expect, it } from "vitest";
+import { createApi } from "../src/app.ts";
+import {
+ isBlockedInPlayground,
+ PLAYGROUND_ALLOW,
+ PLAYGROUND_PRINCIPAL,
+ PLAYGROUND_SCOPES,
+ playgroundDenyRule,
+} from "../src/playground.ts";
+import { ROUTE_SCOPES } from "../src/scopes.ts";
+
+const key = (method: string, pattern: string) => `${method} ${pattern}`;
+
+describe("the playground route table", () => {
+ it("classifies every declared route exactly once", () => {
+ const unclassified: string[] = [];
+ const both: string[] = [];
+ for (const [method, pattern] of ROUTE_SCOPES) {
+ const denied = playgroundDenyRule(method, pattern) !== undefined;
+ const allowed = PLAYGROUND_ALLOW.has(key(method, pattern));
+ if (!denied && !allowed) unclassified.push(key(method, pattern));
+ if (denied && allowed) both.push(key(method, pattern));
+ }
+ expect(unclassified, "routes neither blocked nor allowed in a playground").toEqual([]);
+ expect(both, "routes both blocked and allowed").toEqual([]);
+ const declared = new Set(ROUTE_SCOPES.map(([m, p]) => key(m, p)));
+ expect(
+ [...PLAYGROUND_ALLOW].filter((r) => !declared.has(r)),
+ "stale allow entries",
+ ).toEqual([]);
+ });
+
+ it("allows only routes the playground principal can pass", () => {
+ for (const [method, pattern, req] of ROUTE_SCOPES) {
+ if (!PLAYGROUND_ALLOW.has(key(method, pattern))) continue;
+ const ok =
+ req === "public" ||
+ req === "signed-in" ||
+ req === "member" ||
+ (req !== "owner" && req !== "deploy-token" && holds(PLAYGROUND_SCOPES, req));
+ expect(ok, `${key(method, pattern)} requires ${req}`).toBe(true);
+ }
+ });
+
+ it("gives the principal nothing installation-wide and no uploads", () => {
+ for (const scope of [
+ "*",
+ "asset:upload",
+ "asset:manage",
+ "members:manage",
+ "members:invite",
+ "agents:manage",
+ "site:create",
+ ] as const)
+ expect(holds(PLAYGROUND_PRINCIPAL.scopes, scope), scope).toBe(false);
+ for (const scope of [
+ "site:settings",
+ "content:write",
+ "site:publish",
+ "site:rollback",
+ ] as const)
+ expect(holds(PLAYGROUND_PRINCIPAL.scopes, scope), scope).toBe(true);
+ });
+
+ it.each([
+ ["GET", "/api/auth/get-session", "auth"],
+ ["POST", "/api/password-reset/request", "auth"],
+ ["GET", "/api/members", "people"],
+ ["POST", "/api/invites", "people"],
+ ["POST", "/api/agent-keys", "keys"],
+ ["POST", "/mcp", "mcp"],
+ ["GET", "/mcp/", "mcp"],
+ ["POST", "/api/sites/s1/assets/uploads", "uploads"],
+ ["PUT", "/api/sites/s1/assets/uploads/u1", "uploads"],
+ ["DELETE", "/api/sites/s1/assets/a1", "uploads"],
+ ["POST", "/api/sites/s1/assets/a1/purge", "uploads"],
+ ["PUT", "/api/admin/sites/s1/bundle", "transfer"],
+ ["PUT", "/api/admin/sites/s1/domains", "domains"],
+ ["POST", "/api/sites", "sites"],
+ ["POST", "/api/sites/s1/preview-links", "outbound"],
+ ["GET", "/api/sites/s1/analytics/summary", "outbound"],
+ ["GET", "/api/sites/s1/comments", "outbound"],
+ ["POST", "/api/sites/s1/ai/commands", "agent"],
+ ["POST", "/api/sites/s1/ai/build/p1", "agent"],
+ ["GET", "/api/sites/s1/pages/p1/build/events", "agent"],
+ ["GET", "/api/sites/s1/approvals", "agent"],
+ ["POST", "/api/sites/s1/releases", "schedules"],
+ ["GET", "/api/scheduler", "schedules"],
+ ["GET", "/api/brand-new-route", "unclassified"],
+ ["GET", "/api/sites/s1/brand-new", "unclassified"],
+ ])("blocks %s %s (%s)", (method, path, block) => {
+ expect(isBlockedInPlayground(method, path)).toBe(block);
+ });
+
+ it.each([
+ ["GET", "/api/me"],
+ ["HEAD", "/api/instance"],
+ ["GET", "/api/sites/s1"],
+ ["PUT", "/api/sites/s1/entries/e1"],
+ ["POST", "/api/sites/s1/snapshots/sn1/publish"],
+ ["POST", "/api/sites/s1/menus/m1/ops"],
+ ["GET", "/api/sites/s1/assets/a1/content"],
+ ["GET", "/api/sites/s1/search/"],
+ // not the API's to judge: Studio and the rendered site
+ ["GET", "/studio/members"],
+ ["GET", "/s/01J0000000/blog/"],
+ ])("allows %s %s", (method, path) => {
+ expect(isBlockedInPlayground(method, path)).toBeUndefined();
+ });
+});
+
+describe("createApi({ playground })", () => {
+ const info = {
+ expiresAt: "2026-10-20T19:00:00.000Z",
+ resetUrl: "/_playground/reset",
+ deployUrl: "https://cloudhal.dev/docs/deploy",
+ };
+
+ async function setup() {
+ const sql = await BetterSqlite3Driver.open(":memory:");
+ const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore() });
+ await store.init();
+ const site = await seedStarterSite(store, { name: "Try", slug: "try" });
+ // A resolver that would grant everything: playground mode must ignore it.
+ const app = createApi({
+ store,
+ resolvePrincipal: async () => ({
+ actor: { kind: "human", id: "user_intruder1", name: "x" },
+ scopes: ["*"],
+ }),
+ playground: { info: () => info, maxBodyBytes: 10_000 },
+ });
+ return { store, site, app };
+ }
+
+ const json = { "content-type": "application/json" };
+
+ it("acts as the playground principal and reports features.playground", async () => {
+ const { app } = await setup();
+ const me = (await (await app.request("/api/me")).json()) as {
+ actor: { id: string };
+ role: string;
+ scopes: string[];
+ };
+ expect(me.actor.id).toBe("user_playground");
+ expect(me.role).toBe("editor");
+ expect(me.scopes).not.toContain("*");
+ const instance = (await (await app.request("/api/instance")).json()) as {
+ features: { playground?: unknown; members: boolean; mcp: boolean };
+ };
+ expect(instance.features.playground).toEqual(info);
+ expect(instance.features.members).toBe(false);
+ expect(instance.features.mcp).toBe(false);
+ });
+
+ it("edits, snapshots and publishes", async () => {
+ const { app, site, store } = await setup();
+ expect((await app.request(`/api/sites/${site.id}`)).status).toBe(200);
+ const res = await app.request(`/api/sites/${site.id}/snapshots`, {
+ method: "POST",
+ headers: json,
+ body: JSON.stringify({ publish: true }),
+ });
+ expect(res.status).toBeLessThan(300);
+ expect((await store.getSite(site.id))?.publishedSnapshotId).toBeDefined();
+ });
+
+ it("refuses blocked and unclassified routes with 403 playground", async () => {
+ const { app, site } = await setup();
+ for (const [method, path] of [
+ ["POST", `/api/sites/${site.id}/assets/uploads`],
+ ["POST", "/api/sites"],
+ ["GET", "/api/members"],
+ ["POST", "/api/agent-keys"],
+ ["GET", "/api/auth/get-session"],
+ ["POST", "/mcp"],
+ ["GET", "/api/never-declared"],
+ ] as const) {
+ const res = await app.request(path, {
+ method,
+ headers: json,
+ ...(method === "GET" ? {} : { body: "{}" }),
+ });
+ expect(res.status, `${method} ${path}`).toBe(403);
+ expect(((await res.json()) as { error: string }).error).toBe("playground");
+ }
+ });
+
+ it("refuses bodies over the limit", async () => {
+ const { app, site } = await setup();
+ const res = await app.request(`/api/sites/${site.id}/entries`, {
+ method: "POST",
+ headers: json,
+ body: JSON.stringify({ title: "x".repeat(20_000) }),
+ });
+ expect(res.status).toBe(413);
+ });
+});
diff --git a/packages/ui/src/blocks/PlaygroundBar.tsx b/packages/ui/src/blocks/PlaygroundBar.tsx
new file mode 100644
index 00000000..a7aeade8
--- /dev/null
+++ b/packages/ui/src/blocks/PlaygroundBar.tsx
@@ -0,0 +1,69 @@
+import { Banner, Button, LinkButton } from "@cloudflare/kumo";
+import { ArrowCounterClockwise, CloudArrowUp, Timer } from "@phosphor-icons/react";
+
+export interface PlaygroundBarLabels {
+ /** "Playground" */
+ title: string;
+ /** Time left and the privacy note, already formatted ("41 minutes left. Everything …"). */
+ description: string;
+ /** "Reset" */
+ reset: string;
+ /** "Deploy to Cloudflare" */
+ deploy: string;
+}
+
+export interface PlaygroundBarProps {
+ labels: PlaygroundBarLabels;
+ /** Where "Deploy to Cloudflare" leads (`features.playground.deployUrl`). */
+ deployHref: string;
+ onReset: () => void;
+ resetting?: boolean;
+ className?: string;
+}
+
+/**
+ * The playground session bar (RFC-0036 §5, DESIGN.md §2af): a compact secondary `Banner` under the
+ * top bar with the time left, Reset and "Deploy to Cloudflare". Shown only when `/api/instance`
+ * reports `features.playground`.
+ */
+export function PlaygroundBar({
+ labels,
+ deployHref,
+ onReset,
+ resetting = false,
+ className,
+}: PlaygroundBarProps) {
+ return (
+ }
+ title={labels.title}
+ description={labels.description}
+ className={className}
+ data-testid="playground-bar"
+ action={
+ <>
+ }
+ loading={resetting}
+ onClick={onReset}
+ >
+ {labels.reset}
+
+ }
+ href={deployHref}
+ external
+ >
+ {labels.deploy}
+
+ >
+ }
+ />
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 1ca81254..ca26db3d 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -327,6 +327,11 @@ export {
usePageScrollRoot,
usePageScrollRootRef,
} from "./blocks/PageLayout.tsx";
+export {
+ PlaygroundBar,
+ type PlaygroundBarLabels,
+ type PlaygroundBarProps,
+} from "./blocks/PlaygroundBar.tsx";
export {
morphPreview,
PREVIEW_DEVICE_WIDTH,