Merge pull request 'feat: add housekeeping inventory foundation' (#51) from codex/housekeeping-foundation into main
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 42s

Reviewed-on: #51
This commit was merged in pull request #51.
This commit is contained in:
Simo committed 2026-08-26 19:50:40 +02:00
commit d5eadeb3a7
71 files changed
+10209 -3

No files matched your search

+2
View File
@@ -17,6 +17,8 @@ NODE_ENV=production
PORT=3002
NEXT_TELEMETRY_DISABLED=1
UV_THREADPOOL_SIZE=16
# Non-production preview only; production always returns 404.
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
# --- HOTEL & URLS ---
HOTEL_NAME=EPIC WEB CONTROL
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,438 @@
# Housekeeping modernization design
Date: 2026-08-24
Status: approved in design review; awaiting review of this written specification
## Purpose
Replace the current administration experience with one coherent, role-adaptive Housekeeping (HK) at `/admin`.
The new HK is a modular part of the existing Next.js application. It is built in parallel, validated against the current system, and exposed with one atomic cutover. It unifies the current `/admin` and `/mod` surfaces, removes duplicated workflows, and preserves reliable domain services without automatically preserving their current pages.
This document is the master architecture for the program. It is deliberately not one giant implementation plan. Delivery is split into independently specified and verified subprojects, beginning with **Inventory & Foundation**.
## Current-state findings
- The repository currently contains 124 `page.tsx` files below `src/app/admin` and 13 below `src/app/mod`: 137 administration pages in total.
- `src/lib/admin-nav.ts` currently exposes nine navigation groups and seven hub definitions.
- `/admin` and `/mod` provide overlapping moderation, ticket, ban, team, and user workflows with separate shells.
- `/admin/housekeeping` is a legacy permission archive/comparison/export surface, while `/admin/permissions` is the live permission-management surface.
- The current dashboard reports useful counts but is not an operational work queue.
- Page composition, localization, ACL checks, filtering, error handling, and action feedback are not yet uniform across the administration surface.
The migration must therefore classify every current page. A visual refresh without workflow and boundary changes is insufficient.
## Approved decisions
| Area | Decision |
| --- | --- |
| Audience | One role-adaptive HK. Effective capabilities, not rank names alone, determine what an operator sees and can do. |
| Entry point | `/admin` is the only administration entry point after cutover. `/mod` is removed. |
| Layout | Command Deck: compact domain rail, contextual navigation, global command palette, operational workspace. |
| Personalization | Hybrid: the system supplies mandatory capability-derived content; the operator may pin and reorder allowed shortcuts and optional widgets. |
| Compatibility | Clean break. Old subroute compatibility and legacy UX are not preserved through redirects. |
| Build strategy | Build the new HK in parallel, keep it unavailable to normal production operators, then switch atomically. |
| Work queue | “Da fare ora” is derived from existing sources. It is not a second task database and never owns workflow state. |
| Command palette | It navigates, searches entities, and executes only safe commands. Sensitive actions open a dedicated contextual flow. |
| Architecture | Modular hybrid replacement inside the current application: reuse sound services, rebuild weak UI/workflows, merge duplicates, and remove obsolete surfaces. |
## Goals
1. Give each operator one clear, capability-appropriate place to work.
2. Replace feature sprawl with six stable domains and consistent page contracts.
3. Make urgent work visible without copying or diverging from source workflow state.
4. Enforce authorization, validation, transaction boundaries, error semantics, and audit behavior server-side.
5. Remove `/mod`, the legacy HK archive page, duplicate hubs, and manual navigation concepts that the new foundation owns.
6. Reach explicit functional, authorization, audit, localization, accessibility, and data-parity gates before cutover.
7. Keep rollback practical without exposing a mixed legacy/new experience.
## Non-goals
- Creating a separate HK application, microservice, or deployment.
- Creating a new assignment/task system for the operational inbox.
- Preserving every current page, route, component, or interaction.
- Adding backward-compatible redirects for removed administration subroutes.
- Providing full sensitive-workflow parity on phones. The target is desktop-first with usable tablet layouts.
- Redesigning public CMS or game-client experiences as part of this program.
- Replacing sound domain logic solely for architectural uniformity.
## Architecture
### Modular monolith
The HK remains inside EpicNext CMS and uses the application's existing authentication, database, service, localization, and deployment infrastructure.
The target source organization separates composition from behavior:
```text
src/app/admin/ route composition only
src/features/housekeeping/
foundation/ shell, registry, ACL context, preferences
domains/
operations/ derived inbox, global search, recent work
people/
content/
economy/
hotel/
system/
src/lib/services/ existing and extracted domain services
```
The exact filenames are an implementation-plan concern, but the boundaries are mandatory:
- App Router files compose pages and bind route parameters; they do not own business rules.
- The foundation owns cross-cutting HK behavior and does not mutate domain data.
- Each domain owns its queries, commands, search providers, inbox providers, widgets, and page composition.
- Domains do not import another domain's UI internals. Cross-domain interaction uses registered contracts or links to the owning route.
- Existing reliable services are adapted behind domain contracts rather than copied into the new UI.
### Module manifest and registry
Every domain exports a manifest with stable identifiers for:
- domain metadata and localized labels;
- routes and contextual navigation;
- required capabilities;
- command-palette entries;
- entity-search providers;
- derived-inbox sources;
- mandatory and optional dashboard widgets.
The foundation composes these manifests into the rail, contextual navigation, palette, dashboard, and route metadata. Contract tests reject duplicate IDs, duplicate routes, missing localization keys, unknown capability slugs, and commands without an owner.
The manifest registry replaces hand-maintained duplication between the sidebar, hubs, search, and dashboards. It is code-owned and reviewable. Operator preferences can alter presentation only within what the registry and capability context permit.
### Capability context
The server creates one request-scoped capability context from the authenticated operator and the existing ACL source.
- Capability checks are based on effective permission slugs.
- Super-administrator behavior remains explicit and testable.
- Rank may help choose default presentation, but never grants access by itself.
- Navigation filtering is a usability feature, not an authorization boundary.
- Every query and command rechecks its capability on the server and defaults to deny.
## Functional domains
| Domain | Owns | Representative current areas |
| --- | --- | --- |
| Da fare & operations | Derived inbox, global search, recent work, favorites, operational summaries | Dashboard, selected alerts and cross-domain counts; projections only |
| People & community | Users, online state, accounts, guilds, applications, staff directory, moderation, support | Users, multi-accounts, guilds, applications, CFH, moderation actions, bans, IP/VPN, word filter, tickets, help tickets, `/mod/*` |
| Content & engagement | Public/editorial content and engagement workflows | Articles, photos, media, banners, ads, events, polls, help content, tags, prefixes, writable boxes, email content, branding/localization surfaces |
| Economy & catalog | Products, value, commercial assets, and economic history | Catalog, items, import/maintenance, shop, marketplace, transactions, vouchers, subscriptions, rare values, badges, achievements, sounds |
| Hotel & world | Live hotel surfaces and world-management tools | Rooms, navigator, radio, studio/runtime asset tools, contextual hotel actions |
| System, access & observability | Configuration, authorization, diagnostics, and privileged operations | Permissions, access audit, settings, maintenance, emulator, command center, logs, analytics, alerts, DevOps |
Where an existing feature spans two domains, responsibility follows the action rather than the old route. For example, the staff directory belongs to People, while the policy granting staff capabilities belongs to System and Access.
Domain landing pages summarize their own workflows. They do not recreate the global dashboard or become a second source of state.
## Operator experience
### Command Deck shell
The shared shell contains:
1. A compact rail for the six domains.
2. Contextual navigation generated from the active domain manifest.
3. A global command/search field available by keyboard.
4. A main workspace using consistent title, context, primary action, filters, content, and feedback regions.
5. Operator identity, effective-capability context, notifications, and session controls.
The shell is desktop-first, fully keyboard operable, and responsive for tablets. Phone layouts may support inspection and low-risk triage, but sensitive multi-step operations are not optimized for phone use.
### Adaptive dashboard
ACL and capability data determine:
- visible domains and routes;
- mandatory queues and warnings;
- permitted metrics and widgets;
- available commands and search providers.
The operator may:
- pin allowed routes and safe commands;
- reorder shortcuts and optional widgets;
- add or remove optional allowed widgets;
- persist preferred filters and presentation density where supported.
The operator may not hide mandatory warnings, reveal unauthorized data, or preserve a shortcut after its required capability is lost.
Preferences are server-persisted, user-scoped, schema-versioned, and non-authoritative. If no suitable existing preference store exists, the foundation adds one additive `housekeeping_user_preferences` store containing presentation state only. It never stores task status or authorization decisions. Every preference is reconciled with the current manifest and capability context when read.
### Standard page contract
Every target page follows the same structural contract:
- localized title, description, breadcrumb/context, and one clear primary action;
- capability-derived actions with server authorization;
- shared filtering, pagination, empty, loading, partial, and error states;
- explicit unsaved-change behavior for editable forms;
- consistent confirmation and outcome feedback;
- stable deep links to owned entities and workflows;
- responsive table-to-detail behavior without hiding critical fields;
- audit context for mutations.
## Operational inbox
The inbox is a read model over domain-owned sources such as tickets, CFH reports, alerts, emulator errors, and detected anomalies.
Each source emits normalized work items containing at least:
- stable source and item IDs;
- domain and required capability;
- severity and source timestamp;
- localized summary and optional context;
- stable destination route and entity target;
- deduplication key;
- freshness/availability metadata.
The aggregator:
1. Requests sources independently with bounded timeouts.
2. Filters every result against the operator's capability context.
3. Deduplicates by stable source identity.
4. Orders by severity, age, and domain policy.
5. Returns both items and per-source availability.
The aggregator never creates, assigns, dismisses, or completes work. Selecting an item opens the owning workflow. If that workflow supports assignment or resolution, those state changes occur there.
A failed or timed-out source does not erase successful sources. The UI labels the missing source and the freshness of remaining data instead of presenting the whole system as healthy.
## Global search and command palette
The palette has three provider types:
1. **Navigation providers** for permitted routes and favorites.
2. **Entity providers** for capability-filtered entities such as users, rooms, tickets, articles, or catalog entries.
3. **Safe command providers** for narrowly scoped, validated, idempotent or reversible actions.
A mutation may run directly from the palette only when it is single-target, low impact, reviewable in the palette, protected by a specific capability, and safe against duplicate submission. It still uses the normal server command and audit path.
Destructive, economic, moderation, permission, bulk, or otherwise sensitive actions return a navigation intent. The target page receives validated context and shows impact, current state, required reason, confirmation, and final outcome.
## Data and command flow
### Queries
```text
page or shell
-> request-scoped capability context
-> typed domain query
-> existing API/repository through an adapter
-> sanitized response
```
The UI does not query arbitrary tables or reproduce sensitive filter rules. Authorization-sensitive results are filtered at the query boundary. Short-lived caching may be used for operational counts, but authorization is applied after cache lookup and sensitive per-user results are not shared across capability contexts.
### Commands
```text
intent
-> server capability check
-> schema validation
-> current-state/concurrency check
-> domain transaction or controlled external call
-> audit outcome
-> typed result and cache invalidation
```
Every command receives a server-issued action ID used as an idempotency key. Duplicate submissions return the original known outcome rather than repeating the mutation.
For records with a revision or update timestamp, edits use optimistic concurrency. A stale edit returns a conflict result and current-state reference; it is not silently overwritten. Where a source cannot expose a revision, the command performs the strongest available transactional re-read before mutation.
## Security and audit
- Default-deny server checks protect every query and command.
- Sensitive actions require a dedicated flow, an explicit target, an impact summary, confirmation, and a non-empty operator reason.
- Domain validation occurs after authorization and before mutation.
- Audit is append-only from the HK application: no HK route can edit or delete audit events.
- Audit records include actor, target, command, reason, sanitized before/after details where appropriate, outcome, timestamp, action ID, and correlation ID.
- Secrets, credentials, tokens, and unnecessary personal data are excluded from audit payloads.
- When data and audit share a transactional store, a privileged mutation and its audit record commit together.
- For external operations, an intent/pending audit record is written before dispatch and completed with success or failure afterward.
- A privileged mutation fails closed if its required audit trail cannot be established.
## Error model
Domain boundaries return typed outcomes rather than leaking raw infrastructure errors:
- validation failure;
- authentication required;
- capability denied;
- not found;
- stale/conflicting state;
- dependency unavailable;
- partial aggregate result;
- unexpected internal failure.
Expected outcomes have localized, actionable messages. Unexpected failures expose a correlation ID to the operator and retain technical detail only in server logs. Forms preserve safe input after recoverable failures. Lists and the operational dashboard distinguish empty results from unavailable data.
## Migration inventory
The first subproject creates a committed migration matrix covering all 137 current pages. Each row contains:
- legacy path and source surface (`admin` or `mod`);
- target domain and owning workflow;
- target path;
- decision: `REHOST`, `REBUILD`, `MERGE`, or `REMOVE`;
- required read and mutation capabilities;
- source queries and mutations;
- audit requirement;
- localization and accessibility status;
- required unit, integration, and E2E coverage;
- parity evidence and migration status.
Decision meanings:
- **REHOST**: the current UI and service are sound enough to enter the new shell after contract and ACL adaptation.
- **REBUILD**: preserve the workflow and sound service logic, but reconstruct its interaction and page composition.
- **MERGE**: combine duplicated routes or variants into one owning workflow with contextual views.
- **REMOVE**: eliminate obsolete or foundation-owned behavior at cutover.
Mandatory consolidations:
- All 13 `/mod` pages merge into People and Community workflows. `/mod` does not redirect after cutover.
- `/admin/housekeeping` ceases to exist as a named feature. Useful comparison/export history moves into System, Access, and Audit.
- `/admin/permissions` remains the live policy editor under System and Access.
- Legacy dashboard, hub, and manual HK-navigation concepts are removed when their responsibilities are supplied by the registry and Command Deck.
No page is considered migrated merely because it renders in the new shell. Its matrix row closes only after data, actions, capability behavior, audit, localization, accessibility, and required tests pass.
## Delivery decomposition
This master design controls the program. For delivery purposes it is also the approved design specification for subproject 01. Subprojects 02 through 06 require their own scoped design specifications before their implementation plans. Subprojects are delivered in this order:
### 01. Inventory & Foundation
This is the first and only scope of the initial implementation plan.
Deliverables:
- the complete 137-page migration matrix;
- HK manifest contracts and registry validation;
- request-scoped capability context and server guard interfaces;
- domain query, command, search, inbox, and widget contracts;
- the Command Deck shell primitives and standard page-state contract;
- six domain manifests with no migrated business workflow yet;
- a non-production/test-only entry mechanism that cannot expose a mixed HK to normal production operators;
- contract, capability, localization-key, accessibility-smoke, and shell tests.
Explicit exclusions:
- no current `/admin` or `/mod` route changes;
- no production operator exposure;
- no operational inbox aggregation;
- no entity search implementation;
- no domain mutation migration;
- no legacy deletion.
### 02. Access, audit & system core
Implement the capability enforcement adapters, audit command path, error taxonomy, correlation IDs, and core observability used by every later vertical.
### 03. People, moderation & support
Deliver the first complete vertical and unify user, ticket, CFH, moderation-action, and ban workflows. This vertical proves the future removal of `/mod` without exposing a partial cutover.
### 04. Command Deck operations
Implement global search, safe commands, favorites, preferences, and the derived inbox against the sources available from completed verticals.
### 05. Remaining domain verticals
Deliver separate scoped specifications and plans for:
1. Content and Engagement;
2. Hotel and World;
3. Economy and Catalog;
4. remaining System, Access, and Observability pages.
Economy and permission-affecting mutations receive the strictest confirmation, concurrency, and audit coverage.
### 06. Parity, cutover & cleanup
Close the migration matrix, run cross-role journeys and data comparisons, switch `/admin`, make `/mod` unreachable, observe the release, then delete unreachable legacy code and later remove obsolete schema safely.
Subproject 01 uses this specification; every later subproject has its own spec, implementation plan, tests, review, and completion gate. A later subproject may not silently expand an earlier approved scope.
## Verification strategy
Every subproject runs proportionate checks from these layers:
1. **Unit tests** for manifest parsing, normalizers, policy functions, reducers, and domain services.
2. **Contract tests** for unique IDs/routes, capability declarations, localization keys, command ownership, and provider behavior.
3. **Integration tests** against representative repository/API implementations, including transactions, external failures, idempotency, and conflicts.
4. **ACL matrix tests** covering permitted, denied, capability-revoked, and super-administrator cases at both render and server boundaries.
5. **E2E journeys** for moderation, support, editorial, economy, hotel operations, and administration roles defined by capabilities rather than rank labels.
6. **Audit assertions** after every tested mutation.
7. **Accessibility checks** for keyboard use, focus order, names, contrast, live feedback, dialogs, and table/detail transitions.
8. **Localization checks** rejecting new hard-coded operator copy and missing translation keys.
9. **Visual regression checks** for the shared shell and high-risk standard states.
10. **Performance comparison** against a recorded legacy baseline using the same environment and dataset. Comparable new flows may not regress median or p95 response time by more than 10% without an explicit reviewed exception. Performance improvements are reported only from measurements.
## Cutover gate
The atomic switch is permitted only when all of the following are true:
- all 137 migration rows are closed with evidence;
- every exposed query and command has a declared and tested capability;
- every mutation has validation and required audit coverage;
- no blocking or critical defect remains open;
- equivalent legacy/new counts and records have been compared for migrated read workflows;
- role journeys for moderator, support operator, editor, economy operator, hotel operator, and administrator pass;
- localization, accessibility, build, type, lint, test, and visual checks pass;
- production-like smoke tests, backup verification, rollback procedure, and health checks have been rehearsed;
- the new HK is not dependent on legacy UI routes;
- communication and operator runbooks are ready for the clean break.
## Cutover and rollback
Before cutover, the new HK is exercised through test/staging or an explicit non-production mechanism. Read-only shadow comparisons may run against representative data. There is no production dual-write.
At cutover:
1. `/admin` changes to the new route composition in one release/flag transition.
2. `/mod` and removed legacy subroutes become unreachable without compatibility redirects.
3. Smoke tests verify authentication, capability filtering, representative reads, one controlled mutation per risk class, audit, and health signals.
Database changes required before cutover are additive and backward-compatible for the emergency rollback window. A flag or previous release can temporarily restore the legacy application if the cutover fails. During normal operation, only one HK is exposed.
After the agreed stability window, unreachable legacy code and flags are removed. Destructive schema cleanup is a later migration and is not coupled to the cutover release.
## Success criteria
The program is complete when:
- `/admin` is the single role-adaptive administration surface;
- `/mod` and the legacy Housekeeping archive surface are gone;
- all 137 legacy pages have an evidenced migration decision;
- all exposed data, navigation, commands, widgets, and inbox items are capability-correct;
- the operational inbox derives live work without owning duplicate workflow state;
- all mutations use the domain command, validation, concurrency, idempotency, and audit path appropriate to their risk;
- no mixed legacy/new production experience exists;
- measured performance meets the approved comparison gate;
- rollback and eventual legacy cleanup are complete.
## Rejected alternatives
### Full greenfield rewrite
Rejected because it would discard reliable existing services and maximize parity, timing, and regression risk across 137 pages.
### Cosmetic refactor of the existing HK
Rejected because it would preserve duplicated `/admin` and `/mod` workflows, inconsistent page boundaries, and manual navigation debt.
### Separate HK service/application
Rejected because the current requirement does not justify another deployment, authentication boundary, or distributed consistency problem.
### Persistent cross-domain task database
Rejected because it would duplicate ticket, moderation, alert, and anomaly state and create reconciliation failure modes.
## Final design invariant
The migration may be incremental internally, but the operator-facing product is not. Until the cutover gate passes, the current HK remains the only normal production surface. After cutover, the new HK is the only surface.
+3 -1
View File
@@ -21,7 +21,9 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched"
+18
View File
@@ -0,0 +1,18 @@
import { discoverLegacyPages } from "../src/features/housekeeping/migration/discover-legacy-pages";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix";
import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix";
const discovered = discoverLegacyPages();
const issues = validateMigrationEntries(
discovered,
HOUSEKEEPING_MIGRATION_MATRIX,
);
if (issues.length > 0) {
for (const issue of issues) console.error(issue);
process.exitCode = 1;
} else {
console.log(
`Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${discovered.length} valid`,
);
}
+59
View File
@@ -0,0 +1,59 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
const MESSAGE_PREFIX = "pages.housekeeping.";
function namespaceKey(key: string): string {
if (!key.startsWith(MESSAGE_PREFIX)) {
throw new Error(`invalid housekeeping message key: ${key}`);
}
return key.slice(MESSAGE_PREFIX.length);
}
export default async function AdminNextDomainLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ domain: string }>;
}) {
const { domain } = await params;
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const activeDomain = registry.domains.find((entry) => entry.id === domain);
if (!activeDomain) notFound();
const context = await getHousekeepingCapabilityContext();
if (!satisfiesCapability(context, activeDomain.capability)) notFound();
const translate = await getTranslations("pages.housekeeping");
const navigation = buildHousekeepingNavigation(registry, context, (key) =>
translate(namespaceKey(key) as never),
);
return (
<HousekeepingShell
actor={context.actor}
activeDomainId={activeDomain.id}
domains={navigation}
labels={{
skipToContent: translate("navigation.skipToContent"),
primaryNavigation: translate("navigation.primary"),
contextualNavigation: translate("navigation.contextual"),
command: translate("preview.commandDisabled"),
preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"),
}}
>
{children}
</HousekeepingShell>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
const MESSAGE_PREFIX = "pages.housekeeping.";
function namespaceKey(key: string): string {
if (!key.startsWith(MESSAGE_PREFIX)) {
throw new Error(`invalid housekeeping message key: ${key}`);
}
return key.slice(MESSAGE_PREFIX.length);
}
export default async function AdminNextDomainPage({
params,
}: {
params: Promise<{ domain: string }>;
}) {
const { domain } = await params;
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const activeDomain = registry.domains.find((entry) => entry.id === domain);
if (!activeDomain) notFound();
const translate = await getTranslations("pages.housekeeping");
return (
<HousekeepingPageShell
title={translate(namespaceKey(activeDomain.labelKey) as never)}
description={translate(
namespaceKey(activeDomain.descriptionKey) as never,
)}
>
<HousekeepingPageState
state="empty"
title={translate("states.empty.title")}
description={translate("states.empty.description")}
/>
</HousekeepingPageShell>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { env } from "@/env";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
export default function AdminNextLayout({ children }: { children: ReactNode }) {
if (
!isHousekeepingPreviewEnabled({
nodeEnv: env.NODE_ENV,
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
})
) {
notFound();
}
return children;
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
export default async function AdminNextPage() {
const context = await getHousekeepingCapabilityContext();
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const firstVisibleDomain = registry.domains.find((domain) =>
satisfiesCapability(context, domain.capability),
);
if (!firstVisibleDomain) notFound();
redirect(firstVisibleDomain.previewHref);
}
+4
View File
@@ -9,6 +9,10 @@ const schema = z
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: z
.string()
.optional()
.transform((value) => value === "true" || value === "1"),
DATABASE_URL: z.string().url(),
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10),
DATABASE_IDLE_TIMEOUT_MS: z.coerce
@@ -0,0 +1,33 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const contentManifest = {
id: "content",
labelKey: "pages.housekeeping.domains.content.title",
descriptionKey: "pages.housekeeping.domains.content.description",
iconId: "file-text",
previewHref: "/admin-next/content",
capability: anyCapability(
PERMS.NEWS_VIEW,
PERMS.PAGES_VIEW,
PERMS.BANNERS_VIEW,
PERMS.EVENTS_VIEW,
PERMS.POLLS_VIEW,
PERMS.PREFIXES_VIEW,
PERMS.NEWS_EDIT,
PERMS.PAGES_EDIT,
PERMS.BANNERS_EDIT,
PERMS.EVENTS_EDIT,
PERMS.POLLS_EDIT,
PERMS.PREFIXES_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,23 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const economyManifest = {
id: "economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
iconId: "gem",
previewHref: "/admin-next/economy",
capability: anyCapability(
PERMS.CATALOG_VIEW,
PERMS.SHOP_VIEW,
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,27 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const hotelManifest = {
id: "hotel",
labelKey: "pages.housekeeping.domains.hotel.title",
descriptionKey: "pages.housekeeping.domains.hotel.description",
iconId: "hotel",
previewHref: "/admin-next/hotel",
capability: anyCapability(
PERMS.ROOMS_VIEW,
PERMS.RADIO_VIEW,
PERMS.ASSETS_IMPORT,
PERMS.ROOMS_EDIT,
PERMS.ROOMS_DELETE,
PERMS.RADIO_EDIT,
PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,18 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const operationsManifest = {
id: "operations",
labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description",
iconId: "inbox",
previewHref: "/admin-next/operations",
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,41 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const peopleManifest = {
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
previewHref: "/admin-next/people",
capability: anyCapability(
PERMS.USERS_VIEW,
PERMS.MODERATION_VIEW,
PERMS.TICKETS_VIEW,
PERMS.BANS_VIEW,
PERMS.MOD_DASHBOARD,
PERMS.MOD_CFH_VIEW,
PERMS.MOD_TEAM_VIEW,
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.MOD_BANS_VIEW,
PERMS.USERS_EDIT,
PERMS.USERS_BAN,
PERMS.USERS_RESET_PASSWORD,
PERMS.MODERATION_EDIT,
PERMS.TICKETS_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
PERMS.WORDFILTER_VIEW,
PERMS.WORDFILTER_EDIT,
PERMS.MOD_ACTIONS,
PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,28 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const systemManifest = {
id: "system",
labelKey: "pages.housekeeping.domains.system.title",
descriptionKey: "pages.housekeeping.domains.system.description",
iconId: "settings",
previewHref: "/admin-next/system",
capability: anyCapability(
PERMS.SETTINGS_VIEW,
PERMS.LOGS_VIEW,
PERMS.ANALYTICS_VIEW,
PERMS.DEVOPS_VIEW,
PERMS.NOTIFICATIONS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.RCON_EXECUTE,
PERMS.SETTINGS_EDIT,
PERMS.NOTIFICATIONS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import type { PermissionSet } from "@/types/admin";
import {
createHousekeepingCapabilityContext,
satisfiesCapability,
} from "./capability-context";
import {
allCapabilities,
anyCapability,
type HousekeepingActor,
} from "./contracts";
const actor: HousekeepingActor = {
id: 42,
username: "operator",
rank: 7,
};
const permissionSet = (
slugs: readonly string[],
isSuperAdmin = false,
): PermissionSet => {
const granted = new Set(slugs);
const has = (slug: string) => isSuperAdmin || granted.has(slug);
return {
isSuperAdmin,
has,
hasAny: (...requested) => requested.some(has),
hasAll: (...requested) => requested.every(has),
};
};
describe("housekeeping capability context", () => {
it("evaluates any and all requirements from effective permission methods", () => {
const context = createHousekeepingCapabilityContext(
actor,
permissionSet(["admin.users.view", "admin.tickets.view"]),
);
expect(
satisfiesCapability(
context,
anyCapability("admin.users.view", "admin.logs.view"),
),
).toBe(true);
expect(
satisfiesCapability(
context,
allCapabilities("admin.users.view", "admin.logs.view"),
),
).toBe(false);
});
it("keeps the super administrator bypass explicit", () => {
const context = createHousekeepingCapabilityContext(
actor,
permissionSet([], true),
);
expect(context.isSuperAdmin).toBe(true);
expect(context.has("unknown.future.slug")).toBe(true);
expect(
satisfiesCapability(context, allCapabilities("unknown.future.slug")),
).toBe(true);
});
it("bypasses meaningful requirements without consulting permission methods", () => {
const permissions: PermissionSet = {
isSuperAdmin: true,
has: vi.fn(() => false),
hasAny: vi.fn(() => false),
hasAll: vi.fn(() => false),
};
const context = createHousekeepingCapabilityContext(actor, permissions);
expect(
satisfiesCapability(
context,
anyCapability("admin.users.view", "admin.logs.view"),
),
).toBe(true);
expect(
satisfiesCapability(
context,
allCapabilities("admin.users.view", "admin.logs.view"),
),
).toBe(true);
expect(permissions.has).not.toHaveBeenCalled();
expect(permissions.hasAny).not.toHaveBeenCalled();
expect(permissions.hasAll).not.toHaveBeenCalled();
});
it("delegates capability checks without applying rank logic", () => {
const permissions: PermissionSet = {
isSuperAdmin: false,
has: vi.fn(() => false),
hasAny: vi.fn(() => true),
hasAll: vi.fn(() => false),
};
const context = createHousekeepingCapabilityContext(
{ ...actor, rank: 0 },
permissions,
);
expect(
satisfiesCapability(context, anyCapability("admin.users.view")),
).toBe(true);
expect(
satisfiesCapability(context, allCapabilities("admin.users.view")),
).toBe(false);
expect(permissions.hasAny).toHaveBeenCalledWith("admin.users.view");
expect(permissions.hasAll).toHaveBeenCalledWith("admin.users.view");
});
});
@@ -0,0 +1,30 @@
import type { PermissionSet } from "@/lib/permissions";
import type {
CapabilityRequirement,
HousekeepingActor,
HousekeepingCapabilityContext,
} from "./contracts";
export function createHousekeepingCapabilityContext(
actor: HousekeepingActor,
permissions: PermissionSet,
): HousekeepingCapabilityContext {
return {
actor,
isSuperAdmin: permissions.isSuperAdmin,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => permissions.hasAny(...slugs),
hasAll: (...slugs) => permissions.hasAll(...slugs),
};
}
export function satisfiesCapability(
context: HousekeepingCapabilityContext,
requirement: CapabilityRequirement,
): boolean {
if (context.isSuperAdmin) return true;
return requirement.mode === "any"
? context.hasAny(...requirement.slugs)
: context.hasAll(...requirement.slugs);
}
@@ -0,0 +1,36 @@
export type CapabilityRequirement =
| { mode: "all"; slugs: readonly string[] }
| { mode: "any"; slugs: readonly string[] };
export interface HousekeepingActor {
id: number;
username: string;
rank: number;
}
export interface HousekeepingCapabilityContext {
actor: HousekeepingActor;
isSuperAdmin: boolean;
has(slug: string): boolean;
hasAny(...slugs: string[]): boolean;
hasAll(...slugs: string[]): boolean;
}
function createCapabilityRequirement(
mode: CapabilityRequirement["mode"],
slugs: readonly string[],
): CapabilityRequirement {
if (slugs.length === 0) {
throw new Error("capability requirement is empty");
}
return { mode, slugs };
}
export function anyCapability(...slugs: string[]): CapabilityRequirement {
return createCapabilityRequirement("any", slugs);
}
export function allCapabilities(...slugs: string[]): CapabilityRequirement {
return createCapabilityRequirement("all", slugs);
}
@@ -0,0 +1,18 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export interface HousekeepingCommand<I, O> {
id: string;
owner: HousekeepingDomainId;
risk: "safe" | "sensitive";
capability: CapabilityRequirement;
requiresReason: boolean;
execute(
context: HousekeepingCapabilityContext,
input: I,
): Promise<HousekeepingResult<O>>;
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import {
allCapabilities,
anyCapability,
type CapabilityRequirement,
fail,
type HousekeepingCommand,
type HousekeepingDomainManifest,
type HousekeepingInboxSource,
type HousekeepingInboxSourceResult,
type HousekeepingQuery,
type HousekeepingSearchProvider,
type HousekeepingSearchResult,
type HousekeepingWidgetDefinition,
type HousekeepingWorkItem,
ok,
} from ".";
const capability = anyCapability("admin.users.view");
const workItem = {
sourceId: "tickets",
itemId: "ticket-42",
deduplicationKey: "ticket:42",
domain: "people",
capability,
severity: "warning",
occurredAt: "2026-08-24T12:00:00.000Z",
titleKey: "pages.housekeeping.items.ticket",
context: { ticketId: "42" },
href: "/admin-next/people/tickets/42",
freshness: "fresh",
} satisfies HousekeepingWorkItem;
const searchResult = {
id: "user-42",
domain: "people",
title: "operator",
href: "/admin-next/people/users/42",
} satisfies HousekeepingSearchResult;
const searchProvider: HousekeepingSearchProvider = {
id: "people.users",
owner: "people",
capability,
search: async () => ok([searchResult], "search-1"),
};
const inboxSourceResult = {
items: [workItem],
availability: "available",
} satisfies HousekeepingInboxSourceResult;
const inboxSource: HousekeepingInboxSource = {
id: "people.tickets",
owner: "people",
capability,
getItems: async () => ok(inboxSourceResult, "inbox-1"),
};
const widget: HousekeepingWidgetDefinition = {
id: "people.queue",
owner: "people",
capability,
kind: "mandatory",
load: async () => ok({ count: 2 }, "widget-1"),
};
const query: HousekeepingQuery<{ id: number }, { id: number }> = {
id: "people.user",
owner: "people",
capability,
run: async (_context, input) => ok(input, "query-1"),
};
const command: HousekeepingCommand<{ id: number }, { id: number }> = {
id: "people.user.disable",
owner: "people",
risk: "sensitive",
capability,
requiresReason: true,
execute: async (_context, input) => ok(input, "command-1"),
};
const manifest: HousekeepingDomainManifest = {
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
previewHref: "/admin-next/people",
capability,
routes: [],
searchProviders: [searchProvider],
inboxSources: [inboxSource],
widgets: [widget],
};
describe("housekeeping foundation contracts", () => {
it("creates typed success and error results", () => {
expect(ok({ count: 2 }, "corr-1")).toEqual({
ok: true,
data: { count: 2 },
correlationId: "corr-1",
});
expect(fail("CAPABILITY_DENIED", "corr-2")).toEqual({
ok: false,
error: { code: "CAPABILITY_DENIED" },
correlationId: "corr-2",
});
});
it("rejects empty capability requirements", () => {
expect(() => anyCapability()).toThrow("capability requirement is empty");
expect(() => allCapabilities()).toThrow("capability requirement is empty");
});
it("exports assignable type-only contracts", () => {
const requirements: readonly CapabilityRequirement[] = [
capability,
allCapabilities("admin.users.view"),
];
expect([
workItem,
searchProvider,
inboxSource,
widget,
query,
command,
manifest,
requirements,
]).toHaveLength(8);
});
it("carries stable ownership and capability metadata for registry entries", () => {
expect(searchProvider).toMatchObject({
id: "people.users",
owner: "people",
capability,
});
expect(inboxSource).toMatchObject({
id: "people.tickets",
owner: "people",
capability,
});
expect(widget).toMatchObject({
id: "people.queue",
owner: "people",
capability,
kind: "mandatory",
});
});
});
@@ -0,0 +1,26 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type { CapabilityRequirement } from "./capability";
import type { HousekeepingInboxSource } from "./inbox";
import type { HousekeepingSearchProvider } from "./search";
import type { HousekeepingWidgetDefinition } from "./widget";
export interface HousekeepingRouteDefinition {
id: string;
labelKey: string;
href: string;
capability: CapabilityRequirement;
matchPrefixes?: readonly string[];
}
export interface HousekeepingDomainManifest {
id: HousekeepingDomainId;
labelKey: string;
descriptionKey: string;
iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings";
previewHref: `/admin-next/${HousekeepingDomainId}`;
capability: CapabilityRequirement;
routes: readonly HousekeepingRouteDefinition[];
searchProviders: readonly HousekeepingSearchProvider[];
inboxSources: readonly HousekeepingInboxSource[];
widgets: readonly HousekeepingWidgetDefinition[];
}
@@ -0,0 +1,39 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export type HousekeepingWorkItemSeverity = "info" | "warning" | "critical";
export type HousekeepingWorkItemFreshness = "fresh" | "stale";
export type HousekeepingInboxSourceAvailability = "available" | "unavailable";
export interface HousekeepingWorkItem {
sourceId: string;
itemId: string;
deduplicationKey: string;
domain: HousekeepingDomainId;
capability: CapabilityRequirement;
severity: HousekeepingWorkItemSeverity;
occurredAt: string;
titleKey: string;
context?: Readonly<Record<string, unknown>>;
href: string;
freshness: HousekeepingWorkItemFreshness;
}
export interface HousekeepingInboxSourceResult {
items: readonly HousekeepingWorkItem[];
availability: HousekeepingInboxSourceAvailability;
}
export interface HousekeepingInboxSource {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
getItems(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<HousekeepingResult<HousekeepingInboxSourceResult>>;
}
@@ -0,0 +1,36 @@
export {
allCapabilities,
anyCapability,
type CapabilityRequirement,
type HousekeepingActor,
type HousekeepingCapabilityContext,
} from "./capability";
export type { HousekeepingCommand } from "./command";
export type {
HousekeepingDomainManifest,
HousekeepingRouteDefinition,
} from "./domain";
export type {
HousekeepingInboxSource,
HousekeepingInboxSourceAvailability,
HousekeepingInboxSourceResult,
HousekeepingWorkItem,
HousekeepingWorkItemFreshness,
HousekeepingWorkItemSeverity,
} from "./inbox";
export type { HousekeepingQuery } from "./query";
export {
fail,
type HousekeepingErrorCode,
type HousekeepingResult,
ok,
} from "./result";
export type {
HousekeepingSearchInput,
HousekeepingSearchProvider,
HousekeepingSearchResult,
} from "./search";
export type {
HousekeepingWidgetDefinition,
HousekeepingWidgetKind,
} from "./widget";
@@ -0,0 +1,16 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export interface HousekeepingQuery<I, O> {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
run(
context: HousekeepingCapabilityContext,
input: I,
): Promise<HousekeepingResult<O>>;
}
@@ -0,0 +1,28 @@
export type HousekeepingErrorCode =
| "VALIDATION_FAILED"
| "AUTHENTICATION_REQUIRED"
| "CAPABILITY_DENIED"
| "NOT_FOUND"
| "CONFLICT"
| "DEPENDENCY_UNAVAILABLE"
| "PARTIAL_RESULT"
| "INTERNAL_ERROR";
export type HousekeepingResult<T> =
| { ok: true; data: T; correlationId: string }
| {
ok: false;
error: { code: HousekeepingErrorCode };
correlationId: string;
};
export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
return { ok: true, data, correlationId };
}
export function fail(
code: HousekeepingErrorCode,
correlationId: string,
): HousekeepingResult<never> {
return { ok: false, error: { code }, correlationId };
}
@@ -0,0 +1,28 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export interface HousekeepingSearchInput {
term: string;
limit: number;
}
export interface HousekeepingSearchResult {
id: string;
domain: HousekeepingDomainId;
title: string;
href: string;
}
export interface HousekeepingSearchProvider {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
search(
context: HousekeepingCapabilityContext,
input: HousekeepingSearchInput,
): Promise<HousekeepingResult<readonly HousekeepingSearchResult[]>>;
}
@@ -0,0 +1,18 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export type HousekeepingWidgetKind = "mandatory" | "optional";
export interface HousekeepingWidgetDefinition {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
kind: HousekeepingWidgetKind;
load(
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<unknown>>;
}
@@ -0,0 +1,619 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join, posix } from "node:path";
import { createElement, type ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { discoverLegacyPages } from "../migration/discover-legacy-pages";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
import { validateMigrationEntries } from "../migration/validate-matrix";
import { isHousekeepingPreviewEnabled } from "./preview-gate";
import { createHousekeepingRegistry } from "./registry";
import { CommandTrigger } from "./shell/command-trigger";
const HOUSEKEEPING_ROOT = "src/features/housekeeping";
const SERVER_CAPABILITY_CONTEXT =
"src/features/housekeeping/foundation/server-capability-context.ts";
const PERMISSIONS_ADAPTER = "src/lib/permissions";
const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains";
const forbiddenModuleRoots = [
"src/lib/db",
"src/lib/db-pool",
"src/lib/cached-db",
"src/db",
"src/generated/prisma",
"src/actions",
"src/app/actions",
"src/lib/auth",
"src/app/admin",
"src/app/mod",
"@prisma/client",
"drizzle-orm",
"mysql2",
"cmdk",
] as const;
const resolverExtensionPattern = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/i;
interface BabelNode {
type: string;
[key: string]: unknown;
}
interface BabelParser {
parse(
source: string,
options: {
createImportExpressions: boolean;
plugins: readonly ["typescript", "jsx"];
sourceType: "module";
},
): BabelNode;
}
interface ModuleAccess {
kind: "import" | "export" | "runtime";
importedNames: readonly string[];
specifier: string;
typeOnly: boolean;
}
interface ModuleAccessScan {
accesses: readonly ModuleAccess[];
violations: readonly string[];
}
interface CanonicalModuleSpecifier {
candidates: readonly string[];
violation: string | null;
}
const projectRequire = createRequire(import.meta.url);
const requireFromVitest = createRequire(
projectRequire.resolve("vitest/package.json"),
);
const babelParser = requireFromVitest("@babel/parser") as BabelParser;
const expressionWrapperTypes = new Set([
"ParenthesizedExpression",
"TSAsExpression",
"TSInstantiationExpression",
"TSNonNullExpression",
"TSSatisfiesExpression",
"TSTypeAssertion",
"TypeCastExpression",
]);
function runtimeSourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true })
.flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return runtimeSourceFiles(path);
if (
!/\.(?:ts|tsx)$/.test(entry.name) ||
entry.name.endsWith(".test.ts") ||
entry.name.endsWith(".test.tsx")
) {
return [];
}
return [path.replaceAll("\\", "/")];
})
.sort((a, b) => a.localeCompare(b));
}
function isBabelNode(value: unknown): value is BabelNode {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
);
}
function unwrapModuleArgument(node: unknown): BabelNode | null {
let current = isBabelNode(node) ? node : null;
while (current && expressionWrapperTypes.has(current.type)) {
current = isBabelNode(current.expression) ? current.expression : null;
}
return current;
}
function readLiteralModuleSpecifier(node: unknown): string | null {
const literal = unwrapModuleArgument(node);
if (!literal) return null;
if (literal.type === "StringLiteral" && typeof literal.value === "string") {
return literal.value;
}
if (literal.type !== "TemplateLiteral") return null;
const expressions = Array.isArray(literal.expressions)
? literal.expressions
: [];
const quasis = Array.isArray(literal.quasis) ? literal.quasis : [];
if (expressions.length !== 0 || quasis.length !== 1) return null;
const quasi = isBabelNode(quasis[0]) ? quasis[0] : null;
const value = quasi?.value;
return typeof value === "object" &&
value !== null &&
"cooked" in value &&
typeof value.cooked === "string"
? value.cooked
: null;
}
function memberPropertyName(node: BabelNode): string | null {
const property = unwrapModuleArgument(node.property);
if (!property) return null;
if (node.computed === true) return readLiteralModuleSpecifier(property);
return property.type === "Identifier" && typeof property.name === "string"
? property.name
: null;
}
function isGuardedRequireCallee(node: unknown): boolean {
const callee = unwrapModuleArgument(node);
if (!callee) return false;
if (callee.type === "Identifier" && callee.name === "require") return true;
if (
callee.type !== "MemberExpression" &&
callee.type !== "OptionalMemberExpression"
) {
return false;
}
const object = unwrapModuleArgument(callee.object);
const property = memberPropertyName(callee);
return (
(object?.type === "Identifier" &&
object.name === "module" &&
property === "require") ||
(object?.type === "Identifier" &&
object.name === "require" &&
property === "resolve")
);
}
function isTypeOnlyDeclaration(
node: BabelNode,
kind: "importKind" | "exportKind",
): boolean {
if (node[kind] === "type" || node[kind] === "typeof") return true;
const specifiers = Array.isArray(node.specifiers) ? node.specifiers : [];
return (
specifiers.length > 0 &&
specifiers.every((specifier) => {
const declaration = isBabelNode(specifier) ? specifier : null;
return (
declaration?.importKind === "type" || declaration?.exportKind === "type"
);
})
);
}
function namedImportNames(node: BabelNode): readonly string[] {
const specifiers = Array.isArray(node.specifiers) ? node.specifiers : [];
return specifiers.flatMap((specifier) => {
const declaration = isBabelNode(specifier) ? specifier : null;
if (declaration?.type !== "ImportSpecifier") return [];
const imported = unwrapModuleArgument(declaration.imported);
if (imported?.type === "Identifier" && typeof imported.name === "string") {
return [imported.name];
}
return imported?.type === "StringLiteral" &&
typeof imported.value === "string"
? [imported.value]
: [];
});
}
function scanModuleAccesses(source: string): ModuleAccessScan {
const root = babelParser.parse(source, {
createImportExpressions: true,
plugins: ["typescript", "jsx"],
sourceType: "module",
});
const accesses: ModuleAccess[] = [];
const violations: string[] = [];
function recordArgument(argument: unknown, kind: "import" | "require") {
const specifier = readLiteralModuleSpecifier(argument);
if (specifier === null) {
violations.push(`<non-literal ${kind}>`);
return;
}
accesses.push({
kind: "runtime",
importedNames: [],
specifier,
typeOnly: false,
});
}
function visit(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isBabelNode(value)) return;
if (value.type === "ImportDeclaration") {
const specifier = readLiteralModuleSpecifier(value.source);
if (specifier !== null) {
accesses.push({
kind: "import",
importedNames: namedImportNames(value),
specifier,
typeOnly: isTypeOnlyDeclaration(value, "importKind"),
});
}
} else if (
(value.type === "ExportNamedDeclaration" ||
value.type === "ExportAllDeclaration") &&
value.source !== null
) {
const specifier = readLiteralModuleSpecifier(value.source);
if (specifier !== null) {
accesses.push({
kind: "export",
importedNames: [],
specifier,
typeOnly: isTypeOnlyDeclaration(value, "exportKind"),
});
}
} else if (value.type === "ImportExpression") {
recordArgument(value.source, "import");
} else if (value.type === "TSImportType") {
recordArgument(value.argument, "import");
} else if (
value.type === "CallExpression" ||
value.type === "OptionalCallExpression"
) {
const arguments_ = Array.isArray(value.arguments) ? value.arguments : [];
if (isBabelNode(value.callee) && value.callee.type === "Import") {
recordArgument(arguments_[0], "import");
} else if (isGuardedRequireCallee(value.callee)) {
recordArgument(arguments_[0], "require");
}
} else if (value.type === "TSExternalModuleReference") {
recordArgument(value.expression, "require");
}
for (const [key, child] of Object.entries(value)) {
if (key !== "type") visit(child);
}
}
visit(root);
return { accesses, violations };
}
function canonicalizeModuleSpecifier(
sourceFile: string,
specifier: string,
): CanonicalModuleSpecifier {
const suffixIndex = specifier.search(/[?#]/);
const withoutSuffix =
suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
let decoded: string;
try {
decoded = decodeURIComponent(withoutSuffix).replaceAll("\\", "/");
} catch {
return { candidates: [], violation: "<malformed module specifier>" };
}
let normalized: string;
if (decoded.startsWith("@/")) {
normalized = posix.normalize(`src/${decoded.slice(2)}`);
} else if (decoded.startsWith(".")) {
normalized = posix.normalize(
posix.join(posix.dirname(sourceFile), decoded),
);
} else {
normalized = posix.normalize(decoded);
}
return {
candidates: [normalized.replace(resolverExtensionPattern, "")],
violation: null,
};
}
function isAtOrBelow(path: string, root: string): boolean {
return path === root || path.startsWith(`${root}/`);
}
function isDomainWorkflowModule(path: string): boolean {
if (!isAtOrBelow(path, DOMAIN_MODULE_ROOT)) return false;
return !/^src\/features\/housekeeping\/domains\/[^/]+\/manifest$/.test(path);
}
function isForbiddenModulePath(path: string, sourceFile: string): boolean {
if (isAtOrBelow(path, PERMISSIONS_ADAPTER)) {
return !(
sourceFile === SERVER_CAPABILITY_CONTEXT && path === PERMISSIONS_ADAPTER
);
}
return (
forbiddenModuleRoots.some((root) => isAtOrBelow(path, root)) ||
isDomainWorkflowModule(path)
);
}
function isAllowedPermissionSetTypeImport(
access: ModuleAccess,
canonical: CanonicalModuleSpecifier,
sourceFile: string,
): boolean {
return (
sourceFile ===
"src/features/housekeeping/foundation/capability-context.ts" &&
access.kind === "import" &&
access.typeOnly &&
access.importedNames.length === 1 &&
access.importedNames[0] === "PermissionSet" &&
canonical.candidates.includes(PERMISSIONS_ADAPTER)
);
}
function findHousekeepingImportBoundaryViolations(
source: string,
sourceFile: string,
): readonly string[] {
const scan = scanModuleAccesses(source);
const violations = [...scan.violations];
for (const access of scan.accesses) {
const canonical = canonicalizeModuleSpecifier(sourceFile, access.specifier);
if (canonical.violation) violations.push(canonical.violation);
if (isAllowedPermissionSetTypeImport(access, canonical, sourceFile))
continue;
const forbiddenPath = canonical.candidates.find((candidate) =>
isForbiddenModulePath(candidate, sourceFile),
);
if (forbiddenPath) violations.push(forbiddenPath);
}
return violations;
}
function executablePropNames(element: ReactElement): readonly string[] {
const props = element.props;
if (typeof props !== "object" || props === null) return [];
return Object.entries(props).flatMap(([name, value]) =>
/^on/i.test(name) && typeof value === "function" ? [name] : [],
);
}
describe("housekeeping runtime import boundary", () => {
it("keeps every runtime module inside the foundation boundary", () => {
for (const sourceFile of runtimeSourceFiles(HOUSEKEEPING_ROOT)) {
const source = readFileSync(sourceFile, "utf8");
expect(
findHousekeepingImportBoundaryViolations(source, sourceFile),
sourceFile,
).toEqual([]);
}
});
it.each([
[
"aliased database import",
"src/features/housekeeping/foundation/registry.ts",
'import { db } from "@/lib/db";',
"src/lib/db",
],
[
"aliased type-only database import",
"src/features/housekeeping/foundation/registry.ts",
'import type { Database } from "@/lib/db";',
"src/lib/db",
],
[
"aliased type-only action export",
"src/features/housekeeping/foundation/registry.ts",
'export type { ActionInput } from "@/actions/users";',
"src/actions/users",
],
[
"relative action import",
"src/features/housekeeping/foundation/registry.ts",
'import action from "../../../actions/users";',
"src/actions/users",
],
[
"direct auth export",
"src/features/housekeeping/foundation/registry.ts",
'export * from "@/lib/auth";',
"src/lib/auth",
],
[
"legacy route import",
"src/features/housekeeping/foundation/registry.ts",
'import page from "../../../app/admin/users/page";',
"src/app/admin/users/page",
],
[
"command package import",
"src/features/housekeeping/foundation/registry.ts",
'import { Command } from "cmdk";',
"cmdk",
],
[
"domain workflow import",
"src/features/housekeeping/foundation/registry.ts",
'import workflow from "../domains/people/workflow";',
"src/features/housekeeping/domains/people/workflow",
],
[
"TypeScript import type database access",
"src/features/housekeeping/foundation/registry.ts",
'type PrismaClient = import("@prisma/client").PrismaClient;',
"@prisma/client",
],
] as const)("detects %s", (_name, sourceFile, source, expectedPath) => {
expect(
findHousekeepingImportBoundaryViolations(source, sourceFile),
).toContain(expectedPath);
});
it.each([
[
"dynamic import",
"const modulePath = '@/lib/db'; import(modulePath);",
"<non-literal import>",
],
[
"CommonJS require",
"const modulePath = '@/actions/users'; require(modulePath);",
"<non-literal require>",
],
] as const)(
"fails closed for a non-literal %s",
(_name, source, expected) => {
expect(
findHousekeepingImportBoundaryViolations(
source,
"src/features/housekeeping/foundation/registry.ts",
),
).toContain(expected);
},
);
it("allows only the server capability adapter to import permissions", () => {
const runtimeSource =
'import { getAdminContext } from "@/lib/permissions";';
const typeOnlySource =
'import type { PermissionSet } from "@/lib/permissions";';
expect(
findHousekeepingImportBoundaryViolations(
typeOnlySource,
"src/features/housekeeping/foundation/capability-context.ts",
),
).toEqual([]);
expect(
findHousekeepingImportBoundaryViolations(
runtimeSource,
SERVER_CAPABILITY_CONTEXT,
),
).toEqual([]);
expect(
findHousekeepingImportBoundaryViolations(
runtimeSource,
"src/features/housekeeping/foundation/capability-context.ts",
),
).toContain(PERMISSIONS_ADAPTER);
expect(
findHousekeepingImportBoundaryViolations(
'import adapter from "@/lib/permissions/internal";',
SERVER_CAPABILITY_CONTEXT,
),
).toContain("src/lib/permissions/internal");
});
it("allows harmless lookalikes and the declared domain manifests", () => {
const source = [
'import database from "@/lib/database";',
'import authentication from "@/lib/authentication";',
'import commandKit from "cmdkit";',
'import manifest from "./domains/people/manifest";',
"const documentation = \"import db from '@/lib/db'\";",
'// import action from "@/actions/users";',
].join("\n");
expect(
findHousekeepingImportBoundaryViolations(
source,
"src/features/housekeeping/manifests.ts",
),
).toEqual([]);
});
it("allows a harmless type-only module lookalike", () => {
expect(
findHousekeepingImportBoundaryViolations(
'import type { DatabaseDocument } from "@/lib/database";',
"src/features/housekeeping/manifests.ts",
),
).toEqual([]);
});
it("allows a normalized domain manifest with a resolver extension", () => {
expect(
findHousekeepingImportBoundaryViolations(
'import manifest from "./domains/people/manifest.ts";',
"src/features/housekeeping/manifests.ts",
),
).toEqual([]);
});
});
describe("housekeeping foundation completion contracts", () => {
it("leaves the current and preview route entrypoints present", () => {
for (const path of [
"src/app/admin/layout.tsx",
"src/app/mod/layout.tsx",
"src/app/admin-next/layout.tsx",
]) {
expect(existsSync(path), path).toBe(true);
}
});
it("creates the real six-domain registry in locked order without workflows", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
expect(registry.domains.map((domain) => domain.id)).toEqual([
"operations",
"people",
"content",
"economy",
"hotel",
"system",
]);
expect(registry.domains.every((domain) => domain.routes.length === 0)).toBe(
true,
);
});
it("keeps production preview disabled even when the flag is true", () => {
expect(
isHousekeepingPreviewEnabled({ nodeEnv: "production", flag: true }),
).toBe(false);
});
it("detects executable React props before markup serialization", () => {
const mutatedTrigger = createElement(
"button",
{ onClick: () => undefined, type: "button" },
"mutation witness",
);
expect(executablePropNames(mutatedTrigger)).toEqual(["onClick"]);
});
it("renders one inert localized command affordance", () => {
const sentinel = "HK::comando-localizzato-disabilitato";
const trigger = CommandTrigger({ label: sentinel });
const html = renderToStaticMarkup(trigger);
const buttons = html.match(/<button\b[^>]*>/g) ?? [];
expect(executablePropNames(trigger)).toEqual([]);
expect(buttons).toHaveLength(1);
expect(buttons[0]).toMatch(/\sdisabled(?:=""|(?=\s|>))/);
expect(html).toContain(`>${sentinel}</button>`);
});
it("validates the complete 137-row migration matrix without issues", () => {
const discovered = discoverLegacyPages();
const issues = validateMigrationEntries(
discovered,
HOUSEKEEPING_MIGRATION_MATRIX,
);
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(137);
expect(discovered).toHaveLength(137);
expect(issues).toEqual([]);
});
});
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import en from "@/messages/en.json";
import itMessages from "@/messages/it.json";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
const requiredKeys = [
"pages.housekeeping.preview.badge",
"pages.housekeeping.preview.commandDisabled",
"pages.housekeeping.preview.backToSite",
"pages.housekeeping.navigation.skipToContent",
"pages.housekeeping.navigation.primary",
"pages.housekeeping.navigation.contextual",
"pages.housekeeping.states.loading.title",
"pages.housekeeping.states.loading.description",
"pages.housekeeping.states.empty.title",
"pages.housekeeping.states.empty.description",
"pages.housekeeping.states.partial.label",
"pages.housekeeping.states.partial.title",
"pages.housekeeping.states.partial.description",
"pages.housekeeping.states.error.title",
"pages.housekeeping.states.error.description",
];
const expectedDomainMessages = [
{
id: "operations",
labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description",
},
{
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
},
{
id: "content",
labelKey: "pages.housekeeping.domains.content.title",
descriptionKey: "pages.housekeeping.domains.content.description",
},
{
id: "economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
},
{
id: "hotel",
labelKey: "pages.housekeeping.domains.hotel.title",
descriptionKey: "pages.housekeeping.domains.hotel.description",
},
{
id: "system",
labelKey: "pages.housekeeping.domains.system.title",
descriptionKey: "pages.housekeeping.domains.system.description",
},
] as const;
function resolveMessage(messages: unknown, key: string): unknown {
return key.split(".").reduce<unknown>((value, segment) => {
if (value === null || typeof value !== "object") return undefined;
return (value as Record<string, unknown>)[segment];
}, messages);
}
describe("housekeeping localization contract", () => {
it.each([
["English", en],
["Italian", itMessages],
])("provides the planned housekeeping subtree in %s", (_locale, messages) => {
const housekeeping = resolveMessage(messages, "pages.housekeeping");
expect(housekeeping).toEqual(expect.any(Object));
expect(Object.keys(housekeeping as object)).toEqual([
"preview",
"navigation",
"domains",
"states",
]);
for (const key of requiredKeys) {
expect(resolveMessage(messages, key), key).toEqual(expect.any(String));
}
for (const [index, expected] of expectedDomainMessages.entries()) {
const manifest = HOUSEKEEPING_MANIFESTS[index];
expect(manifest).toMatchObject(expected);
expect(
resolveMessage(messages, expected.labelKey),
expected.labelKey,
).toEqual(expect.any(String));
expect(
resolveMessage(messages, expected.descriptionKey),
expected.descriptionKey,
).toEqual(expect.any(String));
}
});
it("uses idiomatic Italian copy for the housekeeping shell", () => {
expect(resolveMessage(itMessages, "pages.housekeeping.preview.badge")).toBe(
"Anteprima della struttura",
);
expect(
resolveMessage(itMessages, "pages.housekeeping.navigation.primary"),
).toBe("Aree housekeeping");
expect(
resolveMessage(itMessages, "pages.housekeeping.states.loading.title"),
).toBe("Caricamento in corso");
expect(
resolveMessage(
itMessages,
"pages.housekeeping.states.loading.description",
),
).toBe("Stiamo preparando gli strumenti di housekeeping disponibili.");
});
});
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { anyCapability, type HousekeepingCapabilityContext } from "./contracts";
import { buildHousekeepingNavigation } from "./navigation";
import { createHousekeepingRegistry } from "./registry";
const context = (
granted: readonly string[],
): HousekeepingCapabilityContext => ({
actor: { id: 42, username: "moderator", rank: 3 },
isSuperAdmin: false,
has: (slug) => granted.includes(slug),
hasAny: (...slugs) => slugs.some((slug) => granted.includes(slug)),
hasAll: (...slugs) => slugs.every((slug) => granted.includes(slug)),
});
describe("housekeeping navigation", () => {
it("shows People to a moderator with a mod view capability while hiding Economy", () => {
const registry = createHousekeepingRegistry([
{
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
previewHref: "/admin-next/people",
capability: anyCapability(PERMS.MOD_CFH_VIEW),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
{
id: "economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
iconId: "gem",
previewHref: "/admin-next/economy",
capability: anyCapability(PERMS.CATALOG_VIEW),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
]);
const navigation = buildHousekeepingNavigation(
registry,
context([PERMS.MOD_CFH_VIEW]),
(key) => key,
);
expect(navigation).toEqual([
{
id: "people",
href: "/admin-next/people",
iconId: "users",
label: "pages.housekeeping.domains.people.title",
description: "pages.housekeeping.domains.people.description",
items: [],
},
]);
});
it("filters unauthorized domains and routes before translation", () => {
const registry = createHousekeepingRegistry([
{
id: "people",
labelKey: "people.title",
descriptionKey: "people.description",
iconId: "users",
previewHref: "/admin-next/people",
capability: anyCapability(PERMS.USERS_VIEW),
routes: [
{
id: "users",
labelKey: "people.users",
href: "/admin-next/people/users",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "bans",
labelKey: "people.bans",
href: "/admin-next/people/bans",
capability: anyCapability(PERMS.BANS_VIEW),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
},
{
id: "system",
labelKey: "system.title",
descriptionKey: "system.description",
iconId: "settings",
previewHref: "/admin-next/system",
capability: anyCapability(PERMS.SETTINGS_VIEW),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
]);
const translate = vi.fn((key: string) => `translated:${key}`);
const navigation = buildHousekeepingNavigation(
registry,
context([PERMS.USERS_VIEW]),
translate,
);
expect(navigation).toEqual([
{
id: "people",
href: "/admin-next/people",
iconId: "users",
label: "translated:people.title",
description: "translated:people.description",
items: [
{
id: "users",
href: "/admin-next/people/users",
label: "translated:people.users",
},
],
},
]);
expect(translate).not.toHaveBeenCalledWith("people.bans");
expect(translate).not.toHaveBeenCalledWith("system.title");
expect(translate).not.toHaveBeenCalledWith("system.description");
});
it("shows real domains to operators authorized by owned migration capabilities", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const translate = (key: string) => key;
expect(
buildHousekeepingNavigation(
registry,
context([PERMS.MOD_CFH_VIEW]),
translate,
).map((domain) => domain.id),
).toContain("people");
expect(
buildHousekeepingNavigation(
registry,
context([PERMS.MOD_CFH_VIEW]),
translate,
).map((domain) => domain.id),
).not.toContain("economy");
for (const [slug, expectedDomain] of [
[PERMS.MOD_ACTIONS, "people"],
[PERMS.USERS_EDIT, "people"],
[PERMS.SETTINGS_VIEW, "people"],
[PERMS.NEWS_EDIT, "content"],
[PERMS.SHOP_EDIT, "economy"],
[PERMS.ROOMS_EDIT, "hotel"],
] as const) {
const visibleDomainIds = buildHousekeepingNavigation(
registry,
context([slug]),
translate,
).map((domain) => domain.id);
expect(visibleDomainIds, slug).toContain(expectedDomain);
}
});
});
@@ -0,0 +1,39 @@
import type { HousekeepingDomainId } from "../migration/types";
import { satisfiesCapability } from "./capability-context";
import type {
HousekeepingCapabilityContext,
HousekeepingDomainManifest,
} from "./contracts";
import type { HousekeepingRegistry } from "./registry";
export interface HousekeepingNavigationDomain {
id: HousekeepingDomainId;
href: string;
iconId: HousekeepingDomainManifest["iconId"];
label: string;
description: string;
items: readonly { id: string; href: string; label: string }[];
}
export function buildHousekeepingNavigation(
registry: HousekeepingRegistry,
context: HousekeepingCapabilityContext,
translate: (key: string) => string,
): readonly HousekeepingNavigationDomain[] {
return registry.domains
.filter((domain) => satisfiesCapability(context, domain.capability))
.map((domain) => ({
id: domain.id,
href: domain.previewHref,
iconId: domain.iconId,
label: translate(domain.labelKey),
description: translate(domain.descriptionKey),
items: domain.routes
.filter((route) => satisfiesCapability(context, route.capability))
.map((route) => ({
id: route.id,
href: route.href,
label: translate(route.labelKey),
})),
}));
}
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
interface HousekeepingPageShellProps {
title: string;
description: string;
primaryAction?: ReactNode;
context?: ReactNode;
children: ReactNode;
}
export function HousekeepingPageShell({
title,
description,
primaryAction,
context,
children,
}: HousekeepingPageShellProps) {
return (
<section className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--admin-border)] pb-4">
<div className="min-w-0 space-y-1">
<h1 className="text-xl font-semibold text-[var(--admin-text)]">
{title}
</h1>
<p className="text-sm text-[var(--admin-text-muted)]">
{description}
</p>
{context ? <div>{context}</div> : null}
</div>
{primaryAction ? <div>{primaryAction}</div> : null}
</header>
<div>{children}</div>
</section>
);
}
@@ -0,0 +1,109 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import en from "@/messages/en.json";
import itMessages from "@/messages/it.json";
import { HousekeepingPageShell } from "./housekeeping-page-shell";
import { HousekeepingPageState } from "./housekeeping-page-state";
describe("HousekeepingPageState", () => {
it.each([
["loading", "status"],
["empty", "status"],
["partial", "status"],
["error", "alert"],
] as const)("renders %s with the %s role", (state, role) => {
const pageState =
state === "partial" ? (
<HousekeepingPageState
state="partial"
partialLabel="Partial data"
title={`${state} title`}
description={`${state} description`}
/>
) : (
<HousekeepingPageState
state={state}
title={`${state} title`}
description={`${state} description`}
/>
);
const html = renderToStaticMarkup(pageState);
expect(html).toContain(`role="${role}"`);
expect(html).toContain(`>${state} title<`);
expect(html).toContain(`>${state} description<`);
});
it("announces loading politely", () => {
const loading = renderToStaticMarkup(
<HousekeepingPageState
state="loading"
title="Loading title"
description="Loading description"
/>,
);
expect(loading).toContain('aria-live="polite"');
});
it.each([
["English", en.pages.housekeeping.states.partial],
["Italian", itMessages.pages.housekeeping.states.partial],
] as const)(
"preserves the partial-state heading and separate localized warning in %s",
(_locale, copy) => {
const html = renderToStaticMarkup(
<HousekeepingPageState
state="partial"
partialLabel={copy.label}
title={copy.title}
description={copy.description}
/>,
);
expect(html).toMatch(new RegExp(`<h2[^>]*>${copy.title}</h2>`));
expect(html).toContain(`>${copy.label}</p>`);
expect(html).toContain('data-state-tone="warning"');
expect(html).toContain("--admin-warning-border");
expect(html).toContain("--admin-warning-subtle");
},
);
it("renders supplied retry content for an error without owning a callback", () => {
const html = renderToStaticMarkup(
<HousekeepingPageState
state="error"
title="Could not load"
description="Try again later"
retryAction={<a href="/admin-next/operations">Retry preview</a>}
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain('href="/admin-next/operations"');
expect(html).toContain(">Retry preview<");
});
});
describe("HousekeepingPageShell", () => {
it("renders supplied header, context, action, and body content", () => {
const html = renderToStaticMarkup(
<HousekeepingPageShell
title="People"
description="Review operator-facing people data"
context={<span>Preview context</span>}
primaryAction={<a href="/admin-next/people/new">Create preview</a>}
>
<p>Page body</p>
</HousekeepingPageShell>,
);
expect(html).toContain("<header");
expect(html).toContain(">People<");
expect(html).toContain(">Review operator-facing people data<");
expect(html).toContain(">Preview context<");
expect(html).toContain('href="/admin-next/people/new"');
expect(html).toContain(">Create preview<");
expect(html).toContain(">Page body</p>");
});
});
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
interface HousekeepingPageStateBaseProps {
title: string;
description: string;
retryAction?: ReactNode;
}
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
(
| { state: "partial"; partialLabel: string }
| { state: "loading" | "empty" | "error"; partialLabel?: never }
);
export function HousekeepingPageState({
state,
partialLabel,
title,
description,
retryAction,
}: HousekeepingPageStateProps) {
const isError = state === "error";
const isPartial = state === "partial";
return (
<section
role={isError ? "alert" : "status"}
aria-live={state === "loading" ? "polite" : undefined}
className={`rounded-lg border p-4 ${
isError
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
: isPartial
? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)]"
}`}
>
{isPartial ? (
<p
data-state-tone="warning"
className="mb-2 inline-flex rounded-full border border-[var(--admin-warning-border)] bg-[var(--admin-surface)] px-2 py-0.5 text-xs font-medium text-[var(--admin-warning)]"
>
{partialLabel}
</p>
) : null}
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{description}
</p>
{retryAction ? <div className="mt-3">{retryAction}</div> : null}
</section>
);
}
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isHousekeepingPreviewEnabled } from "./preview-gate";
describe("isHousekeepingPreviewEnabled", () => {
it.each([
["development", true, true],
["test", true, true],
["development", false, false],
["production", true, false],
["production", false, false],
] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => {
expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected);
});
});
describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
const originalSkipValidation = process.env.SKIP_ENV_VALIDATION;
const originalPreviewFlag = process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
beforeEach(() => {
vi.resetModules();
delete process.env.SKIP_ENV_VALIDATION;
});
afterEach(() => {
if (originalSkipValidation === undefined) {
delete process.env.SKIP_ENV_VALIDATION;
} else {
process.env.SKIP_ENV_VALIDATION = originalSkipValidation;
}
if (originalPreviewFlag === undefined) {
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
} else {
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = originalPreviewFlag;
}
vi.resetModules();
});
it.each([
["true", true],
["1", true],
["false", false],
["yes", false],
[undefined, false],
] as const)("normalizes %s to %s", async (value, expected) => {
if (value === undefined) {
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
} else {
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = value;
}
const { env } = await import("@/env");
expect(env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED).toBe(expected);
});
});
@@ -0,0 +1,6 @@
export function isHousekeepingPreviewEnabled(input: {
nodeEnv: "development" | "test" | "production";
flag: boolean;
}): boolean {
return input.nodeEnv !== "production" && input.flag;
}
@@ -0,0 +1,859 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { posix, resolve } from "node:path";
import { createElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "./contracts";
const routeMocks = vi.hoisted(() => {
const messages: Record<string, string> = {
"preview.badge": "HK::preview-badge",
"preview.commandDisabled": "HK::command-disabled",
"preview.backToSite": "HK::back-to-site",
"navigation.skipToContent": "HK::skip-to-content",
"navigation.primary": "HK::primary-navigation",
"navigation.contextual": "HK::contextual-navigation",
"domains.people.title": "HK::people-title",
"domains.people.description": "Localized People description",
"domains.economy.title": "Localized Economy",
"domains.economy.description": "Localized Economy description",
"states.empty.title": "Localized empty title",
"states.empty.description": "Localized empty description",
};
const translate = vi.fn((key: string) => {
const message = messages[key];
if (message === undefined)
throw new Error(`Unexpected translation: ${key}`);
return message;
});
return {
env: {
NODE_ENV: "test" as "development" | "test" | "production",
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
},
getHousekeepingCapabilityContext: vi.fn(),
getTranslations: vi.fn(async (namespace: string) => {
if (namespace !== "pages.housekeeping") {
throw new Error(`Unexpected namespace: ${namespace}`);
}
return translate;
}),
notFound: vi.fn((): never => {
throw new Error("NEXT_NOT_FOUND");
}),
redirect: vi.fn((href: string): never => {
throw new Error(`NEXT_REDIRECT:${href}`);
}),
translate,
};
});
vi.mock("@/env", () => ({ env: routeMocks.env }));
vi.mock("next/navigation", () => ({
notFound: routeMocks.notFound,
redirect: routeMocks.redirect,
}));
vi.mock("next-intl/server", () => ({
getTranslations: routeMocks.getTranslations,
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
}));
vi.mock("@/lib/db", () => {
throw new Error("preview routes must not import the database");
});
vi.mock("@/lib/auth", () => {
throw new Error("preview routes must not call auth directly");
});
vi.mock("@/lib/permissions", () => {
throw new Error("preview routes must not reload permissions directly");
});
vi.mock("@/actions", () => {
throw new Error("preview routes must not import actions");
});
vi.mock("@/app/actions", () => {
throw new Error("preview routes must not import actions");
});
import AdminNextDomainLayout from "@/app/admin-next/[domain]/layout";
import AdminNextDomainPage from "@/app/admin-next/[domain]/page";
import AdminNextLayout from "@/app/admin-next/layout";
import AdminNextPage from "@/app/admin-next/page";
const routeFiles = [
"src/app/admin-next/layout.tsx",
"src/app/admin-next/page.tsx",
"src/app/admin-next/[domain]/layout.tsx",
"src/app/admin-next/[domain]/page.tsx",
] as const;
const forbiddenModuleRoots = [
"src/lib/db",
"src/lib/auth",
"src/lib/permissions",
"src/actions",
"src/app/actions",
"src/app/admin",
"src/app/mod",
"@prisma/client",
"drizzle-orm",
"mysql2",
] as const;
interface BabelNode {
type: string;
[key: string]: unknown;
}
interface BabelParser {
parse(
source: string,
options: {
createImportExpressions: boolean;
plugins: readonly ["typescript", "jsx"];
sourceType: "module";
},
): BabelNode;
}
interface ModuleAccessScan {
specifiers: readonly string[];
violations: readonly string[];
}
const projectRequire = createRequire(import.meta.url);
const requireFromVitest = createRequire(
projectRequire.resolve("vitest/package.json"),
);
const babelParser = requireFromVitest("@babel/parser") as BabelParser;
const expressionWrapperTypes = new Set([
"ParenthesizedExpression",
"TSAsExpression",
"TSInstantiationExpression",
"TSNonNullExpression",
"TSSatisfiesExpression",
"TSTypeAssertion",
"TypeCastExpression",
]);
const resolverExtensionPattern = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/i;
function isBabelNode(value: unknown): value is BabelNode {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
);
}
function unwrapModuleArgument(node: unknown): BabelNode | null {
let current = isBabelNode(node) ? node : null;
while (current && expressionWrapperTypes.has(current.type)) {
current = isBabelNode(current.expression) ? current.expression : null;
}
return current;
}
function readLiteralModuleSpecifier(node: unknown): string | null {
const literal = unwrapModuleArgument(node);
if (!literal) return null;
if (literal.type === "StringLiteral" && typeof literal.value === "string") {
return literal.value;
}
if (literal.type !== "TemplateLiteral") return null;
const expressions = Array.isArray(literal.expressions)
? literal.expressions
: [];
const quasis = Array.isArray(literal.quasis) ? literal.quasis : [];
if (expressions.length !== 0 || quasis.length !== 1) return null;
const quasi = isBabelNode(quasis[0]) ? quasis[0] : null;
const value = quasi?.value;
if (
typeof value === "object" &&
value !== null &&
"cooked" in value &&
typeof value.cooked === "string"
) {
return value.cooked;
}
return null;
}
function memberPropertyName(node: BabelNode): string | null {
const property = unwrapModuleArgument(node.property);
if (!property) return null;
if (node.computed === true) return readLiteralModuleSpecifier(property);
return property.type === "Identifier" && typeof property.name === "string"
? property.name
: null;
}
function isGuardedRequireCallee(node: unknown): boolean {
const callee = unwrapModuleArgument(node);
if (!callee) return false;
if (callee.type === "Identifier" && callee.name === "require") return true;
if (
callee.type !== "MemberExpression" &&
callee.type !== "OptionalMemberExpression"
) {
return false;
}
const object = unwrapModuleArgument(callee.object);
const property = memberPropertyName(callee);
return (
(object?.type === "Identifier" &&
object.name === "module" &&
property === "require") ||
(object?.type === "Identifier" &&
object.name === "require" &&
property === "resolve")
);
}
function scanModuleAccesses(source: string): ModuleAccessScan {
const root = babelParser.parse(source, {
createImportExpressions: true,
plugins: ["typescript", "jsx"],
sourceType: "module",
});
const specifiers: string[] = [];
const violations: string[] = [];
function recordArgument(argument: unknown, kind: "import" | "require") {
const specifier = readLiteralModuleSpecifier(argument);
if (specifier === null) {
violations.push(`<non-literal ${kind}>`);
return;
}
specifiers.push(specifier);
}
function visit(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isBabelNode(value)) return;
if (value.type === "ImportDeclaration") {
const specifier = readLiteralModuleSpecifier(value.source);
if (specifier !== null) specifiers.push(specifier);
} else if (
(value.type === "ExportNamedDeclaration" ||
value.type === "ExportAllDeclaration") &&
value.source !== null
) {
const specifier = readLiteralModuleSpecifier(value.source);
if (specifier !== null) specifiers.push(specifier);
} else if (value.type === "ImportExpression") {
recordArgument(value.source, "import");
} else if (value.type === "TSImportType") {
recordArgument(value.argument, "import");
} else if (
value.type === "CallExpression" ||
value.type === "OptionalCallExpression"
) {
const arguments_ = Array.isArray(value.arguments) ? value.arguments : [];
if (isBabelNode(value.callee) && value.callee.type === "Import") {
recordArgument(arguments_[0], "import");
} else if (isGuardedRequireCallee(value.callee)) {
recordArgument(arguments_[0], "require");
}
} else if (value.type === "TSExternalModuleReference") {
recordArgument(value.expression, "require");
}
for (const [key, child] of Object.entries(value)) {
if (key !== "type") visit(child);
}
}
visit(root);
return { specifiers, violations };
}
interface CanonicalLocalSpecifier {
candidates: readonly string[];
violation: string | null;
}
function canonicalizeLocalSpecifier(
routeFile: string,
specifier: string,
): CanonicalLocalSpecifier {
const suffixIndex = specifier.search(/[?#]/);
const withoutSuffix =
suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
let decoded: string;
try {
decoded = decodeURIComponent(withoutSuffix).replaceAll("\\", "/");
} catch {
return { candidates: [], violation: "<malformed local specifier>" };
}
let normalized: string;
if (decoded.startsWith("@/")) {
normalized = posix.normalize(`src/${decoded.slice(2)}`);
} else if (decoded.startsWith(".")) {
normalized = posix.normalize(posix.join(posix.dirname(routeFile), decoded));
} else {
normalized = posix.normalize(decoded);
}
const extensionless = normalized.replace(resolverExtensionPattern, "");
return {
candidates:
decoded.startsWith("@/") || decoded.startsWith(".")
? [...new Set([normalized, extensionless])]
: [normalized],
violation: null,
};
}
function isForbiddenModulePath(path: string): boolean {
return forbiddenModuleRoots.some(
(root) => path === root || path.startsWith(`${root}/`),
);
}
function findRouteImportBoundaryViolations(
source: string,
routeFile: string,
): readonly string[] {
const scan = scanModuleAccesses(source);
const violations = [...scan.violations];
const forbiddenPaths: string[] = [];
for (const specifier of scan.specifiers) {
const canonical = canonicalizeLocalSpecifier(routeFile, specifier);
if (canonical.violation) violations.push(canonical.violation);
const forbiddenPath = canonical.candidates.find(isForbiddenModulePath);
if (forbiddenPath) forbiddenPaths.push(forbiddenPath);
}
return [...violations, ...forbiddenPaths];
}
function capabilityContext(
granted: readonly string[],
actor = { id: 42, username: "refreshed-moderator", rank: 3 },
): HousekeepingCapabilityContext {
const capabilities = new Set(granted);
return {
actor,
isSuperAdmin: false,
has: (slug) => capabilities.has(slug),
hasAny: (...slugs) => slugs.some((slug) => capabilities.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => capabilities.has(slug)),
};
}
async function renderRoute(route: ReactNode | Promise<ReactNode>) {
return renderToStaticMarkup(await route);
}
describe("/admin-next preview gate", () => {
beforeEach(() => {
vi.clearAllMocks();
routeMocks.env.NODE_ENV = "test";
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true;
});
it.each([
["production", true],
["production", false],
["development", false],
] as const)("returns 404 for NODE_ENV=%s flag=%s", async (nodeEnv, flag) => {
routeMocks.env.NODE_ENV = nodeEnv;
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = flag;
await expect(async () =>
renderRoute(
AdminNextLayout({
children: createElement("p", null, "Preview child"),
}),
),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
});
it.each(["development", "test"] as const)(
"renders children in %s when explicitly enabled",
async (nodeEnv) => {
routeMocks.env.NODE_ENV = nodeEnv;
const html = await renderRoute(
AdminNextLayout({
children: createElement("p", null, "Preview child"),
}),
);
expect(html).toContain("Preview child");
expect(routeMocks.notFound).not.toHaveBeenCalled();
},
);
});
describe("/admin-next first visible domain", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("redirects an administrator to Operations in locked registry order", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
);
await expect(AdminNextPage()).rejects.toThrow(
"NEXT_REDIRECT:/admin-next/operations",
);
expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/operations");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
it("redirects a moderator with only an approved mod view capability to People", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.MOD_CFH_VIEW]),
);
await expect(AdminNextPage()).rejects.toThrow(
"NEXT_REDIRECT:/admin-next/people",
);
expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/people");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
});
it("returns 404 when the operator has no visible domain", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([]),
);
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
expect(routeMocks.redirect).not.toHaveBeenCalled();
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
});
});
describe("/admin-next/[domain] layout", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects an unknown domain before loading capability context", async () => {
await expect(
AdminNextDomainLayout({
children: createElement("p", null, "Unknown body"),
params: Promise.resolve({ domain: "unknown" }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
it("rejects a known domain that the operator cannot access", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.MOD_CFH_VIEW]),
);
await expect(
AdminNextDomainLayout({
children: createElement("p", null, "Economy body"),
params: Promise.resolve({ domain: "economy" }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
it("renders localized People shell from one refreshed capability context", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.MOD_CFH_VIEW]),
);
const html = await renderRoute(
AdminNextDomainLayout({
children: createElement("p", null, "People body"),
params: Promise.resolve({ domain: "people" }),
}),
);
expect(html).toContain("refreshed-moderator");
expect(html).toContain("HK::skip-to-content");
expect(html).toContain("HK::primary-navigation");
expect(html).toContain("HK::contextual-navigation");
expect(html).toContain("HK::command-disabled");
expect(html).toContain("HK::preview-badge");
expect(html).toContain("HK::back-to-site");
expect(html).toContain("HK::people-title");
expect(html).toContain("People body");
expect(html).not.toContain("Localized Economy");
expect(routeMocks.translate).not.toHaveBeenCalledWith(
"domains.economy.title",
);
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
});
});
describe("/admin-next/[domain] page", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the real localized manifest and empty state without reloading access", async () => {
const html = await renderRoute(
AdminNextDomainPage({
params: Promise.resolve({ domain: "people" }),
}),
);
expect(html).toContain("HK::people-title");
expect(html).toContain("Localized People description");
expect(html).toContain("Localized empty title");
expect(html).toContain("Localized empty description");
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
});
it("rejects an unknown domain before translating", async () => {
await expect(
AdminNextDomainPage({
params: Promise.resolve({ domain: "unknown" }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
});
describe("preview route import boundary", () => {
it("rejects normalized forbidden imports and legacy chrome in real routes", () => {
for (const path of routeFiles) {
const source = readFileSync(resolve(process.cwd(), path), "utf8");
expect(findRouteImportBoundaryViolations(source, path), path).toEqual([]);
expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/);
}
});
const interpolationOpen = "$" + "{";
it.each([
[
"relative database import with resolver extension",
"src/app/admin-next/page.tsx",
'import db from "../../lib/db.js";',
"src/lib/db",
],
[
"aliased database import with resolver extension",
"src/app/admin-next/page.tsx",
'import db from "@/lib/db.js";',
"src/lib/db",
],
[
"action root import with resolver extension",
"src/app/admin-next/page.tsx",
'import actions from "../../actions.mjs";',
"src/actions",
],
[
"legacy mod root import with resolver extension",
"src/app/admin-next/layout.tsx",
'import mod from "../mod.cjs";',
"src/app/mod",
],
[
"database import with query suffix",
"src/app/admin-next/page.tsx",
'import db from "../../lib/db?server-only";',
"src/lib/db",
],
[
"action import with hash suffix",
"src/app/admin-next/page.tsx",
'import("../../actions/users#server")',
"src/actions/users",
],
[
"Windows-style relative database import",
"src/app/admin-next/page.tsx",
String.raw`import db from "..\\..\\lib\\db";`,
"src/lib/db",
],
[
"Windows-style aliased database import",
"src/app/admin-next/page.tsx",
String.raw`import db from "@\\lib\\db";`,
"src/lib/db",
],
[
"percent-encoded database import",
"src/app/admin-next/page.tsx",
'import db from "../../lib/%64%62";',
"src/lib/db",
],
[
"optional CommonJS database require",
"src/app/admin-next/page.tsx",
'require?.("../../lib/db")',
"src/lib/db",
],
[
"module database require",
"src/app/admin-next/page.tsx",
'module.require("../../lib/db")',
"src/lib/db",
],
[
"require.resolve database access",
"src/app/admin-next/page.tsx",
'require.resolve("../../lib/db")',
"src/lib/db",
],
[
"optional module database require",
"src/app/admin-next/page.tsx",
'module.require?.("../../lib/db")',
"src/lib/db",
],
[
"optional require.resolve database access",
"src/app/admin-next/page.tsx",
'require.resolve?.("../../lib/db")',
"src/lib/db",
],
[
"TypeScript import-equals database access",
"src/app/admin-next/page.tsx",
'import db = require("../../lib/db");',
"src/lib/db",
],
[
"U+2028 line-continuation database import",
"src/app/admin-next/page.tsx",
'import db from "../\\' + "\u2028" + '../lib/db";',
"src/lib/db",
],
[
"U+2029 line-continuation database import",
"src/app/admin-next/page.tsx",
'import db from "../\\' + "\u2029" + '../lib/db";',
"src/lib/db",
],
[
"parenthesized dynamic action import",
"src/app/admin-next/page.tsx",
'import(("../../actions/users"))',
"src/actions/users",
],
[
"regex-brace template-expression action import",
"src/app/admin-next/page.tsx",
`const x = \`${interpolationOpen}/}/.test(value) ? import("../../actions/users") : null}\`;`,
"src/actions/users",
],
[
"CommonJS database require",
"src/app/admin-next/page.tsx",
'require("../../lib/db")',
"src/lib/db",
],
[
"template-literal dynamic action import",
"src/app/admin-next/page.tsx",
"import(`../../actions/users`)",
"src/actions/users",
],
[
"TypeScript-asserted dynamic action import",
"src/app/admin-next/page.tsx",
'import(("../../actions/users" as string))',
"src/actions/users",
],
[
"template-expression dynamic action import",
"src/app/admin-next/page.tsx",
`const x = \`${interpolationOpen}import("../../actions/users")}\`;`,
"src/actions/users",
],
[
"nested template-expression dynamic action import",
"src/app/admin-next/[domain]/page.tsx",
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../actions/nested")}\` : ""}\`;`,
"src/actions/nested",
],
[
"unicode escaped dynamic app-action import",
"src/app/admin-next/page.tsx",
'import("\\u002e\\u002e/actions/users")',
"src/app/actions/users",
],
[
"code-point escaped dynamic app-action import",
"src/app/admin-next/page.tsx",
'import("\\u{2e}\\u{2e}/actions/users")',
"src/app/actions/users",
],
[
"hex escaped export-from auth import",
"src/app/admin-next/page.tsx",
'export * from "\\x2e\\x2e/\\x2e\\x2e/lib/auth";',
"src/lib/auth",
],
[
"escaped-slash permissions import",
"src/app/admin-next/page.tsx",
'import permissions from "..\\/..\\/lib\\/permissions";',
"src/lib/permissions",
],
[
"unknown escape database import",
"src/app/admin-next/page.tsx",
'import db from "../../\\lib/db";',
"src/lib/db",
],
[
"line-continuation database import",
"src/app/admin-next/page.tsx",
'import db from "../\\' + "\n" + '../lib/db";',
"src/lib/db",
],
[
"aliased database descendant import",
"src/app/admin-next/page.tsx",
'import { query } from "@/lib/db/query";',
"src/lib/db/query",
],
[
"root relative database import",
"src/app/admin-next/page.tsx",
'import { db } from "../../lib/db";',
"src/lib/db",
],
[
"domain relative auth side-effect import",
"src/app/admin-next/[domain]/layout.tsx",
'import "../../../lib/auth";',
"src/lib/auth",
],
[
"domain relative permissions export",
"src/app/admin-next/[domain]/page.tsx",
'export { getAdminContext } from "../../../lib/permissions";',
"src/lib/permissions",
],
[
"root relative action dynamic import",
"src/app/admin-next/page.tsx",
'import("../../actions/users")',
"src/actions/users",
],
[
"root relative app action export",
"src/app/admin-next/page.tsx",
'export * from "../actions";',
"src/app/actions",
],
[
"domain relative legacy admin import",
"src/app/admin-next/[domain]/layout.tsx",
'import page from "../../admin/users/page";',
"src/app/admin/users/page",
],
[
"root relative legacy mod dynamic import",
"src/app/admin-next/layout.tsx",
'import("../mod/users/page")',
"src/app/mod/users/page",
],
[
"Prisma TypeScript import type",
"src/app/admin-next/page.tsx",
'type PrismaClient = import("@prisma/client").PrismaClient;',
"@prisma/client",
],
[
"Drizzle package import",
"src/app/admin-next/page.tsx",
'import { sql } from "drizzle-orm";',
"drizzle-orm",
],
[
"mysql2 package import",
"src/app/admin-next/page.tsx",
'import type { Pool } from "mysql2";',
"mysql2",
],
] as const)("detects %s", (_name, routeFile, source, expectedPath) => {
expect(findRouteImportBoundaryViolations(source, routeFile)).toContain(
expectedPath,
);
});
it.each([
[
"optional CommonJS require",
"const path = '../../lib/db'; require?.(path)",
"<non-literal require>",
],
[
"malformed local percent escape",
'import db from "../../lib/db%ZZ";',
"<malformed local specifier>",
],
[
"dynamic import",
"const path = '../../actions/users'; import(path)",
"<non-literal import>",
],
[
"CommonJS require",
"const path = '../../lib/db'; require(path)",
"<non-literal require>",
],
] as const)("fails closed for non-literal %s", (_name, source, violation) => {
expect(
findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"),
).toContain(violation);
});
it("does not reject substring lookalikes, comments, or ordinary strings", () => {
const source = [
'import database from "@/lib/database.js?raw";',
'import dbTools from "../../lib/db-tools.ts";',
'import auth from "../../lib/authentication";',
'import preview from "../admin-next-shared";',
'import prismaTools from "@prisma/client-tools";',
'import drizzleTools from "drizzle-orm-kit";',
'import mysqlTools from "mysql2-wrapper";',
"const documentation = \"import db from '../../lib/db'\";",
'const rawTemplate = `import("../../actions/users")`;',
'// import db from "../../lib/db";',
].join("\n");
expect(
findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"),
).toEqual([]);
});
});
@@ -0,0 +1,520 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
import {
HOUSEKEEPING_DOMAIN_IDS,
type HousekeepingDomainId,
} from "../migration/types";
import {
anyCapability,
type CapabilityRequirement,
type HousekeepingDomainManifest,
type HousekeepingInboxSource,
type HousekeepingSearchProvider,
type HousekeepingWidgetDefinition,
ok,
} from "./contracts";
import { createHousekeepingRegistry } from "./registry";
const manifest = (
id: HousekeepingDomainId,
capabilitySlug = PERMS.ADMIN_DASHBOARD,
): HousekeepingDomainManifest => ({
id,
labelKey: `pages.housekeeping.domains.${id}.title`,
descriptionKey: `pages.housekeeping.domains.${id}.description`,
iconId: "settings",
previewHref: `/admin-next/${id}`,
capability: anyCapability(capabilitySlug),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
});
const providerCapability = anyCapability(PERMS.USERS_VIEW);
const searchProvider = (
id: string,
owner: HousekeepingDomainId = "people",
capability: CapabilityRequirement = providerCapability,
): HousekeepingSearchProvider => ({
id,
owner,
capability,
search: async () => ok([], "search"),
});
const inboxSource = (
id: string,
owner: HousekeepingDomainId = "people",
capability: CapabilityRequirement = providerCapability,
): HousekeepingInboxSource => ({
id,
owner,
capability,
getItems: async () => ok({ items: [], availability: "available" }, "inbox"),
});
const widget = (
id: string,
owner: HousekeepingDomainId = "people",
capability: CapabilityRequirement = providerCapability,
): HousekeepingWidgetDefinition => ({
id,
owner,
capability,
kind: "optional",
load: async () => ok(null, "widget"),
});
const expectedManifests = [
{
id: "operations",
iconId: "inbox",
previewHref: "/admin-next/operations",
labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description",
slugs: [PERMS.ADMIN_DASHBOARD],
},
{
id: "people",
iconId: "users",
previewHref: "/admin-next/people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
slugs: [
PERMS.USERS_VIEW,
PERMS.MODERATION_VIEW,
PERMS.TICKETS_VIEW,
PERMS.BANS_VIEW,
PERMS.MOD_DASHBOARD,
PERMS.MOD_CFH_VIEW,
PERMS.MOD_TEAM_VIEW,
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.MOD_BANS_VIEW,
PERMS.USERS_EDIT,
PERMS.USERS_BAN,
PERMS.USERS_RESET_PASSWORD,
PERMS.MODERATION_EDIT,
PERMS.TICKETS_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
PERMS.WORDFILTER_VIEW,
PERMS.WORDFILTER_EDIT,
PERMS.MOD_ACTIONS,
PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT,
],
},
{
id: "content",
iconId: "file-text",
previewHref: "/admin-next/content",
labelKey: "pages.housekeeping.domains.content.title",
descriptionKey: "pages.housekeeping.domains.content.description",
slugs: [
PERMS.NEWS_VIEW,
PERMS.PAGES_VIEW,
PERMS.BANNERS_VIEW,
PERMS.EVENTS_VIEW,
PERMS.POLLS_VIEW,
PERMS.PREFIXES_VIEW,
PERMS.NEWS_EDIT,
PERMS.PAGES_EDIT,
PERMS.BANNERS_EDIT,
PERMS.EVENTS_EDIT,
PERMS.POLLS_EDIT,
PERMS.PREFIXES_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
],
},
{
id: "economy",
iconId: "gem",
previewHref: "/admin-next/economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
slugs: [
PERMS.CATALOG_VIEW,
PERMS.SHOP_VIEW,
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
],
},
{
id: "hotel",
iconId: "hotel",
previewHref: "/admin-next/hotel",
labelKey: "pages.housekeeping.domains.hotel.title",
descriptionKey: "pages.housekeeping.domains.hotel.description",
slugs: [
PERMS.ROOMS_VIEW,
PERMS.RADIO_VIEW,
PERMS.ASSETS_IMPORT,
PERMS.ROOMS_EDIT,
PERMS.ROOMS_DELETE,
PERMS.RADIO_EDIT,
PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT,
],
},
{
id: "system",
iconId: "settings",
previewHref: "/admin-next/system",
labelKey: "pages.housekeeping.domains.system.title",
descriptionKey: "pages.housekeeping.domains.system.description",
slugs: [
PERMS.SETTINGS_VIEW,
PERMS.LOGS_VIEW,
PERMS.ANALYTICS_VIEW,
PERMS.DEVOPS_VIEW,
PERMS.NOTIFICATIONS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.RCON_EXECUTE,
PERMS.SETTINGS_EDIT,
PERMS.NOTIFICATIONS_EDIT,
],
},
] as const;
describe("housekeeping registry", () => {
it("registers the six approved domains in their locked order", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
expect(registry.domains.map((domain) => domain.id)).toEqual(
HOUSEKEEPING_DOMAIN_IDS,
);
expect(Object.isFrozen(registry.domains)).toBe(true);
});
it("rejects duplicate and unknown domain identifiers", () => {
expect(() =>
createHousekeepingRegistry([
manifest("operations"),
manifest("operations"),
]),
).toThrow("duplicate domain id");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("operations"), id: "future" as HousekeepingDomainId },
]),
).toThrow("unknown domain id");
});
it("rejects invalid preview and empty translation keys", () => {
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), previewHref: "/admin-next/operations" },
]),
).toThrow("invalid preview href");
expect(() =>
createHousekeepingRegistry([{ ...manifest("people"), labelKey: " " }]),
).toThrow("empty label key");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), descriptionKey: "\t" },
]),
).toThrow("empty description key");
});
it("rejects duplicate route identities and hrefs", () => {
const base = manifest("people");
expect(() =>
createHousekeepingRegistry([
{
...base,
routes: [
{
id: "users",
labelKey: "pages.housekeeping.domains.people.title",
href: "/admin-next/people/users",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "users",
labelKey: "pages.housekeeping.domains.people.title",
href: "/admin-next/people/staff",
capability: anyCapability(PERMS.USERS_VIEW),
},
],
},
]),
).toThrow("duplicate route id");
expect(() =>
createHousekeepingRegistry([
{
...base,
routes: [
{
id: "users",
labelKey: "pages.housekeeping.domains.people.title",
href: "/admin-next/people/users",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "staff",
labelKey: "pages.housekeeping.domains.people.title",
href: "/admin-next/people/users",
capability: anyCapability(PERMS.USERS_VIEW),
},
],
},
]),
).toThrow("duplicate route href");
});
it("rejects empty route fields and unknown capability slugs", () => {
const route = {
id: "users",
labelKey: "pages.housekeeping.domains.people.title",
href: "/admin-next/people/users",
capability: anyCapability(PERMS.USERS_VIEW),
};
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), routes: [{ ...route, id: " " }] },
]),
).toThrow("empty route id");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), routes: [{ ...route, href: " " }] },
]),
).toThrow("empty route href");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), routes: [{ ...route, labelKey: " " }] },
]),
).toThrow("empty route label key");
expect(() =>
createHousekeepingRegistry([
{
...manifest("system"),
capability: anyCapability("admin.ghost.view"),
},
]),
).toThrow("unknown capability slug: admin.ghost.view");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
routes: [
{ ...route, capability: anyCapability("admin.ghost.route") },
],
},
]),
).toThrow("unknown capability slug: admin.ghost.route");
});
it("locks every real manifest to its approved presentation and capability group", () => {
expect(HOUSEKEEPING_MANIFESTS).toHaveLength(6);
for (const [index, expected] of expectedManifests.entries()) {
const actual = HOUSEKEEPING_MANIFESTS[index];
expect(actual).toMatchObject({
id: expected.id,
iconId: expected.iconId,
previewHref: expected.previewHref,
labelKey: expected.labelKey,
descriptionKey: expected.descriptionKey,
});
expect(actual.routes).toEqual([]);
expect(actual.searchProviders).toEqual([]);
expect(actual.inboxSources).toEqual([]);
expect(actual.widgets).toEqual([]);
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
}
});
it("covers every capability attributed to each domain's migration rows", () => {
for (const manifest of HOUSEKEEPING_MANIFESTS) {
const attributedCapabilities = new Set(
HOUSEKEEPING_MIGRATION_MATRIX.filter(
(entry) => entry.targetDomain === manifest.id,
).flatMap((entry) => [
...entry.capabilities.read,
...entry.capabilities.mutate,
]),
);
for (const slug of attributedCapabilities) {
expect(
manifest.capability.slugs,
`domain ${manifest.id}: ${slug}`,
).toContain(slug);
}
}
});
it("rejects duplicate provider and widget ids across domain manifests", () => {
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
{
...manifest("content"),
searchProviders: [searchProvider("shared", "content")],
},
]),
).toThrow("duplicate search provider id: shared");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), inboxSources: [inboxSource("shared")] },
{
...manifest("content"),
inboxSources: [inboxSource("shared", "content")],
},
]),
).toThrow("duplicate inbox source id: shared");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), widgets: [widget("shared")] },
{
...manifest("content"),
widgets: [widget("shared", "content")],
},
]),
).toThrow("duplicate widget id: shared");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
{
...manifest("content"),
widgets: [widget("shared", "content")],
},
]),
).toThrow("duplicate widget id: shared");
});
it("rejects provider and widget ownership outside their manifest", () => {
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
searchProviders: [searchProvider("people.users", "content")],
},
]),
).toThrow("search provider owner mismatch: people.users");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
inboxSources: [inboxSource("people.tickets", "content")],
},
]),
).toThrow("inbox source owner mismatch: people.tickets");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
widgets: [widget("people.queue", "content")],
},
]),
).toThrow("widget owner mismatch: people.queue");
});
it("rejects empty ids and invalid capability shapes for registry metadata", () => {
const invalidCapability = {
mode: "some",
slugs: [PERMS.USERS_VIEW],
} as unknown as CapabilityRequirement;
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), searchProviders: [searchProvider(" ")] },
]),
).toThrow("empty search provider id");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
inboxSources: [
inboxSource("people.tickets", "people", invalidCapability),
],
},
]),
).toThrow("invalid capability requirement");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
widgets: [
widget(
"people.queue",
"people",
anyCapability("admin.ghost.widget"),
),
],
},
]),
).toThrow("unknown capability slug: admin.ghost.widget");
});
it("rejects widget kinds outside the mandatory or optional contract", () => {
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
widgets: [
{ ...widget("people.queue"), kind: "fixed" as "mandatory" },
],
},
]),
).toThrow("invalid widget kind: people.queue");
});
it("rejects empty domain and route capability requirements", () => {
expect(() =>
createHousekeepingRegistry([
{ ...manifest("operations"), capability: { mode: "any", slugs: [] } },
]),
).toThrow("empty capability requirement");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
routes: [
{
id: "users",
labelKey: "pages.housekeeping.domains.people.title",
href: "/admin-next/people/users",
capability: { mode: "any", slugs: [] },
},
],
},
]),
).toThrow("empty capability requirement");
});
it("rejects duplicate route identities and hrefs across domains", () => {
const route = {
id: "shared",
labelKey: "pages.housekeeping.domains.people.title",
href: "/admin-next/shared",
capability: anyCapability(PERMS.USERS_VIEW),
};
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), routes: [route] },
{
...manifest("content"),
routes: [{ ...route, href: "/admin-next/content/shared" }],
},
]),
).toThrow("duplicate route id");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), routes: [route] },
{
...manifest("content"),
routes: [{ ...route, id: "content-shared" }],
},
]),
).toThrow("duplicate route href");
});
});
@@ -0,0 +1,137 @@
import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types";
import type {
CapabilityRequirement,
HousekeepingDomainManifest,
} from "./contracts";
const approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
interface OwnedRegistryEntry {
id: string;
owner: HousekeepingDomainManifest["id"];
capability: CapabilityRequirement;
}
export interface HousekeepingRegistry {
domains: readonly HousekeepingDomainManifest[];
}
export function createHousekeepingRegistry(
manifests: readonly HousekeepingDomainManifest[],
): HousekeepingRegistry {
const domainIds = new Set<string>();
const routeIds = new Set<string>();
const routeHrefs = new Set<string>();
const registryEntryIds = new Set<string>();
for (const manifest of manifests) {
if (!approvedDomainIds.has(manifest.id)) {
throw new Error(`unknown domain id: ${manifest.id}`);
}
if (domainIds.has(manifest.id)) {
throw new Error(`duplicate domain id: ${manifest.id}`);
}
domainIds.add(manifest.id);
if (manifest.previewHref !== `/admin-next/${manifest.id}`) {
throw new Error(`invalid preview href: ${manifest.previewHref}`);
}
validateNonEmpty(manifest.labelKey, "label key");
validateNonEmpty(manifest.descriptionKey, "description key");
validateCapability(manifest.capability);
validateOwnedRegistryEntries(
manifest.searchProviders,
manifest.id,
"search provider",
registryEntryIds,
);
validateOwnedRegistryEntries(
manifest.inboxSources,
manifest.id,
"inbox source",
registryEntryIds,
);
validateOwnedRegistryEntries(
manifest.widgets,
manifest.id,
"widget",
registryEntryIds,
);
for (const widget of manifest.widgets) {
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
throw new Error(`invalid widget kind: ${widget.id}`);
}
}
for (const route of manifest.routes) {
validateNonEmpty(route.id, "route id");
if (routeIds.has(route.id)) {
throw new Error(`duplicate route id: ${route.id}`);
}
routeIds.add(route.id);
validateNonEmpty(route.href, "route href");
if (routeHrefs.has(route.href)) {
throw new Error(`duplicate route href: ${route.href}`);
}
routeHrefs.add(route.href);
validateNonEmpty(route.labelKey, "route label key");
validateCapability(route.capability);
}
}
return { domains: Object.freeze([...manifests]) };
}
function validateOwnedRegistryEntries(
entries: readonly OwnedRegistryEntry[],
owner: HousekeepingDomainManifest["id"],
kind: "search provider" | "inbox source" | "widget",
ids: Set<string>,
): void {
for (const entry of entries) {
validateNonEmpty(entry.id, `${kind} id`);
if (ids.has(entry.id)) {
throw new Error(`duplicate ${kind} id: ${entry.id}`);
}
ids.add(entry.id);
if (entry.owner !== owner) {
throw new Error(`${kind} owner mismatch: ${entry.id}`);
}
validateCapability(entry.capability);
}
}
function validateNonEmpty(value: string, name: string): void {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`empty ${name}`);
}
}
function validateCapability(requirement: CapabilityRequirement): void {
if (
typeof requirement !== "object" ||
requirement === null ||
(requirement.mode !== "any" && requirement.mode !== "all") ||
!Array.isArray(requirement.slugs)
) {
throw new Error("invalid capability requirement");
}
if (requirement.slugs.length === 0) {
throw new Error("empty capability requirement");
}
for (const slug of requirement.slugs) {
if (typeof slug !== "string") {
throw new Error("invalid capability requirement");
}
if (!knownCapabilitySlugs.has(slug)) {
throw new Error(`unknown capability slug: ${slug}`);
}
}
}
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { auth, getAdminContext, staleActor } = vi.hoisted(() => ({
auth: vi.fn(),
getAdminContext: vi.fn(),
staleActor: { id: 9, username: "stale-session", rank: 1 },
}));
vi.mock("react", () => ({
cache: <T extends (...args: never[]) => unknown>(callback: T) => callback,
}));
vi.mock("@/lib/auth", () => ({ auth }));
vi.mock("@/lib/db", () => {
throw new Error("server capability context must not access the database");
});
vi.mock("@/lib/permissions", () => ({ getAdminContext }));
import { auth as mockedAuth } from "@/lib/auth";
import { getHousekeepingCapabilityContext } from "./server-capability-context";
describe("getHousekeepingCapabilityContext", () => {
beforeEach(() => {
vi.clearAllMocks();
auth.mockResolvedValue({ user: staleActor });
getAdminContext.mockResolvedValue({
session: {
user: {
id: 42,
username: "operator",
rank: 7,
},
},
permissions: {
isSuperAdmin: false,
has: (slug: string) => slug === "admin.users.view",
hasAny: (...slugs: string[]) => slugs.includes("admin.users.view"),
hasAll: (...slugs: string[]) =>
slugs.every((slug) => slug === "admin.users.view"),
},
});
});
it("uses the refreshed administrator actor instead of the stale auth actor", async () => {
const context = await getHousekeepingCapabilityContext();
expect(context).toMatchObject({
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
});
expect(context.has("admin.users.view")).toBe(true);
expect(context.has("admin.logs.view")).toBe(false);
expect(getAdminContext).toHaveBeenCalledTimes(1);
expect(mockedAuth).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,16 @@
import { cache } from "react";
import { getAdminContext } from "@/lib/permissions";
import { createHousekeepingCapabilityContext } from "./capability-context";
export const getHousekeepingCapabilityContext = cache(async () => {
const { session, permissions } = await getAdminContext();
return createHousekeepingCapabilityContext(
{
id: session.user.id,
username: session.user.username,
rank: session.user.rank,
},
permissions,
);
});
@@ -0,0 +1,15 @@
interface CommandTriggerProps {
label: string;
}
export function CommandTrigger({ label }: CommandTriggerProps) {
return (
<button
type="button"
disabled
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text-muted)]"
>
{label}
</button>
);
}
@@ -0,0 +1,25 @@
import Link from "next/link";
interface ContextNavProps {
items: readonly { id: string; href: string; label: string }[];
ariaLabel: string;
}
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
return (
<nav aria-label={ariaLabel} className="min-w-0">
<ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
{items.map((item) => (
<li key={item.id} className="min-w-max">
<Link
href={item.href}
className="block rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,49 @@
import { FileText, Gem, Hotel, Inbox, Settings, Users } from "lucide-react";
import Link from "next/link";
import type { HousekeepingNavigationDomain } from "../navigation";
interface DomainRailProps {
domains: readonly HousekeepingNavigationDomain[];
activeDomainId: HousekeepingNavigationDomain["id"];
ariaLabel: string;
}
function DomainIcon({ iconId }: Pick<HousekeepingNavigationDomain, "iconId">) {
const Icon = {
inbox: Inbox,
users: Users,
"file-text": FileText,
gem: Gem,
hotel: Hotel,
settings: Settings,
}[iconId];
return <Icon aria-hidden="true" size={18} />;
}
export function DomainRail({
domains,
activeDomainId,
ariaLabel,
}: DomainRailProps) {
return (
<nav aria-label={ariaLabel} className="w-full lg:w-20">
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
{domains.map((domain) => (
<li key={domain.id} className="min-w-max lg:min-w-0">
<Link
href={domain.href}
aria-current={domain.id === activeDomainId ? "page" : undefined}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] lg:justify-center"
>
<span data-domain-icon={domain.iconId}>
<DomainIcon iconId={domain.iconId} />
</span>
<span className="lg:sr-only">{domain.label}</span>
</Link>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,319 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { HousekeepingNavigationDomain } from "../navigation";
import { HousekeepingShell } from "./housekeeping-shell";
const labels = {
skipToContent: "Skip to housekeeping content",
primaryNavigation: "Primary housekeeping navigation",
contextualNavigation: "Operations navigation",
command: "Commands are unavailable in preview",
preview: "Preview",
backToSite: "Back to site",
};
const domains: readonly HousekeepingNavigationDomain[] = [
{
id: "operations",
href: "/admin-next/operations",
iconId: "inbox",
label: "Operations",
description: "Manage operations",
items: [
{ id: "queue", href: "/admin-next/operations/queue", label: "Queue" },
],
},
{
id: "people",
href: "/admin-next/people",
iconId: "users",
label: "People",
description: "Manage people",
items: [],
},
{
id: "content",
href: "/admin-next/content",
iconId: "file-text",
label: "Content",
description: "Manage content",
items: [],
},
{
id: "economy",
href: "/admin-next/economy",
iconId: "gem",
label: "Economy",
description: "Manage economy",
items: [],
},
{
id: "hotel",
href: "/admin-next/hotel",
iconId: "hotel",
label: "Hotel",
description: "Manage hotel",
items: [],
},
{
id: "system",
href: "/admin-next/system",
iconId: "settings",
label: "System",
description: "Manage system",
items: [],
},
];
const taskFiles = [
"src/features/housekeeping/foundation/shell/housekeeping-shell.tsx",
"src/features/housekeeping/foundation/shell/domain-rail.tsx",
"src/features/housekeeping/foundation/shell/context-nav.tsx",
"src/features/housekeeping/foundation/shell/command-trigger.tsx",
"src/features/housekeeping/foundation/shell/operator-summary.tsx",
"src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx",
"src/features/housekeeping/foundation/page/housekeeping-page-state.tsx",
"src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx",
"src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx",
] as const;
const componentTaskFiles = taskFiles.slice(0, 7);
const structuralBoundaryRules = [
{ label: "client directive", pattern: new RegExp("use " + "client") },
{
label: "palette color",
pattern:
/(?:bg|text|border|from|via|to|ring|outline|fill|stroke|divide|shadow|accent|caret|decoration|placeholder)-(?:white|black|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)(?:-\d{2,3})?/,
},
{ label: "hex color", pattern: new RegExp("#" + "[0-9a-fA-F]{3,8}") },
{
label: "rgb or hsl color",
pattern: new RegExp("(?:r" + "gb|h" + "sl)\\("),
},
];
const componentBoundaryRules = [
{
label: "React hook",
pattern: /\b(?:React\.)?use[A-Z]\w*(?:\s*\(|\s*(?:as|,|}))/,
},
{ label: "click handler", pattern: /\bonClick\s*=/ },
{ label: "keyboard handler", pattern: /\bonKey(?:Down|Up|Press)\s*=/ },
{ label: "executable JSX event prop", pattern: /\bon[A-Z]\w*\s*=/ },
{ label: "event listener", pattern: new RegExp("addEvent" + "Listener") },
{
label: "inline literal color",
pattern:
/\bstyle\s*=\s*\{\{[^}]*\b(?:color|background(?:Color)?|borderColor|fill|stroke)\s*:\s*["'](?!var\(--admin-[^)]+\))[^"']+["']/,
},
{
label: "database or action import",
pattern: new RegExp(
"(?:from\\s*|import\\s*\\()\\s*['\"][^'\"]*(?:@/lib/" +
"db|drizzle(?:-orm)?|/" +
"actions?)[^'\"]*['\"]",
),
},
{ label: "command package", pattern: new RegExp("cmd" + "k", "i") },
{ label: "localization import", pattern: new RegExp("next" + "-intl") },
{
label: "search or mutation runtime",
pattern: /\b(?:fetch|useQuery|useMutation)\s*\(/,
},
{
label: "persistence storage",
pattern:
/\b(?:localStorage|sessionStorage|indexedDB)\b|\.(?:setItem|getItem|removeItem)\s*\(/,
},
];
function renderShell(
activeDomainId: HousekeepingNavigationDomain["id"] = "operations",
availableDomains: readonly HousekeepingNavigationDomain[] = domains,
): string {
return renderToStaticMarkup(
<HousekeepingShell
actor={{ id: 7, username: "Nora", rank: 6 }}
activeDomainId={activeDomainId}
domains={availableDomains}
labels={labels}
>
<p>Deck body</p>
</HousekeepingShell>,
);
}
function anchors(html: string): readonly string[] {
return html.match(/<a\b[^>]*>/g) ?? [];
}
function anchorForHref(html: string, href: string): string {
const anchor = anchors(html).find((candidate) =>
candidate.includes(`href="${href}"`),
);
if (!anchor) throw new Error(`missing anchor for ${href}`);
return anchor;
}
function boundaryViolations(
source: string,
rules: readonly { label: string; pattern: RegExp }[],
): readonly string[] {
return rules
.filter((rule) => rule.pattern.test(source))
.map((rule) => rule.label);
}
describe("HousekeepingShell", () => {
it("renders the accessible command deck landmarks and supplied shell content", () => {
const html = renderShell();
expect(html).toContain('href="#housekeeping-content"');
expect(html).toContain(">Skip to housekeeping content<");
expect(html).toContain("<aside");
expect(html).toContain("<header");
expect(html).toContain('<nav aria-label="Primary housekeeping navigation"');
expect(html).toContain('<nav aria-label="Operations navigation"');
expect(html).toContain('<main id="housekeeping-content"');
expect(html).toContain(">Deck body</p>");
expect(html).toContain('href="/admin-next/operations/queue"');
expect(html).toContain(">Queue<");
expect(html).toContain(">Nora<");
expect(html).toContain(">6<");
expect(html).toContain(">Preview<");
expect(html).toContain('href="/"');
expect(html).toContain(">Back to site<");
expect(html).toMatch(
/<button type="button" disabled=""[^>]*>Commands are unavailable in preview<\/button>/,
);
});
it("maps all six authorized icons and exactly one active domain", () => {
const html = renderShell("people");
const allAnchors = anchors(html);
const activeAnchors = allAnchors.filter((anchor) =>
anchor.includes('aria-current="page"'),
);
const peopleAnchor = anchorForHref(html, "/admin-next/people");
for (const iconClass of [
"lucide-inbox",
"lucide-users",
"lucide-file-text",
"lucide-gem",
"lucide-hotel",
"lucide-settings",
]) {
expect(html).toContain(`class="lucide ${iconClass}"`);
}
expect(activeAnchors).toHaveLength(1);
expect(peopleAnchor).toContain('aria-current="page"');
for (const anchor of allAnchors.filter(
(anchor) => anchor !== peopleAnchor,
)) {
expect(anchor).not.toContain('aria-current="page"');
}
});
it("does not throw or render contextual items when a valid active domain is absent", () => {
const domainsWithoutSystem = domains.filter(
(domain) => domain.id !== "system",
);
expect(() => renderShell("system", domainsWithoutSystem)).not.toThrow();
const html = renderShell("system", domainsWithoutSystem);
expect(html).not.toContain('href="/admin-next/operations/queue"');
expect(html).not.toContain(">Queue<");
});
it("keeps Task 10 modules within the server-rendered source boundary", () => {
for (const path of taskFiles) {
const source = readFileSync(resolve(process.cwd(), path), "utf8");
expect(boundaryViolations(source, structuralBoundaryRules)).toEqual([]);
}
for (const path of componentTaskFiles) {
const source = readFileSync(resolve(process.cwd(), path), "utf8");
expect(boundaryViolations(source, componentBoundaryRules)).toEqual([]);
}
});
it("detects representative prohibited source mutations", () => {
const componentMutations = [
["React hook", 'import { useState } from "react";'],
["click handler", "<button onClick={() => undefined} />"],
["keyboard handler", "<input onKeyDown={() => undefined} />"],
[
"event listener",
'window.addEventListener("keydown", () => undefined);',
],
["database or action import", 'import { db } from "@/lib/db";'],
["database or action import", 'await import("@/app/actions");'],
["command package", 'import { Command } from "cmdk";'],
["localization import", 'import { useTranslations } from "next-intl";'],
["search or mutation runtime", "useMutation();"],
["persistence storage", "localStorage.setItem('key', 'value');"],
] as const;
for (const [expectedViolation, source] of componentMutations) {
expect(boundaryViolations(source, componentBoundaryRules)).toContain(
expectedViolation,
);
}
expect(
boundaryViolations(
'const color = "bg-' + 'rose-500";',
structuralBoundaryRules,
),
).toContain("palette color");
expect(
boundaryViolations('const color = "#' + 'abc";', structuralBoundaryRules),
).toContain("hex color");
expect(
boundaryViolations(
'const color = "r' + 'gb(1, 2, 3)";',
structuralBoundaryRules,
),
).toContain("rgb or hsl color");
});
it("detects generic hook, event, neutral color, and inline-style mutations", () => {
const componentMutations = [
["React hook", "useActionState();"],
["React hook", 'import { useState as state } from "react";'],
["React hook", "React.useId();"],
["executable JSX event prop", "<form onSubmit={() => undefined} />"],
["executable JSX event prop", "<div onPointerDown={() => undefined} />"],
["inline literal color", '<div style={{ color: "red" }} />'],
] as const;
for (const [expectedViolation, source] of componentMutations) {
expect(boundaryViolations(source, componentBoundaryRules)).toContain(
expectedViolation,
);
}
expect(
boundaryViolations(
'const color = "bg-' + 'white";',
structuralBoundaryRules,
),
).toContain("palette color");
expect(
boundaryViolations(
'const color = "text-' + 'black";',
structuralBoundaryRules,
),
).toContain("palette color");
});
it("detects hard-coded Tailwind color utilities beyond foreground and backgrounds", () => {
for (const source of [
'className="ring-' + 'red-500"',
'className="outline-' + 'blue-500"',
'className="fill-' + 'green-500"',
'className="stroke-' + 'purple-500"',
'className="divide-' + 'amber-400"',
'className="via-' + 'cyan-500"',
]) {
expect(boundaryViolations(source, structuralBoundaryRules)).toContain(
"palette color",
);
}
});
});
@@ -0,0 +1,80 @@
import Link from "next/link";
import type { ReactNode } from "react";
import type { HousekeepingDomainId } from "../../migration/types";
import type { HousekeepingActor } from "../contracts";
import type { HousekeepingNavigationDomain } from "../navigation";
import { CommandTrigger } from "./command-trigger";
import { ContextNav } from "./context-nav";
import { DomainRail } from "./domain-rail";
import { OperatorSummary } from "./operator-summary";
interface HousekeepingShellProps {
actor: HousekeepingActor;
activeDomainId: HousekeepingDomainId;
domains: readonly HousekeepingNavigationDomain[];
labels: {
skipToContent: string;
primaryNavigation: string;
contextualNavigation: string;
command: string;
preview: string;
backToSite: string;
};
children: ReactNode;
}
export function HousekeepingShell({
actor,
activeDomainId,
domains,
labels,
children,
}: HousekeepingShellProps) {
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
return (
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]">
<a
href="#housekeeping-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)]"
>
{labels.skipToContent}
</a>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-3">
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
{labels.preview}
</span>
<OperatorSummary actor={actor} />
</div>
<div className="flex items-center gap-2">
<CommandTrigger label={labels.command} />
<Link
href="/"
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
>
{labels.backToSite}
</Link>
</div>
</header>
<div className="flex min-h-[calc(100vh-4rem)] flex-col lg:flex-row">
<aside className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r">
<DomainRail
domains={domains}
activeDomainId={activeDomainId}
ariaLabel={labels.primaryNavigation}
/>
</aside>
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:border-b-0 lg:border-r">
<ContextNav
items={activeDomain?.items ?? []}
ariaLabel={labels.contextualNavigation}
/>
</div>
<main id="housekeeping-content" className="min-w-0 flex-1 p-4 lg:p-6">
{children}
</main>
</div>
</div>
);
}
@@ -0,0 +1,19 @@
import type { HousekeepingActor } from "../contracts";
interface OperatorSummaryProps {
actor: HousekeepingActor;
}
export function OperatorSummary({ actor }: OperatorSummaryProps) {
return (
<div className="min-w-0 text-sm text-[var(--admin-text)]">
<div className="truncate font-medium">{actor.username}</div>
<div
data-operator-rank={actor.rank}
className="text-[var(--admin-text-muted)]"
>
{actor.rank}
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { contentManifest } from "./domains/content/manifest";
import { economyManifest } from "./domains/economy/manifest";
import { hotelManifest } from "./domains/hotel/manifest";
import { operationsManifest } from "./domains/operations/manifest";
import { peopleManifest } from "./domains/people/manifest";
import { systemManifest } from "./domains/system/manifest";
import type { HousekeepingDomainManifest } from "./foundation/contracts";
export const HOUSEKEEPING_MANIFESTS = [
operationsManifest,
peopleManifest,
contentManifest,
economyManifest,
hotelManifest,
systemManifest,
] as const satisfies readonly HousekeepingDomainManifest[];
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { contentMigrationEntries } from "./content";
import { ownedLegacyPages } from "./discover-legacy-pages";
import { validateMigrationEntries } from "./validate-matrix";
const CONTENT_PREFIXES = [
"/admin/articles",
"/admin/photos",
"/admin/media",
"/admin/banners",
"/admin/ads",
"/admin/events",
"/admin/polls",
"/admin/help-questions",
"/admin/tags",
"/admin/prefixes",
"/admin/writeable-boxes",
"/admin/email-templates",
"/admin/theme",
"/admin/favicon",
"/admin/translations",
] as const;
describe("contentMigrationEntries", () => {
it("covers every content page without legacy-domain targets", () => {
const expected = ownedLegacyPages(CONTENT_PREFIXES);
expect(validateMigrationEntries(expected, contentMigrationEntries)).toEqual(
[],
);
expect(
contentMigrationEntries.every(
(row) =>
row.targetPath === null ||
row.targetPath.startsWith("/admin/content/"),
),
).toBe(true);
});
it("preserves dynamic ids in the consolidated workflow targets", () => {
expect(
contentMigrationEntries
.filter((row) => row.legacyPath.endsWith(":id"))
.map(({ legacyPath, targetPath }) => ({ legacyPath, targetPath })),
).toEqual([
{
legacyPath: "/admin/articles/:id",
targetPath: "/admin/content/editorial/articles/:id",
},
{
legacyPath: "/admin/ads/:id",
targetPath: "/admin/content/media/ads/:id",
},
{
legacyPath: "/admin/events/:id",
targetPath: "/admin/content/engagement/events/:id",
},
{
legacyPath: "/admin/polls/:id",
targetPath: "/admin/content/engagement/polls/:id",
},
{
legacyPath: "/admin/help-questions/:id",
targetPath: "/admin/content/help/questions/:id",
},
]);
});
it("keeps every audited workflow planned without parity claims", () => {
expect(
contentMigrationEntries.every(
(row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
),
).toBe(true);
});
it("requires privileged settings edits for global runtime configuration", () => {
for (const legacyPath of [
"/admin/theme",
"/admin/favicon",
"/admin/translations/cms",
"/admin/translations/client",
"/admin/translations/emulator",
]) {
expect(contentMigrationEntries).toContainEqual(
expect.objectContaining({
legacyPath,
capabilities: expect.objectContaining({
mutate: [PERMS.SETTINGS_EDIT],
}),
auditRequirement: "PRIVILEGED_MUTATION",
}),
);
}
});
});
@@ -0,0 +1,572 @@
import { PERMS } from "@/lib/permission-slugs";
import type { MigrationEntry } from "./types";
type PlannedContentEntry = Omit<
MigrationEntry,
"targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
> & {
requiredTests?: MigrationEntry["requiredTests"];
notes?: MigrationEntry["notes"];
};
function plannedContentEntry(entry: PlannedContentEntry): MigrationEntry {
return {
...entry,
targetDomain: "content",
requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: entry.notes ?? [],
};
}
export const contentMigrationEntries: readonly MigrationEntry[] = [
// Batch 1: editorial publishing and media management.
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/articles",
sourceFile: "src/app/admin/articles/page.tsx",
targetPath: "/admin/content/editorial/articles",
decision: "REBUILD",
capabilities: {
read: [PERMS.NEWS_VIEW],
mutate: [PERMS.NEWS_EDIT],
},
dependencies: {
queries: ["User", "WebsiteArticles"],
mutations: ["deleteArticle"],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/articles/new",
sourceFile: "src/app/admin/articles/new/page.tsx",
targetPath: "/admin/content/editorial/articles/new",
decision: "REBUILD",
capabilities: {
read: [PERMS.NEWS_EDIT],
mutate: [PERMS.NEWS_EDIT],
},
dependencies: {
queries: ["GET /api/media"],
mutations: ["createArticle", "uploadMediaAndReturn"],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/articles/:id",
sourceFile: "src/app/admin/articles/[id]/page.tsx",
targetPath: "/admin/content/editorial/articles/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.NEWS_VIEW],
mutate: [PERMS.NEWS_EDIT],
},
dependencies: {
queries: ["GET /api/media", "WebsiteArticles"],
mutations: ["deleteArticle", "updateArticle", "uploadMediaAndReturn"],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/photos",
sourceFile: "src/app/admin/photos/page.tsx",
targetPath: "/admin/content/media/photos",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_VIEW],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: {
queries: ["CameraWeb", "User"],
mutations: ["deletePhoto"],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/media",
sourceFile: "src/app/admin/media/page.tsx",
targetPath: "/admin/content/media/library",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_VIEW],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: {
queries: ["GET /api/media"],
mutations: ["deleteMedia", "uploadMedia"],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/banners",
sourceFile: "src/app/admin/banners/page.tsx",
targetPath: "/admin/content/media/banners",
decision: "REBUILD",
capabilities: {
read: [PERMS.BANNERS_VIEW],
mutate: [PERMS.BANNERS_EDIT],
},
dependencies: {
queries: ["WebsiteBanner"],
mutations: ["createBanner", "deleteBanner", "updateBanner"],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/ads",
sourceFile: "src/app/admin/ads/page.tsx",
targetPath: "/admin/content/media/ads",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_VIEW],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: {
queries: ["WebsiteAds"],
mutations: ["deleteAd"],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/ads/new",
sourceFile: "src/app/admin/ads/new/page.tsx",
targetPath: "/admin/content/media/ads/new",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_EDIT],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: { queries: [], mutations: ["createAd"] },
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/ads/:id",
sourceFile: "src/app/admin/ads/[id]/page.tsx",
targetPath: "/admin/content/media/ads/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_VIEW],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: {
queries: ["WebsiteAds"],
mutations: ["deleteAd", "updateAd"],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
// Batch 2: events, polls, help content, and supporting taxonomies.
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/events",
sourceFile: "src/app/admin/events/page.tsx",
targetPath: "/admin/content/engagement/events",
decision: "REBUILD",
capabilities: {
read: [PERMS.EVENTS_VIEW],
mutate: [PERMS.EVENTS_EDIT],
},
dependencies: {
queries: ["fetchAdminList", "WebsiteEvent", "WebsiteEventType"],
mutations: ["deleteEvent"],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/events/create",
sourceFile: "src/app/admin/events/create/page.tsx",
targetPath: "/admin/content/engagement/events/create",
decision: "REBUILD",
capabilities: {
read: [PERMS.EVENTS_EDIT],
mutate: [PERMS.EVENTS_EDIT],
},
dependencies: {
queries: ["WebsiteEventType"],
mutations: ["createEvent"],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/events/types",
sourceFile: "src/app/admin/events/types/page.tsx",
targetPath: "/admin/content/engagement/events/types",
decision: "REBUILD",
capabilities: {
read: [PERMS.EVENTS_EDIT],
mutate: [PERMS.EVENTS_EDIT],
},
dependencies: {
queries: ["WebsiteEventType"],
mutations: ["createEventType", "deleteEventType", "updateEventType"],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/events/:id",
sourceFile: "src/app/admin/events/[id]/page.tsx",
targetPath: "/admin/content/engagement/events/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.EVENTS_EDIT],
mutate: [PERMS.EVENTS_EDIT],
},
dependencies: {
queries: ["User", "WebsiteEvent", "WebsiteEventType"],
mutations: [
"addEventPrize",
"addEventWinner",
"deleteEventPrize",
"updateEvent",
],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/polls",
sourceFile: "src/app/admin/polls/page.tsx",
targetPath: "/admin/content/engagement/polls",
decision: "REBUILD",
capabilities: {
read: [PERMS.POLLS_VIEW],
mutate: [PERMS.POLLS_EDIT],
},
dependencies: {
queries: ["fetchAdminList", "WebsitePoll"],
mutations: ["deletePoll"],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/polls/create",
sourceFile: "src/app/admin/polls/create/page.tsx",
targetPath: "/admin/content/engagement/polls/create",
decision: "REBUILD",
capabilities: {
read: [PERMS.POLLS_EDIT],
mutate: [PERMS.POLLS_EDIT],
},
dependencies: { queries: [], mutations: ["createPoll"] },
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/polls/:id",
sourceFile: "src/app/admin/polls/[id]/page.tsx",
targetPath: "/admin/content/engagement/polls/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.POLLS_EDIT],
mutate: [PERMS.POLLS_EDIT],
},
dependencies: {
queries: ["WebsitePoll"],
mutations: [
"addPollQuestion",
"deletePollQuestion",
"updatePoll",
"updatePollQuestion",
],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/help-questions",
sourceFile: "src/app/admin/help-questions/page.tsx",
targetPath: "/admin/content/help/questions",
decision: "REBUILD",
capabilities: { read: [PERMS.PAGES_VIEW], mutate: [] },
dependencies: {
queries: ["WebsiteHelpCenterCategories"],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/help-questions/new",
sourceFile: "src/app/admin/help-questions/new/page.tsx",
targetPath: "/admin/content/help/questions/new",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_EDIT],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: { queries: [], mutations: ["createHelpQuestion"] },
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/help-questions/:id",
sourceFile: "src/app/admin/help-questions/[id]/page.tsx",
targetPath: "/admin/content/help/questions/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_VIEW],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: {
queries: ["WebsiteHelpCenterCategories"],
mutations: ["deleteHelpQuestion", "updateHelpQuestion"],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/tags",
sourceFile: "src/app/admin/tags/page.tsx",
targetPath: "/admin/content/editorial/tags",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_VIEW],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: {
queries: ["Taggables", "Tags"],
mutations: ["createTag", "deleteTag", "updateTag"],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/prefixes",
sourceFile: "src/app/admin/prefixes/page.tsx",
targetPath: "/admin/content/engagement/prefixes",
decision: "REBUILD",
capabilities: {
read: [PERMS.PREFIXES_VIEW],
mutate: [PERMS.PREFIXES_EDIT],
},
dependencies: {
queries: [
"GET /api/admin/prefixes",
"GET /api/admin/prefixes/blacklist",
"GET /api/admin/prefixes/settings",
],
mutations: [
"addBlacklistWord",
"createPrefix",
"deletePrefix",
"removeBlacklistWord",
"updatePrefix",
"updatePrefixSettings",
],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/writeable-boxes",
sourceFile: "src/app/admin/writeable-boxes/page.tsx",
targetPath: "/admin/content/editorial/writeable-boxes",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_VIEW],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: {
queries: ["WebsiteWriteableBoxes"],
mutations: ["createBox", "deleteBox", "toggleBox", "updateBox"],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/email-templates",
sourceFile: "src/app/admin/email-templates/page.tsx",
targetPath: "/admin/content/help/email-templates",
decision: "REBUILD",
capabilities: {
read: [PERMS.PAGES_VIEW],
mutate: [PERMS.PAGES_EDIT],
},
dependencies: {
queries: ["EmailTemplates"],
mutations: [
"createEmailTemplate",
"deleteEmailTemplate",
"updateEmailTemplate",
],
},
auditRequirement: "MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
// Batch 3: brand presentation and global localization configuration.
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/theme",
sourceFile: "src/app/admin/theme/page.tsx",
targetPath: "/admin/content/brand/theme",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["listCustomThemes", "siteSettings.get"],
mutations: [
"applyCustomTheme",
"applyPreset",
"deleteCustomTheme",
"renameCustomTheme",
"saveCustomTheme",
"saveTheme",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/favicon",
sourceFile: "src/app/admin/favicon/page.tsx",
targetPath: "/admin/content/brand/favicon",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["WebsiteSetting"],
mutations: ["deleteFavicon", "saveFavicon"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Current favicon actions do not enforce an edit permission or emit an audit record",
],
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/translations",
sourceFile: "src/app/admin/translations/page.tsx",
targetPath: "/admin/content/localization",
decision: "MERGE",
capabilities: { read: [PERMS.SETTINGS_VIEW], mutate: [] },
dependencies: { queries: [], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "COMPLETE",
requiredTests: ["e2e"],
notes: ["Redirect-only landing page for the localization workflow"],
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/translations/client",
sourceFile: "src/app/admin/translations/client/page.tsx",
targetPath: "/admin/content/localization/client",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["CLIENT_TRANSLATION_FILES", "fs.readFile"],
mutations: ["saveClientTranslations"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Current file mutation does not emit an audit record"],
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/translations/cms",
sourceFile: "src/app/admin/translations/cms/page.tsx",
targetPath: "/admin/content/localization/cms",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["fs.readFile"],
mutations: ["saveTranslations"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Current file mutation does not emit an audit record"],
}),
plannedContentEntry({
surface: "admin",
legacyPath: "/admin/translations/emulator",
sourceFile: "src/app/admin/translations/emulator/page.tsx",
targetPath: "/admin/content/localization/emulator",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["EmulatorSettings"],
mutations: ["saveEmulatorSettings"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
];
@@ -0,0 +1,118 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { discoverLegacyPages } from "./discover-legacy-pages";
const temporaryRoots: string[] = [];
afterEach(() => {
for (const root of temporaryRoots.splice(0)) {
rmSync(root, { force: true, recursive: true });
}
});
function createRouteTree(files: readonly string[]): string {
const root = mkdtempSync(join(tmpdir(), "housekeeping-routes-"));
temporaryRoots.push(root);
for (const file of files) {
const path = join(root, file);
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, "export default function Page() {}\n");
}
return root;
}
describe("discoverLegacyPages", () => {
it("discovers the exact legacy administration inventory", () => {
const pages = discoverLegacyPages();
expect(pages).toHaveLength(137);
expect(pages).toContainEqual({
surface: "admin",
legacyPath: "/admin/users/:id/edit",
sourceFile: "src/app/admin/users/[id]/edit/page.tsx",
});
expect(pages).toContainEqual({
surface: "mod",
legacyPath: "/mod/cfh/:id",
sourceFile: "src/app/mod/cfh/[id]/page.tsx",
});
});
it("discovers root administration and moderation routes", () => {
expect(discoverLegacyPages()).toEqual(
expect.arrayContaining([
{
surface: "admin",
legacyPath: "/admin",
sourceFile: "src/app/admin/page.tsx",
},
{
surface: "mod",
legacyPath: "/mod",
sourceFile: "src/app/mod/page.tsx",
},
]),
);
});
it("discovers root routes in a controlled route tree", () => {
const root = createRouteTree([
"src/app/admin/page.tsx",
"src/app/mod/page.tsx",
]);
expect(discoverLegacyPages(root)).toEqual([
{
surface: "admin",
legacyPath: "/admin",
sourceFile: "src/app/admin/page.tsx",
},
{
surface: "mod",
legacyPath: "/mod",
sourceFile: "src/app/mod/page.tsx",
},
]);
});
it("sorts a controlled route tree by surface, route, then source file", () => {
const root = createRouteTree([
"src/app/mod/zebra/page.tsx",
"src/app/admin/users/[id]/page.tsx",
"src/app/admin/(hidden)/alpha/page.tsx",
"src/app/admin/users/[...slug]/page.tsx",
"src/app/mod/(group)/aardvark/page.tsx",
]);
expect(discoverLegacyPages(root)).toEqual([
{
surface: "admin",
legacyPath: "/admin/alpha",
sourceFile: "src/app/admin/(hidden)/alpha/page.tsx",
},
{
surface: "admin",
legacyPath: "/admin/users/:id",
sourceFile: "src/app/admin/users/[id]/page.tsx",
},
{
surface: "admin",
legacyPath: "/admin/users/:slug*",
sourceFile: "src/app/admin/users/[...slug]/page.tsx",
},
{
surface: "mod",
legacyPath: "/mod/aardvark",
sourceFile: "src/app/mod/(group)/aardvark/page.tsx",
},
{
surface: "mod",
legacyPath: "/mod/zebra",
sourceFile: "src/app/mod/zebra/page.tsx",
},
]);
});
});
@@ -0,0 +1,74 @@
import { readdirSync } from "node:fs";
import { join, relative } from "node:path";
import type { LegacyPage, LegacySurface } from "./types";
const pageFileName = "page.tsx";
function toLegacyPath(
surface: LegacySurface,
segments: readonly string[],
): string {
const routeSegments = segments
.filter((segment) => !(segment.startsWith("(") && segment.endsWith(")")))
.map((segment) =>
segment.replace(/^\[\.\.\.(.+)\]$/, ":$1*").replace(/^\[(.+)\]$/, ":$1"),
);
return `/${[surface, ...routeSegments].join("/")}`;
}
function discoverSurface(
rootDir: string,
surface: LegacySurface,
): LegacyPage[] {
const surfaceDirectory = join(rootDir, "src", "app", surface);
const pages: LegacyPage[] = [];
function walk(directory: string): void {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const entryPath = join(directory, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
continue;
}
if (!entry.isFile() || entry.name !== pageFileName) continue;
const sourceFile = relative(rootDir, entryPath).replaceAll("\\", "/");
const routeSegments = relative(surfaceDirectory, directory)
.split(/[/\\]/)
.filter((segment) => segment && segment !== ".");
pages.push({
surface,
legacyPath: toLegacyPath(surface, routeSegments),
sourceFile,
});
}
}
walk(surfaceDirectory);
return pages;
}
export function discoverLegacyPages(rootDir = process.cwd()): LegacyPage[] {
return (["admin", "mod"] as const)
.flatMap((surface) => discoverSurface(rootDir, surface))
.sort((a, b) =>
`${a.surface}:${a.legacyPath}:${a.sourceFile}`.localeCompare(
`${b.surface}:${b.legacyPath}:${b.sourceFile}`,
),
);
}
export function ownedLegacyPages(
prefixes: readonly string[],
pages: readonly LegacyPage[] = discoverLegacyPages(),
): LegacyPage[] {
return pages.filter((page) =>
prefixes.some(
(prefix) =>
page.legacyPath === prefix || page.legacyPath.startsWith(`${prefix}/`),
),
);
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { ownedLegacyPages } from "./discover-legacy-pages";
import { economyMigrationEntries } from "./economy";
import { validateMigrationEntries } from "./validate-matrix";
const ECONOMY_PREFIXES = [
"/admin/catalog",
"/admin/items",
"/admin/shop",
"/admin/marketplace",
"/admin/transactions",
"/admin/vouchers",
"/admin/subscriptions",
"/admin/rare-values",
"/admin/badges",
"/admin/achievements",
"/admin/sounds",
"/admin/calendar",
] as const;
describe("economyMigrationEntries", () => {
it("covers every economy page without legacy-domain targets", () => {
const expected = ownedLegacyPages(ECONOMY_PREFIXES);
expect(validateMigrationEntries(expected, economyMigrationEntries)).toEqual(
[],
);
expect(
economyMigrationEntries.every(
(row) =>
row.targetPath === null ||
row.targetPath.startsWith("/admin/economy/"),
),
).toBe(true);
});
it("keeps every audited workflow planned without parity claims", () => {
expect(
economyMigrationEntries.every(
(row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
),
).toBe(true);
});
it("requires privileged audit for economy mutations", () => {
const mutationRows = economyMigrationEntries.filter(
(row) => row.dependencies.mutations.length > 0,
);
expect(mutationRows.length).toBeGreaterThan(0);
expect(
mutationRows.every(
(row) => row.auditRequirement === "PRIVILEGED_MUTATION",
),
).toBe(true);
});
it("records the catalog search dependency on base items", () => {
const catalog = economyMigrationEntries.find(
(row) => row.legacyPath === "/admin/catalog",
);
expect(catalog?.dependencies.queries).toContain("ItemsBase");
});
it("marks the item collection localization as partial", () => {
const items = economyMigrationEntries.find(
(row) => row.legacyPath === "/admin/items",
);
expect(items?.localization).toBe("PARTIAL");
});
});
@@ -0,0 +1,422 @@
import { PERMS } from "@/lib/permission-slugs";
import type { MigrationEntry } from "./types";
type PlannedEconomyEntry = Omit<
MigrationEntry,
"targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
> & {
requiredTests?: MigrationEntry["requiredTests"];
notes?: MigrationEntry["notes"];
};
function plannedEconomyEntry(entry: PlannedEconomyEntry): MigrationEntry {
return {
...entry,
targetDomain: "economy",
requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: entry.notes ?? [],
};
}
export const economyMigrationEntries: readonly MigrationEntry[] = [
// Batch 1: catalog pages, Builder Club, maintenance, and base items.
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/catalog",
sourceFile: "src/app/admin/catalog/page.tsx",
targetPath: "/admin/economy/catalog",
decision: "MERGE",
capabilities: {
read: [PERMS.CATALOG_VIEW],
mutate: [PERMS.CATALOG_EDIT],
},
dependencies: {
queries: [
"CatalogItems",
"CatalogItemsBc",
"CatalogPages",
"CatalogPagesBc",
"ItemsBase",
"GET /api/admin/catalog/items",
"GET /api/admin/catalog/tree",
"getCatalogItemCounts",
],
mutations: [
"createBcPage",
"createCatalogPage",
"DELETE /api/admin/catalog/tree",
"deleteBcTreePage",
"deleteCatalogPage",
"deleteTreePage",
"fixEverythingAction",
"PATCH /api/admin/catalog/tree",
"reorderBcTreePage",
"reorderTreePage",
"toggleBcPage",
"toggleCatalogPage",
"updateBcPage",
"updateCatalogPage",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Merge the overlapping table, tree, and visual-manager editors into one catalog workflow",
],
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/catalog/:id",
sourceFile: "src/app/admin/catalog/[id]/page.tsx",
targetPath: "/admin/economy/catalog/:id",
decision: "MERGE",
capabilities: {
read: [PERMS.CATALOG_VIEW],
mutate: [PERMS.CATALOG_EDIT],
},
dependencies: {
queries: [
"CatalogPages",
"GET /api/admin/catalog/suggest",
"GET /api/admin/furni/search",
"getAncestors",
"loadCatalogItemsData",
],
mutations: [
"bulkCreateCatalogItems",
"createCatalogItem",
"deleteCatalogItems",
"deleteCatalogPage",
"moveCatalogItems",
"reorderCatalogItems",
"translateCatalogItems",
"updateCatalogItem",
"updateCatalogPage",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Merge the detail tabs with the collection-level visual editor while preserving the page id",
],
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/catalog/builder-club",
sourceFile: "src/app/admin/catalog/builder-club/page.tsx",
targetPath: "/admin/economy/catalog",
decision: "MERGE",
capabilities: { read: [PERMS.CATALOG_VIEW], mutate: [] },
dependencies: { queries: [], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "COMPLETE",
requiredTests: ["e2e"],
notes: ["Redirect-only entry into the Builder Club catalog view"],
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/catalog/builder-club/:id",
sourceFile: "src/app/admin/catalog/builder-club/[id]/page.tsx",
targetPath: "/admin/economy/catalog/builder-club/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.CATALOG_VIEW],
mutate: [PERMS.CATALOG_EDIT],
},
dependencies: {
queries: ["CatalogItemsBc", "CatalogPagesBc"],
mutations: [
"createBcItem",
"deleteBcItem",
"updateBcItem",
"updateBcPage",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/catalog/maintenance",
sourceFile: "src/app/admin/catalog/maintenance/page.tsx",
targetPath: "/admin/economy/catalog/maintenance",
decision: "REBUILD",
capabilities: {
read: [PERMS.CATALOG_VIEW],
mutate: [PERMS.CATALOG_EDIT],
},
dependencies: {
queries: ["getFurniHealthAction", "previewAlignIdsAction"],
mutations: [
"applyAlignIdsAction",
"fixCatalogOffersAction",
"fixEverythingAction",
"fixSpriteIdsAction",
"reconcileIdsAction",
"removeDuplicateItemsBaseAction",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/items",
sourceFile: "src/app/admin/items/page.tsx",
targetPath: "/admin/economy/items",
decision: "REBUILD",
capabilities: { read: [PERMS.CATALOG_VIEW], mutate: [] },
dependencies: { queries: ["fetchAdminList", "ItemsBase"], mutations: [] },
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/items/:id",
sourceFile: "src/app/admin/items/[id]/page.tsx",
targetPath: "/admin/economy/items/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.CATALOG_VIEW],
mutate: [PERMS.CATALOG_EDIT],
},
dependencies: {
queries: ["ItemsBase"],
mutations: ["updateItemsBase"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
// Batch 2: commerce products, listings, vouchers, and subscriptions.
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/shop",
sourceFile: "src/app/admin/shop/page.tsx",
targetPath: "/admin/economy/commerce/shop",
decision: "REBUILD",
capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
dependencies: {
queries: ["WebsitePaypalTransactions", "WebsiteShopArticles"],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/shop/new",
sourceFile: "src/app/admin/shop/new/page.tsx",
targetPath: "/admin/economy/commerce/shop/new",
decision: "REBUILD",
capabilities: {
read: [PERMS.SHOP_EDIT],
mutate: [PERMS.SHOP_EDIT],
},
dependencies: { queries: [], mutations: ["createShopArticle"] },
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/shop/:id",
sourceFile: "src/app/admin/shop/[id]/page.tsx",
targetPath: "/admin/economy/commerce/shop/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.SHOP_VIEW],
mutate: [PERMS.SHOP_EDIT],
},
dependencies: {
queries: ["WebsiteShopArticles"],
mutations: ["deleteShopArticle", "updateShopArticle"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/marketplace",
sourceFile: "src/app/admin/marketplace/page.tsx",
targetPath: "/admin/economy/commerce/marketplace",
decision: "REBUILD",
capabilities: {
read: [PERMS.SHOP_VIEW],
mutate: [PERMS.SHOP_EDIT],
},
dependencies: {
queries: ["MarketplaceItems", "User"],
mutations: ["cancelMarketplaceListing"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/vouchers",
sourceFile: "src/app/admin/vouchers/page.tsx",
targetPath: "/admin/economy/commerce/vouchers",
decision: "REBUILD",
capabilities: {
read: [PERMS.SHOP_VIEW],
mutate: [PERMS.SHOP_EDIT],
},
dependencies: {
queries: ["WebsiteShopVouchers"],
mutations: ["createVoucher", "deleteVoucher"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/subscriptions",
sourceFile: "src/app/admin/subscriptions/page.tsx",
targetPath: "/admin/economy/commerce/subscriptions",
decision: "REBUILD",
capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
dependencies: { queries: ["users_subscriptions"], mutations: [] },
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Current route reads users_subscriptions through raw SQL"],
}),
// Batch 3: transaction history and rare-value configuration.
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/transactions",
sourceFile: "src/app/admin/transactions/page.tsx",
targetPath: "/admin/economy/history/transactions",
decision: "REBUILD",
capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
dependencies: {
queries: ["User", "WebsitePaypalTransactions"],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/rare-values",
sourceFile: "src/app/admin/rare-values/page.tsx",
targetPath: "/admin/economy/value/rare-values",
decision: "REBUILD",
capabilities: {
read: [PERMS.SHOP_VIEW],
mutate: [PERMS.SHOP_EDIT],
},
dependencies: {
queries: ["WebsiteRareValueCategories", "WebsiteRareValues"],
mutations: [
"createCategory",
"createValue",
"deleteCategory",
"deleteValue",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
// Batch 4: badges, achievements, soundtracks, and calendar rewards.
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/badges",
sourceFile: "src/app/admin/badges/page.tsx",
targetPath: "/admin/economy/rewards/badges",
decision: "REBUILD",
capabilities: {
read: [PERMS.CATALOG_VIEW],
mutate: [PERMS.CATALOG_EDIT],
},
dependencies: {
queries: ["WebsiteBadges"],
mutations: ["giveBadge", "uploadBadge"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/achievements",
sourceFile: "src/app/admin/achievements/page.tsx",
targetPath: "/admin/economy/rewards/achievements",
decision: "REBUILD",
capabilities: { read: [PERMS.CATALOG_VIEW], mutate: [] },
dependencies: { queries: ["Achievements"], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/sounds",
sourceFile: "src/app/admin/sounds/page.tsx",
targetPath: "/admin/economy/rewards/sounds",
decision: "REBUILD",
capabilities: {
read: [PERMS.CATALOG_VIEW],
mutate: [PERMS.CATALOG_EDIT],
},
dependencies: {
queries: ["CatalogItems", "siteSettings.get", "Soundtracks"],
mutations: [
"deleteSoundtrack",
"POST /api/admin/sounds/upload",
"updateSoundtrack",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/calendar",
sourceFile: "src/app/admin/calendar/page.tsx",
targetPath: "/admin/economy/rewards/calendar",
decision: "REBUILD",
capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
dependencies: {
queries: ["CalendarCampaigns", "CalendarRewards"],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedEconomyEntry({
surface: "admin",
legacyPath: "/admin/calendar/:id",
sourceFile: "src/app/admin/calendar/[id]/page.tsx",
targetPath: "/admin/economy/rewards/calendar/:id",
decision: "REBUILD",
capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
dependencies: {
queries: ["CalendarCampaigns", "CalendarRewards"],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
];
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { ownedLegacyPages } from "./discover-legacy-pages";
import { hotelMigrationEntries } from "./hotel";
import { validateMigrationEntries } from "./validate-matrix";
const HOTEL_PREFIXES = [
"/admin/rooms",
"/admin/navigation",
"/admin/radio",
"/admin/studio",
] as const;
describe("hotelMigrationEntries", () => {
it("maps all hotel tools below the hotel target root", () => {
expect(
validateMigrationEntries(
ownedLegacyPages(HOTEL_PREFIXES),
hotelMigrationEntries,
),
).toEqual([]);
expect(
hotelMigrationEntries.every(
(row) =>
row.targetPath === null || row.targetPath.startsWith("/admin/hotel/"),
),
).toBe(true);
});
it("preserves room ids while merging only the detail aliases", () => {
expect(
hotelMigrationEntries
.filter((row) => row.legacyPath.startsWith("/admin/rooms"))
.map(({ legacyPath, targetPath, decision }) => ({
legacyPath,
targetPath,
decision,
})),
).toEqual([
{
legacyPath: "/admin/rooms",
targetPath: "/admin/hotel/rooms",
decision: "REBUILD",
},
{
legacyPath: "/admin/rooms/:id",
targetPath: "/admin/hotel/rooms/:id",
decision: "MERGE",
},
{
legacyPath: "/admin/rooms/:id/furni",
targetPath: "/admin/hotel/rooms/:id/furni",
decision: "REBUILD",
},
{
legacyPath: "/admin/rooms/edit/:id",
targetPath: "/admin/hotel/rooms/:id",
decision: "MERGE",
},
{
legacyPath: "/admin/rooms/show/:id",
targetPath: "/admin/hotel/rooms/:id",
decision: "MERGE",
},
]);
});
it("keeps every audited workflow planned without parity claims", () => {
expect(
hotelMigrationEntries.every(
(row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
),
).toBe(true);
});
it("requires privileged audit and an explicit capability for every mutation", () => {
const mutationRows = hotelMigrationEntries.filter(
(row) => row.dependencies.mutations.length > 0,
);
expect(mutationRows.length).toBeGreaterThan(0);
expect(
mutationRows.every(
(row) =>
row.capabilities.mutate.length > 0 &&
row.auditRequirement === "PRIVILEGED_MUTATION",
),
).toBe(true);
});
it("classifies Studio Audit repair operations as privileged mutations", () => {
const studioAudit = hotelMigrationEntries.find(
(row) => row.legacyPath === "/admin/studio/audit",
);
expect(studioAudit).toEqual(
expect.objectContaining({
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: expect.objectContaining({
mutations: expect.arrayContaining([
"POST /api/admin/import/audit",
"applyCatalogSql",
"ensureDirectories",
"repairDuplicateClassnames",
"repairFurniData",
"repairMissingIcons",
"repairMissingNitros",
"repairOrphanedCatalog",
]),
}),
auditRequirement: "PRIVILEGED_MUTATION",
}),
);
});
it("records the actual permissions for room, radio, and Studio mutations", () => {
expect(hotelMigrationEntries).toContainEqual(
expect.objectContaining({
legacyPath: "/admin/rooms/edit/:id",
capabilities: {
read: [PERMS.ROOMS_EDIT],
mutate: [PERMS.ROOMS_EDIT, PERMS.ROOMS_DELETE],
},
}),
);
expect(hotelMigrationEntries).toContainEqual(
expect.objectContaining({
legacyPath: "/admin/radio/api-keys",
capabilities: {
read: [PERMS.RADIO_VIEW],
mutate: [PERMS.RADIO_EDIT],
},
}),
);
expect(hotelMigrationEntries).toContainEqual(
expect.objectContaining({
legacyPath: "/admin/studio/sync",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
}),
);
});
});
@@ -0,0 +1,671 @@
import { PERMS } from "@/lib/permission-slugs";
import type { MigrationEntry } from "./types";
type PlannedHotelEntry = Omit<
MigrationEntry,
"targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
> & {
requiredTests?: MigrationEntry["requiredTests"];
notes?: MigrationEntry["notes"];
};
function plannedHotelEntry(entry: PlannedHotelEntry): MigrationEntry {
return {
...entry,
targetDomain: "hotel",
requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: entry.notes ?? [],
};
}
export const hotelMigrationEntries: readonly MigrationEntry[] = [
// Batch 1: room directory, consolidated room detail, furniture, and navigator.
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/rooms",
sourceFile: "src/app/admin/rooms/page.tsx",
targetPath: "/admin/hotel/rooms",
decision: "REBUILD",
capabilities: { read: [PERMS.ROOMS_VIEW], mutate: [] },
dependencies: {
queries: ["fetchAdminList", "Rooms", "GET /api/admin/export?type=rooms"],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/rooms/:id",
sourceFile: "src/app/admin/rooms/[id]/page.tsx",
targetPath: "/admin/hotel/rooms/:id",
decision: "MERGE",
capabilities: { read: [PERMS.ROOMS_VIEW], mutate: [] },
dependencies: { queries: [], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "COMPLETE",
requiredTests: ["e2e"],
notes: ["Redirect-only alias for the room detail workflow"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/rooms/:id/furni",
sourceFile: "src/app/admin/rooms/[id]/furni/page.tsx",
targetPath: "/admin/hotel/rooms/:id/furni",
decision: "REBUILD",
capabilities: {
read: [PERMS.ROOMS_VIEW],
mutate: [PERMS.ROOMS_EDIT],
},
dependencies: {
queries: ["Items", "ItemsBase", "Rooms", "User", "loadRoom"],
mutations: [
"bulkDeleteRoomItems",
"deleteRoomItem",
"RCON kickall",
"RCON reloadroom",
"roomRconAction",
"updateRoomItem",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Preserve the furniture suffix and room id in the target route"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/rooms/edit/:id",
sourceFile: "src/app/admin/rooms/edit/[id]/page.tsx",
targetPath: "/admin/hotel/rooms/:id",
decision: "MERGE",
capabilities: {
read: [PERMS.ROOMS_EDIT],
mutate: [PERMS.ROOMS_EDIT, PERMS.ROOMS_DELETE],
},
dependencies: {
queries: ["Rooms", "User", "loadRoom"],
mutations: [
"deleteRoom",
"RCON kickall",
"RCON reloadroom",
"RCON updateroom",
"roomRconAction",
"updateRoom",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Merge editing and RCON controls into the room detail workflow"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/rooms/show/:id",
sourceFile: "src/app/admin/rooms/show/[id]/page.tsx",
targetPath: "/admin/hotel/rooms/:id",
decision: "MERGE",
capabilities: { read: [PERMS.ROOMS_VIEW], mutate: [] },
dependencies: { queries: ["Rooms", "User", "loadRoom"], mutations: [] },
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Merge the canonical read-only detail alias without changing its id",
],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/navigation",
sourceFile: "src/app/admin/navigation/page.tsx",
targetPath: "/admin/hotel/navigation",
decision: "REBUILD",
capabilities: { read: [PERMS.PAGES_VIEW], mutate: [] },
dependencies: {
queries: [
"NavigatorFlatcats",
"NavigatorPubliccats",
"navigator_publics raw SQL",
"Rooms",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
// Batch 2: radio overview, monitoring, moderation, and runtime configuration.
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio",
sourceFile: "src/app/admin/radio/page.tsx",
targetPath: "/admin/hotel/radio/overview",
decision: "REBUILD",
capabilities: {
read: [PERMS.RADIO_VIEW],
mutate: [PERMS.RADIO_EDIT],
},
dependencies: {
queries: ["RadioApplications", "RadioSchedules", "RadioShouts"],
mutations: ["deleteShout"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/api-keys",
sourceFile: "src/app/admin/radio/api-keys/page.tsx",
targetPath: "/admin/hotel/radio/api-keys",
decision: "REBUILD",
capabilities: {
read: [PERMS.RADIO_VIEW],
mutate: [PERMS.RADIO_EDIT],
},
dependencies: {
queries: ["RadioApiKeys"],
mutations: ["createApiKey", "deleteApiKey", "toggleApiKey"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"API-key generation and lifecycle changes are credential mutations",
],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/autodj",
sourceFile: "src/app/admin/radio/autodj/page.tsx",
targetPath: "/admin/hotel/radio/autodj",
decision: "REBUILD",
capabilities: {
read: [PERMS.RADIO_VIEW],
mutate: [PERMS.RADIO_EDIT],
},
dependencies: {
queries: ["RadioAutoDjPlaylist"],
mutations: ["createTrack", "deleteTrack", "toggleTrack"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/banners",
sourceFile: "src/app/admin/radio/banners/page.tsx",
targetPath: "/admin/hotel/radio/banners",
decision: "REBUILD",
capabilities: {
read: [PERMS.RADIO_VIEW],
mutate: [PERMS.RADIO_EDIT],
},
dependencies: {
queries: ["RadioBanners"],
mutations: [
"createRadioBanner",
"deleteRadioBanner",
"updateRadioBanner",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/embed",
sourceFile: "src/app/admin/radio/embed/page.tsx",
targetPath: "/admin/hotel/radio/embed",
decision: "REBUILD",
capabilities: { read: [PERMS.RADIO_VIEW], mutate: [] },
dependencies: {
queries: ["resolveHotelName", "siteSettings.get"],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/history",
sourceFile: "src/app/admin/radio/history/page.tsx",
targetPath: "/admin/hotel/radio/history",
decision: "REBUILD",
capabilities: { read: [PERMS.RADIO_VIEW], mutate: [] },
dependencies: { queries: ["RadioHistory", "User"], mutations: [] },
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/moderation",
sourceFile: "src/app/admin/radio/moderation/page.tsx",
targetPath: "/admin/hotel/radio/moderation",
decision: "REBUILD",
capabilities: {
read: [PERMS.RADIO_VIEW],
mutate: [PERMS.RADIO_EDIT],
},
dependencies: {
queries: ["RadioShouts"],
mutations: ["deleteShout"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Shout deletion is a radio moderation mutation"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/monitoring",
sourceFile: "src/app/admin/radio/monitoring/page.tsx",
targetPath: "/admin/hotel/radio/monitoring",
decision: "REBUILD",
capabilities: { read: [PERMS.RADIO_VIEW], mutate: [] },
dependencies: {
queries: [
"fetch radio_listeners_api_url",
"fetch radio_now_playing_api_url",
"siteSettings.get",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Monitoring probes operator-configured external HTTP endpoints"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/points",
sourceFile: "src/app/admin/radio/points/page.tsx",
targetPath: "/admin/hotel/radio/points",
decision: "REBUILD",
capabilities: {
read: [PERMS.RADIO_VIEW],
mutate: [PERMS.RADIO_EDIT],
},
dependencies: {
queries: ["siteSettings.get", "WebsiteSetting"],
mutations: ["savePoints", "siteSettings.reload"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/ranks",
sourceFile: "src/app/admin/radio/ranks/page.tsx",
targetPath: "/admin/hotel/radio/ranks",
decision: "REBUILD",
capabilities: {
read: [PERMS.RADIO_VIEW],
mutate: [PERMS.RADIO_EDIT],
},
dependencies: {
queries: ["RadioRanks"],
mutations: ["createRadioRank", "deleteRadioRank", "updateRadioRank"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/radio/settings",
sourceFile: "src/app/admin/radio/settings/page.tsx",
targetPath: "/admin/hotel/radio/settings",
decision: "REBUILD",
capabilities: {
read: [PERMS.RADIO_VIEW],
mutate: [PERMS.RADIO_EDIT],
},
dependencies: {
queries: ["WebsiteSetting"],
mutations: [
"saveRadioSetting",
"saveRadioSettings",
"siteSettings.reload",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Settings include external-service credentials, runtime flags, webhooks, and custom code",
],
}),
// Batch 3: Studio audit and asset import workflows.
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio",
sourceFile: "src/app/admin/studio/page.tsx",
targetPath: "/admin/hotel/studio/furni",
decision: "MERGE",
capabilities: { read: [PERMS.ASSETS_IMPORT], mutate: [] },
dependencies: { queries: [], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "COMPLETE",
requiredTests: ["e2e"],
notes: ["Redirect-only entry into the Studio furniture workflow"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/audit",
sourceFile: "src/app/admin/studio/audit/page.tsx",
targetPath: "/admin/hotel/studio/audit",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: ["runCatalogAudit"],
mutations: [
"POST /api/admin/import/audit",
"applyCatalogSql",
"ensureDirectories",
"repairDuplicateClassnames",
"repairFurniData",
"repairMissingIcons",
"repairMissingNitros",
"repairOrphanedCatalog",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Audit repairs can mutate asset files, catalog data, FurnitureData, and database structure",
],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/badges",
sourceFile: "src/app/admin/studio/badges/page.tsx",
targetPath: "/admin/hotel/studio/badges",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT, PERMS.CATALOG_EDIT],
},
dependencies: {
queries: [
"GET /api/admin/import/badges",
"getBadgeData",
"searchBadges",
"WebsiteBadges",
],
mutations: [
"importBadgeSynced",
"POST /api/admin/import/badges",
"updateBadge",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Imports external badge assets into the configured gamedata filesystem",
],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/clone",
sourceFile: "src/app/admin/studio/clone/page.tsx",
targetPath: "/admin/hotel/studio/clone",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: [
"GET /api/admin/import/clone",
"GET /api/admin/import/clone/icon",
"getCloneList",
"listSources",
],
mutations: [
"DELETE /api/admin/import/clone",
"POST /api/admin/import/clone",
"POST /api/admin/import/clone/batch",
"cloneSingleFurni",
"rcon.updateCatalog",
"rcon.updateItems",
"syncAssetsToGamedataBundle",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Clone imports combine external HTTP, database, filesystem, and RCON mutations",
],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/clothing",
sourceFile: "src/app/admin/studio/clothing/page.tsx",
targetPath: "/admin/hotel/studio/clothing",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: [
"GET /api/admin/import/clothing",
"GET /api/admin/import/clothing/sets",
"getClothingSetList",
"getFigureList",
],
mutations: [
"DELETE /api/admin/import/clothing",
"POST /api/admin/import/clothing/batch",
"POST /api/admin/import/clothing/sets/batch",
"importClothingSet",
"importSingleFigure",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Clothing imports download and delete asset files"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/effects",
sourceFile: "src/app/admin/studio/effects/page.tsx",
targetPath: "/admin/hotel/studio/effects",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: ["GET /api/admin/import/effects", "getEffectList"],
mutations: [
"DELETE /api/admin/import/effects",
"importSingleEffect",
"POST /api/admin/import/effects/batch",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Effect imports download and delete asset files"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/furni",
sourceFile: "src/app/admin/studio/furni/page.tsx",
targetPath: "/admin/hotel/studio/furni",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: [
"GET /api/admin/import/clone",
"GET /api/admin/import/furni",
"getHabboGamedataHotel",
"getTreeFlat",
"previewAutoCatalog",
"siteSettings.getBool",
],
mutations: [
"deleteImportedFurni",
"PATCH /api/admin/import/furni",
"POST /api/admin/import/furni",
"POST /api/admin/import/furni/batch",
"PUT /api/admin/import/furni/nitro-editor",
"rcon.updateCatalog",
"rcon.updateItems",
"setFurnidataTranslateEnabled",
"syncAssetsToGamedataBundle",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Furniture imports combine official Habbo HTTP sources, catalog and item tables, asset files, and RCON reloads",
],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/maintenance",
sourceFile: "src/app/admin/studio/maintenance/page.tsx",
targetPath: "/admin/hotel/studio/maintenance",
decision: "REBUILD",
capabilities: {
read: [PERMS.CATALOG_EDIT],
mutate: [PERMS.CATALOG_EDIT],
},
dependencies: {
queries: ["getFurniHealthAction", "previewAlignIdsAction"],
mutations: [
"applyAlignIdsAction",
"fixCatalogOffersAction",
"fixEverythingAction",
"fixSpriteIdsAction",
"reconcileIdsAction",
"removeDuplicateItemsBaseAction",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["The current page relies on action-level catalog-edit enforcement"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/pets",
sourceFile: "src/app/admin/studio/pets/page.tsx",
targetPath: "/admin/hotel/studio/pets",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: [
"GET /api/admin/import/pets",
"GET /api/admin/import/pets/icon",
"getPetList",
],
mutations: [
"DELETE /api/admin/import/pets",
"importSinglePet",
"POST /api/admin/import/pets/batch",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Pet imports download and delete asset files"],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/repair-icons",
sourceFile: "src/app/admin/studio/repair-icons/page.tsx",
targetPath: "/admin/hotel/studio/repair-icons",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: [],
mutations: [
"POST /api/admin/import/furni/repair-icons",
"repairMissingIcons",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Repairs icon files from local bundles or configured external sources",
],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/sync",
sourceFile: "src/app/admin/studio/sync/page.tsx",
targetPath: "/admin/hotel/studio/sync",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: ["ItemsBase", "listSources"],
mutations: [
"appendFurniEntriesBatch",
"cloneSingleFurni",
"POST /api/admin/import/clone/sync-all",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Full source sync mutates item data and asset files from external clone sources",
],
}),
plannedHotelEntry({
surface: "admin",
legacyPath: "/admin/studio/upload",
sourceFile: "src/app/admin/studio/upload/page.tsx",
targetPath: "/admin/hotel/studio/upload",
decision: "REBUILD",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: [],
mutations: ["POST /api/admin/import/furni/upload", "uploadSingleFurni"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Uploads mutate database, catalog, FurnitureData, and asset files"],
}),
];
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { discoverLegacyPages } from "./discover-legacy-pages";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
import { validateMigrationEntries } from "./validate-matrix";
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
it("covers all 137 legacy pages exactly once", () => {
const discovered = discoverLegacyPages();
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(137);
expect(
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
).toEqual([]);
});
it("contains no undecided evidence markers", () => {
expect(JSON.stringify(HOUSEKEEPING_MIGRATION_MATRIX)).not.toMatch(
/TBD|TODO|FIXME|UNCLASSIFIED/,
);
});
it("is sorted by legacy path", () => {
expect(HOUSEKEEPING_MIGRATION_MATRIX.map((row) => row.legacyPath)).toEqual(
[...HOUSEKEEPING_MIGRATION_MATRIX.map((row) => row.legacyPath)].sort(
(a, b) => a.localeCompare(b),
),
);
});
});
@@ -0,0 +1,15 @@
import { contentMigrationEntries } from "./content";
import { economyMigrationEntries } from "./economy";
import { hotelMigrationEntries } from "./hotel";
import { operationsMigrationEntries } from "./operations";
import { peopleMigrationEntries } from "./people";
import { systemMigrationEntries } from "./system";
export const HOUSEKEEPING_MIGRATION_MATRIX = [
...operationsMigrationEntries,
...peopleMigrationEntries,
...contentMigrationEntries,
...economyMigrationEntries,
...hotelMigrationEntries,
...systemMigrationEntries,
].sort((a, b) => a.legacyPath.localeCompare(b.legacyPath));
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { operationsMigrationEntries } from "./operations";
describe("operationsMigrationEntries", () => {
it("covers the legacy dashboard once", () => {
expect(operationsMigrationEntries).toHaveLength(1);
expect(operationsMigrationEntries[0]).toMatchObject({
surface: "admin",
legacyPath: "/admin",
sourceFile: "src/app/admin/page.tsx",
targetDomain: "operations",
targetPath: "/admin/work",
decision: "REBUILD",
capabilities: { read: [PERMS.ADMIN_DASHBOARD], mutate: [] },
dependencies: {
queries: [
"users",
"active bans",
"website articles",
"staff activities",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
requiredTests: ["integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: [
"Replace metric dashboard with capability-derived operational home",
],
});
});
});
@@ -0,0 +1,27 @@
import { PERMS } from "@/lib/permission-slugs";
import type { MigrationEntry } from "./types";
export const operationsMigrationEntries: readonly MigrationEntry[] = [
{
surface: "admin",
legacyPath: "/admin",
sourceFile: "src/app/admin/page.tsx",
targetDomain: "operations",
targetPath: "/admin/work",
decision: "REBUILD",
capabilities: { read: [PERMS.ADMIN_DASHBOARD], mutate: [] },
dependencies: {
queries: ["users", "active bans", "website articles", "staff activities"],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
requiredTests: ["integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: [
"Replace metric dashboard with capability-derived operational home",
],
},
];
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { discoverLegacyPages } from "./discover-legacy-pages";
import { operationsMigrationEntries } from "./operations";
import { peopleMigrationEntries } from "./people";
import { validateMigrationEntries } from "./validate-matrix";
const PEOPLE_PREFIXES = [
"/admin/users",
"/admin/online",
"/admin/guilds",
"/admin/applications",
"/admin/teams",
"/admin/bans",
"/admin/ip",
"/admin/vpn",
"/admin/wordfilter",
"/admin/moderation",
"/admin/tickets",
"/admin/help-tickets",
"/mod",
] as const;
describe("peopleMigrationEntries", () => {
it("merges every mod page into a people workflow", () => {
const modRows = peopleMigrationEntries.filter(
(row) => row.surface === "mod",
);
expect(modRows).toHaveLength(13);
expect(modRows.every((row) => row.decision === "MERGE")).toBe(true);
expect(
modRows.every((row) => row.targetPath?.startsWith("/admin/people/")),
).toBe(true);
});
it("merges duplicate user detail and edit aliases", () => {
for (const legacyPath of ["/admin/users/:id", "/admin/users/:id/edit"]) {
expect(peopleMigrationEntries).toContainEqual(
expect.objectContaining({ legacyPath, decision: "MERGE" }),
);
}
});
it("keeps planned rows free of unverified parity claims", () => {
expect(
peopleMigrationEntries.every(
(row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
),
).toBe(true);
});
it("covers every Operations and People legacy page exactly once", () => {
const expected = discoverLegacyPages().filter(
(page) =>
page.legacyPath === "/admin" ||
PEOPLE_PREFIXES.some(
(prefix) =>
page.legacyPath === prefix ||
page.legacyPath.startsWith(`${prefix}/`),
),
);
expect(
validateMigrationEntries(expected, [
...operationsMigrationEntries,
...peopleMigrationEntries,
]),
).toEqual([]);
});
});
@@ -0,0 +1,835 @@
import { PERMS } from "@/lib/permission-slugs";
import type { MigrationEntry } from "./types";
type PlannedPeopleEntry = Omit<
MigrationEntry,
"targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
> & {
requiredTests?: MigrationEntry["requiredTests"];
notes?: MigrationEntry["notes"];
};
function plannedPeopleEntry(entry: PlannedPeopleEntry): MigrationEntry {
return {
...entry,
targetDomain: "people",
requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: entry.notes ?? [],
};
}
export const peopleMigrationEntries: readonly MigrationEntry[] = [
// Batch 1: users, aliases, online users, guilds, and applications.
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/users",
sourceFile: "src/app/admin/users/page.tsx",
targetPath: "/admin/people/users",
decision: "REBUILD",
capabilities: {
read: [PERMS.USERS_VIEW],
mutate: [PERMS.USERS_EDIT, PERMS.USERS_BAN],
},
dependencies: {
queries: ["fetchAdminList", "User"],
mutations: [
"createUser",
"bulkAdjustCurrency",
"bulkBan",
"bulkGiveBadge",
"bulkUnban",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/users/edit/:id",
sourceFile: "src/app/admin/users/edit/[id]/page.tsx",
targetPath: "/admin/people/users/:id/edit",
decision: "REBUILD",
capabilities: {
read: [PERMS.USERS_EDIT],
mutate: [PERMS.USERS_EDIT, PERMS.USERS_BAN, PERMS.USERS_RESET_PASSWORD],
},
dependencies: {
queries: ["loadUserById", "getWatchedUserIds", "permissions"],
mutations: [
"updateUser",
"giveBadge",
"removeBadge",
"banUser",
"unbanUser",
"alertUser",
"disconnectUser",
"muteUser",
"unmuteUser",
"resetPassword",
"sendCredits",
"setTradeLock",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/users/multi-accounts",
sourceFile: "src/app/admin/users/multi-accounts/page.tsx",
targetPath: "/admin/people/users/multi-accounts",
decision: "REBUILD",
capabilities: { read: [PERMS.USERS_VIEW], mutate: [] },
dependencies: { queries: ["detectMultiAccounts"], mutations: [] },
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/users/show/:id",
sourceFile: "src/app/admin/users/show/[id]/page.tsx",
targetPath: "/admin/people/users/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.USERS_VIEW],
mutate: [PERMS.USERS_EDIT, PERMS.USERS_BAN, PERMS.USERS_RESET_PASSWORD],
},
dependencies: {
queries: ["loadUserById", "loadUserSanctions", "getWatchedUserIds"],
mutations: [
"alertUser",
"disconnectUser",
"muteUser",
"unmuteUser",
"resetPassword",
"sendCredits",
"setTradeLock",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/users/:id",
sourceFile: "src/app/admin/users/[id]/page.tsx",
targetPath: "/admin/people/users/:id",
decision: "MERGE",
capabilities: { read: [PERMS.USERS_VIEW], mutate: [] },
dependencies: { queries: [], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "COMPLETE",
requiredTests: ["e2e"],
notes: ["Redirect-only duplicate of the canonical user detail route"],
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/users/:id/edit",
sourceFile: "src/app/admin/users/[id]/edit/page.tsx",
targetPath: "/admin/people/users/:id/edit",
decision: "MERGE",
capabilities: { read: [PERMS.USERS_EDIT], mutate: [] },
dependencies: { queries: [], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "COMPLETE",
requiredTests: ["e2e"],
notes: ["Redirect-only duplicate of the canonical user edit route"],
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/online",
sourceFile: "src/app/admin/online/page.tsx",
targetPath: "/admin/people/community/online",
decision: "REBUILD",
capabilities: { read: [PERMS.USERS_VIEW], mutate: [] },
dependencies: { queries: ["fetchAdminList", "User"], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/guilds",
sourceFile: "src/app/admin/guilds/page.tsx",
targetPath: "/admin/people/community/guilds",
decision: "REBUILD",
capabilities: { read: [PERMS.USERS_VIEW], mutate: [] },
dependencies: {
queries: ["Guilds", "GuildsMembers", "User"],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/guilds/:id",
sourceFile: "src/app/admin/guilds/[id]/page.tsx",
targetPath: "/admin/people/community/guilds/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.USERS_VIEW],
mutate: [PERMS.USERS_EDIT],
},
dependencies: {
queries: ["Guilds", "GuildsForumsThreads", "GuildsMembers", "User"],
mutations: ["disbandGuild"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/applications",
sourceFile: "src/app/admin/applications/page.tsx",
targetPath: "/admin/people/staff/applications",
decision: "REBUILD",
capabilities: {
read: [PERMS.USERS_VIEW],
mutate: [PERMS.USERS_EDIT],
},
dependencies: {
queries: ["WebsiteStaffApplications", "User"],
mutations: ["dismissApplication"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
// Batch 2: staff, moderation, CFH, bans, IP, VPN, and word filter.
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/teams",
sourceFile: "src/app/admin/teams/page.tsx",
targetPath: "/admin/people/staff/teams",
decision: "REBUILD",
capabilities: {
read: [PERMS.USERS_VIEW],
mutate: [PERMS.USERS_EDIT],
},
dependencies: {
queries: ["WebsiteTeams"],
mutations: ["createTeam", "deleteTeam"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/moderation",
sourceFile: "src/app/admin/moderation/page.tsx",
targetPath: "/admin/people/moderation",
decision: "REBUILD",
capabilities: { read: [PERMS.MODERATION_VIEW], mutate: [] },
dependencies: {
queries: [
"getMinStaffRank",
"AdminAuditLog",
"Ban",
"SupportTickets",
"User",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/moderation/actions",
sourceFile: "src/app/admin/moderation/actions/page.tsx",
targetPath: "/admin/people/moderation/actions",
decision: "REBUILD",
capabilities: {
read: [PERMS.MODERATION_EDIT],
mutate: [PERMS.MODERATION_EDIT],
},
dependencies: {
queries: [],
mutations: [
"broadcastAlert",
"quickAlert",
"quickKick",
"quickMute",
"quickRoomKick",
"quickUnmute",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/moderation/cfh",
sourceFile: "src/app/admin/moderation/cfh/page.tsx",
targetPath: "/admin/people/moderation/cfh",
decision: "REBUILD",
capabilities: { read: [PERMS.MODERATION_VIEW], mutate: [] },
dependencies: { queries: ["SupportTickets", "User"], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/moderation/cfh/:id",
sourceFile: "src/app/admin/moderation/cfh/[id]/page.tsx",
targetPath: "/admin/people/moderation/cfh/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.MODERATION_VIEW],
mutate: [PERMS.MODERATION_EDIT],
},
dependencies: {
queries: ["Ban", "SupportTickets", "User"],
mutations: [
"assignCfhTicket",
"closeCfhTicket",
"quickAlert",
"quickKick",
"quickMute",
"updateCfhState",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/moderation/team",
sourceFile: "src/app/admin/moderation/team/page.tsx",
targetPath: "/admin/people/staff/moderation-team",
decision: "REBUILD",
capabilities: { read: [PERMS.MODERATION_VIEW], mutate: [] },
dependencies: {
queries: [
"getMinStaffRank",
"AdminAuditLog",
"SupportTickets",
"User",
"WebsiteTicket",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/bans",
sourceFile: "src/app/admin/bans/page.tsx",
targetPath: "/admin/people/moderation/bans",
decision: "REBUILD",
capabilities: {
read: [PERMS.BANS_VIEW],
mutate: [PERMS.USERS_BAN],
},
dependencies: {
queries: ["Ban"],
mutations: ["createBan", "liftBan"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/ip",
sourceFile: "src/app/admin/ip/page.tsx",
targetPath: "/admin/people/moderation/ip",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["WebsiteIpBlacklist", "WebsiteIpWhitelist"],
mutations: [
"addBlacklist",
"addWhitelist",
"deleteBlacklist",
"deleteWhitelist",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/vpn",
sourceFile: "src/app/admin/vpn/page.tsx",
targetPath: "/admin/people/moderation/vpn",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["siteSettings.getMany"],
mutations: ["saveVpn"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/wordfilter",
sourceFile: "src/app/admin/wordfilter/page.tsx",
targetPath: "/admin/people/moderation/word-filter",
decision: "REBUILD",
capabilities: {
read: [PERMS.WORDFILTER_VIEW],
mutate: [PERMS.WORDFILTER_EDIT],
},
dependencies: {
queries: ["WebsiteWordfilter"],
mutations: ["addWord", "deleteWord"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
// Batch 3: website tickets and help-center tickets.
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/tickets",
sourceFile: "src/app/admin/tickets/page.tsx",
targetPath: "/admin/people/support/tickets",
decision: "REBUILD",
capabilities: { read: [PERMS.TICKETS_VIEW], mutate: [] },
dependencies: {
queries: ["fetchUnifiedTicketInbox", "fetchTicketQueueOpenCounts"],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/tickets/desk",
sourceFile: "src/app/admin/tickets/desk/page.tsx",
targetPath: "/admin/people/support/tickets/desk",
decision: "REBUILD",
capabilities: { read: [PERMS.TICKETS_VIEW], mutate: [] },
dependencies: {
queries: ["fetchTicketQueueOpenCounts", "User", "WebsiteTicket"],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/tickets/templates",
sourceFile: "src/app/admin/tickets/templates/page.tsx",
targetPath: "/admin/people/support/tickets/templates",
decision: "REBUILD",
capabilities: {
read: [PERMS.TICKETS_EDIT],
mutate: [PERMS.TICKETS_EDIT],
},
dependencies: {
queries: ["WebsiteTicketTemplate"],
mutations: ["createTemplate", "deleteTemplate", "updateTemplate"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/tickets/:id",
sourceFile: "src/app/admin/tickets/[id]/page.tsx",
targetPath: "/admin/people/support/tickets/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.TICKETS_VIEW],
mutate: [PERMS.TICKETS_EDIT],
},
dependencies: {
queries: [
"getMinStaffRank",
"fetchTicketQueueOpenCounts",
"User",
"WebsiteTicket",
],
mutations: [
"adminReplyTicket",
"assignTicket",
"updateTicketPriority",
"updateTicketStatus",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/help-tickets",
sourceFile: "src/app/admin/help-tickets/page.tsx",
targetPath: "/admin/people/support/help-tickets",
decision: "REBUILD",
capabilities: { read: [PERMS.TICKETS_VIEW], mutate: [] },
dependencies: {
queries: [
"fetchTicketQueueOpenCounts",
"User",
"WebsiteHelpCenterTicketReplies",
"WebsiteHelpCenterTickets",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "admin",
legacyPath: "/admin/help-tickets/:id",
sourceFile: "src/app/admin/help-tickets/[id]/page.tsx",
targetPath: "/admin/people/support/help-tickets/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.TICKETS_VIEW],
mutate: [PERMS.TICKETS_EDIT, PERMS.USERS_BAN],
},
dependencies: {
queries: [
"getMinStaffRank",
"fetchTicketQueueOpenCounts",
"Ban",
"User",
"WebsiteHelpCenterCategories",
"WebsiteHelpCenterTicketReplies",
"WebsiteHelpCenterTickets",
],
mutations: [
"closeHelpCenterTicket",
"liftBanFromHelpTicket",
"reopenHelpCenterTicket",
"replyHelpCenterTicket",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
// Batch 4: merge all moderator routes into the People workflows.
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod",
sourceFile: "src/app/mod/page.tsx",
targetPath: "/admin/people/moderation",
decision: "MERGE",
capabilities: {
read: [
PERMS.MOD_CFH_VIEW,
PERMS.MODERATION_VIEW,
PERMS.MOD_ACTIONS,
PERMS.MODERATION_EDIT,
PERMS.MOD_BANS_VIEW,
PERMS.BANS_VIEW,
PERMS.MOD_TICKETS_VIEW,
PERMS.TICKETS_VIEW,
PERMS.MOD_TEAM_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.USERS_VIEW,
],
mutate: [],
},
dependencies: {
queries: [
"getMinStaffRank",
"Ban",
"SupportTickets",
"User",
"WebsiteHelpCenterTickets",
"WebsiteTicket",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
notes: ["Merge the moderator dashboard into capability-gated People work"],
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/actions",
sourceFile: "src/app/mod/actions/page.tsx",
targetPath: "/admin/people/moderation/actions",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_ACTIONS, PERMS.MODERATION_EDIT],
mutate: [PERMS.MOD_ACTIONS, PERMS.MODERATION_EDIT],
},
dependencies: {
queries: [],
mutations: [
"broadcastAlert",
"quickAlert",
"quickKick",
"quickMute",
"quickRoomKick",
"quickUnmute",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/bans",
sourceFile: "src/app/mod/bans/page.tsx",
targetPath: "/admin/people/moderation/bans",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_BANS_VIEW, PERMS.BANS_VIEW],
mutate: [],
},
dependencies: { queries: ["Ban"], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/cfh",
sourceFile: "src/app/mod/cfh/page.tsx",
targetPath: "/admin/people/moderation/cfh",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW],
mutate: [],
},
dependencies: { queries: ["SupportTickets", "User"], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/cfh/:id",
sourceFile: "src/app/mod/cfh/[id]/page.tsx",
targetPath: "/admin/people/moderation/cfh/:id",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW],
mutate: [PERMS.MOD_CFH_EDIT, PERMS.MODERATION_EDIT],
},
dependencies: {
queries: ["Ban", "SupportTickets", "User"],
mutations: [
"assignCfhTicket",
"closeCfhTicket",
"quickAlert",
"quickKick",
"quickMute",
"updateCfhState",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/help-tickets",
sourceFile: "src/app/mod/help-tickets/page.tsx",
targetPath: "/admin/people/support/help-tickets",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
mutate: [],
},
dependencies: {
queries: [
"fetchTicketQueueOpenCounts",
"User",
"WebsiteHelpCenterTicketReplies",
"WebsiteHelpCenterTickets",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/help-tickets/:id",
sourceFile: "src/app/mod/help-tickets/[id]/page.tsx",
targetPath: "/admin/people/support/help-tickets/:id",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
mutate: [PERMS.MOD_TICKETS_EDIT, PERMS.TICKETS_EDIT, PERMS.USERS_BAN],
},
dependencies: {
queries: [
"getMinStaffRank",
"fetchTicketQueueOpenCounts",
"User",
"WebsiteHelpCenterTicketReplies",
"WebsiteHelpCenterTickets",
],
mutations: [
"closeHelpCenterTicket",
"liftBanFromHelpTicket",
"reopenHelpCenterTicket",
"replyHelpCenterTicket",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/team",
sourceFile: "src/app/mod/team/page.tsx",
targetPath: "/admin/people/staff/moderation-team",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_TEAM_VIEW, PERMS.MODERATION_VIEW],
mutate: [],
},
dependencies: {
queries: [
"getMinStaffRank",
"AdminAuditLog",
"SupportTickets",
"User",
"WebsiteTicket",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/tickets",
sourceFile: "src/app/mod/tickets/page.tsx",
targetPath: "/admin/people/support/tickets",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
mutate: [],
},
dependencies: {
queries: ["fetchUnifiedTicketInbox", "fetchTicketQueueOpenCounts"],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/tickets/desk",
sourceFile: "src/app/mod/tickets/desk/page.tsx",
targetPath: "/admin/people/support/tickets/desk",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
mutate: [],
},
dependencies: {
queries: [
"fetchTicketQueueOpenCounts",
"User",
"WebsiteTicket",
"WebsiteTicketMessage",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/tickets/:id",
sourceFile: "src/app/mod/tickets/[id]/page.tsx",
targetPath: "/admin/people/support/tickets/:id",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
mutate: [PERMS.MOD_TICKETS_EDIT, PERMS.TICKETS_EDIT],
},
dependencies: {
queries: [
"getMinStaffRank",
"fetchTicketQueueOpenCounts",
"User",
"WebsiteTicket",
"WebsiteTicketMessage",
],
mutations: [
"adminReplyTicket",
"assignTicket",
"updateTicketPriority",
"updateTicketStatus",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/users",
sourceFile: "src/app/mod/users/page.tsx",
targetPath: "/admin/people/users",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_USERS_VIEW, PERMS.USERS_VIEW],
mutate: [],
},
dependencies: { queries: ["User"], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
plannedPeopleEntry({
surface: "mod",
legacyPath: "/mod/users/:id",
sourceFile: "src/app/mod/users/[id]/page.tsx",
targetPath: "/admin/people/users/:id",
decision: "MERGE",
capabilities: {
read: [PERMS.MOD_USERS_VIEW, PERMS.USERS_VIEW],
mutate: [],
},
dependencies: { queries: ["User"], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
}),
];
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { ownedLegacyPages } from "./discover-legacy-pages";
import { systemMigrationEntries } from "./system";
import { validateMigrationEntries } from "./validate-matrix";
const SYSTEM_PREFIXES = [
"/admin/alerts",
"/admin/analytics",
"/admin/commandocentrum",
"/admin/devops",
"/admin/emulator",
"/admin/housekeeping",
"/admin/logs",
"/admin/maintenance",
"/admin/menu",
"/admin/permissions",
"/admin/settings",
] as const;
describe("systemMigrationEntries", () => {
it("covers all 19 System pages exactly once", () => {
expect(systemMigrationEntries).toHaveLength(19);
expect(
validateMigrationEntries(
ownedLegacyPages(SYSTEM_PREFIXES),
systemMigrationEntries,
),
).toEqual([]);
});
it("removes legacy foundation-owned pages", () => {
expect(systemMigrationEntries).toEqual(
expect.arrayContaining([
expect.objectContaining({
legacyPath: "/admin/housekeeping",
decision: "REMOVE",
targetPath: null,
}),
expect.objectContaining({
legacyPath: "/admin/menu",
decision: "REMOVE",
targetPath: null,
}),
]),
);
});
it("keeps live permissions as a System workflow", () => {
expect(systemMigrationEntries).toContainEqual(
expect.objectContaining({
legacyPath: "/admin/permissions",
targetPath: "/admin/system/access/permissions",
decision: expect.not.stringMatching("REMOVE"),
}),
);
});
it("retains the read-only legacy housekeeping value in removal notes", () => {
const housekeeping = systemMigrationEntries.find(
(row) => row.legacyPath === "/admin/housekeeping",
);
expect(housekeeping?.dependencies.queries).toEqual(
expect.arrayContaining([
"exportPermissions",
"getAuditLogs",
"legacy ACL comparison",
]),
);
expect(housekeeping?.notes.join(" ")).toMatch(
/read-only.*export.*comparison/i,
);
});
it("uses only the approved System target roots", () => {
const allowedRoots = [
"/admin/system/access",
"/admin/system/configuration",
"/admin/system/observability",
"/admin/system/operations",
];
expect(
systemMigrationEntries.every(
(row) =>
row.targetPath === null ||
allowedRoots.some(
(root) =>
row.targetPath === root || row.targetPath?.startsWith(`${root}/`),
),
),
).toBe(true);
});
it("keeps non-removal workflows planned without parity claims", () => {
expect(
systemMigrationEntries
.filter((row) => row.decision !== "REMOVE")
.every(
(row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
),
).toBe(true);
});
it("records authoritative capabilities for privileged System mutations", () => {
expect(systemMigrationEntries).toEqual(
expect.arrayContaining([
expect.objectContaining({
legacyPath: "/admin/commandocentrum",
capabilities: {
read: [PERMS.RCON_EXECUTE],
mutate: [PERMS.RCON_EXECUTE],
},
auditRequirement: "PRIVILEGED_MUTATION",
}),
expect.objectContaining({
legacyPath: "/admin/permissions/:id",
capabilities: {
read: [PERMS.PERMISSIONS_MANAGE],
mutate: [PERMS.PERMISSIONS_MANAGE],
},
auditRequirement: "PRIVILEGED_MUTATION",
}),
]),
);
});
});
@@ -0,0 +1,442 @@
import { PERMS } from "@/lib/permission-slugs";
import type { MigrationEntry } from "./types";
type PlannedSystemEntry = Omit<
MigrationEntry,
"targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
> & {
requiredTests?: MigrationEntry["requiredTests"];
notes?: MigrationEntry["notes"];
};
function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry {
return {
...entry,
targetDomain: "system",
requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: entry.notes ?? [],
};
}
export const systemMigrationEntries: readonly MigrationEntry[] = [
// Operator alerts and read-only analytics.
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/alerts",
sourceFile: "src/app/admin/alerts/page.tsx",
targetPath: "/admin/system/operations/alerts",
decision: "REBUILD",
capabilities: {
read: [PERMS.NOTIFICATIONS_VIEW],
mutate: [PERMS.NOTIFICATIONS_EDIT, PERMS.NOTIFICATIONS_VIEW],
},
dependencies: {
queries: ["AlertLogs"],
mutations: [
"markAllAlertsRead",
"rcon.send hotelalert",
"sendHotelAlert",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Marking alerts read and broadcasting a hotel alert use different current permission slugs",
],
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/analytics",
sourceFile: "src/app/admin/analytics/page.tsx",
targetPath: "/admin/system/observability/analytics",
decision: "REBUILD",
capabilities: { read: [PERMS.ANALYTICS_VIEW], mutate: [] },
dependencies: {
queries: [
"Ban",
"Rooms",
"RoomTradeLog",
"User",
"chatlogs_room raw SQL",
"redisCache",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/analytics/activity",
sourceFile: "src/app/admin/analytics/activity/page.tsx",
targetPath: "/admin/system/observability/analytics/activity",
decision: "REBUILD",
capabilities: { read: [PERMS.ANALYTICS_VIEW], mutate: [] },
dependencies: {
queries: [
"Ban",
"User",
"bans raw SQL",
"chatlogs_room raw SQL",
"commandlogs raw SQL",
"redisCache",
"users raw SQL",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/analytics/economy",
sourceFile: "src/app/admin/analytics/economy/page.tsx",
targetPath: "/admin/system/observability/analytics/economy",
decision: "REBUILD",
capabilities: { read: [PERMS.ANALYTICS_VIEW], mutate: [] },
dependencies: {
queries: [
"LogsShopPurchases",
"MarketplaceItems",
"RoomTradeLog",
"User",
"logs_shop_purchases raw SQL",
"redisCache",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
// Runtime operations, health, and emulator configuration.
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/commandocentrum",
sourceFile: "src/app/admin/commandocentrum/page.tsx",
targetPath: "/admin/system/operations/command-center",
decision: "REBUILD",
capabilities: {
read: [PERMS.RCON_EXECUTE],
mutate: [PERMS.RCON_EXECUTE],
},
dependencies: {
queries: [
"EmulatorErrors",
"StaffActivities",
"User",
"fetchOpsHealth",
"fetchOpsOnlineUsers",
"siteSettings.getBool",
],
mutations: [
"alertUser",
"disconnectUser",
"executeCommand",
"forwardUser",
"giveBadge",
"giveCredits",
"giveDiamonds",
"giveDuckets",
"hotelAlert",
"sendGift",
"setMotto",
"setRank",
"updateCatalog",
"updateNavigator",
"updateWordFilter",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"The current set-rank action combines RCON with a database update and rank-relative authorization",
],
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/devops",
sourceFile: "src/app/admin/devops/page.tsx",
targetPath: "/admin/system/observability/devops",
decision: "REBUILD",
capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [] },
dependencies: {
queries: [
"EmulatorErrors",
"EmulatorSettings",
"GET /api/admin/devops/health",
"fetchOpsHealth",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Health checks probe the database, Redis, RCON, and online users"],
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/devops/errors",
sourceFile: "src/app/admin/devops/errors/page.tsx",
targetPath: "/admin/system/observability/devops/errors",
decision: "REBUILD",
capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [] },
dependencies: { queries: ["EmulatorErrors"], mutations: [] },
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/emulator",
sourceFile: "src/app/admin/emulator/page.tsx",
targetPath: "/admin/system/configuration/emulator",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["EmulatorSettings", "EmulatorTexts"],
mutations: ["updateEmulatorSetting", "updateEmulatorText"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["The current actions upsert emulator-owned settings and texts"],
}),
// Foundation-owned legacy pages are retired, not migrated as workflows.
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/housekeeping",
sourceFile: "src/app/admin/housekeeping/page.tsx",
targetPath: null,
decision: "REMOVE",
capabilities: { read: [PERMS.SETTINGS_VIEW], mutate: [] },
dependencies: {
queries: [
"AclModelPermission",
"AclPermission",
"AclRole",
"WebsiteHousekeepingPermissions",
"exportPermissions",
"getAuditLogs",
"legacy ACL comparison",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
requiredTests: ["integration", "e2e"],
notes: [
"Remove the legacy foundation surface while preserving its read-only export and ACL comparison value in the live access workflow",
"Legacy housekeeping writes already redirect to live Permissions",
],
}),
// Staff, audit, chat, command, and trade observability.
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/logs",
sourceFile: "src/app/admin/logs/page.tsx",
targetPath: "/admin/system/observability/logs/staff",
decision: "REBUILD",
capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
dependencies: { queries: ["StaffActivities", "User"], mutations: [] },
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/logs/audit",
sourceFile: "src/app/admin/logs/audit/page.tsx",
targetPath: "/admin/system/observability/logs/audit",
decision: "REBUILD",
capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
dependencies: { queries: ["getAuditLogs"], mutations: [] },
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/logs/chat",
sourceFile: "src/app/admin/logs/chat/page.tsx",
targetPath: "/admin/system/observability/logs/chat",
decision: "REBUILD",
capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
dependencies: {
queries: ["chatlogs_room raw SQL", "loadChatLogList", "users raw SQL"],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/logs/commands",
sourceFile: "src/app/admin/logs/commands/page.tsx",
targetPath: "/admin/system/observability/logs/commands",
decision: "REBUILD",
capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
dependencies: {
queries: ["commandlogs raw SQL", "loadCommandLogList", "users raw SQL"],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/logs/trades",
sourceFile: "src/app/admin/logs/trades/page.tsx",
targetPath: "/admin/system/observability/logs/trades",
decision: "REBUILD",
capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
dependencies: {
queries: ["loadTradeLogList", "room_trade_log raw SQL", "users raw SQL"],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
// Global configuration and access control.
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/maintenance",
sourceFile: "src/app/admin/maintenance/page.tsx",
targetPath: "/admin/system/operations/maintenance",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["WebsiteSetting"],
mutations: ["saveMaintenance", "siteSettings.reload"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Maintenance changes global login availability and splash content"],
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/menu",
sourceFile: "src/app/admin/menu/page.tsx",
targetPath: null,
decision: "REMOVE",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["ADMIN_NAV_GROUPS", "siteSettings.get"],
mutations: ["saveAdminNavConfig", "siteSettings.update"],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
requiredTests: ["integration", "e2e"],
notes: [
"Remove the manual legacy menu editor because foundation manifests own navigation",
],
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/permissions",
sourceFile: "src/app/admin/permissions/page.tsx",
targetPath: "/admin/system/access/permissions",
decision: "REBUILD",
capabilities: {
read: [PERMS.PERMISSIONS_MANAGE],
mutate: [PERMS.PERMISSIONS_MANAGE],
},
dependencies: {
queries: ["fetchEmulatorRankSummaries", "users grouped by rank"],
mutations: [
"createRank",
"deleteRank",
"repairAdminNavAclGrants",
"rcon.send updatepermissions",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Keep this live ACL and emulator-rank workflow in System access"],
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/permissions/:id",
sourceFile: "src/app/admin/permissions/[id]/page.tsx",
targetPath: "/admin/system/access/permissions/:id",
decision: "REBUILD",
capabilities: {
read: [PERMS.PERMISSIONS_MANAGE],
mutate: [PERMS.PERMISSIONS_MANAGE],
},
dependencies: {
queries: [
"AclModelPermission",
"AclPermission",
"AclRole",
"User",
"fetchEmulatorRankForEdit",
],
mutations: [
"AclRole load-time synchronization",
"rcon.send updatepermissions",
"saveRank",
"setCmsPermissions",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"The current route can create or retitle a CMS role while loading rank detail",
],
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/settings",
sourceFile: "src/app/admin/settings/page.tsx",
targetPath: "/admin/system/configuration/settings",
decision: "REBUILD",
capabilities: {
read: [PERMS.SETTINGS_VIEW],
mutate: [PERMS.SETTINGS_EDIT],
},
dependencies: {
queries: ["WebsiteSetting"],
mutations: [
"createSetting",
"deleteSetting",
"saveManagedSettings",
"siteSettings.reload",
"updateSetting",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: [
"Global setting changes can also invalidate official gamedata and badge caches",
],
}),
];
@@ -0,0 +1,40 @@
export const HOUSEKEEPING_DOMAIN_IDS = [
"operations",
"people",
"content",
"economy",
"hotel",
"system",
] as const;
export type HousekeepingDomainId = (typeof HOUSEKEEPING_DOMAIN_IDS)[number];
export type LegacySurface = "admin" | "mod";
export type MigrationDecision = "REHOST" | "REBUILD" | "MERGE" | "REMOVE";
export type AuditRequirement = "NONE" | "MUTATION" | "PRIVILEGED_MUTATION";
export type AuditState = "UNAUDITED" | "PARTIAL" | "COMPLETE";
export type MigrationStatus =
| "PLANNED"
| "IN_PROGRESS"
| "VERIFIED"
| "REMOVED";
export interface LegacyPage {
surface: LegacySurface;
legacyPath: string;
sourceFile: string;
}
export interface MigrationEntry extends LegacyPage {
targetDomain: HousekeepingDomainId;
targetPath: string | null;
decision: MigrationDecision;
capabilities: { read: readonly string[]; mutate: readonly string[] };
dependencies: { queries: readonly string[]; mutations: readonly string[] };
auditRequirement: AuditRequirement;
localization: AuditState;
accessibility: AuditState;
requiredTests: readonly ("unit" | "integration" | "e2e" | "visual")[];
parityEvidence: readonly string[];
status: MigrationStatus;
notes: readonly string[];
}
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import type { LegacyPage, MigrationEntry } from "./types";
import { validateMigrationEntries } from "./validate-matrix";
const page = (legacyPath: string): LegacyPage => ({
surface: legacyPath.startsWith("/mod") ? "mod" : "admin",
legacyPath,
sourceFile: `src/app${legacyPath}/page.tsx`,
});
const entry = (legacyPath: string): MigrationEntry => ({
...page(legacyPath),
targetDomain: "system",
targetPath: "/admin/system/example",
decision: "REHOST",
capabilities: { read: ["admin.dashboard"], mutate: [] },
dependencies: { queries: [], mutations: [] },
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "COMPLETE",
requiredTests: ["unit"],
parityEvidence: [],
status: "PLANNED",
notes: [],
});
describe("validateMigrationEntries", () => {
it("reports missing, duplicate, and unknown legacy rows", () => {
const discovered = [page("/admin"), page("/admin/users")];
expect(
validateMigrationEntries(discovered, [
entry("/admin"),
entry("/admin"),
entry("/admin/ghost"),
]),
).toEqual([
"duplicate legacyPath: /admin",
"missing legacyPath: /admin/users",
"unknown legacyPath: /admin/ghost",
]);
});
it("rejects incomplete decisions", () => {
const issues = validateMigrationEntries(
[page("/admin")],
[{ ...entry("/admin"), targetPath: null, decision: "REBUILD" }],
);
expect(issues).toContain("REBUILD requires targetPath: /admin");
});
it("rejects a discovery surface mismatch for the same legacy path", () => {
expect(
validateMigrationEntries(
[page("/admin")],
[{ ...entry("/admin"), surface: "mod" }],
),
).toEqual([
"surface mismatch for legacyPath /admin: expected admin, received mod",
]);
});
it("rejects a discovery source file mismatch for the same legacy path", () => {
expect(
validateMigrationEntries(
[page("/admin")],
[
{
...entry("/admin"),
sourceFile: "src/app/admin/renamed/page.tsx",
},
],
),
).toEqual([
"sourceFile mismatch for legacyPath /admin: expected src/app/admin/page.tsx, received src/app/admin/renamed/page.tsx",
]);
});
it("rejects removal with a non-null target", () => {
expect(
validateMigrationEntries(
[page("/admin")],
[
{
...entry("/admin"),
decision: "REMOVE",
targetPath: "/admin/system/legacy",
},
],
),
).toEqual(["REMOVE requires null targetPath: /admin"]);
});
it("enforces migration safety evidence", () => {
const issues = validateMigrationEntries(
[page("/admin")],
[
{
...entry("/admin"),
targetPath: "/mod/unsupported",
decision: "MERGE",
requiredTests: [],
dependencies: { queries: [], mutations: ["users.disable"] },
status: "VERIFIED",
notes: ["TODO confirm ownership"],
},
],
);
expect(issues).toEqual([
"MERGE requires requiredTests: /admin",
"mutation dependencies require auditRequirement: /admin",
"mutation dependencies require mutate capabilities: /admin",
"non-REMOVE targetPath must start with /admin/: /admin",
"prohibited placeholder: /admin",
"VERIFIED requires parityEvidence: /admin",
]);
});
it("allows removal without a target and rejects non-removal null targets", () => {
expect(
validateMigrationEntries(
[page("/admin")],
[{ ...entry("/admin"), decision: "REMOVE", targetPath: null }],
),
).toEqual([]);
expect(
validateMigrationEntries(
[page("/admin")],
[{ ...entry("/admin"), targetPath: null }],
),
).toEqual(["REHOST requires targetPath: /admin"]);
});
});
@@ -0,0 +1,125 @@
import type { LegacyPage, MigrationEntry } from "./types";
const prohibitedPlaceholder = /\b(?:TBD|TODO|FIXME|UNCLASSIFIED)\b/i;
function hasProhibitedPlaceholder(value: unknown): boolean {
if (typeof value === "string") return prohibitedPlaceholder.test(value);
if (Array.isArray(value)) return value.some(hasProhibitedPlaceholder);
if (value && typeof value === "object")
return Object.values(value).some(hasProhibitedPlaceholder);
return false;
}
export function validateMigrationEntries(
discovered: readonly LegacyPage[],
entries: readonly MigrationEntry[],
): string[] {
const issues = new Set<string>();
const discoveredByPath = new Map<string, LegacyPage[]>();
for (const page of discovered) {
const matchingPages = discoveredByPath.get(page.legacyPath) ?? [];
matchingPages.push(page);
discoveredByPath.set(page.legacyPath, matchingPages);
}
const entriesByPath = new Map<string, MigrationEntry[]>();
for (const entry of entries) {
const matchingEntries = entriesByPath.get(entry.legacyPath) ?? [];
matchingEntries.push(entry);
entriesByPath.set(entry.legacyPath, matchingEntries);
const matchingPages = discoveredByPath.get(entry.legacyPath);
if (!matchingPages) {
issues.add(`unknown legacyPath: ${entry.legacyPath}`);
continue;
}
const surfaceMatch = matchingPages.find(
(page) => page.surface === entry.surface,
);
const identityMatch = matchingPages.some(
(page) =>
page.surface === entry.surface && page.sourceFile === entry.sourceFile,
);
if (!surfaceMatch) {
const expectedSurface = matchingPages
.map((page) => page.surface)
.join(" or ");
issues.add(
`surface mismatch for legacyPath ${entry.legacyPath}: expected ${expectedSurface}, received ${entry.surface}`,
);
} else if (!identityMatch) {
issues.add(
`sourceFile mismatch for legacyPath ${entry.legacyPath}: expected ${surfaceMatch.sourceFile}, received ${entry.sourceFile}`,
);
}
}
for (const page of discovered) {
const matchingEntries = entriesByPath.get(page.legacyPath) ?? [];
if (matchingEntries.length === 0) {
issues.add(`missing legacyPath: ${page.legacyPath}`);
continue;
}
if (matchingEntries.length > 1) {
issues.add(`duplicate legacyPath: ${page.legacyPath}`);
}
}
for (const entry of entries) {
const { legacyPath } = entry;
if (entry.decision === "REMOVE" && entry.targetPath !== null) {
issues.add(`REMOVE requires null targetPath: ${legacyPath}`);
}
if (entry.targetPath === null && entry.decision !== "REMOVE") {
issues.add(`${entry.decision} requires targetPath: ${legacyPath}`);
}
if (
entry.targetPath !== null &&
entry.decision !== "REMOVE" &&
!entry.targetPath.startsWith("/admin/")
) {
issues.add(
`non-REMOVE targetPath must start with /admin/: ${legacyPath}`,
);
}
if (
(entry.decision === "MERGE" || entry.decision === "REBUILD") &&
entry.requiredTests.length === 0
) {
issues.add(`${entry.decision} requires requiredTests: ${legacyPath}`);
}
if (entry.dependencies.mutations.length > 0) {
if (entry.capabilities.mutate.length === 0) {
issues.add(
`mutation dependencies require mutate capabilities: ${legacyPath}`,
);
}
if (entry.auditRequirement === "NONE") {
issues.add(
`mutation dependencies require auditRequirement: ${legacyPath}`,
);
}
}
if (entry.status === "VERIFIED" && entry.parityEvidence.length === 0) {
issues.add(`VERIFIED requires parityEvidence: ${legacyPath}`);
}
if (hasProhibitedPlaceholder(entry)) {
issues.add(`prohibited placeholder: ${legacyPath}`);
}
}
return [...issues].sort((a, b) => a.localeCompare(b));
}
+9 -2
View File
@@ -2,7 +2,12 @@ import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOTS = ["src/app/admin", "src/components/admin"];
const ROOTS = [
"src/app/admin",
"src/components/admin",
"src/app/admin-next",
"src/features/housekeeping",
];
const GRAPHICAL_ALLOWLIST = [
"src/app/admin/favicon/favicon-generator.tsx",
"src/app/admin/import/clone/import-clone-client.tsx",
@@ -34,7 +39,9 @@ function sourceFiles(directory: string): string[] {
const path = join(directory, entry.name);
return entry.isDirectory()
? sourceFiles(path)
: /\.(?:ts|tsx)$/.test(entry.name)
: /\.(?:ts|tsx)$/.test(entry.name) &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".test.tsx")
? [path]
: [];
});
+57
View File
@@ -3256,6 +3256,63 @@
"failed": "Failed"
}
}
},
"housekeeping": {
"preview": {
"badge": "Foundation preview",
"commandDisabled": "Search and commands are enabled in a later subproject.",
"backToSite": "Back to site"
},
"navigation": {
"skipToContent": "Skip to content",
"primary": "Housekeeping domains",
"contextual": "Domain navigation"
},
"domains": {
"operations": {
"title": "Operations",
"description": "Operational overview and daily queues"
},
"people": {
"title": "People",
"description": "Users, moderation, and support"
},
"content": {
"title": "Content",
"description": "Editorial content and community engagement"
},
"economy": {
"title": "Economy",
"description": "Catalog and shop operations"
},
"hotel": {
"title": "Hotel",
"description": "Rooms, radio, and asset tools"
},
"system": {
"title": "System",
"description": "Configuration, observability, and access"
}
},
"states": {
"loading": {
"title": "Loading housekeeping",
"description": "Preparing the available housekeeping tools."
},
"empty": {
"title": "Nothing available",
"description": "No housekeeping content is available for this domain."
},
"partial": {
"label": "Partial data",
"title": "Some information is unavailable",
"description": "Review the available information and try again later."
},
"error": {
"title": "Unable to load housekeeping",
"description": "Try again later or contact an administrator."
}
}
}
}
}
+57
View File
@@ -3261,6 +3261,63 @@
"failed": "Failed"
}
}
},
"housekeeping": {
"preview": {
"badge": "Anteprima della struttura",
"commandDisabled": "Ricerca e comandi saranno abilitati in un sottoprogetto successivo.",
"backToSite": "Torna al sito"
},
"navigation": {
"skipToContent": "Vai al contenuto",
"primary": "Aree housekeeping",
"contextual": "Navigazione del dominio"
},
"domains": {
"operations": {
"title": "Operazioni",
"description": "Panoramica operativa e code giornaliere"
},
"people": {
"title": "Persone",
"description": "Utenti, moderazione e supporto"
},
"content": {
"title": "Contenuti",
"description": "Contenuti editoriali e coinvolgimento della community"
},
"economy": {
"title": "Economia",
"description": "Operazioni di catalogo e shop"
},
"hotel": {
"title": "Hotel",
"description": "Stanze, radio e strumenti per gli asset"
},
"system": {
"title": "Sistema",
"description": "Configurazione, osservabilità e accesso"
}
},
"states": {
"loading": {
"title": "Caricamento in corso",
"description": "Stiamo preparando gli strumenti di housekeeping disponibili."
},
"empty": {
"title": "Nessun contenuto disponibile",
"description": "Non ci sono contenuti housekeeping disponibili per questo dominio."
},
"partial": {
"label": "Dati parziali",
"title": "Alcune informazioni non sono disponibili",
"description": "Controlla le informazioni disponibili e riprova più tardi."
},
"error": {
"title": "Impossibile caricare housekeeping",
"description": "Riprova più tardi o contatta un amministratore."
}
}
}
}
}