From 8ead2fa6bb7e25382fc07e031f5db3da2b331d1e Mon Sep 17 00:00:00 2001 From: kasunben Date: Sun, 14 Jun 2026 08:51:28 +0200 Subject: [PATCH 1/4] feat(launcher): add Launcher home-screen plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds plugins/launcher/ — the platform home screen (LCH-01–05) that serves /launcher and, as the default root_plugin_id, now backs /. This makes the Task 0.4.04 root redirect resolve to a real plugin for the first time (/ → /launcher). Because the SDK boundary rule forbids a plugin importing the registry, the Launcher reads its tiles from a new session-gated runtime route GET /api/plugins (forwarding the caller's cookie; middleware injects x-sovereign-user-role). The route role-filters via a pure selectLauncherPlugins() in runtime/src/launcher-plugins.ts: excludes the three chrome plugins (CHROME_PLUGIN_IDS), excludes disabled plugins, and hides adminOnly plugins from non-admins. The page renders a main grid, an admin-only "Admin" section, and an empty state. The sidebar middle section now also excludes chrome plugins (PLT-12); full root-plugin-first ordering (PLT-11–15) stays separate. Notes: plugin components live under app/_components/ since the generate script composes only each plugin's app/ tree; tiles use a monogram pending an icon-serving pipeline (launcher.md Q3). Verified live with a real session: / → 307 → /launcher, /launcher → 200 (empty state), /api/plugins → {plugins:[]} (only chrome installed), and unauthenticated gating (307). selectLauncherPlugins has 7 unit tests. runtime → 0.3.0; new plugins/launcher at 0.1.0. Co-Authored-By: Claude Code --- CLAUDE.md | 15 ++- docs/plugins/launcher.md | 9 +- docs/sovereign-implementation-tasks.md | 2 + .../launcher/app/_components/PluginGrid.tsx | 15 +++ .../launcher/app/_components/PluginTile.tsx | 32 +++++ plugins/launcher/app/launcher.module.css | 114 ++++++++++++++++++ plugins/launcher/app/page.tsx | 78 ++++++++++++ plugins/launcher/icon.svg | 17 +++ plugins/launcher/manifest.json | 16 +++ plugins/launcher/package.json | 19 +++ pnpm-lock.yaml | 31 +++++ runtime/app/(platform)/layout.tsx | 7 +- runtime/app/api/plugins/route.ts | 29 +++++ runtime/generated/registry.ts | 19 +++ runtime/package.json | 2 +- runtime/src/launcher-plugins.test.ts | 54 +++++++++ runtime/src/launcher-plugins.ts | 53 ++++++++ 17 files changed, 504 insertions(+), 8 deletions(-) create mode 100644 plugins/launcher/app/_components/PluginGrid.tsx create mode 100644 plugins/launcher/app/_components/PluginTile.tsx create mode 100644 plugins/launcher/app/launcher.module.css create mode 100644 plugins/launcher/app/page.tsx create mode 100644 plugins/launcher/icon.svg create mode 100644 plugins/launcher/manifest.json create mode 100644 plugins/launcher/package.json create mode 100644 runtime/app/api/plugins/route.ts create mode 100644 runtime/src/launcher-plugins.test.ts create mode 100644 runtime/src/launcher-plugins.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7804929..8e95a4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,6 +170,17 @@ pnpm lint:fix # run ESLint with auto-fix CREATE-TABLE-IF-NOT-EXISTS + seed rows in `packages/db`'s `getPlatformDb()` until drizzle-kit migrations land in Task 0.5.03; the DDL there must stay in sync with the Drizzle schema. +- **Chrome plugins** (`fs.sovereign.launcher`, `fs.sovereign.account`, + `fs.sovereign.console`) are reached through the sidebar chrome (home `/`, + Console ⚙, Account avatar), never via the Launcher grid or the sidebar's + middle plugin-icon section (LCH-04, PLT-12). The canonical ID set is + `CHROME_PLUGIN_IDS` in `runtime/src/launcher-plugins.ts` — reuse it, never + re-hardcode the list. +- **Plugins that need the installed-plugin list fetch the gated `/api/plugins`** + (forwarding the session cookie), not import the registry — the SDK boundary + rule forbids plugins importing `runtime/src` or internal packages. The route + is session-gated (middleware injects `x-sovereign-user-role`) and role-filters + via `selectLauncherPlugins`; `sdk.db` replaces this fetch in Task 0.5.05. ## Design system (`packages/ui`) @@ -410,8 +421,8 @@ pnpm install:plugins # clone declared sovereign/community plugins (stub until - ✅ Task 0.4.01 — Console plugin scaffold (plugin route-composition model + middleware admin gating; platform → 0.4.0) (merged to `main`). - ✅ Task 0.4.02 — Console: user management (user list with invited/active/deactivated status, invite flow, role change, deactivate/reactivate; `sdk.auth` + `sdk.mailer` wired) (merged to `main`). - ✅ Task 0.4.03 — Console: plugin management (installed plugin list, enable/disable toggle, middleware 404 for disabled routes; platform DB singleton + `plugin_status` table) (merged to `main`). -- ▶️ In review: Task 0.4.04 — Console: tenant settings, system health, root plugin config (`platform_settings` + `tenants` seeded in platform DB; `sdk.platform.getConfig()` wired; invite-only toggle dual-written to auth server; `/` redirects to the configured root plugin). -- ⏳ Spec complete: Launcher platform plugin (`docs/plugins/launcher.md`) — Task 0.4.05. +- ✅ Task 0.4.04 — Console: tenant settings, system health, root plugin config (`platform_settings` + `tenants` seeded in platform DB; `sdk.platform.getConfig()` wired; invite-only toggle dual-written to auth server; `/` redirects to the configured root plugin) (merged to `main`). +- ▶️ In review: Task 0.4.05 — Launcher plugin (`plugins/launcher/` home grid; gated `/api/plugins` + `selectLauncherPlugins` helper; chrome plugins excluded from grid and sidebar middle section; `/` now resolves to `/launcher`). - ⏳ Spec complete: Account platform plugin (`docs/plugins/account.md`) — Task 0.4.06. - ⏳ Spec complete: Shell sidebar three-section architecture (PLT-11–PLT-15, SRS updated). - ⏳ Spec complete: Plainwrite sovereign plugin (`docs/plugins/plainwrite.md`, v0.2 — provider + SSG adapters). diff --git a/docs/plugins/launcher.md b/docs/plugins/launcher.md index bf23ac0..30c556b 100644 --- a/docs/plugins/launcher.md +++ b/docs/plugins/launcher.md @@ -4,7 +4,7 @@ **Date:** June 2026\ **Author:** kasunben\ **Purpose:** Canonical specification for the Sovereign Launcher plugin — the single source of truth for its manifest, access model, functional requirements, and build plan.\ -**Status:** Draft +**Status:** v0.1 implemented (Task 0.4.05) --- @@ -245,6 +245,7 @@ Multi-project tile expansion, search, notification badges. ## Changelog -| Version | Date | Change | -| ------- | -------- | ------------------------------------------ | -| 0.1 | Jun 2026 | Initial draft — platform home screen spec. | +| Version | Date | Change | +| ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0.1 | Jun 2026 | Initial draft — platform home screen spec. | +| 0.1 | Jun 2026 | v0.1 implemented (Task 0.4.05). Deviations from this spec: components live under `app/_components/` (not a sibling `components/`) since composition copies only `app/`; registry is read via the gated `/api/plugins` route (forwarding the session cookie) rather than `sdk.db`, which lands in 0.5.05; tiles use monograms pending an icon-serving pipeline (Q3). | diff --git a/docs/sovereign-implementation-tasks.md b/docs/sovereign-implementation-tasks.md index cca7c11..fd6c43a 100644 --- a/docs/sovereign-implementation-tasks.md +++ b/docs/sovereign-implementation-tasks.md @@ -953,6 +953,8 @@ consistent info/success/warn/error formatting. CLI is monorepo-internal in v1 --- +_Version 1.7 — June 2026. Changes from v1.6 (Task 0.4.05, Launcher plugin): the first non-Console platform plugin ships in `plugins/launcher/` — a home grid (LCH-01–05) that serves `/launcher` and, as the default `root_plugin_id`, now backs `/` (so the 0.4.04 root redirect resolves to a real plugin for the first time). Because the SDK boundary rule forbids a plugin importing the registry, the Launcher reads its tile list from a new **session-gated** `runtime/app/api/plugins/route.ts` (not under the `/api/admin` exclusion — middleware injects `x-sovereign-user-role`), forwarding the caller's cookie; the route role-filters via a pure `selectLauncherPlugins(plugins, disabledIds, role)` in `runtime/src/launcher-plugins.ts` (excludes the three **chrome** plugins `fs.sovereign.{launcher,account,console}` via the canonical `CHROME_PLUGIN_IDS` set, excludes disabled plugins, and hides `adminOnly` plugins from non-admins). The Launcher page renders a main grid plus an admin-only "Admin" section and an empty state (Console link for admins, contact-admin note otherwise). The sidebar middle section now also excludes chrome plugins (PLT-12); full root-plugin-first ordering (PLT-11–15) remains separate. Plugin components live under `app/_components/` (a private App Router folder) because the generate script composes only each plugin's `app/` tree. Tiles use a two-letter monogram — no icon-serving pipeline yet (launcher.md Q3). Verified live with a real session: `/` → 307 → `/launcher`, `/launcher` → 200 empty state, `/api/plugins` → `{plugins:[]}` (only chrome installed), and unauthenticated gating (307). `selectLauncherPlugins` has 7 unit tests. `runtime` → 0.3.0; new `plugins/launcher` at 0.1.0. Earlier notes retained._ + _Version 1.6 — June 2026. Changes from v1.5 (Task 0.4.04, Console settings + health + root plugin): `packages/db` gained a `platform_settings` table (composite PK `key`+`tenant_id`, PLT-15) and a `tenants` table, both bootstrapped and seeded by a new `getPlatformDb()` singleton that moved from `runtime/src/db.ts` into `packages/db/src/platform-db.ts` (the runtime now re-exports it). First run seeds the default tenant (`default`, name "Sovereign") and `root_plugin_id = fs.sovereign.launcher`. `sdk.platform.getConfig()` is now wired (PLT-06) — reads tenant name + `invite_only` from the platform DB and the platform version from the workspace-root `package.json`; the SDK took a `@sovereignfs/db` dependency for this. Console `/console/settings` exposes tenant name (CON-08), an invite-only toggle (CON-10), and a root-plugin selector (CON-11, eligible = installed + enabled + non-`adminOnly`, validated in `runtime/src/root-plugin.ts`). Invite-only is **dual-written**: the runtime PATCH writes the platform-DB copy and proxies to a new `apps/auth` `/api/admin/settings` route that writes the auth server's own `auth_settings` table — registration enforcement reads only the auth copy (a stored value overrides the `AUTH_INVITE_ONLY` env default; the create-hook resolves this per registration, no restart). `/console/health` (CON-09) renders runtime version, DB dialect + connection status + SQLite file size, auth-server reachability, and uptime from a new `runtime/api/admin/health` route; `apps/auth` gained an unauthenticated `/api/health` liveness probe. `runtime/app/(platform)/page.tsx` now redirects `/` to the configured root plugin's `routePrefix` (PLT-14), falling back to a placeholder while the Launcher is uninstalled. Verified live: settings GET/PATCH incl. validation rejections (admin-only/not-installed/empty-name), health report, invite-only dual-write reaching the auth server, admin-key gating. `packages/db` → 0.3.0, `packages/sdk` → 0.3.0, `apps/auth` → 0.3.0, `plugins/console` → 0.4.0, `runtime` → 0.2.0. Earlier notes retained._ _Version 1.5 — June 2026. Changes from v1.4 (Task 0.4.03, Console plugin management): the platform database is now actually opened by the runtime — `packages/db` gained a `plugin_status` table (plugin_id PK, tenant_id, enabled, updated_at; absence of a row = enabled; bootstrapped via CREATE TABLE IF NOT EXISTS in `runtime/src/db.ts` until drizzle-kit migrations land in 0.5.03). Console `/console/plugins` lists installed plugins with an enable/disable toggle. The middleware returns 404 for routes under a disabled plugin's `routePrefix`; because middleware runs on the Edge runtime (no SQLite access), it fetches disabled IDs from the Node-runtime route `/api/admin/plugins/disabled` — the same round-trip pattern as `/api/verify` — and fails open. Internal admin routes (`/api/admin/*`) are gated by `SOVEREIGN_ADMIN_KEY` and excluded from the middleware matcher; self-fetches to the runtime's own API use `http://localhost:3000` (the server always pins :3000), never the public URL. **New convention:** relative SQLite `file:` paths resolve against the workspace root (nearest `pnpm-workspace.yaml`), not the process cwd — all SQLite files land in the single root-level `data/` directory in native dev and Docker alike (this also fixed Docker persistence: DBs previously landed outside the `./data:/app/data` mount). `better-sqlite3` is a direct runtime dependency (pnpm strict node_modules requires it for webpack externalization) and is in `serverExternalPackages`. Both compose files now pass `SOVEREIGN_ADMIN_KEY` and `NEXT_PUBLIC_RUNTIME_URL` to the containers. `packages/db` → 0.2.0, `plugins/console` → 0.3.0. Earlier notes retained._ diff --git a/plugins/launcher/app/_components/PluginGrid.tsx b/plugins/launcher/app/_components/PluginGrid.tsx new file mode 100644 index 0000000..9e0e657 --- /dev/null +++ b/plugins/launcher/app/_components/PluginGrid.tsx @@ -0,0 +1,15 @@ +import { PluginTile, type PluginTileData } from './PluginTile'; +import styles from '../launcher.module.css'; + +/** Responsive grid of plugin tiles (LCH-01). */ +export function PluginGrid({ plugins }: { plugins: PluginTileData[] }) { + return ( + + ); +} diff --git a/plugins/launcher/app/_components/PluginTile.tsx b/plugins/launcher/app/_components/PluginTile.tsx new file mode 100644 index 0000000..a3155d2 --- /dev/null +++ b/plugins/launcher/app/_components/PluginTile.tsx @@ -0,0 +1,32 @@ +import Link from 'next/link'; +import styles from '../launcher.module.css'; + +export interface PluginTileData { + id: string; + name: string; + description: string; + routePrefix: string; +} + +/** Two-letter monogram fallback (no icon-serving pipeline yet — see launcher.md Q3). */ +function monogram(name: string): string { + const initials = name + .trim() + .split(/\s+/) + .map((word) => word[0] ?? '') + .join(''); + return (initials.slice(0, 2) || name.slice(0, 2)).toUpperCase(); +} + +/** A single plugin tile (LCH-01/02): icon, name, description; links to the plugin. */ +export function PluginTile({ plugin }: { plugin: PluginTileData }) { + return ( + + + {plugin.name} + {plugin.description && {plugin.description}} + + ); +} diff --git a/plugins/launcher/app/launcher.module.css b/plugins/launcher/app/launcher.module.css new file mode 100644 index 0000000..8b6e7a1 --- /dev/null +++ b/plugins/launcher/app/launcher.module.css @@ -0,0 +1,114 @@ +.launcher { + display: flex; + flex-direction: column; + gap: var(--sv-space-6); +} + +.title { + margin: 0; + font-size: var(--sv-font-size-2xl); + font-weight: var(--sv-font-weight-semibold); + color: var(--sv-color-text-primary); +} + +/* ── Grid ───────────────────────────────────────────────────────────────── */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--sv-space-4); + margin: 0; + padding: 0; + list-style: none; +} + +.tile { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--sv-space-3); + height: 100%; + padding: var(--sv-space-6) var(--sv-space-5); + border: 1px solid var(--sv-color-border); + border-radius: var(--sv-radius-lg); + background-color: var(--sv-color-surface-raised); + box-shadow: var(--sv-shadow-card); + text-decoration: none; +} + +.tile:hover { + border-color: var(--sv-color-border-strong); +} + +.tileIcon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: var(--sv-radius-md); + background-color: var(--sv-color-surface-sunken); + color: var(--sv-color-text-primary); + font-size: var(--sv-font-size-lg); + font-weight: var(--sv-font-weight-semibold); +} + +.tileName { + font-size: var(--sv-font-size-md); + font-weight: var(--sv-font-weight-medium); + color: var(--sv-color-text-primary); +} + +.tileDesc { + font-size: var(--sv-font-size-sm); + color: var(--sv-color-text-muted); +} + +/* ── Admin section ──────────────────────────────────────────────────────── */ + +.adminSection { + display: flex; + flex-direction: column; + gap: var(--sv-space-4); + padding-top: var(--sv-space-4); + border-top: 1px solid var(--sv-color-border); +} + +.sectionTitle { + margin: 0; + font-size: var(--sv-font-size-lg); + font-weight: var(--sv-font-weight-medium); + color: var(--sv-color-text-muted); +} + +/* ── Empty state ────────────────────────────────────────────────────────── */ + +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sv-space-2); + padding: var(--sv-space-16) var(--sv-space-6); + border: 1px dashed var(--sv-color-border); + border-radius: var(--sv-radius-lg); + text-align: center; +} + +.emptyTitle { + margin: 0; + font-size: var(--sv-font-size-lg); + font-weight: var(--sv-font-weight-medium); + color: var(--sv-color-text-primary); +} + +.emptyText { + margin: 0; + font-size: var(--sv-font-size-sm); + color: var(--sv-color-text-muted); +} + +.emptyLink { + color: var(--sv-color-accent); + text-decoration: underline; +} diff --git a/plugins/launcher/app/page.tsx b/plugins/launcher/app/page.tsx new file mode 100644 index 0000000..c23817f --- /dev/null +++ b/plugins/launcher/app/page.tsx @@ -0,0 +1,78 @@ +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { sdk } from '@sovereignfs/sdk'; +import { PluginGrid } from './_components/PluginGrid'; +import type { PluginTileData } from './_components/PluginTile'; +import styles from './launcher.module.css'; + +// Always reflect the current registry + enabled state on each visit. +export const dynamic = 'force-dynamic'; + +// The runtime always listens on :3000; self-fetch its own API rather than the +// public URL (which may sit behind a reverse proxy the container can't hairpin +// through) — same rationale as the Console pages. +const SELF_URL = 'http://localhost:3000'; + +interface LauncherPlugin extends PluginTileData { + adminOnly: boolean; +} + +async function getPlugins(): Promise { + // Forward the caller's session cookie so the gated /api/plugins route resolves + // the same user (and role) the Launcher page was rendered for. + const cookie = (await headers()).get('cookie') ?? ''; + const res = await fetch(`${SELF_URL}/api/plugins`, { + headers: { cookie }, + cache: 'no-store', + }); + if (!res.ok) throw new Error(`Failed to fetch plugins: ${res.status}`); + const data = (await res.json()) as { plugins: LauncherPlugin[] }; + return data.plugins; +} + +export default async function LauncherPage() { + const [plugins, session] = await Promise.all([getPlugins(), sdk.auth.getSession()]); + const isAdmin = session?.user.role === 'platform:admin'; + + const mainPlugins = plugins.filter((p) => !p.adminOnly); + const adminPlugins = plugins.filter((p) => p.adminOnly); + + if (plugins.length === 0) { + return ( +
+

Home

+
+

No plugins installed yet

+ {isAdmin ? ( +

+ Install and enable plugins from the{' '} + + Console + + . +

+ ) : ( +

+ Ask your administrator to install plugins for this workspace. +

+ )} +
+
+ ); + } + + return ( +
+

Home

+ + {mainPlugins.length > 0 && } + + {isAdmin && adminPlugins.length > 0 && ( +
+

Admin

+ +
+ )} +
+ ); +} diff --git a/plugins/launcher/icon.svg b/plugins/launcher/icon.svg new file mode 100644 index 0000000..e0c67a8 --- /dev/null +++ b/plugins/launcher/icon.svg @@ -0,0 +1,17 @@ + diff --git a/plugins/launcher/manifest.json b/plugins/launcher/manifest.json new file mode 100644 index 0000000..4f07074 --- /dev/null +++ b/plugins/launcher/manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "id": "fs.sovereign.launcher", + "name": "Launcher", + "version": "0.1.0", + "description": "Home screen — lists all installed plugins for easy access.", + "type": "platform", + "runtime": "native", + "routePrefix": "/launcher", + "shell": "default", + "icon": "icon.svg", + "permissions": ["auth:session", "db:readOnly"], + "compatibility": { + "minPlatformVersion": "0.4.0" + } +} diff --git a/plugins/launcher/package.json b/plugins/launcher/package.json new file mode 100644 index 0000000..953bf04 --- /dev/null +++ b/plugins/launcher/package.json @@ -0,0 +1,19 @@ +{ + "name": "@sovereignfs/plugin-launcher", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@sovereignfs/sdk": "workspace:*", + "@sovereignfs/ui": "workspace:*", + "next": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@sovereignfs/tsconfig": "workspace:*", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "typescript": "catalog:" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46fbbac..16b9a08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,37 @@ importers: specifier: 'catalog:' version: 5.9.3 + plugins/launcher: + dependencies: + '@sovereignfs/sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@sovereignfs/ui': + specifier: workspace:* + version: link:../../packages/ui + next: + specifier: 'catalog:' + version: 15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: 'catalog:' + version: 19.2.7 + react-dom: + specifier: 'catalog:' + version: 19.2.7(react@19.2.7) + devDependencies: + '@sovereignfs/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + '@types/react': + specifier: 'catalog:' + version: 19.2.17 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.17) + typescript: + specifier: 'catalog:' + version: 5.9.3 + runtime: dependencies: '@next/env': diff --git a/runtime/app/(platform)/layout.tsx b/runtime/app/(platform)/layout.tsx index 8fc7136..482ec64 100644 --- a/runtime/app/(platform)/layout.tsx +++ b/runtime/app/(platform)/layout.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { headers } from 'next/headers'; import Link from 'next/link'; import { getInstalledPlugins } from '@/src/registry'; +import { CHROME_PLUGIN_IDS } from '@/src/launcher-plugins'; import styles from './shell.module.css'; function monogram(name: string): string { @@ -11,7 +12,11 @@ function monogram(name: string): string { export default async function PlatformLayout({ children }: { children: ReactNode }) { const role = (await headers()).get('x-sovereign-user-role') ?? 'platform:user'; const isAdmin = role === 'platform:admin'; - const plugins = getInstalledPlugins(); + // Middle section: one icon per non-chrome plugin. Chrome plugins (Launcher, + // Console, Account) are reached via the home `/`, ⚙, and avatar links below + // (SRS PLT-12). Full root-plugin-first ordering lands with the shell + // three-section work (PLT-11–15). + const plugins = getInstalledPlugins().filter((plugin) => !CHROME_PLUGIN_IDS.has(plugin.id)); const pluginIcons = plugins.map((plugin) => ( diff --git a/runtime/app/api/plugins/route.ts b/runtime/app/api/plugins/route.ts new file mode 100644 index 0000000..3fef9a0 --- /dev/null +++ b/runtime/app/api/plugins/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { schema } from '@sovereignfs/db'; +import { getPlatformDb } from '@/src/db'; +import { getInstalledPlugins } from '@/src/registry'; +import { selectLauncherPlugins } from '@/src/launcher-plugins'; + +/** + * Launcher-visible plugins for the current user (SRS LCH-01/03/04). Session- + * gated by the middleware (not under the `/api/admin` exclusion), which injects + * the verified role as `x-sovereign-user-role` — so this needs no admin key. + * Returns enabled, non-chrome plugins; admin-only ones only for admins. + */ +export async function GET(request: Request): Promise { + const role = request.headers.get('x-sovereign-user-role') ?? 'platform:user'; + + const db = getPlatformDb(); + const disabledIds = new Set( + db + .select({ pluginId: schema.pluginStatus.pluginId }) + .from(schema.pluginStatus) + .where(eq(schema.pluginStatus.enabled, false)) + .all() + .map((r) => r.pluginId), + ); + + const plugins = selectLauncherPlugins(getInstalledPlugins(), disabledIds, role); + return NextResponse.json({ plugins }); +} diff --git a/runtime/generated/registry.ts b/runtime/generated/registry.ts index 49dff53..a1ff175 100644 --- a/runtime/generated/registry.ts +++ b/runtime/generated/registry.ts @@ -23,5 +23,24 @@ export const registry: SovereignManifest[] = [ "compatibility": { "minPlatformVersion": "0.4.0" } + }, + { + "schemaVersion": 1, + "id": "fs.sovereign.launcher", + "name": "Launcher", + "version": "0.1.0", + "description": "Home screen — lists all installed plugins for easy access.", + "type": "platform", + "runtime": "native", + "routePrefix": "/launcher", + "permissions": [ + "auth:session", + "db:readOnly" + ], + "shell": "default", + "icon": "icon.svg", + "compatibility": { + "minPlatformVersion": "0.4.0" + } } ]; diff --git a/runtime/package.json b/runtime/package.json index a46e241..fbd6a01 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@sovereignfs/runtime", - "version": "0.2.0", + "version": "0.3.0", "private": true, "type": "module", "scripts": { diff --git a/runtime/src/launcher-plugins.test.ts b/runtime/src/launcher-plugins.test.ts new file mode 100644 index 0000000..b1166f4 --- /dev/null +++ b/runtime/src/launcher-plugins.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { selectLauncherPlugins, type LauncherPluginInput } from './launcher-plugins'; + +const plugins: LauncherPluginInput[] = [ + { id: 'fs.sovereign.launcher', name: 'Launcher', routePrefix: '/launcher' }, + { id: 'fs.sovereign.console', name: 'Console', routePrefix: '/console', adminOnly: true }, + { id: 'fs.sovereign.account', name: 'Account', routePrefix: '/account' }, + { id: 'fs.example.tasks', name: 'Tasks', routePrefix: '/tasks', description: 'To-dos' }, + { id: 'fs.example.audit', name: 'Audit', routePrefix: '/audit', adminOnly: true }, +]; +const none = new Set(); + +describe('selectLauncherPlugins', () => { + it('excludes all three chrome plugins', () => { + const ids = selectLauncherPlugins(plugins, none, 'platform:admin').map((p) => p.id); + expect(ids).not.toContain('fs.sovereign.launcher'); + expect(ids).not.toContain('fs.sovereign.console'); + expect(ids).not.toContain('fs.sovereign.account'); + }); + + it('returns non-chrome plugins for an admin, including admin-only ones', () => { + const ids = selectLauncherPlugins(plugins, none, 'platform:admin').map((p) => p.id); + expect(ids).toEqual(['fs.example.tasks', 'fs.example.audit']); + }); + + it('hides admin-only plugins from a regular user', () => { + const result = selectLauncherPlugins(plugins, none, 'platform:user'); + expect(result.map((p) => p.id)).toEqual(['fs.example.tasks']); + expect(result.every((p) => !p.adminOnly)).toBe(true); + }); + + it('excludes disabled plugins', () => { + const disabled = new Set(['fs.example.tasks']); + const ids = selectLauncherPlugins(plugins, disabled, 'platform:admin').map((p) => p.id); + expect(ids).toEqual(['fs.example.audit']); + }); + + it('flags admin-only plugins and defaults the flag to false', () => { + const result = selectLauncherPlugins(plugins, none, 'platform:admin'); + expect(result.find((p) => p.id === 'fs.example.audit')?.adminOnly).toBe(true); + expect(result.find((p) => p.id === 'fs.example.tasks')?.adminOnly).toBe(false); + }); + + it('defaults a missing description to an empty string', () => { + const result = selectLauncherPlugins(plugins, none, 'platform:admin'); + expect(result.find((p) => p.id === 'fs.example.audit')?.description).toBe(''); + expect(result.find((p) => p.id === 'fs.example.tasks')?.description).toBe('To-dos'); + }); + + it('returns an empty list when only chrome plugins are installed', () => { + const chromeOnly = plugins.filter((p) => p.id.startsWith('fs.sovereign.')); + expect(selectLauncherPlugins(chromeOnly, none, 'platform:admin')).toEqual([]); + }); +}); diff --git a/runtime/src/launcher-plugins.ts b/runtime/src/launcher-plugins.ts new file mode 100644 index 0000000..cfb6718 --- /dev/null +++ b/runtime/src/launcher-plugins.ts @@ -0,0 +1,53 @@ +import type { PluginRouteInfo } from './route-guard'; + +/** + * Platform chrome plugins — reachable through the sidebar chrome (home `/`, + * Console ⚙, Account avatar), never listed among the Launcher tiles or the + * sidebar's middle plugin-icon section (SRS LCH-04, PLT-12). + */ +export const CHROME_PLUGIN_IDS: ReadonlySet = new Set([ + 'fs.sovereign.launcher', + 'fs.sovereign.account', + 'fs.sovereign.console', +]); + +/** The Launcher-visible projection of a plugin manifest. */ +export interface LauncherPlugin { + id: string; + name: string; + description: string; + routePrefix: string; + adminOnly: boolean; +} + +/** A plugin manifest with the fields the Launcher projection needs. */ +export interface LauncherPluginInput extends PluginRouteInfo { + name: string; + description?: string; +} + +/** + * Select the plugins a user should see in the Launcher (SRS LCH-01/03/04): + * installed, enabled (not in `disabledIds`), and not platform chrome. Admin-only + * plugins are included only for `platform:admin` — non-admins never receive + * them. Each result carries `adminOnly` so the Launcher can render the admin + * tiles in their own section. + */ +export function selectLauncherPlugins( + plugins: readonly LauncherPluginInput[], + disabledIds: ReadonlySet, + role: string, +): LauncherPlugin[] { + const isAdmin = role === 'platform:admin'; + return plugins + .filter((p) => !CHROME_PLUGIN_IDS.has(p.id)) + .filter((p) => !disabledIds.has(p.id)) + .filter((p) => isAdmin || !p.adminOnly) + .map((p) => ({ + id: p.id, + name: p.name, + description: p.description ?? '', + routePrefix: p.routePrefix, + adminOnly: p.adminOnly ?? false, + })); +} From b9e21ef520bb75f42a0de87122695499c1767b7d Mon Sep 17 00:00:00 2001 From: kasunben Date: Sun, 14 Jun 2026 09:34:51 +0200 Subject: [PATCH 2/4] test(launcher): cover monogram + fix single-word bug; sync launcher.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the tile monogram into a pure app/_components/monogram.ts and adds unit tests (vitest now also includes plugins/** source tests). The tests caught a real bug in the shipped helper: `initials || name.slice` never fell through for a single-word name (a one-letter `initials` is truthy), so "Tasks" rendered as "T" instead of "TA"; the whitespace-only fallback also used the untrimmed name and returned spaces. Reworked to take the first letter of the first two words, else the first two characters of the trimmed name, else empty. Docs: bring the launcher.md body in line with what was built — the directory tree now shows app/_components/ + launcher.module.css + package.json, and Data model / SDK dependencies document the interim session-gated /api/plugins read (replacing sdk.db until 0.5.05). Co-Authored-By: Claude Code --- docs/plugins/launcher.md | 44 ++++++++++++++----- .../launcher/app/_components/PluginTile.tsx | 11 +---- .../launcher/app/_components/monogram.test.ts | 29 ++++++++++++ plugins/launcher/app/_components/monogram.ts | 15 +++++++ vitest.config.ts | 4 ++ 5 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 plugins/launcher/app/_components/monogram.test.ts create mode 100644 plugins/launcher/app/_components/monogram.ts diff --git a/docs/plugins/launcher.md b/docs/plugins/launcher.md index 30c556b..5cc52a8 100644 --- a/docs/plugins/launcher.md +++ b/docs/plugins/launcher.md @@ -142,31 +142,47 @@ This icon is special: ## Directory structure -Launcher lives in the monorepo under `plugins/launcher/`. +Launcher lives in the monorepo under `plugins/launcher/`. As built (v0.1): ``` plugins/launcher/ ├── manifest.json -├── icon.svg # Launcher icon — grid-of-dots or home symbol -├── app/ -│ └── page.tsx # Plugin grid: regular + admin sections -└── components/ - ├── PluginGrid.tsx # Grid of plugin tiles - └── PluginTile.tsx # Single tile: icon, name, description +├── icon.svg # Launcher icon — four-square grid symbol +├── package.json +└── app/ + ├── page.tsx # Plugin grid: main + admin sections, empty state + ├── launcher.module.css # Grid, tile, admin-section, empty-state styles + └── _components/ + ├── PluginGrid.tsx # Responsive grid of tiles + ├── PluginTile.tsx # Single tile: monogram, name, description + └── monogram.ts # Two-letter monogram fallback (pure; unit-tested) ``` +Components live under `app/_components/` (a private App Router folder, not a +sibling `components/` as originally sketched) because the generate script +composes only each plugin's `app/` tree into the runtime — anything outside +`app/` is not copied and would fail to resolve at runtime. + No `db/`, `migrations/`, or `lib/` directories — Launcher has no private tables -and no complex business logic. It reads the plugin registry through the platform -SDK. +and no complex business logic. --- ## Data model Launcher has no plugin-specific database tables. It reads the platform plugin -registry (maintained by the runtime) via `sdk.db`. The registry exposes the -installed plugin list, their manifest fields (including `icon`, `name`, -`description`, `adminOnly`), and their enabled/disabled status. +registry (maintained by the runtime) — exposing the installed plugin list, their +manifest fields (`name`, `description`, `routePrefix`, `adminOnly`), and their +enabled/disabled status. + +**As built (v0.1):** because the SDK boundary rule forbids a plugin importing the +runtime registry or internal packages, and `sdk.db` does not land until Task +0.5.05, the Launcher reads this list from a **session-gated** runtime route, +`GET /api/plugins`, forwarding the caller's session cookie. The route applies +all access filtering server-side via `selectLauncherPlugins()` (excludes chrome +plugins and disabled plugins; hides `adminOnly` plugins from non-admins) and +returns each plugin's `adminOnly` flag so the page can section the tiles. This +fetch is replaced by `sdk.db` when 0.5.05 lands. --- @@ -177,6 +193,10 @@ installed plugin list, their manifest fields (including `icon`, `name`, | `sdk.auth` | Current user session; role check for the admin section | Task 0.4.02 | | `sdk.db` | Read plugin registry (installed plugins + their manifest metadata) | Task 0.5.05 | +In v0.1, the registry read is served by the runtime route `GET /api/plugins` +(see [Data model](#data-model)) until `sdk.db` is wired. `sdk.auth.getSession()` +supplies the role used to decide whether the Admin section renders. + --- ## UI diff --git a/plugins/launcher/app/_components/PluginTile.tsx b/plugins/launcher/app/_components/PluginTile.tsx index a3155d2..7d7d6e0 100644 --- a/plugins/launcher/app/_components/PluginTile.tsx +++ b/plugins/launcher/app/_components/PluginTile.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import { monogram } from './monogram'; import styles from '../launcher.module.css'; export interface PluginTileData { @@ -8,16 +9,6 @@ export interface PluginTileData { routePrefix: string; } -/** Two-letter monogram fallback (no icon-serving pipeline yet — see launcher.md Q3). */ -function monogram(name: string): string { - const initials = name - .trim() - .split(/\s+/) - .map((word) => word[0] ?? '') - .join(''); - return (initials.slice(0, 2) || name.slice(0, 2)).toUpperCase(); -} - /** A single plugin tile (LCH-01/02): icon, name, description; links to the plugin. */ export function PluginTile({ plugin }: { plugin: PluginTileData }) { return ( diff --git a/plugins/launcher/app/_components/monogram.test.ts b/plugins/launcher/app/_components/monogram.test.ts new file mode 100644 index 0000000..872cbf0 --- /dev/null +++ b/plugins/launcher/app/_components/monogram.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { monogram } from './monogram'; + +describe('monogram', () => { + it('takes the first letter of the first two words', () => { + expect(monogram('Plain Write')).toBe('PW'); + expect(monogram('Acme Project Tracker')).toBe('AP'); + }); + + it('falls back to the first two characters of a single word', () => { + expect(monogram('Tasks')).toBe('TA'); + expect(monogram('a')).toBe('A'); + }); + + it('upper-cases the result', () => { + expect(monogram('plainwrite')).toBe('PL'); + expect(monogram('split it')).toBe('SI'); + }); + + it('ignores surrounding and repeated whitespace', () => { + expect(monogram(' Plain Write ')).toBe('PW'); + expect(monogram('\tTasks\n')).toBe('TA'); + }); + + it('returns an empty string for an empty or whitespace-only name', () => { + expect(monogram('')).toBe(''); + expect(monogram(' ')).toBe(''); + }); +}); diff --git a/plugins/launcher/app/_components/monogram.ts b/plugins/launcher/app/_components/monogram.ts new file mode 100644 index 0000000..ab67307 --- /dev/null +++ b/plugins/launcher/app/_components/monogram.ts @@ -0,0 +1,15 @@ +/** + * Two-letter monogram fallback for a plugin tile — used until an icon-serving + * pipeline exists (see docs/plugins/launcher.md, open question 3). + * + * Takes the first letter of each whitespace-separated word, up to two; falls + * back to the first two characters of the name when that yields nothing (e.g. a + * single word). Always upper-cased. + */ +export function monogram(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return ''; + const [first = '', second = ''] = trimmed.split(/\s+/); + const initials = second ? first.charAt(0) + second.charAt(0) : first.slice(0, 2); + return initials.toUpperCase(); +} diff --git a/vitest.config.ts b/vitest.config.ts index 95d2dad..e7fe682 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,10 @@ export default defineConfig({ 'packages/**/src/**/*.test.{ts,tsx}', 'apps/**/src/**/*.test.{ts,tsx}', 'runtime/src/**/*.test.{ts,tsx}', + // Plugin source tests. Only the source tree under plugins/ is matched — + // the composed copies live under runtime/app/(platform)/(plugins)/ and + // are not covered by any include pattern, so they are never double-run. + 'plugins/**/*.test.{ts,tsx}', ], // Default to node; component tests opt into jsdom with a // `// @vitest-environment jsdom` pragma at the top of the file. From b27bafeacb4470e1037b4159f9118de6618cce54 Mon Sep 17 00:00:00 2001 From: kasunben Date: Sun, 14 Jun 2026 09:48:38 +0200 Subject: [PATCH 3/4] feat(runtime): serve the root plugin in place at / instead of redirecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously / 307-redirected to the configured root plugin's routePrefix (e.g. /launcher), changing the URL. Now the middleware rewrites / to that routePrefix so the plugin renders in place — the URL stays /, and the plugin remains reachable at its own prefix (so / and /launcher both serve the Launcher). Resolution stays request-time and respects the configurable root_plugin_id (CON-11): a new admin-key-guarded GET /api/admin/root-plugin returns the prefix (or null when the configured plugin isn't a valid root), which the Edge middleware fetches — the same round-trip pattern as the disabled- plugins check, since middleware can't read the DB. resolveRootRoutePrefix() in runtime/src/root-plugin.ts encapsulates the logic (reuses validateRootPlugin) and is unit-tested (4 cases). (platform)/page.tsx keeps its redirect() as a fallback for when the resolution fetch fails. Verified live with a real session: / -> 200 serving the Launcher in place (URL unchanged), /launcher -> 200 directly, / unauthenticated -> 307 to login. Revises PLT-14 (redirect -> in-place serve); docs updated. Co-Authored-By: Claude Code --- CLAUDE.md | 13 ++++++--- docs/sovereign-implementation-tasks.md | 2 +- runtime/app/api/admin/root-plugin/route.ts | 32 ++++++++++++++++++++++ runtime/middleware.ts | 32 ++++++++++++++++++++++ runtime/src/root-plugin.test.ts | 23 +++++++++++++++- runtime/src/root-plugin.ts | 15 ++++++++++ 6 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 runtime/app/api/admin/root-plugin/route.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8e95a4b..cbeb042 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,10 +163,15 @@ pnpm lint:fix # run ESLint with auto-fix overrides the `AUTH_INVITE_ONLY` env default; absent a stored value, the env default applies. Never make registration read the platform DB instead. - **`root_plugin_id` lives in `platform_settings`, seeded on first run** to - `fs.sovereign.launcher` (PLT-14/PLT-15). `/` redirects to that plugin's - `routePrefix`; the eligible set is installed + enabled + non-`adminOnly` - (validated in `runtime/src/root-plugin.ts`). Platform tables (`tenants`, - `plugin_status`, `platform_settings`) are bootstrapped with + `fs.sovereign.launcher` (PLT-14/PLT-15). The eligible set is installed + + enabled + non-`adminOnly` (validated in `runtime/src/root-plugin.ts`). `/` + **serves the root plugin in place** — the middleware rewrites `/` to the + configured plugin's `routePrefix` (URL stays `/`; the plugin remains reachable + at its own prefix too), resolving the prefix at request time via + `GET /api/admin/root-plugin` (Edge middleware can't read the DB, same fetch + pattern as `/api/admin/plugins/disabled`). `(platform)/page.tsx` keeps a + `redirect()` as a fallback for when that resolution fetch fails. Platform + tables (`tenants`, `plugin_status`, `platform_settings`) are bootstrapped with CREATE-TABLE-IF-NOT-EXISTS + seed rows in `packages/db`'s `getPlatformDb()` until drizzle-kit migrations land in Task 0.5.03; the DDL there must stay in sync with the Drizzle schema. diff --git a/docs/sovereign-implementation-tasks.md b/docs/sovereign-implementation-tasks.md index fd6c43a..a5866a5 100644 --- a/docs/sovereign-implementation-tasks.md +++ b/docs/sovereign-implementation-tasks.md @@ -953,7 +953,7 @@ consistent info/success/warn/error formatting. CLI is monorepo-internal in v1 --- -_Version 1.7 — June 2026. Changes from v1.6 (Task 0.4.05, Launcher plugin): the first non-Console platform plugin ships in `plugins/launcher/` — a home grid (LCH-01–05) that serves `/launcher` and, as the default `root_plugin_id`, now backs `/` (so the 0.4.04 root redirect resolves to a real plugin for the first time). Because the SDK boundary rule forbids a plugin importing the registry, the Launcher reads its tile list from a new **session-gated** `runtime/app/api/plugins/route.ts` (not under the `/api/admin` exclusion — middleware injects `x-sovereign-user-role`), forwarding the caller's cookie; the route role-filters via a pure `selectLauncherPlugins(plugins, disabledIds, role)` in `runtime/src/launcher-plugins.ts` (excludes the three **chrome** plugins `fs.sovereign.{launcher,account,console}` via the canonical `CHROME_PLUGIN_IDS` set, excludes disabled plugins, and hides `adminOnly` plugins from non-admins). The Launcher page renders a main grid plus an admin-only "Admin" section and an empty state (Console link for admins, contact-admin note otherwise). The sidebar middle section now also excludes chrome plugins (PLT-12); full root-plugin-first ordering (PLT-11–15) remains separate. Plugin components live under `app/_components/` (a private App Router folder) because the generate script composes only each plugin's `app/` tree. Tiles use a two-letter monogram — no icon-serving pipeline yet (launcher.md Q3). Verified live with a real session: `/` → 307 → `/launcher`, `/launcher` → 200 empty state, `/api/plugins` → `{plugins:[]}` (only chrome installed), and unauthenticated gating (307). `selectLauncherPlugins` has 7 unit tests. `runtime` → 0.3.0; new `plugins/launcher` at 0.1.0. Earlier notes retained._ +_Version 1.7 — June 2026. Changes from v1.6 (Task 0.4.05, Launcher plugin): the first non-Console platform plugin ships in `plugins/launcher/` — a home grid (LCH-01–05) that serves `/launcher` and, as the default `root_plugin_id`, now backs `/` (so the 0.4.04 root redirect resolves to a real plugin for the first time). Because the SDK boundary rule forbids a plugin importing the registry, the Launcher reads its tile list from a new **session-gated** `runtime/app/api/plugins/route.ts` (not under the `/api/admin` exclusion — middleware injects `x-sovereign-user-role`), forwarding the caller's cookie; the route role-filters via a pure `selectLauncherPlugins(plugins, disabledIds, role)` in `runtime/src/launcher-plugins.ts` (excludes the three **chrome** plugins `fs.sovereign.{launcher,account,console}` via the canonical `CHROME_PLUGIN_IDS` set, excludes disabled plugins, and hides `adminOnly` plugins from non-admins). The Launcher page renders a main grid plus an admin-only "Admin" section and an empty state (Console link for admins, contact-admin note otherwise). The sidebar middle section now also excludes chrome plugins (PLT-12); full root-plugin-first ordering (PLT-11–15) remains separate. Plugin components live under `app/_components/` (a private App Router folder) because the generate script composes only each plugin's `app/` tree. Tiles use a two-letter monogram — no icon-serving pipeline yet (launcher.md Q3). `/` now **serves the root plugin in place** rather than redirecting (revises the 0.4.04 redirect): the middleware rewrites `/` to the configured plugin's `routePrefix` (URL stays `/`, plugin still reachable at its prefix), resolving the prefix at request time via a new `GET /api/admin/root-plugin` route (Edge middleware can't read the DB — same fetch pattern as disabled-plugins); `resolveRootRoutePrefix()` in `runtime/src/root-plugin.ts` is unit-tested, and `(platform)/page.tsx` keeps its `redirect()` as a fallback. Verified live with a real session: `/` → 200 serving the Launcher in place, `/launcher` → 200 directly, `/api/plugins` → `{plugins:[]}` (only chrome installed), unauthenticated gating (307). `selectLauncherPlugins` has 7 unit tests, `monogram` 5, `resolveRootRoutePrefix` 4. `runtime` → 0.3.0; new `plugins/launcher` at 0.1.0. Earlier notes retained._ _Version 1.6 — June 2026. Changes from v1.5 (Task 0.4.04, Console settings + health + root plugin): `packages/db` gained a `platform_settings` table (composite PK `key`+`tenant_id`, PLT-15) and a `tenants` table, both bootstrapped and seeded by a new `getPlatformDb()` singleton that moved from `runtime/src/db.ts` into `packages/db/src/platform-db.ts` (the runtime now re-exports it). First run seeds the default tenant (`default`, name "Sovereign") and `root_plugin_id = fs.sovereign.launcher`. `sdk.platform.getConfig()` is now wired (PLT-06) — reads tenant name + `invite_only` from the platform DB and the platform version from the workspace-root `package.json`; the SDK took a `@sovereignfs/db` dependency for this. Console `/console/settings` exposes tenant name (CON-08), an invite-only toggle (CON-10), and a root-plugin selector (CON-11, eligible = installed + enabled + non-`adminOnly`, validated in `runtime/src/root-plugin.ts`). Invite-only is **dual-written**: the runtime PATCH writes the platform-DB copy and proxies to a new `apps/auth` `/api/admin/settings` route that writes the auth server's own `auth_settings` table — registration enforcement reads only the auth copy (a stored value overrides the `AUTH_INVITE_ONLY` env default; the create-hook resolves this per registration, no restart). `/console/health` (CON-09) renders runtime version, DB dialect + connection status + SQLite file size, auth-server reachability, and uptime from a new `runtime/api/admin/health` route; `apps/auth` gained an unauthenticated `/api/health` liveness probe. `runtime/app/(platform)/page.tsx` now redirects `/` to the configured root plugin's `routePrefix` (PLT-14), falling back to a placeholder while the Launcher is uninstalled. Verified live: settings GET/PATCH incl. validation rejections (admin-only/not-installed/empty-name), health report, invite-only dual-write reaching the auth server, admin-key gating. `packages/db` → 0.3.0, `packages/sdk` → 0.3.0, `apps/auth` → 0.3.0, `plugins/console` → 0.4.0, `runtime` → 0.2.0. Earlier notes retained._ diff --git a/runtime/app/api/admin/root-plugin/route.ts b/runtime/app/api/admin/root-plugin/route.ts new file mode 100644 index 0000000..725c8ff --- /dev/null +++ b/runtime/app/api/admin/root-plugin/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { DEFAULT_ROOT_PLUGIN_ID, getPlatformSetting, schema } from '@sovereignfs/db'; +import { checkAdminKey } from '@/src/admin-guard'; +import { getPlatformDb } from '@/src/db'; +import { getInstalledPlugins } from '@/src/registry'; +import { resolveRootRoutePrefix } from '@/src/root-plugin'; + +/** + * The `routePrefix` the platform root `/` should serve in place (PLT-14), or + * null when the configured root plugin is not a valid root. The middleware + * (Edge — no DB access) fetches this to rewrite `/`, the same round-trip + * pattern as `/api/admin/plugins/disabled`. + */ +export async function GET(request: Request): Promise { + const denied = checkAdminKey(request); + if (denied) return denied; + + const db = getPlatformDb(); + const rootPluginId = getPlatformSetting(db, 'root_plugin_id') ?? DEFAULT_ROOT_PLUGIN_ID; + const disabledIds = new Set( + db + .select({ pluginId: schema.pluginStatus.pluginId }) + .from(schema.pluginStatus) + .where(eq(schema.pluginStatus.enabled, false)) + .all() + .map((r) => r.pluginId), + ); + + const routePrefix = resolveRootRoutePrefix(rootPluginId, getInstalledPlugins(), disabledIds); + return NextResponse.json({ routePrefix }); +} diff --git a/runtime/middleware.ts b/runtime/middleware.ts index efdb571..84757d0 100644 --- a/runtime/middleware.ts +++ b/runtime/middleware.ts @@ -32,6 +32,25 @@ async function fetchDisabledPluginIds(): Promise> { } } +/** + * The configured root plugin's `routePrefix`, fetched from the Node-runtime + * route (Edge middleware cannot read the DB). Used to serve the root plugin in + * place at `/` (PLT-14). Returns null on any failure, so `/` falls through to + * the placeholder home page rather than erroring. + */ +async function fetchRootPluginPrefix(): Promise { + try { + const res = await fetch(`${SELF_URL}/api/admin/root-plugin`, { + headers: { authorization: `Bearer ${process.env.SOVEREIGN_ADMIN_KEY ?? ''}` }, + }); + if (!res.ok) return null; + const { routePrefix } = (await res.json()) as { routePrefix: string | null }; + return routePrefix; + } catch { + return null; + } +} + /** * Session gate + plugin route protection. Verifies the request against the auth * server's /api/verify (v0.3 approach; SRS AUTH-05 targets local JWT @@ -79,6 +98,19 @@ export async function middleware(request: NextRequest): Promise { headers.set('x-sovereign-session-expires-at', String(expiresAt)); if (user.name != null) headers.set('x-sovereign-user-name', user.name); if (user.image != null) headers.set('x-sovereign-user-image', user.image); + + // Serve the configured root plugin in place at `/` (PLT-14) — the URL stays + // `/` while the plugin's route renders, and the plugin is still reachable at + // its own routePrefix. Falls through to the placeholder home page when no + // valid root plugin resolves. `(platform)/page.tsx` keeps a redirect as a + // belt-and-suspenders fallback for the rare case this fetch fails. + if (pathname === '/') { + const rootPrefix = await fetchRootPluginPrefix(); + if (rootPrefix && rootPrefix !== '/') { + return NextResponse.rewrite(new URL(rootPrefix, request.url), { request: { headers } }); + } + } + return NextResponse.next({ request: { headers } }); } diff --git a/runtime/src/root-plugin.test.ts b/runtime/src/root-plugin.test.ts index 6f95e36..9ec575e 100644 --- a/runtime/src/root-plugin.test.ts +++ b/runtime/src/root-plugin.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateRootPlugin } from './root-plugin'; +import { resolveRootRoutePrefix, validateRootPlugin } from './root-plugin'; import type { PluginRouteInfo } from './route-guard'; const plugins: PluginRouteInfo[] = [ @@ -37,3 +37,24 @@ describe('validateRootPlugin', () => { }); }); }); + +describe('resolveRootRoutePrefix', () => { + it('returns the routePrefix for a valid root plugin', () => { + expect(resolveRootRoutePrefix('fs.sovereign.launcher', plugins, none)).toBe('/launcher'); + expect(resolveRootRoutePrefix('fs.example.tasks', plugins, none)).toBe('/tasks'); + }); + + it('returns null when the root plugin is not installed', () => { + expect(resolveRootRoutePrefix('fs.missing', plugins, none)).toBeNull(); + }); + + it('returns null when the root plugin is disabled', () => { + expect( + resolveRootRoutePrefix('fs.example.tasks', plugins, new Set(['fs.example.tasks'])), + ).toBeNull(); + }); + + it('returns null when the root plugin is admin-only', () => { + expect(resolveRootRoutePrefix('fs.sovereign.console', plugins, none)).toBeNull(); + }); +}); diff --git a/runtime/src/root-plugin.ts b/runtime/src/root-plugin.ts index 3967cac..621d75c 100644 --- a/runtime/src/root-plugin.ts +++ b/runtime/src/root-plugin.ts @@ -20,3 +20,18 @@ export function validateRootPlugin( if (plugin.adminOnly) return { ok: false, reason: 'admin-only' }; return { ok: true }; } + +/** + * The `routePrefix` the platform root `/` should serve in place (SRS PLT-14): + * the configured root plugin's prefix when it is a valid root, else null (the + * caller falls back to the placeholder home page). Resolved at request time so + * an admin's CON-11 change takes effect without a rebuild. + */ +export function resolveRootRoutePrefix( + rootPluginId: string, + plugins: readonly PluginRouteInfo[], + disabledIds: ReadonlySet, +): string | null { + if (!validateRootPlugin(rootPluginId, plugins, disabledIds).ok) return null; + return plugins.find((p) => p.id === rootPluginId)?.routePrefix ?? null; +} From c9046c2beb386946b8c9eff51e5cc49d0a6fab3c Mon Sep 17 00:00:00 2001 From: kasunben Date: Sun, 14 Jun 2026 09:51:41 +0200 Subject: [PATCH 4/4] docs: record in-place root serve (PLT-14) and point to Task 0.4.06 next Updates SRS PLT-14 + the root-plugin paragraph and launcher.md sidebar section from "redirects to" to "serves in place" (the runtime rewrites / to the root plugin's routePrefix; URL stays /). Adds an explicit Next pointer to Task 0.4.06 (Account plugin) in CLAUDE.md's Status section. Co-Authored-By: Claude Code --- CLAUDE.md | 4 ++-- docs/plugins/launcher.md | 5 +++-- docs/sovereign-proposal-plan-srs.md | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cbeb042..adf96c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -427,8 +427,8 @@ pnpm install:plugins # clone declared sovereign/community plugins (stub until - ✅ Task 0.4.02 — Console: user management (user list with invited/active/deactivated status, invite flow, role change, deactivate/reactivate; `sdk.auth` + `sdk.mailer` wired) (merged to `main`). - ✅ Task 0.4.03 — Console: plugin management (installed plugin list, enable/disable toggle, middleware 404 for disabled routes; platform DB singleton + `plugin_status` table) (merged to `main`). - ✅ Task 0.4.04 — Console: tenant settings, system health, root plugin config (`platform_settings` + `tenants` seeded in platform DB; `sdk.platform.getConfig()` wired; invite-only toggle dual-written to auth server; `/` redirects to the configured root plugin) (merged to `main`). -- ▶️ In review: Task 0.4.05 — Launcher plugin (`plugins/launcher/` home grid; gated `/api/plugins` + `selectLauncherPlugins` helper; chrome plugins excluded from grid and sidebar middle section; `/` now resolves to `/launcher`). -- ⏳ Spec complete: Account platform plugin (`docs/plugins/account.md`) — Task 0.4.06. +- ▶️ In review: Task 0.4.05 — Launcher plugin (`plugins/launcher/` home grid; gated `/api/plugins` + `selectLauncherPlugins` helper; chrome plugins excluded from grid and sidebar middle section; `/` serves the root plugin in place via middleware rewrite — `/` and `/launcher` both render the Launcher). +- ⏳ Next: Task 0.4.06 — Account plugin (`plugins/account/` per-user profile, all authenticated users): three tabs — Profile (display name + avatar upload), Security (password change + active-session revoke), Preferences (IANA timezone + Light/Dark/System theme via `account_prefs` table + `sv-theme` cookie). Spec in `docs/plugins/account.md`. Functional dependency is `sdk.auth` (Task 0.4.02, done); branch from an up-to-date `main` once #22 merges, per the one-task-at-a-time rule. Closes the v0.4 chrome-plugin trio (Console, Launcher, Account). - ⏳ Spec complete: Shell sidebar three-section architecture (PLT-11–PLT-15, SRS updated). - ⏳ Spec complete: Plainwrite sovereign plugin (`docs/plugins/plainwrite.md`, v0.2 — provider + SSG adapters). diff --git a/docs/plugins/launcher.md b/docs/plugins/launcher.md index 5cc52a8..6fae2b8 100644 --- a/docs/plugins/launcher.md +++ b/docs/plugins/launcher.md @@ -128,8 +128,9 @@ This icon is special: - It always appears first, regardless of install order. - It points to `/` (the platform root), not to `/launcher` directly. The - platform resolves `/` to the configured root plugin's `routePrefix` (default: - `/launcher`; admin can change this via CON-11). + platform serves the configured root plugin in place at `/` — the runtime + rewrites `/` to that plugin's `routePrefix` (default: `/launcher`; admin can + change this via CON-11), so the URL stays `/` while the plugin renders. - It cannot be hidden or reordered by users (v1). - Its icon is the configured root plugin's `icon.svg` (not necessarily the Launcher's own icon, if the admin has promoted a different plugin to root). diff --git a/docs/sovereign-proposal-plan-srs.md b/docs/sovereign-proposal-plan-srs.md index d7df429..87da846 100644 --- a/docs/sovereign-proposal-plan-srs.md +++ b/docs/sovereign-proposal-plan-srs.md @@ -567,7 +567,7 @@ The runtime reads the active plugin's `shell` value from the registry on each na **Mobile layout:** The mobile shell uses a header (branding logo left, Account avatar right), a content area, and a footer launcher. The footer launcher mirrors the middle sidebar section (same plugin icons, same order). Console icon appears in the footer launcher for admin users only. -**Root plugin:** The platform has a configurable `root_plugin_id` (default: `fs.sovereign.launcher`). Navigating to `/` redirects to the root plugin's `routePrefix`. An admin can change this to any installed, enabled, non-adminOnly plugin via Console (CON-11). The first middle-section sidebar icon always resolves the current root plugin's icon and routes to `/`. +**Root plugin:** The platform has a configurable `root_plugin_id` (default: `fs.sovereign.launcher`). Navigating to `/` **serves the root plugin in place** — the runtime rewrites `/` to the root plugin's `routePrefix`, so the URL stays `/` while the plugin renders, and the plugin remains reachable at its own prefix. An admin can change this to any installed, enabled, non-adminOnly plugin via Console (CON-11). The first middle-section sidebar icon always resolves the current root plugin's icon and routes to `/`. ### 3.9 Plugin Loading Model @@ -726,7 +726,7 @@ Granular per-user capability overrides and per-plugin role assignments are expli | PLT-11 | The desktop sidebar must have three sections: (1) top branding header, (2) middle plugin icon area, (3) bottom fixed chrome with Console (admin only) and Account avatar (all users). | | PLT-12 | The middle sidebar section must show one icon per accessible, enabled, non-chrome plugin. The first icon is always the configured root plugin, points to `/`, and cannot be removed or reordered by users in v1. The root plugin is not duplicated among the remaining icons. When the Launcher is not the root plugin, it appears in the middle section as a regular icon linking to `/launcher`. | | PLT-13 | The mobile footer launcher must mirror the middle sidebar section. The Account avatar appears in the mobile header; the Console icon appears in the footer launcher (admin only). | -| PLT-14 | The platform must maintain a configurable `root_plugin_id` setting (default: `fs.sovereign.launcher`). Navigating to `/` redirects to the root plugin's `routePrefix`. | +| PLT-14 | The platform must maintain a configurable `root_plugin_id` setting (default: `fs.sovereign.launcher`). Navigating to `/` serves the root plugin in place — the runtime rewrites `/` to the root plugin's `routePrefix` (the URL stays `/`; the plugin is also reachable at its own prefix). | | PLT-15 | A `platform_settings` table in `packages/db` stores key-value platform configuration scoped by `tenant_id`. Initial key: `root_plugin_id`. | ### 4.3 Functional Requirements — Auth