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={ + <> + + } + 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,