Feat default surface routing (#4)
Some checks failed
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Failing after 2m17s
CI/CD / test-sqlite (push) Successful in 2m44s
CI/CD / build-and-deploy (push) Has been skipped

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
2026-03-26 19:56:14 +00:00
committed by xcaliber
parent a36be83aaf
commit f4512071db
9 changed files with 169 additions and 40 deletions

View File

@@ -2,7 +2,35 @@
All notable changes to Switchboard Core are documented here.
## [Unreleased] — v0.2.0
## [Unreleased] — v0.2.1
### Added
- **Default surface routing**: `/` redirects to configurable default surface.
Fallback chain: configured default → first enabled extension surface → `/admin`.
First installed extension surface auto-becomes default. Admin can change via
Settings > Default Surface dropdown.
- `default_surface` global config key (JSON `{"id": "slug"}`)
- Admin settings UI: Default Surface dropdown (extension surfaces only)
### Changed
- `disabledRedirect()` now redirects to `/admin` instead of `/` to prevent
redirect loops when the default surface is disabled
- Disabled extension surfaces (`/s/:slug`) redirect to `/admin` instead of `/`
### Fixed
- **Gin route param conflict** causing backend startup hang: team-scoped
package settings routes used `:pkgId` while sibling routes used `:id`.
Gin's radix tree entered an infinite loop on the conflicting param names.
Unified to `:id` across all `/teams/:teamId/packages/` routes.
- Docker entrypoint: increased health check timeout (10s → 60s), added stale
process cleanup and crash detection to prevent zombie backends on restart
---
## [v0.2.0] — 2026-03-26
### Added

View File

@@ -48,7 +48,7 @@ SDK stabilization, and the first rebuilt extension (tasks).
| Step | Status | Description |
|------|--------|-------------|
| Default surface routing | 🔲 | `/` redirects to configurable default surface. No surfaces → admin. First install becomes default. Changeable in admin settings. |
| Default surface routing | | `/` redirects to configurable default surface. No surfaces → admin. First install becomes default. Changeable in admin settings. |
| ICD (API contract) | 🔲 | OpenAPI spec from registered routes, kernel-only endpoint docs |
### v0.2.2 — Event Bus + Triggers
@@ -65,42 +65,35 @@ SDK stabilization, and the first rebuilt extension (tasks).
| SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI |
| Task extension | 🔲 | First proof-of-concept — full task system rebuilt as Starlark extension using triggers + ext_data + notifications |
## v0.3.0 — Editor Surface
## v0.3.0 — Notes Surface
The code/markdown editor rebuilt as an installable surface package.
Obsidian-style rich-text notes rebuilt as an installable surface package.
Zero platform special-casing. Proves the full extension stack E2E.
- Editor as `.pkg` archive
- CM6 integration via surface viewport
- File tree, tabs, preview pane — all extension-provided
- Notes as `.pkg` archive
- Rich text editor (ProseMirror or similar)
- Folder tree, backlinks, tags — all extension-provided
- Markdown import/export
## v0.4.0 — Chat Extension
The AI chat system rebuilt as an installable extension package.
- Provider registry as extension (BYOK chain, model catalog)
- Completion streaming via Starlark `provider.complete`
- Personas as extension data
- Channel/message storage via ext_data tables
- Tool system as extension hooks
## v0.5.0 — MVP
## v0.4.0 — MVP
Extension and operations tracks converge. First externally usable release.
- Package registry (browse, install, update, uninstall)
- Extension marketplace foundations
- Package distribution model (no auto-install; explicit install only)
- Health monitoring dashboard
- Backup/restore tooling
- Documentation site
## Post-MVP
- Chat extension (provider registry, streaming, personas, tool system)
- Rich media extensions: image generation, code sandbox, STT/TTS
- Desktop app (Tauri or Electron)
- Sidecar tier: container-based extensions
- Federation: cross-instance package sharing
- Plugin marketplace with signing and review
- Mermaid diagrams extension (nice-to-have)
## Design Decisions Log
@@ -116,3 +109,6 @@ Extension and operations tracks converge. First externally usable release.
| Admin → RBAC group | The `role` column is pre-RBAC. v0.2.0 replaces it with a seeded "Admins" group + `surface.admin.access` grant. All users auto-join "Everyone" group. Admin middleware becomes a grant check, not a role check. |
| Settings cascade | RBAC controls scope auth (who can set at what level). `user_overridable` flag controls whether lower scopes can override higher. Two orthogonal axes, composes cleanly with extension manifests. |
| No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. |
| Notes over Editor | First surface is Obsidian-style notes (rich text, folders, backlinks) instead of a code editor. Notes is a stronger E2E proof — it exercises ext_data, storage, and the SDK more fully than a pure-browser CM6 editor. |
| No built-in auto-install | Extensions ship in the repo but are not auto-installed. Distribution model TBD — explicit install only. Keeps the kernel clean and avoids opinionated defaults. |
| Chat → post-MVP | Chat extension (providers, streaming, personas) is valuable but not MVP-critical. The platform must prove itself with simpler surfaces first. Chat moves to post-MVP track. |

View File

@@ -22,19 +22,29 @@ fi
echo "🔀 Starting Switchboard Core backend..."
# Kill any stale backend from a previous entrypoint run
pkill -f /usr/local/bin/switchboard 2>/dev/null || true
sleep 0.2
# Launch Go backend in background (from /app so migrations are found)
cd /app
/usr/local/bin/switchboard &
BACKEND_PID=$!
# Wait for backend to be ready (max 10s)
for i in $(seq 1 20); do
# Wait for backend to be ready (max 60s)
for i in $(seq 1 120); do
if wget -q --spider http://localhost:${PORT:-8080}${BASE_PATH}/health 2>/dev/null; then
echo "✅ Backend ready (PID ${BACKEND_PID})"
exit 0
fi
# Check backend didn't crash
if ! kill -0 ${BACKEND_PID} 2>/dev/null; then
echo "❌ Backend process died"
exit 1
fi
sleep 0.5
done
echo "❌ Backend failed to start within 10s"
echo "❌ Backend failed to start within 60s"
kill ${BACKEND_PID} 2>/dev/null || true
exit 1

View File

@@ -553,6 +553,18 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
}
// v0.2.1: Auto-set default_surface when the first extension surface is installed.
if (pkgType == "surface" || pkgType == "full") && pkgSource != "core" {
if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
dflt := models.JSONMap{"id": pkgID}
if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
} else {
log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
}
}
}
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,

