From d42cd2af53990dc4c4a4e298c60cfa6467031253 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 26 Aug 2026 20:08:42 +0200 Subject: [PATCH 01/68] docs: define complete housekeeping cutover --- ...26-08-26-housekeeping-completion-design.md | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-housekeeping-completion-design.md diff --git a/docs/superpowers/specs/2026-08-26-housekeeping-completion-design.md b/docs/superpowers/specs/2026-08-26-housekeeping-completion-design.md new file mode 100644 index 00000000..f2c48525 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-housekeeping-completion-design.md @@ -0,0 +1,411 @@ +# Housekeeping Completion and Atomic Cutover Design + +**Status:** Approved in conversation on 2026-08-26 +**Delivery branch:** `codex/housekeeping-complete` +**Delivery shape:** one final pull request +**Cutover:** atomic, with no compatibility redirects + +## Relationship to the existing design + +This specification completes the program described by +`2026-08-24-housekeeping-modernization-design.md` after the merged Inventory & +Foundation subproject. The existing foundation is not the finished product: it +provides the 137-route migration matrix, capability-aware contracts, validated +domain manifests, shell primitives, and a non-production preview. + +This document defines the remaining implementation and the final cutover. Where +delivery details differ, this document is authoritative for phases 02 onward. +The master architecture remains authoritative for domain ownership and product +behavior. + +## Approved decisions + +- Build complete verticals behind the existing non-production gate. +- Keep all remaining work on one branch and deliver it through one final pull + request. +- Implement in vertical slices rather than UI-first placeholders. +- Keep current `/admin` and `/mod` behavior unchanged until the final cutover + commit. +- At cutover, make the new Command Deck the real `/admin`, remove `/mod`, and + remove obsolete legacy routes without redirects. +- Use hybrid personalization: mandatory content is capability-derived; operators + may pin and reorder allowed shortcuts and optional widgets. +- Add only backward-compatible database migrations before cutover. + +## Outcomes + +The completed program must: + +1. Give every one of the 137 legacy routes a verified canonical destination or + an explicit removal decision. +2. Replace the fragmented admin and moderator surfaces with one capability-aware + Command Deck. +3. Deliver real workflows for all retained administration responsibilities, not + wrappers around legacy pages. +4. Provide global search, safe commands, derived operational inboxes, recent + work, favorites, and optional widgets. +5. Enforce the existing ACL model on navigation, reads, mutations, commands, + search results, inbox items, and widgets. +6. Produce durable and sanitized audit evidence for sensitive operations. +7. Preserve a release-level rollback path without destructive database rollback. + +## Delivery model + +All work is committed to `codex/housekeeping-complete`, based on the latest +`origin/main`. No pull request is opened until every vertical and the cutover are +implemented, reviewed, and verified. + +The existing `/admin-next` entry remains unavailable when +`NODE_ENV=production`. Development and test environments use it to exercise the +new shell before cutover. The final cutover changes the canonical `/admin` route; +it does not weaken the production preview gate. + +The branch is built in this order: + +1. access, audit, error, and preference core; +2. People, moderation, and support; +3. Content and engagement; +4. Economy and catalog; +5. Hotel, world, and operational systems; +6. Command Deck operations and cross-domain composition; +7. atomic route cutover and legacy removal. + +## Canonical route structure + +After cutover the public administration route tree is: + +```text +/admin Operations workspace +/admin/people/* users, tickets, CFH, bans, moderation, teams +/admin/content/* articles, events, polls, media, engagement +/admin/economy/* catalog, shop, transactions, vouchers, values +/admin/hotel/* rooms, furni, badges, radio, emulator, Studio +/admin/system/* settings, ACL, logs, DevOps, maintenance +``` + +`/admin` is the operational home, not a duplicate menu page. `/mod` has no route +after cutover. A workflow has one canonical owner and one canonical destination; +the new tree must not retain duplicate hubs or aliases. + +## Module ownership + +`src/features/housekeeping/foundation` owns only cross-cutting composition: + +- request-scoped actor and capability context; +- registry and navigation projection; +- Command Deck chrome and page-state primitives; +- command dispatch contracts; +- search and inbox orchestration; +- preference reconciliation; +- shared error and audit envelopes. + +Each domain owns its routes, pages, query services, commands, search providers, +inbox sources, widgets, and domain-specific validation. Domains communicate with +the foundation through the published contracts. They do not import another +domain's internal modules. + +The foundation must not import database clients, server actions, or domain page +modules. Server-only domain adapters may import data and action services. + +## Domain manifests + +Every manifest registers real, non-placeholder definitions for: + +- canonical routes and contextual navigation; +- safe and sensitive commands; +- entity-search providers; +- derived-inbox sources; +- mandatory and optional widgets; +- localization keys and capability requirements. + +Registry validation rejects duplicate IDs across all provider categories, +duplicate routes, invalid ownership, missing localization, unknown capability +slugs, invalid widget kinds, and commands without an owning domain. + +The migration matrix and manifests are linked by contract tests. Every retained +matrix row must resolve to one registered route or workflow. Every manifest +capability set must cover the capabilities attributed to its matrix rows. + +## Authorization flow + +Each request creates one capability context from `getAdminContext()`. The context +contains the authenticated actor and immutable effective permission slugs. +Rank is informational and may influence presentation defaults only; it is never +used as a new authorization threshold. + +Authorization is applied at every layer: + +1. registry projection removes inaccessible domains and routes; +2. provider orchestration calls only permitted providers; +3. providers filter inaccessible results and items; +4. page loaders revalidate their required capability; +5. command execution revalidates capability and input on the server; +6. the underlying mutation service retains its own permission guard. + +Client state, hidden navigation, preferences, or a previously loaded page never +authorize an operation. + +## Commands and audit + +Commands use typed input schemas and typed success/error results. Safe commands +may execute directly from the palette. Sensitive commands open a dedicated +contextual confirmation flow and require a reason when the command contract says +so. + +The existing `admin_audit_log` remains the canonical audit store. An additive +migration adds nullable `correlation_id varchar(64)`, `outcome varchar(32)`, +`reason text`, and `domain varchar(32)` columns plus an index on +`correlation_id`. Existing `action`, `target`, `target_id`, `before`, `after`, +`diff`, `ip_address`, and actor fields remain in use. + +- Database mutations write mutation and audit evidence in the same transaction + whenever the affected service uses the same database connection. +- Sensitive external or file operations persist an audit intent before + execution and a final outcome afterward. Failure to persist the intent blocks + execution. +- Audit payloads pass through the existing recursive secret redaction. +- Every command result and audit record carries the same correlation ID. +- Failed, denied, and partially completed sensitive operations are audited. + +## Preferences + +No suitable user-scoped HK preference store currently exists. Add +`housekeeping_user_preferences` with: + +- `user_id int` as the primary key and unique owner; +- `schema_version int not null default 1`; +- `payload longtext not null`, containing validated JSON presentation state; +- `created_at datetime` and `updated_at datetime` timestamps. + +The payload stores pinned route/command IDs, shortcut order, widget order, and +enabled optional widget IDs. It never stores permissions, authorization +decisions, workflow state, or inbox status. + +Every read reconciles stored IDs against the current registry and effective +capabilities. Unknown, removed, or unauthorized entries are dropped before the +payload reaches the UI. Mandatory widgets cannot be disabled. + +## Command Deck experience + +The shell has four stable regions: + +1. a compact six-domain rail; +2. domain-owned contextual navigation; +3. a global search and command field with keyboard access; +4. an operational workspace for pages, inboxes, recent work, and widgets. + +Desktop and mobile share the same semantic hierarchy. Mobile collapses the rail +and contextual navigation without changing route ownership or available +actions. Focus order, landmarks, headings, active-state uniqueness, keyboard +navigation, reduced motion, and semantic theme tokens are tested contracts. + +Loading, empty, partial, error, forbidden, and ready states use the shared page +state primitives. Partial provider failure is visible without replacing valid +results from other providers. + +## Search + +Search supports navigation, entity results, and commands. It is not a raw +database search endpoint. + +- A term shorter than two trimmed characters performs navigation/command + matching only. +- Entity providers have a two-second timeout and a maximum of 25 results each. +- The combined entity response is capped at 50 results before client rendering. +- Providers run only when their declared capability is satisfied. +- Results include stable ID, owner, type, title, optional description, canonical + href, and capability metadata. +- Provider errors produce a typed partial result and do not fail unrelated + providers. +- Search terms and result payloads are not written to audit logs by default. + +## Derived operational inbox + +The inbox is a read model over domain-owned work: tickets, CFH reports, alerts, +emulator errors, operational anomalies, and other existing live states. It does +not introduce a second assignment or task-status system. + +Each inbox item exposes stable source/item IDs, domain, type, title, priority, +age, state, canonical href, available actions, and required capability. Source +items are deduplicated by the pair `(sourceId, itemId)`. + +Sources run independently with a two-second timeout. The composed response +contains successful items plus per-source errors. The server caps the result at +200 items after capability filtering and deterministic priority/age ordering. + +## Recent work, favorites, and widgets + +Recent work is derived from the operator's existing audit events and canonical +route visits; it does not create workflow state. Favorites and ordering come +from the reconciled preference payload. + +Mandatory widgets are supplied by the system according to capability and cannot +be removed. Optional widgets can be enabled and reordered. Widget loaders are +server-side, capability-checked, independently timed out, and represented as +partial failures rather than shell failures. + +## Vertical scope + +### Access, audit, and system core + +- command dispatcher and confirmation model; +- audit extension and correlation IDs; +- typed error taxonomy and boundary mapping; +- preference repository and reconciliation; +- shared provider orchestration and timeout behavior; +- System routes for ACL, settings, logs, DevOps, and maintenance. + +### People, moderation, and support + +- user discovery, details, editing, password/reset controls, account relations, + bans, and permitted staff actions; +- help tickets and moderator tickets; +- CFH queues and details; +- moderation actions, team views, and ban workflows; +- People search providers, inbox sources, commands, and widgets. + +This vertical proves that all retained `/mod` responsibilities work inside the +new capability model before `/mod` is removed. + +### Content and engagement + +- articles, events, polls, media, navigation content, tags, banners, and related + editorial tools; +- Content search, commands, inbox sources, and widgets; +- consolidation of duplicate editorial hubs into canonical workflows. + +### Economy and catalog + +- catalog and item management, Builder Club catalog, maintenance, shop, + transactions, vouchers, subscriptions, marketplace, and value tools; +- Economy search, commands, anomaly sources, and widgets; +- existing specialized editors remain components of canonical workflows rather + than parallel navigation roots. + +### Hotel, world, and operational systems + +- rooms and room furni, badges, sounds, radio, emulator controls, imports, and + Studio tools; +- Hotel search, commands, operational sources, and widgets; +- long-running operations retain progress/error behavior and gain consistent + capability and audit envelopes. + +### Operations composition + +- global search and command palette; +- derived inbox and partial-source reporting; +- recent work and favorites; +- mandatory operational summaries and optional widgets; +- no duplicate mutation logic: actions route to the owning domain command. + +## Error model + +All HK services return typed errors from this stable set: + +- `UNAUTHENTICATED`; +- `FORBIDDEN`; +- `VALIDATION`; +- `NOT_FOUND`; +- `CONFLICT`; +- `RATE_LIMITED`; +- `DEPENDENCY_UNAVAILABLE`; +- `TIMEOUT`; +- `INTERNAL`. + +User messages are localized and do not expose internal details. Server logs and +audit evidence include correlation IDs. Expected domain errors do not rely on +framework exception text. Unknown errors are sanitized at the boundary and +logged once. + +## Database changes + +Allowed pre-cutover migrations are additive only: + +1. nullable HK audit metadata columns on `admin_audit_log`; +2. the `housekeeping_user_preferences` table and its unique user index. + +No legacy table or column is dropped or repurposed in this program. Removal of +legacy UI routes is an application cutover, not a destructive data migration. + +## Atomic cutover + +The final cutover commit is created only after all vertical gates pass. It: + +1. moves the completed shell and Operations workspace to `/admin`; +2. changes domain preview hrefs to canonical `/admin/` hrefs; +3. updates internal links, navigation configuration, and authorization fallback + destinations; +4. removes `/mod` and every legacy route marked `REMOVE`; +5. removes legacy pages whose behavior moved or merged into canonical routes; +6. removes the temporary preview entry and flag if no longer used by tests; +7. adds no compatibility redirects. + +The cutover must leave no links, imports, route discovery entries, or tests that +depend on removed UI modules. + +## Verification strategy + +Each vertical uses TDD and has four gates: + +1. contract and authorization tests; +2. domain query/command behavior tests, including denied and failure paths; +3. page and accessibility behavior tests; +4. cumulative Housekeeping and repository verification. + +The final branch requires: + +- the migration matrix reporting 137/137 valid with every retained row linked to + a canonical implementation; +- mutation-sensitive authorization, provider, command, audit, and preference + tests; +- full project tests, Housekeeping tests, typecheck, semantic Biome, targeted + formatting checks, and `git diff --check`; +- production build with temporary environment restoration; +- visual verification at desktop and mobile widths for every domain and shared + state; +- route-level smoke checks for canonical pages, denied access, and removal of + `/mod`/obsolete routes; +- a broad whole-branch code review followed by one reviewed fix wave if needed. + +Repository-wide pre-existing formatter debt is reported separately and must not +be hidden by mass-formatting unrelated files. + +## Merge, deployment, and rollback + +The single pull request targets `main` only after all final gates pass. Merging +is the atomic release boundary; no partial vertical is intentionally exposed to +production operators. + +After merge, the deployment pipeline must complete and `/api/health` must be +verified live. A push or successful build alone is not deployment evidence. + +Rollback deploys the prior application release. Because database changes are +additive and ignored by the prior release, rollback does not require manual data +reversal. If audit or preference migrations themselves fail, deployment stops +before serving the cutover release. + +## Explicit non-goals + +- A new task-assignment system for inbox items. +- A replacement authentication or ACL model. +- Rank-based authorization thresholds. +- Compatibility redirects for removed admin/mod routes. +- Destructive cleanup of legacy database data. +- Rewriting specialized domain engines that already work; they are integrated + behind consistent domain contracts instead. +- Unrelated CMS redesign or repository-wide formatting cleanup. + +## Completion criteria + +The program is complete only when: + +- all retained legacy capabilities are available through canonical new routes; +- all six manifests contain real routes/providers/widgets rather than empty + placeholders; +- the Command Deck search, commands, inbox, preferences, recent work, and widgets + operate against real domain services; +- capability enforcement and audit evidence cover every exposed read and + mutation path; +- `/admin` serves the new HK, `/mod` and removed legacy routes are unreachable, + and no compatibility redirects exist; +- final local, CI, deployment, health, and visual evidence are all recorded. -- 2.54.0 From 74756dfed2c431778544c5afa56186caf139e86d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 26 Aug 2026 20:26:19 +0200 Subject: [PATCH 02/68] docs: plan complete ase housekeeping cutover --- .../2026-08-26-housekeeping-completion.md | 1191 +++++++++++++++++ ...26-08-26-housekeeping-completion-design.md | 57 +- 2 files changed, 1225 insertions(+), 23 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-26-housekeeping-completion.md diff --git a/docs/superpowers/plans/2026-08-26-housekeeping-completion.md b/docs/superpowers/plans/2026-08-26-housekeeping-completion.md new file mode 100644 index 00000000..414e7d2b --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-housekeeping-completion.md @@ -0,0 +1,1191 @@ +# Housekeeping Completion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (current session) or `superpowers:executing-plans` (separate session) to execute this plan task-by-task. + +**Goal:** Complete all 137 Housekeeping migrations behind `/ase-next`, then atomically publish the capability-aware Command Deck at `/ase` and remove the legacy `/admin`, `/admin-next`, and `/mod` UI trees without redirects. + +**Architecture:** Keep `src/features/housekeeping/foundation` limited to cross-domain contracts, registry projection, provider orchestration, shell composition, preferences, commands, and audit envelopes. Each of the six domains owns its route catalog, server adapters, commands, search, inbox, widgets, and workflow components. App Router entrypoints dispatch canonical route IDs to domain-owned renderers; the preview maps canonical `/ase/*` destinations to `/ase-next/*` without changing domain manifests. + +**Tech Stack:** Node.js 26, Next.js 16 App Router, React 19, TypeScript 7, Drizzle ORM/MySQL, next-intl, Zod 4, Vitest 4, Biome 2, Tailwind CSS 4, cmdk, dnd-kit. + +**Spec:** `docs/superpowers/specs/2026-08-26-housekeeping-completion-design.md` + +**Global Constraints:** Work only on `codex/housekeeping-complete`; do not use worktrees; preserve `.remember/` and unrelated changes; keep `/admin` and `/mod` behavior unchanged until Task 25; keep `/ase-next` unavailable in production; retain `/api/admin/*` as internal transport contracts unless a task explicitly changes one; add only backward-compatible database changes; use the current ACL permission slugs and never introduce rank thresholds; stage exact files; run `git diff --check` before every commit; do not push or open a pull request until the entire plan and final review pass. + +## File structure and ownership + +```text +src/app/ase-next/ gated preview App Router surface + layout.tsx production-denying preview gate + page.tsx first visible domain redirect + [domain]/layout.tsx capability-projected Command Deck shell + [domain]/[[...segments]]/page.tsx route ID resolution and domain dispatch +src/app/ase/ created only by atomic cutover + +src/features/housekeeping/foundation/ + contracts/ stable capability, result, route, command, + search, inbox, widget, and preference types + routing/ canonical/preview href mapping and matcher + providers/ timeout, cap, partial-result orchestration + commands/ dispatcher, confirmation, audit envelope + preferences/ schema validation and reconciliation + recent/ audit-derived recent work + shell/ rail, contextual nav, command deck, inbox, + widgets, favorites, and responsive chrome + page/ shared page states and workflow primitives + +src/features/housekeeping/domains// + manifest.ts declarative registry entry + routes.ts canonical `/ase` routes and handler IDs + route-handlers.ts domain-owned renderer dispatch + queries/ server-only read adapters + commands/ typed mutations around existing services + search.ts entity-search providers + inbox.ts derived work sources + widgets.ts mandatory and optional loaders + pages/ workflow-focused React server/client views + +src/actions/housekeeping-*.ts narrow server-action boundaries +drizzle/migrations/0023_housekeeping.sql additive audit and preference migration +src/features/housekeeping/cutover/ 137-row parity and legacy-removal contracts +``` + +The migration files remain the authoritative inventory of legacy source pages and dependencies. Domain route catalogs are authoritative for canonical `/ase` destinations. A contract joins the two by canonical route ID; no legacy page component is imported into the new route tree. + +### Task 1: Lock the `/ase` namespace and preview mapping + +**Files:** + +- Modify: `src/features/housekeeping/foundation/contracts/domain.ts` +- Create: `src/features/housekeeping/foundation/routing/href.ts` +- Create: `src/features/housekeeping/foundation/routing/href.test.ts` +- Modify: `src/features/housekeeping/foundation/registry.ts` +- Modify: `src/features/housekeeping/foundation/registry.test.ts` +- Modify: `src/features/housekeeping/foundation/navigation.ts` +- Modify: `src/features/housekeeping/foundation/navigation.test.ts` +- Modify: `src/features/housekeeping/domains/*/manifest.ts` +- Modify: `src/features/housekeeping/migration/operations.ts` +- Modify: `src/features/housekeeping/migration/people.ts` +- Modify: `src/features/housekeeping/migration/content.ts` +- Modify: `src/features/housekeeping/migration/economy.ts` +- Modify: `src/features/housekeeping/migration/hotel.ts` +- Modify: `src/features/housekeeping/migration/system.ts` +- Modify: `src/features/housekeeping/migration/validate-matrix.ts` +- Modify: `src/features/housekeeping/migration/validate-matrix.test.ts` +- Move: `src/app/admin-next` to `src/app/ase-next` +- Modify: `src/features/housekeeping/foundation/preview-route-contract.test.ts` +- Modify: `src/features/housekeeping/foundation/foundation-source-contract.test.ts` +- Modify: `src/lib/admin-theme-source-audit.test.ts` + +**Interfaces:** + +- Consumes: canonical route strings beginning with `/ase`. +- Produces: + +```ts +export type HousekeepingSurface = "preview" | "canonical"; +export type CanonicalHousekeepingHref = `/ase${string}`; +export function toHousekeepingHref( + href: CanonicalHousekeepingHref, + surface: HousekeepingSurface, +): CanonicalHousekeepingHref | `/ase-next${string}`; +``` + +- `HousekeepingDomainManifest.canonicalHref` is a `CanonicalHousekeepingHref`; + Operations uses `/ase`, while the other domains use `/ase/`. Route + definitions store canonical `/ase` hrefs only. + +- [ ] Write failing href tests proving `/ase` stays `/ase`, `/ase/people/users` maps to `/ase-next/people/users` in preview, and `/admin` input is rejected by the type/runtime guard. +- [ ] Run `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/foundation/routing/href.test.ts` and confirm RED. +- [ ] Implement `toHousekeepingHref`, replace `previewHref` with `canonicalHref`, and pass `surface` into `buildHousekeepingNavigation`. +- [ ] Change every non-null migration `targetPath` prefix from `/admin` to `/ase`; change validation to require `targetPath === "/ase" || targetPath.startsWith("/ase/")`. +- [ ] Move the preview route tree to `src/app/ase-next`, update source contracts/localized test fixtures, and prove production still calls `notFound()`. +- [ ] Run `pnpm hk:matrix:check` and the routing, registry, navigation, preview, matrix, and theme-source tests; confirm 137 discovered rows and zero issues. +- [ ] Run targeted Biome, `git diff --check`, stage only listed paths, and commit `refactor(housekeeping): adopt ase route namespace`. + +### Task 2: Stabilize result, error, correlation, and route contracts + +**Files:** + +- Modify: `src/features/housekeeping/foundation/contracts/result.ts` +- Modify: `src/features/housekeeping/foundation/contracts/query.ts` +- Modify: `src/features/housekeeping/foundation/contracts/command.ts` +- Modify: `src/features/housekeeping/foundation/contracts/search.ts` +- Modify: `src/features/housekeeping/foundation/contracts/inbox.ts` +- Modify: `src/features/housekeeping/foundation/contracts/widget.ts` +- Modify: `src/features/housekeeping/foundation/contracts/domain.ts` +- Modify: `src/features/housekeeping/foundation/contracts/index.ts` +- Modify: `src/features/housekeeping/foundation/contracts/contracts.test.ts` +- Create: `src/features/housekeeping/foundation/correlation.ts` +- Create: `src/features/housekeeping/foundation/correlation.test.ts` + +**Interfaces:** + +```ts +export type HousekeepingErrorCode = + | "UNAUTHENTICATED" | "FORBIDDEN" | "VALIDATION" | "NOT_FOUND" + | "CONFLICT" | "RATE_LIMITED" | "DEPENDENCY_UNAVAILABLE" + | "TIMEOUT" | "INTERNAL"; +export interface HousekeepingError { + code: HousekeepingErrorCode; + messageKey: string; + fieldErrors?: Readonly>; +} +export type HousekeepingResult = + | { ok: true; data: T; correlationId: string } + | { ok: false; error: HousekeepingError; correlationId: string }; +export function createCorrelationId(): string; +``` + +- [ ] Replace the obsolete error-code expectations with table-driven tests for all nine approved codes, field errors, success, and correlation preservation. +- [ ] Run the contracts and correlation tests and confirm RED on the renamed taxonomy. +- [ ] Implement the types, `ok`, `fail`, `mapUnknownError`, and a 64-character-safe correlation generator using `crypto.randomUUID()`. +- [ ] Extend search results with `type`, `description`, canonical href, and capability metadata; extend inbox items with priority, age/state, actions; allow widget loaders to accept an abort signal. +- [ ] Run the two tests plus `pnpm typecheck`; fix all foundation callers without weakening types. +- [ ] Run targeted Biome, `git diff --check`, stage exact contract files, and commit `refactor(housekeeping): stabilize service contracts`. + +### Task 3: Make capability context request-scoped and reusable + +**Files:** + +- Modify: `src/features/housekeeping/foundation/server-capability-context.ts` +- Modify: `src/features/housekeeping/foundation/server-capability-context.test.ts` +- Modify: `src/features/housekeeping/foundation/capability-context.ts` +- Modify: `src/features/housekeeping/foundation/capability-context.test.ts` +- Modify: `src/lib/admin/guard.ts` +- Modify: `src/lib/admin/guard.test.ts` +- Create: `src/features/housekeeping/foundation/authorization.ts` +- Create: `src/features/housekeeping/foundation/authorization.test.ts` + +**Interfaces:** + +```ts +export const getHousekeepingCapabilityContext: () => + Promise; +export function authorizeHousekeeping( + context: HousekeepingCapabilityContext, + requirement: CapabilityRequirement, +): HousekeepingResult; +export function requireHousekeepingCapability( + requirement: CapabilityRequirement, + context?: HousekeepingCapabilityContext, +): Promise; +``` + +- [ ] Write tests proving one request calls `getAdminContext()` once across shell, page, and command preflight; denied checks return `FORBIDDEN` without rank thresholds. +- [ ] Run the capability, authorization, and guard tests and confirm RED. +- [ ] Keep React `cache()` as the request boundary, add reusable authorization functions, and let HK guards accept an already-created context. +- [ ] Retain legacy `requireStaff`, `requirePermission`, and `/admin` fallbacks unchanged until cutover; remove only duplicate HK lookups. +- [ ] Run targeted tests and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `refactor(housekeeping): reuse request capability context`. + +### Task 4: Add the audit and preferences schema additively + +**Files:** + +- Create: `drizzle/migrations/0023_housekeeping.sql` +- Modify: `src/db/schema.ts` +- Create: `src/features/housekeeping/foundation/persistence-contract.test.ts` + +**Interfaces:** + +```ts +export type HousekeepingUserPreferenceRow = + typeof HousekeepingUserPreferences.$inferSelect; +// admin_audit_log adds correlationId, outcome, reason, and domain. +``` + +Migration shape: + +```sql +ALTER TABLE `admin_audit_log` + ADD COLUMN `correlation_id` VARCHAR(64) NULL, + ADD COLUMN `outcome` VARCHAR(32) NULL, + ADD COLUMN `reason` TEXT NULL, + ADD COLUMN `domain` VARCHAR(32) NULL, + ADD INDEX `admin_audit_log_correlation_id_idx` (`correlation_id`); +CREATE TABLE `housekeeping_user_preferences` ( + `user_id` INT NOT NULL, + `schema_version` INT NOT NULL DEFAULT 1, + `payload` LONGTEXT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`) +); +``` + +- [ ] Write a source-contract test asserting exact nullable audit columns/index, exact preference columns, one primary key, and no `DROP`, `RENAME`, or legacy-column mutation. +- [ ] Run the persistence contract and confirm RED. +- [ ] Add migration `0023` and matching Drizzle declarations using existing schema naming conventions. +- [ ] Run the contract, `pnpm typecheck`, and `pnpm db:migrate:status` against the configured non-production environment only when available; record an unavailable DB as unexecuted, not passed. +- [ ] Run targeted Biome, `git diff --check`, stage the migration/schema/test, and commit `feat(housekeeping): add audit and preference storage`. + +### Task 5: Extend audit writing and correlation evidence + +**Files:** + +- Modify: `src/lib/services/audit.ts` +- Modify: `src/lib/services/audit.test.ts` +- Create: `src/features/housekeeping/foundation/commands/audit-envelope.ts` +- Create: `src/features/housekeeping/foundation/commands/audit-envelope.test.ts` + +**Interfaces:** + +```ts +export interface AuditEntry { + userId: number; + action: string; + target: string; + targetId?: number; + before?: Record; + after?: Record; + correlationId?: string; + outcome?: "intent" | "success" | "failure" | "partial" | "denied"; + reason?: string; + domain?: HousekeepingDomainId; + ipAddress?: string; +} +export interface HousekeepingAuditWriter { + write(entry: AuditEntry, transaction?: HousekeepingAuditTransaction): Promise; +} +``` + +- [ ] Add tests for recursive secret redaction, correlation/domain/outcome persistence, denied/failure evidence, and transaction-injected writes. +- [ ] Run audit tests and confirm RED. +- [ ] Export the audit payload type, preserve existing call compatibility, and add a writer adapter that can use either `db` or the supplied transaction. +- [ ] Implement `writeIntent` and `writeOutcome`; block external/file execution if intent persistence fails. +- [ ] Run audit and envelope tests plus `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): correlate command audit evidence`. + +### Task 6: Implement preferences validation, repository, and reconciliation + +**Files:** + +- Create: `src/features/housekeeping/foundation/preferences/schema.ts` +- Create: `src/features/housekeeping/foundation/preferences/schema.test.ts` +- Create: `src/features/housekeeping/foundation/preferences/repository.ts` +- Create: `src/features/housekeeping/foundation/preferences/repository.test.ts` +- Create: `src/features/housekeeping/foundation/preferences/reconcile.ts` +- Create: `src/features/housekeeping/foundation/preferences/reconcile.test.ts` +- Create: `src/actions/housekeeping-preferences.ts` +- Create: `src/actions/housekeeping-preferences.test.ts` + +**Interfaces:** + +```ts +export const housekeepingPreferencesSchema: z.ZodType<{ + schemaVersion: 1; + pinnedRouteIds: string[]; + pinnedCommandIds: string[]; + shortcutOrder: string[]; + widgetOrder: string[]; + enabledOptionalWidgetIds: string[]; +}>; +export interface HousekeepingPreferencesRepository { + read(userId: number): Promise; + upsert(userId: number, value: HousekeepingPreferences): Promise; +} +export function reconcilePreferences( + stored: HousekeepingPreferences, + registry: HousekeepingRegistry, + context: HousekeepingCapabilityContext, +): HousekeepingPreferences; +``` + +- [ ] Write failing schema tests for malformed JSON, unknown keys, duplicate IDs, and schema version; write repository tests for absent/default and upsert behavior. +- [ ] Write reconciliation tests proving unauthorized/removed IDs are dropped, mandatory widgets remain, and relative order of valid IDs is stable. +- [ ] Run all four preference test files and confirm RED. +- [ ] Implement Zod validation, injected repository DB adapter, deterministic reconciliation, and capability-checked load/save server actions. +- [ ] Run preference tests, `pnpm typecheck`, and the Housekeeping suite. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): persist operator preferences`. + +### Task 7: Build bounded provider orchestration + +**Files:** + +- Create: `src/features/housekeeping/foundation/providers/run-provider.ts` +- Create: `src/features/housekeeping/foundation/providers/run-provider.test.ts` +- Create: `src/features/housekeeping/foundation/providers/orchestrate.ts` +- Create: `src/features/housekeeping/foundation/providers/orchestrate.test.ts` + +**Interfaces:** + +```ts +export interface ProviderPolicy { + timeoutMs: number; + perProviderLimit: number; + combinedLimit: number; + sort(items: readonly T[]): readonly T[]; + dedupeKey(item: T): string; +} +export interface ProviderBatchResult { + items: readonly T[]; + errors: readonly { providerId: string; code: HousekeepingErrorCode }[]; + correlationId: string; +} +``` + +- [ ] Use fake timers to test a 2,000 ms abort, capability-skipped providers, thrown errors mapped once, stable dedupe, per-provider cap, combined cap, and deterministic ordering. +- [ ] Run both provider tests and confirm RED. +- [ ] Implement orchestration without importing domain modules or database clients; accept providers and policy through arguments. +- [ ] Prove one timeout returns successful sibling results with a typed partial error. +- [ ] Run provider tests and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): orchestrate partial providers`. + +### Task 8: Implement the typed command dispatcher + +**Files:** + +- Create: `src/features/housekeeping/foundation/commands/registry.ts` +- Create: `src/features/housekeeping/foundation/commands/registry.test.ts` +- Create: `src/features/housekeeping/foundation/commands/dispatcher.ts` +- Create: `src/features/housekeeping/foundation/commands/dispatcher.test.ts` +- Create: `src/features/housekeeping/foundation/commands/confirmation.ts` +- Create: `src/features/housekeeping/foundation/commands/confirmation.test.ts` +- Create: `src/actions/housekeeping-command.ts` +- Create: `src/actions/housekeeping-command.test.ts` + +**Interfaces:** + +```ts +export interface HousekeepingCommand { + id: string; + owner: HousekeepingDomainId; + risk: "safe" | "sensitive"; + capability: CapabilityRequirement; + input: z.ZodType; + requiresReason: boolean; + rateLimit: { attempts: number; windowMs: number }; + execute(ctx: HousekeepingCommandContext, input: I): Promise>; +} +export interface HousekeepingCommandContext { + capability: HousekeepingCapabilityContext; + correlationId: string; + ipAddress: string; +} +export interface HousekeepingCommandDependencies { + context: HousekeepingCapabilityContext; + audit: HousekeepingAuditWriter; + rateLimit(key: string, attempts: number, windowMs: number): Promise; +} +export async function dispatchHousekeepingCommand( + request: { commandId: string; input: unknown; reason?: string }, + dependencies: HousekeepingCommandDependencies, +): Promise>; +``` + +- [ ] Write tests for unknown command, denied capability, invalid input, missing reason, rate limit, safe success, sensitive intent/success, sensitive intent/failure, and sanitized unknown exception. +- [ ] Run dispatcher/action tests and confirm RED. +- [ ] Implement global ID/owner validation, server-side capability recheck, Zod parsing, per-actor/IP limit, confirmation metadata, and audit envelope usage. +- [ ] Ensure server actions accept plain serializable values and never trust client risk/capability metadata. +- [ ] Run all command tests, audit tests, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): dispatch audited commands`. + +### Task 9: Resolve canonical routes and dispatch domain pages + +**Files:** + +- Create: `src/features/housekeeping/foundation/routing/match-route.ts` +- Create: `src/features/housekeeping/foundation/routing/match-route.test.ts` +- Create: `src/features/housekeeping/route-handlers.ts` +- Create: `src/features/housekeeping/route-handlers.test.ts` +- Modify: `src/app/ase-next/page.tsx` +- Modify: `src/app/ase-next/[domain]/layout.tsx` +- Create: `src/app/ase-next/[domain]/[[...segments]]/page.tsx` +- Delete: `src/app/ase-next/[domain]/page.tsx` + +**Interfaces:** + +```ts +export interface HousekeepingRouteMatch { + routeId: string; + domain: HousekeepingDomainId; + params: Readonly>; + canonicalHref: CanonicalHousekeepingHref; +} +export interface HousekeepingRouteHandler { + routeId: string; + render(input: HousekeepingPageInput): Promise; +} +export function matchHousekeepingRoute( + registry: HousekeepingRegistry, + canonicalPath: string, +): HousekeepingRouteMatch | null; +``` + +- [ ] Write matcher tests for static, `:id`, nested, unknown, malformed, and cross-domain paths; test one-to-one equality between registered route IDs and handler IDs. +- [ ] Run matcher/handler tests and confirm RED. +- [ ] Implement segment-safe matching without regular-expression injection; reject duplicate dynamic shapes during registry creation. +- [ ] Update preview pages to map `/ase-next//` to canonical `/ase//`, reauthorize the matched route, and call its handler. +- [ ] Test inaccessible routes as `notFound()` and permitted routes with one request-scoped context. +- [ ] Run routing, registry, preview-route tests and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact paths, and commit `feat(housekeeping): dispatch canonical domain routes`. + +### Task 10: Deliver System access, configuration, observability, and operations + +**Files:** + +- Create: `src/features/housekeeping/domains/system/routes.ts` +- Create: `src/features/housekeeping/domains/system/routes.test.ts` +- Create: `src/features/housekeeping/domains/system/queries/access.ts` +- Create: `src/features/housekeeping/domains/system/queries/configuration.ts` +- Create: `src/features/housekeeping/domains/system/queries/observability.ts` +- Create: `src/features/housekeeping/domains/system/queries/operations.ts` +- Create: `src/features/housekeeping/domains/system/queries/system-queries.test.ts` +- Create: `src/features/housekeeping/domains/system/commands/system-commands.ts` +- Create: `src/features/housekeeping/domains/system/commands/system-commands.test.ts` +- Create: `src/features/housekeeping/domains/system/pages/access.tsx` +- Create: `src/features/housekeeping/domains/system/pages/configuration.tsx` +- Create: `src/features/housekeeping/domains/system/pages/observability.tsx` +- Create: `src/features/housekeeping/domains/system/pages/operations.tsx` +- Create: `src/features/housekeeping/domains/system/pages/system-pages.test.tsx` +- Create: `src/features/housekeeping/domains/system/route-handlers.ts` +- Modify: `src/features/housekeeping/domains/system/manifest.ts` +- Modify: `src/actions/admin-alerts.ts` +- Modify: `src/actions/admin-emulator.ts` +- Modify: `src/actions/admin-maintenance.ts` +- Modify: `src/actions/admin-settings.ts` +- Modify: `src/actions/commandocentrum.ts` +- Modify: `src/actions/permissions.ts` +- Modify: `src/lib/admin/ops-health.ts` +- Modify: `src/lib/admin/ops-online-users.ts` + +**Interfaces:** + +```ts +export const SYSTEM_ROUTE_IDS = [ + "system.access.permissions", "system.access.permission-detail", + "system.configuration.settings", "system.configuration.emulator", + "system.observability.analytics", "system.observability.analytics-activity", + "system.observability.analytics-economy", "system.observability.devops", + "system.observability.devops-errors", "system.observability.logs-staff", + "system.observability.logs-audit", "system.observability.logs-chat", + "system.observability.logs-commands", "system.observability.logs-trades", + "system.operations.alerts", "system.operations.command-center", + "system.operations.maintenance", +] as const; +``` + +- [ ] Write route tests asserting the exact route IDs above, canonical `/ase/system/*` destinations, matrix coverage, labels, and read capabilities. +- [ ] Write query tests using injected adapters for ACL/ranks, settings, emulator data, analytics/logs, health, errors, alerts, and maintenance; include dependency-unavailable mapping. +- [ ] Write command tests for rank/ACL writes, settings/emulator updates, alerts, RCON commands, and maintenance; require reasons for permission, RCON, and global-availability mutations. +- [ ] Run System tests and confirm RED before each production module is added. +- [ ] Extract redirect-free mutation functions from the listed legacy actions; keep legacy action wrappers and `/admin` redirects working until cutover. +- [ ] Build the four workflow pages with loading/empty/partial/error/forbidden states, then register real System routes and commands; Task 19 registers the System search, inbox, and widget providers. +- [ ] Run System, legacy action, audit, authorization, full HK tests, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact System/action files, and commit `feat(housekeeping): deliver system vertical`. + +### Task 11: Build People read adapters and canonical data models + +**Files:** + +- Create: `src/features/housekeeping/domains/people/routes.ts` +- Create: `src/features/housekeeping/domains/people/routes.test.ts` +- Create: `src/features/housekeeping/domains/people/queries/users.ts` +- Create: `src/features/housekeeping/domains/people/queries/community.ts` +- Create: `src/features/housekeeping/domains/people/queries/staff.ts` +- Create: `src/features/housekeeping/domains/people/queries/support.ts` +- Create: `src/features/housekeeping/domains/people/queries/moderation.ts` +- Create: `src/features/housekeeping/domains/people/queries/people-queries.test.ts` +- Create: `src/features/housekeeping/domains/people/models.ts` +- Create: `src/features/housekeeping/domains/people/models.test.ts` + +**Interfaces:** + +```ts +export interface PeopleUserSummary { + id: number; username: string; rank: number; online: boolean; + mail: string | null; ipCurrent: string | null; bannedUntil: number | null; +} +export interface PeopleQueueSnapshot { + tickets: number; helpTickets: number; cfh: number; activeBans: number; +} +export interface PeopleQueries { + users(input: ListInput): Promise>>; + user(id: number): Promise>; + queue(): Promise>; +} +``` + +- [ ] Write exact route-catalog tests for users, multi-accounts, online, guilds, applications, teams, bans, IP/VPN/wordfilter, tickets, help tickets, CFH, moderation actions, and mod-team workflows. +- [ ] Write query tests for pagination, stable sorting, missing users, capability-safe projections, ticket/CFH counts, guild detail, staff applications, sanctions, and dependency failures. +- [ ] Run People model/query/route tests and confirm RED. +- [ ] Implement server-only adapters around the matrix-listed Drizzle/RCON/service dependencies; never expose password hashes, auth tickets, secrets, or unredacted IPs without their explicit capability. +- [ ] Map all returned failures to the stable HK error set and canonical `/ase/people/*` links. +- [ ] Run People tests and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact People query files, and commit `feat(housekeeping): model people workflows`. + +### Task 12: Deliver People users, community, and staff workflows + +**Files:** + +- Create: `src/features/housekeeping/domains/people/commands/user-commands.ts` +- Create: `src/features/housekeeping/domains/people/commands/user-commands.test.ts` +- Create: `src/features/housekeeping/domains/people/commands/community-commands.ts` +- Create: `src/features/housekeeping/domains/people/commands/community-commands.test.ts` +- Create: `src/features/housekeeping/domains/people/pages/users.tsx` +- Create: `src/features/housekeeping/domains/people/pages/user-detail.tsx` +- Create: `src/features/housekeeping/domains/people/pages/user-edit.tsx` +- Create: `src/features/housekeeping/domains/people/pages/multi-accounts.tsx` +- Create: `src/features/housekeeping/domains/people/pages/community.tsx` +- Create: `src/features/housekeeping/domains/people/pages/staff.tsx` +- Create: `src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx` +- Modify: `src/actions/bulk-users.ts` +- Modify: `src/actions/users.ts` +- Modify: `src/actions/admin-guilds.ts` +- Modify: `src/actions/admin-applications.ts` +- Modify: `src/actions/admin-teams.ts` +- Modify: `src/actions/admin-ip.ts` +- Modify: `src/actions/admin-vpn.ts` +- Modify: `src/actions/admin-wordfilter.ts` + +**Interfaces:** + +```ts +export type UserCommandId = + | "people.user.update" | "people.user.ban" | "people.user.unban" + | "people.user.alert" | "people.user.disconnect" | "people.user.mute" + | "people.user.unmute" | "people.user.reset-password" + | "people.user.send-currency" | "people.user.trade-lock" + | "people.users.bulk-ban" | "people.users.bulk-unban" + | "people.users.bulk-currency" | "people.users.bulk-badge"; +``` + +- [ ] Write command tests for the exact IDs above plus guild disband, application decision, team change, IP action, VPN configuration, and wordfilter update; assert server capability rechecks and audit before/after. +- [ ] Run command tests and confirm RED. +- [ ] Extract redirect-free service functions from listed actions while preserving legacy server-action wrappers. +- [ ] Implement user list/detail/edit and multi-account pages; preserve existing fields/actions only when the matrix capability permits them. +- [ ] Implement community and staff workflows with canonical links and no duplicate show/edit aliases. +- [ ] Run People primary page/command tests, affected legacy tests, HK suite, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): deliver people account workflows`. +### Task 13: Deliver People support and moderation parity + +**Files:** + +- Create: `src/features/housekeeping/domains/people/commands/support-commands.ts` +- Create: `src/features/housekeeping/domains/people/commands/moderation-commands.ts` +- Create: `src/features/housekeeping/domains/people/commands/support-commands.test.ts` +- Create: `src/features/housekeeping/domains/people/commands/moderation-commands.test.ts` +- Create: `src/features/housekeeping/domains/people/pages/support.tsx` +- Create: `src/features/housekeeping/domains/people/pages/moderation.tsx` +- Create: `src/features/housekeeping/domains/people/pages/cfh-detail.tsx` +- Create: `src/features/housekeeping/domains/people/pages/ticket-detail.tsx` +- Create: `src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx` +- Create: `src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx` +- Create: `src/features/housekeeping/domains/people/search.ts` +- Create: `src/features/housekeeping/domains/people/inbox.ts` +- Create: `src/features/housekeeping/domains/people/widgets.ts` +- Create: `src/features/housekeeping/domains/people/people-providers.test.ts` +- Create: `src/features/housekeeping/domains/people/route-handlers.ts` +- Modify: `src/features/housekeeping/domains/people/manifest.ts` +- Modify: `src/actions/admin-bans.ts` +- Modify: `src/actions/admin-help-tickets.ts` +- Modify: `src/actions/help-tickets.ts` +- Modify: `src/actions/moderation.ts` +- Modify: `src/actions/tickets.ts` +- Modify: `src/actions/ticket-templates.ts` +- Modify: `src/lib/services/moderation.ts` +- Modify: `src/lib/services/ticket-replies.ts` + +**Interfaces:** + +```ts +export const PEOPLE_INBOX_SOURCE_IDS = [ + "people.tickets", "people.help-tickets", "people.cfh", "people.active-bans", +] as const; +export const PEOPLE_SEARCH_PROVIDER_IDS = [ + "people.users", "people.guilds", "people.tickets", +] as const; +``` + +- [ ] Write failing parity tests joining all People matrix rows, including every `/mod` row, to one route or removed decision. +- [ ] Write support/moderation command tests for assign/reply/close/reopen/template, CFH resolve/sanction, ban/unban, and moderation action; require reasons for sanctions and bans. +- [ ] Implement support and moderation pages with queue/detail flows, safe links, preserved mid-rank `mod.*` access, and no `admin.dashboard` dependency when the original route did not require it. +- [ ] Implement the exact People search/inbox providers and mandatory queue widget; enforce 25-result and item capability filtering at provider level. +- [ ] Register handlers/providers/widgets and prove no People manifest collection is empty. +- [ ] Run People, legacy moderation/ticket tests, HK suite, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): complete people moderation parity`. + +### Task 14: Deliver Content and engagement + +**Files:** + +- Create: `src/features/housekeeping/domains/content/routes.ts` +- Create: `src/features/housekeeping/domains/content/routes.test.ts` +- Create: `src/features/housekeeping/domains/content/queries/content-queries.ts` +- Create: `src/features/housekeeping/domains/content/queries/content-queries.test.ts` +- Create: `src/features/housekeeping/domains/content/commands/content-commands.ts` +- Create: `src/features/housekeeping/domains/content/commands/content-commands.test.ts` +- Create: `src/features/housekeeping/domains/content/pages/editorial.tsx` +- Create: `src/features/housekeeping/domains/content/pages/media.tsx` +- Create: `src/features/housekeeping/domains/content/pages/engagement.tsx` +- Create: `src/features/housekeeping/domains/content/pages/help.tsx` +- Create: `src/features/housekeeping/domains/content/pages/brand.tsx` +- Create: `src/features/housekeeping/domains/content/pages/localization.tsx` +- Create: `src/features/housekeeping/domains/content/pages/content-pages.test.tsx` +- Create: `src/features/housekeeping/domains/content/search.ts` +- Create: `src/features/housekeeping/domains/content/inbox.ts` +- Create: `src/features/housekeeping/domains/content/widgets.ts` +- Create: `src/features/housekeeping/domains/content/route-handlers.ts` +- Create: `src/features/housekeeping/domains/content/content-providers.test.ts` +- Modify: `src/features/housekeeping/domains/content/manifest.ts` +- Modify: `src/actions/admin-ads.ts` +- Modify: `src/actions/admin-articles.ts` +- Modify: `src/actions/admin-banners.ts` +- Modify: `src/actions/admin-email-templates.ts` +- Modify: `src/actions/admin-help.ts` +- Modify: `src/actions/admin-media.ts` +- Modify: `src/actions/admin-nav-menu.ts` +- Modify: `src/actions/admin-photos.ts` +- Modify: `src/actions/admin-tags.ts` +- Modify: `src/actions/admin-theme.ts` +- Modify: `src/actions/admin-writeable-boxes.ts` +- Modify: `src/actions/banners.ts` +- Modify: `src/actions/events.ts` +- Modify: `src/actions/polls.ts` +- Modify: `src/actions/prefixes.ts` +- Modify: `src/actions/save-favicon.ts` +- Modify: `src/actions/save-logo.ts` +- Modify: `src/actions/translations.ts` + +**Interfaces:** + +```ts +export const CONTENT_ROUTE_GROUPS = [ + "editorial", "media", "engagement", "help", "brand", "localization", +] as const; +export const CONTENT_SEARCH_PROVIDER_IDS = [ + "content.articles", "content.events", "content.media", "content.help", +] as const; +``` + +- [ ] Write route tests mapping every Content matrix row to one of the six exact route groups and canonical `/ase/content/*` destinations. +- [ ] Write query/command tests for articles, ads, banners, events/types, polls, photos/media, navigation, tags/prefixes, help questions, writable boxes, email templates, theme/favicon, and three translation stores. +- [ ] Run Content tests and confirm RED before production implementations. +- [ ] Extract redirect-free domain operations from the listed actions, retain legacy wrappers, and ensure global brand/localization mutations are sensitive and audited. +- [ ] Implement the six workflow pages, content search providers, publication/attention inbox source, mandatory editorial summary, and optional media/localization widgets. +- [ ] Register real routes/commands/providers/widgets and prove Content matrix closure. +- [ ] Run Content, affected legacy tests, HK suite, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): deliver content vertical`. + +### Task 15: Deliver Economy and catalog + +**Files:** + +- Create: `src/features/housekeeping/domains/economy/routes.ts` +- Create: `src/features/housekeeping/domains/economy/routes.test.ts` +- Create: `src/features/housekeeping/domains/economy/queries/catalog.ts` +- Create: `src/features/housekeeping/domains/economy/queries/commerce.ts` +- Create: `src/features/housekeeping/domains/economy/queries/value.ts` +- Create: `src/features/housekeeping/domains/economy/queries/economy-queries.test.ts` +- Create: `src/features/housekeeping/domains/economy/commands/economy-commands.ts` +- Create: `src/features/housekeeping/domains/economy/commands/economy-commands.test.ts` +- Create: `src/features/housekeeping/domains/economy/pages/catalog.tsx` +- Create: `src/features/housekeeping/domains/economy/pages/items.tsx` +- Create: `src/features/housekeeping/domains/economy/pages/commerce.tsx` +- Create: `src/features/housekeeping/domains/economy/pages/history.tsx` +- Create: `src/features/housekeeping/domains/economy/pages/value.tsx` +- Create: `src/features/housekeeping/domains/economy/pages/rewards.tsx` +- Create: `src/features/housekeeping/domains/economy/pages/economy-pages.test.tsx` +- Create: `src/features/housekeeping/domains/economy/search.ts` +- Create: `src/features/housekeeping/domains/economy/inbox.ts` +- Create: `src/features/housekeeping/domains/economy/widgets.ts` +- Create: `src/features/housekeeping/domains/economy/route-handlers.ts` +- Create: `src/features/housekeeping/domains/economy/economy-providers.test.ts` +- Modify: `src/features/housekeeping/domains/economy/manifest.ts` +- Modify: `src/actions/catalog.ts` +- Modify: `src/actions/catalog-bc.ts` +- Modify: `src/actions/catalog-items.ts` +- Modify: `src/actions/items-base.ts` +- Modify: `src/actions/admin-marketplace.ts` +- Modify: `src/actions/admin-rare-values.ts` +- Modify: `src/actions/admin-shop.ts` +- Modify: `src/actions/admin-vouchers.ts` +- Modify: `src/actions/shop.ts` +- Modify: `src/actions/soundtracks.ts` +- Modify: `src/actions/voucher.ts` +- Modify: `src/lib/services/catalog-audit.ts` +- Modify: `src/lib/services/catalog-items-loader.ts` +- Modify: `src/lib/services/catalog-tree.ts` +- Modify: `src/lib/services/paypal.ts` +- Modify: `src/lib/services/paypal-topup.ts` +- Modify: `src/lib/services/send-currency.ts` + +**Interfaces:** + +```ts +export const ECONOMY_ROUTE_GROUPS = [ + "catalog", "items", "commerce", "history", "value", "rewards", +] as const; +export const ECONOMY_SEARCH_PROVIDER_IDS = [ + "economy.catalog-pages", "economy.items", "economy.transactions", +] as const; +``` + +- [ ] Write route tests covering catalog/detail/Builder Club/maintenance, items/detail, shop, marketplace, transactions, vouchers, subscriptions, rare values, badges, achievements, sounds, and calendar matrix rows. +- [ ] Write adapter tests for catalog trees/items, commerce history, transactions, vouchers/subscriptions, marketplace, rare values, rewards, sounds, and calendar; verify stable pagination and money/value serialization. +- [ ] Write sensitive command tests for catalog/item edits, maintenance, vouchers, shop changes, rare values, badge/reward changes, and destructive catalog operations with reason/audit requirements. +- [ ] Run Economy tests and confirm RED before implementation. +- [ ] Extract redirect-free domain services, implement six pages plus exact providers/widgets, and retain specialized catalog editors as components inside canonical workflows. +- [ ] Register the Economy manifest and prove matrix closure and non-empty route/search/inbox/widget collections. +- [ ] Run Economy, catalog/service, HK tests, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): deliver economy vertical`. + +### Task 16: Deliver Hotel rooms, radio, badges, and runtime tools + +**Files:** + +- Create: `src/features/housekeeping/domains/hotel/routes.ts` +- Create: `src/features/housekeeping/domains/hotel/routes.test.ts` +- Create: `src/features/housekeeping/domains/hotel/queries/rooms.ts` +- Create: `src/features/housekeeping/domains/hotel/queries/radio.ts` +- Create: `src/features/housekeeping/domains/hotel/queries/assets.ts` +- Create: `src/features/housekeeping/domains/hotel/queries/hotel-queries.test.ts` +- Create: `src/features/housekeeping/domains/hotel/commands/room-commands.ts` +- Create: `src/features/housekeeping/domains/hotel/commands/radio-commands.ts` +- Create: `src/features/housekeeping/domains/hotel/commands/asset-commands.ts` +- Create: `src/features/housekeeping/domains/hotel/commands/hotel-commands.test.ts` +- Create: `src/features/housekeeping/domains/hotel/pages/rooms.tsx` +- Create: `src/features/housekeeping/domains/hotel/pages/room-detail.tsx` +- Create: `src/features/housekeeping/domains/hotel/pages/room-furni.tsx` +- Create: `src/features/housekeeping/domains/hotel/pages/radio.tsx` +- Create: `src/features/housekeeping/domains/hotel/pages/badges.tsx` +- Create: `src/features/housekeeping/domains/hotel/pages/sounds.tsx` +- Create: `src/features/housekeeping/domains/hotel/pages/hotel-pages.test.tsx` +- Modify: `src/actions/rooms.ts` +- Modify: `src/actions/admin-radio-api-keys.ts` +- Modify: `src/actions/admin-radio-autodj.ts` +- Modify: `src/actions/admin-radio-extra.ts` +- Modify: `src/actions/admin-radio-moderation.ts` +- Modify: `src/actions/admin-radio-points.ts` +- Modify: `src/actions/admin-badges.ts` +- Modify: `src/actions/admin-badge-upload.ts` +- Modify: `src/lib/services/radio.ts` +- Modify: `src/lib/services/import-badge.ts` +- Modify: `src/lib/services/rcon.ts` +- Modify: `src/lib/services/soundtracks.ts` + +**Interfaces:** + +```ts +export const HOTEL_PRIMARY_ROUTE_IDS = [ + "hotel.rooms", "hotel.room-detail", "hotel.room-furni", + "hotel.radio.overview", "hotel.radio.settings", "hotel.radio.monitoring", + "hotel.radio.moderation", "hotel.radio.autodj", "hotel.radio.history", + "hotel.radio.points", "hotel.radio.ranks", "hotel.radio.api-keys", + "hotel.radio.banners", "hotel.radio.embed", "hotel.badges", "hotel.sounds", +] as const; +``` + +- [ ] Write route tests proving room show/edit aliases merge into one detail and one furni route; assert every radio/badge/sound matrix row has a canonical destination. +- [ ] Write query tests for room/owner/furni, radio configuration/monitoring/history/ranks, badges, and soundtracks; include unavailable RCON/radio dependencies. +- [ ] Write command tests for room changes, furni movement/removal, radio configuration/moderation/API keys/points, badge upload, and sounds; require reasons for destructive or external operations. +- [ ] Run Hotel tests and confirm RED. +- [ ] Extract redirect-free services, implement the six workflow page modules, and keep legacy wrappers live until cutover. +- [ ] Run Hotel primary, RCON/radio/sound, HK tests, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): deliver hotel operations`. + +### Task 17: Integrate Studio and long-running asset operations + +**Files:** + +- Create: `src/features/housekeeping/domains/hotel/queries/studio.ts` +- Create: `src/features/housekeeping/domains/hotel/queries/studio.test.ts` +- Create: `src/features/housekeeping/domains/hotel/commands/studio-commands.ts` +- Create: `src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts` +- Create: `src/features/housekeeping/domains/hotel/pages/studio.tsx` +- Create: `src/features/housekeeping/domains/hotel/pages/studio.test.tsx` +- Create: `src/features/housekeeping/domains/hotel/components/operation-progress.tsx` +- Create: `src/features/housekeeping/domains/hotel/components/operation-result.tsx` +- Create: `src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx` +- Create: `src/features/housekeeping/domains/hotel/search.ts` +- Create: `src/features/housekeeping/domains/hotel/inbox.ts` +- Create: `src/features/housekeeping/domains/hotel/widgets.ts` +- Create: `src/features/housekeeping/domains/hotel/route-handlers.ts` +- Create: `src/features/housekeeping/domains/hotel/hotel-providers.test.ts` +- Modify: `src/features/housekeeping/domains/hotel/manifest.ts` +- Modify: `src/actions/import-furni.ts` +- Modify: `src/actions/furni-maintenance.ts` +- Modify: `src/lib/services/clone-import.ts` +- Modify: `src/lib/services/clothing-set-import.ts` +- Modify: `src/lib/services/effect-import.ts` +- Modify: `src/lib/services/figure-import.ts` +- Modify: `src/lib/services/furni-import.ts` +- Modify: `src/lib/services/furni-maintenance.ts` +- Modify: `src/lib/services/pet-import.ts` +- Modify: `src/lib/services/repair-icons.ts` +- Modify: `src/lib/services/repair-nitros.ts` +- Modify: `src/lib/services/upload-import.ts` + +**Interfaces:** + +```ts +export type StudioOperationKind = + | "badge" | "clone" | "clothing" | "effect" | "furni" + | "maintenance" | "pet" | "repair-icons" | "sync" | "upload"; +export interface StudioOperationEvent { + operationId: string; + kind: StudioOperationKind; + phase: "queued" | "running" | "completed" | "failed" | "partial"; + completed: number; + total: number | null; + messageKey: string; + correlationId: string; +} +``` + +- [ ] Write tests for all ten Studio kinds, progress ordering, partial batch results, cancellation/abort propagation, unavailable external sources, and persisted intent before filesystem/RCON work. +- [ ] Run Studio tests and confirm RED. +- [ ] Wrap existing services with typed operations while preserving their current progress/error semantics and stable internal `/api/admin/import/*` transport paths. +- [ ] Implement the canonical `/ase/hotel/studio/*` workflow, operation progress/result components, Studio search, operational inbox source, and mandatory active-operation widget. +- [ ] Register every Hotel matrix route and prove no duplicate Studio root or orphan handler. +- [ ] Run Studio/import/service tests, Hotel tests, HK suite, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): integrate studio operations`. + +### Task 18: Implement global navigation, entity search, and command discovery + +**Files:** + +- Create: `src/features/housekeeping/foundation/search/search-service.ts` +- Create: `src/features/housekeeping/foundation/search/search-service.test.ts` +- Create: `src/features/housekeeping/foundation/search/navigation-search.ts` +- Create: `src/features/housekeeping/foundation/search/navigation-search.test.ts` +- Create: `src/actions/housekeeping-search.ts` +- Create: `src/actions/housekeeping-search.test.ts` +- Create: `src/features/housekeeping/foundation/shell/command-deck.tsx` +- Create: `src/features/housekeeping/foundation/shell/command-deck.test.tsx` +- Modify: `src/features/housekeeping/foundation/shell/command-trigger.tsx` +- Modify: `src/features/housekeeping/foundation/shell/housekeeping-shell.tsx` +- Modify: `src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx` + +**Interfaces:** + +```ts +export interface HousekeepingSearchResponse { + navigation: readonly HousekeepingNavigationHit[]; + commands: readonly HousekeepingCommandHit[]; + entities: readonly HousekeepingSearchResult[]; + errors: readonly { providerId: string; code: HousekeepingErrorCode }[]; + correlationId: string; +} +export function searchHousekeeping( + term: string, + context: HousekeepingCapabilityContext, +): Promise; +``` + +- [ ] Write tests proving trimmed terms shorter than two characters search navigation/commands only; two-character terms invoke permitted entity providers. +- [ ] Add orchestration tests for 2,000 ms/provider, 25/provider, 50 combined, stable dedupe/order, forbidden provider omission, and partial errors. +- [ ] Run search/action/deck tests and confirm RED. +- [ ] Implement registry navigation/command matching, domain entity orchestration, and a cmdk-based keyboard dialog with grouped results and canonical-to-preview href projection. +- [ ] Test open/close shortcut, focus restoration, arrow navigation, Enter activation, Escape, loading, no-results, and partial-provider UI. +- [ ] Run search, shell, provider, HK tests, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): add global command deck search`. + +### Task 19: Implement the derived operational inbox + +**Files:** + +- Create: `src/features/housekeeping/foundation/inbox/inbox-service.ts` +- Create: `src/features/housekeeping/foundation/inbox/inbox-service.test.ts` +- Create: `src/actions/housekeeping-inbox.ts` +- Create: `src/actions/housekeeping-inbox.test.ts` +- Create: `src/features/housekeeping/foundation/shell/operational-inbox.tsx` +- Create: `src/features/housekeeping/foundation/shell/operational-inbox.test.tsx` +- Create: `src/features/housekeeping/domains/system/search.ts` +- Create: `src/features/housekeeping/domains/system/inbox.ts` +- Create: `src/features/housekeeping/domains/system/widgets.ts` +- Create: `src/features/housekeeping/domains/system/system-providers.test.ts` +- Modify: `src/features/housekeeping/domains/system/manifest.ts` + +**Interfaces:** + +```ts +export interface HousekeepingInboxResponse { + items: readonly HousekeepingWorkItem[]; + errors: readonly { sourceId: string; code: HousekeepingErrorCode }[]; + correlationId: string; +} +export const INBOX_POLICY = { + timeoutMs: 2_000, + combinedLimit: 200, + dedupe: "sourceId:itemId", +} as const; +``` + +- [ ] Write tests for `(sourceId,itemId)` dedupe, capability filtering before count, priority then age ordering, 2,000 ms/source, 200-item cap, and partial-source failures. +- [ ] Run inbox action/service/component tests and confirm RED. +- [ ] Implement composition over registered People, Content, Economy, Hotel, and System sources without adding assignment/read-status persistence. +- [ ] Add System alert, emulator-error, and operational-anomaly sources plus System search/widgets that Task 10 registered conceptually. +- [ ] Implement accessible filters by domain/state/priority, canonical links, source error summaries, and empty/loading/partial states. +- [ ] Run inbox, all domain-provider, HK tests, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): compose operational inbox`. + +### Task 20: Add recent work, favorites, and widget personalization + +**Files:** + +- Create: `src/features/housekeeping/foundation/recent/recent-work.ts` +- Create: `src/features/housekeeping/foundation/recent/recent-work.test.ts` +- Create: `src/actions/housekeeping-recent.ts` +- Create: `src/actions/housekeeping-recent.test.ts` +- Create: `src/features/housekeeping/foundation/shell/recent-work.tsx` +- Create: `src/features/housekeeping/foundation/shell/favorites.tsx` +- Create: `src/features/housekeeping/foundation/shell/widget-grid.tsx` +- Create: `src/features/housekeeping/foundation/shell/widget-settings.tsx` +- Create: `src/features/housekeeping/foundation/shell/personalization.test.tsx` +- Modify: `src/features/housekeeping/foundation/registry.ts` +- Modify: `src/features/housekeeping/foundation/registry.test.ts` + +**Interfaces:** + +```ts +export interface HousekeepingRecentItem { + routeId: string; + canonicalHref: CanonicalHousekeepingHref; + labelKey: string; + occurredAt: string; + source: "route-visit" | "audit"; +} +export interface HousekeepingWidgetLoadResult { + widgets: readonly { id: string; data: unknown }[]; + errors: readonly { widgetId: string; code: HousekeepingErrorCode }[]; +} +``` + +- [ ] Write tests deriving recent work from `housekeeping.route.visit` and mutation audit rows, deduping by route ID, capability-filtering, and limiting to 12. +- [ ] Write component tests for pin/unpin, drag/keyboard ordering, mandatory widget lock, optional widget enable/disable, reconciled stale IDs, and partial widget failure. +- [ ] Run recent/personalization/action tests and confirm RED. +- [ ] Implement route-visit recording in `admin_audit_log`, recent-work queries, reconciled preference actions, dnd-kit ordering, and provider-orchestrated widget loading. +- [ ] Ensure preferences never authorize content and failed preference saves retain the previous UI state with an error announcement. +- [ ] Run preference, recent, widget, HK tests, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): personalize command deck`. + +### Task 21: Build the Operations workspace and complete shell composition + +**Files:** + +- Create: `src/features/housekeeping/domains/operations/routes.ts` +- Create: `src/features/housekeeping/domains/operations/routes.test.ts` +- Create: `src/features/housekeeping/domains/operations/queries.ts` +- Create: `src/features/housekeeping/domains/operations/queries.test.ts` +- Create: `src/features/housekeeping/domains/operations/pages/workspace.tsx` +- Create: `src/features/housekeeping/domains/operations/pages/workspace.test.tsx` +- Create: `src/features/housekeeping/domains/operations/search.ts` +- Create: `src/features/housekeeping/domains/operations/inbox.ts` +- Create: `src/features/housekeeping/domains/operations/widgets.ts` +- Create: `src/features/housekeeping/domains/operations/route-handlers.ts` +- Modify: `src/features/housekeeping/domains/operations/manifest.ts` +- Modify: `src/features/housekeeping/foundation/shell/housekeeping-shell.tsx` +- Modify: `src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx` +- Modify: `src/app/ase-next/page.tsx` + +**Interfaces:** + +```ts +export const OPERATIONS_ROUTES = [{ + id: "operations.workspace", + href: "/ase", + capability: anyCapability(PERMS.ADMIN_DASHBOARD), +}] as const; +export interface OperationsWorkspaceModel { + inbox: HousekeepingInboxResponse; + recent: readonly HousekeepingRecentItem[]; + favorites: HousekeepingPreferences; + widgets: HousekeepingWidgetLoadResult; +} +``` + +- [ ] Write route/matrix tests mapping the legacy `/admin` row to `operations.workspace` at canonical `/ase`. +- [ ] Write workspace tests for capability-specific sections, independent partial failures, no accessible domain, and preview links. +- [ ] Run Operations tests and confirm RED. +- [ ] Implement parallel inbox/recent/preferences/widget loading and render the operational home directly at `/ase-next`; the canonical cutover renders the same handler directly at `/ase`. +- [ ] Populate the Operations manifest with real route, safe navigation command, operational inbox summary, and mandatory workspace widget. +- [ ] Run Operations, shell, registry, HK tests, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `feat(housekeeping): compose operations workspace`. +### Task 22: Finish responsive behavior, accessibility, and localization + +**Files:** + +- Modify: `src/features/housekeeping/foundation/shell/housekeeping-shell.tsx` +- Modify: `src/features/housekeeping/foundation/shell/domain-rail.tsx` +- Modify: `src/features/housekeeping/foundation/shell/context-nav.tsx` +- Modify: `src/features/housekeeping/foundation/shell/operator-summary.tsx` +- Modify: `src/features/housekeeping/foundation/shell/command-deck.tsx` +- Modify: `src/features/housekeeping/foundation/shell/operational-inbox.tsx` +- Modify: `src/features/housekeeping/foundation/shell/recent-work.tsx` +- Modify: `src/features/housekeeping/foundation/shell/favorites.tsx` +- Modify: `src/features/housekeeping/foundation/shell/widget-grid.tsx` +- Modify: `src/features/housekeeping/foundation/shell/widget-settings.tsx` +- Create: `src/features/housekeeping/foundation/shell/accessibility.test.tsx` +- Create: `src/features/housekeeping/foundation/shell/responsive-contract.test.tsx` +- Modify: `src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx` +- Modify: `src/features/housekeeping/foundation/page/housekeeping-page-state.tsx` +- Modify: `src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx` +- Modify: `src/features/housekeeping/foundation/localization-contract.test.ts` +- Modify: `src/messages/ar.json` +- Modify: `src/messages/bg.json` +- Modify: `src/messages/cs.json` +- Modify: `src/messages/da.json` +- Modify: `src/messages/de.json` +- Modify: `src/messages/el.json` +- Modify: `src/messages/en.json` +- Modify: `src/messages/es.json` +- Modify: `src/messages/fi.json` +- Modify: `src/messages/fr.json` +- Modify: `src/messages/hr.json` +- Modify: `src/messages/hu.json` +- Modify: `src/messages/it.json` +- Modify: `src/messages/ja.json` +- Modify: `src/messages/nl.json` +- Modify: `src/messages/no.json` +- Modify: `src/messages/pl.json` +- Modify: `src/messages/pt.json` +- Modify: `src/messages/ro.json` +- Modify: `src/messages/ru.json` +- Modify: `src/messages/sk.json` +- Modify: `src/messages/sr.json` +- Modify: `src/messages/sv.json` +- Modify: `src/messages/tr.json` +- Modify: `src/messages/uk.json` +- Modify: `src/app/globals.css` + +**Interfaces:** + +```ts +export const HOUSEKEEPING_LANDMARKS = [ + "banner", "primary-navigation", "context-navigation", "main", +] as const; +export type HousekeepingPageState = + | "loading" | "empty" | "partial" | "error" | "forbidden" | "ready"; +``` + +- [ ] Write failing tests for one active nav item, landmark labels, heading hierarchy, skip link, focus-visible behavior, keyboard-reorder instructions, live error announcements, reduced-motion classes, and page-state semantics. +- [ ] Write responsive contracts for rail/context navigation collapse below `lg`, usable command deck at 320 px, non-overflowing tables/cards, and unchanged capabilities between mobile/desktop. +- [ ] Extend localization contract to collect every manifest/route/command/widget/state key and assert a non-empty value in all 25 locale files. +- [ ] Run accessibility/responsive/localization tests and confirm RED. +- [ ] Implement semantic shell behavior and complete every locale subtree with native-language operator copy; do not expose untranslated keys or preview wording after cutover. +- [ ] Run shell/page/localization tests, HK suite, and `pnpm typecheck`. +- [ ] Run targeted Biome, `git diff --check`, stage exact UI/locale files, and commit `feat(housekeeping): finish accessible command deck`. + +### Task 23: Close the 137-row matrix against the runtime registry + +**Files:** + +- Create: `src/features/housekeeping/cutover/parity.ts` +- Create: `src/features/housekeeping/cutover/parity.test.ts` +- Create: `src/features/housekeeping/cutover/source-boundaries.test.ts` +- Modify: `src/features/housekeeping/migration/operations.ts` +- Modify: `src/features/housekeeping/migration/people.ts` +- Modify: `src/features/housekeeping/migration/content.ts` +- Modify: `src/features/housekeeping/migration/economy.ts` +- Modify: `src/features/housekeeping/migration/hotel.ts` +- Modify: `src/features/housekeeping/migration/system.ts` +- Modify: `scripts/verify-housekeeping-matrix.ts` +- Modify: `package.json` + +**Interfaces:** + +```ts +export interface HousekeepingParityReport { + discovered: 137; + mapped: 137; + verified: 137; + removed: number; + unresolved: readonly string[]; + capabilityGaps: readonly string[]; + handlerGaps: readonly string[]; +} +export function verifyHousekeepingRuntimeParity( + matrix: readonly MigrationEntry[], + registry: HousekeepingRegistry, + handlers: readonly HousekeepingRouteHandler[], +): HousekeepingParityReport; +``` + +- [ ] Write a failing aggregate test requiring exactly 137 discovered/mapped/verified rows, zero unresolved/capability/handler gaps, every retained target under `/ase`, and every removed row with no handler. +- [ ] Write source-boundary tests forbidding foundation imports from domain internals/database/actions and forbidding one domain from another domain's internals. +- [ ] Run parity/boundary tests and confirm RED. +- [ ] Add concrete `parityEvidence` test IDs to each matrix row and change retained rows to `VERIFIED`, removed rows to `REMOVED` only after their evidence passes. +- [ ] Extend `hk:matrix:check` output with the runtime parity counts and make non-137 or any gap exit non-zero. +- [ ] Run `pnpm hk:matrix:check`, `pnpm test:housekeeping`, and `pnpm typecheck`. +- [ ] Run Biome, `git diff --check`, stage exact files, and commit `test(housekeeping): prove 137 route parity`. + +### Task 24: Pass cumulative pre-cutover verification + +**Files:** + +- Create: `docs/superpowers/evidence/2026-08-26-housekeeping-pre-cutover.md` +- Inspect: every file changed by Tasks 1-23; source failures return to their owning task and commit before this evidence-only task continues + +**Interfaces:** + +The evidence document records command, exit code, test count, date/time, environment limitations, visual viewport, route, actor capability fixture, and observed result. It never reports unavailable live services as passing. + +- [ ] Run `pnpm toolchain:check`, `pnpm hk:matrix:check`, `pnpm test:housekeeping`, `pnpm test`, and `pnpm typecheck`; record fresh output and fix only Housekeeping-caused failures with a RED/GREEN test. +- [ ] Run semantic Biome on all changed JS/TS/JSON with formatter disabled, run targeted formatter only on changed files, then run `git diff --check`. +- [ ] Run `pnpm build` with the repository's required temporary environment values; restore every environment file/value afterward and record restoration. +- [ ] Start the preview in non-production mode and verify `/ase-next` at 1440x900, 1024x768, 390x844, and 320x568 for all six domains plus loading, empty, partial, error, and forbidden states. +- [ ] Smoke permitted/denied access, safe/sensitive commands, provider timeout isolation, preference persistence/reconciliation, and all long-running Studio states. +- [ ] Record evidence, run `git diff --check`, stage only the evidence and verified fixes, and commit `test(housekeeping): record pre-cutover verification`. + +### Task 25: Perform the atomic `/ase` cutover + +**Files:** + +- Move: `src/app/ase-next` to `src/app/ase` +- Delete: `src/app/admin` +- Delete: `src/app/mod` +- Modify: `src/features/housekeeping/foundation/routing/href.ts` +- Modify: `src/features/housekeeping/foundation/preview-gate.ts` +- Modify: `src/features/housekeeping/foundation/preview-gate.test.ts` +- Modify: `src/features/housekeeping/foundation/preview-route-contract.test.ts` +- Create: `src/features/housekeeping/cutover/route-cutover.test.ts` +- Modify: `src/lib/admin/guard.ts` +- Modify: `src/lib/admin/guard.test.ts` +- Modify: `src/proxy.ts` +- Modify: `src/components/navigation.tsx` +- Modify: `src/components/top-header.tsx` +- Modify: `src/components/admin/admin-breadcrumb.tsx` +- Modify: `src/lib/admin-theme-source-audit.test.ts` +- Modify: `src/features/housekeeping/foundation/foundation-source-contract.test.ts` +- Modify: `src/env.ts` +- Modify: `.env.example` + +**Interfaces:** + +```ts +export const HOUSEKEEPING_ROOT = "/ase" as const; +// No runtime preview surface remains after cutover. +``` + +- [ ] Write the route-cutover test first: `src/app/ase/layout.tsx` and canonical dispatcher must exist; `src/app/admin`, `src/app/admin-next`, `src/app/ase-next`, and `src/app/mod` must not exist; proxy/global navigation/fallbacks must target `/ase`; no redirect maps removed UI paths. +- [ ] Run the cutover test and confirm RED before moving/removing route trees. +- [ ] Move the completed preview tree to `/ase`, remove preview-only badge/gate/flag behavior, and make all navigation/search/inbox/widget links canonical without preview projection. +- [ ] Remove legacy UI trees, rewrite global navigation and authorization fallbacks, and keep `/api/admin/*` internal endpoints unchanged because they are not UI compatibility routes. +- [ ] Remove imports/tests tied to deleted page modules; retain shared services/components only when the new domains import them without legacy route coupling. +- [ ] Run cutover, proxy/auth, source-boundary, theme, matrix, HK, full tests, and `pnpm typecheck`. +- [ ] Run Biome on changed files, `git diff --check`, stage the entire exact cutover set, and commit `feat(housekeeping): cut over administration to ase`. + +### Task 26: Run final review and release-quality verification + +**Files:** + +- Create: `docs/superpowers/evidence/2026-08-26-housekeeping-final.md` +- Inspect: every file changed on `origin/main...HEAD`; confirmed findings return to their owning task and commit before this evidence-only task continues + +**Interfaces:** + +Final acceptance requires a clean `git diff --check`, 137/137 runtime parity, production build success, no legacy UI route tree, no compatibility redirect, and recorded desktop/mobile evidence for `/ase`. + +- [ ] Review `git diff --stat origin/main...HEAD`, every commit, and the complete diff for authorization bypass, client-trusted capability, missing audit, leaked secret, unsafe external/file mutation, cross-domain import, dead legacy route, and unrelated churn. +- [ ] Invoke `superpowers:requesting-code-review`; classify each finding by evidence and fix confirmed findings in one reviewed wave using a failing regression test first. +- [ ] Re-run `pnpm toolchain:check`, `pnpm hk:matrix:check`, `pnpm test:housekeeping`, `pnpm test`, `pnpm typecheck`, semantic Biome, targeted formatting, and `git diff --check` from a clean process. +- [ ] Re-run `pnpm build` with temporary environment restoration and repeat canonical `/ase` route/access/command visual smoke at 1440x900 and 390x844. +- [ ] Verify direct requests to `/admin`, `/admin-next`, `/ase-next`, `/mod`, and removed routes are unreachable and not redirected; verify `/api/health` locally only if its dependencies are available. +- [ ] Record exact final evidence and residual pre-existing repository debt, stage only the evidence/fix wave, and commit `test(housekeeping): finalize release evidence`. +- [ ] Stop before network mutation. Present branch status, commits, checks, and evidence to the user; push and open the one final PR only after an explicit instruction. + +## Post-merge release gate + +After the final PR is explicitly authorized, pushed, reviewed, and merged, wait for the deployment pipeline to finish and verify the live `/api/health` response plus authenticated `/ase` access. A failed migration or health check blocks the release. Rollback deploys the prior application release; the additive `0023` schema remains compatible and is not destructively reversed. diff --git a/docs/superpowers/specs/2026-08-26-housekeeping-completion-design.md b/docs/superpowers/specs/2026-08-26-housekeeping-completion-design.md index f2c48525..da5b3b23 100644 --- a/docs/superpowers/specs/2026-08-26-housekeeping-completion-design.md +++ b/docs/superpowers/specs/2026-08-26-housekeeping-completion-design.md @@ -18,6 +18,10 @@ delivery details differ, this document is authoritative for phases 02 onward. The master architecture remains authoritative for domain ownership and product behavior. +This completion specification supersedes the earlier documents only for the +route namespace: the new surface uses `/ase`, never `/admin`, as its canonical +production root. + ## Approved decisions - Build complete verticals behind the existing non-production gate. @@ -26,8 +30,9 @@ behavior. - Implement in vertical slices rather than UI-first placeholders. - Keep current `/admin` and `/mod` behavior unchanged until the final cutover commit. -- At cutover, make the new Command Deck the real `/admin`, remove `/mod`, and - remove obsolete legacy routes without redirects. +- At cutover, make the new Command Deck live at `/ase`, remove the legacy + `/admin`, `/admin-next`, and `/mod` trees, and remove obsolete legacy routes + without redirects. - Use hybrid personalization: mandatory content is capability-derived; operators may pin and reorder allowed shortcuts and optional widgets. - Add only backward-compatible database migrations before cutover. @@ -55,10 +60,12 @@ All work is committed to `codex/housekeeping-complete`, based on the latest `origin/main`. No pull request is opened until every vertical and the cutover are implemented, reviewed, and verified. -The existing `/admin-next` entry remains unavailable when -`NODE_ENV=production`. Development and test environments use it to exercise the -new shell before cutover. The final cutover changes the canonical `/admin` route; -it does not weaken the production preview gate. +The foundation currently exposes `/admin-next`. The first completion change +renames that preview tree and its links to `/ase-next`; the preview remains +unavailable when `NODE_ENV=production`. Development and test environments use +`/ase-next` to exercise the new shell before cutover. The final cutover publishes +the canonical `/ase` tree and removes the preview entry; it does not weaken the +production preview gate before that point. The branch is built in this order: @@ -75,17 +82,18 @@ The branch is built in this order: After cutover the public administration route tree is: ```text -/admin Operations workspace -/admin/people/* users, tickets, CFH, bans, moderation, teams -/admin/content/* articles, events, polls, media, engagement -/admin/economy/* catalog, shop, transactions, vouchers, values -/admin/hotel/* rooms, furni, badges, radio, emulator, Studio -/admin/system/* settings, ACL, logs, DevOps, maintenance +/ase Operations workspace +/ase/people/* users, tickets, CFH, bans, moderation, teams +/ase/content/* articles, events, polls, media, engagement +/ase/economy/* catalog, shop, transactions, vouchers, values +/ase/hotel/* rooms, furni, badges, radio, emulator, Studio +/ase/system/* settings, ACL, logs, DevOps, maintenance ``` -`/admin` is the operational home, not a duplicate menu page. `/mod` has no route -after cutover. A workflow has one canonical owner and one canonical destination; -the new tree must not retain duplicate hubs or aliases. +`/ase` is the operational home, not a duplicate menu page. `/admin`, +`/admin-next`, and `/mod` have no route after cutover. A workflow has one +canonical owner and one canonical destination; the new tree must not retain +duplicate hubs or aliases. ## Module ownership @@ -331,11 +339,13 @@ legacy UI routes is an application cutover, not a destructive data migration. The final cutover commit is created only after all vertical gates pass. It: -1. moves the completed shell and Operations workspace to `/admin`; -2. changes domain preview hrefs to canonical `/admin/` hrefs; +1. moves the completed shell and Operations workspace from `/ase-next` to + `/ase`; +2. changes domain preview hrefs to canonical `/ase/` hrefs; 3. updates internal links, navigation configuration, and authorization fallback destinations; -4. removes `/mod` and every legacy route marked `REMOVE`; +4. removes the legacy `/admin`, `/admin-next`, and `/mod` route trees plus every + legacy route marked `REMOVE`; 5. removes legacy pages whose behavior moved or merged into canonical routes; 6. removes the temporary preview entry and flag if no longer used by tests; 7. adds no compatibility redirects. @@ -363,8 +373,8 @@ The final branch requires: - production build with temporary environment restoration; - visual verification at desktop and mobile widths for every domain and shared state; -- route-level smoke checks for canonical pages, denied access, and removal of - `/mod`/obsolete routes; +- route-level smoke checks for canonical `/ase` pages, denied access, and + removal of `/admin`, `/admin-next`, `/mod`, and obsolete routes; - a broad whole-branch code review followed by one reviewed fix wave if needed. Repository-wide pre-existing formatter debt is reported separately and must not @@ -389,7 +399,8 @@ before serving the cutover release. - A new task-assignment system for inbox items. - A replacement authentication or ACL model. - Rank-based authorization thresholds. -- Compatibility redirects for removed admin/mod routes. +- Compatibility redirects for removed `/admin`, `/admin-next`, or `/mod` + routes. - Destructive cleanup of legacy database data. - Rewriting specialized domain engines that already work; they are integrated behind consistent domain contracts instead. @@ -406,6 +417,6 @@ The program is complete only when: operate against real domain services; - capability enforcement and audit evidence cover every exposed read and mutation path; -- `/admin` serves the new HK, `/mod` and removed legacy routes are unreachable, - and no compatibility redirects exist; +- `/ase` serves the new HK; `/admin`, `/admin-next`, `/mod`, and removed legacy + routes are unreachable; and no compatibility redirects exist; - final local, CI, deployment, health, and visual evidence are all recorded. -- 2.54.0 From f72a2b6c74a4333324a2c4cda1295c0ca8b7db39 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 26 Aug 2026 20:44:38 +0200 Subject: [PATCH 03/68] refactor(housekeeping): adopt ase route namespace --- .../[domain]/layout.tsx | 7 +- .../[domain]/page.tsx | 0 src/app/{admin-next => ase-next}/layout.tsx | 0 src/app/{admin-next => ase-next}/page.tsx | 3 +- .../housekeeping/domains/content/manifest.ts | 2 +- .../housekeeping/domains/economy/manifest.ts | 2 +- .../housekeeping/domains/hotel/manifest.ts | 2 +- .../domains/operations/manifest.ts | 2 +- .../housekeeping/domains/people/manifest.ts | 2 +- .../housekeeping/domains/system/manifest.ts | 2 +- .../foundation/contracts/contracts.test.ts | 6 +- .../foundation/contracts/domain.ts | 6 +- .../foundation/contracts/index.ts | 2 + .../foundation-source-contract.test.ts | 2 +- .../foundation/navigation.test.ts | 58 +++++++-- .../housekeeping/foundation/navigation.ts | 7 +- .../page/housekeeping-page-state.test.tsx | 8 +- .../foundation/preview-route-contract.test.ts | 122 +++++++++--------- .../housekeeping/foundation/registry.test.ts | 55 +++++--- .../housekeeping/foundation/registry.ts | 10 +- .../foundation/routing/href.test.ts | 21 +++ .../housekeeping/foundation/routing/href.ts | 15 +++ .../shell/housekeeping-shell.test.tsx | 20 +-- .../housekeeping/migration/content.ts | 58 ++++----- .../housekeeping/migration/economy.ts | 40 +++--- src/features/housekeeping/migration/hotel.ts | 58 ++++----- .../housekeeping/migration/operations.ts | 2 +- src/features/housekeeping/migration/people.ts | 78 +++++------ src/features/housekeeping/migration/system.ts | 34 ++--- .../migration/validate-matrix.test.ts | 20 ++- .../housekeeping/migration/validate-matrix.ts | 6 +- src/lib/admin-theme-source-audit.test.ts | 2 +- 32 files changed, 388 insertions(+), 264 deletions(-) rename src/app/{admin-next => ase-next}/[domain]/layout.tsx (93%) rename src/app/{admin-next => ase-next}/[domain]/page.tsx (100%) rename src/app/{admin-next => ase-next}/layout.tsx (100%) rename src/app/{admin-next => ase-next}/page.tsx (82%) create mode 100644 src/features/housekeeping/foundation/routing/href.test.ts create mode 100644 src/features/housekeeping/foundation/routing/href.ts diff --git a/src/app/admin-next/[domain]/layout.tsx b/src/app/ase-next/[domain]/layout.tsx similarity index 93% rename from src/app/admin-next/[domain]/layout.tsx rename to src/app/ase-next/[domain]/layout.tsx index cedf4ca2..72d73818 100644 --- a/src/app/admin-next/[domain]/layout.tsx +++ b/src/app/ase-next/[domain]/layout.tsx @@ -35,8 +35,11 @@ export default async function AdminNextDomainLayout({ if (!satisfiesCapability(context, activeDomain.capability)) notFound(); const translate = await getTranslations("pages.housekeeping"); - const navigation = buildHousekeepingNavigation(registry, context, (key) => - translate(namespaceKey(key) as never), + const navigation = buildHousekeepingNavigation( + registry, + context, + "preview", + (key) => translate(namespaceKey(key) as never), ); return ( diff --git a/src/app/admin-next/[domain]/page.tsx b/src/app/ase-next/[domain]/page.tsx similarity index 100% rename from src/app/admin-next/[domain]/page.tsx rename to src/app/ase-next/[domain]/page.tsx diff --git a/src/app/admin-next/layout.tsx b/src/app/ase-next/layout.tsx similarity index 100% rename from src/app/admin-next/layout.tsx rename to src/app/ase-next/layout.tsx diff --git a/src/app/admin-next/page.tsx b/src/app/ase-next/page.tsx similarity index 82% rename from src/app/admin-next/page.tsx rename to src/app/ase-next/page.tsx index b36e4e7d..7ff08c60 100644 --- a/src/app/admin-next/page.tsx +++ b/src/app/ase-next/page.tsx @@ -1,6 +1,7 @@ import { notFound, redirect } from "next/navigation"; import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; +import { toHousekeepingHref } from "@/features/housekeeping/foundation/routing/href"; import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; @@ -13,5 +14,5 @@ export default async function AdminNextPage() { if (!firstVisibleDomain) notFound(); - redirect(firstVisibleDomain.previewHref); + redirect(toHousekeepingHref(firstVisibleDomain.canonicalHref, "preview")); } diff --git a/src/features/housekeeping/domains/content/manifest.ts b/src/features/housekeeping/domains/content/manifest.ts index f367587c..418995ff 100644 --- a/src/features/housekeeping/domains/content/manifest.ts +++ b/src/features/housekeeping/domains/content/manifest.ts @@ -9,7 +9,7 @@ export const contentManifest = { labelKey: "pages.housekeeping.domains.content.title", descriptionKey: "pages.housekeeping.domains.content.description", iconId: "file-text", - previewHref: "/admin-next/content", + canonicalHref: "/ase/content", capability: anyCapability( PERMS.NEWS_VIEW, PERMS.PAGES_VIEW, diff --git a/src/features/housekeeping/domains/economy/manifest.ts b/src/features/housekeeping/domains/economy/manifest.ts index 8eddf3b5..d500e1e0 100644 --- a/src/features/housekeeping/domains/economy/manifest.ts +++ b/src/features/housekeeping/domains/economy/manifest.ts @@ -9,7 +9,7 @@ export const economyManifest = { labelKey: "pages.housekeeping.domains.economy.title", descriptionKey: "pages.housekeeping.domains.economy.description", iconId: "gem", - previewHref: "/admin-next/economy", + canonicalHref: "/ase/economy", capability: anyCapability( PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW, diff --git a/src/features/housekeeping/domains/hotel/manifest.ts b/src/features/housekeeping/domains/hotel/manifest.ts index 8c0ef95b..1c89cd05 100644 --- a/src/features/housekeeping/domains/hotel/manifest.ts +++ b/src/features/housekeeping/domains/hotel/manifest.ts @@ -9,7 +9,7 @@ export const hotelManifest = { labelKey: "pages.housekeeping.domains.hotel.title", descriptionKey: "pages.housekeeping.domains.hotel.description", iconId: "hotel", - previewHref: "/admin-next/hotel", + canonicalHref: "/ase/hotel", capability: anyCapability( PERMS.ROOMS_VIEW, PERMS.RADIO_VIEW, diff --git a/src/features/housekeeping/domains/operations/manifest.ts b/src/features/housekeeping/domains/operations/manifest.ts index c06d2687..931693bc 100644 --- a/src/features/housekeeping/domains/operations/manifest.ts +++ b/src/features/housekeeping/domains/operations/manifest.ts @@ -9,7 +9,7 @@ export const operationsManifest = { labelKey: "pages.housekeeping.domains.operations.title", descriptionKey: "pages.housekeeping.domains.operations.description", iconId: "inbox", - previewHref: "/admin-next/operations", + canonicalHref: "/ase", capability: anyCapability(PERMS.ADMIN_DASHBOARD), routes: [], searchProviders: [], diff --git a/src/features/housekeeping/domains/people/manifest.ts b/src/features/housekeeping/domains/people/manifest.ts index 21d1f3ea..4fa1c1fa 100644 --- a/src/features/housekeeping/domains/people/manifest.ts +++ b/src/features/housekeeping/domains/people/manifest.ts @@ -9,7 +9,7 @@ export const peopleManifest = { labelKey: "pages.housekeeping.domains.people.title", descriptionKey: "pages.housekeeping.domains.people.description", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase/people", capability: anyCapability( PERMS.USERS_VIEW, PERMS.MODERATION_VIEW, diff --git a/src/features/housekeeping/domains/system/manifest.ts b/src/features/housekeeping/domains/system/manifest.ts index f4ba2345..e7fd86bb 100644 --- a/src/features/housekeeping/domains/system/manifest.ts +++ b/src/features/housekeeping/domains/system/manifest.ts @@ -9,7 +9,7 @@ export const systemManifest = { labelKey: "pages.housekeeping.domains.system.title", descriptionKey: "pages.housekeeping.domains.system.description", iconId: "settings", - previewHref: "/admin-next/system", + canonicalHref: "/ase/system", capability: anyCapability( PERMS.SETTINGS_VIEW, PERMS.LOGS_VIEW, diff --git a/src/features/housekeeping/foundation/contracts/contracts.test.ts b/src/features/housekeeping/foundation/contracts/contracts.test.ts index caf21dd0..dfc53c99 100644 --- a/src/features/housekeeping/foundation/contracts/contracts.test.ts +++ b/src/features/housekeeping/foundation/contracts/contracts.test.ts @@ -28,7 +28,7 @@ const workItem = { occurredAt: "2026-08-24T12:00:00.000Z", titleKey: "pages.housekeeping.items.ticket", context: { ticketId: "42" }, - href: "/admin-next/people/tickets/42", + href: "/ase-next/people/tickets/42", freshness: "fresh", } satisfies HousekeepingWorkItem; @@ -36,7 +36,7 @@ const searchResult = { id: "user-42", domain: "people", title: "operator", - href: "/admin-next/people/users/42", + href: "/ase-next/people/users/42", } satisfies HousekeepingSearchResult; const searchProvider: HousekeepingSearchProvider = { @@ -87,7 +87,7 @@ const manifest: HousekeepingDomainManifest = { labelKey: "pages.housekeeping.domains.people.title", descriptionKey: "pages.housekeeping.domains.people.description", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase-next/people", capability, routes: [], searchProviders: [searchProvider], diff --git a/src/features/housekeeping/foundation/contracts/domain.ts b/src/features/housekeeping/foundation/contracts/domain.ts index 9ccb4f54..a4c6f9eb 100644 --- a/src/features/housekeeping/foundation/contracts/domain.ts +++ b/src/features/housekeeping/foundation/contracts/domain.ts @@ -4,10 +4,12 @@ import type { HousekeepingInboxSource } from "./inbox"; import type { HousekeepingSearchProvider } from "./search"; import type { HousekeepingWidgetDefinition } from "./widget"; +export type HousekeepingSurface = "preview" | "canonical"; +export type CanonicalHousekeepingHref = `/ase${string}`; export interface HousekeepingRouteDefinition { id: string; labelKey: string; - href: string; + href: CanonicalHousekeepingHref; capability: CapabilityRequirement; matchPrefixes?: readonly string[]; } @@ -17,7 +19,7 @@ export interface HousekeepingDomainManifest { labelKey: string; descriptionKey: string; iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings"; - previewHref: `/admin-next/${HousekeepingDomainId}`; + canonicalHref: CanonicalHousekeepingHref; capability: CapabilityRequirement; routes: readonly HousekeepingRouteDefinition[]; searchProviders: readonly HousekeepingSearchProvider[]; diff --git a/src/features/housekeeping/foundation/contracts/index.ts b/src/features/housekeeping/foundation/contracts/index.ts index 55f607fb..5f0e35ca 100644 --- a/src/features/housekeeping/foundation/contracts/index.ts +++ b/src/features/housekeeping/foundation/contracts/index.ts @@ -7,8 +7,10 @@ export { } from "./capability"; export type { HousekeepingCommand } from "./command"; export type { + CanonicalHousekeepingHref, HousekeepingDomainManifest, HousekeepingRouteDefinition, + HousekeepingSurface, } from "./domain"; export type { HousekeepingInboxSource, diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 3c7f541a..561edd00 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -555,7 +555,7 @@ describe("housekeeping foundation completion contracts", () => { for (const path of [ "src/app/admin/layout.tsx", "src/app/mod/layout.tsx", - "src/app/admin-next/layout.tsx", + "src/app/ase-next/layout.tsx", ]) { expect(existsSync(path), path).toBe(true); } diff --git a/src/features/housekeeping/foundation/navigation.test.ts b/src/features/housekeeping/foundation/navigation.test.ts index 88dce236..328732aa 100644 --- a/src/features/housekeeping/foundation/navigation.test.ts +++ b/src/features/housekeeping/foundation/navigation.test.ts @@ -23,7 +23,7 @@ describe("housekeeping navigation", () => { labelKey: "pages.housekeeping.domains.people.title", descriptionKey: "pages.housekeeping.domains.people.description", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase/people", capability: anyCapability(PERMS.MOD_CFH_VIEW), routes: [], searchProviders: [], @@ -35,7 +35,7 @@ describe("housekeeping navigation", () => { labelKey: "pages.housekeeping.domains.economy.title", descriptionKey: "pages.housekeeping.domains.economy.description", iconId: "gem", - previewHref: "/admin-next/economy", + canonicalHref: "/ase/economy", capability: anyCapability(PERMS.CATALOG_VIEW), routes: [], searchProviders: [], @@ -47,13 +47,14 @@ describe("housekeeping navigation", () => { const navigation = buildHousekeepingNavigation( registry, context([PERMS.MOD_CFH_VIEW]), + "canonical", (key) => key, ); expect(navigation).toEqual([ { id: "people", - href: "/admin-next/people", + href: "/ase/people", iconId: "users", label: "pages.housekeeping.domains.people.title", description: "pages.housekeeping.domains.people.description", @@ -69,19 +70,19 @@ describe("housekeeping navigation", () => { labelKey: "people.title", descriptionKey: "people.description", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase/people", capability: anyCapability(PERMS.USERS_VIEW), routes: [ { id: "users", labelKey: "people.users", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }, { id: "bans", labelKey: "people.bans", - href: "/admin-next/people/bans", + href: "/ase/people/bans", capability: anyCapability(PERMS.BANS_VIEW), }, ], @@ -94,7 +95,7 @@ describe("housekeeping navigation", () => { labelKey: "system.title", descriptionKey: "system.description", iconId: "settings", - previewHref: "/admin-next/system", + canonicalHref: "/ase/system", capability: anyCapability(PERMS.SETTINGS_VIEW), routes: [], searchProviders: [], @@ -107,20 +108,21 @@ describe("housekeeping navigation", () => { const navigation = buildHousekeepingNavigation( registry, context([PERMS.USERS_VIEW]), + "canonical", translate, ); expect(navigation).toEqual([ { id: "people", - href: "/admin-next/people", + href: "/ase/people", iconId: "users", label: "translated:people.title", description: "translated:people.description", items: [ { id: "users", - href: "/admin-next/people/users", + href: "/ase/people/users", label: "translated:people.users", }, ], @@ -138,6 +140,7 @@ describe("housekeeping navigation", () => { buildHousekeepingNavigation( registry, context([PERMS.MOD_CFH_VIEW]), + "preview", translate, ).map((domain) => domain.id), ).toContain("people"); @@ -145,6 +148,7 @@ describe("housekeeping navigation", () => { buildHousekeepingNavigation( registry, context([PERMS.MOD_CFH_VIEW]), + "preview", translate, ).map((domain) => domain.id), ).not.toContain("economy"); @@ -160,6 +164,7 @@ describe("housekeeping navigation", () => { const visibleDomainIds = buildHousekeepingNavigation( registry, context([slug]), + "preview", translate, ).map((domain) => domain.id); @@ -167,3 +172,38 @@ describe("housekeeping navigation", () => { } }); }); + +it("maps canonical navigation hrefs onto the requested surface", () => { + const registry = createHousekeepingRegistry([ + { + id: "people", + labelKey: "people.title", + descriptionKey: "people.description", + iconId: "users", + canonicalHref: "/ase/people", + capability: anyCapability(PERMS.USERS_VIEW), + routes: [ + { + id: "users", + labelKey: "people.users", + href: "/ase/people/users", + capability: anyCapability(PERMS.USERS_VIEW), + }, + ], + searchProviders: [], + inboxSources: [], + widgets: [], + }, + ]); + const navigation = buildHousekeepingNavigation( + registry, + context([PERMS.USERS_VIEW]), + "preview", + (key) => key, + ); + + expect(navigation[0]).toMatchObject({ + href: "/ase-next/people", + items: [{ href: "/ase-next/people/users" }], + }); +}); diff --git a/src/features/housekeeping/foundation/navigation.ts b/src/features/housekeeping/foundation/navigation.ts index 5af2037f..02b63cc7 100644 --- a/src/features/housekeeping/foundation/navigation.ts +++ b/src/features/housekeeping/foundation/navigation.ts @@ -3,8 +3,10 @@ import { satisfiesCapability } from "./capability-context"; import type { HousekeepingCapabilityContext, HousekeepingDomainManifest, + HousekeepingSurface, } from "./contracts"; import type { HousekeepingRegistry } from "./registry"; +import { toHousekeepingHref } from "./routing/href"; export interface HousekeepingNavigationDomain { id: HousekeepingDomainId; @@ -18,13 +20,14 @@ export interface HousekeepingNavigationDomain { export function buildHousekeepingNavigation( registry: HousekeepingRegistry, context: HousekeepingCapabilityContext, + surface: HousekeepingSurface, translate: (key: string) => string, ): readonly HousekeepingNavigationDomain[] { return registry.domains .filter((domain) => satisfiesCapability(context, domain.capability)) .map((domain) => ({ id: domain.id, - href: domain.previewHref, + href: toHousekeepingHref(domain.canonicalHref, surface), iconId: domain.iconId, label: translate(domain.labelKey), description: translate(domain.descriptionKey), @@ -32,7 +35,7 @@ export function buildHousekeepingNavigation( .filter((route) => satisfiesCapability(context, route.capability)) .map((route) => ({ id: route.id, - href: route.href, + href: toHousekeepingHref(route.href, surface), label: translate(route.labelKey), })), })); diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx index b6c9ecbf..13c02396 100644 --- a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx +++ b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx @@ -75,12 +75,12 @@ describe("HousekeepingPageState", () => { state="error" title="Could not load" description="Try again later" - retryAction={Retry preview} + retryAction={Retry preview} />, ); expect(html).toContain('role="alert"'); - expect(html).toContain('href="/admin-next/operations"'); + expect(html).toContain('href="/ase-next/operations"'); expect(html).toContain(">Retry preview<"); }); }); @@ -92,7 +92,7 @@ describe("HousekeepingPageShell", () => { title="People" description="Review operator-facing people data" context={Preview context} - primaryAction={Create preview} + primaryAction={Create preview} >

