feat(shell): Settings hierarchy, Discover, and /settings/<module-id>
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s

The Settings foundation (ADR 0061):

- URL model. /settings is a real landing page. The platform sections
  are /settings/general, /branding, /displays, and /system. Module
  lifecycle is Discover: /settings/discover/browse, /installed, and
  /updates. /settings/discover is respelled in place to Browse's one
  canonical address. A module's settings are /settings/<module-id>,
  never nested under Discover. Deeper Settings addresses are
  reserved. "Modules" stays the user-facing word: "Discover modules",
  "Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
  names (branding, discover, displays, general, system) as a second
  documented group, so no module id can shadow a section; `modules` is
  an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
  into Platform and Modules (and Discover's three views) while in
  Settings, and the phone drawer has the same hierarchy. The rest of
  the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
  Core's evaluator, which destinations the viewer may see:
  - Displays: core.devices.manage
  - Discover: core.modules.lifecycle.manage
  - a module's settings: core.modules.configuration.manage, for each
    module that declares configuration (ADR 0029)
  - General, Branding, System: the platform owner, until each has its
    own permission
  These are presentation hints; every data route keeps its own
  enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
  module's settings say it is turned off; a section or module the
  person cannot see says so without detail; an unknown name is not
  found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
  enters Discover, opens the module's settings, uses the phone
  drawer, and returns to the Dashboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 22:22:50 -05:00
co-authored by Claude Opus 5.5
parent 34692ac05f
commit 89ef4b5ff6
30 changed files with 2343 additions and 50 deletions
+3 -3
View File
@@ -617,11 +617,11 @@ async function scanModuleDirectory(
}
if (isReservedModuleId(manifest.id)) {
// A module's Dashboard page is `/<module-id>` (ADR 0057), in the same URL space as the
// platform's own top-level namespaces.
// A module's id is a stable URL identifier: its page is `/<module-id>` (ADR 0057) and its
// settings `/settings/<module-id>` (ADR 0061), beside the platform's own pages in both.
issues.push({
code: "MODULE_ID_RESERVED",
message: `Module ID ${manifest.id} is reserved: the platform uses /${manifest.id} for its own pages and routes.`,
message: `Module ID ${manifest.id} is reserved for the platform's own pages and routes.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "id",
+36 -13
View File
@@ -1,24 +1,23 @@
/**
* Top-level names the platform owns, which therefore can never be a module id (ADR 0057).
* Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061).
*
* Every module with a Dashboard UI is addressed at `/<module-id>`, beside the platform's own
* top-level namespaces in one URL space. A module called `settings` would collide with the
* Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the
* platform serves today, not a guess at what it might want later:
* A module id is a **stable URL identifier**. Every module with a Dashboard UI is addressed at
* `/<module-id>`, and every module with settings at `/settings/<module-id>`, beside the platform's
* own pages in the same two namespaces. A module called `settings` would collide with the
* Dashboard's Settings, and one called `discover` with Settings → Discover. This is the complete set
* the platform serves today, not a guess at what it might want later.
*/
/**
* Top-level namespaces (`/<name>`):
*
* - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts,
* display descriptors, and realtime (`/api/…`);
* - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`,
* `/assets/ekklesia-display/…`);
* - `assets` — the Dashboard's and display host's scripts (`/assets/…`);
* - `display` — the display host's pages and service worker (`/display/…`);
* - `settings` and `account` — the Dashboard's own Settings and account areas.
*
* `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the
* shell interprets, so a new top-level platform route cannot be added without reserving it here.
* Reserving a name is a breaking change for any installation with a module of that id, so the set
* grows only deliberately.
*/
export const RESERVED_MODULE_IDS = Object.freeze([
export const RESERVED_TOP_LEVEL_NAMES = Object.freeze([
"account",
"api",
"assets",
@@ -26,6 +25,30 @@ export const RESERVED_MODULE_IDS = Object.freeze([
"settings",
] as const);
/**
* The platform's own Settings sections (`/settings/<name>`): General, Branding, Displays, Discover
* (finding, installing, and managing modules), and System. Every other `/settings/<name>` is a
* module's settings. Discover's own pages live beneath it (`/settings/discover/…`), so they cannot
* collide with a module and need no reservation of their own.
*/
export const RESERVED_SETTINGS_NAMES = Object.freeze([
"branding",
"discover",
"displays",
"general",
"system",
] as const);
/**
* Every reserved name, sorted. `apps/api` holds this list to the routes it actually registers and to
* the pages and Settings sections the shell interprets, so a new platform route or Settings section
* cannot be added without reserving it here. Reserving a name is a breaking change for any
* installation with a module of that id, so the set grows only deliberately.
*/
export const RESERVED_MODULE_IDS = Object.freeze(
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
);
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
export function isReservedModuleId(moduleId: string): boolean {
+43 -4
View File
@@ -8,6 +8,8 @@ import {
ModuleDiscoveryError,
type ModuleDiscoveryIssueCode,
RESERVED_MODULE_IDS,
RESERVED_SETTINGS_NAMES,
RESERVED_TOP_LEVEL_NAMES,
} from "../src/modules/index.js";
interface ModuleFixtureOptions {
@@ -277,8 +279,31 @@ describe("module discovery", () => {
// Every module with a Dashboard UI is addressed at `/<module-id>`, so an id equal to a
// platform-owned top-level namespace would collide with the API, the display host, a host
// script, or a Dashboard page.
expect(RESERVED_MODULE_IDS).toEqual(["account", "api", "assets", "display", "settings"]);
expect(Object.isFrozen(RESERVED_MODULE_IDS)).toBe(true);
expect(RESERVED_TOP_LEVEL_NAMES).toEqual(["account", "api", "assets", "display", "settings"]);
// A module's settings live at `/settings/<module-id>` (ADR 0061), beside the platform's own
// Settings sections, so their names are reserved too.
expect(RESERVED_SETTINGS_NAMES).toEqual([
"branding",
"discover",
"displays",
"general",
"system",
]);
expect(RESERVED_MODULE_IDS).toEqual([
"account",
"api",
"assets",
"branding",
"discover",
"display",
"displays",
"general",
"settings",
"system",
]);
for (const list of [RESERVED_MODULE_IDS, RESERVED_TOP_LEVEL_NAMES, RESERVED_SETTINGS_NAMES]) {
expect(Object.isFrozen(list)).toBe(true);
}
for (const id of RESERVED_MODULE_IDS) {
const modulesDirectory = await createModulesDirectory();
@@ -299,7 +324,17 @@ describe("module discovery", () => {
it("accepts ids that merely resemble a reserved name", async () => {
const modulesDirectory = await createModulesDirectory();
for (const id of ["settings-sync", "my-account", "displays", "api-docs"]) {
// `modules` is an ordinary id again: module management is Settings → Discover.
for (const id of [
"settings-sync",
"my-account",
"display-board",
"api-docs",
"general-info",
"modules",
"discovery",
"systems",
]) {
await createModule(modulesDirectory, id);
}
@@ -311,9 +346,13 @@ describe("module discovery", () => {
expect(modules.map((module) => module.manifest.id).sort()).toEqual([
"api-docs",
"displays",
"discovery",
"display-board",
"general-info",
"modules",
"my-account",
"settings-sync",
"systems",
]);
expect(isReservedModuleId("settings")).toBe(true);
expect(isReservedModuleId("settings-sync")).toBe(false);