View File

@@ -26,10 +26,10 @@ func NewTeamPackageSettingsHandler(s store.Stores) *TeamPackageSettingsHandler {
// GetTeamPackageSettings returns the team-scoped settings for a package,
// along with the overridable schema keys for the UI.
// GET /api/v1/teams/:teamId/packages/:pkgId/settings
// GET /api/v1/teams/:teamId/packages/:id/settings
func (h *TeamPackageSettingsHandler) GetTeamPackageSettings(c *gin.Context) {
teamID := c.Param("teamId")
pkgID := c.Param("pkgId")
pkgID := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
@@ -60,10 +60,10 @@ func (h *TeamPackageSettingsHandler) GetTeamPackageSettings(c *gin.Context) {
// UpdateTeamPackageSettings saves team-scoped overrides for a package.
// Non-overridable keys are stripped before saving.
// PUT /api/v1/teams/:teamId/packages/:pkgId/settings
// PUT /api/v1/teams/:teamId/packages/:id/settings
func (h *TeamPackageSettingsHandler) UpdateTeamPackageSettings(c *gin.Context) {
teamID := c.Param("teamId")
pkgID := c.Param("pkgId")
pkgID := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
@@ -108,10 +108,10 @@ func (h *TeamPackageSettingsHandler) UpdateTeamPackageSettings(c *gin.Context) {
}
// DeleteTeamPackageSettings removes team-scoped overrides for a package.
// DELETE /api/v1/teams/:teamId/packages/:pkgId/settings
// DELETE /api/v1/teams/:teamId/packages/:id/settings
func (h *TeamPackageSettingsHandler) DeleteTeamPackageSettings(c *gin.Context) {
teamID := c.Param("teamId")
pkgID := c.Param("pkgId")
pkgID := c.Param("id")
if err := h.stores.Packages.DeleteTeamSettings(c.Request.Context(), pkgID, teamID); err != nil {
c.JSON(500, gin.H{"error": "failed to delete team settings"})

View File

@@ -478,9 +478,9 @@ func main() {
// Team package settings — cascade overrides (v0.2.0)
teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores)
teamScoped.GET("/packages/:pkgId/settings", teamPkgSettingsH.GetTeamPackageSettings)
teamScoped.PUT("/packages/:pkgId/settings", teamPkgSettingsH.UpdateTeamPackageSettings)
teamScoped.DELETE("/packages/:pkgId/settings", teamPkgSettingsH.DeleteTeamPackageSettings)
teamScoped.GET("/packages/:id/settings", teamPkgSettingsH.GetTeamPackageSettings)
teamScoped.PUT("/packages/:id/settings", teamPkgSettingsH.UpdateTeamPackageSettings)
teamScoped.DELETE("/packages/:id/settings", teamPkgSettingsH.DeleteTeamPackageSettings)
// Team audit log (team admins only — RequireTeamAdmin on group)
teamScoped.GET("/audit", teams.ListTeamAuditLog)
@@ -683,10 +683,8 @@ func main() {
pages.SetVersion(Version)
pageEngine := pages.New(cfg, stores)
// Root redirect → default surface (admin for now, configurable in v0.2.0)
base.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, cfg.BasePath+"/admin")
})
// Root redirect → configurable default surface (v0.2.1)
base.GET("/", pageEngine.DefaultSurfaceRedirect())
// Login page — no auth required
base.GET("/login", pageEngine.RenderLogin())

View File

@@ -391,7 +391,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
return
}
if !sr.Enabled {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
return
}
if sr.Source == "core" {

View File

@@ -90,9 +90,77 @@ func (e *Engine) EnabledSurfaceIDs() []string {
return ids
}
// disabledRedirect returns a handler that redirects to the root.
// DefaultSurfaceRedirect returns a handler for GET / that redirects to the
// configured default surface, falling back to the first enabled extension
// surface, then /admin.
func (e *Engine) DefaultSurfaceRedirect() gin.HandlerFunc {
return func(c *gin.Context) {
target := e.resolveDefaultSurface(c.Request.Context())
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+target)
}
}
// resolveDefaultSurface returns the path to redirect to (without BasePath).
// Priority: configured default_surface → first enabled extension surface → /admin.
func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
if e.stores.GlobalConfig == nil || e.stores.Packages == nil {
return "/admin"
}
// 1. Check configured default_surface
if raw, err := e.stores.GlobalConfig.Get(ctx, "default_surface"); err == nil && raw != nil {
if id, ok := raw["id"].(string); ok && id != "" {
if path := e.surfacePath(ctx, id); path != "" {
return path
}
// Configured surface is missing or disabled — fall through
}
}
// 2. First enabled extension surface
surfaces, err := e.stores.Packages.ListEnabledByType(ctx, "surface")
if err == nil {
for _, s := range surfaces {
if s.Source == "core" {
continue
}
if s.Enabled {
return "/s/" + s.ID
}
}
}
// 3. Fallback
return "/admin"
}
// surfacePath returns the URL path for a surface ID, or "" if the surface
// doesn't exist or is disabled.
func (e *Engine) surfacePath(ctx context.Context, id string) string {
pkg, err := e.stores.Packages.Get(ctx, id)
if err != nil || pkg == nil || !pkg.Enabled {
return ""
}
// Core surfaces live at /{id}, extension surfaces at /s/{slug}
if pkg.Source == "core" {
// Look up the core surface's base route
if sm := e.GetSurface(id); sm != nil {
// Use the first alt route (without params) or strip params from Route
if len(sm.AltRoutes) > 0 {
return sm.AltRoutes[0]
}
}
return "/" + id
}
return "/s/" + id
}
// disabledRedirect returns a handler that redirects to /admin.
// Uses /admin (not /) to avoid a redirect loop when the default surface
// is the one being disabled.
func (e *Engine) disabledRedirect() gin.HandlerFunc {
return func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
}
}

View File

@@ -7,21 +7,25 @@ const { useState, useEffect, useCallback } = hooks;
export default function SettingsSection() {
const [cfg, setCfg] = useState({});
const [models, setModels] = useState([]);
const [surfaces, setSurfaces] = useState([]);
const [vault, setVault] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const [s, m, v] = await Promise.all([
const [s, m, v, surfList] = await Promise.all([
sw.api.admin.settings.get(),
sw.api.admin.models.list().catch(() => []),
sw.api.admin.vault.status().catch(() => null),
sw.api.admin.surfaces.list().catch(() => []),
]);
const raw = s || {};
const pol = raw.policies || {};
const cfg_ = raw.settings || {};
setSurfaces((surfList || []).filter(s => s.enabled));
setCfg({
default_surface: cfg_.default_surface?.id || '',
allow_registration: pol.allow_registration === 'true',
registration_default_state: cfg_.registration_default_state || 'pending',
system_prompt: cfg_.system_prompt || '',
@@ -62,6 +66,9 @@ export default function SettingsSection() {
async function save() {
setSaving(true);
try {
// Default surface
await sw.api.admin.settings.update('default_surface', { value: cfg.default_surface ? { id: cfg.default_surface } : null });
// Policies
await sw.api.admin.settings.update('allow_registration', { value: String(cfg.allow_registration) });
await sw.api.admin.settings.update('registration_default_state', { value: cfg.registration_default_state });
@@ -128,6 +135,16 @@ export default function SettingsSection() {
return html`
<div class="admin-settings-form">
<div class="settings-section"><h3>Default Surface</h3>
<div class="form-group"><label>Landing page for /</label>
<select value=${cfg.default_surface} onChange=${e => set('default_surface', e.target.value)}>
<option value="">-- Auto (first extension surface, then Admin) --</option>
${surfaces.filter(s => s.source !== 'core').map(s => html`<option key=${s.id} value=${s.id}>${s.title || s.id}</option>`)}
</select>
</div>
<span class="text-muted" style="font-size:12px;">Where users land when visiting /. Only extension surfaces are listed.</span>
</div>
<div class="settings-section"><h3>Registration</h3>
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_registration} onChange=${e => set('allow_registration', e.target.checked)} /><span class="toggle-track"></span><span>Allow new user registration</span></label>
<div class="form-group" style="margin-top:8px;"><label>Default state</label>