feat: add Dashboard navigation as presentation configuration #26
@@ -48,6 +48,11 @@ pnpm infrastructure:config
|
|||||||
the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
|
the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
|
||||||
installed module UIs through `GET /api/v1/module-ui` and mounts them with
|
installed module UIs through `GET /api/v1/module-ui` and mounts them with
|
||||||
`@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026).
|
`@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026).
|
||||||
|
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
||||||
|
module, child module, owning module, or group-derived dependency: real module relationships are
|
||||||
|
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
||||||
|
ordering, startup, or authorization (ADR 0027). A module declares its display name
|
||||||
|
(`manifest.name`); the Dashboard owns where it appears.
|
||||||
- A surface is browser code. It may depend on browser-safe shared roles only — never
|
- A surface is browser code. It may depend on browser-safe shared roles only — never
|
||||||
`@ekklesia/contracts` or `@ekklesia/module-sdk`, whose type graphs pull Node's globals into the
|
`@ekklesia/contracts` or `@ekklesia/module-sdk`, whose type graphs pull Node's globals into the
|
||||||
browser — and it reaches the server over HTTP.
|
browser — and it reaches the server over HTTP.
|
||||||
|
|||||||
@@ -355,10 +355,42 @@ still refused `403` when the UI calls the API anyway. The host owns the containe
|
|||||||
and every failure; a broken module costs its own panel, not the shell. See
|
and every failure; a broken module costs its own panel, not the shell. See
|
||||||
[ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md).
|
[ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md).
|
||||||
|
|
||||||
**This is not the Dashboard.** There is no navigation model, grouping, folders, icons, ordering, home
|
Phase 6D pass 1 gives that shell a real **navigation model** — and pins down the rule it could
|
||||||
screen, design system, or client-side routing — the selector is a list of module ids. Sign-in UI, a
|
easily have broken. A church sees `Media → Livestream, Video` and `Planning → Calendar`, while the
|
||||||
production CSP and deployment for the built shell, asset publication beyond the UI entrypoint, React
|
modules underneath stay exactly what they were: flat, independently versioned products with no
|
||||||
runtime sharing, the standalone host, and every real feature module also do not exist.
|
parent, no owner, and no folder-derived dependency.
|
||||||
|
|
||||||
|
**Grouping is presentation configuration, not module structure.** A group is a label on a rendering
|
||||||
|
bucket: no manifest, no version, no artifact, no API, no lifecycle, and nothing depends on it.
|
||||||
|
Placing Livestream and Video in one folder creates no relationship between them, and moving a module
|
||||||
|
changes one row. *Livestream depends on Video because it calls Video's functionality* is a real
|
||||||
|
dependency that orders startup; *Livestream is shown under Media* orders nothing. That distinction 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 come out byte-identical every time.
|
||||||
|
|
||||||
|
**No manifest change, and no version 5.** `manifest.name` has been required since version 1 and is
|
||||||
|
already the module's product identity, so the shell simply renders "Livestream" instead of
|
||||||
|
`livestream`. `suggestedGroup` was 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
|
||||||
|
would risk a module's suggestion overwriting an administrator's decision.
|
||||||
|
|
||||||
|
Two Core tables (migration `0003`) hold groups and placements — server-side, because a church
|
||||||
|
configures its Dashboard, not one browser; platform-global, because Ekklesia has no organization
|
||||||
|
model yet. `GET /api/v1/dashboard/navigation` returns the resolved view, layered **on top of** the
|
||||||
|
host-neutral module UI inventory rather than merged into it, embedding each generic descriptor
|
||||||
|
untouched. Reading needs the same permission the inventory does; organizing needs a new platform
|
||||||
|
permission, `core.dashboard.navigation.manage`. A newly installed module appears immediately as
|
||||||
|
ungrouped with no initialization write; an uninstalled module's placement is retained so reinstalling
|
||||||
|
restores its position; deleting a group ungroups its modules through the database rather than
|
||||||
|
application cleanup. See
|
||||||
|
[ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md).
|
||||||
|
|
||||||
|
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||||
|
folders, no icons or colors, no Dashboard Home or widgets, no design system, and no client-side
|
||||||
|
routing. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
|
||||||
|
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do
|
||||||
|
not exist.
|
||||||
|
|
||||||
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
||||||
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import type {
|
|||||||
PermissionRegistry,
|
PermissionRegistry,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
|
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context, MiddlewareHandler } from "hono";
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||||
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||||
@@ -47,6 +49,12 @@ export interface CreateApiAppOptions {
|
|||||||
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
|
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
|
||||||
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
||||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
|
||||||
|
* rather than defaulted, like every other module option here: a caller that exercises no
|
||||||
|
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
|
||||||
|
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||||
|
* ordering, module startup, or any authorization decision. */
|
||||||
|
readonly dashboard: DashboardStore;
|
||||||
readonly runtime: EkklesiaIdentityRuntime;
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||||
@@ -193,6 +201,21 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Layered deliberately on top of the generic inventory above rather than merged into it: the
|
||||||
|
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
|
||||||
|
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
createDashboardRoutes({
|
||||||
|
evaluator: options.authorization.evaluator,
|
||||||
|
moduleUi: options.moduleUi,
|
||||||
|
registry: options.authorization.registry,
|
||||||
|
runtime,
|
||||||
|
store: options.dashboard,
|
||||||
|
trustedOrigins: allowedOrigins,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
||||||
evaluator: options.authorization.evaluator,
|
evaluator: options.authorization.evaluator,
|
||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import {
|
||||||
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_MODULES_UI_READ,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
DASHBOARD_SORT_ORDER_MAX,
|
||||||
|
DASHBOARD_SORT_ORDER_MIN,
|
||||||
|
type DashboardStore,
|
||||||
|
DashboardStoreError,
|
||||||
|
isValidDashboardIdentifier,
|
||||||
|
normalizeDashboardGroupLabel,
|
||||||
|
resolveDashboardNavigation,
|
||||||
|
} from "@ekklesia/core/dashboard";
|
||||||
|
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
requireTrustedOrigin,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js";
|
||||||
|
|
||||||
|
export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
||||||
|
const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`;
|
||||||
|
const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module as the Dashboard presents it: the module's display name, plus the generic mount
|
||||||
|
* descriptor unchanged.
|
||||||
|
*
|
||||||
|
* The descriptor is **embedded rather than flattened**, and that is the layering made visible.
|
||||||
|
* `module` is exactly `HostModuleUiDescriptor` — the host-neutral answer to "what is mountable and
|
||||||
|
* how" (ADR 0026) — and `name` is the Dashboard's presentation concern layered on top. A standalone
|
||||||
|
* host consumes the inner object and never learns that Ekklesia has folders; Ekklesia's shell
|
||||||
|
* consumes the outer one. Neither contract is warped to serve the other, and there is one
|
||||||
|
* construction of the descriptor rather than two that could drift.
|
||||||
|
*/
|
||||||
|
export interface DashboardNavigationModuleView {
|
||||||
|
readonly name: string;
|
||||||
|
readonly module: HostModuleUiDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationGroupView {
|
||||||
|
/** The stable key, unchanged by a rename. Addresses the group in the mutation API; never displayed. */
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly order: number;
|
||||||
|
readonly modules: readonly DashboardNavigationModuleView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationResponse {
|
||||||
|
readonly groups: readonly DashboardNavigationGroupView[];
|
||||||
|
/** Modules an administrator placed outside every group, and modules never configured at all — a
|
||||||
|
* newly installed module most importantly. Never empty merely because nothing has been arranged. */
|
||||||
|
readonly ungrouped: readonly DashboardNavigationModuleView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDashboardRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The same publication registry the artifact route and the generic inventory serve from: the one
|
||||||
|
* accepted, mountable module set. Navigation never rescans, never reads a manifest, and never
|
||||||
|
* discovers anything of its own. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
readonly store: DashboardStore;
|
||||||
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. A
|
||||||
|
* validation failure the route already rejects can still arrive here from a concurrent change, so
|
||||||
|
* both paths are handled rather than assumed unreachable. */
|
||||||
|
function storeFailure(c: Context, error: unknown): Response {
|
||||||
|
if (error instanceof DashboardStoreError) {
|
||||||
|
if (error.code === "DASHBOARD_GROUP_NOT_FOUND") {
|
||||||
|
return sanitizedFailure(c, 404, error.code);
|
||||||
|
}
|
||||||
|
if (error.code === "DASHBOARD_READ_FAILED" || error.code === "DASHBOARD_WRITE_FAILED") {
|
||||||
|
return sanitizedFailure(c, 503, error.code);
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 503, "DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsedSortOrder(value: unknown): number | undefined {
|
||||||
|
if (
|
||||||
|
typeof value !== "number" ||
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value < DASHBOARD_SORT_ORDER_MIN ||
|
||||||
|
value > DASHBOARD_SORT_ORDER_MAX
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_BODY_KEYS = new Set(["label", "order"]);
|
||||||
|
const PLACEMENT_BODY_KEYS = new Set(["groupId", "order"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured, closed validation — never a free-form configuration blob. Any key beyond the declared
|
||||||
|
* ones makes the whole body invalid rather than being silently dropped, so a caller cannot smuggle a
|
||||||
|
* field a future version might interpret, and a typo fails loudly instead of quietly doing nothing.
|
||||||
|
*/
|
||||||
|
function parseGroupBody(raw: unknown): { label: string; order: number } | undefined {
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !GROUP_BODY_KEYS.has(key))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const order = parsedSortOrder(raw.order);
|
||||||
|
const label = normalizeDashboardGroupLabel(raw.label);
|
||||||
|
if (label === undefined || order === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { label, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlacementBody(raw: unknown): { groupId: string | null; order: number } | undefined {
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !PLACEMENT_BODY_KEYS.has(key))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const order = parsedSortOrder(raw.order);
|
||||||
|
const groupId = raw.groupId;
|
||||||
|
if (order === undefined || (groupId !== null && !isValidDashboardIdentifier(groupId))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { groupId, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(c: Context): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return (await c.req.json()) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Ekklesia Dashboard's navigation: how this installation presents its mountable modules, and the
|
||||||
|
* narrow, structured API for changing that.
|
||||||
|
*
|
||||||
|
* ## Navigation is layered on the inventory, not merged into it
|
||||||
|
*
|
||||||
|
* `GET /api/v1/module-ui` (ADR 0026) answers *what may be mounted* and stays host-neutral — a
|
||||||
|
* standalone host uses it and never learns Ekklesia has folders. This route answers *how this
|
||||||
|
* Dashboard presents those modules*, and embeds each generic descriptor untouched inside a
|
||||||
|
* presentation wrapper. Keeping them separate is what stops Dashboard semantics leaking into the
|
||||||
|
* portable module-host contract.
|
||||||
|
*
|
||||||
|
* ## Grouping is presentation, and structurally cannot be anything else
|
||||||
|
*
|
||||||
|
* The resolved view is computed by `resolveDashboardNavigation`, a pure function of stored
|
||||||
|
* configuration and the current module set. It reads no manifest, no dependency, no capability, and
|
||||||
|
* no entrypoint, and it produces no relationship between two modules. Modules stay flat (ADR 0021,
|
||||||
|
* ADR 0027): two modules in one group are exactly as related as two in different groups, and moving
|
||||||
|
* one changes a single presentation row — never its artifact, its API root, its permissions, its
|
||||||
|
* dependencies, or when it starts.
|
||||||
|
*
|
||||||
|
* ## Reading versus organizing
|
||||||
|
*
|
||||||
|
* Reading requires `core.modules.ui.read`, the same permission the generic inventory requires,
|
||||||
|
* because this response *contains* that inventory and serves the same audience: any signed-in user
|
||||||
|
* needs it to see anything at all. Organizing requires `core.dashboard.navigation.manage`
|
||||||
|
* (restricted) — a platform permission, never a module's — and, like every other Core mutation, an
|
||||||
|
* exact trusted `Origin` and a bounded body before authorization work begins.
|
||||||
|
*
|
||||||
|
* Holding the manage permission grants no module authority. Every module route remains enforced
|
||||||
|
* against its own declared permission, and no arrangement of folders changes a single authorization
|
||||||
|
* decision.
|
||||||
|
*/
|
||||||
|
export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
const middlewareOptions: AuthorizationMiddlewareOptions = {
|
||||||
|
evaluator: options.evaluator,
|
||||||
|
registry: options.registry,
|
||||||
|
runtime: options.runtime,
|
||||||
|
};
|
||||||
|
const requireRead = requirePermission(middlewareOptions, CORE_MODULES_UI_READ);
|
||||||
|
const requireManage = requirePermission(middlewareOptions, CORE_DASHBOARD_NAVIGATION_MANAGE);
|
||||||
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_NAVIGATION_PATH, requireRead, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
// Per-principal: the embedded descriptors carry permission hints evaluated for this caller.
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
try {
|
||||||
|
const [configuration, descriptors] = await Promise.all([
|
||||||
|
options.store.readConfiguration(),
|
||||||
|
describeMountableModules(options.evaluator, principal, options.moduleUi),
|
||||||
|
]);
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration,
|
||||||
|
descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })),
|
||||||
|
);
|
||||||
|
const present = (entry: {
|
||||||
|
readonly descriptor: HostModuleUiDescriptor;
|
||||||
|
readonly name: string;
|
||||||
|
}): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name });
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
groups: view.groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
label: group.label,
|
||||||
|
modules: group.modules.map(present),
|
||||||
|
order: group.order,
|
||||||
|
})),
|
||||||
|
ungrouped: view.ungrouped.map(present),
|
||||||
|
} satisfies DashboardNavigationResponse,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DashboardStoreError) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
// An authorization-evaluation outage, exactly as the generic inventory reports it: a
|
||||||
|
// permission hint that could not be determined is never rendered as "not granted".
|
||||||
|
return sanitizedFailure(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
`${DASHBOARD_GROUPS_PATH}/:groupId`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const groupId = c.req.param("groupId");
|
||||||
|
const body = parseGroupBody(await readJsonBody(c));
|
||||||
|
if (!body || !isValidDashboardIdentifier(groupId)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Idempotent by design: creating and renaming are the same operation against a
|
||||||
|
// client-chosen, stable id, so a rename can never orphan the placements pointing at it.
|
||||||
|
const group = await options.store.upsertGroup({
|
||||||
|
id: groupId,
|
||||||
|
label: body.label,
|
||||||
|
sortOrder: body.order,
|
||||||
|
});
|
||||||
|
return c.json({ id: group.id, label: group.label, order: group.sortOrder }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.delete(`${DASHBOARD_GROUPS_PATH}/:groupId`, requireOrigin, requireManage, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const groupId = c.req.param("groupId");
|
||||||
|
if (!isValidDashboardIdentifier(groupId)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Modules in the group become ungrouped through the schema's `on delete set null`, so no
|
||||||
|
// module is lost and no placement is left pointing at a group that no longer exists.
|
||||||
|
await options.store.deleteGroup(groupId);
|
||||||
|
return c.body(null, 204);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
`${DASHBOARD_PLACEMENTS_PATH}/:moduleId`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const moduleId = c.req.param("moduleId");
|
||||||
|
const body = parsePlacementBody(await readJsonBody(c));
|
||||||
|
if (!body || !isValidDashboardIdentifier(moduleId)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// A placement may be stored for a module that is not installed: that is how uninstalling
|
||||||
|
// and reinstalling preserves an arrangement. Nothing renders it until the module is back.
|
||||||
|
const placement = await options.store.setPlacement({
|
||||||
|
groupId: body.groupId,
|
||||||
|
moduleId,
|
||||||
|
sortOrder: body.order,
|
||||||
|
});
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
groupId: placement.groupId,
|
||||||
|
moduleId: placement.moduleId,
|
||||||
|
order: placement.sortOrder,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -3,12 +3,17 @@ import {
|
|||||||
type AuthorizationDrizzleDatabase,
|
type AuthorizationDrizzleDatabase,
|
||||||
createAuthorizationDrizzleView,
|
createAuthorizationDrizzleView,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
createDashboardDrizzleView,
|
||||||
|
type DashboardDrizzleDatabase,
|
||||||
|
} from "@ekklesia/core/dashboard";
|
||||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||||
import { createModuleDatabaseView } from "@ekklesia/core/modules";
|
import { createModuleDatabaseView } from "@ekklesia/core/modules";
|
||||||
|
|
||||||
export interface ApiDatabaseHandle {
|
export interface ApiDatabaseHandle {
|
||||||
readonly authorization: AuthorizationDrizzleDatabase;
|
readonly authorization: AuthorizationDrizzleDatabase;
|
||||||
|
readonly dashboard: DashboardDrizzleDatabase;
|
||||||
readonly identity: IdentityDrizzleDatabase;
|
readonly identity: IdentityDrizzleDatabase;
|
||||||
readonly module: ModuleDatabase;
|
readonly module: ModuleDatabase;
|
||||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||||
@@ -35,6 +40,7 @@ export function createApiDatabase(
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
authorization: createAuthorizationDrizzleView(pool),
|
authorization: createAuthorizationDrizzleView(pool),
|
||||||
close,
|
close,
|
||||||
|
dashboard: createDashboardDrizzleView(pool),
|
||||||
identity: createIdentityDrizzleView(pool),
|
identity: createIdentityDrizzleView(pool),
|
||||||
module: createModuleDatabaseView(pool),
|
module: createModuleDatabaseView(pool),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,6 +120,38 @@ async function describeModule(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One mountable module, described once: the host-neutral mount descriptor plus the module's own
|
||||||
|
* display name, kept beside it rather than inside it. */
|
||||||
|
export interface MountableModuleDescription {
|
||||||
|
readonly descriptor: HostModuleUiDescriptor;
|
||||||
|
/** `manifest.name`. Presentation *identity* the module owns — never its placement, which the
|
||||||
|
* Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the
|
||||||
|
* host-neutral contract a standalone host consumes stays free of presentation concerns. */
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one composition of the mountable-module set, shared by the generic inventory route and the
|
||||||
|
* Dashboard navigation route (`./dashboard-routes.js`).
|
||||||
|
*
|
||||||
|
* Both derive from the same publication registry and the same evaluator, so the two endpoints can
|
||||||
|
* never disagree about what exists, what its URLs are, or what the caller may do inside it. Sharing
|
||||||
|
* this function rather than duplicating the mapping is what makes that a structural property instead
|
||||||
|
* of a convention.
|
||||||
|
*/
|
||||||
|
export async function describeMountableModules(
|
||||||
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
|
): Promise<readonly MountableModuleDescription[]> {
|
||||||
|
return Promise.all(
|
||||||
|
registry.list().map(async (published) => ({
|
||||||
|
descriptor: await describeModule(evaluator, principal, published),
|
||||||
|
name: published.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that
|
/** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that
|
||||||
* could not be determined is never reported as "not granted". Carries no module id, permission id,
|
* could not be determined is never reported as "not granted". Carries no module id, permission id,
|
||||||
* or caught error. */
|
* or caught error. */
|
||||||
@@ -178,11 +210,14 @@ export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRo
|
|||||||
// The `authenticated` baseline guarantees this is an authenticated principal.
|
// The `authenticated` baseline guarantees this is an authenticated principal.
|
||||||
const principal = getAuthorizedPrincipal(c);
|
const principal = getAuthorizedPrincipal(c);
|
||||||
try {
|
try {
|
||||||
const modules = await Promise.all(
|
const described = await describeMountableModules(
|
||||||
options.moduleUi
|
options.evaluator,
|
||||||
.list()
|
principal,
|
||||||
.map((published) => describeModule(options.evaluator, principal, published)),
|
options.moduleUi,
|
||||||
);
|
);
|
||||||
|
// Only the host-neutral descriptor reaches this response: the display name stays out of the
|
||||||
|
// portable contract and is carried by the Dashboard navigation view instead.
|
||||||
|
const modules = described.map((entry) => entry.descriptor);
|
||||||
return c.json({ modules } satisfies HostModuleUiInventory, 200);
|
return c.json({ modules } satisfies HostModuleUiInventory, 200);
|
||||||
} catch {
|
} catch {
|
||||||
return sanitizedInventoryFailure(c);
|
return sanitizedInventoryFailure(c);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createAuthorizationEvaluator,
|
createAuthorizationEvaluator,
|
||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
@@ -413,6 +414,8 @@ export async function startApi(
|
|||||||
// module, and a module that failed to load, register, or start never reaches this line at
|
// module, and a module that failed to load, register, or start never reaches this line at
|
||||||
// all. Modules declaring no `entrypoints.ui` are simply absent from the registry.
|
// all. Modules declaring no `entrypoints.ui` are simply absent from the registry.
|
||||||
moduleUi: createModuleUiPublicationRegistry(inventory.modules),
|
moduleUi: createModuleUiPublicationRegistry(inventory.modules),
|
||||||
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: config.identity.trustedOrigins,
|
trustedOrigins: config.identity.trustedOrigins,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
|
"core:0003_dashboard_navigation_tables",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
@@ -2158,9 +2159,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||||
expect(permissionsCheck).toMatchObject({
|
expect(permissionsCheck).toMatchObject({
|
||||||
// Core's own two (`core.authorization.grants.manage`, `core.modules.ui.read`) plus the
|
// Core's own three (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||||
// fixture module's one.
|
// `core.dashboard.navigation.manage`) plus the fixture module's one.
|
||||||
details: ["3 permissions registered"],
|
details: ["4 permissions registered"],
|
||||||
status: "pass",
|
status: "pass",
|
||||||
});
|
});
|
||||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -105,6 +107,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -124,6 +127,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -145,6 +149,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -168,6 +173,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -191,6 +197,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -211,6 +218,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -233,6 +241,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -252,6 +261,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -273,6 +283,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -301,6 +312,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -330,6 +342,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -364,6 +377,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -380,6 +394,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -398,6 +413,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -415,6 +431,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -430,6 +447,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -452,6 +470,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -477,6 +496,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -505,6 +525,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
evaluator: { authorize: evaluatorAuthorize },
|
evaluator: { authorize: evaluatorAuthorize },
|
||||||
@@ -524,6 +545,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationStore,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { DashboardConfiguration } from "@ekklesia/core/dashboard";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
discoverModules,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||||
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
|
const viewerId = parseEkklesiaUserId("33333333-3333-4333-8333-333333333333");
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly withUi?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${fixture.id}`,
|
||||||
|
type: "module",
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
|
},
|
||||||
|
id: fixture.id,
|
||||||
|
manifestVersion: 4,
|
||||||
|
name: fixture.name,
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||||
|
);
|
||||||
|
if (fixture.withUi !== false) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallOptions {
|
||||||
|
readonly fixtures?: readonly ModuleFixture[];
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
readonly platformOwner?: boolean;
|
||||||
|
readonly configuration?: Partial<DashboardConfiguration>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFixtures: readonly ModuleFixture[] = [
|
||||||
|
{ id: "video", name: "Video" },
|
||||||
|
{ id: "livestream", name: "Livestream" },
|
||||||
|
{ id: "calendar", name: "Calendar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dashboard-routes-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
for (const fixture of options.fixtures ?? defaultFixtures) {
|
||||||
|
await createModule(modulesDirectory, fixture);
|
||||||
|
}
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluator = createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async () => false },
|
||||||
|
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||||
|
});
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
|
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: Object.freeze({
|
||||||
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
|
}) as ModuleRuntimeServices,
|
||||||
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedIn(): EkklesiaPrincipal {
|
||||||
|
return createAuthenticatedPrincipal(viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationEntry {
|
||||||
|
readonly name: string;
|
||||||
|
readonly module: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly ui: { readonly url: string };
|
||||||
|
readonly api: { readonly baseUrl: string };
|
||||||
|
readonly permissions: readonly string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationBody {
|
||||||
|
readonly groups: ReadonlyArray<{
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly order: number;
|
||||||
|
readonly modules: readonly NavigationEntry[];
|
||||||
|
}>;
|
||||||
|
readonly ungrouped: readonly NavigationEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigationOf(app: Hono): Promise<NavigationBody> {
|
||||||
|
const response = await app.request(DASHBOARD_NAVIGATION_PATH);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
return (await response.json()) as NavigationBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An administrator: a platform owner, who passes the evaluator's owner bypass for the restricted
|
||||||
|
* manage permission exactly as in production. */
|
||||||
|
async function administrator(configuration: Partial<DashboardConfiguration> = {}) {
|
||||||
|
return install({ configuration, platformOwner: true, principal: signedIn() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutate(app: Hono, method: "PUT" | "DELETE", url: string, body?: unknown) {
|
||||||
|
return app.request(url, {
|
||||||
|
...(body === undefined
|
||||||
|
? {}
|
||||||
|
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
|
||||||
|
headers: {
|
||||||
|
origin: trustedOrigin,
|
||||||
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard navigation — reading", () => {
|
||||||
|
it("shows every mountable module as ungrouped when nothing is configured", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups).toEqual([]);
|
||||||
|
// A fresh installation is immediately usable: no initialization write, no manual database edit.
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
|
||||||
|
"Calendar",
|
||||||
|
"Livestream",
|
||||||
|
"Video",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the module's own manifest name, never its id", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).not.toContain("livestream");
|
||||||
|
expect(navigation.ungrouped.find((entry) => entry.module.id === "livestream")?.name).toBe(
|
||||||
|
"Livestream",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups and orders exactly as the stored configuration says", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
configuration: {
|
||||||
|
groups: [
|
||||||
|
{ id: "planning", label: "Planning", sortOrder: 20 },
|
||||||
|
{ id: "media", label: "Media", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
placements: [
|
||||||
|
{ groupId: "media", moduleId: "livestream", sortOrder: 20 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||||
|
{ groupId: "planning", moduleId: "calendar", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
navigation.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]),
|
||||||
|
).toEqual([
|
||||||
|
["Media", ["Video", "Livestream"]],
|
||||||
|
["Planning", ["Calendar"]],
|
||||||
|
]);
|
||||||
|
expect(navigation.ungrouped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embeds the generic mount descriptor unchanged inside each entry", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
const livestream = navigation.ungrouped.find((entry) => entry.module.id === "livestream");
|
||||||
|
|
||||||
|
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
||||||
|
expect(livestream?.module).toEqual({
|
||||||
|
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||||
|
id: "livestream",
|
||||||
|
permissions: [],
|
||||||
|
ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees exactly with the generic inventory about what is mountable", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
configuration: {
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||||
|
},
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
|
modules: ReadonlyArray<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigationModules = [
|
||||||
|
...navigation.groups.flatMap((group) => group.modules),
|
||||||
|
...navigation.ungrouped,
|
||||||
|
].map((entry) => entry.module);
|
||||||
|
// Both derive from one publication registry through one shared description, so they cannot
|
||||||
|
// disagree about what exists or about any URL.
|
||||||
|
expect(navigationModules.map((module) => module.id).sort()).toEqual(
|
||||||
|
inventory.modules.map((module) => module.id).sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the generic inventory free of presentation metadata", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
|
||||||
|
|
||||||
|
// The host-neutral contract a standalone host consumes must not learn Ekklesia has folders or
|
||||||
|
// display names. That layering is the whole reason navigation is a separate route.
|
||||||
|
for (const field of ["name", "label", "group", "order", "ungrouped"]) {
|
||||||
|
expect(body, field).not.toContain(`"${field}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits a module that is no longer installed, without breaking navigation", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
configuration: {
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "removed-module", sortOrder: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fixtures: [{ id: "video", name: "Video" }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups[0]?.modules.map((entry) => entry.module.id)).toEqual(["video"]);
|
||||||
|
expect(JSON.stringify(navigation)).not.toContain("removed-module");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a newly installed module visible alongside an existing arrangement", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
configuration: {
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||||
|
},
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
|
||||||
|
// Calendar and Livestream were never configured and are still reachable.
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Livestream"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes a module that ships no UI, exactly like the inventory does", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{ id: "video", name: "Video" },
|
||||||
|
{ id: "roster", name: "Roster", withUi: false },
|
||||||
|
],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(JSON.stringify(navigation)).not.toContain("Roster");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaks no filesystem path, package root, or manifest internal", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const body = await (await app.request(DASHBOARD_NAVIGATION_PATH)).text();
|
||||||
|
|
||||||
|
expect(body).not.toContain(tmpdir());
|
||||||
|
expect(body).not.toContain("dist");
|
||||||
|
expect(body).not.toContain("@test/module-");
|
||||||
|
expect(body).not.toContain("ekklesia.module.json");
|
||||||
|
expect(body).not.toContain("entrypoints");
|
||||||
|
expect(body).not.toContain("manifestVersion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is per-principal, uncached, and refused to anonymous callers", async () => {
|
||||||
|
const { app: anonymous } = await install();
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const denied = await anonymous.request(DASHBOARD_NAVIGATION_PATH);
|
||||||
|
const allowed = await app.request(DASHBOARD_NAVIGATION_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(denied.status).toBe(403);
|
||||||
|
expect(await denied.text()).not.toContain("Livestream");
|
||||||
|
expect(allowed.headers.get("cache-control")).toBe("no-store");
|
||||||
|
const vary = allowed.headers.get("vary")?.toLowerCase() ?? "";
|
||||||
|
expect(vary).toContain("origin");
|
||||||
|
expect(vary).toContain("cookie");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard navigation — organizing", () => {
|
||||||
|
it("creates a group and places a module, and both persist into the resolved view", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
label: "Media",
|
||||||
|
order: 10,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
|
||||||
|
groupId: "media",
|
||||||
|
order: 10,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups.map((group) => group.label)).toEqual(["Media"]);
|
||||||
|
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Video"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames a group without moving anything", async () => {
|
||||||
|
const { app } = await administrator({
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
label: "Sunday Production",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups[0]).toMatchObject({ id: "media", label: "Sunday Production" });
|
||||||
|
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a module between groups and changes only its placement", async () => {
|
||||||
|
const { app } = await administrator({
|
||||||
|
groups: [
|
||||||
|
{ id: "media", label: "Media", sortOrder: 10 },
|
||||||
|
{ id: "sunday-production", label: "Sunday Production", sortOrder: 5 },
|
||||||
|
],
|
||||||
|
placements: [
|
||||||
|
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 20 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const before = await navigationOf(app);
|
||||||
|
const livestreamBefore = before.groups
|
||||||
|
.flatMap((group) => group.modules)
|
||||||
|
.find((entry) => entry.module.id === "livestream");
|
||||||
|
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
|
||||||
|
groupId: "sunday-production",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
const after = await navigationOf(app);
|
||||||
|
const livestreamAfter = after.groups
|
||||||
|
.flatMap((group) => group.modules)
|
||||||
|
.find((entry) => entry.module.id === "livestream");
|
||||||
|
|
||||||
|
// The one property this whole pass exists to guarantee: the module — its artifact URL, its API
|
||||||
|
// root, its permissions, its version, its name — is byte-identical across the move.
|
||||||
|
expect(livestreamAfter).toEqual(livestreamBefore);
|
||||||
|
expect(
|
||||||
|
after.groups.find((group) => group.id === "sunday-production")?.modules.map((e) => e.name),
|
||||||
|
).toEqual(["Livestream"]);
|
||||||
|
expect(after.groups.find((group) => group.id === "media")?.modules.map((e) => e.name)).toEqual([
|
||||||
|
"Video",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ungroups a module deliberately", async () => {
|
||||||
|
const { app } = await administrator({
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
|
||||||
|
groupId: null,
|
||||||
|
order: 5,
|
||||||
|
});
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups[0]?.modules).toEqual([]);
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
|
||||||
|
"Video",
|
||||||
|
"Calendar",
|
||||||
|
"Livestream",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ungroups a deleted group's modules rather than losing them", async () => {
|
||||||
|
const { app } = await administrator({
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await mutate(app, "DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`);
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(deleted.status).toBe(204);
|
||||||
|
expect(navigation.groups).toEqual([]);
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
|
||||||
|
"Video",
|
||||||
|
"Calendar",
|
||||||
|
"Livestream",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a placement naming a group that does not exist", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
|
||||||
|
groupId: "not-a-group",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "DASHBOARD_GROUP_NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a body carrying anything beyond the declared fields", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
const bodies: readonly unknown[] = [
|
||||||
|
{ label: "Media", order: 10, parentGroupId: "root" },
|
||||||
|
{ label: "Media", order: 10, ownerModuleId: "video" },
|
||||||
|
{ label: "Media" },
|
||||||
|
{ label: "", order: 10 },
|
||||||
|
{ label: "Media", order: -1 },
|
||||||
|
{ label: "Media", order: 1.5 },
|
||||||
|
{ label: 42, order: 10 },
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
for (const body of bodies) {
|
||||||
|
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, body);
|
||||||
|
expect(response.status, JSON.stringify(body)).toBe(400);
|
||||||
|
expect(await response.json(), JSON.stringify(body)).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a malformed group identifier in the path", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/Not%20Valid`, {
|
||||||
|
label: "Media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard navigation — authority", () => {
|
||||||
|
it("refuses organizing to a signed-in user without the manage permission", async () => {
|
||||||
|
// Reading works for any signed-in user; organizing does not.
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
expect((await app.request(DASHBOARD_NAVIGATION_PATH)).status).toBe(200);
|
||||||
|
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
label: "Media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses organizing from an untrusted origin, before authorization even runs", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
const response = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
body: JSON.stringify({ label: "Media", order: 10 }),
|
||||||
|
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses organizing to an anonymous caller", async () => {
|
||||||
|
const { app } = await install();
|
||||||
|
|
||||||
|
for (const [method, url, body] of [
|
||||||
|
["PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10 }],
|
||||||
|
["DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, undefined],
|
||||||
|
["PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: null, order: 0 }],
|
||||||
|
] as const) {
|
||||||
|
const response = await mutate(app, method, url, body);
|
||||||
|
expect(response.status, `${method} ${url}`).toBe(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants no module authority to an administrator who reorganizes the Dashboard", async () => {
|
||||||
|
// The platform owner holds `core.dashboard.navigation.manage` through the owner bypass. That
|
||||||
|
// says nothing about any module: the hints in the navigation view are still evaluated per
|
||||||
|
// module permission, and every module route stays enforced on its own.
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
label: "Media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
|
||||||
|
groupId: "media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
const livestream = navigation.groups[0]?.modules[0];
|
||||||
|
// The fixture modules declare no permissions at all, so an owner still holds none for them:
|
||||||
|
// moving a module into a folder cannot manufacture a permission hint, let alone a grant.
|
||||||
|
expect(livestream?.module.permissions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes no method the navigation contract does not define", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
for (const method of ["POST", "PATCH"] as const) {
|
||||||
|
const response = await app.request(DASHBOARD_NAVIGATION_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
expect(response.status, method).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -220,6 +220,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
|
"core:0003_dashboard_navigation_tables",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -245,6 +246,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
|
"core:0003_dashboard_navigation_tables",
|
||||||
"db-facade:0000_initial",
|
"db-facade:0000_initial",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
@@ -439,6 +441,9 @@ describe.sequential("live API — module database explicit migration ownership",
|
|||||||
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
|
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
|
||||||
|
).toBe(1);
|
||||||
} finally {
|
} finally {
|
||||||
await dropDatabase(database.name);
|
await dropDatabase(database.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import { createModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
import { createModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
canonicalizeVaryHeader,
|
canonicalizeVaryHeader,
|
||||||
@@ -108,6 +109,7 @@ function buildModuleApp(options: {
|
|||||||
moduleRoutes: options.routes,
|
moduleRoutes: options.routes,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry([]),
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
moduleUiSourceUrl,
|
moduleUiSourceUrl,
|
||||||
} from "@ekklesia/shell";
|
} from "@ekklesia/shell";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,8 +74,12 @@ afterEach(async () => {
|
|||||||
* already proven end to end by `templates/module`'s own tests, and what this test needs is a byte
|
* already proven end to end by `templates/module`'s own tests, and what this test needs is a byte
|
||||||
* sequence whose journey through publication, inventory, and the loader can be asserted exactly.
|
* sequence whose journey through publication, inventory, and the loader can be asserted exactly.
|
||||||
*/
|
*/
|
||||||
async function createFixtureModule(modulesDirectory: string): Promise<void> {
|
async function createFixtureModule(
|
||||||
const moduleDirectory = path.join(modulesDirectory, "example");
|
modulesDirectory: string,
|
||||||
|
id = "example",
|
||||||
|
name = "Example module",
|
||||||
|
): Promise<void> {
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
|
||||||
@@ -82,7 +87,7 @@ async function createFixtureModule(modulesDirectory: string): Promise<void> {
|
|||||||
path.join(moduleDirectory, "package.json"),
|
path.join(moduleDirectory, "package.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ekklesia: { layer: "module" },
|
ekklesia: { layer: "module" },
|
||||||
name: "@test/module-example",
|
name: `@test/module-${id}`,
|
||||||
type: "module",
|
type: "module",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
}),
|
}),
|
||||||
@@ -92,14 +97,14 @@ async function createFixtureModule(modulesDirectory: string): Promise<void> {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: "example",
|
id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 4,
|
||||||
name: "Example module",
|
name,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
baseline: "restricted",
|
baseline: "restricted",
|
||||||
description: "Read example records.",
|
description: "Read example records.",
|
||||||
id: "example.records.read",
|
id: `${id}.records.read`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -108,13 +113,13 @@ async function createFixtureModule(modulesDirectory: string): Promise<void> {
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/server/index.js"),
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
id: "example",
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
register({ http }) {
|
register({ http }) {
|
||||||
http.route({
|
http.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/records",
|
path: "/records",
|
||||||
permission: "example.records.read",
|
permission: ${JSON.stringify(`${id}.records.read`)},
|
||||||
maxBodyBytes: 0,
|
maxBodyBytes: 0,
|
||||||
async handler() {
|
async handler() {
|
||||||
return Response.json([{ id: "a" }, { id: "b" }]);
|
return Response.json([{ id: "a" }, { id: "b" }]);
|
||||||
@@ -128,10 +133,10 @@ async function createFixtureModule(modulesDirectory: string): Promise<void> {
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
id: "example",
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
if (!context.permissions.has("example.records.read")) {
|
if (!context.permissions.has(${JSON.stringify(`${id}.records.read`)})) {
|
||||||
container.textContent = context.moduleId + ":denied";
|
container.textContent = context.moduleId + ":denied";
|
||||||
return () => { container.textContent = ""; };
|
return () => { container.textContent = ""; };
|
||||||
}
|
}
|
||||||
@@ -148,6 +153,10 @@ async function createFixtureModule(modulesDirectory: string): Promise<void> {
|
|||||||
interface HarnessOptions {
|
interface HarnessOptions {
|
||||||
readonly principal?: EkklesiaPrincipal;
|
readonly principal?: EkklesiaPrincipal;
|
||||||
readonly grants?: readonly string[];
|
readonly grants?: readonly string[];
|
||||||
|
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
|
||||||
|
* single module proves very little. */
|
||||||
|
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
|
||||||
|
readonly platformOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Harness {
|
interface Harness {
|
||||||
@@ -164,6 +173,9 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
|
||||||
temporaryDirectories.push(modulesDirectory);
|
temporaryDirectories.push(modulesDirectory);
|
||||||
await createFixtureModule(modulesDirectory);
|
await createFixtureModule(modulesDirectory);
|
||||||
|
for (const extra of options.extraModules ?? []) {
|
||||||
|
await createFixtureModule(modulesDirectory, extra.id, extra.name);
|
||||||
|
}
|
||||||
|
|
||||||
const modules = await discoverModules({
|
const modules = await discoverModules({
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
@@ -180,7 +192,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
const granted = new Set(options.grants ?? []);
|
const granted = new Set(options.grants ?? []);
|
||||||
const evaluator = createAuthorizationEvaluator({
|
const evaluator = createAuthorizationEvaluator({
|
||||||
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
||||||
platformOwners: { isPlatformOwner: async () => false },
|
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||||
});
|
});
|
||||||
const runtime: EkklesiaIdentityRuntime = {
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
@@ -201,6 +213,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
moduleRoutes: moduleRuntime.routes,
|
moduleRoutes: moduleRuntime.routes,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: ["https://dashboard.example.test"],
|
trustedOrigins: ["https://dashboard.example.test"],
|
||||||
});
|
});
|
||||||
@@ -250,9 +263,12 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
// 1. The shell's own bootstrap: principal, then inventory.
|
// 1. The shell's own bootstrap: principal, then inventory.
|
||||||
const bootstrap = await bootstrapHost(harness.config);
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId });
|
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId });
|
||||||
expect(bootstrap.modules).toHaveLength(1);
|
// A module nobody has arranged yet is ungrouped and visible — no initialization write.
|
||||||
|
expect(bootstrap.navigation.groups).toEqual([]);
|
||||||
|
expect(bootstrap.navigation.ungrouped).toHaveLength(1);
|
||||||
|
expect(bootstrap.navigation.ungrouped[0]?.name).toBe("Example module");
|
||||||
|
|
||||||
const descriptor = bootstrap.modules[0];
|
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||||
if (!descriptor) {
|
if (!descriptor) {
|
||||||
throw new Error("the inventory returned no module");
|
throw new Error("the inventory returned no module");
|
||||||
}
|
}
|
||||||
@@ -304,7 +320,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const bootstrap = await bootstrapHost(harness.config);
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
const descriptor = bootstrap.modules[0];
|
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||||
if (!descriptor) {
|
if (!descriptor) {
|
||||||
throw new Error("the inventory returned no module");
|
throw new Error("the inventory returned no module");
|
||||||
}
|
}
|
||||||
@@ -336,7 +352,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const bootstrap = await bootstrapHost(harness.config);
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
const descriptor = bootstrap.modules[0];
|
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||||
if (!descriptor) {
|
if (!descriptor) {
|
||||||
throw new Error("the inventory returned no module");
|
throw new Error("the inventory returned no module");
|
||||||
}
|
}
|
||||||
@@ -364,13 +380,187 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a configured arrangement, and a move changes presentation only", async () => {
|
||||||
|
const harness = await startHarness({
|
||||||
|
extraModules: [
|
||||||
|
{ id: "video", name: "Video" },
|
||||||
|
{ id: "calendar", name: "Calendar" },
|
||||||
|
],
|
||||||
|
grants: ["example.records.read"],
|
||||||
|
platformOwner: true,
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
const organize = (url: string, body: unknown) =>
|
||||||
|
(harness.config.fetch as typeof fetch)(`https://ekklesia.example.test${url}`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// An administrator organizes the Dashboard, through the real mutation API.
|
||||||
|
await organize("/api/v1/dashboard/navigation/groups/media", { label: "Media", order: 10 });
|
||||||
|
await organize("/api/v1/dashboard/navigation/groups/planning", {
|
||||||
|
label: "Planning",
|
||||||
|
order: 20,
|
||||||
|
});
|
||||||
|
await organize("/api/v1/dashboard/navigation/placements/video", {
|
||||||
|
groupId: "media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
await organize("/api/v1/dashboard/navigation/placements/example", {
|
||||||
|
groupId: "media",
|
||||||
|
order: 20,
|
||||||
|
});
|
||||||
|
await organize("/api/v1/dashboard/navigation/placements/calendar", {
|
||||||
|
groupId: "planning",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shell's own bootstrap sees exactly that arrangement.
|
||||||
|
const before = await bootstrapHost(harness.config);
|
||||||
|
expect(
|
||||||
|
before.navigation.groups.map((group) => [
|
||||||
|
group.label,
|
||||||
|
group.modules.map((entry) => entry.name),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
["Media", ["Video", "Example module"]],
|
||||||
|
["Planning", ["Calendar"]],
|
||||||
|
]);
|
||||||
|
expect(before.navigation.ungrouped).toEqual([]);
|
||||||
|
|
||||||
|
// Mounting from inside a group uses the descriptor the group carried, untouched.
|
||||||
|
const groupedEntry = before.navigation.groups[0]?.modules[1];
|
||||||
|
if (!groupedEntry) {
|
||||||
|
throw new Error("the arrangement did not contain the expected module");
|
||||||
|
}
|
||||||
|
const container = containerSink();
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
container: container as unknown as LoaderContainer,
|
||||||
|
context: createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor: groupedEntry.descriptor,
|
||||||
|
principal: before.principal,
|
||||||
|
}),
|
||||||
|
source: {
|
||||||
|
moduleId: groupedEntry.descriptor.id,
|
||||||
|
url: await publishedArtifactUrl(
|
||||||
|
harness,
|
||||||
|
moduleUiSourceUrl(harness.config, groupedEntry.descriptor),
|
||||||
|
),
|
||||||
|
version: groupedEntry.descriptor.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(container.textContent).toBe("example:200:2");
|
||||||
|
await mounted.unmount();
|
||||||
|
|
||||||
|
// Now move it somewhere else entirely.
|
||||||
|
await organize("/api/v1/dashboard/navigation/groups/sunday-production", {
|
||||||
|
label: "Sunday Production",
|
||||||
|
order: 5,
|
||||||
|
});
|
||||||
|
await organize("/api/v1/dashboard/navigation/placements/example", {
|
||||||
|
groupId: "sunday-production",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await bootstrapHost(harness.config);
|
||||||
|
expect(
|
||||||
|
after.navigation.groups.map((group) => [
|
||||||
|
group.label,
|
||||||
|
group.modules.map((entry) => entry.name),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
["Sunday Production", ["Example module"]],
|
||||||
|
["Media", ["Video"]],
|
||||||
|
["Planning", ["Calendar"]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const movedEntry = after.navigation.groups[0]?.modules[0];
|
||||||
|
if (!movedEntry) {
|
||||||
|
throw new Error("the moved module was not found in its new group");
|
||||||
|
}
|
||||||
|
// The whole point of the pass: the module is byte-identical across the move — same artifact
|
||||||
|
// URL, same API root, same permissions, same version, same name.
|
||||||
|
expect(movedEntry).toEqual(groupedEntry);
|
||||||
|
|
||||||
|
// And it still mounts the same artifact and reaches the same API.
|
||||||
|
const callsBeforeRemount = harness.moduleApiCalls.length;
|
||||||
|
const movedContainer = containerSink();
|
||||||
|
const remounted = await loadModuleUi({
|
||||||
|
container: movedContainer as unknown as LoaderContainer,
|
||||||
|
context: createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor: movedEntry.descriptor,
|
||||||
|
principal: after.principal,
|
||||||
|
}),
|
||||||
|
source: {
|
||||||
|
moduleId: movedEntry.descriptor.id,
|
||||||
|
url: await publishedArtifactUrl(
|
||||||
|
harness,
|
||||||
|
moduleUiSourceUrl(harness.config, movedEntry.descriptor),
|
||||||
|
),
|
||||||
|
version: movedEntry.descriptor.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(movedContainer.textContent).toBe("example:200:2");
|
||||||
|
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||||
|
"GET /api/v1/modules/example/records",
|
||||||
|
]);
|
||||||
|
await remounted.unmount();
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a newly installed module visible beside an existing arrangement", async () => {
|
||||||
|
const harness = await startHarness({
|
||||||
|
extraModules: [{ id: "video", name: "Video" }],
|
||||||
|
platformOwner: true,
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await (harness.config.fetch as typeof fetch)(
|
||||||
|
"https://ekklesia.example.test/api/v1/dashboard/navigation/groups/media",
|
||||||
|
{
|
||||||
|
body: JSON.stringify({ label: "Media", order: 10 }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
origin: "https://dashboard.example.test",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await (harness.config.fetch as typeof fetch)(
|
||||||
|
"https://ekklesia.example.test/api/v1/dashboard/navigation/placements/video",
|
||||||
|
{
|
||||||
|
body: JSON.stringify({ groupId: "media", order: 10 }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
origin: "https://dashboard.example.test",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
|
|
||||||
|
expect(bootstrap.navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
|
||||||
|
// `example` was never placed, and is still reachable rather than invisible.
|
||||||
|
expect(bootstrap.navigation.ungrouped.map((entry) => entry.name)).toEqual(["Example module"]);
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
|
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
|
||||||
const harness = await startHarness();
|
const harness = await startHarness();
|
||||||
try {
|
try {
|
||||||
const bootstrap = await bootstrapHost(harness.config);
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
|
|
||||||
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
|
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
|
||||||
expect(bootstrap.modules).toEqual([]);
|
expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] });
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
}
|
}
|
||||||
@@ -383,7 +573,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const bootstrap = await bootstrapHost(harness.config);
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
const descriptor = bootstrap.modules[0];
|
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||||
if (!descriptor) {
|
if (!descriptor) {
|
||||||
throw new Error("the inventory returned no module");
|
throw new Error("the inventory returned no module");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
@@ -168,6 +169,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
MODULE_UI_ASSET_PREFIX,
|
MODULE_UI_ASSET_PREFIX,
|
||||||
@@ -168,6 +169,7 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type {
|
||||||
|
DashboardConfiguration,
|
||||||
|
DashboardGroupRecord,
|
||||||
|
DashboardPlacementRecord,
|
||||||
|
DashboardStore,
|
||||||
|
SetDashboardPlacementInput,
|
||||||
|
UpsertDashboardGroupInput,
|
||||||
|
} from "@ekklesia/core/dashboard";
|
||||||
|
import { DashboardStoreError } from "@ekklesia/core/dashboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory `DashboardStore` for tests that exercise **routing and resolution**, not persistence.
|
||||||
|
*
|
||||||
|
* It reproduces only the two behaviours those tests depend on — a placement may not name a group
|
||||||
|
* that does not exist, and deleting a group ungroups its modules (the schema's
|
||||||
|
* `on delete set null`) — and deliberately reproduces nothing else. Everything the real store owns
|
||||||
|
* that this double would only be guessing at (identifier grammar, label trimming and bounds,
|
||||||
|
* ordering bounds, transactional behaviour, and the actual `on delete set null` constraint) is
|
||||||
|
* proven against real PostgreSQL in `core/tests/dashboard-store.integration.ts` instead, so this
|
||||||
|
* double can never quietly stand in for a persistence guarantee nobody checked.
|
||||||
|
*/
|
||||||
|
export function createInMemoryDashboardStore(
|
||||||
|
seed: Partial<DashboardConfiguration> = {},
|
||||||
|
): DashboardStore {
|
||||||
|
const groups = new Map<string, DashboardGroupRecord>(
|
||||||
|
(seed.groups ?? []).map((group) => [group.id, { ...group }] as const),
|
||||||
|
);
|
||||||
|
const placements = new Map<string, DashboardPlacementRecord>(
|
||||||
|
(seed.placements ?? []).map((placement) => [placement.moduleId, { ...placement }] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = <Record extends { readonly sortOrder: number }>(
|
||||||
|
records: readonly Record[],
|
||||||
|
key: (record: Record) => string,
|
||||||
|
): readonly Record[] =>
|
||||||
|
Object.freeze(
|
||||||
|
[...records].sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.sortOrder - right.sortOrder ||
|
||||||
|
(key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
async deleteGroup(groupId: string): Promise<void> {
|
||||||
|
if (!groups.delete(groupId)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
for (const [moduleId, placement] of placements) {
|
||||||
|
if (placement.groupId === groupId) {
|
||||||
|
placements.set(moduleId, { ...placement, groupId: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async readConfiguration(): Promise<DashboardConfiguration> {
|
||||||
|
return Object.freeze({
|
||||||
|
groups: sorted([...groups.values()], (group) => group.id),
|
||||||
|
placements: sorted([...placements.values()], (placement) => placement.moduleId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
|
||||||
|
if (input.groupId !== null && !groups.has(input.groupId)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
const record: DashboardPlacementRecord = Object.freeze({
|
||||||
|
groupId: input.groupId,
|
||||||
|
moduleId: input.moduleId,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
});
|
||||||
|
placements.set(input.moduleId, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
|
||||||
|
const record: DashboardGroupRecord = Object.freeze({
|
||||||
|
id: input.id,
|
||||||
|
label: input.label.trim(),
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
});
|
||||||
|
groups.set(input.id, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ import { defineConfig } from "drizzle-kit";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"],
|
schema: [
|
||||||
|
"./src/identity/schema.ts",
|
||||||
|
"./src/authorization/schema.ts",
|
||||||
|
"./src/dashboard/schema.ts",
|
||||||
|
],
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
migrations: {
|
migrations: {
|
||||||
prefix: "index",
|
prefix: "index",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE "ekklesia_core_dashboard_group" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "ekklesia_core_dashboard_group_id_check" CHECK (length("ekklesia_core_dashboard_group"."id") > 0),
|
||||||
|
CONSTRAINT "ekklesia_core_dashboard_group_label_check" CHECK (length(btrim("ekklesia_core_dashboard_group"."label")) > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ekklesia_core_dashboard_placement" (
|
||||||
|
"module_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"group_id" text,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "ekklesia_core_dashboard_placement_module_check" CHECK (length("ekklesia_core_dashboard_placement"."module_id") > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_dashboard_placement" ADD CONSTRAINT "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."ekklesia_core_dashboard_group"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_dashboard_group_order_idx" ON "ekklesia_core_dashboard_group" USING btree ("sort_order","id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_dashboard_placement_group_idx" ON "ekklesia_core_dashboard_placement" USING btree ("group_id","sort_order");
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
{
|
||||||
|
"id": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
|
||||||
|
"prevId": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.ekklesia_core_account": {
|
||||||
|
"name": "ekklesia_core_account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_account_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_account_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_account",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_session": {
|
||||||
|
"name": "ekklesia_core_session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_session_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_session_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_session",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_session_token_unique": {
|
||||||
|
"name": "ekklesia_core_session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["token"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_user": {
|
||||||
|
"name": "ekklesia_core_user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_user_email_unique": {
|
||||||
|
"name": "ekklesia_core_user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["email"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_verification": {
|
||||||
|
"name": "ekklesia_core_verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_verification_identifier_idx": {
|
||||||
|
"name": "ekklesia_core_verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_authorization_audit": {
|
||||||
|
"name": "ekklesia_core_authorization_audit",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_type": {
|
||||||
|
"name": "actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_id": {
|
||||||
|
"name": "actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"subject_user_id": {
|
||||||
|
"name": "subject_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"grant_id": {
|
||||||
|
"name": "grant_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_authorization_audit_subject_idx": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "subject_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_authorization_audit_action_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_action_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_scope_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_grant": {
|
||||||
|
"name": "ekklesia_core_grant",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"source_kind": {
|
||||||
|
"name": "source_kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source_ref": {
|
||||||
|
"name": "source_ref",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"created_by_actor_type": {
|
||||||
|
"name": "created_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_by_actor_id": {
|
||||||
|
"name": "created_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_grant_user_permission_idx": {
|
||||||
|
"name": "ekklesia_core_grant_user_permission_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "permission_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_grant",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_grant_source_kind_check": {
|
||||||
|
"name": "ekklesia_core_grant_source_kind_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_created_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner": {
|
||||||
|
"name": "ekklesia_core_platform_owner",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_at": {
|
||||||
|
"name": "granted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"granted_by_actor_type": {
|
||||||
|
"name": "granted_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_by_actor_id": {
|
||||||
|
"name": "granted_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_platform_owner",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"consumed_at": {
|
||||||
|
"name": "consumed_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"consumed_for_user_id": {
|
||||||
|
"name": "consumed_for_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_dashboard_group": {
|
||||||
|
"name": "ekklesia_core_dashboard_group",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"name": "label",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_dashboard_group_order_idx": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_order_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "sort_order",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_dashboard_group_id_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_id_check",
|
||||||
|
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
|
||||||
|
},
|
||||||
|
"ekklesia_core_dashboard_group_label_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_label_check",
|
||||||
|
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_dashboard_placement": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"module_id": {
|
||||||
|
"name": "module_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"group_id": {
|
||||||
|
"name": "group_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_dashboard_placement_group_idx": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_group_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "group_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "sort_order",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_dashboard_placement",
|
||||||
|
"tableTo": "ekklesia_core_dashboard_group",
|
||||||
|
"columnsFrom": ["group_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_dashboard_placement_module_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_module_check",
|
||||||
|
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@
|
|||||||
"destructive": {
|
"destructive": {
|
||||||
"mode": "none"
|
"mode": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0003_dashboard_navigation_tables",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1785104670124,
|
"when": 1785104670124,
|
||||||
"tag": "0002_authorization_integrity",
|
"tag": "0002_authorization_integrity",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787408382271,
|
||||||
|
"tag": "0003_dashboard_navigation_tables",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
"types": "./dist/modules/index.d.ts",
|
"types": "./dist/modules/index.d.ts",
|
||||||
"import": "./dist/modules/index.js"
|
"import": "./dist/modules/index.js"
|
||||||
},
|
},
|
||||||
|
"./dashboard": {
|
||||||
|
"types": "./dist/dashboard/index.d.ts",
|
||||||
|
"import": "./dist/dashboard/index.js"
|
||||||
|
},
|
||||||
"./migrations": {
|
"./migrations": {
|
||||||
"types": "./dist/migrations/index.d.ts",
|
"types": "./dist/migrations/index.d.ts",
|
||||||
"import": "./dist/migrations/index.js"
|
"import": "./dist/migrations/index.js"
|
||||||
|
|||||||
@@ -98,6 +98,19 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
|||||||
*/
|
*/
|
||||||
export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read");
|
export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricted: reorganizing the Dashboard is an administrative action, not something every signed-in
|
||||||
|
* user may do. It is deliberately a **platform** permission and never a module's own — grouping is
|
||||||
|
* Ekklesia's presentation configuration, so no module may gate, grant, or influence it (ADR 0027).
|
||||||
|
*
|
||||||
|
* Holding it confers no module authority whatsoever. 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 exactly as before.
|
||||||
|
*/
|
||||||
|
export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId(
|
||||||
|
"core.dashboard.navigation.manage",
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||||
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||||
@@ -116,6 +129,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
|||||||
description: "List the installed module UIs a browser host may mount.",
|
description: "List the installed module UIs a browser host may mount.",
|
||||||
id: CORE_MODULES_UI_READ,
|
id: CORE_MODULES_UI_READ,
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
|
||||||
|
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./navigation.js";
|
||||||
|
export * from "./schema.js";
|
||||||
|
export * from "./store.js";
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type { DashboardConfiguration } from "./store.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module as the Dashboard needs to present it: its identity, its administrator-visible name,
|
||||||
|
* and nothing about where it lives on disk.
|
||||||
|
*
|
||||||
|
* `name` is the module's own `manifest.name` — its product identity, which every manifest has
|
||||||
|
* already required since version 1. The Dashboard renders it rather than the module id, and a module
|
||||||
|
* declaring it is *not* the module declaring its navigation: a name answers "what is this product
|
||||||
|
* called", while a group answers "where does this church want it", and only the second is
|
||||||
|
* configuration an administrator owns.
|
||||||
|
*/
|
||||||
|
export interface DashboardNavigationModule {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationGroupView<Module extends DashboardNavigationModule> {
|
||||||
|
/** The stable key, unchanged by a rename. Callers use it to address the group, never to display. */
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly order: number;
|
||||||
|
readonly modules: readonly Module[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationView<Module extends DashboardNavigationModule> {
|
||||||
|
readonly groups: readonly DashboardNavigationGroupView<Module>[];
|
||||||
|
/**
|
||||||
|
* Modules that belong to no group: those an administrator deliberately placed outside every group,
|
||||||
|
* and those never configured at all — a newly installed module, most importantly. Both render
|
||||||
|
* here, which is what guarantees a new module is visible the moment it is discovered, with no
|
||||||
|
* initialization write, no manual database edit, and no migration.
|
||||||
|
*/
|
||||||
|
readonly ungrouped: readonly Module[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Placed modules first, in the administrator's order; never-configured modules after them, by id.
|
||||||
|
* A newcomer therefore appears without interrupting an ordering someone chose, and the module id is
|
||||||
|
* always the final tie-break, so two modules sharing a `sortOrder` still order identically on every
|
||||||
|
* request and in every process. */
|
||||||
|
interface SortKey {
|
||||||
|
readonly configured: 0 | 1;
|
||||||
|
readonly order: number;
|
||||||
|
readonly id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSortKeys(left: SortKey, right: SortKey): number {
|
||||||
|
if (left.configured !== right.configured) {
|
||||||
|
return left.configured - right.configured;
|
||||||
|
}
|
||||||
|
if (left.order !== right.order) {
|
||||||
|
return left.order - right.order;
|
||||||
|
}
|
||||||
|
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves stored Dashboard configuration against the modules that are actually mountable right now.
|
||||||
|
*
|
||||||
|
* Pure and synchronous: no database, no filesystem, no clock, no module code. It is given the stored
|
||||||
|
* arrangement and the current module set and returns what to render, so every rule below is
|
||||||
|
* directly testable without PostgreSQL.
|
||||||
|
*
|
||||||
|
* ## The rules, exactly
|
||||||
|
*
|
||||||
|
* - **A module with no placement is ungrouped and visible.** Nothing is written to make that true.
|
||||||
|
* Installing a module is enough to see it; an administrator moves it afterwards if they want to.
|
||||||
|
* - **A stored placement for a module that is not installed renders nothing, and is retained.** The
|
||||||
|
* store keeps the row, so uninstalling and reinstalling a module restores its position rather than
|
||||||
|
* silently resetting it. A dead navigation entry never appears.
|
||||||
|
* - **A placement naming a group that no longer exists cannot occur** — the schema's
|
||||||
|
* `on delete set null` makes the module ungrouped when its group is deleted — but if one somehow
|
||||||
|
* did, it is treated as ungrouped rather than dropped, so no module can be made invisible by a
|
||||||
|
* configuration inconsistency.
|
||||||
|
* - **Empty groups are kept.** A group an administrator created and has not filled yet, or whose
|
||||||
|
* only module is temporarily uninstalled, still renders; hiding it would look like the group had
|
||||||
|
* been deleted.
|
||||||
|
* - **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)`. Colliding
|
||||||
|
* order values never produce a nondeterministic result.
|
||||||
|
*
|
||||||
|
* ## What this function structurally cannot do
|
||||||
|
*
|
||||||
|
* It never reads a manifest, a dependency, a capability, an entrypoint, or a permission, and it
|
||||||
|
* returns no edge between two modules. Two modules sharing a group are exactly as related as two
|
||||||
|
* modules that do not: grouping is a rendering decision computed here, downstream of everything that
|
||||||
|
* decides what a module *is* (ADR 0027). Nothing upstream — discovery, dependency ordering, runtime
|
||||||
|
* startup, authorization — ever calls this.
|
||||||
|
*/
|
||||||
|
export function resolveDashboardNavigation<Module extends DashboardNavigationModule>(
|
||||||
|
configuration: DashboardConfiguration,
|
||||||
|
mountableModules: readonly Module[],
|
||||||
|
): DashboardNavigationView<Module> {
|
||||||
|
const placementsByModuleId = new Map(
|
||||||
|
configuration.placements.map((placement) => [placement.moduleId, placement] as const),
|
||||||
|
);
|
||||||
|
const groupIds = new Set(configuration.groups.map((group) => group.id));
|
||||||
|
|
||||||
|
const modulesByGroupId = new Map<string, Array<{ key: SortKey; module: Module }>>();
|
||||||
|
const ungrouped: Array<{ key: SortKey; module: Module }> = [];
|
||||||
|
|
||||||
|
for (const module of mountableModules) {
|
||||||
|
const placement = placementsByModuleId.get(module.id);
|
||||||
|
const key: SortKey = {
|
||||||
|
configured: placement ? 0 : 1,
|
||||||
|
id: module.id,
|
||||||
|
order: placement?.sortOrder ?? 0,
|
||||||
|
};
|
||||||
|
const groupId =
|
||||||
|
placement?.groupId !== undefined &&
|
||||||
|
placement.groupId !== null &&
|
||||||
|
groupIds.has(placement.groupId)
|
||||||
|
? placement.groupId
|
||||||
|
: undefined;
|
||||||
|
if (groupId === undefined) {
|
||||||
|
ungrouped.push({ key, module });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bucket = modulesByGroupId.get(groupId);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.push({ key, module });
|
||||||
|
} else {
|
||||||
|
modulesByGroupId.set(groupId, [{ key, module }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortModules = (entries: Array<{ key: SortKey; module: Module }>): readonly Module[] =>
|
||||||
|
Object.freeze(
|
||||||
|
[...entries]
|
||||||
|
.sort((left, right) => compareSortKeys(left.key, right.key))
|
||||||
|
.map((entry) => entry.module),
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = [...configuration.groups]
|
||||||
|
.sort((left, right) =>
|
||||||
|
left.sortOrder !== right.sortOrder
|
||||||
|
? left.sortOrder - right.sortOrder
|
||||||
|
: left.id < right.id
|
||||||
|
? -1
|
||||||
|
: left.id > right.id
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
.map((group) =>
|
||||||
|
Object.freeze({
|
||||||
|
id: group.id,
|
||||||
|
label: group.label,
|
||||||
|
modules: sortModules(modulesByGroupId.get(group.id) ?? []),
|
||||||
|
order: group.sortOrder,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
groups: Object.freeze(groups),
|
||||||
|
ungrouped: sortModules(ungrouped),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { check, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core-owned storage for how the Ekklesia Dashboard *presents* installed modules.
|
||||||
|
*
|
||||||
|
* This is presentation configuration, and it is deliberately the only thing here. A group owns no
|
||||||
|
* tables, no migrations, no routes, and no lifecycle; it never participates in dependency
|
||||||
|
* resolution, discovery order, or module startup. **Modules stay flat** (ADR 0021): there is no
|
||||||
|
* parent module, no child module, no owning module, and nothing in this schema is ever read by
|
||||||
|
* anything that decides what a module may do or when it starts. Moving a module between groups
|
||||||
|
* changes a row here and nothing else.
|
||||||
|
*
|
||||||
|
* Core stores this; it renders nothing. `surfaces/shell` decides what the arrangement looks like.
|
||||||
|
*
|
||||||
|
* Platform-global, because Ekklesia has no organization or tenant model to scope it to. When one
|
||||||
|
* exists, these two tables gain a scope column; inventing a second notion of organization here to
|
||||||
|
* anticipate it would be worse than adding a column later.
|
||||||
|
*
|
||||||
|
* Table and index names are explicit and namespaced exactly like `core/src/authorization/schema.ts`,
|
||||||
|
* so ownership is unambiguous inside the shared public PostgreSQL schema.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Dashboard navigation group — "Media", "Planning", "Sunday Production".
|
||||||
|
*
|
||||||
|
* `id` is an **opaque, stable key**, never displayed and never re-derived from `label`. Renaming a
|
||||||
|
* group changes only `label`, so every placement pointing at it survives untouched; that invariant
|
||||||
|
* is what keeps a rename from silently re-organizing the Dashboard. A slug is used rather than a
|
||||||
|
* generated UUID purely so the configuration API and its tests stay legible — it carries no meaning
|
||||||
|
* the platform reads.
|
||||||
|
*
|
||||||
|
* There is no parent group column. Nesting is deliberately out of scope, and leaving the shape flat
|
||||||
|
* is what keeps it out.
|
||||||
|
*/
|
||||||
|
export const ekklesiaCoreDashboardGroup = pgTable(
|
||||||
|
"ekklesia_core_dashboard_group",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
label: text("label").notNull(),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("ekklesia_core_dashboard_group_order_idx").on(table.sortOrder, table.id),
|
||||||
|
// Defense in depth beneath the application-layer identifier and label contracts, which never
|
||||||
|
// produce either of these: an empty id would be an unaddressable group, and an empty or
|
||||||
|
// whitespace-only label would render as a blank heading no administrator could find again.
|
||||||
|
check("ekklesia_core_dashboard_group_id_check", sql`length(${table.id}) > 0`),
|
||||||
|
check("ekklesia_core_dashboard_group_label_check", sql`length(btrim(${table.label})) > 0`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one module sits in the Dashboard. One row per module at most — the primary key enforces it
|
||||||
|
* — so a module can never be placed in two groups, which is the database-level expression of "one
|
||||||
|
* level of grouping, and no module ownership graph."
|
||||||
|
*
|
||||||
|
* `group_id` is nullable and that is a real state, not a missing value: **ungrouped is a placement**,
|
||||||
|
* chosen by an administrator who ordered a module outside every group. A module with no row at all
|
||||||
|
* is a different state — never configured — and both render in the same ungrouped area (see
|
||||||
|
* `../dashboard/navigation.js` for the exact ordering rule).
|
||||||
|
*
|
||||||
|
* `on delete set null` is what makes group deletion safe: deleting a group cannot leave a dangling
|
||||||
|
* reference, and its modules become ungrouped rather than disappearing. That is enforced by the
|
||||||
|
* database, not by application cleanup that could be skipped.
|
||||||
|
*
|
||||||
|
* `module_id` deliberately carries **no foreign key to any module**, because modules are not rows —
|
||||||
|
* they are packages discovered on disk at startup. A placement for a module that is not installed
|
||||||
|
* is therefore normal and is retained: uninstalling and reinstalling a module keeps its position
|
||||||
|
* rather than silently resetting it. Nothing renders such a row (see `resolveDashboardNavigation`).
|
||||||
|
*/
|
||||||
|
export const ekklesiaCoreDashboardPlacement = pgTable(
|
||||||
|
"ekklesia_core_dashboard_placement",
|
||||||
|
{
|
||||||
|
moduleId: text("module_id").primaryKey(),
|
||||||
|
groupId: text("group_id").references(() => ekklesiaCoreDashboardGroup.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("ekklesia_core_dashboard_placement_group_idx").on(table.groupId, table.sortOrder),
|
||||||
|
check("ekklesia_core_dashboard_placement_module_check", sql`length(${table.moduleId}) > 0`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Explicit map passed to Drizzle, mirroring `authorizationSchema`'s explicit-mapping style. */
|
||||||
|
export const dashboardSchema = {
|
||||||
|
dashboardGroup: ekklesiaCoreDashboardGroup,
|
||||||
|
dashboardPlacement: ekklesiaCoreDashboardPlacement,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
import {
|
||||||
|
dashboardSchema,
|
||||||
|
ekklesiaCoreDashboardGroup,
|
||||||
|
ekklesiaCoreDashboardPlacement,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
export type DashboardDrizzleDatabase = NodePgDatabase<typeof dashboardSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`:
|
||||||
|
* it never constructs, registers an error listener on, or closes a pool. The caller owns the pool
|
||||||
|
* once, however many typed views it creates over it.
|
||||||
|
*/
|
||||||
|
export function createDashboardDrizzleView(pool: Pool): DashboardDrizzleDatabase {
|
||||||
|
return drizzle(pool, { schema: dashboardSchema });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounds every stored ordering value. Wide enough that an administrator never runs out of room
|
||||||
|
* between two entries, small enough that a client cannot store a value that surprises a later
|
||||||
|
* integer column. Enforced identically by the store and by the HTTP surface, so there is one bound
|
||||||
|
* rather than two that could drift. */
|
||||||
|
export const DASHBOARD_SORT_ORDER_MIN = 0;
|
||||||
|
export const DASHBOARD_SORT_ORDER_MAX = 1_000_000;
|
||||||
|
|
||||||
|
/** The label an administrator sees. Bounded like every other operator-facing string in the
|
||||||
|
* platform; `moduleManifestSchema.name` uses the same ceiling. */
|
||||||
|
export const DASHBOARD_GROUP_LABEL_MAX_LENGTH = 120;
|
||||||
|
|
||||||
|
/** Lowercase kebab-case, matching `moduleIdSchema`'s grammar so the platform has one identifier
|
||||||
|
* shape rather than two. Opaque: never displayed, never parsed for meaning, never re-derived from a
|
||||||
|
* label. */
|
||||||
|
const GROUP_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||||
|
const GROUP_ID_MAX_LENGTH = 64;
|
||||||
|
|
||||||
|
/** The one identifier grammar for both a group id and a placement's module id, exported so the HTTP
|
||||||
|
* boundary rejects malformed input with a request error *before* reaching the store rather than
|
||||||
|
* relying on mapping a store failure back to a status code — and so there is one grammar rather
|
||||||
|
* than two that could drift. */
|
||||||
|
export function isValidDashboardIdentifier(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === "string" && value.length <= GROUP_ID_MAX_LENGTH && GROUP_ID_PATTERN.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one label rule, exported for the same reason. Returns the trimmed value the store would
|
||||||
|
* persist, or `undefined` when the label is empty, whitespace-only, or too long. */
|
||||||
|
export function normalizeDashboardGroupLabel(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length === 0 || trimmed.length > DASHBOARD_GROUP_LABEL_MAX_LENGTH
|
||||||
|
? undefined
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardStoreErrorCode =
|
||||||
|
| "DASHBOARD_GROUP_ID_INVALID"
|
||||||
|
| "DASHBOARD_GROUP_LABEL_INVALID"
|
||||||
|
| "DASHBOARD_GROUP_NOT_FOUND"
|
||||||
|
| "DASHBOARD_MODULE_ID_INVALID"
|
||||||
|
| "DASHBOARD_SORT_ORDER_INVALID"
|
||||||
|
| "DASHBOARD_READ_FAILED"
|
||||||
|
| "DASHBOARD_WRITE_FAILED";
|
||||||
|
|
||||||
|
const storeErrorMessages: Readonly<Record<DashboardStoreErrorCode, string>> = Object.freeze({
|
||||||
|
DASHBOARD_GROUP_ID_INVALID: "A Dashboard group identifier must be lowercase kebab-case.",
|
||||||
|
DASHBOARD_GROUP_LABEL_INVALID: "A Dashboard group label must be non-empty and bounded.",
|
||||||
|
DASHBOARD_GROUP_NOT_FOUND: "The Dashboard group was not found.",
|
||||||
|
DASHBOARD_MODULE_ID_INVALID: "A Dashboard placement module identifier is invalid.",
|
||||||
|
DASHBOARD_READ_FAILED: "The Dashboard navigation configuration could not be read.",
|
||||||
|
DASHBOARD_SORT_ORDER_INVALID: "A Dashboard ordering value is out of range.",
|
||||||
|
DASHBOARD_WRITE_FAILED: "The Dashboard navigation configuration could not be saved.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Never carries the underlying database error, SQL text, host, or stack — matching
|
||||||
|
* `AuthorizationStoreError` exactly. */
|
||||||
|
export class DashboardStoreError extends Error {
|
||||||
|
readonly code: DashboardStoreErrorCode;
|
||||||
|
|
||||||
|
constructor(code: DashboardStoreErrorCode) {
|
||||||
|
super(storeErrorMessages[code]);
|
||||||
|
this.name = "DashboardStoreError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored group. `label` is what an administrator sees; `id` is the stable key placements point
|
||||||
|
* at, and a rename never touches it. */
|
||||||
|
export interface DashboardGroupRecord {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where one module sits. `groupId: null` is a real, chosen state — deliberately ungrouped — and is
|
||||||
|
* distinct from having no record at all, which means never configured. */
|
||||||
|
export interface DashboardPlacementRecord {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly groupId: string | null;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The complete stored arrangement. Contains no module facts whatsoever: resolving it against the
|
||||||
|
* modules that actually exist is `resolveDashboardNavigation`'s job, not the store's. */
|
||||||
|
export interface DashboardConfiguration {
|
||||||
|
readonly groups: readonly DashboardGroupRecord[];
|
||||||
|
readonly placements: readonly DashboardPlacementRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertDashboardGroupInput {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetDashboardPlacementInput {
|
||||||
|
readonly moduleId: string;
|
||||||
|
/** `null` places the module in the ungrouped area deliberately. A group id that does not exist is
|
||||||
|
* rejected rather than stored, so a placement can never point at a group that was never created. */
|
||||||
|
readonly groupId: string | null;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStore {
|
||||||
|
/** The whole stored arrangement, in deterministic order. Small by nature — one row per group and
|
||||||
|
* at most one per module — so it is read whole rather than paginated. */
|
||||||
|
readConfiguration(): Promise<DashboardConfiguration>;
|
||||||
|
/** Creates the group or replaces its label and order. Idempotent: the same input twice leaves the
|
||||||
|
* same single row, and placements pointing at it are untouched either way. */
|
||||||
|
upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord>;
|
||||||
|
/** Deletes a group. Its placements become ungrouped through the schema's `on delete set null`, so
|
||||||
|
* no dangling reference is possible and no module is lost. */
|
||||||
|
deleteGroup(groupId: string): Promise<void>;
|
||||||
|
/** Creates or replaces one module's placement. */
|
||||||
|
setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidGroupId(value: string): void {
|
||||||
|
if (!isValidDashboardIdentifier(value)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_ID_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidModuleId(value: string): void {
|
||||||
|
if (!isValidDashboardIdentifier(value)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_MODULE_ID_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedLabel(value: string): string {
|
||||||
|
const trimmed = normalizeDashboardGroupLabel(value);
|
||||||
|
if (trimmed === undefined) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_LABEL_INVALID");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidSortOrder(value: number): void {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value < DASHBOARD_SORT_ORDER_MIN ||
|
||||||
|
value > DASHBOARD_SORT_ORDER_MAX
|
||||||
|
) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_SORT_ORDER_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rethrowAsStoreError(error: unknown, code: DashboardStoreErrorCode): never {
|
||||||
|
if (error instanceof DashboardStoreError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new DashboardStoreError(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's persistence for Dashboard navigation configuration.
|
||||||
|
*
|
||||||
|
* Validation lives here rather than only at the HTTP boundary, so the invariants hold for any
|
||||||
|
* caller: identifiers are checked against one grammar, labels are trimmed and bounded, ordering is
|
||||||
|
* a bounded safe integer, and a placement may only name a group that exists. Nothing here reads,
|
||||||
|
* imports, or reasons about a module — a placement is a `module_id` string and no more — which is
|
||||||
|
* what keeps grouping structurally incapable of influencing discovery, dependency resolution, or
|
||||||
|
* startup order.
|
||||||
|
*/
|
||||||
|
export function createDashboardStore(db: DashboardDrizzleDatabase): DashboardStore {
|
||||||
|
return Object.freeze({
|
||||||
|
async readConfiguration(): Promise<DashboardConfiguration> {
|
||||||
|
try {
|
||||||
|
const [groups, placements] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: ekklesiaCoreDashboardGroup.id,
|
||||||
|
label: ekklesiaCoreDashboardGroup.label,
|
||||||
|
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreDashboardGroup)
|
||||||
|
.orderBy(asc(ekklesiaCoreDashboardGroup.sortOrder), asc(ekklesiaCoreDashboardGroup.id)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
groupId: ekklesiaCoreDashboardPlacement.groupId,
|
||||||
|
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
|
||||||
|
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreDashboardPlacement)
|
||||||
|
.orderBy(
|
||||||
|
asc(ekklesiaCoreDashboardPlacement.sortOrder),
|
||||||
|
asc(ekklesiaCoreDashboardPlacement.moduleId),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return Object.freeze({
|
||||||
|
groups: Object.freeze(groups.map((group) => Object.freeze({ ...group }))),
|
||||||
|
placements: Object.freeze(placements.map((placement) => Object.freeze({ ...placement }))),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "DASHBOARD_READ_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
|
||||||
|
assertValidGroupId(input.id);
|
||||||
|
const label = normalizedLabel(input.label);
|
||||||
|
assertValidSortOrder(input.sortOrder);
|
||||||
|
try {
|
||||||
|
// A rename updates `label` and `sort_order` only. `id` is the conflict target, so it is
|
||||||
|
// never rewritten and every placement pointing at this group keeps pointing at it.
|
||||||
|
const [row] = await db
|
||||||
|
.insert(ekklesiaCoreDashboardGroup)
|
||||||
|
.values({ id: input.id, label, sortOrder: input.sortOrder })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
set: { label, sortOrder: input.sortOrder, updatedAt: new Date() },
|
||||||
|
target: ekklesiaCoreDashboardGroup.id,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: ekklesiaCoreDashboardGroup.id,
|
||||||
|
label: ekklesiaCoreDashboardGroup.label,
|
||||||
|
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
return Object.freeze(row);
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteGroup(groupId: string): Promise<void> {
|
||||||
|
assertValidGroupId(groupId);
|
||||||
|
try {
|
||||||
|
const deleted = await db
|
||||||
|
.delete(ekklesiaCoreDashboardGroup)
|
||||||
|
.where(eq(ekklesiaCoreDashboardGroup.id, groupId))
|
||||||
|
.returning({ id: ekklesiaCoreDashboardGroup.id });
|
||||||
|
if (deleted.length === 0) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
|
||||||
|
assertValidModuleId(input.moduleId);
|
||||||
|
assertValidSortOrder(input.sortOrder);
|
||||||
|
if (input.groupId !== null) {
|
||||||
|
assertValidGroupId(input.groupId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Checked in the application rather than left to the foreign key so the caller gets
|
||||||
|
// `DASHBOARD_GROUP_NOT_FOUND` instead of a driver error. The foreign key remains the
|
||||||
|
// authority: a group deleted concurrently still fails the insert rather than storing a
|
||||||
|
// dangling reference.
|
||||||
|
if (input.groupId !== null) {
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: ekklesiaCoreDashboardGroup.id })
|
||||||
|
.from(ekklesiaCoreDashboardGroup)
|
||||||
|
.where(eq(ekklesiaCoreDashboardGroup.id, input.groupId))
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [row] = await db
|
||||||
|
.insert(ekklesiaCoreDashboardPlacement)
|
||||||
|
.values({
|
||||||
|
groupId: input.groupId,
|
||||||
|
moduleId: input.moduleId,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
set: { groupId: input.groupId, sortOrder: input.sortOrder, updatedAt: new Date() },
|
||||||
|
target: ekklesiaCoreDashboardPlacement.moduleId,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
groupId: ekklesiaCoreDashboardPlacement.groupId,
|
||||||
|
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
|
||||||
|
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
return Object.freeze(row);
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,16 @@ import type { DiscoveredModule } from "./discovery.js";
|
|||||||
export interface PublishableModuleUi {
|
export interface PublishableModuleUi {
|
||||||
readonly moduleId: string;
|
readonly moduleId: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
|
/**
|
||||||
|
* The module's own `manifest.name` — its product identity, required by every manifest version and
|
||||||
|
* therefore available without any manifest change (ADR 0027).
|
||||||
|
*
|
||||||
|
* Carried here so a host can render something a person recognizes instead of a module id. It is
|
||||||
|
* presentation *identity*, not presentation *placement*: a module says what it is called, while
|
||||||
|
* where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping,
|
||||||
|
* ordering, or navigation belongs on this record.
|
||||||
|
*/
|
||||||
|
readonly name: string;
|
||||||
readonly artifactPath: string;
|
readonly artifactPath: string;
|
||||||
/**
|
/**
|
||||||
* The module's own manifest-declared permissions, carried here so a publisher can compute the
|
* The module's own manifest-declared permissions, carried here so a publisher can compute the
|
||||||
@@ -92,6 +102,7 @@ export function createModuleUiPublicationRegistry(
|
|||||||
Object.freeze({
|
Object.freeze({
|
||||||
artifactPath,
|
artifactPath,
|
||||||
moduleId: module.manifest.id,
|
moduleId: module.manifest.id,
|
||||||
|
name: module.manifest.name,
|
||||||
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
|
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
|
||||||
// this record can never reach the manifest object graph itself.
|
// this record can never reach the manifest object graph itself.
|
||||||
permissions: Object.freeze([...module.manifest.permissions]),
|
permissions: Object.freeze([...module.manifest.permissions]),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { parsePermissionId } from "@ekklesia/contracts";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_PERMISSION_DECLARATIONS,
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
CORE_PERMISSION_REGISTRY,
|
CORE_PERMISSION_REGISTRY,
|
||||||
@@ -100,10 +101,20 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
|||||||
expect(declaration?.id).toBe("core.modules.ui.read");
|
expect(declaration?.id).toBe("core.modules.ui.read");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("contains exactly these two permissions and no others", () => {
|
it("registers core.dashboard.navigation.manage as restricted", () => {
|
||||||
|
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_DASHBOARD_NAVIGATION_MANAGE);
|
||||||
|
expect(declaration).toBeDefined();
|
||||||
|
// Reorganizing the Dashboard is administrative, not something every signed-in user may do —
|
||||||
|
// unlike merely reading which module UIs exist (ADR 0027).
|
||||||
|
expect(declaration?.baseline).toBe("restricted");
|
||||||
|
expect(declaration?.id).toBe("core.dashboard.navigation.manage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains exactly these three permissions and no others", () => {
|
||||||
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -121,10 +132,16 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
|||||||
description: "List the installed module UIs a browser host may mount.",
|
description: "List the installed module UIs a browser host may mount.",
|
||||||
id: CORE_MODULES_UI_READ,
|
id: CORE_MODULES_UI_READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
|
||||||
|
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true);
|
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true);
|
||||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true);
|
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true);
|
||||||
|
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[2])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => {
|
it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => {
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
|
"core:0003_dashboard_navigation_tables",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -207,7 +208,7 @@ describe.sequential("live Core authorization store", () => {
|
|||||||
const scope: AuthorizationScope = parseAuthorizationScope("calendar-event:123");
|
const scope: AuthorizationScope = parseAuthorizationScope("calendar-event:123");
|
||||||
const actor = createUserActor("authz_user_two"); // an operator/admin user distinct from the subject
|
const actor = createUserActor("authz_user_two"); // an operator/admin user distinct from the subject
|
||||||
|
|
||||||
it("applies a fresh migration and creates exactly the three authorization tables with their constraints", async () => {
|
it("applies a fresh migration and creates exactly the expected Core tables with their constraints", async () => {
|
||||||
database = await createDisposableDatabase("fresh");
|
database = await createDisposableDatabase("fresh");
|
||||||
await applyCoreMigrations(database.url);
|
await applyCoreMigrations(database.url);
|
||||||
|
|
||||||
@@ -218,9 +219,13 @@ describe.sequential("live Core authorization store", () => {
|
|||||||
where table_schema = 'public' and table_name like 'ekklesia_core_%'
|
where table_schema = 'public' and table_name like 'ekklesia_core_%'
|
||||||
order by table_name`,
|
order by table_name`,
|
||||||
);
|
);
|
||||||
|
// The complete Core schema after every migration, asserted exactly so a new table is a
|
||||||
|
// deliberate change here rather than an unnoticed one.
|
||||||
expect(tables.map((row) => row.table_name)).toEqual([
|
expect(tables.map((row) => row.table_name)).toEqual([
|
||||||
"ekklesia_core_account",
|
"ekklesia_core_account",
|
||||||
"ekklesia_core_authorization_audit",
|
"ekklesia_core_authorization_audit",
|
||||||
|
"ekklesia_core_dashboard_group",
|
||||||
|
"ekklesia_core_dashboard_placement",
|
||||||
"ekklesia_core_grant",
|
"ekklesia_core_grant",
|
||||||
"ekklesia_core_migrations",
|
"ekklesia_core_migrations",
|
||||||
"ekklesia_core_platform_owner",
|
"ekklesia_core_platform_owner",
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { type DashboardConfiguration, resolveDashboardNavigation } from "../src/dashboard/index.js";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModules,
|
||||||
|
} from "../src/modules/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The executable form of the architecture's central rule: **Dashboard grouping is presentation
|
||||||
|
* configuration and can never become module structure** (ADR 0021, ADR 0027).
|
||||||
|
*
|
||||||
|
* These run real discovery over real fixtures, including a genuine module dependency, and then
|
||||||
|
* arrange those modules into folders in several different ways. Everything discovery decides —
|
||||||
|
* which modules exist, what depends on what, and the order they must start in — has to come out
|
||||||
|
* byte-identical every time. A future change that let a group influence any of that fails here.
|
||||||
|
*/
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
/** A *real* technical dependency — one module calling another's functionality — expressed through
|
||||||
|
* the existing module dependency contract, which is the only kind of module relationship that
|
||||||
|
* exists. */
|
||||||
|
readonly dependsOn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${fixture.id}`,
|
||||||
|
type: "module",
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
...(fixture.dependsOn
|
||||||
|
? { dependencies: { modules: { required: { [fixture.dependsOn]: "^1.0.0" } } } }
|
||||||
|
: {}),
|
||||||
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
|
id: fixture.id,
|
||||||
|
manifestVersion: 4,
|
||||||
|
name: fixture.name,
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Livestream genuinely depends on Video — it calls Video's functionality — so discovery must order
|
||||||
|
* Video first. Calendar depends on nothing. This is the *only* kind of relationship modules have. */
|
||||||
|
async function discoverFixtures(): Promise<readonly DiscoveredModule[]> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-flat-modules-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
await createModule(modulesDirectory, { id: "video", name: "Video" });
|
||||||
|
await createModule(modulesDirectory, {
|
||||||
|
dependsOn: "video",
|
||||||
|
id: "livestream",
|
||||||
|
name: "Livestream",
|
||||||
|
});
|
||||||
|
await createModule(modulesDirectory, { id: "calendar", name: "Calendar" });
|
||||||
|
return discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Four arrangements of the same three modules, including the one the architecture warns about:
|
||||||
|
* Livestream and Video together under "Media", which must **not** create a Media module or a
|
||||||
|
* dependency between them.
|
||||||
|
*/
|
||||||
|
const arrangements: ReadonlyArray<readonly [string, DashboardConfiguration]> = [
|
||||||
|
["nothing configured", { groups: [], placements: [] }],
|
||||||
|
[
|
||||||
|
"Media over Planning",
|
||||||
|
{
|
||||||
|
groups: [
|
||||||
|
{ id: "media", label: "Media", sortOrder: 10 },
|
||||||
|
{ id: "planning", label: "Planning", sortOrder: 20 },
|
||||||
|
],
|
||||||
|
placements: [
|
||||||
|
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 20 },
|
||||||
|
{ groupId: "planning", moduleId: "calendar", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Livestream moved to Sunday Production, away from Video",
|
||||||
|
{
|
||||||
|
groups: [
|
||||||
|
{ id: "media", label: "Media", sortOrder: 10 },
|
||||||
|
{ id: "sunday-production", label: "Sunday Production", sortOrder: 5 },
|
||||||
|
],
|
||||||
|
placements: [
|
||||||
|
{ groupId: "sunday-production", moduleId: "livestream", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"the dependency inverted in presentation: Video grouped under Livestream's folder alone",
|
||||||
|
{
|
||||||
|
groups: [{ id: "streaming", label: "Streaming", sortOrder: 1 }],
|
||||||
|
placements: [{ groupId: "streaming", moduleId: "video", sortOrder: 10 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Dashboard grouping never becomes module structure", () => {
|
||||||
|
it("orders discovery by real dependencies, which is the only module relationship", async () => {
|
||||||
|
const discovered = await discoverFixtures();
|
||||||
|
|
||||||
|
// Video before Livestream because Livestream *depends* on it — not because of any folder.
|
||||||
|
expect(discovered.map((module) => module.manifest.id)).toEqual([
|
||||||
|
"calendar",
|
||||||
|
"video",
|
||||||
|
"livestream",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
discovered.find((module) => module.manifest.id === "livestream")?.manifest.dependencies,
|
||||||
|
).toMatchObject({ modules: { required: { video: "^1.0.0" } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves discovery, dependencies, and start order identical under every arrangement", async () => {
|
||||||
|
const discovered = await discoverFixtures();
|
||||||
|
const registry = createModuleUiPublicationRegistry(discovered);
|
||||||
|
// The exact facts the runtime consumes, captured before any Dashboard configuration exists.
|
||||||
|
const baseline = JSON.stringify(
|
||||||
|
discovered.map((module) => ({
|
||||||
|
capabilities: module.manifest.capabilities,
|
||||||
|
dependencies: module.manifest.dependencies,
|
||||||
|
id: module.manifest.id,
|
||||||
|
permissions: module.manifest.permissions,
|
||||||
|
version: module.manifest.version,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [label, configuration] of arrangements) {
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration,
|
||||||
|
registry.list().map((entry) => ({ id: entry.moduleId, name: entry.name })),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Discovery order — which is module *startup* order — is untouched by presentation.
|
||||||
|
expect(
|
||||||
|
discovered.map((module) => module.manifest.id),
|
||||||
|
label,
|
||||||
|
).toEqual(["calendar", "video", "livestream"]);
|
||||||
|
// So is every fact the runtime reads.
|
||||||
|
expect(
|
||||||
|
JSON.stringify(
|
||||||
|
discovered.map((module) => ({
|
||||||
|
capabilities: module.manifest.capabilities,
|
||||||
|
dependencies: module.manifest.dependencies,
|
||||||
|
id: module.manifest.id,
|
||||||
|
permissions: module.manifest.permissions,
|
||||||
|
version: module.manifest.version,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
label,
|
||||||
|
).toBe(baseline);
|
||||||
|
// And every module is still rendered exactly once, whatever the folders say.
|
||||||
|
expect(
|
||||||
|
[...view.groups.flatMap((group) => group.modules), ...view.ungrouped]
|
||||||
|
.map((entry) => entry.id)
|
||||||
|
.sort(),
|
||||||
|
label,
|
||||||
|
).toEqual(["calendar", "livestream", "video"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never invents a module for a group, however the group is named", async () => {
|
||||||
|
const discovered = await discoverFixtures();
|
||||||
|
const registry = createModuleUiPublicationRegistry(discovered);
|
||||||
|
const [, mediaArrangement] = arrangements[1] as readonly [string, DashboardConfiguration];
|
||||||
|
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
mediaArrangement,
|
||||||
|
registry.list().map((entry) => ({ id: entry.moduleId, name: entry.name })),
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Media" and "Planning" are group labels. There is no `media` module, no manifest for one, no
|
||||||
|
// artifact, no API root, no permission, and nothing depends on one.
|
||||||
|
const moduleIds = discovered.map((module) => module.manifest.id);
|
||||||
|
expect(moduleIds).toEqual(["calendar", "video", "livestream"]);
|
||||||
|
for (const groupId of view.groups.map((group) => group.id)) {
|
||||||
|
expect(moduleIds).not.toContain(groupId);
|
||||||
|
expect(registry.resolve(groupId, "1.0.0")).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the publication record free of any grouping, ordering, or navigation field", async () => {
|
||||||
|
const registry = createModuleUiPublicationRegistry(await discoverFixtures());
|
||||||
|
|
||||||
|
const published = registry.resolve("livestream", "1.0.0");
|
||||||
|
|
||||||
|
// The record a host mounts from carries identity, the validated artifact, declared permissions,
|
||||||
|
// and the module's own name — never a group, an order, a parent, or a folder.
|
||||||
|
expect(Object.keys(published ?? {}).sort()).toEqual([
|
||||||
|
"artifactPath",
|
||||||
|
"moduleId",
|
||||||
|
"name",
|
||||||
|
"permissions",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the display name from the module's own manifest, not from any folder", async () => {
|
||||||
|
const registry = createModuleUiPublicationRegistry(await discoverFixtures());
|
||||||
|
|
||||||
|
expect(registry.list().map((entry) => [entry.moduleId, entry.name])).toEqual([
|
||||||
|
["calendar", "Calendar"],
|
||||||
|
["video", "Video"],
|
||||||
|
["livestream", "Livestream"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
type DashboardConfiguration,
|
||||||
|
type DashboardNavigationModule,
|
||||||
|
resolveDashboardNavigation,
|
||||||
|
} from "../src/dashboard/index.js";
|
||||||
|
|
||||||
|
function module(id: string, name = id): DashboardNavigationModule {
|
||||||
|
return { id, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuration(
|
||||||
|
groups: ReadonlyArray<{ id: string; label: string; sortOrder: number }>,
|
||||||
|
placements: ReadonlyArray<{ moduleId: string; groupId: string | null; sortOrder: number }>,
|
||||||
|
): DashboardConfiguration {
|
||||||
|
return { groups, placements };
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = { id: "media", label: "Media", sortOrder: 10 };
|
||||||
|
const planning = { id: "planning", label: "Planning", sortOrder: 20 };
|
||||||
|
|
||||||
|
describe("resolving Dashboard navigation", () => {
|
||||||
|
it("groups modules exactly where the configuration places them", () => {
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration(
|
||||||
|
[media, planning],
|
||||||
|
[
|
||||||
|
{ groupId: "media", moduleId: "livestream", sortOrder: 20 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||||
|
{ groupId: "planning", moduleId: "calendar", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
[
|
||||||
|
module("calendar", "Calendar"),
|
||||||
|
module("livestream", "Livestream"),
|
||||||
|
module("video", "Video"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
view.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]),
|
||||||
|
).toEqual([
|
||||||
|
["Media", ["Video", "Livestream"]],
|
||||||
|
["Planning", ["Calendar"]],
|
||||||
|
]);
|
||||||
|
expect(view.ungrouped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a newly discovered module without any stored placement", () => {
|
||||||
|
// The rule that makes installing a module sufficient to see it: no initialization write, no
|
||||||
|
// manual database edit, no migration.
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration([media], [{ groupId: "media", moduleId: "video", sortOrder: 10 }]),
|
||||||
|
[module("video", "Video"), module("roster", "Roster")],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(view.groups[0]?.modules.map((entry) => entry.id)).toEqual(["video"]);
|
||||||
|
expect(view.ungrouped.map((entry) => entry.id)).toEqual(["roster"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a deliberately ungrouped module distinct from an unconfigured one, and orders both", () => {
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{ groupId: null, moduleId: "video", sortOrder: 20 },
|
||||||
|
{ groupId: null, moduleId: "calendar", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
[module("roster"), module("video"), module("calendar")],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Placed-and-ungrouped first, in the administrator's order; never-configured after, by id — so a
|
||||||
|
// newcomer never interrupts an ordering someone chose.
|
||||||
|
expect(view.ungrouped.map((entry) => entry.id)).toEqual(["calendar", "video", "roster"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits a stored placement whose module is not installed", () => {
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration(
|
||||||
|
[media],
|
||||||
|
[
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "uninstalled", sortOrder: 20 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
[module("video", "Video")],
|
||||||
|
);
|
||||||
|
|
||||||
|
// No dead entry renders, and the caller's configuration is untouched — the record survives for a
|
||||||
|
// reinstall.
|
||||||
|
expect(view.groups[0]?.modules.map((entry) => entry.id)).toEqual(["video"]);
|
||||||
|
expect(view.ungrouped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a reinstalled module to the position its retained placement describes", () => {
|
||||||
|
const stored = configuration(
|
||||||
|
[media],
|
||||||
|
[
|
||||||
|
{ groupId: "media", moduleId: "livestream", sortOrder: 5 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const whileUninstalled = resolveDashboardNavigation(stored, [module("video", "Video")]);
|
||||||
|
const afterReinstall = resolveDashboardNavigation(stored, [
|
||||||
|
module("video", "Video"),
|
||||||
|
module("livestream", "Livestream"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(whileUninstalled.groups[0]?.modules.map((entry) => entry.id)).toEqual(["video"]);
|
||||||
|
expect(afterReinstall.groups[0]?.modules.map((entry) => entry.id)).toEqual([
|
||||||
|
"livestream",
|
||||||
|
"video",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an empty group rather than hiding it", () => {
|
||||||
|
const view = resolveDashboardNavigation(configuration([media, planning], []), []);
|
||||||
|
|
||||||
|
expect(view.groups.map((group) => group.id)).toEqual(["media", "planning"]);
|
||||||
|
expect(view.groups.every((group) => group.modules.length === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a placement naming a missing group as ungrouped rather than losing the module", () => {
|
||||||
|
// The schema's `on delete set null` makes this unreachable in practice; the resolver still
|
||||||
|
// refuses to let a configuration inconsistency make a module invisible.
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration([], [{ groupId: "deleted-group", moduleId: "video", sortOrder: 10 }]),
|
||||||
|
[module("video", "Video")],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(view.groups).toEqual([]);
|
||||||
|
expect(view.ungrouped.map((entry) => entry.id)).toEqual(["video"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders deterministically when order values collide", () => {
|
||||||
|
const collided = configuration(
|
||||||
|
[
|
||||||
|
{ id: "zebra", label: "Zebra", sortOrder: 10 },
|
||||||
|
{ id: "alpha", label: "Alpha", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ groupId: "alpha", moduleId: "video", sortOrder: 0 },
|
||||||
|
{ groupId: "alpha", moduleId: "calendar", sortOrder: 0 },
|
||||||
|
{ groupId: "alpha", moduleId: "roster", sortOrder: 0 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const modules = [module("roster"), module("video"), module("calendar")];
|
||||||
|
|
||||||
|
const first = resolveDashboardNavigation(collided, modules);
|
||||||
|
// A different input order must not change the output: the tie-break is the identifier, never
|
||||||
|
// arrival order.
|
||||||
|
const second = resolveDashboardNavigation(collided, [...modules].reverse());
|
||||||
|
|
||||||
|
expect(first.groups.map((group) => group.id)).toEqual(["alpha", "zebra"]);
|
||||||
|
expect(first.groups[0]?.modules.map((entry) => entry.id)).toEqual([
|
||||||
|
"calendar",
|
||||||
|
"roster",
|
||||||
|
"video",
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(second)).toBe(JSON.stringify(first));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty and usable with no configuration and no modules", () => {
|
||||||
|
const view = resolveDashboardNavigation(configuration([], []), []);
|
||||||
|
|
||||||
|
expect(view.groups).toEqual([]);
|
||||||
|
expect(view.ungrouped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a frozen view no caller can mutate", () => {
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration([media], [{ groupId: "media", moduleId: "video", sortOrder: 10 }]),
|
||||||
|
[module("video", "Video")],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Object.isFrozen(view)).toBe(true);
|
||||||
|
expect(Object.isFrozen(view.groups)).toBe(true);
|
||||||
|
expect(Object.isFrozen(view.groups[0])).toBe(true);
|
||||||
|
expect(Object.isFrozen(view.ungrouped)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard grouping is presentation and nothing else", () => {
|
||||||
|
const modules = [
|
||||||
|
module("livestream", "Livestream"),
|
||||||
|
module("video", "Video"),
|
||||||
|
module("calendar", "Calendar"),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("creates no module, no parent, and no relationship from a shared group", () => {
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration(
|
||||||
|
[media],
|
||||||
|
[
|
||||||
|
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 20 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
modules,
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Media" is a label on a rendering bucket. It is not a module: nothing in the view carries a
|
||||||
|
// module id, version, artifact, or API root for it, and no entry names another module.
|
||||||
|
const rendered = JSON.parse(JSON.stringify(view)) as unknown;
|
||||||
|
expect(view.groups[0]?.modules.map((entry) => entry.id)).toEqual(["livestream", "video"]);
|
||||||
|
expect(modules.map((entry) => entry.id)).not.toContain("media");
|
||||||
|
for (const entry of view.groups.flatMap((group) => group.modules)) {
|
||||||
|
// A navigation entry describes exactly one module and never references a second one.
|
||||||
|
expect(Object.keys(entry).sort()).toEqual(["id", "name"]);
|
||||||
|
}
|
||||||
|
expect(JSON.stringify(rendered)).not.toContain("parent");
|
||||||
|
expect(JSON.stringify(rendered)).not.toContain("depends");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes only placement when a module moves to a different group", () => {
|
||||||
|
const before = resolveDashboardNavigation(
|
||||||
|
configuration(
|
||||||
|
[media],
|
||||||
|
[
|
||||||
|
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 20 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
modules,
|
||||||
|
);
|
||||||
|
const after = resolveDashboardNavigation(
|
||||||
|
configuration(
|
||||||
|
[media, { id: "sunday-production", label: "Sunday Production", sortOrder: 5 }],
|
||||||
|
[
|
||||||
|
{ groupId: "sunday-production", moduleId: "livestream", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 20 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
modules,
|
||||||
|
);
|
||||||
|
|
||||||
|
const findLivestream = (view: typeof before) =>
|
||||||
|
[...view.groups.flatMap((group) => group.modules), ...view.ungrouped].find(
|
||||||
|
(entry) => entry.id === "livestream",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The module object itself is identical across the move: same identity, same name, and — in the
|
||||||
|
// API layer above this — the same embedded mount descriptor.
|
||||||
|
expect(findLivestream(after)).toEqual(findLivestream(before));
|
||||||
|
expect(before.groups.find((group) => group.id === "media")?.modules.map((m) => m.id)).toEqual([
|
||||||
|
"livestream",
|
||||||
|
"video",
|
||||||
|
]);
|
||||||
|
expect(after.groups.find((group) => group.id === "media")?.modules.map((m) => m.id)).toEqual([
|
||||||
|
"video",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
after.groups.find((group) => group.id === "sunday-production")?.modules.map((m) => m.id),
|
||||||
|
).toEqual(["livestream"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the same module set whatever the arrangement", () => {
|
||||||
|
const arrangements: readonly DashboardConfiguration[] = [
|
||||||
|
configuration([], []),
|
||||||
|
configuration([media], [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }]),
|
||||||
|
configuration(
|
||||||
|
[media, planning],
|
||||||
|
[
|
||||||
|
{ groupId: "planning", moduleId: "livestream", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "calendar", sortOrder: 10 },
|
||||||
|
{ groupId: null, moduleId: "video", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const arrangement of arrangements) {
|
||||||
|
const view = resolveDashboardNavigation(arrangement, modules);
|
||||||
|
const rendered = [...view.groups.flatMap((group) => group.modules), ...view.ungrouped]
|
||||||
|
.map((entry) => entry.id)
|
||||||
|
.sort();
|
||||||
|
// Every module is rendered exactly once, in every arrangement. Grouping can never add,
|
||||||
|
// remove, duplicate, or hide a module.
|
||||||
|
expect(rendered).toEqual(["calendar", "livestream", "video"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createDashboardDrizzleView,
|
||||||
|
createDashboardStore,
|
||||||
|
type DashboardStore,
|
||||||
|
DashboardStoreError,
|
||||||
|
resolveDashboardNavigation,
|
||||||
|
} from "../src/dashboard/index.js";
|
||||||
|
import { createValidatedPostgresPool } from "../src/database/index.js";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "../src/migrations/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dashboard navigation store against real PostgreSQL.
|
||||||
|
*
|
||||||
|
* The unit suites prove the resolution rules; this proves the things only a database can: that the
|
||||||
|
* migration really creates these tables, that configuration survives a completely new connection,
|
||||||
|
* that `on delete set null` really ungroups a deleted group's modules, and that a placement can
|
||||||
|
* never point at a group that does not exist. The in-memory double used by the API route tests
|
||||||
|
* deliberately reproduces only two of those behaviours, so this file is where they are actually
|
||||||
|
* established rather than assumed.
|
||||||
|
*/
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const coreMigrationsDirectory = path.join(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"drizzle",
|
||||||
|
);
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function harnessError(message: string): IntegrationHarnessError {
|
||||||
|
return new IntegrationHarnessError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof value !== "string" || value.trim() === "") {
|
||||||
|
throw harnessError("The Dashboard integration database is not configured.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||||
|
throw new TypeError("Unsupported protocol.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw harnessError("The Dashboard integration database configuration is invalid.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteDatabaseName(databaseName: string): string {
|
||||||
|
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||||
|
throw harnessError("The generated integration database name is invalid.");
|
||||||
|
}
|
||||||
|
return `"${databaseName}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDatabaseName(label: string): string {
|
||||||
|
const safeLabel = label
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||||
|
.replaceAll(/^_+|_+$/gu, "")
|
||||||
|
.slice(0, 14);
|
||||||
|
databaseCounter += 1;
|
||||||
|
return `ekk_dash_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(databaseName: string): string {
|
||||||
|
const parsed = new URL(baseDatabaseUrl());
|
||||||
|
parsed.pathname = `/${databaseName}`;
|
||||||
|
parsed.searchParams.delete("application_name");
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString });
|
||||||
|
client.on("error", () => undefined);
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
// The public harness failure stays static when connection cleanup also fails.
|
||||||
|
}
|
||||||
|
throw harnessError("The Dashboard integration database connection failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<Row extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: unknown[] = [],
|
||||||
|
): Promise<readonly Row[]> {
|
||||||
|
try {
|
||||||
|
const result = await client.query<Row>(text, values);
|
||||||
|
return result.rows;
|
||||||
|
} catch {
|
||||||
|
throw harnessError("An integration database administration operation failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeEnd(client: Client): Promise<void> {
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
throw harnessError("An integration database connection could not be closed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropDatabase(databaseName: string): Promise<void> {
|
||||||
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||||
|
try {
|
||||||
|
await safeQuery(
|
||||||
|
admin,
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||||
|
[databaseName],
|
||||||
|
);
|
||||||
|
await safeQuery(
|
||||||
|
admin,
|
||||||
|
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||||
|
);
|
||||||
|
trackedDatabases.delete(databaseName);
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||||
|
const name = makeDatabaseName(label);
|
||||||
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
return { name, url: databaseUrl(name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoreMigrations(url: string): Promise<void> {
|
||||||
|
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dash-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
|
"core:0000_identity_core_tables",
|
||||||
|
"core:0001_authorization_core_tables",
|
||||||
|
"core:0002_authorization_integrity",
|
||||||
|
"core:0003_dashboard_navigation_tables",
|
||||||
|
]);
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModulesDirectory, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const databaseName of [...trackedDatabases]) {
|
||||||
|
await dropDatabase(databaseName).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("live Core Dashboard navigation store", () => {
|
||||||
|
let database: { name: string; url: string };
|
||||||
|
let admin: Client;
|
||||||
|
let store: DashboardStore;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
|
||||||
|
it("creates exactly the two Dashboard tables, with their constraints", async () => {
|
||||||
|
database = await createDisposableDatabase("fresh");
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
admin = await openAdministrativeClient(database.url);
|
||||||
|
|
||||||
|
const tables = await safeQuery<{ table_name: string }>(
|
||||||
|
admin,
|
||||||
|
`select table_name from information_schema.tables
|
||||||
|
where table_schema = 'public' and table_name like 'ekklesia_core_dashboard%'
|
||||||
|
order by table_name`,
|
||||||
|
);
|
||||||
|
const constraints = await safeQuery<{ constraint_name: string; delete_rule: string }>(
|
||||||
|
admin,
|
||||||
|
`select rc.constraint_name, rc.delete_rule
|
||||||
|
from information_schema.referential_constraints rc
|
||||||
|
join information_schema.table_constraints tc
|
||||||
|
on tc.constraint_name = rc.constraint_name
|
||||||
|
where tc.table_name = 'ekklesia_core_dashboard_placement'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tables.map((table) => table.table_name)).toEqual([
|
||||||
|
"ekklesia_core_dashboard_group",
|
||||||
|
"ekklesia_core_dashboard_placement",
|
||||||
|
]);
|
||||||
|
// The database, not the application, is what guarantees a deleted group leaves no dangling
|
||||||
|
// reference.
|
||||||
|
expect(constraints.map((constraint) => constraint.delete_rule)).toEqual(["SET NULL"]);
|
||||||
|
|
||||||
|
const handle = createValidatedPostgresPool(database.url);
|
||||||
|
close = handle.close;
|
||||||
|
store = createDashboardStore(createDashboardDrizzleView(handle.pool));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts empty, so an installation with no configuration is a normal state", async () => {
|
||||||
|
expect(await store.readConfiguration()).toEqual({ groups: [], placements: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists groups and placements, and reads them back in deterministic order", async () => {
|
||||||
|
await store.upsertGroup({ id: "planning", label: "Planning", sortOrder: 20 });
|
||||||
|
await store.upsertGroup({ id: "media", label: "Media", sortOrder: 10 });
|
||||||
|
await store.setPlacement({ groupId: "media", moduleId: "livestream", sortOrder: 20 });
|
||||||
|
await store.setPlacement({ groupId: "media", moduleId: "video", sortOrder: 10 });
|
||||||
|
await store.setPlacement({ groupId: "planning", moduleId: "calendar", sortOrder: 10 });
|
||||||
|
|
||||||
|
const configuration = await store.readConfiguration();
|
||||||
|
|
||||||
|
expect(configuration.groups.map((group) => group.id)).toEqual(["media", "planning"]);
|
||||||
|
expect(configuration.placements.map((placement) => placement.moduleId)).toEqual([
|
||||||
|
"calendar",
|
||||||
|
"video",
|
||||||
|
"livestream",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a completely new connection, so a church configures its Dashboard once", async () => {
|
||||||
|
// A different pool, as a restarted process would use — nothing is cached in the old one.
|
||||||
|
const reopened = createValidatedPostgresPool(database.url);
|
||||||
|
try {
|
||||||
|
const configuration = await createDashboardStore(
|
||||||
|
createDashboardDrizzleView(reopened.pool),
|
||||||
|
).readConfiguration();
|
||||||
|
const view = resolveDashboardNavigation(configuration, [
|
||||||
|
{ id: "livestream", name: "Livestream" },
|
||||||
|
{ id: "video", name: "Video" },
|
||||||
|
{ id: "calendar", name: "Calendar" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
view.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]),
|
||||||
|
).toEqual([
|
||||||
|
["Media", ["Video", "Livestream"]],
|
||||||
|
["Planning", ["Calendar"]],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await reopened.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames a group without disturbing a single placement", async () => {
|
||||||
|
const before = await store.readConfiguration();
|
||||||
|
|
||||||
|
await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 10 });
|
||||||
|
const after = await store.readConfiguration();
|
||||||
|
|
||||||
|
expect(after.groups.find((group) => group.id === "media")?.label).toBe("Media & Streaming");
|
||||||
|
// The stable id is what placements point at, so a rename can never re-organize the Dashboard.
|
||||||
|
expect(after.placements).toEqual(before.placements);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-orders a group and a module in place, without creating duplicates", async () => {
|
||||||
|
await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 30 });
|
||||||
|
await store.setPlacement({ groupId: "media", moduleId: "video", sortOrder: 99 });
|
||||||
|
|
||||||
|
const configuration = await store.readConfiguration();
|
||||||
|
|
||||||
|
expect(configuration.groups).toHaveLength(2);
|
||||||
|
expect(configuration.groups.map((group) => group.id)).toEqual(["planning", "media"]);
|
||||||
|
expect(configuration.placements.filter((entry) => entry.moduleId === "video")).toHaveLength(1);
|
||||||
|
expect(configuration.placements.find((entry) => entry.moduleId === "video")?.sortOrder).toBe(
|
||||||
|
99,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a module between groups by changing exactly one row", async () => {
|
||||||
|
await store.upsertGroup({ id: "sunday-production", label: "Sunday Production", sortOrder: 5 });
|
||||||
|
await store.setPlacement({
|
||||||
|
groupId: "sunday-production",
|
||||||
|
moduleId: "livestream",
|
||||||
|
sortOrder: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configuration = await store.readConfiguration();
|
||||||
|
|
||||||
|
expect(configuration.placements.find((entry) => entry.moduleId === "livestream")?.groupId).toBe(
|
||||||
|
"sunday-production",
|
||||||
|
);
|
||||||
|
// Video did not move.
|
||||||
|
expect(configuration.placements.find((entry) => entry.moduleId === "video")?.groupId).toBe(
|
||||||
|
"media",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ungroups a module deliberately, which is distinct from having no record", async () => {
|
||||||
|
await store.setPlacement({ groupId: null, moduleId: "calendar", sortOrder: 7 });
|
||||||
|
|
||||||
|
const configuration = await store.readConfiguration();
|
||||||
|
const calendar = configuration.placements.find((entry) => entry.moduleId === "calendar");
|
||||||
|
|
||||||
|
expect(calendar).toEqual({ groupId: null, moduleId: "calendar", sortOrder: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ungroups a deleted group's modules instead of leaving a dangling reference", async () => {
|
||||||
|
await store.deleteGroup("sunday-production");
|
||||||
|
|
||||||
|
const configuration = await store.readConfiguration();
|
||||||
|
const groupIds = new Set(configuration.groups.map((group) => group.id));
|
||||||
|
|
||||||
|
expect(groupIds.has("sunday-production")).toBe(false);
|
||||||
|
expect(
|
||||||
|
configuration.placements.find((entry) => entry.moduleId === "livestream")?.groupId,
|
||||||
|
).toBeNull();
|
||||||
|
// No placement anywhere names a group that no longer exists.
|
||||||
|
for (const placement of configuration.placements) {
|
||||||
|
if (placement.groupId !== null) {
|
||||||
|
expect(groupIds.has(placement.groupId)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a placement naming a group that was never created", async () => {
|
||||||
|
await expect(
|
||||||
|
store.setPlacement({ groupId: "not-a-group", moduleId: "video", sortOrder: 10 }),
|
||||||
|
).rejects.toMatchObject({ code: "DASHBOARD_GROUP_NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to delete a group that does not exist", async () => {
|
||||||
|
await expect(store.deleteGroup("not-a-group")).rejects.toMatchObject({
|
||||||
|
code: "DASHBOARD_GROUP_NOT_FOUND",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a placement for a module that is not installed, and renders nothing for it", async () => {
|
||||||
|
await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 30 });
|
||||||
|
await store.setPlacement({ groupId: "media", moduleId: "uninstalled", sortOrder: 1 });
|
||||||
|
|
||||||
|
const configuration = await store.readConfiguration();
|
||||||
|
const view = resolveDashboardNavigation(configuration, [{ id: "video", name: "Video" }]);
|
||||||
|
|
||||||
|
// Retained, so a reinstall restores the position...
|
||||||
|
expect(configuration.placements.map((entry) => entry.moduleId)).toContain("uninstalled");
|
||||||
|
// ...and never rendered while the module is absent.
|
||||||
|
expect(
|
||||||
|
[...view.groups.flatMap((group) => group.modules), ...view.ungrouped].map(
|
||||||
|
(entry) => entry.id,
|
||||||
|
),
|
||||||
|
).toEqual(["video"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed input before it reaches the database", async () => {
|
||||||
|
await expect(
|
||||||
|
store.upsertGroup({ id: "Not Kebab Case", label: "Nope", sortOrder: 0 }),
|
||||||
|
).rejects.toBeInstanceOf(DashboardStoreError);
|
||||||
|
await expect(
|
||||||
|
store.upsertGroup({ id: "media", label: " ", sortOrder: 0 }),
|
||||||
|
).rejects.toMatchObject({ code: "DASHBOARD_GROUP_LABEL_INVALID" });
|
||||||
|
await expect(
|
||||||
|
store.upsertGroup({ id: "media", label: "x".repeat(121), sortOrder: 0 }),
|
||||||
|
).rejects.toMatchObject({ code: "DASHBOARD_GROUP_LABEL_INVALID" });
|
||||||
|
await expect(
|
||||||
|
store.upsertGroup({ id: "media", label: "Media", sortOrder: -1 }),
|
||||||
|
).rejects.toMatchObject({ code: "DASHBOARD_SORT_ORDER_INVALID" });
|
||||||
|
await expect(
|
||||||
|
store.setPlacement({ groupId: null, moduleId: "Not Valid", sortOrder: 0 }),
|
||||||
|
).rejects.toMatchObject({ code: "DASHBOARD_MODULE_ID_INVALID" });
|
||||||
|
await expect(
|
||||||
|
store.setPlacement({ groupId: null, moduleId: "video", sortOrder: 1.5 }),
|
||||||
|
).rejects.toMatchObject({ code: "DASHBOARD_SORT_ORDER_INVALID" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims a stored label rather than persisting surrounding whitespace", async () => {
|
||||||
|
await store.upsertGroup({ id: "planning", label: " Planning ", sortOrder: 20 });
|
||||||
|
|
||||||
|
const configuration = await store.readConfiguration();
|
||||||
|
|
||||||
|
expect(configuration.groups.find((group) => group.id === "planning")?.label).toBe("Planning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaks no database detail in a sanitized failure", async () => {
|
||||||
|
await close();
|
||||||
|
|
||||||
|
const error = await store.readConfiguration().catch((caught: unknown) => caught);
|
||||||
|
const text = `${String(error)}\n${(error as Error).stack ?? ""}`;
|
||||||
|
|
||||||
|
expect(error).toMatchObject({ code: "DASHBOARD_READ_FAILED" });
|
||||||
|
expect(text).not.toContain(database.name);
|
||||||
|
expect(text).not.toContain("select");
|
||||||
|
expect(text).not.toContain("ekklesia_core_dashboard");
|
||||||
|
await safeEnd(admin);
|
||||||
|
await dropDatabase(database.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,7 +78,7 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
2 permissions registered
|
3 permissions registered
|
||||||
|
|
||||||
Data definitions
|
Data definitions
|
||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type PermissionBaseline, parseModuleManifest } from "@ekklesia/contract
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_PERMISSION_DECLARATIONS,
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
PermissionRegistryError,
|
PermissionRegistryError,
|
||||||
@@ -67,8 +68,9 @@ describe("composePlatformPermissionRegistry", () => {
|
|||||||
expect(registry.has(CORE_MODULES_UI_READ)).toBe(true);
|
expect(registry.has(CORE_MODULES_UI_READ)).toBe(true);
|
||||||
expect(registry.has("calendar.events.read")).toBe(true);
|
expect(registry.has("calendar.events.read")).toBe(true);
|
||||||
expect(registry.has("people.directory.read")).toBe(true);
|
expect(registry.has("people.directory.read")).toBe(true);
|
||||||
// Core's own two declarations plus one from each module.
|
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||||
expect(registry.declarations).toHaveLength(4);
|
// Core's own three declarations plus one from each module.
|
||||||
|
expect(registry.declarations).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||||
@@ -85,6 +87,7 @@ describe("composePlatformPermissionRegistry", () => {
|
|||||||
expect(registry.declarations.map((declaration) => declaration.id)).toEqual([
|
expect(registry.declarations.map((declaration) => declaration.id)).toEqual([
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
"calendar.events.read",
|
"calendar.events.read",
|
||||||
"calendar.events.write",
|
"calendar.events.write",
|
||||||
"people.directory.read",
|
"people.directory.read",
|
||||||
|
|||||||
@@ -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
|
- [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
|
- [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
|
- [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
|
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.
|
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;
|
**Phase 6D pass 1 adds the Dashboard navigation model, and fixes the rule it could have broken.**
|
||||||
the selector lists module ids. A production CSP and a deployment for the built shell, sign-in UI,
|
[ADR 0027](decisions/0027-dashboard-navigation-and-flat-modules.md) establishes that a Dashboard
|
||||||
general asset publication, and the standalone host remain future work.
|
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
|
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||||
are all unchanged.
|
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
|
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
|
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)).
|
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
|
- 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,
|
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
|
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). |
|
| `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). |
|
| `/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/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). |
|
| `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
|
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
|
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"}`.
|
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
|
## Module UI inventory
|
||||||
|
|
||||||
```text
|
```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
|
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
|
||||||
JavaScript, exactly as described above.
|
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
|
### Client-side permission checks are not a security boundary
|
||||||
|
|
||||||
A host may tell a module UI what permissions it believes the viewer holds
|
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*
|
own business logic, are both still later work; this only makes a declared permission *usable*
|
||||||
through Core's existing, generic surface.
|
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`,
|
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
|
`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.
|
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
|
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.
|
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
|
> **This is not the finished Dashboard.** The shell renders one level of grouping and mounts the
|
||||||
> navigation, grouping, icons, ordering, theme, or design system yet, and the selector shows your
|
> selected module. Your module appears under whatever group an administrator placed it in — by its
|
||||||
> module's id — the inventory deliberately carries no display metadata until that design exists.
|
> `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
|
### What a module UI will never own
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
|||||||
import { type ReactElement, useEffect, useState } from "react";
|
import { type ReactElement, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
|
type DashboardNavigationEntry,
|
||||||
type EkklesiaHostConfig,
|
type EkklesiaHostConfig,
|
||||||
type HostBootstrap,
|
type HostBootstrap,
|
||||||
HostProtocolError,
|
HostProtocolError,
|
||||||
@@ -24,7 +25,14 @@ export interface HostAppProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shellStyles = {
|
const shellStyles = {
|
||||||
aside: { borderRight: "1px solid #e7e5e4", minWidth: "12rem", padding: "1rem" },
|
aside: { borderRight: "1px solid #e7e5e4", minWidth: "13rem", padding: "1rem" },
|
||||||
|
groupLabel: {
|
||||||
|
color: "#78716c",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
margin: "0 0 0.25rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
borderBottom: "1px solid #e7e5e4",
|
borderBottom: "1px solid #e7e5e4",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -42,24 +50,66 @@ const shellStyles = {
|
|||||||
message: { color: "#57534e", padding: "1rem" },
|
message: { color: "#57534e", padding: "1rem" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function moduleKey(id: string, version: string): string {
|
/** Identity, not placement: a module keeps the same selection key wherever it is arranged, so moving
|
||||||
return `${id}@${version}`;
|
* it between groups never resets what is open or remounts what is showing. */
|
||||||
|
function moduleKey(entry: DashboardNavigationEntry): string {
|
||||||
|
return `${entry.descriptor.id}@${entry.descriptor.version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry[] {
|
||||||
|
return [
|
||||||
|
...bootstrap.navigation.groups.flatMap((group) => group.modules),
|
||||||
|
...bootstrap.navigation.ungrouped,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleButton(props: {
|
||||||
|
readonly entry: DashboardNavigationEntry;
|
||||||
|
readonly selected: boolean;
|
||||||
|
readonly onSelect: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-current={props.selected ? "page" : undefined}
|
||||||
|
data-testid={`select-${props.entry.descriptor.id}`}
|
||||||
|
onClick={props.onSelect}
|
||||||
|
style={{
|
||||||
|
background: props.selected ? "#e7e5e4" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
font: "inherit",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
textAlign: "left",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{props.entry.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The minimal Ekklesia browser shell: sign-in awareness, a module list, and one mount target.
|
* The Ekklesia browser shell: sign-in awareness, the Dashboard's configured navigation, and one
|
||||||
|
* mount target.
|
||||||
*
|
*
|
||||||
* **This is not the Dashboard.** There is no navigation model, no grouping or folders, no icons, no
|
* ## The shell renders the arrangement; it never computes one
|
||||||
* ordering, no home screen, no design system, and no routing — a selected-module state is the whole
|
|
||||||
* of it, and the selector renders module ids because the inventory deliberately carries no display
|
|
||||||
* metadata yet (ADR 0021 keeps that an administrator's presentation configuration, not a module
|
|
||||||
* declaration). What this proves is the architecture underneath: that a module discovered on the
|
|
||||||
* server, published as an artifact, and described by the inventory can be mounted here without the
|
|
||||||
* shell knowing anything about it in advance.
|
|
||||||
*
|
*
|
||||||
* Nothing about any specific module appears in this file, or anywhere in this package. There is no
|
* Groups, their order, their labels, which module sits where, and the order within each group all
|
||||||
* import of a module, no module id, no registry, no allowlist, and no fallback bundle. Every module
|
* arrive already resolved from the server (ADR 0027). This component reads no manifest, performs no
|
||||||
* the shell can mount arrives at run time from the inventory the server returned.
|
* sorting, and knows nothing about how a placement is decided — so changing the Dashboard's ordering
|
||||||
|
* rules, or eventually where configuration is scoped, needs no change here at all. The module id is
|
||||||
|
* never displayed: the module's own manifest name is.
|
||||||
|
*
|
||||||
|
* ## Still not the finished Dashboard
|
||||||
|
*
|
||||||
|
* There is one level of grouping and no nesting, no icons, no drag and drop, no editor, no home
|
||||||
|
* screen or widgets, no theme, and no client routing — selection is state. What this proves is the
|
||||||
|
* layer beneath a Dashboard: persistent, platform-owned presentation configuration driving a real
|
||||||
|
* host that mounts flat, independent modules.
|
||||||
|
*
|
||||||
|
* Nothing about any specific module appears in this file, or anywhere in this package.
|
||||||
*/
|
*/
|
||||||
export function HostApp(props: HostAppProps): ReactElement {
|
export function HostApp(props: HostAppProps): ReactElement {
|
||||||
const [state, setState] = useState<BootstrapState>({ status: "loading" });
|
const [state, setState] = useState<BootstrapState>({ status: "loading" });
|
||||||
@@ -79,11 +129,8 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
? { status: "signed-out" }
|
? { status: "signed-out" }
|
||||||
: { bootstrap, status: "ready" },
|
: { bootstrap, status: "ready" },
|
||||||
);
|
);
|
||||||
setSelectedKey(
|
const first = allEntries(bootstrap)[0];
|
||||||
bootstrap.modules[0]
|
setSelectedKey(first ? moduleKey(first) : undefined);
|
||||||
? moduleKey(bootstrap.modules[0].id, bootstrap.modules[0].version)
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (abort.signal.aborted) {
|
if (abort.signal.aborted) {
|
||||||
@@ -118,54 +165,68 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { bootstrap } = state;
|
const { bootstrap } = state;
|
||||||
const selected = bootstrap.modules.find(
|
const entries = allEntries(bootstrap);
|
||||||
(module) => moduleKey(module.id, module.version) === selectedKey,
|
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
|
||||||
);
|
const { groups, ungrouped } = bootstrap.navigation;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={shellStyles.layout}>
|
<div style={shellStyles.layout}>
|
||||||
<header style={shellStyles.header}>Ekklesia</header>
|
<header style={shellStyles.header}>Ekklesia</header>
|
||||||
<aside style={shellStyles.aside}>
|
<aside style={shellStyles.aside}>
|
||||||
{bootstrap.modules.length === 0 ? (
|
{entries.length === 0 && groups.length === 0 ? (
|
||||||
<p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}>
|
<p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}>
|
||||||
No installed module provides a user interface.
|
No installed module provides a user interface.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<nav style={{ display: "grid", gap: "0.25rem" }}>
|
<nav aria-label="Modules" style={{ display: "grid", gap: "1rem" }}>
|
||||||
{bootstrap.modules.map((module) => {
|
{groups.map((group) => (
|
||||||
const key = moduleKey(module.id, module.version);
|
// An empty group still renders: an administrator created it, and hiding it would look
|
||||||
return (
|
// like it had been deleted.
|
||||||
<button
|
<section data-testid={`group-${group.id}`} key={group.id}>
|
||||||
data-testid={`select-${module.id}`}
|
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
||||||
key={key}
|
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||||
onClick={() => setSelectedKey(key)}
|
{group.modules.map((entry) => (
|
||||||
style={{
|
<ModuleButton
|
||||||
background: key === selectedKey ? "#e7e5e4" : "transparent",
|
entry={entry}
|
||||||
border: "none",
|
key={moduleKey(entry)}
|
||||||
cursor: "pointer",
|
onSelect={() => setSelectedKey(moduleKey(entry))}
|
||||||
font: "inherit",
|
selected={moduleKey(entry) === selectedKey}
|
||||||
padding: "0.375rem 0.5rem",
|
/>
|
||||||
textAlign: "left",
|
))}
|
||||||
}}
|
</div>
|
||||||
type="button"
|
</section>
|
||||||
>
|
))}
|
||||||
{module.id}
|
{ungrouped.length > 0 ? (
|
||||||
</button>
|
// Where a module lives until someone decides otherwise — including one installed five
|
||||||
);
|
// minutes ago. The heading is omitted when nothing is grouped, so an unconfigured
|
||||||
})}
|
// installation reads as a plain module list rather than a mysterious "Other" bucket.
|
||||||
|
<section data-testid="group-ungrouped">
|
||||||
|
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
|
||||||
|
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||||
|
{ungrouped.map((entry) => (
|
||||||
|
<ModuleButton
|
||||||
|
entry={entry}
|
||||||
|
key={moduleKey(entry)}
|
||||||
|
onSelect={() => setSelectedKey(moduleKey(entry))}
|
||||||
|
selected={moduleKey(entry) === selectedKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : undefined}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
<main style={shellStyles.main}>
|
<main style={shellStyles.main}>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
// Keyed by module identity, so switching selection discards this frame — and its container
|
// Keyed by module identity — not by group — so switching selection discards this frame and
|
||||||
// element — entirely rather than reusing one across two modules. See `ModuleUiFrame` for
|
// its container entirely, while re-grouping a module never remounts it. See
|
||||||
// why that is what makes a slow, stale load harmless.
|
// `ModuleUiFrame` for why that is what makes a slow, stale load harmless.
|
||||||
<ModuleUiFrame
|
<ModuleUiFrame
|
||||||
config={config}
|
config={config}
|
||||||
descriptor={selected}
|
descriptor={selected.descriptor}
|
||||||
{...(importModule ? { importModule } : {})}
|
{...(importModule ? { importModule } : {})}
|
||||||
key={moduleKey(selected.id, selected.version)}
|
key={moduleKey(selected)}
|
||||||
principal={bootstrap.principal}
|
principal={bootstrap.principal}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ export interface EkklesiaHostConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API
|
/** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API
|
||||||
* URL — the server produces both and hands them over in the inventory — so these two are the only
|
* URL — the server produces both and hands them over in the navigation view — so these two are the
|
||||||
* URLs it knows, and they are the two that describe the platform itself rather than any module. */
|
* only URLs it knows, and they describe the platform itself rather than any module.
|
||||||
|
*
|
||||||
|
* `/api/v1/module-ui` is deliberately absent: that host-neutral inventory (ADR 0026) remains the
|
||||||
|
* endpoint a standalone host would consume, but Ekklesia's own shell needs the arrangement too, so
|
||||||
|
* it reads the navigation view instead. Keeping an unused client for it here would be speculative
|
||||||
|
* code for a consumer that does not exist. */
|
||||||
const PRINCIPAL_PATH = "/api/v1/principal";
|
const PRINCIPAL_PATH = "/api/v1/principal";
|
||||||
const MODULE_UI_INVENTORY_PATH = "/api/v1/module-ui";
|
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
||||||
|
|
||||||
export type HostProtocolErrorCode =
|
export type HostProtocolErrorCode =
|
||||||
/** The caller is not signed in, or lacks `core.modules.ui.read`. */
|
/** The caller is not signed in, or lacks `core.modules.ui.read`. */
|
||||||
@@ -112,37 +117,99 @@ function rootRelativePath(value: unknown, key: string): string | undefined {
|
|||||||
* yielding a half-built descriptor, because a descriptor with a missing URL would surface much later
|
* yielding a half-built descriptor, because a descriptor with a missing URL would surface much later
|
||||||
* as an inexplicable mount failure.
|
* as an inexplicable mount failure.
|
||||||
*/
|
*/
|
||||||
export function parseHostModuleUiInventory(payload: unknown): readonly HostModuleUiDescriptor[] {
|
function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
||||||
const modules = isRecord(payload) ? payload.modules : undefined;
|
const id = nestedString(entry, "id");
|
||||||
if (!Array.isArray(modules)) {
|
const version = nestedString(entry, "version");
|
||||||
|
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
|
||||||
|
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
|
||||||
|
const permissions = isRecord(entry) ? entry.permissions : undefined;
|
||||||
|
if (
|
||||||
|
id === undefined ||
|
||||||
|
version === undefined ||
|
||||||
|
uiUrl === undefined ||
|
||||||
|
apiBaseUrl === undefined ||
|
||||||
|
!Array.isArray(permissions) ||
|
||||||
|
permissions.some((permission) => typeof permission !== "string")
|
||||||
|
) {
|
||||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
}
|
}
|
||||||
return Object.freeze(
|
return Object.freeze({
|
||||||
modules.map((entry): HostModuleUiDescriptor => {
|
apiBaseUrl,
|
||||||
const id = nestedString(entry, "id");
|
id,
|
||||||
const version = nestedString(entry, "version");
|
permissions: Object.freeze([...(permissions as string[])]),
|
||||||
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
|
uiUrl,
|
||||||
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
|
version,
|
||||||
const permissions = isRecord(entry) ? entry.permissions : undefined;
|
});
|
||||||
if (
|
}
|
||||||
id === undefined ||
|
|
||||||
version === undefined ||
|
/**
|
||||||
uiUrl === undefined ||
|
* One module as the Dashboard presents it.
|
||||||
apiBaseUrl === undefined ||
|
*
|
||||||
!Array.isArray(permissions) ||
|
* The mount descriptor is nested exactly as the server sends it, not flattened into this object.
|
||||||
permissions.some((permission) => typeof permission !== "string")
|
* That keeps the two concerns visibly separate in the browser as well as on the wire: `descriptor`
|
||||||
) {
|
* is the host-neutral "what and how to mount", and `name` is Ekklesia's presentation of it. Nothing
|
||||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
* about a group is stored on the module — a module does not know, and must not know, where it has
|
||||||
}
|
* been placed.
|
||||||
return Object.freeze({
|
*/
|
||||||
apiBaseUrl,
|
export interface DashboardNavigationEntry {
|
||||||
id,
|
readonly name: string;
|
||||||
permissions: Object.freeze([...(permissions as string[])]),
|
readonly descriptor: HostModuleUiDescriptor;
|
||||||
uiUrl,
|
}
|
||||||
version,
|
|
||||||
});
|
export interface DashboardNavigationGroup {
|
||||||
}),
|
/** Opaque and stable; the shell uses it as a React key and never displays it. */
|
||||||
);
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly modules: readonly DashboardNavigationEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole navigation the shell renders, already resolved and ordered by the server.
|
||||||
|
*
|
||||||
|
* The browser performs no grouping, no sorting, and no joining of its own: it does not read
|
||||||
|
* manifests, does not know which module belongs where, and could not compute this view if it wanted
|
||||||
|
* to. Ordering arrives as array order, so a change to the server's ordering rules needs no shell
|
||||||
|
* change at all.
|
||||||
|
*/
|
||||||
|
export interface DashboardNavigation {
|
||||||
|
readonly groups: readonly DashboardNavigationGroup[];
|
||||||
|
readonly ungrouped: readonly DashboardNavigationEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
|
||||||
|
const name = nestedString(entry, "name");
|
||||||
|
if (name === undefined || !isRecord(entry)) {
|
||||||
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ descriptor: parseDescriptor(entry.module), name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strict, total validation, exactly like the inventory parser: a malformed group or entry fails
|
||||||
|
* the whole document rather than rendering a half-built navigation. */
|
||||||
|
export function parseDashboardNavigation(payload: unknown): DashboardNavigation {
|
||||||
|
const groups = isRecord(payload) ? payload.groups : undefined;
|
||||||
|
const ungrouped = isRecord(payload) ? payload.ungrouped : undefined;
|
||||||
|
if (!Array.isArray(groups) || !Array.isArray(ungrouped)) {
|
||||||
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
groups: Object.freeze(
|
||||||
|
groups.map((group): DashboardNavigationGroup => {
|
||||||
|
const id = nestedString(group, "id");
|
||||||
|
const label = nestedString(group, "label");
|
||||||
|
const modules = isRecord(group) ? group.modules : undefined;
|
||||||
|
if (id === undefined || label === undefined || !Array.isArray(modules)) {
|
||||||
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
modules: Object.freeze(modules.map((entry) => parseNavigationEntry(entry))),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
ungrouped: Object.freeze(ungrouped.map((entry) => parseNavigationEntry(entry))),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/api/v1/principal`'s two documented shapes, narrowed to the browser-owned `ModuleUiPrincipal`
|
/** `/api/v1/principal`'s two documented shapes, narrowed to the browser-owned `ModuleUiPrincipal`
|
||||||
@@ -214,21 +281,27 @@ export async function loadHostPrincipal(
|
|||||||
return parseHostPrincipal(await requestPlatformJson(config, PRINCIPAL_PATH, signal));
|
return parseHostPrincipal(await requestPlatformJson(config, PRINCIPAL_PATH, signal));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which module UIs this viewer may mount, where to fetch each, and what they may do inside it. */
|
/** The resolved Dashboard navigation: the same mountable modules, arranged as this installation has
|
||||||
export async function loadModuleUiInventory(
|
* configured them (ADR 0027). */
|
||||||
|
export async function loadDashboardNavigation(
|
||||||
config: EkklesiaHostConfig,
|
config: EkklesiaHostConfig,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<readonly HostModuleUiDescriptor[]> {
|
): Promise<DashboardNavigation> {
|
||||||
return parseHostModuleUiInventory(
|
return parseDashboardNavigation(
|
||||||
await requestPlatformJson(config, MODULE_UI_INVENTORY_PATH, signal),
|
await requestPlatformJson(config, DASHBOARD_NAVIGATION_PATH, signal),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostBootstrap {
|
export interface HostBootstrap {
|
||||||
readonly principal: ModuleUiPrincipal;
|
readonly principal: ModuleUiPrincipal;
|
||||||
readonly modules: readonly HostModuleUiDescriptor[];
|
readonly navigation: DashboardNavigation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_NAVIGATION: DashboardNavigation = Object.freeze({
|
||||||
|
groups: Object.freeze([]),
|
||||||
|
ungrouped: Object.freeze([]),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shell's whole startup sequence, in one explicit function: resolve who is viewing, then ask
|
* The shell's whole startup sequence, in one explicit function: resolve who is viewing, then ask
|
||||||
* what they may mount. Sequential rather than parallel on purpose — an anonymous viewer is told to
|
* what they may mount. Sequential rather than parallel on purpose — an anonymous viewer is told to
|
||||||
@@ -241,7 +314,7 @@ export async function bootstrapHost(
|
|||||||
): Promise<HostBootstrap> {
|
): Promise<HostBootstrap> {
|
||||||
const principal = await loadHostPrincipal(config, signal);
|
const principal = await loadHostPrincipal(config, signal);
|
||||||
if (principal.kind === "anonymous") {
|
if (principal.kind === "anonymous") {
|
||||||
return { modules: Object.freeze([]), principal };
|
return { navigation: EMPTY_NAVIGATION, principal };
|
||||||
}
|
}
|
||||||
return { modules: await loadModuleUiInventory(config, signal), principal };
|
return { navigation: await loadDashboardNavigation(config, signal), principal };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,17 @@
|
|||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
|
type DashboardNavigation,
|
||||||
|
type DashboardNavigationEntry,
|
||||||
|
type DashboardNavigationGroup,
|
||||||
type EkklesiaHostConfig,
|
type EkklesiaHostConfig,
|
||||||
type HostBootstrap,
|
type HostBootstrap,
|
||||||
type HostModuleUiDescriptor,
|
type HostModuleUiDescriptor,
|
||||||
HostProtocolError,
|
HostProtocolError,
|
||||||
type HostProtocolErrorCode,
|
type HostProtocolErrorCode,
|
||||||
|
loadDashboardNavigation,
|
||||||
loadHostPrincipal,
|
loadHostPrincipal,
|
||||||
loadModuleUiInventory,
|
parseDashboardNavigation,
|
||||||
parseHostModuleUiInventory,
|
|
||||||
parseHostPrincipal,
|
parseHostPrincipal,
|
||||||
resolveApiUrl,
|
resolveApiUrl,
|
||||||
} from "./host-protocol.js";
|
} from "./host-protocol.js";
|
||||||
|
|||||||
@@ -34,25 +34,36 @@ afterEach(async () => {
|
|||||||
consoleError.mockRestore();
|
consoleError.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
function descriptor(id: string, version = "0.1.0", permissions: readonly string[] = []) {
|
/** One navigation entry, in the wire shape the Dashboard navigation endpoint produces: the module's
|
||||||
|
* display name beside the untouched mount descriptor. */
|
||||||
|
function entry(id: string, name: string, version = "0.1.0", permissions: readonly string[] = []) {
|
||||||
return {
|
return {
|
||||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
module: {
|
||||||
id,
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||||
permissions,
|
id,
|
||||||
ui: { url: `/api/v1/module-ui/${id}/${version}` },
|
permissions,
|
||||||
version,
|
ui: { url: `/api/v1/module-ui/${id}/${version}` },
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function group(id: string, label: string, modules: ReturnType<typeof entry>[], order = 10) {
|
||||||
|
return { id, label, modules, order };
|
||||||
|
}
|
||||||
|
|
||||||
interface PlatformStub {
|
interface PlatformStub {
|
||||||
readonly modules?: ReadonlyArray<ReturnType<typeof descriptor>>;
|
readonly groups?: ReadonlyArray<ReturnType<typeof group>>;
|
||||||
|
readonly ungrouped?: ReadonlyArray<ReturnType<typeof entry>>;
|
||||||
readonly principal?: unknown;
|
readonly principal?: unknown;
|
||||||
readonly inventoryStatus?: number;
|
readonly navigationStatus?: number;
|
||||||
readonly inventoryThrows?: boolean;
|
readonly navigationThrows?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function platformFetch(stub: PlatformStub = {}): typeof fetch {
|
function platformFetch(stub: PlatformStub = {}): typeof fetch {
|
||||||
const modules = stub.modules ?? [descriptor("example")];
|
const groups = stub.groups ?? [];
|
||||||
|
const ungrouped = stub.ungrouped ?? (stub.groups ? [] : [entry("example", "Example Module")]);
|
||||||
return vi.fn(async (input: RequestInfo | URL) => {
|
return vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.endsWith("/api/v1/principal")) {
|
if (url.endsWith("/api/v1/principal")) {
|
||||||
@@ -61,13 +72,13 @@ function platformFetch(stub: PlatformStub = {}): typeof fetch {
|
|||||||
{ headers: { "content-type": "application/json" }, status: 200 },
|
{ headers: { "content-type": "application/json" }, status: 200 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (url.endsWith("/api/v1/module-ui")) {
|
if (url.endsWith("/api/v1/dashboard/navigation")) {
|
||||||
if (stub.inventoryThrows) {
|
if (stub.navigationThrows) {
|
||||||
throw new TypeError("network down");
|
throw new TypeError("network down");
|
||||||
}
|
}
|
||||||
return new Response(JSON.stringify({ modules }), {
|
return new Response(JSON.stringify({ groups, ungrouped }), {
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
status: stub.inventoryStatus ?? 200,
|
status: stub.navigationStatus ?? 200,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new Response("{}", { status: 404 });
|
return new Response("{}", { status: 404 });
|
||||||
@@ -123,10 +134,12 @@ function query(testId: string): Element | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Ekklesia shell — bootstrap", () => {
|
describe("Ekklesia shell — bootstrap", () => {
|
||||||
it("renders the module list the inventory returned", async () => {
|
it("renders the module list the navigation returned", async () => {
|
||||||
await render({
|
await render({
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
|
fetch: platformFetch({
|
||||||
|
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(query("select-example")).not.toBeNull();
|
expect(query("select-example")).not.toBeNull();
|
||||||
@@ -140,26 +153,174 @@ describe("Ekklesia shell — bootstrap", () => {
|
|||||||
expect(query("module-container")).toBeNull();
|
expect(query("module-container")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a refused inventory as signed out rather than as a crash", async () => {
|
it("treats refused navigation as signed out rather than as a crash", async () => {
|
||||||
await render({ apiBaseUrl, fetch: platformFetch({ inventoryStatus: 403 }) });
|
await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 403 }) });
|
||||||
|
|
||||||
expect(query("host-signed-out")).not.toBeNull();
|
expect(query("host-signed-out")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a host-owned error when the platform cannot be reached", async () => {
|
it("renders a host-owned error when the platform cannot be reached", async () => {
|
||||||
await render({ apiBaseUrl, fetch: platformFetch({ inventoryThrows: true }) });
|
await render({ apiBaseUrl, fetch: platformFetch({ navigationThrows: true }) });
|
||||||
|
|
||||||
expect(query("host-error")).not.toBeNull();
|
expect(query("host-error")).not.toBeNull();
|
||||||
expect(text()).not.toContain("network down");
|
expect(text()).not.toContain("network down");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says so plainly when no installed module provides a UI", async () => {
|
it("says so plainly when no installed module provides a UI", async () => {
|
||||||
await render({ apiBaseUrl, fetch: platformFetch({ modules: [] }) });
|
await render({ apiBaseUrl, fetch: platformFetch({ groups: [], ungrouped: [] }) });
|
||||||
|
|
||||||
expect(query("host-empty")).not.toBeNull();
|
expect(query("host-empty")).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Ekklesia shell — Dashboard navigation", () => {
|
||||||
|
const mediaGroup = () =>
|
||||||
|
group("media", "Media", [entry("video", "Video"), entry("livestream", "Livestream", "2.0.0")]);
|
||||||
|
const planningGroup = () => group("planning", "Planning", [entry("calendar", "Calendar")], 20);
|
||||||
|
|
||||||
|
it("renders groups with their labels and the modules' display names", async () => {
|
||||||
|
await render({
|
||||||
|
apiBaseUrl,
|
||||||
|
fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query("group-media")?.textContent).toBe("MediaVideoLivestream");
|
||||||
|
expect(query("group-planning")?.textContent).toBe("PlanningCalendar");
|
||||||
|
// The module id is never shown; the manifest name is.
|
||||||
|
expect(text()).not.toContain("livestream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders groups and modules in exactly the server's order", async () => {
|
||||||
|
// The shell sorts nothing: whatever order arrives is the order shown, so the server stays the
|
||||||
|
// single authority on arrangement.
|
||||||
|
await render({
|
||||||
|
apiBaseUrl,
|
||||||
|
fetch: platformFetch({
|
||||||
|
groups: [
|
||||||
|
group("planning", "Planning", [entry("calendar", "Calendar")]),
|
||||||
|
group("media", "Media", [
|
||||||
|
entry("livestream", "Livestream", "2.0.0"),
|
||||||
|
entry("video", "Video"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
|
||||||
|
expect(labels).toEqual(["Planning", "Media"]);
|
||||||
|
const buttons = [...container.querySelectorAll("button")].map((button) => button.textContent);
|
||||||
|
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ungrouped modules alongside groups, under a heading", async () => {
|
||||||
|
await render({
|
||||||
|
apiBaseUrl,
|
||||||
|
fetch: platformFetch({
|
||||||
|
groups: [mediaGroup()],
|
||||||
|
ungrouped: [entry("roster", "Roster")],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query("group-ungrouped")?.textContent).toBe("OtherRoster");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the heading when nothing is grouped, so an unconfigured install reads as a list", async () => {
|
||||||
|
await render({
|
||||||
|
apiBaseUrl,
|
||||||
|
fetch: platformFetch({ ungrouped: [entry("roster", "Roster"), entry("video", "Video")] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query("group-ungrouped")?.textContent).toBe("RosterVideo");
|
||||||
|
expect(container.querySelectorAll("h2")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an empty group visible", async () => {
|
||||||
|
await render({
|
||||||
|
apiBaseUrl,
|
||||||
|
fetch: platformFetch({
|
||||||
|
groups: [group("media", "Media", [])],
|
||||||
|
ungrouped: [entry("video", "Video")],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The administrator created it; hiding it would look like it had been deleted.
|
||||||
|
expect(query("group-media")?.textContent).toBe("Media");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the first module in the arranged order and marks it active", async () => {
|
||||||
|
await render(
|
||||||
|
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
|
||||||
|
async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(query("select-video")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
|
||||||
|
const requested: string[] = [];
|
||||||
|
await render(
|
||||||
|
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
|
||||||
|
async (url) => {
|
||||||
|
requested.push(url);
|
||||||
|
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
(query("select-calendar") as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The nested descriptor is used unchanged: grouping altered nothing about the artifact URL.
|
||||||
|
expect(requested).toEqual([
|
||||||
|
`${apiBaseUrl}/api/v1/module-ui/video/0.1.0`,
|
||||||
|
`${apiBaseUrl}/api/v1/module-ui/calendar/0.1.0`,
|
||||||
|
]);
|
||||||
|
expect(query("module-container")?.textContent).toBe("calendar mounted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the identical artifact whichever group a module is presented in", async () => {
|
||||||
|
const requestedWhenGrouped: string[] = [];
|
||||||
|
await render(
|
||||||
|
{
|
||||||
|
apiBaseUrl,
|
||||||
|
fetch: platformFetch({
|
||||||
|
groups: [group("media", "Media", [entry("livestream", "Livestream")])],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (url) => {
|
||||||
|
requestedWhenGrouped.push(url);
|
||||||
|
return artifact("livestream", "0.1.0");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same module, presented somewhere else entirely.
|
||||||
|
root = createRoot(container);
|
||||||
|
const requestedWhenMoved: string[] = [];
|
||||||
|
await render(
|
||||||
|
{
|
||||||
|
apiBaseUrl,
|
||||||
|
fetch: platformFetch({
|
||||||
|
groups: [
|
||||||
|
group("sunday-production", "Sunday Production", [entry("livestream", "Livestream")], 5),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (url) => {
|
||||||
|
requestedWhenMoved.push(url);
|
||||||
|
return artifact("livestream", "0.1.0");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Presentation changed; the module did not.
|
||||||
|
expect(requestedWhenMoved).toEqual(requestedWhenGrouped);
|
||||||
|
expect(query("group-sunday-production")?.textContent).toBe("Sunday ProductionLivestream");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Ekklesia shell — mount lifecycle", () => {
|
describe("Ekklesia shell — mount lifecycle", () => {
|
||||||
it("mounts the first module into the host's own container", async () => {
|
it("mounts the first module into the host's own container", async () => {
|
||||||
const mountedInto: Element[] = [];
|
const mountedInto: Element[] = [];
|
||||||
@@ -175,7 +336,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
expect(mountedInto[0]).not.toBe(document.body);
|
expect(mountedInto[0]).not.toBe(document.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("imports exactly the URL the inventory published, resolved against the API origin", async () => {
|
it("imports exactly the URL the navigation published, resolved against the API origin", async () => {
|
||||||
const requested: string[] = [];
|
const requested: string[] = [];
|
||||||
await render({ apiBaseUrl, fetch: platformFetch() }, async (url) => {
|
await render({ apiBaseUrl, fetch: platformFetch() }, async (url) => {
|
||||||
requested.push(url);
|
requested.push(url);
|
||||||
@@ -229,7 +390,9 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
await render(
|
await render(
|
||||||
{
|
{
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
|
fetch: platformFetch({
|
||||||
|
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
importModule,
|
importModule,
|
||||||
);
|
);
|
||||||
@@ -275,7 +438,9 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
await render(
|
await render(
|
||||||
{
|
{
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
|
fetch: platformFetch({
|
||||||
|
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
importModule,
|
importModule,
|
||||||
);
|
);
|
||||||
@@ -352,7 +517,9 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
|||||||
await render(
|
await render(
|
||||||
{
|
{
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
|
fetch: platformFetch({
|
||||||
|
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
importModule,
|
importModule,
|
||||||
);
|
);
|
||||||
@@ -389,7 +556,9 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
|||||||
await render(
|
await render(
|
||||||
{
|
{
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
|
fetch: platformFetch({
|
||||||
|
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
importModule,
|
importModule,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
type EkklesiaHostConfig,
|
|
||||||
type HostModuleUiDescriptor,
|
type HostModuleUiDescriptor,
|
||||||
|
loadDashboardNavigation,
|
||||||
loadHostPrincipal,
|
loadHostPrincipal,
|
||||||
loadModuleUiInventory,
|
parseDashboardNavigation,
|
||||||
parseHostModuleUiInventory,
|
|
||||||
parseHostPrincipal,
|
parseHostPrincipal,
|
||||||
} from "../src/host-protocol.js";
|
} from "../src/host-protocol.js";
|
||||||
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
|
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
|
||||||
@@ -47,6 +46,37 @@ const inventoryPayload = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const calendarDescriptor: HostModuleUiDescriptor = {
|
||||||
|
apiBaseUrl: "/api/v1/modules/calendar",
|
||||||
|
id: "calendar",
|
||||||
|
permissions: [],
|
||||||
|
uiUrl: "/api/v1/module-ui/calendar/2.0.0",
|
||||||
|
version: "2.0.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigationPayload = {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: "media",
|
||||||
|
label: "Media",
|
||||||
|
modules: [{ module: inventoryPayload.modules[0], name: "Example Module" }],
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ungrouped: [
|
||||||
|
{
|
||||||
|
module: {
|
||||||
|
api: { baseUrl: "/api/v1/modules/calendar" },
|
||||||
|
id: "calendar",
|
||||||
|
permissions: [],
|
||||||
|
ui: { url: "/api/v1/module-ui/calendar/2.0.0" },
|
||||||
|
version: "2.0.0",
|
||||||
|
},
|
||||||
|
name: "Calendar",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
id: "example",
|
id: "example",
|
||||||
@@ -55,69 +85,57 @@ const exampleDescriptor: HostModuleUiDescriptor = {
|
|||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("host protocol — inventory", () => {
|
function navigationWithModule(module: unknown): unknown {
|
||||||
it("parses the server's descriptor into exactly what a host context needs", () => {
|
return { groups: [], ungrouped: [{ module, name: "Example Module" }] };
|
||||||
expect(parseHostModuleUiInventory(inventoryPayload)).toEqual([exampleDescriptor]);
|
}
|
||||||
|
|
||||||
|
describe("host protocol — descriptor validation", () => {
|
||||||
|
it("parses a well-formed descriptor out of the navigation view", () => {
|
||||||
|
const navigation = parseDashboardNavigation(navigationWithModule(inventoryPayload.modules[0]));
|
||||||
|
|
||||||
|
expect(navigation.ungrouped[0]?.descriptor).toEqual(exampleDescriptor);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a malformed document rather than yielding a half-built descriptor", () => {
|
it("rejects a malformed descriptor rather than yielding a half-built one", () => {
|
||||||
|
const base = inventoryPayload.modules[0] as Record<string, unknown>;
|
||||||
const malformed: unknown[] = [
|
const malformed: unknown[] = [
|
||||||
{},
|
{},
|
||||||
{ modules: {} },
|
{ id: "example", version: "0.1.0" },
|
||||||
{ modules: [{ id: "example", version: "0.1.0" }] },
|
{ ...base, ui: {} },
|
||||||
{ modules: [{ ...inventoryPayload.modules[0], ui: {} }] },
|
{ ...base, api: { baseUrl: "" } },
|
||||||
{ modules: [{ ...inventoryPayload.modules[0], api: { baseUrl: "" } }] },
|
{ ...base, permissions: "example.records.read" },
|
||||||
{ modules: [{ ...inventoryPayload.modules[0], permissions: "example.records.read" }] },
|
{ ...base, permissions: [42] },
|
||||||
{ modules: [{ ...inventoryPayload.modules[0], permissions: [42] }] },
|
|
||||||
// Both URLs must be root-relative, so a value that would resolve against another origin is
|
// Both URLs must be root-relative, so a value that would resolve against another origin is
|
||||||
// refused rather than silently used to fetch browser code.
|
// refused rather than silently used to fetch browser code.
|
||||||
{
|
{ ...base, ui: { url: "https://evil.example.test/x.js" } },
|
||||||
modules: [
|
{ ...base, ui: { url: "//evil.example.test/x.js" } },
|
||||||
{ ...inventoryPayload.modules[0], ui: { url: "https://evil.example.test/x.js" } },
|
{ ...base, ui: { url: "relative/x.js" } },
|
||||||
],
|
{ ...base, api: { baseUrl: "https://evil.example.test/api" } },
|
||||||
},
|
|
||||||
{ modules: [{ ...inventoryPayload.modules[0], ui: { url: "//evil.example.test/x.js" } }] },
|
|
||||||
{ modules: [{ ...inventoryPayload.modules[0], ui: { url: "relative/x.js" } }] },
|
|
||||||
{
|
|
||||||
modules: [
|
|
||||||
{ ...inventoryPayload.modules[0], api: { baseUrl: "https://evil.example.test/api" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
for (const payload of malformed) {
|
for (const module of malformed) {
|
||||||
expect(() => parseHostModuleUiInventory(payload)).toThrowError(
|
expect(
|
||||||
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
|
() => parseDashboardNavigation(navigationWithModule(module)),
|
||||||
);
|
JSON.stringify(module),
|
||||||
|
).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches the inventory from the platform path with credentials", async () => {
|
|
||||||
const { calls, implementation } = stubFetch({
|
|
||||||
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
|
|
||||||
});
|
|
||||||
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: implementation };
|
|
||||||
|
|
||||||
await loadModuleUiInventory(config);
|
|
||||||
|
|
||||||
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/module-ui`);
|
|
||||||
expect(calls[0]?.init?.credentials).toBe("include");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an unauthenticated caller distinctly from any other failure", async () => {
|
it("reports an unauthenticated caller distinctly from any other failure", async () => {
|
||||||
for (const status of [401, 403]) {
|
for (const status of [401, 403]) {
|
||||||
const { implementation } = stubFetch({
|
const { implementation } = stubFetch({
|
||||||
"/api/v1/module-ui": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, status),
|
"/api/v1/dashboard/navigation": () =>
|
||||||
|
jsonResponse({ code: "AUTHORIZATION_DENIED" }, status),
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
|
loadDashboardNavigation({ apiBaseUrl, fetch: implementation }),
|
||||||
).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" });
|
).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { implementation } = stubFetch({
|
const { implementation } = stubFetch({
|
||||||
"/api/v1/module-ui": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500),
|
"/api/v1/dashboard/navigation": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500),
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
|
loadDashboardNavigation({ apiBaseUrl, fetch: implementation }),
|
||||||
).rejects.toMatchObject({ code: "HOST_REQUEST_FAILED" });
|
).rejects.toMatchObject({ code: "HOST_REQUEST_FAILED" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +144,7 @@ describe("host protocol — inventory", () => {
|
|||||||
throw new Error("connect ECONNREFUSED /run/postgres/.s.PGSQL.5432");
|
throw new Error("connect ECONNREFUSED /run/postgres/.s.PGSQL.5432");
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const error = await loadModuleUiInventory({ apiBaseUrl, fetch: failing }).catch(
|
const error = await loadDashboardNavigation({ apiBaseUrl, fetch: failing }).catch(
|
||||||
(caught: unknown) => caught,
|
(caught: unknown) => caught,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,10 +185,63 @@ describe("host protocol — principal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("host protocol — bootstrap", () => {
|
describe("host protocol — Dashboard navigation", () => {
|
||||||
it("resolves the principal and then the inventory", async () => {
|
it("parses the server's resolved arrangement, keeping the mount descriptor nested", () => {
|
||||||
|
const navigation = parseDashboardNavigation(navigationPayload);
|
||||||
|
|
||||||
|
expect(navigation.groups.map((group) => [group.id, group.label])).toEqual([["media", "Media"]]);
|
||||||
|
expect(navigation.groups[0]?.modules).toEqual([
|
||||||
|
{ descriptor: exampleDescriptor, name: "Example Module" },
|
||||||
|
]);
|
||||||
|
expect(navigation.ungrouped).toEqual([{ descriptor: calendarDescriptor, name: "Calendar" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed navigation document rather than rendering half of it", () => {
|
||||||
|
const malformed: unknown[] = [
|
||||||
|
{},
|
||||||
|
{ groups: [] },
|
||||||
|
{ groups: {}, ungrouped: [] },
|
||||||
|
{ groups: [{ id: "media", label: "Media" }], ungrouped: [] },
|
||||||
|
{ groups: [{ id: "media", modules: [] }], ungrouped: [] },
|
||||||
|
{ groups: [], ungrouped: [{ name: "Example" }] },
|
||||||
|
{ groups: [], ungrouped: [{ module: inventoryPayload.modules[0] }] },
|
||||||
|
// An absolute artifact URL must be refused here exactly as in the inventory.
|
||||||
|
{
|
||||||
|
groups: [],
|
||||||
|
ungrouped: [
|
||||||
|
{
|
||||||
|
module: {
|
||||||
|
...inventoryPayload.modules[0],
|
||||||
|
ui: { url: "https://evil.example.test/x.js" },
|
||||||
|
},
|
||||||
|
name: "Example",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const payload of malformed) {
|
||||||
|
expect(() => parseDashboardNavigation(payload), JSON.stringify(payload)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches navigation from the platform path with credentials", async () => {
|
||||||
const { calls, implementation } = stubFetch({
|
const { calls, implementation } = stubFetch({
|
||||||
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
|
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadDashboardNavigation({ apiBaseUrl, fetch: implementation });
|
||||||
|
|
||||||
|
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/dashboard/navigation`);
|
||||||
|
expect(calls[0]?.init?.credentials).toBe("include");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("host protocol — bootstrap", () => {
|
||||||
|
it("resolves the principal and then the Dashboard navigation", async () => {
|
||||||
|
const { calls, implementation } = stubFetch({
|
||||||
|
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
|
||||||
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,22 +249,22 @@ describe("host protocol — bootstrap", () => {
|
|||||||
|
|
||||||
expect(calls.map((call) => call.url)).toEqual([
|
expect(calls.map((call) => call.url)).toEqual([
|
||||||
`${apiBaseUrl}/api/v1/principal`,
|
`${apiBaseUrl}/api/v1/principal`,
|
||||||
`${apiBaseUrl}/api/v1/module-ui`,
|
`${apiBaseUrl}/api/v1/dashboard/navigation`,
|
||||||
]);
|
]);
|
||||||
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||||
expect(bootstrap.modules).toEqual([exampleDescriptor]);
|
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never requests the inventory for an anonymous viewer", async () => {
|
it("never requests navigation for an anonymous viewer", async () => {
|
||||||
const { calls, implementation } = stubFetch({
|
const { calls, implementation } = stubFetch({
|
||||||
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
|
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
|
||||||
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
|
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
||||||
|
|
||||||
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
|
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
|
||||||
expect(bootstrap.modules).toEqual([]);
|
expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] });
|
||||||
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
|
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -144,9 +144,24 @@ describe("the shell builds no module knowledge into its bundle", () => {
|
|||||||
expect(bundle).not.toContain("entrypoints");
|
expect(bundle).not.toContain("entrypoints");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spells only the two platform paths it genuinely owns", () => {
|
it("spells only the platform paths it genuinely owns", () => {
|
||||||
|
// The platform's own endpoints — never a module's. Everything module-specific arrives at run
|
||||||
|
// time from the navigation the server resolved.
|
||||||
expect(bundle).toContain('"/api/v1/principal"');
|
expect(bundle).toContain('"/api/v1/principal"');
|
||||||
expect(bundle).toContain('"/api/v1/module-ui"');
|
expect(bundle).toContain('"/api/v1/dashboard/navigation"');
|
||||||
|
// The shell reads the navigation view, which already contains the inventory; it never calls
|
||||||
|
// the host-neutral inventory endpoint itself.
|
||||||
|
expect(bundle).not.toContain('"/api/v1/module-ui"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bakes in no group, label, or placement", () => {
|
||||||
|
// Grouping is server-resolved configuration. A group id or label appearing in the bundle would
|
||||||
|
// mean the shell had an opinion about an arrangement it must only render.
|
||||||
|
// Deliberately distinctive terms: a generic word like "media" appears inside React's own
|
||||||
|
// bundled source and would make this assertion meaningless rather than strict.
|
||||||
|
for (const term of ["planning", "sunday-production", "livestream", "calendar", "ungrouped-"]) {
|
||||||
|
expect(bundle.toLowerCase(), term).not.toContain(term);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses neither eval nor a blob URL to load module code", () => {
|
it("uses neither eval nor a blob URL to load module code", () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
"core/tests/database-tls.integration.ts",
|
"core/tests/database-tls.integration.ts",
|
||||||
"core/tests/authorization-store.integration.ts",
|
"core/tests/authorization-store.integration.ts",
|
||||||
"core/tests/authorization-bootstrap.integration.ts",
|
"core/tests/authorization-bootstrap.integration.ts",
|
||||||
|
"core/tests/dashboard-store.integration.ts",
|
||||||
"apps/api/tests/api-http.integration.ts",
|
"apps/api/tests/api-http.integration.ts",
|
||||||
"apps/api/tests/module-database.integration.ts",
|
"apps/api/tests/module-database.integration.ts",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user