diff --git a/Makefile b/Makefile index 97209a8a..027001b0 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ run: # Seed the local SQLite database and start GoModel with a populated dashboard. demo: seed-demo-data - $(MAKE) run + $(MAKE) run GOMODEL_DEMO_MODE=true # Clean build artifacts clean: diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index 506f7dbe..58c9d9b8 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -5828,6 +5828,9 @@ const docTemplate = `{ "DASHBOARD_LIVE_LOGS_ENABLED": { "type": "string" }, + "DEMO_MODE": { + "type": "string" + }, "FAILOVER_ENABLED": { "type": "string" }, diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 151df479..176d8abd 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -43,9 +43,16 @@ The most common way to configure GoModel. Set any of the variables below to over | `PORT` | HTTP server port | `8080` | | `BASE_PATH` | Mount path prefix, for example `/g` | `/` | | `GOMODEL_MASTER_KEY` | Authentication key for securing the gateway | _(empty, unsafe mode)_ | +| `GOMODEL_DEMO_MODE` | Enable public demo warnings in logs and the dashboard | `false` | | `BODY_SIZE_LIMIT` | Max request body size (e.g., `10M`, `1024K`, `500KB`) | _(no limit)_ | | `USER_PATH_HEADER` | Header used to read/write request `user_path` values | `X-GoModel-User-Path` | +Set `GOMODEL_DEMO_MODE=true` for a public or shared demonstration instance. +GoModel logs a warning at startup and every five minutes, renders a persistent +warning at the top of the dashboard, and exposes `DEMO_MODE=on` through the +allowlisted `/admin/runtime/config` response. Demo mode does not reset storage; +schedule that separately in the deployment. + #### MCP Gateway See [MCP Gateway](/features/mcp-gateway) for the full feature guide. diff --git a/docs/openapi.json b/docs/openapi.json index d97aadec..59c9fdf3 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -8647,6 +8647,9 @@ "DASHBOARD_LIVE_LOGS_ENABLED": { "type": "string" }, + "DEMO_MODE": { + "type": "string" + }, "FAILOVER_ENABLED": { "type": "string" }, diff --git a/internal/admin/dashboard/dashboard.go b/internal/admin/dashboard/dashboard.go index 9f0d85eb..c26268c4 100644 --- a/internal/admin/dashboard/dashboard.go +++ b/internal/admin/dashboard/dashboard.go @@ -26,11 +26,18 @@ type Handler struct { indexTmpl *template.Template staticFS http.Handler basePath string + demoMode bool } // NewWithBasePath creates a dashboard handler for an app mounted under basePath. // It parses templates and sets up the static file server. func NewWithBasePath(basePath string) (*Handler, error) { + return NewWithDemoMode(basePath, false) +} + +// NewWithDemoMode creates a dashboard handler and controls whether the demo +// warning is rendered in the main content area. +func NewWithDemoMode(basePath string, demoMode bool) (*Handler, error) { basePath = config.NormalizeBasePath(basePath) assetVersions, err := buildFrontendAssetVersions() if err != nil { @@ -58,18 +65,20 @@ func NewWithBasePath(basePath string) (*Handler, error) { indexTmpl: tmpl, staticFS: http.StripPrefix("/admin/static/", http.FileServer(http.FS(staticSub))), basePath: basePath, + demoMode: demoMode, }, nil } type templateData struct { BasePath string Version string + DemoMode bool } // Index serves GET /admin/dashboard — the main dashboard page. func (h *Handler) Index(c *echo.Context) error { var buf bytes.Buffer - if err := h.indexTmpl.ExecuteTemplate(&buf, "layout", templateData{BasePath: h.basePath, Version: version.Info()}); err != nil { + if err := h.indexTmpl.ExecuteTemplate(&buf, "layout", templateData{BasePath: h.basePath, Version: version.Info(), DemoMode: h.demoMode}); err != nil { slog.Error("failed to render admin dashboard", "path", c.Request().URL.Path, "error", err) return err } diff --git a/internal/admin/dashboard/dashboard_test.go b/internal/admin/dashboard/dashboard_test.go index 710d00ec..e768dae8 100644 --- a/internal/admin/dashboard/dashboard_test.go +++ b/internal/admin/dashboard/dashboard_test.go @@ -77,6 +77,49 @@ func TestIndex_ReturnsHTML(t *testing.T) { } } +func TestIndex_DemoModeShowsWarning(t *testing.T) { + h, err := NewWithDemoMode("/", true) + if err != nil { + t.Fatalf("NewWithDemoMode() returned error: %v", err) + } + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := h.Index(c); err != nil { + t.Fatalf("Index() returned error: %v", err) + } + + body := rec.Body.String() + if !strings.Contains(body, `class="demo-mode-banner"`) { + t.Error("expected demo mode banner in page HTML") + } + if !strings.Contains(body, "Do not enter sensitive or personal data. Demo data is reset regularly.") { + t.Error("expected demo mode data warning in page HTML") + } +} + +func TestIndex_StandardModeHidesDemoWarning(t *testing.T) { + h, err := NewWithBasePath("/") + if err != nil { + t.Fatalf("NewWithBasePath() returned error: %v", err) + } + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := h.Index(c); err != nil { + t.Fatalf("Index() returned error: %v", err) + } + if strings.Contains(rec.Body.String(), `class="demo-mode-banner"`) { + t.Error("did not expect demo mode banner in standard mode") + } +} + func TestIndex_UsesBasePathForGeneratedURLs(t *testing.T) { h, err := NewWithBasePath("g/") if err != nil { diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index b428ac08..68a7ec55 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -667,6 +667,45 @@ body.dashboard-modal-open { transition: width 0.2s; } +.demo-mode-banner { + position: sticky; + top: 16px; + z-index: 8; + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; + padding: 12px 16px; + border: 1px solid color-mix(in srgb, var(--warning) 55%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--warning) 14%, var(--bg-surface)); + color: var(--text); + box-shadow: 0 8px 24px color-mix(in srgb, var(--bg) 70%, transparent); +} + +.demo-mode-banner-icon { + width: 20px; + height: 20px; + flex: 0 0 20px; + color: var(--warning); +} + +.demo-mode-banner div { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + font-size: 13px; +} + +.demo-mode-banner strong { + flex-shrink: 0; + color: var(--warning); + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + .page-header { display: flex; align-items: center; @@ -5353,6 +5392,14 @@ body.conversation-drawer-open { margin: 0 auto; padding: 20px; } + .demo-mode-banner { + top: 10px; + align-items: flex-start; + } + .demo-mode-banner div { + display: grid; + gap: 2px; + } .auth-dialog-shell { align-items: end; padding: 12px; diff --git a/internal/admin/dashboard/static/js/modules/workflows.js b/internal/admin/dashboard/static/js/modules/workflows.js index b6ca5498..81f60f51 100644 --- a/internal/admin/dashboard/static/js/modules/workflows.js +++ b/internal/admin/dashboard/static/js/modules/workflows.js @@ -83,6 +83,7 @@ workflowRuntimeConfigKeys() { return [ + 'DEMO_MODE', 'FAILOVER_ENABLED', 'LOGGING_ENABLED', 'LOGGING_RETENTION_DAYS', diff --git a/internal/admin/dashboard/static/js/modules/workflows.test.cjs b/internal/admin/dashboard/static/js/modules/workflows.test.cjs index b1bb999d..d213741c 100644 --- a/internal/admin/dashboard/static/js/modules/workflows.test.cjs +++ b/internal/admin/dashboard/static/js/modules/workflows.test.cjs @@ -1284,6 +1284,7 @@ test('fetchWorkflowRuntimeConfig loads FAILOVER_ENABLED from the admin config en return Promise.resolve({ ok: true, json: async () => ({ + DEMO_MODE: 'on', FAILOVER_ENABLED: 'on', LOGGING_ENABLED: 'on', LOGGING_RETENTION_DAYS: 30, @@ -1307,6 +1308,7 @@ test('fetchWorkflowRuntimeConfig loads FAILOVER_ENABLED from the admin config en assert.equal( JSON.stringify(module.workflowRuntimeConfig), JSON.stringify({ + DEMO_MODE: 'on', FAILOVER_ENABLED: 'on', LOGGING_ENABLED: 'on', LOGGING_RETENTION_DAYS: '30', diff --git a/internal/admin/dashboard/templates/layout.html b/internal/admin/dashboard/templates/layout.html index 820e0c83..66304f66 100644 --- a/internal/admin/dashboard/templates/layout.html +++ b/internal/admin/dashboard/templates/layout.html @@ -3,6 +3,7 @@ + GoModel Dashboard @@ -76,6 +77,15 @@
{{template "sidebar" .}}
+ {{if .DemoMode}} + + {{end}} {{template "index" .}}
GoModel Dashboard", "dashboard HTML should carry the expected ") + assert.Contains(t, html, `<meta name="robots" content="noindex, nofollow, nosnippet, noimageindex">`, + "dashboard HTML should prevent search engine indexing") assert.Contains(t, html, "css/dashboard.css", "dashboard HTML should reference its stylesheet bundle") assert.Contains(t, html, "js/dashboard.js",