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
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:
co-authored by
Claude Opus 5.5
parent
34692ac05f
commit
89ef4b5ff6
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user