Page body

, @@ -102,7 +102,7 @@ describe("HousekeepingPageShell", () => { 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('href="/ase-next/people/new"'); expect(html).toContain(">Create preview<"); expect(html).toContain(">Page body

"); }); diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index ae9bd9b6..f3ad2a55 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -78,16 +78,16 @@ 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"; +import AdminNextDomainLayout from "@/app/ase-next/[domain]/layout"; +import AdminNextDomainPage from "@/app/ase-next/[domain]/page"; +import AdminNextLayout from "@/app/ase-next/layout"; +import AdminNextPage from "@/app/ase-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", + "src/app/ase-next/layout.tsx", + "src/app/ase-next/page.tsx", + "src/app/ase-next/[domain]/layout.tsx", + "src/app/ase-next/[domain]/page.tsx", ] as const; const forbiddenModuleRoots = [ @@ -360,7 +360,7 @@ async function renderRoute(route: ReactNode | Promise) { return renderToStaticMarkup(await route); } -describe("/admin-next preview gate", () => { +describe("/ase-next preview gate", () => { beforeEach(() => { vi.clearAllMocks(); routeMocks.env.NODE_ENV = "test"; @@ -402,7 +402,7 @@ describe("/admin-next preview gate", () => { ); }); -describe("/admin-next first visible domain", () => { +describe("/ase-next first visible domain", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -412,10 +412,8 @@ describe("/admin-next first visible domain", () => { capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]), ); - await expect(AdminNextPage()).rejects.toThrow( - "NEXT_REDIRECT:/admin-next/operations", - ); - expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/operations"); + await expect(AdminNextPage()).rejects.toThrow("NEXT_REDIRECT:/ase-next"); + expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next"); expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( 1, ); @@ -428,9 +426,9 @@ describe("/admin-next first visible domain", () => { ); await expect(AdminNextPage()).rejects.toThrow( - "NEXT_REDIRECT:/admin-next/people", + "NEXT_REDIRECT:/ase-next/people", ); - expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/people"); + expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people"); expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( 1, ); @@ -450,7 +448,7 @@ describe("/admin-next first visible domain", () => { }); }); -describe("/admin-next/[domain] layout", () => { +describe("/ase-next/[domain] layout", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -515,7 +513,7 @@ describe("/admin-next/[domain] layout", () => { }); }); -describe("/admin-next/[domain] page", () => { +describe("/ase-next/[domain] page", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -561,247 +559,247 @@ describe("preview route import boundary", () => { it.each([ [ "relative database import with resolver extension", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../../lib/db.js";', "src/lib/db", ], [ "aliased database import with resolver extension", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "@/lib/db.js";', "src/lib/db", ], [ "action root import with resolver extension", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import actions from "../../actions.mjs";', "src/actions", ], [ "legacy mod root import with resolver extension", - "src/app/admin-next/layout.tsx", + "src/app/ase-next/layout.tsx", 'import mod from "../mod.cjs";', "src/app/mod", ], [ "database import with query suffix", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../../lib/db?server-only";', "src/lib/db", ], [ "action import with hash suffix", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import("../../actions/users#server")', "src/actions/users", ], [ "Windows-style relative database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", String.raw`import db from "..\\..\\lib\\db";`, "src/lib/db", ], [ "Windows-style aliased database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", String.raw`import db from "@\\lib\\db";`, "src/lib/db", ], [ "percent-encoded database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../../lib/%64%62";', "src/lib/db", ], [ "optional CommonJS database require", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'require?.("../../lib/db")', "src/lib/db", ], [ "module database require", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'module.require("../../lib/db")', "src/lib/db", ], [ "require.resolve database access", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'require.resolve("../../lib/db")', "src/lib/db", ], [ "optional module database require", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'module.require?.("../../lib/db")', "src/lib/db", ], [ "optional require.resolve database access", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'require.resolve?.("../../lib/db")', "src/lib/db", ], [ "TypeScript import-equals database access", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db = require("../../lib/db");', "src/lib/db", ], [ "U+2028 line-continuation database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../\\' + "\u2028" + '../lib/db";', "src/lib/db", ], [ "U+2029 line-continuation database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../\\' + "\u2029" + '../lib/db";', "src/lib/db", ], [ "parenthesized dynamic action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import(("../../actions/users"))', "src/actions/users", ], [ "regex-brace template-expression action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", `const x = \`${interpolationOpen}/}/.test(value) ? import("../../actions/users") : null}\`;`, "src/actions/users", ], [ "CommonJS database require", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'require("../../lib/db")', "src/lib/db", ], [ "template-literal dynamic action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", "import(`../../actions/users`)", "src/actions/users", ], [ "TypeScript-asserted dynamic action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import(("../../actions/users" as string))', "src/actions/users", ], [ "template-expression dynamic action import", - "src/app/admin-next/page.tsx", + "src/app/ase-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", + "src/app/ase-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", + "src/app/ase-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", + "src/app/ase-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", + "src/app/ase-next/page.tsx", 'export * from "\\x2e\\x2e/\\x2e\\x2e/lib/auth";', "src/lib/auth", ], [ "escaped-slash permissions import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import permissions from "..\\/..\\/lib\\/permissions";', "src/lib/permissions", ], [ "unknown escape database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../../\\lib/db";', "src/lib/db", ], [ "line-continuation database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../\\' + "\n" + '../lib/db";', "src/lib/db", ], [ "aliased database descendant import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import { query } from "@/lib/db/query";', "src/lib/db/query", ], [ "root relative database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import { db } from "../../lib/db";', "src/lib/db", ], [ "domain relative auth side-effect import", - "src/app/admin-next/[domain]/layout.tsx", + "src/app/ase-next/[domain]/layout.tsx", 'import "../../../lib/auth";', "src/lib/auth", ], [ "domain relative permissions export", - "src/app/admin-next/[domain]/page.tsx", + "src/app/ase-next/[domain]/page.tsx", 'export { getAdminContext } from "../../../lib/permissions";', "src/lib/permissions", ], [ "root relative action dynamic import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import("../../actions/users")', "src/actions/users", ], [ "root relative app action export", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'export * from "../actions";', "src/app/actions", ], [ "domain relative legacy admin import", - "src/app/admin-next/[domain]/layout.tsx", + "src/app/ase-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", + "src/app/ase-next/layout.tsx", 'import("../mod/users/page")', "src/app/mod/users/page", ], [ "Prisma TypeScript import type", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'type PrismaClient = import("@prisma/client").PrismaClient;', "@prisma/client", ], [ "Drizzle package import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import { sql } from "drizzle-orm";', "drizzle-orm", ], [ "mysql2 package import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import type { Pool } from "mysql2";', "mysql2", ], @@ -834,7 +832,7 @@ describe("preview route import boundary", () => { ], ] as const)("fails closed for non-literal %s", (_name, source, violation) => { expect( - findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"), + findRouteImportBoundaryViolations(source, "src/app/ase-next/page.tsx"), ).toContain(violation); }); @@ -843,7 +841,7 @@ describe("preview route import boundary", () => { '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 preview from "../ase-next-shared";', 'import prismaTools from "@prisma/client-tools";', 'import drizzleTools from "drizzle-orm-kit";', 'import mysqlTools from "mysql2-wrapper";', @@ -853,7 +851,7 @@ describe("preview route import boundary", () => { ].join("\n"); expect( - findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"), + findRouteImportBoundaryViolations(source, "src/app/ase-next/page.tsx"), ).toEqual([]); }); }); diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 5bbc528d..5dd58fb1 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -25,7 +25,7 @@ const manifest = ( labelKey: `pages.housekeeping.domains.${id}.title`, descriptionKey: `pages.housekeeping.domains.${id}.description`, iconId: "settings", - previewHref: `/admin-next/${id}`, + canonicalHref: id === "operations" ? "/ase" : `/ase/${id}`, capability: anyCapability(capabilitySlug), routes: [], searchProviders: [], @@ -73,7 +73,7 @@ const expectedManifests = [ { id: "operations", iconId: "inbox", - previewHref: "/admin-next/operations", + canonicalHref: "/ase", labelKey: "pages.housekeeping.domains.operations.title", descriptionKey: "pages.housekeeping.domains.operations.description", slugs: [PERMS.ADMIN_DASHBOARD], @@ -81,7 +81,7 @@ const expectedManifests = [ { id: "people", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase/people", labelKey: "pages.housekeeping.domains.people.title", descriptionKey: "pages.housekeeping.domains.people.description", slugs: [ @@ -112,7 +112,7 @@ const expectedManifests = [ { id: "content", iconId: "file-text", - previewHref: "/admin-next/content", + canonicalHref: "/ase/content", labelKey: "pages.housekeeping.domains.content.title", descriptionKey: "pages.housekeeping.domains.content.description", slugs: [ @@ -135,7 +135,7 @@ const expectedManifests = [ { id: "economy", iconId: "gem", - previewHref: "/admin-next/economy", + canonicalHref: "/ase/economy", labelKey: "pages.housekeeping.domains.economy.title", descriptionKey: "pages.housekeeping.domains.economy.description", slugs: [ @@ -148,7 +148,7 @@ const expectedManifests = [ { id: "hotel", iconId: "hotel", - previewHref: "/admin-next/hotel", + canonicalHref: "/ase/hotel", labelKey: "pages.housekeeping.domains.hotel.title", descriptionKey: "pages.housekeeping.domains.hotel.description", slugs: [ @@ -165,7 +165,7 @@ const expectedManifests = [ { id: "system", iconId: "settings", - previewHref: "/admin-next/system", + canonicalHref: "/ase/system", labelKey: "pages.housekeeping.domains.system.title", descriptionKey: "pages.housekeeping.domains.system.description", slugs: [ @@ -208,9 +208,9 @@ describe("housekeeping registry", () => { it("rejects invalid preview and empty translation keys", () => { expect(() => createHousekeepingRegistry([ - { ...manifest("people"), previewHref: "/admin-next/operations" }, + { ...manifest("people"), canonicalHref: "/ase" }, ]), - ).toThrow("invalid preview href"); + ).toThrow("invalid canonical href"); expect(() => createHousekeepingRegistry([{ ...manifest("people"), labelKey: " " }]), ).toThrow("empty label key"); @@ -232,13 +232,13 @@ describe("housekeeping registry", () => { { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }, { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/staff", + href: "/ase/people/staff", capability: anyCapability(PERMS.USERS_VIEW), }, ], @@ -253,13 +253,13 @@ describe("housekeeping registry", () => { { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }, { id: "staff", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }, ], @@ -272,7 +272,7 @@ describe("housekeeping registry", () => { const route = { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }; @@ -319,7 +319,7 @@ describe("housekeeping registry", () => { expect(actual).toMatchObject({ id: expected.id, iconId: expected.iconId, - previewHref: expected.previewHref, + canonicalHref: expected.canonicalHref, labelKey: expected.labelKey, descriptionKey: expected.descriptionKey, }); @@ -481,7 +481,7 @@ describe("housekeeping registry", () => { { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: { mode: "any", slugs: [] }, }, ], @@ -494,7 +494,7 @@ describe("housekeeping registry", () => { const route = { id: "shared", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/shared", + href: "/ase/shared", capability: anyCapability(PERMS.USERS_VIEW), }; @@ -503,7 +503,7 @@ describe("housekeeping registry", () => { { ...manifest("people"), routes: [route] }, { ...manifest("content"), - routes: [{ ...route, href: "/admin-next/content/shared" }], + routes: [{ ...route, href: "/ase/content/shared" }], }, ]), ).toThrow("duplicate route id"); @@ -518,3 +518,22 @@ describe("housekeeping registry", () => { ).toThrow("duplicate route href"); }); }); + +it("accepts the canonical operations root and rejects legacy admin hrefs", () => { + const canonicalOperations = manifest("operations") as Record; + delete canonicalOperations.canonicalHref; + canonicalOperations.canonicalHref = "/ase"; + + expect(() => + createHousekeepingRegistry([ + canonicalOperations as unknown as HousekeepingDomainManifest, + ]), + ).not.toThrow(); + + canonicalOperations.canonicalHref = "/admin"; + expect(() => + createHousekeepingRegistry([ + canonicalOperations as unknown as HousekeepingDomainManifest, + ]), + ).toThrow("invalid canonical href"); +}); diff --git a/src/features/housekeeping/foundation/registry.ts b/src/features/housekeeping/foundation/registry.ts index f301ebbe..bc63f55b 100644 --- a/src/features/housekeeping/foundation/registry.ts +++ b/src/features/housekeeping/foundation/registry.ts @@ -34,8 +34,11 @@ export function createHousekeepingRegistry( } domainIds.add(manifest.id); - if (manifest.previewHref !== `/admin-next/${manifest.id}`) { - throw new Error(`invalid preview href: ${manifest.previewHref}`); + if ( + manifest.canonicalHref !== + (manifest.id === "operations" ? "/ase" : `/ase/${manifest.id}`) + ) { + throw new Error(`invalid canonical href: ${manifest.canonicalHref}`); } validateNonEmpty(manifest.labelKey, "label key"); validateNonEmpty(manifest.descriptionKey, "description key"); @@ -72,6 +75,9 @@ export function createHousekeepingRegistry( routeIds.add(route.id); validateNonEmpty(route.href, "route href"); + if (route.href !== "/ase" && !route.href.startsWith("/ase/")) { + throw new Error(`invalid canonical route href: ${route.href}`); + } if (routeHrefs.has(route.href)) { throw new Error(`duplicate route href: ${route.href}`); } diff --git a/src/features/housekeeping/foundation/routing/href.test.ts b/src/features/housekeeping/foundation/routing/href.test.ts new file mode 100644 index 00000000..5f70f312 --- /dev/null +++ b/src/features/housekeeping/foundation/routing/href.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import type { CanonicalHousekeepingHref } from "../contracts"; +import { toHousekeepingHref } from "./href"; + +describe("toHousekeepingHref", () => { + it("keeps the canonical operations root unchanged", () => { + expect(toHousekeepingHref("/ase", "canonical")).toBe("/ase"); + }); + + it("maps a canonical domain route to the preview namespace", () => { + expect(toHousekeepingHref("/ase/people/users", "preview")).toBe( + "/ase-next/people/users", + ); + }); + + it("rejects paths outside the canonical ase namespace", () => { + expect(() => + toHousekeepingHref("/admin" as CanonicalHousekeepingHref, "canonical"), + ).toThrow("invalid canonical housekeeping href"); + }); +}); diff --git a/src/features/housekeeping/foundation/routing/href.ts b/src/features/housekeeping/foundation/routing/href.ts new file mode 100644 index 00000000..dfc9218a --- /dev/null +++ b/src/features/housekeeping/foundation/routing/href.ts @@ -0,0 +1,15 @@ +import type { + CanonicalHousekeepingHref, + HousekeepingSurface, +} from "../contracts"; + +export function toHousekeepingHref( + href: CanonicalHousekeepingHref, + surface: HousekeepingSurface, +): CanonicalHousekeepingHref | `/ase-next${string}` { + if (href !== "/ase" && !href.startsWith("/ase/")) { + throw new Error(`invalid canonical housekeeping href: ${href}`); + } + + return surface === "canonical" ? href : href.replace(/^\/ase/, "/ase-next"); +} diff --git a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx index 0aac7178..f462514a 100644 --- a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx +++ b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx @@ -17,17 +17,17 @@ const labels = { const domains: readonly HousekeepingNavigationDomain[] = [ { id: "operations", - href: "/admin-next/operations", + href: "/ase-next/operations", iconId: "inbox", label: "Operations", description: "Manage operations", items: [ - { id: "queue", href: "/admin-next/operations/queue", label: "Queue" }, + { id: "queue", href: "/ase-next/operations/queue", label: "Queue" }, ], }, { id: "people", - href: "/admin-next/people", + href: "/ase-next/people", iconId: "users", label: "People", description: "Manage people", @@ -35,7 +35,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "content", - href: "/admin-next/content", + href: "/ase-next/content", iconId: "file-text", label: "Content", description: "Manage content", @@ -43,7 +43,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "economy", - href: "/admin-next/economy", + href: "/ase-next/economy", iconId: "gem", label: "Economy", description: "Manage economy", @@ -51,7 +51,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "hotel", - href: "/admin-next/hotel", + href: "/ase-next/hotel", iconId: "hotel", label: "Hotel", description: "Manage hotel", @@ -59,7 +59,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "system", - href: "/admin-next/system", + href: "/ase-next/system", iconId: "settings", label: "System", description: "Manage system", @@ -178,7 +178,7 @@ describe("HousekeepingShell", () => { expect(html).toContain('