feat: add Dashboard navigation as presentation configuration
Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac5b2b7f6c
commit
bd34c9c714
@@ -0,0 +1,268 @@
|
||||
# 0027: Dashboard navigation is presentation configuration, and modules stay flat
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-22
|
||||
- Extends: [0021](0021-modules-as-hostable-products.md),
|
||||
[0026](0026-minimal-ekklesia-module-ui-host.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0026 gave Ekklesia a browser shell that mounts discovered module UI. Its navigation was a flat
|
||||
list of **module ids** — `livestream`, `announcements` — which is not a usable long-term experience,
|
||||
and it had no notion of organizing anything.
|
||||
|
||||
The obvious next step carries an equally obvious trap. A church wants to see:
|
||||
|
||||
```text
|
||||
Media Planning Communication
|
||||
├── Livestream ├── Calendar └── Announcements
|
||||
└── Video └── Roster
|
||||
```
|
||||
|
||||
and the tempting way to build that is to let a module say which folder it belongs to, or worse, to
|
||||
model "Media" as something Livestream and Video belong *to*. Either turns a rendering preference
|
||||
into architecture: a folder becomes an owner, membership starts to look like a dependency, and the
|
||||
flat, independently versioned module graph ADR 0021 established quietly acquires a hierarchy nobody
|
||||
decided to add.
|
||||
|
||||
Two facts from the repository shaped what was actually needed.
|
||||
|
||||
**A display name already exists.** `moduleManifestSchema` has required `name` (trimmed, 1–120
|
||||
characters) since version 1, and every fixture and the template already set it — `"Example Module"`.
|
||||
Nothing needed adding to the manifest to stop rendering raw ids.
|
||||
|
||||
**Nothing else exists.** There is no settings or configuration table, no key-value store, no
|
||||
organization or tenant model, and no client-side routing anywhere in the repository. Whatever stores
|
||||
an arrangement had to be built, and could not be scoped to an organization that does not exist.
|
||||
|
||||
## Decision
|
||||
|
||||
### Grouping is presentation configuration. It is never module structure.
|
||||
|
||||
A Dashboard group is a label on a rendering bucket. It owns no tables, no migrations, no routes, and
|
||||
no lifecycle; it has no manifest, no version, and no artifact; nothing depends on it and it depends
|
||||
on nothing. **It is not a module**, and "Media" existing does not make Livestream and Video related
|
||||
in any way the platform can observe.
|
||||
|
||||
The distinction the architecture has to keep sharp:
|
||||
|
||||
- *Livestream depends on Video because it calls Video's functionality* — a real technical
|
||||
dependency, declared through the existing module dependency contract, which orders discovery and
|
||||
startup.
|
||||
- *Livestream is shown under Media* — a rendering preference an administrator can change at any time,
|
||||
which orders nothing.
|
||||
|
||||
Nothing in this pass introduces a parent module, a child module, an owning module, a container
|
||||
module, or a group-derived dependency edge. The words do not appear in the contracts, and the
|
||||
absence is enforced by tests rather than left to discipline (see **Consequences**).
|
||||
|
||||
### No manifest change, and no version 5
|
||||
|
||||
`manifest.name` is the display name. It is the module's **product identity** — what the product is
|
||||
called — and a module is exactly the right owner of that. Adding a `presentation.name` beside it
|
||||
would have created two names for one thing and forced `manifestVersion: 5`, a hard cutover for every
|
||||
installed module (ADR 0006's convention: each version rejects all others) in exchange for nothing.
|
||||
|
||||
`PublishableModuleUi` gained `name`, carried from the manifest through the existing publication
|
||||
registry. That is a Core-internal record, not a manifest change.
|
||||
|
||||
### `suggestedGroup` is deferred, deliberately
|
||||
|
||||
A module suggesting a default folder is a reasonable idea and was rejected **for this pass** on three
|
||||
grounds, in order of weight.
|
||||
|
||||
It would force `manifestVersion: 5` — the most disruptive change available — for a *hint*. Its only
|
||||
correct semantics ("used when a placement is first materialized, never re-applied over an
|
||||
administrator's decision") requires configuration-initialization logic that writes rows on first
|
||||
sight of a module; without that machinery the field would be either inert or authoritative, and
|
||||
authoritative is the outcome this ADR exists to prevent. And it is not needed: an unplaced module is
|
||||
simply ungrouped, which is deterministic, requires no write at all, and is strictly simpler than
|
||||
initializing state.
|
||||
|
||||
Shipping the field now with semantics we could not yet honour would be adding a misleading contract
|
||||
because we know we want the idea eventually. When there is a reason to bump the manifest version,
|
||||
this can travel with it.
|
||||
|
||||
### Platform-owned, server-side, platform-global persistence
|
||||
|
||||
Two Core tables (`core/src/dashboard/schema.ts`), following the `ekklesia_core_*` conventions
|
||||
exactly, in migration `0003_dashboard_navigation_tables`:
|
||||
|
||||
- `ekklesia_core_dashboard_group` — `id` (opaque stable key), `label`, `sort_order`.
|
||||
- `ekklesia_core_dashboard_placement` — `module_id` (primary key: at most one placement per module),
|
||||
nullable `group_id`, `sort_order`.
|
||||
|
||||
A church configures its Dashboard, not one browser, so this is server state rather than
|
||||
`localStorage`. It is **platform-global**, because Ekklesia has no organization or tenant model;
|
||||
inventing a second notion of organization to anticipate one would be worse than adding a scope column
|
||||
when a real one exists. That limitation is recorded rather than hidden.
|
||||
|
||||
Three schema decisions carry invariants rather than convenience:
|
||||
|
||||
`group_id` is `on delete set null`. Deleting a group cannot leave a dangling reference and cannot
|
||||
lose a module — its modules become ungrouped, enforced by the database rather than by application
|
||||
cleanup that could be skipped.
|
||||
|
||||
`module_id` carries **no foreign key**, because modules are packages discovered on disk, not rows. A
|
||||
placement for an uninstalled module is therefore normal and is retained, so uninstall-and-reinstall
|
||||
preserves an arrangement instead of silently resetting it.
|
||||
|
||||
The group `id` is opaque and never re-derived from the label. Renaming "Media" to "Sunday Production"
|
||||
changes `label` alone, so every placement survives — a rename can never re-organize the Dashboard.
|
||||
|
||||
### The resolution rules, stated exactly
|
||||
|
||||
`resolveDashboardNavigation` is a pure function of stored configuration and the currently mountable
|
||||
module set. Every rule is directly testable without PostgreSQL:
|
||||
|
||||
- **A module with no placement is ungrouped and visible.** Installing a module is sufficient to see
|
||||
it: no initialization write, no manual database edit, no migration. This is the rule that stops a
|
||||
newly installed module from disappearing.
|
||||
- **A stored placement for an absent module renders nothing and is retained.** No dead entry; no lost
|
||||
position on reinstall.
|
||||
- **A placement naming a missing group is treated as ungrouped**, not dropped. `on delete set null`
|
||||
makes this unreachable in practice; a configuration inconsistency must still never make a module
|
||||
invisible.
|
||||
- **Empty groups are kept.** Hiding one would look like it had been deleted.
|
||||
- **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)` — placed
|
||||
modules in the administrator's order, then never-configured ones by id, so a newcomer never
|
||||
interrupts an ordering someone chose. Colliding order values can never produce a
|
||||
nondeterministic result; the identifier is always the final tie-break.
|
||||
|
||||
Simple integers, not fractional ordering or CRDTs. Reordering rewrites the values it needs to.
|
||||
|
||||
### Navigation is layered on the inventory, not merged into it
|
||||
|
||||
`GET /api/v1/module-ui` (ADR 0026) still answers *what is mountable*, host-neutrally, and is
|
||||
unchanged — no display name, no group, no order. A standalone host consumes it and never learns
|
||||
Ekklesia has folders.
|
||||
|
||||
`GET /api/v1/dashboard/navigation` answers *how this Dashboard presents those modules*, and
|
||||
**embeds** each generic descriptor untouched inside a presentation wrapper:
|
||||
|
||||
```json
|
||||
{ "groups": [ { "id": "media", "label": "Media", "order": 10,
|
||||
"modules": [ { "name": "Livestream", "module": { "id": "livestream", "…": "…" } } ] } ],
|
||||
"ungrouped": [] }
|
||||
```
|
||||
|
||||
Embedding rather than flattening is the layering made visible in the payload, and both endpoints
|
||||
compose the descriptor through one shared function, so they cannot disagree about what exists or
|
||||
about any URL. The server is authoritative for the resolved view: the browser does no grouping, no
|
||||
sorting, and no joining, and reads no manifest.
|
||||
|
||||
### Reading versus organizing
|
||||
|
||||
Reading requires `core.modules.ui.read` — the same permission the inventory requires, because this
|
||||
response *contains* that inventory and serves the same audience: any signed-in user needs it to see
|
||||
anything at all. A second read permission for the same data would be ceremony.
|
||||
|
||||
Organizing requires a new **platform** permission, `core.dashboard.navigation.manage`
|
||||
(baseline `restricted`), never a module's — grouping is Ekklesia's configuration, so no module may
|
||||
gate, grant, or influence it. Mutations additionally require an exact trusted `Origin` and a bounded
|
||||
body before authorization work begins, exactly like every other Core mutation.
|
||||
|
||||
Holding it grants no module authority. Moving a module into a folder changes a presentation row; it
|
||||
grants nobody access to that module's data, and every module route stays enforced against its own
|
||||
declared permission.
|
||||
|
||||
### A narrow, structured mutation contract — no configuration blob
|
||||
|
||||
Three idempotent operations, each with closed validation that rejects any unexpected key rather than
|
||||
dropping it:
|
||||
|
||||
```text
|
||||
PUT /api/v1/dashboard/navigation/groups/:groupId { label, order }
|
||||
DELETE /api/v1/dashboard/navigation/groups/:groupId
|
||||
PUT /api/v1/dashboard/navigation/placements/:moduleId { groupId | null, order }
|
||||
```
|
||||
|
||||
`PUT` against a client-chosen stable id makes create and rename the same operation, so a rename can
|
||||
never orphan a placement. A "save the whole dashboard config" blob was rejected: it has no
|
||||
invariants, no per-operation authority, and no way to reject a field nobody meant to send.
|
||||
|
||||
No editor UI ships in this pass. The persistence model is proven through the real API instead, which
|
||||
is the repository's normal pattern.
|
||||
|
||||
### One level of grouping
|
||||
|
||||
`Group → Modules`. No nesting, no subgroups. The schema has no parent-group column, so the shape
|
||||
itself forecloses it rather than a rule asking developers not to. Nested navigation can be
|
||||
reconsidered when a real use case appears.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A `presentation.name` manifest field.** Rejected: `manifest.name` already is the display name,
|
||||
and adding a second would mean two names for one product and a version 5 cutover for nothing.
|
||||
- **`suggestedGroup` now.** Deferred; see above. It buys a default at the cost of a manifest version
|
||||
and initialization machinery, and risks becoming authoritative.
|
||||
- **A module declaring its own folder authoritatively.** Rejected outright — it is the mistake this
|
||||
ADR exists to prevent. An administrator must be able to move a module without touching it.
|
||||
- **Re-applying a module's suggestion on every startup.** Rejected for the same reason: defaulting
|
||||
and ownership are different, and a suggestion that overwrites a decision is ownership.
|
||||
- **Modelling a group as a module.** Rejected: it would give a rendering bucket a manifest, a
|
||||
version, a lifecycle, and a place in the dependency graph, and would make "Livestream belongs under
|
||||
Media" indistinguishable from "Livestream depends on Media."
|
||||
- **Browser `localStorage`.** Rejected: a church configures its Dashboard, not one browser on one
|
||||
device.
|
||||
- **A generalized platform settings framework.** Rejected: two purpose-built tables with real
|
||||
constraints beat a key-value blob whose invariants live only in application code, and nothing else
|
||||
needs settings yet.
|
||||
- **Extending the module UI inventory with groups.** Rejected: it would push Dashboard semantics into
|
||||
the host-neutral contract a standalone host is meant to consume.
|
||||
- **Making the browser join inventory and configuration.** Rejected: it would put grouping logic —
|
||||
and therefore the ordering rules — in the client, where a second implementation could drift.
|
||||
- **Deleting placements for uninstalled modules.** Rejected: retaining them makes reinstall restore
|
||||
an arrangement, at the cost of a few harmless rows.
|
||||
- **Hiding empty groups.** Rejected: indistinguishable from deletion, from the administrator's side.
|
||||
- **Fractional or CRDT ordering.** Rejected as unjustified for a list a person arranges by hand.
|
||||
- **Nested folders, icons, drag and drop, a navigation editor, client routing.** All deferred; each
|
||||
needs a design decision this pass should not make by accident. Icons in particular would
|
||||
immediately require an icon vocabulary, an asset-publication story, and a trust decision about
|
||||
arbitrary SVG.
|
||||
|
||||
## Consequences
|
||||
|
||||
Ekklesia has a real Dashboard navigation model: persistent, platform-owned, administrator-editable
|
||||
through a structured API, rendered by the shell, mounting flat modules through the unchanged generic
|
||||
loader. A module now shows as "Livestream" rather than `livestream`.
|
||||
|
||||
**The flatness invariant is executable, not aspirational.** `core/tests/dashboard-flat-modules.test.ts`
|
||||
discovers real fixtures with a genuine dependency (Livestream depends on Video), then arranges them
|
||||
four ways — including both modules under one "Media" folder, and the dependency deliberately inverted
|
||||
in presentation — and asserts that discovery order, dependency declarations, capabilities,
|
||||
permissions, and the module set come out byte-identical every time, and that no group id is ever a
|
||||
module. A future change that let grouping influence any of that fails there.
|
||||
|
||||
The end-to-end test goes further: it organizes a real installation through the real mutation API,
|
||||
bootstraps the real shell code against it, mounts a module from inside a group, moves that module to
|
||||
a different group, and asserts the navigation entry is **byte-identical** across the move — same
|
||||
artifact URL, same API root, same permissions, same version, same name — and still mounts and reaches
|
||||
the same API.
|
||||
|
||||
Four things changed outside the new code. `createApiApp` takes a required `dashboard` store.
|
||||
`PublishableModuleUi` carries `name`. Core has a third permission, which moved five existing
|
||||
assertions (each updated to name all three permissions rather than to loosen a count). And the shell's
|
||||
bootstrap now resolves navigation instead of the raw inventory, which made its client for
|
||||
`GET /api/v1/module-ui` dead code; that client was **removed** rather than kept speculatively, and
|
||||
its descriptor-validation coverage moved onto the navigation parser. The endpoint itself is unchanged
|
||||
and remains fully covered by `apps/api`'s tests.
|
||||
|
||||
Two costs worth naming. Navigation configuration is platform-global and will need a scope column when
|
||||
an organization model exists. And the navigation endpoint evaluates every listed module's declared
|
||||
permissions per request, inheriting ADR 0026's per-request evaluation cost; for a handful of modules
|
||||
that is unremarkable, and it is measured work rather than hidden.
|
||||
|
||||
## Deferred
|
||||
|
||||
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
|
||||
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
|
||||
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
|
||||
defaulting logic; per-module administrator label overrides; Dashboard Home, widgets, summaries, and
|
||||
quick actions; permission-aware navigation visibility (whether to hide a module a viewer can do
|
||||
nothing in remains an open UX policy); client-side routing and deep links to a selected module;
|
||||
organization or tenant scoping; a design system, theme tokens, and CSS asset publication; and every
|
||||
real feature module.
|
||||
|
||||
The permission-id typing gap carried since ADR 0022 is unchanged: `permissions.has` takes a plain
|
||||
string, and the wire arrays are `string[]`, so a typo still silently hides a control.
|
||||
@@ -35,3 +35,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0024: Browser-safe module UI SDK and the generic host loader](0024-browser-safe-ui-sdk-and-host-loader.md) — Accepted
|
||||
- [0025: Trusted module UI asset publication](0025-trusted-module-ui-asset-publication.md) — Accepted
|
||||
- [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted
|
||||
- [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted
|
||||
|
||||
@@ -267,9 +267,40 @@ checked to contain exactly one dynamic import with no module identity baked in.
|
||||
from Core's own evaluator and stay presentation-only — server authorization is proven unchanged by a
|
||||
test in which a UI holding no grant calls its API anyway and is refused.
|
||||
|
||||
**This is not the Dashboard.** No navigation, grouping, icons, ordering, design system, or routing;
|
||||
the selector lists module ids. A production CSP and a deployment for the built shell, sign-in UI,
|
||||
general asset publication, and the standalone host remain future work.
|
||||
**Phase 6D pass 1 adds the Dashboard navigation model, and fixes the rule it could have broken.**
|
||||
[ADR 0027](decisions/0027-dashboard-navigation-and-flat-modules.md) establishes that a Dashboard
|
||||
group is **presentation configuration and never module structure**. A group is a label on a rendering
|
||||
bucket: no manifest, version, artifact, API, permission, or lifecycle, and nothing depends on it.
|
||||
Placing two modules in one folder creates no relationship between them; *Livestream depends on Video
|
||||
because it calls Video's functionality* orders startup, while *Livestream is shown under Media*
|
||||
orders nothing. Modules stay flat, with no parent, child, owner, or group-derived dependency.
|
||||
|
||||
No manifest change was needed: `manifest.name` has been required since version 1 and already is the
|
||||
module's product identity, so the shell renders it instead of the module id. `suggestedGroup` was
|
||||
deferred rather than shipped — it would force a hard manifest-version cutover for a hint, and its
|
||||
only correct semantics needs initialization logic that risks a suggestion overwriting an
|
||||
administrator's decision.
|
||||
|
||||
Core owns the persistence: two `ekklesia_core_dashboard_*` tables (migration `0003`) holding groups
|
||||
and placements, platform-global because no organization model exists yet. `group_id` is
|
||||
`on delete set null`, so deleting a group ungroups its modules and a dangling reference is
|
||||
impossible; `module_id` carries no foreign key, because modules are packages rather than rows, so a
|
||||
placement for an uninstalled module is retained and restores its position on reinstall.
|
||||
`resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced
|
||||
module is ungrouped and visible with no initialization write, an absent module renders nothing,
|
||||
empty groups are kept, and ordering is total with the identifier as final tie-break.
|
||||
|
||||
`GET /api/v1/dashboard/navigation` returns the resolved view, layered **on top of** the host-neutral
|
||||
module UI inventory rather than merged into it — it embeds each generic descriptor untouched, so a
|
||||
standalone host consuming the inventory never inherits Dashboard semantics. Reading requires the same
|
||||
permission the inventory does; organizing requires the new platform permission
|
||||
`core.dashboard.navigation.manage` through a narrow, structured, idempotent mutation contract. The
|
||||
shell renders that arrangement and computes none of it.
|
||||
|
||||
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
|
||||
Dashboard Home or widgets, no design system, and no client routing. A production CSP and a deployment
|
||||
for the built shell, sign-in UI, general asset publication, and the standalone host remain future
|
||||
work.
|
||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||
are all unchanged.
|
||||
|
||||
|
||||
@@ -58,6 +58,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
import from `@ekklesia/module-ui-sdk` to close the browser boundary. `module-sdk` re-exports the
|
||||
same barrel and is excluded for the same reason. A surface declares its own types for the wire and
|
||||
validates what arrives ([ADR 0026](decisions/0026-minimal-ekklesia-module-ui-host.md)).
|
||||
- Dashboard navigation configuration is **Core-owned platform state**, never module-owned: Core
|
||||
stores groups and placements and resolves the view, `apps/api` exposes it, and `surfaces/shell`
|
||||
renders it. A group is presentation configuration and never module structure — it has no manifest,
|
||||
version, artifact, or lifecycle, and nothing in discovery, dependency resolution, or module startup
|
||||
may ever read it
|
||||
([ADR 0027](decisions/0027-dashboard-navigation-and-flat-modules.md)). A module's manifest declares
|
||||
its own display name and nothing about where it is presented.
|
||||
- The browser host (`surfaces/shell`) statically imports no module. Every installed module UI it can
|
||||
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
|
||||
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
|
||||
|
||||
@@ -77,6 +77,8 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
|
||||
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's API lives, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
|
||||
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
||||
|
||||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||
@@ -452,6 +454,94 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
|
||||
registry: installing, removing, or editing a module's routes takes effect only on the next API
|
||||
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
|
||||
|
||||
## Dashboard navigation
|
||||
|
||||
```text
|
||||
GET /api/v1/dashboard/navigation
|
||||
PUT /api/v1/dashboard/navigation/groups/<group-id> { "label": "Media", "order": 10 }
|
||||
DELETE /api/v1/dashboard/navigation/groups/<group-id>
|
||||
PUT /api/v1/dashboard/navigation/placements/<module-id> { "groupId": "media"|null, "order": 10 }
|
||||
```
|
||||
|
||||
How this installation presents its mountable modules
|
||||
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)).
|
||||
|
||||
```json
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"id": "media",
|
||||
"label": "Media",
|
||||
"order": 10,
|
||||
"modules": [
|
||||
{
|
||||
"name": "Livestream",
|
||||
"module": {
|
||||
"id": "livestream",
|
||||
"version": "1.0.0",
|
||||
"ui": { "url": "/api/v1/module-ui/livestream/1.0.0" },
|
||||
"api": { "baseUrl": "/api/v1/modules/livestream" },
|
||||
"permissions": ["livestream.streams.read"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ungrouped": []
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation versus inventory
|
||||
|
||||
They are deliberately separate, and this route is layered **on top of** the inventory rather than
|
||||
merged into it:
|
||||
|
||||
- `GET /api/v1/module-ui` answers *what may be mounted*, host-neutrally. A standalone host consumes
|
||||
it and never learns Ekklesia has folders. It carries no display name, group, or order.
|
||||
- `GET /api/v1/dashboard/navigation` answers *how this Dashboard presents those modules*, and
|
||||
**embeds** each generic descriptor untouched under `module`.
|
||||
|
||||
Both compose that descriptor through one shared function, so they cannot disagree about what exists
|
||||
or about any URL.
|
||||
|
||||
### Grouping is presentation, never module structure
|
||||
|
||||
A group is a label on a rendering bucket. It is **not a module**: it has no manifest, version,
|
||||
artifact, API, permission, or lifecycle, and nothing depends on it. Placing Livestream and Video in
|
||||
one folder creates no relationship between them, and moving a module changes a single presentation
|
||||
row — never its artifact, API root, permissions, dependencies, or startup order. Real technical
|
||||
dependencies remain the module dependency contract's job and are the only relationship modules have.
|
||||
|
||||
### Resolution rules
|
||||
|
||||
- A module with **no placement is ungrouped and visible** — installing it is enough, with no
|
||||
initialization write and no manual database edit.
|
||||
- A stored placement for a module that is **not installed renders nothing and is retained**, so
|
||||
uninstall-and-reinstall preserves an arrangement.
|
||||
- **Empty groups are kept** (hiding one would look like deletion), and a placement naming a missing
|
||||
group renders as ungrouped rather than disappearing.
|
||||
- Deleting a group **ungroups its modules** through the schema's `on delete set null`; a dangling
|
||||
reference is impossible.
|
||||
- Ordering is total: groups by `(order, id)`, modules by `(configured, order, id)` — placed modules
|
||||
in the administrator's order, then never-configured ones by id, with the identifier always the
|
||||
final tie-break, so colliding order values are still deterministic.
|
||||
|
||||
### Authority
|
||||
|
||||
Reading requires `core.modules.ui.read` — the same permission the inventory requires, because this
|
||||
response contains it and serves the same audience. Organizing requires
|
||||
`core.dashboard.navigation.manage` (baseline `restricted`), a **platform** permission and never a
|
||||
module's, plus an exact trusted `Origin` and a bounded body. Holding it grants no module authority:
|
||||
every module route stays enforced against its own declared permission, and no arrangement of folders
|
||||
changes an authorization decision.
|
||||
|
||||
Group ids are opaque, stable, lowercase kebab-case keys, never displayed and never re-derived from a
|
||||
label — so renaming a group cannot re-organize the Dashboard. Bodies are closed: any key beyond the
|
||||
declared ones makes the whole request invalid rather than being silently dropped. Configuration is
|
||||
platform-global; Ekklesia has no organization or tenant model to scope it to yet.
|
||||
|
||||
Responses are per-principal, so `Cache-Control: no-store` and `Vary: Origin, Cookie`.
|
||||
|
||||
## Module UI inventory
|
||||
|
||||
```text
|
||||
|
||||
@@ -152,6 +152,22 @@ shell owns the container, the loading state, and every error, so a failing modul
|
||||
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
|
||||
JavaScript, exactly as described above.
|
||||
|
||||
### Dashboard arrangement is not an access control
|
||||
|
||||
Dashboard groups and placements are presentation configuration
|
||||
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). Organizing the
|
||||
Dashboard requires the platform permission `core.dashboard.navigation.manage` (baseline
|
||||
`restricted`), and holding it **grants no module authority whatsoever**: moving a module into a
|
||||
folder changes one presentation row, and every module route stays enforced against its own declared
|
||||
permission exactly as before. Nothing in the navigation configuration is read by discovery,
|
||||
dependency resolution, module startup, or the authorization evaluator.
|
||||
|
||||
Navigation is also not a way to hide anything. A module absent from a viewer's navigation is a
|
||||
rendering decision, never a restriction — server authorization remains the only thing that decides
|
||||
what a request may do. Reading navigation requires the same `core.modules.ui.read` the module UI
|
||||
inventory requires, so it discloses the same installed-module enumeration and nothing more; it
|
||||
carries no filesystem path, package root, or manifest internal.
|
||||
|
||||
### Client-side permission checks are not a security boundary
|
||||
|
||||
A host may tell a module UI what permissions it believes the viewer holds
|
||||
|
||||
@@ -155,6 +155,27 @@ module's own HTTP routes, and creating relationship-derived grants automatically
|
||||
own business logic, are both still later work; this only makes a declared permission *usable*
|
||||
through Core's existing, generic surface.
|
||||
|
||||
### Your module's display name
|
||||
|
||||
`name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees
|
||||
in Ekklesia's Dashboard navigation. It is your module's **product identity**: what the product is
|
||||
called, which you own. Set it to something a church staff member would recognize ("Livestream"), not
|
||||
your package name or module id.
|
||||
|
||||
**You do not declare where your module appears.** Dashboard groups — "Media", "Planning", "Sunday
|
||||
Production" — are presentation configuration an administrator owns, stored by the platform and
|
||||
changed at any time without touching your module
|
||||
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). There is no
|
||||
manifest field for a folder, a group, an order, an icon, or a category, and adding one would make a
|
||||
rendering preference into module structure.
|
||||
|
||||
That separation also means grouping never affects you. Two modules sharing a folder have no
|
||||
relationship: no dependency, no ordering, no shared anything. If your module genuinely needs another
|
||||
module's functionality, declare it in `dependencies.modules` — that is the only kind of module
|
||||
relationship the platform has, and it is what orders discovery and startup. An administrator moving
|
||||
your module into a different folder changes nothing about your artifact, your API root, your
|
||||
permissions, your dependencies, or when you start.
|
||||
|
||||
Every entrypoint except `migrations` must be a compiled ESM `.js` or `.mjs` file — `server`,
|
||||
`schema`, `jobs`, and `ui` alike. `migrations` is the one directory entrypoint. All entrypoints begin
|
||||
with `./`, remain inside the package, and exist. Production never imports source TypeScript directly.
|
||||
@@ -499,9 +520,10 @@ Three things follow for you:
|
||||
API root; a path that escapes it (`"../other-module/records"`, an absolute URL) is refused before
|
||||
it becomes a request. Keep asking for relative paths and your artifact stays hostable elsewhere.
|
||||
|
||||
> **This is not the Dashboard.** The shell is a module list and a mount target. There is no
|
||||
> navigation, grouping, icons, ordering, theme, or design system yet, and the selector shows your
|
||||
> module's id — the inventory deliberately carries no display metadata until that design exists.
|
||||
> **This is not the finished Dashboard.** The shell renders one level of grouping and mounts the
|
||||
> selected module. Your module appears under whatever group an administrator placed it in — by its
|
||||
> `name`, never its id — and under "Other" until someone arranges it. There is no navigation editor,
|
||||
> no nested folders, no icons, no theme, and no Dashboard Home yet.
|
||||
|
||||
### What a module UI will never own
|
||||
|
||||
|
||||
Reference in New Issue
Block a user