@murumets-ee/content 0.75.1 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.mjs +1 -1
- package/dist/block-resolver.d.mts +5 -0
- package/dist/block-resolver.d.mts.map +1 -1
- package/dist/block-resolver.mjs +1 -1
- package/dist/block-resolver.mjs.map +1 -1
- package/dist/client.mjs +1 -1
- package/dist/enumerate-CBqu63Kp.mjs +2 -0
- package/dist/{enumerate-B5tWe9JC.mjs.map → enumerate-CBqu63Kp.mjs.map} +1 -1
- package/dist/plugin-BxOHoKPR.mjs +2 -0
- package/dist/{plugin-DEYaEeWC.mjs.map → plugin-BxOHoKPR.mjs.map} +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/resolve-context-MRZZ3rQl.mjs +2 -0
- package/dist/{resolve-context-rr-Eanwp.mjs.map → resolve-context-MRZZ3rQl.mjs.map} +1 -1
- package/dist/resolve.mjs +2 -2
- package/dist/resolve.mjs.map +1 -1
- package/dist/routes-C9I1iZw8.mjs +2 -0
- package/dist/{routes-DuJa-w4T.mjs.map → routes-C9I1iZw8.mjs.map} +1 -1
- package/dist/schema-0zayzpD_.mjs +2 -0
- package/dist/schema-0zayzpD_.mjs.map +1 -0
- package/dist/schema-D8sBQozw.mjs +2 -0
- package/dist/schema-D8sBQozw.mjs.map +1 -0
- package/dist/seed.d.mts +20 -1
- package/dist/seed.d.mts.map +1 -1
- package/dist/seed.mjs +1 -1
- package/dist/seed.mjs.map +1 -1
- package/dist/{versions-qld3n7B-.mjs → versions-BDxB6k_S.mjs} +2 -2
- package/dist/{versions-qld3n7B-.mjs.map → versions-BDxB6k_S.mjs.map} +1 -1
- package/package.json +9 -9
- package/dist/apply-path-s-YeCUXP.mjs +0 -2
- package/dist/apply-path-s-YeCUXP.mjs.map +0 -1
- package/dist/enumerate-B5tWe9JC.mjs +0 -2
- package/dist/plugin-DEYaEeWC.mjs +0 -2
- package/dist/resolve-context-rr-Eanwp.mjs +0 -2
- package/dist/routes-DuJa-w4T.mjs +0 -2
- package/dist/schema-BPDn8ajz.mjs +0 -2
- package/dist/schema-BPDn8ajz.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes-DuJa-w4T.mjs","names":["json"],"sources":["../src/paths/routes.ts","../src/redirects/errors.ts","../src/redirects/refs.ts","../src/redirects/target.ts","../src/redirects/validation.ts","../src/redirects/routes.ts"],"sourcesContent":["/**\n * Path-resolution admin route — \"which document owns this URL?\".\n *\n * Mounted under prefix `paths`. The URL surface:\n *\n * GET /api/admin/paths/resolve ?path=/foo/bar&locale=en\n *\n * **Why this exists.** The block editor's preview canvas is a `srcDoc` iframe,\n * so a rendered link's site-absolute `href` resolves against the ADMIN origin —\n * clicking one navigated the CANVAS and could render the whole admin editor\n * inside itself (#775). `@murumets-ee/blocks` now contains every in-canvas link\n * click and hands it to its host; the host needs exactly one server capability\n * to make that click useful: turn a site path into the document that serves it,\n * so it can open THAT document's editor.\n *\n * **Draft resolution, deliberately.** Uses `resolveDraftPath` (not\n * `resolvePath`): an editor following a link to an unpublished sibling page\n * must land on its editor, and the caller is an authenticated admin who can\n * already read the row through the entity API. This is exactly the\n * \"admin-only surface\" `resolveDraftPath`'s docblock reserves — the FIRST HTTP\n * route wired to it. It must never be reachable from `content-api` (public,\n * published-only).\n *\n * **Authorization — two gates.**\n * 1. Route gate (`defineAdminRoute`): `path:resolve`, `defaultRoles:\n * ['admin', 'editor']`. Viewers are excluded; the capability only exists\n * to navigate BETWEEN editors.\n * 2. Per-document gate: the resolved `entityType` must also pass\n * `ctx.checkPermission(entityType, 'view')`. A caller with the route\n * permission but no grant on that entity gets `{ kind: 'none' }` — the\n * SAME answer as a path that owns nothing, so the response never\n * discloses that a document exists there (no-leak, matching\n * `menus`' anchors route and content-api's 404-not-403 rule).\n *\n * **Bounded work.** One resolve is a handful of indexed single-row reads. A\n * `redirect`-kind result is followed exactly ONCE more (the index stores\n * collapsed targets, and `resolveInternal` already caps its own chain at\n * `MAX_REDIRECT_HOPS`) — so this handler's cost is constant, never a chain\n * walk driven by caller input.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n getContext,\n} from '@murumets-ee/core'\nimport { getContentConfig } from '../content-config-store.js'\n// `./resolve.js` is a LEAF (every dependency injected, no `@murumets-ee/core`\n// graph) so it imports statically. `./resolve-context.js` is NOT — it holds the\n// app-binding imports, and its own header reserves it for request-time surfaces\n// and forbids the `lumi` CLI command graph (F027 guardrail). This module is\n// reached from `plugin.ts`'s STATIC graph, which the CLI loads through\n// `lumi.config.ts`, so the binding is imported at REQUEST time inside the\n// handler instead — same deferral `redirects/routes.ts` uses for `getApp`.\nimport { resolveDraftPath } from './resolve.js'\n\n/** Route permission + the roles that hold it by default. */\nexport const PATH_RESOLVE_PERMISSION = 'path:resolve'\nexport const PATH_RESOLVE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Cap on the `path` query param. `toolkit_paths.path` is `text` (no DB cap), so\n * the bound is ours: generously above any real URL, low enough that the param\n * can't be used to push megabytes through the query planner.\n */\nconst MAX_PATH_LENGTH = 2048\n\n/** The answer shape the caller (admin-ui's editor canvas) consumes. */\nexport type ResolvePathResponse =\n /** The path is served by this document. */\n | { readonly kind: 'document'; readonly entityType: string; readonly id: string }\n /** The path redirects OFF-SITE (a `url`-kind manual redirect). */\n | { readonly kind: 'redirect'; readonly to: string }\n /** Nothing the caller may see owns this path. */\n | { readonly kind: 'none' }\n\nfunction json(data: ResolvePathResponse | { error: string }, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nconst NONE: ResolvePathResponse = { kind: 'none' }\n\n/**\n * Boundary validation for the `path` param. Rejects (rather than normalizes)\n * anything that isn't a bare locale-relative path: a protocol-relative\n * `//host` or a `?query` / `#fragment` tail means the CALLER built the request\n * wrong, and answering it would silently resolve something other than what it\n * asked about.\n */\nfunction validatePath(raw: string | null): { path: string } | { error: string } {\n if (raw === null || raw.length === 0) return { error: \"query param 'path' is required\" }\n if (raw.length > MAX_PATH_LENGTH) return { error: \"query param 'path' is too long\" }\n if (!raw.startsWith('/')) return { error: \"query param 'path' must start with '/'\" }\n if (raw.startsWith('//')) return { error: \"query param 'path' must not be protocol-relative\" }\n if (raw.includes('?') || raw.includes('#')) {\n return { error: \"query param 'path' must not include a query or fragment\" }\n }\n return { path: raw }\n}\n\n/**\n * Whitelist the locale (CLAUDE.md \"whitelist, don't blacklist\"), falling back\n * to the request's own content locale and then the configured default so the\n * common call can omit it.\n *\n * Checks the **request-scoped effective set** — `content()` shipped defaults ⊕\n * DB `locale` override rows, resolved once per request (SD006) — with the static\n * set as fallback outside a request context. Using only the static list was a\n * real bug, not just an inconsistency: the in-editor locale switcher is driven\n * from `effectiveLocales`, so a locale enabled at RUNTIME is a legal editor\n * locale, and it would have 400'd here — making EVERY canvas link click in that\n * locale report \"no CMS page serves …\".\n *\n * **Why this doesn't just call `locale-utils.validateLocale`**, which implements\n * exactly this predicate: that module imports `getContentConfig` from\n * `./plugin.js` rather than from the leaf store, deliberately and with a\n * documented reason (see `content-config-store.ts`'s header — its consumers'\n * unit tests mock `plugin.js`, and repointing it ripples through ~7 mocks).\n * Since `plugin.ts` imports THIS module, calling it would close a real import\n * cycle (`locale-utils -> plugin -> paths/routes -> locale-utils`), which\n * `verify:cycles` rejects. So the same two-line read is done here against the\n * same sources of truth, rather than growing this PR into that refactor.\n */\nfunction resolveRequestLocale(\n raw: string | null,\n fallback: string | undefined,\n): { locale: string } | { error: string } {\n const config = getContentConfig()\n const candidate = raw ?? fallback ?? config.defaultLocale\n const allowed = getContext()?.availableLocales ?? config.locales.map((l) => l.code)\n if (!allowed.includes(candidate)) {\n // Deliberately does NOT echo the allowed set — no reason to enumerate the\n // deployment's configured locales to the caller.\n return { error: \"query param 'locale' is not a content locale\" }\n }\n return { locale: candidate }\n}\n\n/**\n * `GET /api/admin/paths/resolve?path=&locale=` — answer \"which document serves\n * this URL?\".\n *\n * Validates both params at the boundary, resolves through the draft resolver,\n * and projects the result through the per-document `view` gate so an\n * unviewable document is indistinguishable from an unowned path. See the module\n * header for the authorization model and why the deps builder is imported\n * lazily.\n */\nconst handleResolve: AdminRouteHandler = async (req, ctx) => {\n const params = new URL(req.url).searchParams\n\n const pathResult = validatePath(params.get('path'))\n if ('error' in pathResult) return json({ error: pathResult.error }, 400)\n const localeResult = resolveRequestLocale(params.get('locale'), ctx.locale ?? ctx.defaultLocale)\n if ('error' in localeResult) return json({ error: localeResult.error }, 400)\n const { locale } = localeResult\n\n const { buildResolveDeps } = await import('./resolve-context.js')\n const deps = buildResolveDeps(ctx.app)\n\n // Gate 2 (see the module header) — a document the caller can't view answers\n // exactly like an unowned path.\n const project = (entityType: string, id: string): ResolvePathResponse =>\n ctx.checkPermission(entityType, 'view') ? { kind: 'document', entityType, id } : NONE\n\n const result = await resolveDraftPath(pathResult.path, locale, deps)\n if (result.kind === 'document') return json(project(result.entityType, result.id))\n if (result.kind === 'none') return json(NONE)\n\n // `redirect`: an off-site target is handed back for the host to open; a\n // same-site one is followed ONE hop so the caller lands on the document\n // instead of round-tripping. No loop — exactly one extra resolve. A\n // protocol-relative `//host` target counts as off-site, not as a path (target\n // validation shouldn't produce one, but resolving it AS a path would silently\n // answer about something else entirely).\n const sameSite = result.to.startsWith('/') && !result.to.startsWith('//')\n if (!sameSite) return json({ kind: 'redirect', to: result.to })\n const hop = await resolveDraftPath(result.to, locale, deps)\n return json(hop.kind === 'document' ? project(hop.entityType, hop.id) : NONE)\n}\n\n/**\n * Build the path-resolution admin routes. One entry: `GET /paths/resolve`.\n */\nexport function pathRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'paths',\n path: 'resolve',\n method: 'GET',\n permission: PATH_RESOLVE_PERMISSION,\n defaultRoles: PATH_RESOLVE_DEFAULT_ROLES,\n description:\n 'Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).',\n handler: handleResolve,\n }),\n ])\n}\n","/**\n * Domain errors thrown by the manual-redirect save-time validation\n * (`./validation.ts`, plan/routing PR05, phase `04-path-lifecycle-and-redirects.md`\n * §3). Distinct classes so Task 2's admin API handler can map each to a 4xx\n * (caller-actionable) rather than a generic 500, and so callers can branch on\n * `instanceof`.\n *\n * Style mirrors `../paths/errors.ts` exactly (`extends Error`, named readonly\n * fields, an actionable message, `this.name = 'ClassName'`). A redirect-specific\n * reserved-prefix error is defined here rather than reusing `paths`'\n * `PathReservedPrefixError`, whose constructor requires an `entityName` — a\n * redirect has no owning entity, so forcing that field would be a lie.\n */\n\n/**\n * A manual redirect's `from_path` falls under a reserved path prefix\n * (`reservedPathPrefixes`, e.g. `/admin` or `/api`). Allowing it would make the\n * redirect shadow a route the framework owns — the same hazard the path-index\n * guard rejects for entity canonical URLs — so the write is aborted before commit.\n */\nexport class RedirectReservedPrefixError extends Error {\n readonly fromPath: string\n readonly prefix: string\n\n constructor(fromPath: string, prefix: string) {\n super(\n `Redirect source \"${fromPath}\" falls under the reserved prefix \"${prefix}\" and cannot be ` +\n `used — that path range is owned by the framework.`,\n )\n this.name = 'RedirectReservedPrefixError'\n this.fromPath = fromPath\n this.prefix = prefix\n }\n}\n\n/**\n * A manual redirect's `from_path` already matches a live canonical path — that URL\n * serves content, so redirecting away from it would hide the content (phase 04 §3,\n * \"that URL serves content\"). The write is aborted before commit.\n *\n * `locale` is the redirect's own locale (`null` = a catch-all redirect, which was\n * checked against every configured locale); `conflictingLocale` names the specific\n * locale whose canonical path collided, so the message can be precise even for a\n * catch-all redirect.\n */\nexport class RedirectServesContentError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n readonly conflictingLocale: string\n\n constructor(fromPath: string, locale: string | null, conflictingLocale: string) {\n super(\n `Redirect source \"${fromPath}\" already serves content in locale \"${conflictingLocale}\" ` +\n `(it is a live canonical URL). Choose a different source path, or unpublish/rename the ` +\n `content first.`,\n )\n this.name = 'RedirectServesContentError'\n this.fromPath = fromPath\n this.locale = locale\n this.conflictingLocale = conflictingLocale\n }\n}\n\n/**\n * An `entity`-kind redirect target names an entity type that isn't registered in\n * the app. A redirect to an unknown entity type can never resolve, so it's\n * rejected at save (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectUnknownEntityError extends Error {\n readonly entity: string\n\n constructor(entity: string) {\n super(\n `Redirect target references entity type \"${entity}\", which is not a registered entity. ` +\n `Check the entity name.`,\n )\n this.name = 'RedirectUnknownEntityError'\n this.entity = entity\n }\n}\n\n/**\n * An `entity`-kind redirect target names a real entity type but a row id that\n * doesn't exist. A redirect to a nonexistent document can never resolve to a\n * useful URL, so it's rejected at save rather than discovered as a dead link at\n * request time (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectTargetNotFoundError extends Error {\n readonly entity: string\n readonly id: string\n\n constructor(entity: string, id: string) {\n super(\n `Redirect target ${entity} \"${id}\" does not exist. Redirect to a document that exists, or ` +\n `use a URL/path target instead.`,\n )\n this.name = 'RedirectTargetNotFoundError'\n this.entity = entity\n this.id = id\n }\n}\n\n/**\n * A manual redirect's collapsed target points straight back at its OWN\n * `from_path` (in the same locale being saved) — a self-loop / no-op that could\n * never resolve to content. Rejected by Task 2's CRUD route BEFORE the row is\n * persisted, rather than stored as a degenerate row a request-time resolver\n * would have to special-case.\n *\n * Distinct from the collapse rule (`./validation.ts` `collapsePathTarget`, which\n * dedupes redirect CHAINS down to one hop): this guards the case where that\n * collapse resolves the destination back to the source path of the row itself.\n * `locale` is the redirect's own locale (`null` = catch-all).\n */\nexport class RedirectSelfReferenceError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n\n constructor(fromPath: string, locale: string | null) {\n super(\n `Redirect source \"${fromPath}\"${locale === null ? '' : ` (locale \"${locale}\")`} resolves ` +\n `back to itself — a redirect cannot point at its own source path. Choose a different ` +\n `destination.`,\n )\n this.name = 'RedirectSelfReferenceError'\n this.fromPath = fromPath\n this.locale = locale\n }\n}\n","/**\n * `entity_refs` bookkeeping for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3; decision F020 point 2).\n *\n * When a redirect targets an ENTITY, we register a row in the universal\n * `entity_refs` table so the broken-link dashboard can surface \"this redirect\n * points at a deleted document\". No public write helper exists for this:\n * `AdminClient.syncRefs` is private and entity-only (it needs a field map to\n * `extractRefs`), and `toolkit_redirects` is a `defineTable` infra row, not an\n * AdminClient entity. So we hand-roll insert/delete against the exported\n * `entityRefs` table, mirroring `syncRefs`'s shape (F020 point 2).\n *\n * The ref uses `relationKind: 'link'` — a SOFT reference. Only `'structured'`\n * refs block deleting the target entity (AdminClient's delete-protection filters\n * on that kind); a redirect must NEVER block deleting the document it points at\n * (phase 04 §3's Delete row: the manual redirect merely *surfaces in the\n * broken-link view* via `entity_refs`, it doesn't hard-depend). `'link'` is\n * exactly that: tracked, never blocking.\n *\n * `sourceEntity: 'toolkit_redirects'` is safe as a free-form marker —\n * `entity_refs.sourceEntity` / `targetEntity` are plain varchar with no FK to a\n * registered-entity list.\n *\n * Both functions take `exec: PostgresJsDatabase` (the same parameter type\n * `AdminClient.syncRefs` takes), so Task 2's route can call them inside the SAME\n * transaction as the `toolkit_redirects` write itself — keeping the row write and\n * its ref bookkeeping atomic.\n */\n\nimport { entityRefs, type RelationKind } from '@murumets-ee/entity/refs'\nimport { and, eq } from 'drizzle-orm'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { RedirectTarget } from './target.js'\n\n/** The `entity_refs.source_entity` marker for every manual-redirect ref row. */\nconst SOURCE_ENTITY = 'toolkit_redirects'\n/** The `entity_refs.source_field` marker — the `target` column of the redirect. */\nconst SOURCE_FIELD = 'target'\n/** Soft reference: tracked for the broken-link dashboard, never delete-blocking. */\nconst RELATION_KIND: RelationKind = 'link'\n\n/**\n * Register the `entity_refs` row for a redirect whose target is an entity.\n * No-op for `url` / `path` targets (nothing to track). Idempotent via\n * `.onConflictDoNothing()` (the `uq_entity_refs` unique covers\n * source+field+target), matching `AdminClient.syncRefs`'s own pattern — so a\n * re-run (e.g. an update that re-writes the same entity target) doesn't error.\n *\n * Call INSIDE the redirect-write transaction, AFTER {@link clearRedirectRefs} on\n * update (delete-then-insert; see that function's note).\n */\nexport async function writeRedirectRef(\n exec: PostgresJsDatabase,\n redirectId: string,\n target: RedirectTarget,\n): Promise<void> {\n if (target.kind !== 'entity') return\n await exec\n .insert(entityRefs)\n .values({\n sourceEntity: SOURCE_ENTITY,\n sourceId: redirectId,\n sourceField: SOURCE_FIELD,\n targetEntity: target.entity,\n targetId: target.id,\n relationKind: RELATION_KIND,\n })\n .onConflictDoNothing()\n}\n\n/**\n * Remove ALL `entity_refs` rows for a redirect. Called:\n * - before {@link writeRedirectRef} on UPDATE — delete-then-insert is simpler and\n * safer than diffing the old vs new target, and this is not a hot path; and\n * - on DELETE of the redirect row, so no dangling ref survives.\n *\n * Call INSIDE the redirect-write transaction so the ref cleanup commits (or rolls\n * back) atomically with the row change.\n */\nexport async function clearRedirectRefs(\n exec: PostgresJsDatabase,\n redirectId: string,\n): Promise<void> {\n await exec\n .delete(entityRefs)\n .where(and(eq(entityRefs.sourceEntity, SOURCE_ENTITY), eq(entityRefs.sourceId, redirectId)))\n}\n","/**\n * `RedirectTarget` — the discriminated union stored in `toolkit_redirects.target`\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3; decision\n * F020 point 1).\n *\n * Phase 04 §3 sketches this as \"LinkValue-shaped\", but it is NOT literally blocks'\n * D008 `LinkValue` (`packages/blocks/src/core/types.ts`), which is\n * `entity | media | url | email` — it has NO `path` arm and carries `media`/`email`\n * arms a redirect never needs. `asLinkValue` / `resolveLinkHref` / the picker UI all\n * switch on that union exhaustively with no `default`, so bolting a `path` arm onto\n * the real type would ripple into blocks' richtext/picker surface for zero benefit\n * here. So this is a redirect-local union matching the phase doc's actual three\n * kinds (F020 point 1) — \"LinkValue-shaped\" read as \"same tagged-union spirit\",\n * not a literal type import.\n *\n * The three kinds:\n * - `entity` — points at a document. Resolves to that document's *current*\n * canonical path AT REQUEST TIME (never stale, per phase 04 §3 / PR06's\n * `resolvePath`), and registers an `entity_refs` row for broken-link dashboard\n * coverage (see `./refs.ts`).\n * - `url` — an absolute external URL. Admin-authored only (the table is never\n * writable from a public surface), so no open-redirect vector.\n * - `path` — a locale-relative internal path. Collapsed at save time if it\n * itself points at another manual redirect (see `./validation.ts`), so a stored\n * `path` target always points at content, never at another redirect row.\n */\n\nimport { z } from 'zod'\n\n/** The stored shape of `toolkit_redirects.target`. */\nexport type RedirectTarget =\n | { kind: 'entity'; entity: string; id: string }\n | { kind: 'url'; url: string }\n | { kind: 'path'; path: string }\n\n/**\n * API-boundary validation for a {@link RedirectTarget} (CLAUDE.md \"validate at the\n * boundary\"). Task 2's admin CRUD routes parse the request body's `target` with\n * this before any write. Discriminated on `kind` so a malformed arm reports the\n * precise field, and unknown `kind`s are rejected rather than silently stored.\n */\nexport const redirectTargetSchema = z.discriminatedUnion('kind', [\n z.object({\n kind: z.literal('entity'),\n /** Registered entity type name (existence checked in `./validation.ts`). */\n entity: z.string().min(1),\n /** Entity row id — always a UUID (entity ids are UUIDs; only user ids are varchar). */\n id: z.string().uuid(),\n }),\n z.object({\n kind: z.literal('url'),\n /** Absolute external URL. */\n url: z.string().max(2048).url(),\n }),\n z.object({\n kind: z.literal('path'),\n /** Locale-relative internal path — leading '/', same convention as `toolkit_paths.path`. */\n path: z.string().min(1).max(2048).startsWith('/'),\n }),\n])\n","/**\n * Save-time validation for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3). Each rule is an exported function\n * Task 2's admin CRUD route calls BEFORE writing a `toolkit_redirects` row.\n *\n * The recommended route order (Task 2 owns the actual wiring):\n * 1. normalize `fromPath` + a `path`-kind target's `path` via {@link normalizeRedirectPath}\n * 2. {@link assertFromPathNotReserved} — reserved-prefix rejection (security)\n * 3. {@link assertFromPathNotServingContent} — \"that URL serves content\" rejection\n * 4. {@link assertEntityTargetValid} — entity target type + id existence\n * 5. {@link collapsePathTarget} — collapse a redirect-chain to one hop\n * 6. write the row (+ `entity_refs` via `./refs.ts`) inside one transaction\n *\n * ## Dependency injection (leaf-friendly, testable)\n *\n * The paths / redirects readers are injected as NARROW structural interfaces\n * ({@link PathsCanonicalReader}, {@link RedirectsCollapseReader}) rather than the\n * full `TableClient`. The real `ToolkitPathsClient` / `ToolkitRedirectsClient`\n * satisfy them (proven at compile time by the integration test, which passes the\n * real clients straight in), so Task 2 injects the real client while unit tests\n * inject a trivial stub — no `as unknown as` casts on either side. The\n * entity-existence check is injected the same way, as {@link\n * RedirectEntityClientResolver}, so this module never imports `@murumets-ee/core`.\n */\n\nimport { matchReservedPrefix } from '@murumets-ee/entity'\nimport { normalizePath } from '../paths/generate-path.js'\nimport {\n RedirectReservedPrefixError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport type { RedirectTarget } from './target.js'\n\n/**\n * Normalize a redirect path the SAME way the path index normalizes\n * `toolkit_paths.path` (collapse duplicate slashes + strip a trailing slash), so a\n * redirect's `from_path` and a `path`-kind target compare byte-identically against\n * the stored canonical/redirect paths. Task 2 should apply this to the stored\n * value too, so what is written matches what was validated. Re-exported thin\n * wrapper over the path module's `normalizePath` (DRY — do not reimplement).\n */\nexport function normalizeRedirectPath(path: string): string {\n return normalizePath(path)\n}\n\n/**\n * The read surface {@link assertFromPathNotServingContent} needs from the paths\n * index. The real `ToolkitPathsClient` satisfies this (its `findOne` accepts any\n * `WhereClause` and returns the full row).\n */\nexport interface PathsCanonicalReader {\n findOne(where: {\n path: string\n isCanonical: true\n /** A single locale, or an `IN (...)` set for the catch-all \"any configured locale\" check. */\n locale: string | { in: string[] }\n }): Promise<{ locale: string } | null>\n}\n\n/**\n * The read surface {@link collapsePathTarget} needs from the redirects table.\n * The real `ToolkitRedirectsClient` satisfies this.\n */\nexport interface RedirectsCollapseReader {\n findOne(where: {\n fromPath: string\n locale: string | null\n /** Excludes the row being updated so its own pre-write state can't mask a\n * self-referential collapse (M1 fix — see {@link collapsePathTarget}). */\n id?: { ne: string }\n }): Promise<{ target: RedirectTarget } | null>\n}\n\n/** Minimal client surface used to confirm an `entity`-kind target's id exists. */\nexport interface RedirectEntityClient {\n findById(id: string): Promise<unknown>\n}\n\n/**\n * Resolve an `AdminClient`-like reader for an entity TYPE NAME, or `undefined`\n * when that type isn't registered. Task 2 wires this to\n * `app.entities.get(name)` → `app.getClient(entity)`; injecting it keeps this\n * module a leaf (no `@murumets-ee/core` import).\n */\nexport type RedirectEntityClientResolver = (entity: string) => RedirectEntityClient | undefined\n\n/**\n * Rule (a) — reject a `from_path` under a reserved prefix (`/admin`, `/api`, …).\n * A manual redirect claiming a framework-owned path would be as broken as an\n * entity claiming it, and is the same security-relevant class as F013's gap, so\n * the same guard applies. Synchronous — pure string check. Throws\n * {@link RedirectReservedPrefixError}.\n */\nexport function assertFromPathNotReserved(args: {\n fromPath: string\n reservedPathPrefixes: readonly string[]\n}): void {\n const fromPath = normalizeRedirectPath(args.fromPath)\n const reserved = matchReservedPrefix(fromPath, args.reservedPathPrefixes)\n if (reserved !== undefined) {\n throw new RedirectReservedPrefixError(fromPath, reserved)\n }\n}\n\n/**\n * Rule (b) — reject a `from_path` that matches a live canonical path (\"that URL\n * serves content\"; phase 04 §3). Redirecting away from a URL that already serves\n * content would hide the content.\n *\n * - A LOCALE-SPECIFIC redirect (`locale` a string) is checked against that one\n * locale's canonical rows.\n * - A CATCH-ALL redirect (`locale === null`) is checked against EVERY configured\n * locale (a single `locale IN (...)` lookup — `toolkit_paths` has no null-locale\n * rows, so a null-locale lookup would never match). Restricting to configured\n * locales avoids over-blocking on orphaned rows left by a removed locale.\n *\n * Throws {@link RedirectServesContentError} naming the specific conflicting locale.\n */\nexport async function assertFromPathNotServingContent(args: {\n fromPath: string\n locale: string | null\n configuredLocales: readonly string[]\n pathsClient: PathsCanonicalReader\n}): Promise<void> {\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n if (args.locale !== null) {\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: args.locale,\n })\n if (hit) throw new RedirectServesContentError(fromPath, args.locale, hit.locale)\n return\n }\n\n // Catch-all: does ANY configured locale serve this path? One IN-lookup covers\n // it (the query is bounded by the small, static configured-locale count).\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: { in: [...args.configuredLocales] },\n })\n if (hit) throw new RedirectServesContentError(fromPath, null, hit.locale)\n}\n\n/**\n * Rule (c) — collapse a `path`-kind target that itself points at another manual\n * redirect down to that redirect's (already-collapsed) target, so a stored `path`\n * target always points at CONTENT, never at another redirect row. \"Max one hop by\n * construction\" (phase 04 §3): every stored row's target is already fully\n * collapsed by this same rule, so a SINGLE lookup suffices — no loop, no\n * cycle-guard.\n *\n * Precedence, given the redirect being saved has its own `locale`:\n * - LOCALE-SPECIFIC save — prefer an exact-locale redirect at the target path,\n * else fall back to a catch-all (`locale = null`) redirect there.\n * - CATCH-ALL save (`locale === null`) — only a catch-all redirect at the target\n * path applies (a locale-specific row can't stand in for an all-locales\n * redirect).\n *\n * Non-`path` targets (entity / url) are returned unchanged without any lookup.\n * A `path` target that points at no redirect is returned unchanged (it points at\n * content, or a dead path — not this rule's concern).\n *\n * `excludeId` — pass the row's own id on UPDATE. Without it, a `PATCH` that\n * keeps `fromPath`/`locale` unchanged and sets a self-referential `path` target\n * would have this lookup match the row's own PRE-write state (the UPDATE hasn't\n * executed yet within the same transaction), silently returning its OLD target\n * instead of surfacing as a self-reference — the caller's write appears to\n * succeed (200) while the target silently doesn't change to what was requested.\n * Excluding the row's own id makes the lookup correctly report \"no redirect at\n * the target path\", which the caller's self-redirect guard then catches.\n */\nexport async function collapsePathTarget(args: {\n target: RedirectTarget\n locale: string | null\n redirectsClient: RedirectsCollapseReader\n excludeId?: string\n}): Promise<RedirectTarget> {\n if (args.target.kind !== 'path') return args.target\n const path = normalizeRedirectPath(args.target.path)\n const idFilter = args.excludeId === undefined ? {} : { id: { ne: args.excludeId } }\n\n // Prefer an exact-locale redirect (only meaningful for a locale-specific save).\n if (args.locale !== null) {\n const exact = await args.redirectsClient.findOne({\n fromPath: path,\n locale: args.locale,\n ...idFilter,\n })\n if (exact) return exact.target\n }\n // Fall back to (or, for a catch-all save, only consider) a catch-all redirect.\n const catchAll = await args.redirectsClient.findOne({ fromPath: path, locale: null, ...idFilter })\n if (catchAll) return catchAll.target\n\n // No redirect at the target path — leave the path target as-is.\n return { kind: 'path', path }\n}\n\n/**\n * Rule for `entity`-kind targets — confirm the referenced entity TYPE is\n * registered and the referenced ID exists. A redirect to an unknown type or a\n * nonexistent document can never resolve to a useful URL, so both are rejected at\n * save (CLAUDE.md \"validate at the boundary\") rather than surfacing as a dead link\n * at request time. No-op for `url` / `path` targets. Entity targets do NOT resolve\n * to a path here — that happens at REQUEST time in PR06 (phase 04 §3).\n *\n * Throws {@link RedirectUnknownEntityError} (unregistered type) or\n * {@link RedirectTargetNotFoundError} (type ok, id missing).\n */\nexport async function assertEntityTargetValid(args: {\n target: RedirectTarget\n resolveClient: RedirectEntityClientResolver\n}): Promise<void> {\n if (args.target.kind !== 'entity') return\n const { entity, id } = args.target\n\n const client = args.resolveClient(entity)\n if (client === undefined) throw new RedirectUnknownEntityError(entity)\n\n const found = await client.findById(id)\n if (found === null || found === undefined) throw new RedirectTargetNotFoundError(entity, id)\n}\n","/**\n * Manual-redirect admin CRUD routes — declarative `defineAdminRoute` entries\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3-4; F022:\n * backend-only, no admin-ui page in this PR).\n *\n * Mounted under prefix `redirects`. The URL surface:\n *\n * GET /api/admin/redirects ?locale=&limit=&offset= — list (paginated)\n * POST /api/admin/redirects { locale, fromPath, target, status? }\n * PATCH /api/admin/redirects/:id { locale?, fromPath?, target?, status? }\n * DELETE /api/admin/redirects/:id\n * GET /api/admin/redirects/history ?entityType=&entityId=&locale= — automatic\n * (rename) redirect history for one document\n *\n * **Registration shape** (per `defineAdminRoute`'s single-segment-path\n * constraint): PATCH + DELETE embed the redirect id directly after the prefix\n * (`/redirects/<uuid>`), so they register `path: '', matchAnyPath: true` and read\n * the id from `ctx.segments[0]`. `combineAdminRoutes`' dispatcher routes any\n * unmatched sub-path under `(prefix, method)` through the single catch-all when\n * no exact `path` claims `segments[0]` (see `makeDispatcher`). GET registers two\n * EXACT entries — `''` (list) and `'history'` — which take precedence over any\n * catch-all and never collide. POST registers `''` (create) only.\n *\n * **Authorization model:** every route gates on the SINGLE `redirect:manage`\n * permission (phase 04 §4 — one permission for all CRUD, not a per-verb split).\n * `defaultRoles: ['admin']`. The wrapper (`guardedHandler`) emits the\n * `permission.denied` audit on every 403 uniformly.\n *\n * **DB access** is resolved at REQUEST time (never at factory/module scope —\n * `getApp()` throws before boot; the factory runs at config-load under jiti/tsx).\n * Create / update / delete each run inside ONE `db.transaction` so the\n * `toolkit_redirects` row write and its `entity_refs` bookkeeping (`./refs.ts`)\n * commit — or roll back — atomically.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n type AuthUser,\n combineAdminRoutes,\n defineAdminRoute,\n safeAudit,\n} from '@murumets-ee/core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { z } from 'zod'\nimport { getContentConfig } from '../content-config-store.js'\nimport { isUniqueViolation } from '../paths/apply-path.js'\nimport { toolkitPathsTable } from '../paths/schema.js'\nimport {\n RedirectReservedPrefixError,\n RedirectSelfReferenceError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport { clearRedirectRefs, writeRedirectRef } from './refs.js'\nimport { toolkitRedirectsTable } from './schema.js'\nimport { type RedirectTarget, redirectTargetSchema } from './target.js'\nimport {\n assertEntityTargetValid,\n assertFromPathNotReserved,\n assertFromPathNotServingContent,\n collapsePathTarget,\n normalizeRedirectPath,\n type RedirectEntityClientResolver,\n} from './validation.js'\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n/** `toolkit_paths.entity_type` is `varchar(100)`; same shape as queue's type filter. */\nconst ENTITY_TYPE_RE = /^[a-zA-Z0-9:_.-]{1,100}$/\n\n/** ONE permission gates every redirect CRUD verb (phase 04 §4). */\nconst REDIRECT_PERMISSION = 'redirect:manage'\nconst REDIRECT_DEFAULT_ROLES = ['admin'] as const\n\n// ---------------------------------------------------------------------------\n// HTTP helpers (same shape as queue/src/admin.ts)\n// ---------------------------------------------------------------------------\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction errorJson(message: string, status: number): Response {\n return json({ error: message }, status)\n}\n\nasync function parseJsonBody<S extends z.ZodTypeAny>(\n req: Request,\n schema: S,\n): Promise<{ data: z.infer<S> } | { response: Response }> {\n let raw: unknown\n try {\n raw = await req.json()\n } catch {\n return { response: errorJson('Invalid JSON body', 400) }\n }\n const result = schema.safeParse(raw)\n if (!result.success) {\n const issue = result.error.issues[0]\n const message = issue ? `${issue.path.join('.') || 'body'}: ${issue.message}` : 'Invalid body'\n return { response: errorJson(message, 400) }\n }\n return { data: result.data }\n}\n\n/**\n * Strict uint query-param parse — same regex + `Number.isSafeInteger` guard as\n * queue's `parseUintParam` (plain `Number()` silently coerces blanks / hex /\n * exponent / signed input; a long digit string overflows to `Infinity`).\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n/** Pagination offset cap — a deep `?offset=` forces a scan-and-discard pass. */\nconst MAX_OFFSET = 100_000\n/** History is inherently bounded (PR04 caps rename history per doc/locale), but keep a hard ceiling. */\nconst HISTORY_LIMIT = 100\n\nfunction auditActor(user: AuthUser): { userId: string; userName?: string } {\n return user.name === undefined ? { userId: user.id } : { userId: user.id, userName: user.name }\n}\n\n// ---------------------------------------------------------------------------\n// App / DB access — deferred to request time (getApp throws before boot; the\n// factory runs at config-load). Mirrors queue/src/admin.ts's getDb().\n// ---------------------------------------------------------------------------\n\nasync function getAppInstance() {\n const { getApp } = await import('@murumets-ee/core')\n return getApp()\n}\n\nasync function getDb(): Promise<PostgresJsDatabase> {\n return (await getAppInstance()).db.readWrite\n}\n\n/**\n * Build the `entity`-target existence resolver from the live app registry:\n * `app.entities.get(name)` → `app.getClient(entity)`. Passed into\n * `assertEntityTargetValid`, keeping `./validation.ts` a leaf (no core import).\n */\nasync function buildEntityResolver(): Promise<RedirectEntityClientResolver> {\n const app = await getAppInstance()\n return (name) => {\n const entity = app.entities.get(name)\n return entity ? app.getClient(entity) : undefined\n }\n}\n\n/**\n * Drizzle's transaction handle is structurally a `PgDatabase` but its generic\n * parameters don't line up with the `PostgresJsDatabase` alias `makeClient` /\n * `writeRedirectRef` expect, so the tx handle is cast at this single boundary —\n * the exact pattern `@murumets-ee/db`'s own `TableClient.transaction` uses\n * (`innerDb as unknown as PostgresJsDatabase`). One cast per transaction.\n */\ntype DrizzleTx = Parameters<Parameters<PostgresJsDatabase['transaction']>[0]>[0]\nfunction txAsDb(tx: DrizzleTx): PostgresJsDatabase {\n return tx as unknown as PostgresJsDatabase\n}\n\n// ---------------------------------------------------------------------------\n// Request-body schemas — validated at the boundary (CLAUDE.md). `createdBy` is\n// NEVER accepted from the body; it's set server-side from ctx.user.id.\n// ---------------------------------------------------------------------------\n\n/** `301 | 302` only. */\nconst statusSchema = z.union([z.literal(301), z.literal(302)])\n\n/** Locale-relative internal path — leading '/', bounded, same convention as `target.ts`'s `path` arm. */\nconst fromPathSchema = z.string().min(1).max(2048).startsWith('/')\n\nconst createBodySchema = z.object({\n /** BCP47 locale, or `null` = catch-all (applies to all locales). Membership vs. configured locales is checked in the handler. */\n locale: z.string().min(1).nullable(),\n fromPath: fromPathSchema,\n target: redirectTargetSchema,\n status: statusSchema.default(301),\n})\n\nconst updateBodySchema = z.object({\n locale: z.string().min(1).nullable().optional(),\n fromPath: fromPathSchema.optional(),\n target: redirectTargetSchema.optional(),\n status: statusSchema.optional(),\n})\n\n/**\n * Whitelist a redirect's locale against the CONFIGURED content locales\n * (deny-by-default; CLAUDE.md). `null` (catch-all) is always allowed. Returns a\n * 400 Response on rejection, or `null` when the locale is acceptable.\n */\nfunction rejectUnconfiguredLocale(locale: string | null): Response | null {\n if (locale === null) return null\n const configured = getContentConfig().locales.map((l) => l.code)\n if (!configured.includes(locale)) {\n return errorJson(`Unknown locale \"${locale}\" — not one of the configured content locales.`, 400)\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Error → HTTP status mapping. Conflict-with-existing-state errors → 409\n// (matches the codebase convention: ReferencedEntityError / CrossLocaleWriteError\n// / RestoreConflictError all → 409). Bad-target-in-payload → 400 (matches\n// EntityCapabilityError / InvalidLocaleError → 400). A 23505 escaping the tx (the\n// DB-level partial-unique backstop firing on a race) → 409, never a raw 500.\n// Anything else is rethrown so the api-handler's central catch logs + 500s.\n// ---------------------------------------------------------------------------\n\nfunction mapRedirectError(err: unknown): Response {\n if (err instanceof RedirectReservedPrefixError) {\n return json({ error: err.message, fromPath: err.fromPath, prefix: err.prefix }, 409)\n }\n if (err instanceof RedirectServesContentError) {\n return json(\n {\n error: err.message,\n fromPath: err.fromPath,\n locale: err.locale,\n conflictingLocale: err.conflictingLocale,\n },\n 409,\n )\n }\n if (err instanceof RedirectSelfReferenceError) {\n return json({ error: err.message, fromPath: err.fromPath, locale: err.locale }, 409)\n }\n if (err instanceof RedirectUnknownEntityError) {\n return json({ error: err.message, entity: err.entity }, 400)\n }\n if (err instanceof RedirectTargetNotFoundError) {\n return json({ error: err.message, entity: err.entity, id: err.id }, 400)\n }\n if (isUniqueViolation(err)) {\n return json({ error: 'A redirect already exists for this locale + source path.' }, 409)\n }\n // Unknown — let the api-handler's central catch log it and return 500.\n throw err\n}\n\n// ---------------------------------------------------------------------------\n// Core write path — shared by create + update. Runs the full save-time\n// validation chain (normalize → reserved-prefix → entity-target → serves-content\n// → collapse → self-redirect guard) then writes the row + entity_refs inside ONE\n// transaction. Returns the written row, or `null` when an UPDATE matched no row.\n// ---------------------------------------------------------------------------\n\n/** The stored redirect row (typed via the DefinedTable's client). */\ntype RedirectRow = Awaited<\n ReturnType<ReturnType<typeof toolkitRedirectsTable.makeClient>['insert']>\n>\n\nasync function runRedirectWrite(args: {\n /** `null` = create; a UUID = update that id. */\n id: string | null\n locale: string | null\n fromPath: string\n target: RedirectTarget\n status: number\n userId: string\n resolveClient: RedirectEntityClientResolver\n}): Promise<RedirectRow | null> {\n const cfg = getContentConfig()\n const reservedPathPrefixes = cfg.reservedPathPrefixes ?? []\n const configuredLocales = cfg.locales.map((l) => l.code)\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n // 1. Reserved-prefix rejection (pure string check; security).\n assertFromPathNotReserved({ fromPath, reservedPathPrefixes })\n\n // 2. Entity-target existence — a non-tx AdminClient read (soft pre-check; the\n // row it validates isn't part of this write's transaction).\n await assertEntityTargetValid({ target: args.target, resolveClient: args.resolveClient })\n\n const db = await getDb()\n return db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n const redirectsClient = toolkitRedirectsTable.makeClient(tx)\n const pathsClient = toolkitPathsTable.makeClient(tx)\n\n // 3. \"That URL serves content\" rejection — reads toolkit_paths in-tx.\n await assertFromPathNotServingContent({\n fromPath,\n locale: args.locale,\n configuredLocales,\n pathsClient,\n })\n\n // 4. Collapse a path-kind target that points at another redirect (one hop).\n // excludeId (update only) keeps the row's own pre-write state from\n // masking a self-referential collapse — see collapsePathTarget's doc.\n const collapsed = await collapsePathTarget({\n target: args.target,\n locale: args.locale,\n redirectsClient,\n ...(args.id === null ? {} : { excludeId: args.id }),\n })\n\n // 5. Self-redirect guard — a collapsed path target that points back at this\n // very from_path (same locale) is a no-op loop; reject rather than store.\n if (collapsed.kind === 'path' && normalizeRedirectPath(collapsed.path) === fromPath) {\n throw new RedirectSelfReferenceError(fromPath, args.locale)\n }\n\n // 6. Write the row + entity_refs bookkeeping, atomically.\n if (args.id === null) {\n const created = await redirectsClient.insert({\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n createdBy: args.userId,\n })\n await writeRedirectRef(tx, created.id, collapsed)\n return created\n }\n\n const updated = await redirectsClient.update(\n { id: args.id },\n {\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n updatedAt: new Date(),\n },\n )\n if (!updated) return null // row vanished between the pre-tx fetch and here.\n // Delete-then-insert refs (not diff) — matches ./refs.ts's documented usage.\n await clearRedirectRefs(tx, args.id)\n await writeRedirectRef(tx, args.id, collapsed)\n return updated\n })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleList: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const localeParam = url.searchParams.get('locale')\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 50), 1), 200)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitRedirectsTable.makeClient(await getDb())\n const items =\n localeFilter !== undefined\n ? await client.findMany({\n where: { locale: localeFilter },\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n : await client.findMany({\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n const total =\n localeFilter !== undefined ? await client.count({ locale: localeFilter }) : await client.count()\n\n return json({ items, total })\n}\n\nconst handleCreate: AdminRouteHandler = async (req, ctx) => {\n const parsed = await parseJsonBody(req, createBodySchema)\n if ('response' in parsed) return parsed.response\n const body = parsed.data\n\n const localeRejected = rejectUnconfiguredLocale(body.locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id: null,\n locale: body.locale,\n fromPath: body.fromPath,\n target: body.target,\n status: body.status,\n userId: ctx.user.id,\n resolveClient,\n })\n // Create always returns a row (insert never matches \"nothing\").\n if (!row) return errorJson('Failed to create redirect', 500)\n safeAudit(ctx, {\n action: 'redirect.created',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row }, 201)\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleUpdate: AdminRouteHandler = async (req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const parsed = await parseJsonBody(req, updateBodySchema)\n if ('response' in parsed) return parsed.response\n const patch = parsed.data\n\n // Fetch the existing row so a partial PATCH merges onto its current state.\n const existing = await toolkitRedirectsTable.makeClient(await getDb()).findOne({ id })\n if (!existing) return errorJson('Not found', 404)\n\n // Merge — only provided keys change. `locale` may be explicitly `null`\n // (switch to catch-all), so key-presence (!== undefined), not truthiness,\n // decides whether to keep the existing value.\n const locale = patch.locale !== undefined ? patch.locale : existing.locale\n const fromPath = patch.fromPath !== undefined ? patch.fromPath : existing.fromPath\n const target = patch.target !== undefined ? patch.target : existing.target\n const status = patch.status !== undefined ? patch.status : existing.status\n\n // Re-validate the RESULTING merged state (e.g. changing only `locale` can\n // newly conflict with content served in the new locale).\n const localeRejected = rejectUnconfiguredLocale(locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id,\n locale,\n fromPath,\n target,\n status,\n userId: ctx.user.id,\n resolveClient,\n })\n if (!row) return errorJson('Not found', 404)\n safeAudit(ctx, {\n action: 'redirect.updated',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row })\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleDelete: AdminRouteHandler = async (_req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const db = await getDb()\n const deleted = await db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n // Clear refs then delete, same tx — no dangling entity_refs row survives.\n await clearRedirectRefs(tx, id)\n return toolkitRedirectsTable.makeClient(tx).delete({ id })\n })\n if (!deleted) return errorJson('Not found', 404)\n\n safeAudit(ctx, {\n action: 'redirect.deleted',\n entityType: 'redirect',\n entityId: id,\n ...auditActor(ctx.user),\n metadata: { locale: deleted.locale, fromPath: deleted.fromPath },\n })\n return json({ ok: true })\n}\n\n/**\n * Read-only automatic-redirect history for ONE document — the demoted\n * (`is_canonical = false`) `toolkit_paths` rows a rename leaves behind.\n * `entityType` + `entityId` are REQUIRED; `locale` optional. No write path.\n */\nconst handleHistory: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const entityType = url.searchParams.get('entityType')\n const entityId = url.searchParams.get('entityId')\n if (!entityType || !ENTITY_TYPE_RE.test(entityType)) {\n return errorJson('entityType is required', 400)\n }\n if (!entityId || !UUID_RE.test(entityId)) {\n return errorJson('entityId is required and must be a UUID', 400)\n }\n\n const localeParam = url.searchParams.get('locale')\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitPathsTable.makeClient(await getDb())\n const items = await client.findMany({\n where: {\n entityType,\n entityId,\n isCanonical: false,\n ...(localeFilter !== undefined && { locale: localeFilter }),\n },\n orderBy: [{ column: 'updatedAt', dir: 'desc' }],\n limit: HISTORY_LIMIT,\n })\n return json({ items })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the manual-redirect admin API routes.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry for prefix\n * `'redirects'` with GET/POST/PATCH/DELETE handlers. Every entry gates on\n * `redirect:manage` (defaultRoles `['admin']`); the permission is\n * auto-registered into the catalog by `defineAdminRoute`.\n */\nexport function redirectRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'List manual URL redirects (paginated, optional locale filter).',\n handler: handleList,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: 'history',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Read-only automatic-redirect (rename) history for one document.',\n handler: handleHistory,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'POST',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Create a manual URL redirect.',\n handler: handleCreate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'PATCH',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Update a manual URL redirect by id.',\n handler: handleUpdate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'DELETE',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Delete a manual URL redirect by id.',\n handler: handleDelete,\n }),\n ])\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"routes-C9I1iZw8.mjs","names":["json"],"sources":["../src/paths/routes.ts","../src/redirects/errors.ts","../src/redirects/refs.ts","../src/redirects/target.ts","../src/redirects/validation.ts","../src/redirects/routes.ts"],"sourcesContent":["/**\n * Path-resolution admin route — \"which document owns this URL?\".\n *\n * Mounted under prefix `paths`. The URL surface:\n *\n * GET /api/admin/paths/resolve ?path=/foo/bar&locale=en\n *\n * **Why this exists.** The block editor's preview canvas is a `srcDoc` iframe,\n * so a rendered link's site-absolute `href` resolves against the ADMIN origin —\n * clicking one navigated the CANVAS and could render the whole admin editor\n * inside itself (#775). `@murumets-ee/blocks` now contains every in-canvas link\n * click and hands it to its host; the host needs exactly one server capability\n * to make that click useful: turn a site path into the document that serves it,\n * so it can open THAT document's editor.\n *\n * **Draft resolution, deliberately.** Uses `resolveDraftPath` (not\n * `resolvePath`): an editor following a link to an unpublished sibling page\n * must land on its editor, and the caller is an authenticated admin who can\n * already read the row through the entity API. This is exactly the\n * \"admin-only surface\" `resolveDraftPath`'s docblock reserves — the FIRST HTTP\n * route wired to it. It must never be reachable from `content-api` (public,\n * published-only).\n *\n * **Authorization — two gates.**\n * 1. Route gate (`defineAdminRoute`): `path:resolve`, `defaultRoles:\n * ['admin', 'editor']`. Viewers are excluded; the capability only exists\n * to navigate BETWEEN editors.\n * 2. Per-document gate: the resolved `entityType` must also pass\n * `ctx.checkPermission(entityType, 'view')`. A caller with the route\n * permission but no grant on that entity gets `{ kind: 'none' }` — the\n * SAME answer as a path that owns nothing, so the response never\n * discloses that a document exists there (no-leak, matching\n * `menus`' anchors route and content-api's 404-not-403 rule).\n *\n * **Bounded work.** One resolve is a handful of indexed single-row reads. A\n * `redirect`-kind result is followed exactly ONCE more (the index stores\n * collapsed targets, and `resolveInternal` already caps its own chain at\n * `MAX_REDIRECT_HOPS`) — so this handler's cost is constant, never a chain\n * walk driven by caller input.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n getContext,\n} from '@murumets-ee/core'\nimport { getContentConfig } from '../content-config-store.js'\n// `./resolve.js` is a LEAF (every dependency injected, no `@murumets-ee/core`\n// graph) so it imports statically. `./resolve-context.js` is NOT — it holds the\n// app-binding imports, and its own header reserves it for request-time surfaces\n// and forbids the `lumi` CLI command graph (F027 guardrail). This module is\n// reached from `plugin.ts`'s STATIC graph, which the CLI loads through\n// `lumi.config.ts`, so the binding is imported at REQUEST time inside the\n// handler instead — same deferral `redirects/routes.ts` uses for `getApp`.\nimport { resolveDraftPath } from './resolve.js'\n\n/** Route permission + the roles that hold it by default. */\nexport const PATH_RESOLVE_PERMISSION = 'path:resolve'\nexport const PATH_RESOLVE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Cap on the `path` query param. `toolkit_paths.path` is `text` (no DB cap), so\n * the bound is ours: generously above any real URL, low enough that the param\n * can't be used to push megabytes through the query planner.\n */\nconst MAX_PATH_LENGTH = 2048\n\n/** The answer shape the caller (admin-ui's editor canvas) consumes. */\nexport type ResolvePathResponse =\n /** The path is served by this document. */\n | { readonly kind: 'document'; readonly entityType: string; readonly id: string }\n /** The path redirects OFF-SITE (a `url`-kind manual redirect). */\n | { readonly kind: 'redirect'; readonly to: string }\n /** Nothing the caller may see owns this path. */\n | { readonly kind: 'none' }\n\nfunction json(data: ResolvePathResponse | { error: string }, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nconst NONE: ResolvePathResponse = { kind: 'none' }\n\n/**\n * Boundary validation for the `path` param. Rejects (rather than normalizes)\n * anything that isn't a bare locale-relative path: a protocol-relative\n * `//host` or a `?query` / `#fragment` tail means the CALLER built the request\n * wrong, and answering it would silently resolve something other than what it\n * asked about.\n */\nfunction validatePath(raw: string | null): { path: string } | { error: string } {\n if (raw === null || raw.length === 0) return { error: \"query param 'path' is required\" }\n if (raw.length > MAX_PATH_LENGTH) return { error: \"query param 'path' is too long\" }\n if (!raw.startsWith('/')) return { error: \"query param 'path' must start with '/'\" }\n if (raw.startsWith('//')) return { error: \"query param 'path' must not be protocol-relative\" }\n if (raw.includes('?') || raw.includes('#')) {\n return { error: \"query param 'path' must not include a query or fragment\" }\n }\n return { path: raw }\n}\n\n/**\n * Whitelist the locale (CLAUDE.md \"whitelist, don't blacklist\"), falling back\n * to the request's own content locale and then the configured default so the\n * common call can omit it.\n *\n * Checks the **request-scoped effective set** — `content()` shipped defaults ⊕\n * DB `locale` override rows, resolved once per request (SD006) — with the static\n * set as fallback outside a request context. Using only the static list was a\n * real bug, not just an inconsistency: the in-editor locale switcher is driven\n * from `effectiveLocales`, so a locale enabled at RUNTIME is a legal editor\n * locale, and it would have 400'd here — making EVERY canvas link click in that\n * locale report \"no CMS page serves …\".\n *\n * **Why this doesn't just call `locale-utils.validateLocale`**, which implements\n * exactly this predicate: that module imports `getContentConfig` from\n * `./plugin.js` rather than from the leaf store, deliberately and with a\n * documented reason (see `content-config-store.ts`'s header — its consumers'\n * unit tests mock `plugin.js`, and repointing it ripples through ~7 mocks).\n * Since `plugin.ts` imports THIS module, calling it would close a real import\n * cycle (`locale-utils -> plugin -> paths/routes -> locale-utils`), which\n * `verify:cycles` rejects. So the same two-line read is done here against the\n * same sources of truth, rather than growing this PR into that refactor.\n */\nfunction resolveRequestLocale(\n raw: string | null,\n fallback: string | undefined,\n): { locale: string } | { error: string } {\n const config = getContentConfig()\n const candidate = raw ?? fallback ?? config.defaultLocale\n const allowed = getContext()?.availableLocales ?? config.locales.map((l) => l.code)\n if (!allowed.includes(candidate)) {\n // Deliberately does NOT echo the allowed set — no reason to enumerate the\n // deployment's configured locales to the caller.\n return { error: \"query param 'locale' is not a content locale\" }\n }\n return { locale: candidate }\n}\n\n/**\n * `GET /api/admin/paths/resolve?path=&locale=` — answer \"which document serves\n * this URL?\".\n *\n * Validates both params at the boundary, resolves through the draft resolver,\n * and projects the result through the per-document `view` gate so an\n * unviewable document is indistinguishable from an unowned path. See the module\n * header for the authorization model and why the deps builder is imported\n * lazily.\n */\nconst handleResolve: AdminRouteHandler = async (req, ctx) => {\n const params = new URL(req.url).searchParams\n\n const pathResult = validatePath(params.get('path'))\n if ('error' in pathResult) return json({ error: pathResult.error }, 400)\n const localeResult = resolveRequestLocale(params.get('locale'), ctx.locale ?? ctx.defaultLocale)\n if ('error' in localeResult) return json({ error: localeResult.error }, 400)\n const { locale } = localeResult\n\n const { buildResolveDeps } = await import('./resolve-context.js')\n const deps = buildResolveDeps(ctx.app)\n\n // Gate 2 (see the module header) — a document the caller can't view answers\n // exactly like an unowned path.\n const project = (entityType: string, id: string): ResolvePathResponse =>\n ctx.checkPermission(entityType, 'view') ? { kind: 'document', entityType, id } : NONE\n\n const result = await resolveDraftPath(pathResult.path, locale, deps)\n if (result.kind === 'document') return json(project(result.entityType, result.id))\n if (result.kind === 'none') return json(NONE)\n\n // `redirect`: an off-site target is handed back for the host to open; a\n // same-site one is followed ONE hop so the caller lands on the document\n // instead of round-tripping. No loop — exactly one extra resolve. A\n // protocol-relative `//host` target counts as off-site, not as a path (target\n // validation shouldn't produce one, but resolving it AS a path would silently\n // answer about something else entirely).\n const sameSite = result.to.startsWith('/') && !result.to.startsWith('//')\n if (!sameSite) return json({ kind: 'redirect', to: result.to })\n const hop = await resolveDraftPath(result.to, locale, deps)\n return json(hop.kind === 'document' ? project(hop.entityType, hop.id) : NONE)\n}\n\n/**\n * Build the path-resolution admin routes. One entry: `GET /paths/resolve`.\n */\nexport function pathRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'paths',\n path: 'resolve',\n method: 'GET',\n permission: PATH_RESOLVE_PERMISSION,\n defaultRoles: PATH_RESOLVE_DEFAULT_ROLES,\n description:\n 'Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).',\n handler: handleResolve,\n }),\n ])\n}\n","/**\n * Domain errors thrown by the manual-redirect save-time validation\n * (`./validation.ts`, plan/routing PR05, phase `04-path-lifecycle-and-redirects.md`\n * §3). Distinct classes so Task 2's admin API handler can map each to a 4xx\n * (caller-actionable) rather than a generic 500, and so callers can branch on\n * `instanceof`.\n *\n * Style mirrors `../paths/errors.ts` exactly (`extends Error`, named readonly\n * fields, an actionable message, `this.name = 'ClassName'`). A redirect-specific\n * reserved-prefix error is defined here rather than reusing `paths`'\n * `PathReservedPrefixError`, whose constructor requires an `entityName` — a\n * redirect has no owning entity, so forcing that field would be a lie.\n */\n\n/**\n * A manual redirect's `from_path` falls under a reserved path prefix\n * (`reservedPathPrefixes`, e.g. `/admin` or `/api`). Allowing it would make the\n * redirect shadow a route the framework owns — the same hazard the path-index\n * guard rejects for entity canonical URLs — so the write is aborted before commit.\n */\nexport class RedirectReservedPrefixError extends Error {\n readonly fromPath: string\n readonly prefix: string\n\n constructor(fromPath: string, prefix: string) {\n super(\n `Redirect source \"${fromPath}\" falls under the reserved prefix \"${prefix}\" and cannot be ` +\n `used — that path range is owned by the framework.`,\n )\n this.name = 'RedirectReservedPrefixError'\n this.fromPath = fromPath\n this.prefix = prefix\n }\n}\n\n/**\n * A manual redirect's `from_path` already matches a live canonical path — that URL\n * serves content, so redirecting away from it would hide the content (phase 04 §3,\n * \"that URL serves content\"). The write is aborted before commit.\n *\n * `locale` is the redirect's own locale (`null` = a catch-all redirect, which was\n * checked against every configured locale); `conflictingLocale` names the specific\n * locale whose canonical path collided, so the message can be precise even for a\n * catch-all redirect.\n */\nexport class RedirectServesContentError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n readonly conflictingLocale: string\n\n constructor(fromPath: string, locale: string | null, conflictingLocale: string) {\n super(\n `Redirect source \"${fromPath}\" already serves content in locale \"${conflictingLocale}\" ` +\n `(it is a live canonical URL). Choose a different source path, or unpublish/rename the ` +\n `content first.`,\n )\n this.name = 'RedirectServesContentError'\n this.fromPath = fromPath\n this.locale = locale\n this.conflictingLocale = conflictingLocale\n }\n}\n\n/**\n * An `entity`-kind redirect target names an entity type that isn't registered in\n * the app. A redirect to an unknown entity type can never resolve, so it's\n * rejected at save (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectUnknownEntityError extends Error {\n readonly entity: string\n\n constructor(entity: string) {\n super(\n `Redirect target references entity type \"${entity}\", which is not a registered entity. ` +\n `Check the entity name.`,\n )\n this.name = 'RedirectUnknownEntityError'\n this.entity = entity\n }\n}\n\n/**\n * An `entity`-kind redirect target names a real entity type but a row id that\n * doesn't exist. A redirect to a nonexistent document can never resolve to a\n * useful URL, so it's rejected at save rather than discovered as a dead link at\n * request time (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectTargetNotFoundError extends Error {\n readonly entity: string\n readonly id: string\n\n constructor(entity: string, id: string) {\n super(\n `Redirect target ${entity} \"${id}\" does not exist. Redirect to a document that exists, or ` +\n `use a URL/path target instead.`,\n )\n this.name = 'RedirectTargetNotFoundError'\n this.entity = entity\n this.id = id\n }\n}\n\n/**\n * A manual redirect's collapsed target points straight back at its OWN\n * `from_path` (in the same locale being saved) — a self-loop / no-op that could\n * never resolve to content. Rejected by Task 2's CRUD route BEFORE the row is\n * persisted, rather than stored as a degenerate row a request-time resolver\n * would have to special-case.\n *\n * Distinct from the collapse rule (`./validation.ts` `collapsePathTarget`, which\n * dedupes redirect CHAINS down to one hop): this guards the case where that\n * collapse resolves the destination back to the source path of the row itself.\n * `locale` is the redirect's own locale (`null` = catch-all).\n */\nexport class RedirectSelfReferenceError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n\n constructor(fromPath: string, locale: string | null) {\n super(\n `Redirect source \"${fromPath}\"${locale === null ? '' : ` (locale \"${locale}\")`} resolves ` +\n `back to itself — a redirect cannot point at its own source path. Choose a different ` +\n `destination.`,\n )\n this.name = 'RedirectSelfReferenceError'\n this.fromPath = fromPath\n this.locale = locale\n }\n}\n","/**\n * `entity_refs` bookkeeping for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3; decision F020 point 2).\n *\n * When a redirect targets an ENTITY, we register a row in the universal\n * `entity_refs` table so the broken-link dashboard can surface \"this redirect\n * points at a deleted document\". No public write helper exists for this:\n * `AdminClient.syncRefs` is private and entity-only (it needs a field map to\n * `extractRefs`), and `toolkit_redirects` is a `defineTable` infra row, not an\n * AdminClient entity. So we hand-roll insert/delete against the exported\n * `entityRefs` table, mirroring `syncRefs`'s shape (F020 point 2).\n *\n * The ref uses `relationKind: 'link'` — a SOFT reference. Only `'structured'`\n * refs block deleting the target entity (AdminClient's delete-protection filters\n * on that kind); a redirect must NEVER block deleting the document it points at\n * (phase 04 §3's Delete row: the manual redirect merely *surfaces in the\n * broken-link view* via `entity_refs`, it doesn't hard-depend). `'link'` is\n * exactly that: tracked, never blocking.\n *\n * `sourceEntity: 'toolkit_redirects'` is safe as a free-form marker —\n * `entity_refs.sourceEntity` / `targetEntity` are plain varchar with no FK to a\n * registered-entity list.\n *\n * Both functions take `exec: PostgresJsDatabase` (the same parameter type\n * `AdminClient.syncRefs` takes), so Task 2's route can call them inside the SAME\n * transaction as the `toolkit_redirects` write itself — keeping the row write and\n * its ref bookkeeping atomic.\n */\n\nimport { entityRefs, type RelationKind } from '@murumets-ee/entity/refs'\nimport { and, eq } from 'drizzle-orm'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { RedirectTarget } from './target.js'\n\n/** The `entity_refs.source_entity` marker for every manual-redirect ref row. */\nconst SOURCE_ENTITY = 'toolkit_redirects'\n/** The `entity_refs.source_field` marker — the `target` column of the redirect. */\nconst SOURCE_FIELD = 'target'\n/** Soft reference: tracked for the broken-link dashboard, never delete-blocking. */\nconst RELATION_KIND: RelationKind = 'link'\n\n/**\n * Register the `entity_refs` row for a redirect whose target is an entity.\n * No-op for `url` / `path` targets (nothing to track). Idempotent via\n * `.onConflictDoNothing()` (the `uq_entity_refs` unique covers\n * source+field+target), matching `AdminClient.syncRefs`'s own pattern — so a\n * re-run (e.g. an update that re-writes the same entity target) doesn't error.\n *\n * Call INSIDE the redirect-write transaction, AFTER {@link clearRedirectRefs} on\n * update (delete-then-insert; see that function's note).\n */\nexport async function writeRedirectRef(\n exec: PostgresJsDatabase,\n redirectId: string,\n target: RedirectTarget,\n): Promise<void> {\n if (target.kind !== 'entity') return\n await exec\n .insert(entityRefs)\n .values({\n sourceEntity: SOURCE_ENTITY,\n sourceId: redirectId,\n sourceField: SOURCE_FIELD,\n targetEntity: target.entity,\n targetId: target.id,\n relationKind: RELATION_KIND,\n })\n .onConflictDoNothing()\n}\n\n/**\n * Remove ALL `entity_refs` rows for a redirect. Called:\n * - before {@link writeRedirectRef} on UPDATE — delete-then-insert is simpler and\n * safer than diffing the old vs new target, and this is not a hot path; and\n * - on DELETE of the redirect row, so no dangling ref survives.\n *\n * Call INSIDE the redirect-write transaction so the ref cleanup commits (or rolls\n * back) atomically with the row change.\n */\nexport async function clearRedirectRefs(\n exec: PostgresJsDatabase,\n redirectId: string,\n): Promise<void> {\n await exec\n .delete(entityRefs)\n .where(and(eq(entityRefs.sourceEntity, SOURCE_ENTITY), eq(entityRefs.sourceId, redirectId)))\n}\n","/**\n * `RedirectTarget` — the discriminated union stored in `toolkit_redirects.target`\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3; decision\n * F020 point 1).\n *\n * Phase 04 §3 sketches this as \"LinkValue-shaped\", but it is NOT literally blocks'\n * D008 `LinkValue` (`packages/blocks/src/core/types.ts`), which is\n * `entity | media | url | email` — it has NO `path` arm and carries `media`/`email`\n * arms a redirect never needs. `asLinkValue` / `resolveLinkHref` / the picker UI all\n * switch on that union exhaustively with no `default`, so bolting a `path` arm onto\n * the real type would ripple into blocks' richtext/picker surface for zero benefit\n * here. So this is a redirect-local union matching the phase doc's actual three\n * kinds (F020 point 1) — \"LinkValue-shaped\" read as \"same tagged-union spirit\",\n * not a literal type import.\n *\n * The three kinds:\n * - `entity` — points at a document. Resolves to that document's *current*\n * canonical path AT REQUEST TIME (never stale, per phase 04 §3 / PR06's\n * `resolvePath`), and registers an `entity_refs` row for broken-link dashboard\n * coverage (see `./refs.ts`).\n * - `url` — an absolute external URL. Admin-authored only (the table is never\n * writable from a public surface), so no open-redirect vector.\n * - `path` — a locale-relative internal path. Collapsed at save time if it\n * itself points at another manual redirect (see `./validation.ts`), so a stored\n * `path` target always points at content, never at another redirect row.\n */\n\nimport { z } from 'zod'\n\n/** The stored shape of `toolkit_redirects.target`. */\nexport type RedirectTarget =\n | { kind: 'entity'; entity: string; id: string }\n | { kind: 'url'; url: string }\n | { kind: 'path'; path: string }\n\n/**\n * API-boundary validation for a {@link RedirectTarget} (CLAUDE.md \"validate at the\n * boundary\"). Task 2's admin CRUD routes parse the request body's `target` with\n * this before any write. Discriminated on `kind` so a malformed arm reports the\n * precise field, and unknown `kind`s are rejected rather than silently stored.\n */\nexport const redirectTargetSchema = z.discriminatedUnion('kind', [\n z.object({\n kind: z.literal('entity'),\n /** Registered entity type name (existence checked in `./validation.ts`). */\n entity: z.string().min(1),\n /** Entity row id — always a UUID (entity ids are UUIDs; only user ids are varchar). */\n id: z.string().uuid(),\n }),\n z.object({\n kind: z.literal('url'),\n /** Absolute external URL. */\n url: z.string().max(2048).url(),\n }),\n z.object({\n kind: z.literal('path'),\n /** Locale-relative internal path — leading '/', same convention as `toolkit_paths.path`. */\n path: z.string().min(1).max(2048).startsWith('/'),\n }),\n])\n","/**\n * Save-time validation for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3). Each rule is an exported function\n * Task 2's admin CRUD route calls BEFORE writing a `toolkit_redirects` row.\n *\n * The recommended route order (Task 2 owns the actual wiring):\n * 1. normalize `fromPath` + a `path`-kind target's `path` via {@link normalizeRedirectPath}\n * 2. {@link assertFromPathNotReserved} — reserved-prefix rejection (security)\n * 3. {@link assertFromPathNotServingContent} — \"that URL serves content\" rejection\n * 4. {@link assertEntityTargetValid} — entity target type + id existence\n * 5. {@link collapsePathTarget} — collapse a redirect-chain to one hop\n * 6. write the row (+ `entity_refs` via `./refs.ts`) inside one transaction\n *\n * ## Dependency injection (leaf-friendly, testable)\n *\n * The paths / redirects readers are injected as NARROW structural interfaces\n * ({@link PathsCanonicalReader}, {@link RedirectsCollapseReader}) rather than the\n * full `TableClient`. The real `ToolkitPathsClient` / `ToolkitRedirectsClient`\n * satisfy them (proven at compile time by the integration test, which passes the\n * real clients straight in), so Task 2 injects the real client while unit tests\n * inject a trivial stub — no `as unknown as` casts on either side. The\n * entity-existence check is injected the same way, as {@link\n * RedirectEntityClientResolver}, so this module never imports `@murumets-ee/core`.\n */\n\nimport { matchReservedPrefix } from '@murumets-ee/entity'\nimport { normalizePath } from '../paths/generate-path.js'\nimport {\n RedirectReservedPrefixError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport type { RedirectTarget } from './target.js'\n\n/**\n * Normalize a redirect path the SAME way the path index normalizes\n * `toolkit_paths.path` (collapse duplicate slashes + strip a trailing slash), so a\n * redirect's `from_path` and a `path`-kind target compare byte-identically against\n * the stored canonical/redirect paths. Task 2 should apply this to the stored\n * value too, so what is written matches what was validated. Re-exported thin\n * wrapper over the path module's `normalizePath` (DRY — do not reimplement).\n */\nexport function normalizeRedirectPath(path: string): string {\n return normalizePath(path)\n}\n\n/**\n * The read surface {@link assertFromPathNotServingContent} needs from the paths\n * index. The real `ToolkitPathsClient` satisfies this (its `findOne` accepts any\n * `WhereClause` and returns the full row).\n */\nexport interface PathsCanonicalReader {\n findOne(where: {\n path: string\n isCanonical: true\n /** A single locale, or an `IN (...)` set for the catch-all \"any configured locale\" check. */\n locale: string | { in: string[] }\n }): Promise<{ locale: string } | null>\n}\n\n/**\n * The read surface {@link collapsePathTarget} needs from the redirects table.\n * The real `ToolkitRedirectsClient` satisfies this.\n */\nexport interface RedirectsCollapseReader {\n findOne(where: {\n fromPath: string\n locale: string | null\n /** Excludes the row being updated so its own pre-write state can't mask a\n * self-referential collapse (M1 fix — see {@link collapsePathTarget}). */\n id?: { ne: string }\n }): Promise<{ target: RedirectTarget } | null>\n}\n\n/** Minimal client surface used to confirm an `entity`-kind target's id exists. */\nexport interface RedirectEntityClient {\n findById(id: string): Promise<unknown>\n}\n\n/**\n * Resolve an `AdminClient`-like reader for an entity TYPE NAME, or `undefined`\n * when that type isn't registered. Task 2 wires this to\n * `app.entities.get(name)` → `app.getClient(entity)`; injecting it keeps this\n * module a leaf (no `@murumets-ee/core` import).\n */\nexport type RedirectEntityClientResolver = (entity: string) => RedirectEntityClient | undefined\n\n/**\n * Rule (a) — reject a `from_path` under a reserved prefix (`/admin`, `/api`, …).\n * A manual redirect claiming a framework-owned path would be as broken as an\n * entity claiming it, and is the same security-relevant class as F013's gap, so\n * the same guard applies. Synchronous — pure string check. Throws\n * {@link RedirectReservedPrefixError}.\n */\nexport function assertFromPathNotReserved(args: {\n fromPath: string\n reservedPathPrefixes: readonly string[]\n}): void {\n const fromPath = normalizeRedirectPath(args.fromPath)\n const reserved = matchReservedPrefix(fromPath, args.reservedPathPrefixes)\n if (reserved !== undefined) {\n throw new RedirectReservedPrefixError(fromPath, reserved)\n }\n}\n\n/**\n * Rule (b) — reject a `from_path` that matches a live canonical path (\"that URL\n * serves content\"; phase 04 §3). Redirecting away from a URL that already serves\n * content would hide the content.\n *\n * - A LOCALE-SPECIFIC redirect (`locale` a string) is checked against that one\n * locale's canonical rows.\n * - A CATCH-ALL redirect (`locale === null`) is checked against EVERY configured\n * locale (a single `locale IN (...)` lookup — `toolkit_paths` has no null-locale\n * rows, so a null-locale lookup would never match). Restricting to configured\n * locales avoids over-blocking on orphaned rows left by a removed locale.\n *\n * Throws {@link RedirectServesContentError} naming the specific conflicting locale.\n */\nexport async function assertFromPathNotServingContent(args: {\n fromPath: string\n locale: string | null\n configuredLocales: readonly string[]\n pathsClient: PathsCanonicalReader\n}): Promise<void> {\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n if (args.locale !== null) {\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: args.locale,\n })\n if (hit) throw new RedirectServesContentError(fromPath, args.locale, hit.locale)\n return\n }\n\n // Catch-all: does ANY configured locale serve this path? One IN-lookup covers\n // it (the query is bounded by the small, static configured-locale count).\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: { in: [...args.configuredLocales] },\n })\n if (hit) throw new RedirectServesContentError(fromPath, null, hit.locale)\n}\n\n/**\n * Rule (c) — collapse a `path`-kind target that itself points at another manual\n * redirect down to that redirect's (already-collapsed) target, so a stored `path`\n * target always points at CONTENT, never at another redirect row. \"Max one hop by\n * construction\" (phase 04 §3): every stored row's target is already fully\n * collapsed by this same rule, so a SINGLE lookup suffices — no loop, no\n * cycle-guard.\n *\n * Precedence, given the redirect being saved has its own `locale`:\n * - LOCALE-SPECIFIC save — prefer an exact-locale redirect at the target path,\n * else fall back to a catch-all (`locale = null`) redirect there.\n * - CATCH-ALL save (`locale === null`) — only a catch-all redirect at the target\n * path applies (a locale-specific row can't stand in for an all-locales\n * redirect).\n *\n * Non-`path` targets (entity / url) are returned unchanged without any lookup.\n * A `path` target that points at no redirect is returned unchanged (it points at\n * content, or a dead path — not this rule's concern).\n *\n * `excludeId` — pass the row's own id on UPDATE. Without it, a `PATCH` that\n * keeps `fromPath`/`locale` unchanged and sets a self-referential `path` target\n * would have this lookup match the row's own PRE-write state (the UPDATE hasn't\n * executed yet within the same transaction), silently returning its OLD target\n * instead of surfacing as a self-reference — the caller's write appears to\n * succeed (200) while the target silently doesn't change to what was requested.\n * Excluding the row's own id makes the lookup correctly report \"no redirect at\n * the target path\", which the caller's self-redirect guard then catches.\n */\nexport async function collapsePathTarget(args: {\n target: RedirectTarget\n locale: string | null\n redirectsClient: RedirectsCollapseReader\n excludeId?: string\n}): Promise<RedirectTarget> {\n if (args.target.kind !== 'path') return args.target\n const path = normalizeRedirectPath(args.target.path)\n const idFilter = args.excludeId === undefined ? {} : { id: { ne: args.excludeId } }\n\n // Prefer an exact-locale redirect (only meaningful for a locale-specific save).\n if (args.locale !== null) {\n const exact = await args.redirectsClient.findOne({\n fromPath: path,\n locale: args.locale,\n ...idFilter,\n })\n if (exact) return exact.target\n }\n // Fall back to (or, for a catch-all save, only consider) a catch-all redirect.\n const catchAll = await args.redirectsClient.findOne({ fromPath: path, locale: null, ...idFilter })\n if (catchAll) return catchAll.target\n\n // No redirect at the target path — leave the path target as-is.\n return { kind: 'path', path }\n}\n\n/**\n * Rule for `entity`-kind targets — confirm the referenced entity TYPE is\n * registered and the referenced ID exists. A redirect to an unknown type or a\n * nonexistent document can never resolve to a useful URL, so both are rejected at\n * save (CLAUDE.md \"validate at the boundary\") rather than surfacing as a dead link\n * at request time. No-op for `url` / `path` targets. Entity targets do NOT resolve\n * to a path here — that happens at REQUEST time in PR06 (phase 04 §3).\n *\n * Throws {@link RedirectUnknownEntityError} (unregistered type) or\n * {@link RedirectTargetNotFoundError} (type ok, id missing).\n */\nexport async function assertEntityTargetValid(args: {\n target: RedirectTarget\n resolveClient: RedirectEntityClientResolver\n}): Promise<void> {\n if (args.target.kind !== 'entity') return\n const { entity, id } = args.target\n\n const client = args.resolveClient(entity)\n if (client === undefined) throw new RedirectUnknownEntityError(entity)\n\n const found = await client.findById(id)\n if (found === null || found === undefined) throw new RedirectTargetNotFoundError(entity, id)\n}\n","/**\n * Manual-redirect admin CRUD routes — declarative `defineAdminRoute` entries\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3-4; F022:\n * backend-only, no admin-ui page in this PR).\n *\n * Mounted under prefix `redirects`. The URL surface:\n *\n * GET /api/admin/redirects ?locale=&limit=&offset= — list (paginated)\n * POST /api/admin/redirects { locale, fromPath, target, status? }\n * PATCH /api/admin/redirects/:id { locale?, fromPath?, target?, status? }\n * DELETE /api/admin/redirects/:id\n * GET /api/admin/redirects/history ?entityType=&entityId=&locale= — automatic\n * (rename) redirect history for one document\n *\n * **Registration shape** (per `defineAdminRoute`'s single-segment-path\n * constraint): PATCH + DELETE embed the redirect id directly after the prefix\n * (`/redirects/<uuid>`), so they register `path: '', matchAnyPath: true` and read\n * the id from `ctx.segments[0]`. `combineAdminRoutes`' dispatcher routes any\n * unmatched sub-path under `(prefix, method)` through the single catch-all when\n * no exact `path` claims `segments[0]` (see `makeDispatcher`). GET registers two\n * EXACT entries — `''` (list) and `'history'` — which take precedence over any\n * catch-all and never collide. POST registers `''` (create) only.\n *\n * **Authorization model:** every route gates on the SINGLE `redirect:manage`\n * permission (phase 04 §4 — one permission for all CRUD, not a per-verb split).\n * `defaultRoles: ['admin']`. The wrapper (`guardedHandler`) emits the\n * `permission.denied` audit on every 403 uniformly.\n *\n * **DB access** is resolved at REQUEST time (never at factory/module scope —\n * `getApp()` throws before boot; the factory runs at config-load under jiti/tsx).\n * Create / update / delete each run inside ONE `db.transaction` so the\n * `toolkit_redirects` row write and its `entity_refs` bookkeeping (`./refs.ts`)\n * commit — or roll back — atomically.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n type AuthUser,\n combineAdminRoutes,\n defineAdminRoute,\n safeAudit,\n} from '@murumets-ee/core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { z } from 'zod'\nimport { getContentConfig } from '../content-config-store.js'\nimport { isUniqueViolation } from '../paths/apply-path.js'\nimport { toolkitPathsTable } from '../paths/schema.js'\nimport {\n RedirectReservedPrefixError,\n RedirectSelfReferenceError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport { clearRedirectRefs, writeRedirectRef } from './refs.js'\nimport { toolkitRedirectsTable } from './schema.js'\nimport { type RedirectTarget, redirectTargetSchema } from './target.js'\nimport {\n assertEntityTargetValid,\n assertFromPathNotReserved,\n assertFromPathNotServingContent,\n collapsePathTarget,\n normalizeRedirectPath,\n type RedirectEntityClientResolver,\n} from './validation.js'\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n/** `toolkit_paths.entity_type` is `varchar(100)`; same shape as queue's type filter. */\nconst ENTITY_TYPE_RE = /^[a-zA-Z0-9:_.-]{1,100}$/\n\n/** ONE permission gates every redirect CRUD verb (phase 04 §4). */\nconst REDIRECT_PERMISSION = 'redirect:manage'\nconst REDIRECT_DEFAULT_ROLES = ['admin'] as const\n\n// ---------------------------------------------------------------------------\n// HTTP helpers (same shape as queue/src/admin.ts)\n// ---------------------------------------------------------------------------\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction errorJson(message: string, status: number): Response {\n return json({ error: message }, status)\n}\n\nasync function parseJsonBody<S extends z.ZodTypeAny>(\n req: Request,\n schema: S,\n): Promise<{ data: z.infer<S> } | { response: Response }> {\n let raw: unknown\n try {\n raw = await req.json()\n } catch {\n return { response: errorJson('Invalid JSON body', 400) }\n }\n const result = schema.safeParse(raw)\n if (!result.success) {\n const issue = result.error.issues[0]\n const message = issue ? `${issue.path.join('.') || 'body'}: ${issue.message}` : 'Invalid body'\n return { response: errorJson(message, 400) }\n }\n return { data: result.data }\n}\n\n/**\n * Strict uint query-param parse — same regex + `Number.isSafeInteger` guard as\n * queue's `parseUintParam` (plain `Number()` silently coerces blanks / hex /\n * exponent / signed input; a long digit string overflows to `Infinity`).\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n/** Pagination offset cap — a deep `?offset=` forces a scan-and-discard pass. */\nconst MAX_OFFSET = 100_000\n/** History is inherently bounded (PR04 caps rename history per doc/locale), but keep a hard ceiling. */\nconst HISTORY_LIMIT = 100\n\nfunction auditActor(user: AuthUser): { userId: string; userName?: string } {\n return user.name === undefined ? { userId: user.id } : { userId: user.id, userName: user.name }\n}\n\n// ---------------------------------------------------------------------------\n// App / DB access — deferred to request time (getApp throws before boot; the\n// factory runs at config-load). Mirrors queue/src/admin.ts's getDb().\n// ---------------------------------------------------------------------------\n\nasync function getAppInstance() {\n const { getApp } = await import('@murumets-ee/core')\n return getApp()\n}\n\nasync function getDb(): Promise<PostgresJsDatabase> {\n return (await getAppInstance()).db.readWrite\n}\n\n/**\n * Build the `entity`-target existence resolver from the live app registry:\n * `app.entities.get(name)` → `app.getClient(entity)`. Passed into\n * `assertEntityTargetValid`, keeping `./validation.ts` a leaf (no core import).\n */\nasync function buildEntityResolver(): Promise<RedirectEntityClientResolver> {\n const app = await getAppInstance()\n return (name) => {\n const entity = app.entities.get(name)\n return entity ? app.getClient(entity) : undefined\n }\n}\n\n/**\n * Drizzle's transaction handle is structurally a `PgDatabase` but its generic\n * parameters don't line up with the `PostgresJsDatabase` alias `makeClient` /\n * `writeRedirectRef` expect, so the tx handle is cast at this single boundary —\n * the exact pattern `@murumets-ee/db`'s own `TableClient.transaction` uses\n * (`innerDb as unknown as PostgresJsDatabase`). One cast per transaction.\n */\ntype DrizzleTx = Parameters<Parameters<PostgresJsDatabase['transaction']>[0]>[0]\nfunction txAsDb(tx: DrizzleTx): PostgresJsDatabase {\n return tx as unknown as PostgresJsDatabase\n}\n\n// ---------------------------------------------------------------------------\n// Request-body schemas — validated at the boundary (CLAUDE.md). `createdBy` is\n// NEVER accepted from the body; it's set server-side from ctx.user.id.\n// ---------------------------------------------------------------------------\n\n/** `301 | 302` only. */\nconst statusSchema = z.union([z.literal(301), z.literal(302)])\n\n/** Locale-relative internal path — leading '/', bounded, same convention as `target.ts`'s `path` arm. */\nconst fromPathSchema = z.string().min(1).max(2048).startsWith('/')\n\nconst createBodySchema = z.object({\n /** BCP47 locale, or `null` = catch-all (applies to all locales). Membership vs. configured locales is checked in the handler. */\n locale: z.string().min(1).nullable(),\n fromPath: fromPathSchema,\n target: redirectTargetSchema,\n status: statusSchema.default(301),\n})\n\nconst updateBodySchema = z.object({\n locale: z.string().min(1).nullable().optional(),\n fromPath: fromPathSchema.optional(),\n target: redirectTargetSchema.optional(),\n status: statusSchema.optional(),\n})\n\n/**\n * Whitelist a redirect's locale against the CONFIGURED content locales\n * (deny-by-default; CLAUDE.md). `null` (catch-all) is always allowed. Returns a\n * 400 Response on rejection, or `null` when the locale is acceptable.\n */\nfunction rejectUnconfiguredLocale(locale: string | null): Response | null {\n if (locale === null) return null\n const configured = getContentConfig().locales.map((l) => l.code)\n if (!configured.includes(locale)) {\n return errorJson(`Unknown locale \"${locale}\" — not one of the configured content locales.`, 400)\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Error → HTTP status mapping. Conflict-with-existing-state errors → 409\n// (matches the codebase convention: ReferencedEntityError / CrossLocaleWriteError\n// / RestoreConflictError all → 409). Bad-target-in-payload → 400 (matches\n// EntityCapabilityError / InvalidLocaleError → 400). A 23505 escaping the tx (the\n// DB-level partial-unique backstop firing on a race) → 409, never a raw 500.\n// Anything else is rethrown so the api-handler's central catch logs + 500s.\n// ---------------------------------------------------------------------------\n\nfunction mapRedirectError(err: unknown): Response {\n if (err instanceof RedirectReservedPrefixError) {\n return json({ error: err.message, fromPath: err.fromPath, prefix: err.prefix }, 409)\n }\n if (err instanceof RedirectServesContentError) {\n return json(\n {\n error: err.message,\n fromPath: err.fromPath,\n locale: err.locale,\n conflictingLocale: err.conflictingLocale,\n },\n 409,\n )\n }\n if (err instanceof RedirectSelfReferenceError) {\n return json({ error: err.message, fromPath: err.fromPath, locale: err.locale }, 409)\n }\n if (err instanceof RedirectUnknownEntityError) {\n return json({ error: err.message, entity: err.entity }, 400)\n }\n if (err instanceof RedirectTargetNotFoundError) {\n return json({ error: err.message, entity: err.entity, id: err.id }, 400)\n }\n if (isUniqueViolation(err)) {\n return json({ error: 'A redirect already exists for this locale + source path.' }, 409)\n }\n // Unknown — let the api-handler's central catch log it and return 500.\n throw err\n}\n\n// ---------------------------------------------------------------------------\n// Core write path — shared by create + update. Runs the full save-time\n// validation chain (normalize → reserved-prefix → entity-target → serves-content\n// → collapse → self-redirect guard) then writes the row + entity_refs inside ONE\n// transaction. Returns the written row, or `null` when an UPDATE matched no row.\n// ---------------------------------------------------------------------------\n\n/** The stored redirect row (typed via the DefinedTable's client). */\ntype RedirectRow = Awaited<\n ReturnType<ReturnType<typeof toolkitRedirectsTable.makeClient>['insert']>\n>\n\nasync function runRedirectWrite(args: {\n /** `null` = create; a UUID = update that id. */\n id: string | null\n locale: string | null\n fromPath: string\n target: RedirectTarget\n status: number\n userId: string\n resolveClient: RedirectEntityClientResolver\n}): Promise<RedirectRow | null> {\n const cfg = getContentConfig()\n const reservedPathPrefixes = cfg.reservedPathPrefixes ?? []\n const configuredLocales = cfg.locales.map((l) => l.code)\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n // 1. Reserved-prefix rejection (pure string check; security).\n assertFromPathNotReserved({ fromPath, reservedPathPrefixes })\n\n // 2. Entity-target existence — a non-tx AdminClient read (soft pre-check; the\n // row it validates isn't part of this write's transaction).\n await assertEntityTargetValid({ target: args.target, resolveClient: args.resolveClient })\n\n const db = await getDb()\n return db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n const redirectsClient = toolkitRedirectsTable.makeClient(tx)\n const pathsClient = toolkitPathsTable.makeClient(tx)\n\n // 3. \"That URL serves content\" rejection — reads toolkit_paths in-tx.\n await assertFromPathNotServingContent({\n fromPath,\n locale: args.locale,\n configuredLocales,\n pathsClient,\n })\n\n // 4. Collapse a path-kind target that points at another redirect (one hop).\n // excludeId (update only) keeps the row's own pre-write state from\n // masking a self-referential collapse — see collapsePathTarget's doc.\n const collapsed = await collapsePathTarget({\n target: args.target,\n locale: args.locale,\n redirectsClient,\n ...(args.id === null ? {} : { excludeId: args.id }),\n })\n\n // 5. Self-redirect guard — a collapsed path target that points back at this\n // very from_path (same locale) is a no-op loop; reject rather than store.\n if (collapsed.kind === 'path' && normalizeRedirectPath(collapsed.path) === fromPath) {\n throw new RedirectSelfReferenceError(fromPath, args.locale)\n }\n\n // 6. Write the row + entity_refs bookkeeping, atomically.\n if (args.id === null) {\n const created = await redirectsClient.insert({\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n createdBy: args.userId,\n })\n await writeRedirectRef(tx, created.id, collapsed)\n return created\n }\n\n const updated = await redirectsClient.update(\n { id: args.id },\n {\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n updatedAt: new Date(),\n },\n )\n if (!updated) return null // row vanished between the pre-tx fetch and here.\n // Delete-then-insert refs (not diff) — matches ./refs.ts's documented usage.\n await clearRedirectRefs(tx, args.id)\n await writeRedirectRef(tx, args.id, collapsed)\n return updated\n })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleList: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const localeParam = url.searchParams.get('locale')\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 50), 1), 200)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitRedirectsTable.makeClient(await getDb())\n const items =\n localeFilter !== undefined\n ? await client.findMany({\n where: { locale: localeFilter },\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n : await client.findMany({\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n const total =\n localeFilter !== undefined ? await client.count({ locale: localeFilter }) : await client.count()\n\n return json({ items, total })\n}\n\nconst handleCreate: AdminRouteHandler = async (req, ctx) => {\n const parsed = await parseJsonBody(req, createBodySchema)\n if ('response' in parsed) return parsed.response\n const body = parsed.data\n\n const localeRejected = rejectUnconfiguredLocale(body.locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id: null,\n locale: body.locale,\n fromPath: body.fromPath,\n target: body.target,\n status: body.status,\n userId: ctx.user.id,\n resolveClient,\n })\n // Create always returns a row (insert never matches \"nothing\").\n if (!row) return errorJson('Failed to create redirect', 500)\n safeAudit(ctx, {\n action: 'redirect.created',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row }, 201)\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleUpdate: AdminRouteHandler = async (req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const parsed = await parseJsonBody(req, updateBodySchema)\n if ('response' in parsed) return parsed.response\n const patch = parsed.data\n\n // Fetch the existing row so a partial PATCH merges onto its current state.\n const existing = await toolkitRedirectsTable.makeClient(await getDb()).findOne({ id })\n if (!existing) return errorJson('Not found', 404)\n\n // Merge — only provided keys change. `locale` may be explicitly `null`\n // (switch to catch-all), so key-presence (!== undefined), not truthiness,\n // decides whether to keep the existing value.\n const locale = patch.locale !== undefined ? patch.locale : existing.locale\n const fromPath = patch.fromPath !== undefined ? patch.fromPath : existing.fromPath\n const target = patch.target !== undefined ? patch.target : existing.target\n const status = patch.status !== undefined ? patch.status : existing.status\n\n // Re-validate the RESULTING merged state (e.g. changing only `locale` can\n // newly conflict with content served in the new locale).\n const localeRejected = rejectUnconfiguredLocale(locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id,\n locale,\n fromPath,\n target,\n status,\n userId: ctx.user.id,\n resolveClient,\n })\n if (!row) return errorJson('Not found', 404)\n safeAudit(ctx, {\n action: 'redirect.updated',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row })\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleDelete: AdminRouteHandler = async (_req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const db = await getDb()\n const deleted = await db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n // Clear refs then delete, same tx — no dangling entity_refs row survives.\n await clearRedirectRefs(tx, id)\n return toolkitRedirectsTable.makeClient(tx).delete({ id })\n })\n if (!deleted) return errorJson('Not found', 404)\n\n safeAudit(ctx, {\n action: 'redirect.deleted',\n entityType: 'redirect',\n entityId: id,\n ...auditActor(ctx.user),\n metadata: { locale: deleted.locale, fromPath: deleted.fromPath },\n })\n return json({ ok: true })\n}\n\n/**\n * Read-only automatic-redirect history for ONE document — the demoted\n * (`is_canonical = false`) `toolkit_paths` rows a rename leaves behind.\n * `entityType` + `entityId` are REQUIRED; `locale` optional. No write path.\n */\nconst handleHistory: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const entityType = url.searchParams.get('entityType')\n const entityId = url.searchParams.get('entityId')\n if (!entityType || !ENTITY_TYPE_RE.test(entityType)) {\n return errorJson('entityType is required', 400)\n }\n if (!entityId || !UUID_RE.test(entityId)) {\n return errorJson('entityId is required and must be a UUID', 400)\n }\n\n const localeParam = url.searchParams.get('locale')\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitPathsTable.makeClient(await getDb())\n const items = await client.findMany({\n where: {\n entityType,\n entityId,\n isCanonical: false,\n ...(localeFilter !== undefined && { locale: localeFilter }),\n },\n orderBy: [{ column: 'updatedAt', dir: 'desc' }],\n limit: HISTORY_LIMIT,\n })\n return json({ items })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the manual-redirect admin API routes.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry for prefix\n * `'redirects'` with GET/POST/PATCH/DELETE handlers. Every entry gates on\n * `redirect:manage` (defaultRoles `['admin']`); the permission is\n * auto-registered into the catalog by `defineAdminRoute`.\n */\nexport function redirectRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'List manual URL redirects (paginated, optional locale filter).',\n handler: handleList,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: 'history',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Read-only automatic-redirect (rename) history for one document.',\n handler: handleHistory,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'POST',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Create a manual URL redirect.',\n handler: handleCreate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'PATCH',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Update a manual URL redirect by id.',\n handler: handleUpdate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'DELETE',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Delete a manual URL redirect by id.',\n handler: handleDelete,\n }),\n ])\n}\n"],"mappings":"0bA2DA,MAAa,EAA0B,eAC1B,GAA6B,CAAC,QAAS,QAAQ,EAkB5D,SAASA,EAAK,EAA+C,EAAS,IAAe,CACnF,OAAO,IAAI,SAAS,KAAK,UAAU,CAAI,EAAG,CACxC,SACA,QAAS,CAAE,eAAgB,kBAAmB,CAChD,CAAC,CACH,CAEA,MAAM,EAA4B,CAAE,KAAM,MAAO,EASjD,SAAS,EAAa,EAA0D,CAQ9E,OAPI,IAAQ,MAAQ,EAAI,SAAW,EAAU,CAAE,MAAO,gCAAiC,EACnF,EAAI,OAAS,KAAwB,CAAE,MAAO,gCAAiC,EAC9E,EAAI,WAAW,GAAG,EACnB,EAAI,WAAW,IAAI,EAAU,CAAE,MAAO,kDAAmD,EACzF,EAAI,SAAS,GAAG,GAAK,EAAI,SAAS,GAAG,EAChC,CAAE,MAAO,yDAA0D,EAErE,CAAE,KAAM,CAAI,EALc,CAAE,MAAO,wCAAyC,CAMrF,CAyBA,SAAS,EACP,EACA,EACwC,CACxC,IAAM,EAAS,EAAiB,EAC1B,EAAY,GAAO,GAAY,EAAO,cAO5C,OANgB,EAAW,CAAC,EAAE,kBAAoB,EAAO,QAAQ,IAAK,GAAM,EAAE,IAAI,EAAA,CACrE,SAAS,CAAS,EAKxB,CAAE,OAAQ,CAAU,EAFlB,CAAE,MAAO,8CAA+C,CAGnE,CAYA,MAAM,EAAmC,MAAO,EAAK,IAAQ,CAC3D,IAAM,EAAS,IAAI,IAAI,EAAI,GAAG,CAAC,CAAC,aAE1B,EAAa,EAAa,EAAO,IAAI,MAAM,CAAC,EAClD,GAAI,UAAW,EAAY,OAAOA,EAAK,CAAE,MAAO,EAAW,KAAM,EAAG,GAAG,EACvE,IAAM,EAAe,EAAqB,EAAO,IAAI,QAAQ,EAAG,EAAI,QAAU,EAAI,aAAa,EAC/F,GAAI,UAAW,EAAc,OAAOA,EAAK,CAAE,MAAO,EAAa,KAAM,EAAG,GAAG,EAC3E,GAAM,CAAE,UAAW,EAEb,CAAE,oBAAqB,MAAM,OAAO,iCAAuB,CAAA,KAAA,GAAA,EAAA,CAAA,EAC3D,EAAO,EAAiB,EAAI,GAAG,EAI/B,GAAW,EAAoB,IACnC,EAAI,gBAAgB,EAAY,MAAM,EAAI,CAAE,KAAM,WAAY,aAAY,IAAG,EAAI,EAE7E,EAAS,MAAM,EAAiB,EAAW,KAAM,EAAQ,CAAI,EACnE,GAAI,EAAO,OAAS,WAAY,OAAOA,EAAK,EAAQ,EAAO,WAAY,EAAO,EAAE,CAAC,EACjF,GAAI,EAAO,OAAS,OAAQ,OAAOA,EAAK,CAAI,EAS5C,GAAI,EADa,EAAO,GAAG,WAAW,GAAG,GAAK,CAAC,EAAO,GAAG,WAAW,IAAI,GACzD,OAAOA,EAAK,CAAE,KAAM,WAAY,GAAI,EAAO,EAAG,CAAC,EAC9D,IAAM,EAAM,MAAM,EAAiB,EAAO,GAAI,EAAQ,CAAI,EAC1D,OAAOA,EAAK,EAAI,OAAS,WAAa,EAAQ,EAAI,WAAY,EAAI,EAAE,EAAI,CAAI,CAC9E,EAKA,SAAgB,GAA2B,CACzC,OAAO,EAAmB,CACxB,EAAiB,CACf,OAAQ,QACR,KAAM,UACN,OAAQ,MACR,WAAY,EACZ,aAAc,GACd,YACE,8FACF,QAAS,CACX,CAAC,CACH,CAAC,CACH,CCtLA,IAAa,EAAb,cAAiD,KAAM,CACrD,SACA,OAEA,YAAY,EAAkB,EAAgB,CAC5C,MACE,oBAAoB,EAAS,qCAAqC,EAAO,kEAE3E,EACA,KAAK,KAAO,8BACZ,KAAK,SAAW,EAChB,KAAK,OAAS,CAChB,CACF,EAYa,EAAb,cAAgD,KAAM,CACpD,SACA,OACA,kBAEA,YAAY,EAAkB,EAAuB,EAA2B,CAC9E,MACE,oBAAoB,EAAS,sCAAsC,EAAkB,uGAGvF,EACA,KAAK,KAAO,6BACZ,KAAK,SAAW,EAChB,KAAK,OAAS,EACd,KAAK,kBAAoB,CAC3B,CACF,EAOa,EAAb,cAAgD,KAAM,CACpD,OAEA,YAAY,EAAgB,CAC1B,MACE,2CAA2C,EAAO,4DAEpD,EACA,KAAK,KAAO,6BACZ,KAAK,OAAS,CAChB,CACF,EAQa,EAAb,cAAiD,KAAM,CACrD,OACA,GAEA,YAAY,EAAgB,EAAY,CACtC,MACE,mBAAmB,EAAO,IAAI,EAAG,wFAEnC,EACA,KAAK,KAAO,8BACZ,KAAK,OAAS,EACd,KAAK,GAAK,CACZ,CACF,EAca,EAAb,cAAgD,KAAM,CACpD,SACA,OAEA,YAAY,EAAkB,EAAuB,CACnD,MACE,oBAAoB,EAAS,GAAG,IAAW,KAAO,GAAK,aAAa,EAAO,IAAI,2GAGjF,EACA,KAAK,KAAO,6BACZ,KAAK,SAAW,EAChB,KAAK,OAAS,CAChB,CACF,EC7FA,MAAM,EAAgB,oBAgBtB,eAAsB,EACpB,EACA,EACA,EACe,CACX,EAAO,OAAS,UACpB,MAAM,EACH,OAAO,CAAU,CAAC,CAClB,OAAO,CACN,aAAc,EACd,SAAU,EACV,YAAa,SACb,aAAc,EAAO,OACrB,SAAU,EAAO,GACjB,aAAc,MAChB,CAAC,CAAC,CACD,oBAAoB,CACzB,CAWA,eAAsB,EACpB,EACA,EACe,CACf,MAAM,EACH,OAAO,CAAU,CAAC,CAClB,MAAM,EAAI,EAAG,EAAW,aAAc,CAAa,EAAG,EAAG,EAAW,SAAU,CAAU,CAAC,CAAC,CAC/F,CC7CA,MAAa,EAAuB,EAAE,mBAAmB,OAAQ,CAC/D,EAAE,OAAO,CACP,KAAM,EAAE,QAAQ,QAAQ,EAExB,OAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,EAExB,GAAI,EAAE,OAAO,CAAC,CAAC,KAAK,CACtB,CAAC,EACD,EAAE,OAAO,CACP,KAAM,EAAE,QAAQ,KAAK,EAErB,IAAK,EAAE,OAAO,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,CAChC,CAAC,EACD,EAAE,OAAO,CACP,KAAM,EAAE,QAAQ,MAAM,EAEtB,KAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,WAAW,GAAG,CAClD,CAAC,CACH,CAAC,EChBD,SAAgB,EAAsB,EAAsB,CAC1D,OAAO,EAAc,CAAI,CAC3B,CAkDA,SAAgB,GAA0B,EAGjC,CACP,IAAM,EAAW,EAAsB,EAAK,QAAQ,EAC9C,EAAW,EAAoB,EAAU,EAAK,oBAAoB,EACxE,GAAI,IAAa,IAAA,GACf,MAAM,IAAI,EAA4B,EAAU,CAAQ,CAE5D,CAgBA,eAAsB,GAAgC,EAKpC,CAChB,IAAM,EAAW,EAAsB,EAAK,QAAQ,EAEpD,GAAI,EAAK,SAAW,KAAM,CACxB,IAAM,EAAM,MAAM,EAAK,YAAY,QAAQ,CACzC,KAAM,EACN,YAAa,GACb,OAAQ,EAAK,MACf,CAAC,EACD,GAAI,EAAK,MAAM,IAAI,EAA2B,EAAU,EAAK,OAAQ,EAAI,MAAM,EAC/E,MACF,CAIA,IAAM,EAAM,MAAM,EAAK,YAAY,QAAQ,CACzC,KAAM,EACN,YAAa,GACb,OAAQ,CAAE,GAAI,CAAC,GAAG,EAAK,iBAAiB,CAAE,CAC5C,CAAC,EACD,GAAI,EAAK,MAAM,IAAI,EAA2B,EAAU,KAAM,EAAI,MAAM,CAC1E,CA8BA,eAAsB,EAAmB,EAKb,CAC1B,GAAI,EAAK,OAAO,OAAS,OAAQ,OAAO,EAAK,OAC7C,IAAM,EAAO,EAAsB,EAAK,OAAO,IAAI,EAC7C,EAAW,EAAK,YAAc,IAAA,GAAY,CAAC,EAAI,CAAE,GAAI,CAAE,GAAI,EAAK,SAAU,CAAE,EAGlF,GAAI,EAAK,SAAW,KAAM,CACxB,IAAM,EAAQ,MAAM,EAAK,gBAAgB,QAAQ,CAC/C,SAAU,EACV,OAAQ,EAAK,OACb,GAAG,CACL,CAAC,EACD,GAAI,EAAO,OAAO,EAAM,MAC1B,CAEA,IAAM,EAAW,MAAM,EAAK,gBAAgB,QAAQ,CAAE,SAAU,EAAM,OAAQ,KAAM,GAAG,CAAS,CAAC,EAIjG,OAHI,EAAiB,EAAS,OAGvB,CAAE,KAAM,OAAQ,MAAK,CAC9B,CAaA,eAAsB,EAAwB,EAG5B,CAChB,GAAI,EAAK,OAAO,OAAS,SAAU,OACnC,GAAM,CAAE,SAAQ,MAAO,EAAK,OAEtB,EAAS,EAAK,cAAc,CAAM,EACxC,GAAI,IAAW,IAAA,GAAW,MAAM,IAAI,EAA2B,CAAM,EAGrE,GAAI,MADgB,EAAO,SAAS,CAAE,GACxB,KAA6B,MAAM,IAAI,EAA4B,EAAQ,CAAE,CAC7F,CC/JA,MAAM,EAAU,kEAEV,EAAiB,2BAGjB,EAAsB,kBACtB,EAAyB,CAAC,OAAO,EAMvC,SAAS,EAAK,EAAe,EAAS,IAAe,CACnD,OAAO,IAAI,SAAS,KAAK,UAAU,CAAI,EAAG,CACxC,SACA,QAAS,CAAE,eAAgB,kBAAmB,CAChD,CAAC,CACH,CAEA,SAAS,EAAU,EAAiB,EAA0B,CAC5D,OAAO,EAAK,CAAE,MAAO,CAAQ,EAAG,CAAM,CACxC,CAEA,eAAe,EACb,EACA,EACwD,CACxD,IAAI,EACJ,GAAI,CACF,EAAM,MAAM,EAAI,KAAK,CACvB,MAAQ,CACN,MAAO,CAAE,SAAU,EAAU,oBAAqB,GAAG,CAAE,CACzD,CACA,IAAM,EAAS,EAAO,UAAU,CAAG,EACnC,GAAI,CAAC,EAAO,QAAS,CACnB,IAAM,EAAQ,EAAO,MAAM,OAAO,GAElC,MAAO,CAAE,SAAU,EADH,EAAQ,GAAG,EAAM,KAAK,KAAK,GAAG,GAAK,OAAO,IAAI,EAAM,UAAY,eAC1C,GAAG,CAAE,CAC7C,CACA,MAAO,CAAE,KAAM,EAAO,IAAK,CAC7B,CAOA,SAAS,EAAe,EAAoB,EAA0B,CACpE,GAAI,IAAQ,MAAQ,CAAC,QAAQ,KAAK,CAAG,EAAG,OAAO,EAC/C,IAAM,EAAS,OAAO,CAAG,EAEzB,OADK,OAAO,cAAc,CAAM,EACzB,EADmC,CAE5C,CAOA,SAAS,EAAW,EAAuD,CACzE,OAAO,EAAK,OAAS,IAAA,GAAY,CAAE,OAAQ,EAAK,EAAG,EAAI,CAAE,OAAQ,EAAK,GAAI,SAAU,EAAK,IAAK,CAChG,CAOA,eAAe,GAAiB,CAC9B,GAAM,CAAE,UAAW,MAAM,OAAO,qBAChC,OAAO,EAAO,CAChB,CAEA,eAAe,GAAqC,CAClD,OAAQ,MAAM,EAAe,EAAA,CAAG,GAAG,SACrC,CAOA,eAAe,GAA6D,CAC1E,IAAM,EAAM,MAAM,EAAe,EACjC,MAAQ,IAAS,CACf,IAAM,EAAS,EAAI,SAAS,IAAI,CAAI,EACpC,OAAO,EAAS,EAAI,UAAU,CAAM,EAAI,IAAA,EAC1C,CACF,CAUA,SAAS,EAAO,EAAmC,CACjD,OAAO,CACT,CAQA,MAAM,EAAe,EAAE,MAAM,CAAC,EAAE,QAAQ,GAAG,EAAG,EAAE,QAAQ,GAAG,CAAC,CAAC,EAGvD,EAAiB,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,WAAW,GAAG,EAE3D,EAAmB,EAAE,OAAO,CAEhC,OAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,EACnC,SAAU,EACV,OAAQ,EACR,OAAQ,EAAa,QAAQ,GAAG,CAClC,CAAC,EAEK,EAAmB,EAAE,OAAO,CAChC,OAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,SAAS,EAC9C,SAAU,EAAe,SAAS,EAClC,OAAQ,EAAqB,SAAS,EACtC,OAAQ,EAAa,SAAS,CAChC,CAAC,EAOD,SAAS,EAAyB,EAAwC,CAMxE,OALI,IAAW,MACI,EAAiB,CAAC,CAAC,QAAQ,IAAK,GAAM,EAAE,IAC7C,CAAC,CAAC,SAAS,CAAM,EAFH,KAGnB,EAAU,mBAAmB,EAAO,gDAAiD,GAAG,CAGnG,CAWA,SAAS,EAAiB,EAAwB,CAChD,GAAI,aAAe,EACjB,OAAO,EAAK,CAAE,MAAO,EAAI,QAAS,SAAU,EAAI,SAAU,OAAQ,EAAI,MAAO,EAAG,GAAG,EAErF,GAAI,aAAe,EACjB,OAAO,EACL,CACE,MAAO,EAAI,QACX,SAAU,EAAI,SACd,OAAQ,EAAI,OACZ,kBAAmB,EAAI,iBACzB,EACA,GACF,EAEF,GAAI,aAAe,EACjB,OAAO,EAAK,CAAE,MAAO,EAAI,QAAS,SAAU,EAAI,SAAU,OAAQ,EAAI,MAAO,EAAG,GAAG,EAErF,GAAI,aAAe,EACjB,OAAO,EAAK,CAAE,MAAO,EAAI,QAAS,OAAQ,EAAI,MAAO,EAAG,GAAG,EAE7D,GAAI,aAAe,EACjB,OAAO,EAAK,CAAE,MAAO,EAAI,QAAS,OAAQ,EAAI,OAAQ,GAAI,EAAI,EAAG,EAAG,GAAG,EAEzE,GAAI,EAAkB,CAAG,EACvB,OAAO,EAAK,CAAE,MAAO,0DAA2D,EAAG,GAAG,EAGxF,MAAM,CACR,CAcA,eAAe,EAAiB,EASA,CAC9B,IAAM,EAAM,EAAiB,EACvB,EAAuB,EAAI,sBAAwB,CAAC,EACpD,EAAoB,EAAI,QAAQ,IAAK,GAAM,EAAE,IAAI,EACjD,EAAW,EAAsB,EAAK,QAAQ,EAUpD,OAPA,GAA0B,CAAE,WAAU,sBAAqB,CAAC,EAI5D,MAAM,EAAwB,CAAE,OAAQ,EAAK,OAAQ,cAAe,EAAK,aAAc,CAAC,GAGjF,MADU,EAAM,EAAA,CACb,YAAY,KAAO,IAAa,CACxC,IAAM,EAAK,EAAO,CAAQ,EACpB,EAAkB,EAAsB,WAAW,CAAE,EACrD,EAAc,EAAkB,WAAW,CAAE,EAGnD,MAAM,GAAgC,CACpC,WACA,OAAQ,EAAK,OACb,oBACA,aACF,CAAC,EAKD,IAAM,EAAY,MAAM,EAAmB,CACzC,OAAQ,EAAK,OACb,OAAQ,EAAK,OACb,kBACA,GAAI,EAAK,KAAO,KAAO,CAAC,EAAI,CAAE,UAAW,EAAK,EAAG,CACnD,CAAC,EAID,GAAI,EAAU,OAAS,QAAU,EAAsB,EAAU,IAAI,IAAM,EACzE,MAAM,IAAI,EAA2B,EAAU,EAAK,MAAM,EAI5D,GAAI,EAAK,KAAO,KAAM,CACpB,IAAM,EAAU,MAAM,EAAgB,OAAO,CAC3C,OAAQ,EAAK,OACb,WACA,OAAQ,EACR,OAAQ,EAAK,OACb,UAAW,EAAK,MAClB,CAAC,EAED,OADA,MAAM,EAAiB,EAAI,EAAQ,GAAI,CAAS,EACzC,CACT,CAEA,IAAM,EAAU,MAAM,EAAgB,OACpC,CAAE,GAAI,EAAK,EAAG,EACd,CACE,OAAQ,EAAK,OACb,WACA,OAAQ,EACR,OAAQ,EAAK,OACb,UAAW,IAAI,IACjB,CACF,EAKA,OAJK,GAEL,MAAM,EAAkB,EAAI,EAAK,EAAE,EACnC,MAAM,EAAiB,EAAI,EAAK,GAAI,CAAS,EACtC,GAJc,IAKvB,CAAC,CACH,CAMA,MAAM,GAAgC,KAAO,IAAQ,CACnD,IAAM,EAAM,IAAI,IAAI,EAAI,GAAG,EACrB,EAAc,EAAI,aAAa,IAAI,QAAQ,EAC3C,EAAQ,KAAK,IAAI,KAAK,IAAI,EAAe,EAAI,aAAa,IAAI,OAAO,EAAG,EAAE,EAAG,CAAC,EAAG,GAAG,EACpF,EAAS,KAAK,IAAI,EAAe,EAAI,aAAa,IAAI,QAAQ,EAAG,CAAC,EAAG,GAAU,EAEjF,EACJ,GAAI,IAAgB,MAAQ,IAAgB,GAAI,CAC9C,IAAM,EAAW,EAAyB,CAAW,EACrD,GAAI,EAAU,OAAO,EACrB,EAAe,CACjB,CAEA,IAAM,EAAS,EAAsB,WAAW,MAAM,EAAM,CAAC,EAiB7D,OAAO,EAAK,CAAE,MAfZ,IAAiB,IAAA,GAOb,MAAM,EAAO,SAAS,CACpB,QAAS,CAAC,CAAE,OAAQ,YAAa,IAAK,MAAO,CAAC,EAC9C,QACA,QACF,CAAC,EAVD,MAAM,EAAO,SAAS,CACpB,MAAO,CAAE,OAAQ,CAAa,EAC9B,QAAS,CAAC,CAAE,OAAQ,YAAa,IAAK,MAAO,CAAC,EAC9C,QACA,QACF,CAAC,EASc,MAFnB,IAAiB,IAAA,GAA2D,MAAM,EAAO,MAAM,EAAlE,MAAM,EAAO,MAAM,CAAE,OAAQ,CAAa,CAAC,CAE/C,CAAC,CAC9B,EAEM,GAAkC,MAAO,EAAK,IAAQ,CAC1D,IAAM,EAAS,MAAM,EAAc,EAAK,CAAgB,EACxD,GAAI,aAAc,EAAQ,OAAO,EAAO,SACxC,IAAM,EAAO,EAAO,KAEd,EAAiB,EAAyB,EAAK,MAAM,EAC3D,GAAI,EAAgB,OAAO,EAE3B,GAAI,CACF,IAAM,EAAgB,MAAM,EAAoB,EAC1C,EAAM,MAAM,EAAiB,CACjC,GAAI,KACJ,OAAQ,EAAK,OACb,SAAU,EAAK,SACf,OAAQ,EAAK,OACb,OAAQ,EAAK,OACb,OAAQ,EAAI,KAAK,GACjB,eACF,CAAC,EAUD,OARK,GACL,EAAU,EAAK,CACb,OAAQ,mBACR,WAAY,WACZ,SAAU,EAAI,GACd,GAAG,EAAW,EAAI,IAAI,EACtB,SAAU,CAAE,OAAQ,EAAI,OAAQ,SAAU,EAAI,SAAU,WAAY,EAAI,OAAO,IAAK,CACtF,CAAC,EACM,EAAK,CAAE,KAAM,CAAI,EAAG,GAAG,GARb,EAAU,4BAA6B,GAAG,CAS7D,OAAS,EAAK,CACZ,OAAO,EAAiB,CAAG,CAC7B,CACF,EAEM,GAAkC,MAAO,EAAK,IAAQ,CAC1D,IAAM,EAAK,EAAI,SAAS,GACxB,GAAI,CAAC,GAAM,CAAC,EAAQ,KAAK,CAAE,EAAG,OAAO,EAAU,aAAc,GAAG,EAEhE,IAAM,EAAS,MAAM,EAAc,EAAK,CAAgB,EACxD,GAAI,aAAc,EAAQ,OAAO,EAAO,SACxC,IAAM,EAAQ,EAAO,KAGf,EAAW,MAAM,EAAsB,WAAW,MAAM,EAAM,CAAC,CAAC,CAAC,QAAQ,CAAE,IAAG,CAAC,EACrF,GAAI,CAAC,EAAU,OAAO,EAAU,YAAa,GAAG,EAKhD,IAAM,EAAS,EAAM,SAAW,IAAA,GAA2B,EAAS,OAAxB,EAAM,OAC5C,EAAW,EAAM,WAAa,IAAA,GAA6B,EAAS,SAA1B,EAAM,SAChD,EAAS,EAAM,SAAW,IAAA,GAA2B,EAAS,OAAxB,EAAM,OAC5C,EAAS,EAAM,SAAW,IAAA,GAA2B,EAAS,OAAxB,EAAM,OAI5C,EAAiB,EAAyB,CAAM,EACtD,GAAI,EAAgB,OAAO,EAE3B,GAAI,CACF,IAAM,EAAgB,MAAM,EAAoB,EAC1C,EAAM,MAAM,EAAiB,CACjC,KACA,SACA,WACA,SACA,SACA,OAAQ,EAAI,KAAK,GACjB,eACF,CAAC,EASD,OARK,GACL,EAAU,EAAK,CACb,OAAQ,mBACR,WAAY,WACZ,SAAU,EAAI,GACd,GAAG,EAAW,EAAI,IAAI,EACtB,SAAU,CAAE,OAAQ,EAAI,OAAQ,SAAU,EAAI,SAAU,WAAY,EAAI,OAAO,IAAK,CACtF,CAAC,EACM,EAAK,CAAE,KAAM,CAAI,CAAC,GARR,EAAU,YAAa,GAAG,CAS7C,OAAS,EAAK,CACZ,OAAO,EAAiB,CAAG,CAC7B,CACF,EAEM,GAAkC,MAAO,EAAM,IAAQ,CAC3D,IAAM,EAAK,EAAI,SAAS,GACxB,GAAI,CAAC,GAAM,CAAC,EAAQ,KAAK,CAAE,EAAG,OAAO,EAAU,aAAc,GAAG,EAGhE,IAAM,EAAU,MAAM,MADL,EAAM,EAAA,CACE,YAAY,KAAO,IAAa,CACvD,IAAM,EAAK,EAAO,CAAQ,EAG1B,OADA,MAAM,EAAkB,EAAI,CAAE,EACvB,EAAsB,WAAW,CAAE,CAAC,CAAC,OAAO,CAAE,IAAG,CAAC,CAC3D,CAAC,EAUD,OATK,GAEL,EAAU,EAAK,CACb,OAAQ,mBACR,WAAY,WACZ,SAAU,EACV,GAAG,EAAW,EAAI,IAAI,EACtB,SAAU,CAAE,OAAQ,EAAQ,OAAQ,SAAU,EAAQ,QAAS,CACjE,CAAC,EACM,EAAK,CAAE,GAAI,EAAK,CAAC,GATH,EAAU,YAAa,GAAG,CAUjD,EAOM,GAAmC,KAAO,IAAQ,CACtD,IAAM,EAAM,IAAI,IAAI,EAAI,GAAG,EACrB,EAAa,EAAI,aAAa,IAAI,YAAY,EAC9C,EAAW,EAAI,aAAa,IAAI,UAAU,EAChD,GAAI,CAAC,GAAc,CAAC,EAAe,KAAK,CAAU,EAChD,OAAO,EAAU,yBAA0B,GAAG,EAEhD,GAAI,CAAC,GAAY,CAAC,EAAQ,KAAK,CAAQ,EACrC,OAAO,EAAU,0CAA2C,GAAG,EAGjE,IAAM,EAAc,EAAI,aAAa,IAAI,QAAQ,EAC7C,EACJ,GAAI,IAAgB,MAAQ,IAAgB,GAAI,CAC9C,IAAM,EAAW,EAAyB,CAAW,EACrD,GAAI,EAAU,OAAO,EACrB,EAAe,CACjB,CAaA,OAAO,EAAK,CAAE,MAAA,MAXC,EAAkB,WAAW,MAAM,EAAM,CAC/B,CAAC,CAAC,SAAS,CAClC,MAAO,CACL,aACA,WACA,YAAa,GACb,GAAI,IAAiB,IAAA,IAAa,CAAE,OAAQ,CAAa,CAC3D,EACA,QAAS,CAAC,CAAE,OAAQ,YAAa,IAAK,MAAO,CAAC,EAC9C,MAAO,GACT,CAAC,CACmB,CAAC,CACvB,EAcA,SAAgB,IAA+B,CAC7C,OAAO,EAAmB,CACxB,EAAiB,CACf,OAAQ,YACR,KAAM,GACN,OAAQ,MACR,WAAY,EACZ,aAAc,EACd,YAAa,iEACb,QAAS,EACX,CAAC,EACD,EAAiB,CACf,OAAQ,YACR,KAAM,UACN,OAAQ,MACR,WAAY,EACZ,aAAc,EACd,YAAa,kEACb,QAAS,EACX,CAAC,EACD,EAAiB,CACf,OAAQ,YACR,KAAM,GACN,OAAQ,OACR,WAAY,EACZ,aAAc,EACd,YAAa,gCACb,QAAS,EACX,CAAC,EACD,EAAiB,CACf,OAAQ,YACR,KAAM,GACN,OAAQ,QACR,aAAc,GACd,WAAY,EACZ,aAAc,EACd,YAAa,sCACb,QAAS,EACX,CAAC,EACD,EAAiB,CACf,OAAQ,YACR,KAAM,GACN,OAAQ,SACR,aAAc,GACd,WAAY,EACZ,aAAc,EACd,YAAa,sCACb,QAAS,EACX,CAAC,CACH,CAAC,CACH"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{buildEntityHref as e,matchReservedPrefix as t,resolvePattern as n}from"@murumets-ee/entity";import{column as r,defineTable as i}from"@murumets-ee/db";import{sql as a}from"drizzle-orm";var o=class extends Error{entityName;locale;path;conflictingEntityId;constructor(e,t,n,r,i){super(i??`Path "${n}" (locale "${t}") is already used by another ${e} document. Choose a different slug, or set this document's slug manually.`),this.name=`PathConflictError`,this.entityName=e,this.locale=t,this.path=n,this.conflictingEntityId=r}},s=class extends Error{entityName;path;prefix;constructor(e,t,n){super(`Path "${t}" falls under the reserved prefix "${n}" and cannot be used as a public URL for a ${e} document.`),this.name=`PathReservedPrefixError`,this.entityName=e,this.path=t,this.prefix=n}};async function c(t){let r=t.entity.frontend?.path;if(r===void 0)return{path:null,skipReason:`not-routable`};let i=n(r,t.locale,t.defaultLocale);if(i===null)return{path:null,skipReason:`no-pattern-for-locale`};let a=i;if(typeof i==`string`&&i.includes(`[parent.path]`)){let e=t.row.parent;if(typeof e==`string`&&e.length>0){let n=await t.pathsClient.findOne({entityType:t.entity.name,entityId:e,locale:t.locale,isCanonical:!0});if(!n)return{path:null,skipReason:`parent-not-indexed`};a=i.replaceAll(`[parent.path]`,n.path)}else a=i.replaceAll(`[parent.path]`,``)}let o=e(a,t.row,t.locale,t.defaultLocale);return o===null?{path:null,skipReason:`unresolved-token`}:{path:l(o)}}function l(e){let t=e.replace(/\/{2,}/g,`/`);return t.length>1&&t.endsWith(`/`)?t.slice(0,-1):t}async function u(e){let{entity:n,data:r,currentRow:i,id:a,locale:l,defaultLocale:u,slugMode:f,reservedPathPrefixes:p,pathsClient:m}=e,h=``;for(let e=0;e<50;e++){let e={...i??{},...r},g=await c({entity:n,row:e,locale:l,defaultLocale:u,pathsClient:m});if(g.path===null)return;let _=g.path;h=_;let v=t(_,p);if(v!==void 0)throw new s(n.name,_,v);let y=await m.findOne({locale:l,path:_,isCanonical:!0}),b=y!=null&&y.entityType===n.name&&y.entityId===a;if(y==null||b)return;if(f===`manual`)throw new o(n.name,l,_,y.entityId);let x=e.slug;if(typeof x!=`string`||x.length===0)throw new o(n.name,l,_,y.entityId);r.slug=d(x)}throw new o(n.name,l,h,void 0,`Could not find a free path for a ${n.name} document near "${h}" (locale "${l}") after 50 attempts — choose a different slug manually.`)}function d(e){let t=/^(.*)-(\d+)$/.exec(e);return t?`${t[1]??e}-${Number.parseInt(t[2]??`1`,10)+1}`:`${e}-2`}async function f(e){let{pathsClient:t,locale:n,path:r,logger:i}=e,a=await t.findMany({where:{locale:n,path:r},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:10});for(let e of a){let r=await t.findOne({entityType:e.entityType,entityId:e.entityId,locale:n,isCanonical:!0});if(r)return{entityType:e.entityType,entityId:e.entityId,currentPath:r.path}}return a.length===10&&i?.warn?.({locale:n,path:r,examined:a.length},`toolkit_paths: redirect lookup examined the full candidate window without resolving one — answering 404, but a further candidate may exist`),null}const p=5;async function m(e,t,n,r){return(await r.pathsClient.findOne({entityType:e,entityId:t,locale:n,isCanonical:!0}))?.path??null}function h(e){return{async isPublished(t,n,r){let i=e.entityResolver(t);return i===void 0?!1:await e.queryClientFactory(i).findById(n,{locale:r})!=null}}}function g(e,t,n){return v(e,t,n,(e,t,r)=>n.publishChecker.isPublished(e,t,r))}function _(e,t,n){return v(e,t,n,async()=>!0)}async function v(e,t,n,r){let i=l(e),a=await n.homepageReader();if(i===`/`)return a===null||!await r(a.entity,a.id,t)?{kind:`none`}:{kind:`document`,entityType:a.entity,id:a.id};let o=await n.pathsClient.findOne({locale:t,path:i,isCanonical:!0});if(o)return await r(o.entityType,o.entityId,t)?S(a,o.entityType,o.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`document`,entityType:o.entityType,id:o.entityId}:{kind:`none`};let s=await y(i,t,n,a);if(s!==null)return s;let c=await f({pathsClient:n.pathsClient,locale:t,path:i,...n.logger!==void 0&&{logger:n.logger}});return c===null||!await r(c.entityType,c.entityId,t)?{kind:`none`}:S(a,c.entityType,c.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:c.currentPath,status:301}}async function y(e,t,n,r){let i=await b(n.redirectsClient,e,t);if(i===null)return null;for(let e=0;e<5;e++){let e=i.target,a=C(i.status);if(e.kind===`url`)return{kind:`redirect`,to:e.url,status:a};if(e.kind===`entity`){if(S(r,e.entity,e.id))return{kind:`redirect`,to:`/`,status:a};let i=await m(e.entity,e.id,t,n);return i===null?{kind:`none`}:{kind:`redirect`,to:i,status:a}}let o=l(e.path),s=await b(n.redirectsClient,o,t);if(s!==null){i=s;continue}let c=await n.pathsClient.findOne({locale:t,path:o,isCanonical:!0});return c?S(r,c.entityType,c.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:o,status:a}:{kind:`none`}}return{kind:`none`}}async function b(e,t,n){return await e.findOne({fromPath:t,locale:n})||e.findOne({fromPath:t,locale:null})}async function x(e,t,n){let r=[];for(let i of n.locales){let a=await m(e,t,i,n);a!==null&&await n.publishChecker.isPublished(e,t,i)&&r.push({locale:i,path:a})}return r}function S(e,t,n){return e!==null&&e.entity===t&&e.id===n}function C(e){return e===302?302:301}const w=i({name:`toolkit_redirects`,columns:{id:r.uuid({primaryKey:!0,defaultRandom:!0}),locale:r.varchar({length:10}),fromPath:r.text({notNull:!0,pgName:`from_path`}),target:r.jsonb({notNull:!0}),status:r.integer({notNull:!0,default:301}),createdBy:r.varchar({length:255,pgName:`created_by`}),createdAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`locale`,`fromPath`],where:e=>a`${e.locale} IS NOT NULL`},{on:[`fromPath`],where:e=>a`${e.locale} IS NULL`}]}),T=w.table;export{x as a,g as c,l as d,h as i,c as l,w as n,m as o,p as r,_ as s,T as t,u};
|
|
2
|
+
//# sourceMappingURL=schema-0zayzpD_.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-0zayzpD_.mjs","names":[],"sources":["../src/paths/errors.ts","../src/paths/generate-path.ts","../src/paths/redirect-target.ts","../src/paths/resolve.ts","../src/redirects/schema.ts"],"sourcesContent":["/**\n * Domain errors thrown by the pre-commit `pathConflictGuard` (plan/routing PR03,\n * F010). Distinct classes so the admin-ui API handler can map them to a 4xx\n * (caller-actionable) rather than a generic 500, and so callers can branch on\n * `instanceof`.\n */\n\n/**\n * A document's candidate canonical URL is already claimed by a DIFFERENT\n * document in the same locale, and it can't be auto-resolved: either the slug\n * is manually pinned (`slug_mode = 'manual'`), or the auto-suffix ladder was\n * exhausted / there is no slug to suffix. The write is aborted before commit.\n */\nexport class PathConflictError extends Error {\n readonly entityName: string\n readonly locale: string\n readonly path: string\n /** The id of the document already holding `(locale, path)`, when known. */\n readonly conflictingEntityId: string | undefined\n\n constructor(\n entityName: string,\n locale: string,\n path: string,\n conflictingEntityId?: string,\n message?: string,\n ) {\n super(\n message ??\n `Path \"${path}\" (locale \"${locale}\") is already used by another ${entityName} document. ` +\n `Choose a different slug, or set this document's slug manually.`,\n )\n this.name = 'PathConflictError'\n this.entityName = entityName\n this.locale = locale\n this.path = path\n this.conflictingEntityId = conflictingEntityId\n }\n}\n\n/**\n * A document's resolved canonical URL falls under a reserved path prefix\n * (`reservedPathPrefixes`, e.g. `/admin` or `/api`). Allowing it would make the\n * document reachable under a route the framework owns, so the write is aborted\n * before commit — for the WRITE's OWN locale only (F010's one-locale-per-write\n * scope; the boot-time lint only WARNs, and only on statically-analysable\n * patterns). A reserved-prefix hit in a DIFFERENT locale than the one being\n * written (e.g. a base-row reparent that only collides for a non-default\n * locale) isn't caught here — `pathIndexWriter`'s post-commit pass screens\n * every other locale too and refuses to persist a reserved path, but by then\n * the entity write has already committed (F013).\n */\nexport class PathReservedPrefixError extends Error {\n readonly entityName: string\n readonly path: string\n readonly prefix: string\n\n constructor(entityName: string, path: string, prefix: string) {\n super(\n `Path \"${path}\" falls under the reserved prefix \"${prefix}\" and cannot be used as a ` +\n `public URL for a ${entityName} document.`,\n )\n this.name = 'PathReservedPrefixError'\n this.entityName = entityName\n this.path = path\n this.prefix = prefix\n }\n}\n","/**\n * Shared path-generation + conflict logic (plan/routing PR03, Part 3).\n *\n * `computeCanonicalPath` resolves a routable row to its locale-relative\n * canonical URL — reusing `buildEntityHref` (the PR02-shipped token engine) and\n * expanding the `[parent.path]` token against the parent's own indexed path\n * (F004's PR03 payoff). Used by BOTH seams:\n * - `pathIndexWriter` (post-commit) — compute + upsert.\n * - `pathConflictGuard` (pre-commit, via `ensurePathAvailable`) — compute +\n * check + maybe-mutate-and-rethrow.\n *\n * so the pattern-resolution + parent-expansion logic exists exactly once.\n *\n * NOTE on the signature vs. the task brief: the brief typed\n * `computeCanonicalPath` as synchronous (`: string | null`), but the\n * `[parent.path]` expansion requires a `toolkit_paths` lookup, which is async —\n * so both functions are `Promise`-returning and take the `ToolkitPathsClient`.\n * (Same shape of necessary deviation as Task 1's `defineTable` `where`\n * predicate, which the brief typed as `SQL` but had to widen to a callback.)\n */\n\nimport {\n buildEntityHref,\n type Entity,\n matchReservedPrefix,\n resolvePattern,\n} from '@murumets-ee/entity'\nimport type { PathSkipReason } from './apply-path.js'\nimport { PathConflictError, PathReservedPrefixError } from './errors.js'\nimport type { ToolkitPathsClient } from './schema.js'\n\n/** Hard cap on auto-suffix attempts — a pathological run of taken slugs throws\n * rather than looping forever (matches the repo's no-unbounded-work posture,\n * even though this is a single-document loop, not a fan-out). */\nconst MAX_SUFFIX_ATTEMPTS = 50\n\n/** {@link computeCanonicalPath}'s result — a computed path, or `null` plus WHY (F075). */\nexport type ComputeCanonicalPathResult =\n | { path: string; skipReason?: undefined }\n | { path: null; skipReason: PathSkipReason }\n\n/**\n * Compute a row's locale-relative canonical path for `locale`, or `{ path:\n * null, skipReason }` when it can't be built — the same typed-BROKEN-link\n * contract as {@link buildEntityHref}, plus a reason a caller can log or\n * ignore (F075; see {@link PathSkipReason} for what each reason means and\n * which ones are worth surfacing).\n *\n * `[parent.path]` expansion: when `row.parent` is a set self-reference\n * (`pathParent()`), the parent's OWN canonical `toolkit_paths` path for the\n * SAME `(entityType, locale, isCanonical)` is substituted directly into the\n * PATTERN STRING (not via `buildEntityHref`'s row-based token substitution —\n * see below for why). If the parent isn't indexed yet in this locale, the\n * whole path is unresolved (`skipReason: 'parent-not-indexed'` — usually a\n * transient/ordering condition, not a permanent error). A ROOT document\n * (`row.parent` null/absent) substitutes `''` — per Phase 01 §3's settled\n * design, \"root documents expand `[parent.path]` to nothing\", so a root under\n * `/[parent.path]/[slug]` still resolves (to `/[slug]` after\n * {@link normalizePath} collapses the doubled separator).\n *\n * This can't go through `buildEntityHref`'s normal `row[token]` substitution:\n * that function treats ANY empty-string token value as a broken link\n * (`missing = true`) — correct for ordinary content tokens (an empty slug\n * really is broken), wrong for `parent.path`'s root case (empty there is\n * intentional, not missing). So `[parent.path]` is resolved against the raw\n * PATTERN STRING first (via `resolvePattern`, the same per-locale/map\n * resolution `buildEntityHref` uses internally), and the already-substituted\n * string is then handed to `buildEntityHref` for the REMAINING tokens (e.g.\n * `[slug]`), which keep the normal missing-is-broken rule.\n */\nexport async function computeCanonicalPath(args: {\n entity: Entity\n /** Locale-merged row for the target locale. */\n row: Record<string, unknown>\n locale: string\n defaultLocale: string\n pathsClient: ToolkitPathsClient\n}): Promise<ComputeCanonicalPathResult> {\n const declaredPath = args.entity.frontend?.path\n if (declaredPath === undefined) return { path: null, skipReason: 'not-routable' }\n\n const pattern = resolvePattern(declaredPath, args.locale, args.defaultLocale)\n if (pattern === null) return { path: null, skipReason: 'no-pattern-for-locale' }\n\n let effectivePattern = pattern\n if (typeof pattern === 'string' && pattern.includes('[parent.path]')) {\n const parentId = args.row.parent\n if (typeof parentId === 'string' && parentId.length > 0) {\n // Same-entity self-reference, so the parent lives in this entity's own\n // path rows — no cross-entity-type registry needed.\n const parentRow = await args.pathsClient.findOne({\n entityType: args.entity.name,\n entityId: parentId,\n locale: args.locale,\n isCanonical: true,\n })\n // parent not indexed in this locale yet — broken, not root\n if (!parentRow) return { path: null, skipReason: 'parent-not-indexed' }\n effectivePattern = pattern.replaceAll('[parent.path]', parentRow.path)\n } else {\n // Root document — expands to nothing (Phase 01 §3), not \"missing\".\n effectivePattern = pattern.replaceAll('[parent.path]', '')\n }\n }\n\n const href = buildEntityHref(effectivePattern, args.row, args.locale, args.defaultLocale)\n return href === null\n ? { path: null, skipReason: 'unresolved-token' }\n : { path: normalizePath(href) }\n}\n\n/**\n * Canonicalize a path for storage/comparison: collapse duplicate slashes and\n * strip a trailing slash (except the bare root `/`). This makes the\n * `[parent.path]` composition robust — `/[parent.path]/[slug]` with a\n * `parent.path` of `/handbook` yields `//handbook/intro`, which normalizes to\n * `/handbook/intro` — and guarantees the guard and writer compute byte-identical\n * paths for the same row (so a re-save is correctly seen as \"no change\").\n */\nexport function normalizePath(path: string): string {\n const collapsed = path.replace(/\\/{2,}/g, '/')\n return collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed\n}\n\n/**\n * Pre-commit conflict check + auto-suffix for a SINGLE locale's write. Called by\n * `pathConflictGuard`. Computes the candidate path from `data` merged onto\n * `currentRow`, rejects reserved-prefix hits and manual-slug conflicts, and —\n * for auto slugs — mutates `data.slug` in place with an incrementing suffix\n * (`hello` → `hello-2` → `hello-3`) until the path is free (bounded).\n *\n * `data.slug` mutations are persisted by the SAME entity write (the guard fires\n * before the INSERT/UPDATE and `data` is the in-flight payload), so no\n * follow-up write is needed.\n */\nexport async function ensurePathAvailable(args: {\n entity: Entity\n /** Mutable in-flight write payload — may get `data.slug` rewritten. */\n data: Record<string, unknown>\n /** Pre-write locale-merged row for `update`/`updateForLocale`; `null` on create. */\n currentRow: Record<string, unknown> | null\n /** The row id, or `undefined` on create (nothing to self-exclude). */\n id: string | undefined\n locale: string\n defaultLocale: string\n slugMode: 'auto' | 'manual'\n reservedPathPrefixes: readonly string[]\n pathsClient: ToolkitPathsClient\n}): Promise<void> {\n const {\n entity,\n data,\n currentRow,\n id,\n locale,\n defaultLocale,\n slugMode,\n reservedPathPrefixes,\n pathsClient,\n } = args\n\n let lastPath = ''\n for (let attempt = 0; attempt < MAX_SUFFIX_ATTEMPTS; attempt++) {\n // Working row = current row overlaid with the in-flight write (create:\n // `data` IS the full row, `currentRow` is null).\n const row = { ...(currentRow ?? {}), ...data }\n const computed = await computeCanonicalPath({ entity, row, locale, defaultLocale, pathsClient })\n // Typed broken link — nothing to conflict-check for this locale. Pre-commit\n // silence is correct here (unlike the post-commit writer, F075): the write\n // hasn't happened yet, so there is nothing yet to be missing a path.\n if (computed.path === null) return\n const path = computed.path\n lastPath = path\n\n // Reserved-prefix boundary — the real enforcement point (Part 2 write-time).\n const reserved = matchReservedPrefix(path, reservedPathPrefixes)\n if (reserved !== undefined) {\n throw new PathReservedPrefixError(entity.name, path, reserved)\n }\n\n // The partial-unique index guarantees at most ONE canonical row per\n // (locale, path). A hit that ISN'T this document's own row is a conflict\n // (self-match excluded — a routine re-save keeping its own path is fine;\n // on create `id` is undefined so nothing is excluded).\n const existing = await pathsClient.findOne({ locale, path, isCanonical: true })\n const isSelf =\n existing != null && existing.entityType === entity.name && existing.entityId === id\n if (existing == null || isSelf) return\n\n // Real conflict with another document.\n if (slugMode === 'manual') {\n throw new PathConflictError(entity.name, locale, path, existing.entityId)\n }\n const currentSlug = row.slug\n if (typeof currentSlug !== 'string' || currentSlug.length === 0) {\n // Auto mode but nothing to suffix — can't resolve automatically.\n throw new PathConflictError(entity.name, locale, path, existing.entityId)\n }\n // Persisted by the same write (the guard runs before the SQL is built).\n data.slug = incrementSlugSuffix(currentSlug)\n }\n\n // Exhausted the suffix ladder — pathological (50 taken slugs in a row).\n throw new PathConflictError(\n entity.name,\n locale,\n lastPath,\n undefined,\n `Could not find a free path for a ${entity.name} document near \"${lastPath}\" (locale \"${locale}\") ` +\n `after ${MAX_SUFFIX_ATTEMPTS} attempts — choose a different slug manually.`,\n )\n}\n\n/**\n * Increment a numeric slug suffix, WordPress/pathauto-style:\n * `hello` → `hello-2`, `hello-2` → `hello-3`, `hello-9` → `hello-10`.\n * A slug with no trailing `-<n>` gets `-2` appended.\n */\nexport function incrementSlugSuffix(slug: string): string {\n const match = /^(.*)-(\\d+)$/.exec(slug)\n if (match) {\n const base = match[1] ?? slug\n const n = Number.parseInt(match[2] ?? '1', 10)\n return `${base}-${n + 1}`\n }\n return `${slug}-2`\n}\n","/**\n * Internal redirect-resolution helper (plan/routing PR04, Part 4).\n *\n * Given a `(locale, path)` that may be a HISTORICAL (demoted) URL, resolve the\n * CURRENT canonical path of the document that once lived there — the \"301 to\n * wherever this document now lives\" lookup that backs automatic redirect\n * history (D005 / the change-matrix in `04-path-lifecycle-and-redirects.md`).\n *\n * **Chain-free by construction.** Every historical row is keyed to the OWNING\n * DOCUMENT (`entity_type`, `entity_id`), never to \"the next URL in the rename\n * sequence\". So a document renamed A → B → C leaves historical rows at A and B\n * both pointing at the same document, and this helper resolves EITHER of them\n * straight to C in one hop — it never walks A → B → C. (WP `_wp_old_slug` /\n * Drupal redirect both use this keyed-to-target model — R01.)\n *\n * NOT the public resolution API. Phase 05 / PR06 owns `resolvePath` and the\n * canonical → manual → historical → 404 precedence contract (§2, §4 of Phase\n * 04); this phase ships only the underlying historical-row → current-canonical\n * mechanism. PR06 imports and wraps this. (Scope split filed as a Finding in\n * `plan/routing/progress.md`, mirroring F004's PR02/03 precedent.)\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { PathIndexReader } from './index-reader.js'\n\n/**\n * How many `(locale, path)` candidates a single redirect lookup will consider\n * before giving up and answering 404.\n *\n * Several documents can hold a historical row at one URL, and one whose owner\n * has no current canonical (trashed, or that locale unpublished) must not\n * shadow one that does — so the lookup walks candidates rather than trusting\n * the newest. This is the ceiling on that walk: it runs on the miss path of\n * every unmatched URL, which is where crawlers and scanners spend their time,\n * and CLAUDE.md's no-unbounded-fan-out rule applies to a per-request loop as\n * much as to a server one.\n */\nconst MAX_REDIRECT_CANDIDATES = 10\n\n/** The current canonical location a historical/canonical `(locale, path)` resolves to. */\nexport interface RedirectTarget {\n entityType: string\n entityId: string\n /** The owning document's CURRENT canonical path for this locale. */\n currentPath: string\n}\n\n/**\n * Resolve `(locale, path)` — canonical OR historical — to the current canonical\n * path of its owning document, or `null` when nothing routes there.\n *\n * Returns `null` when:\n * - No `toolkit_paths` row (of any `is_canonical`) matches `(locale, path)`.\n * - The owning document has no current canonical row for `locale` anymore\n * (its locale variant was unpublished/deleted since) — the caller treats\n * this as a 404, matching the Unpublish/Delete rows of the §2 change matrix.\n *\n * Known edge case (documented, NOT over-engineered per the brief): two DIFFERENT\n * documents can both hold a HISTORICAL row at the same `(locale, path)` if one\n * occupied that URL, moved away, and a LATER document independently claimed then\n * also moved away from it — the partial-unique only forbids two *canonical* rows\n * sharing a URL, never two historical ones. When multiple rows match we prefer\n * the most-recently-updated, but only among those that still RESOLVE: self-\n * healing / approximate, consistent with this feature's already-accepted\n * tolerance for narrow non-security drift (F014's precedent — accept the race\n * rather than add locking machinery).\n *\n * Preferring the newest *resolvable* candidate rather than simply the newest is\n * load-bearing since trashing a document releases its canonical claim while\n * keeping its history: without it, a trashed document's leftover history row\n * would out-rank and silently kill a LIVE document's redirect at the same URL —\n * the same \"a row belonging to an invisible document shadows a live one\" defect\n * that the trash-side release exists to remove, displaced one tier down.\n */\nexport async function findRedirectTarget(args: {\n // Narrow structural reader (the real `ToolkitPathsClient` satisfies it — see\n // `./index-reader.ts`). Widened from `ToolkitPathsClient` in PR06 so the\n // resolution core can inject a cast-free unit-test stub; the integration\n // tests still pass the real client, proving the assignment.\n pathsClient: PathIndexReader\n locale: string\n path: string\n /**\n * Optional, and used for ONE thing: saying so when the candidate cap was\n * reached and nothing resolved. Without it that 404 is indistinguishable\n * from a genuine miss and the pathological URL stays invisible — which is\n * the silent-truncation failure CLAUDE.md's fan-out rule names directly.\n */\n logger?: Logger\n}): Promise<RedirectTarget | null> {\n const { pathsClient, locale, path, logger } = args\n\n // Any row (canonical or historical) at this URL, most-recent first. Several\n // documents can hold a historical row at the same URL — each one lived here\n // and moved on — and the most recent is the best answer among those that can\n // still BE answered.\n //\n // Bounded, and taking only the first row would be the bug: a document whose\n // canonical is gone (trashed, so its claim was released; or its locale\n // variant unpublished) still owns its history rows, and if one of those wins\n // the pick, an unanswerable candidate would shadow a live document's\n // redirect at the same URL and turn a working 301 into a 404. So walk the\n // candidates in recency order and take the first whose owner can actually be\n // redirected to.\n //\n // `MAX_REDIRECT_CANDIDATES` is a hard ceiling per CLAUDE.md's no-unbounded-\n // fan-out rule — this runs on the miss path of every unmatched URL, which is\n // exactly where a crawler or a scanner spends its time. Beyond it we answer\n // 404 rather than keep looking; a URL contended by that many documents is\n // already pathological, and the alternative is letting one request issue\n // unbounded lookups.\n const matches = await pathsClient.findMany({\n where: { locale, path },\n orderBy: [{ column: 'updatedAt', dir: 'desc' }],\n limit: MAX_REDIRECT_CANDIDATES,\n })\n\n for (const match of matches) {\n // Resolve the owning document's CURRENT canonical row for this locale.\n // This is the single hop that makes redirects chain-free: we jump from\n // \"some URL this document used to (or does) live at\" straight to \"where it\n // lives now\".\n const canonical = await pathsClient.findOne({\n entityType: match.entityType,\n entityId: match.entityId,\n locale,\n isCanonical: true,\n })\n // This owner has no live canonical path in this locale anymore. Skip it and\n // try the next candidate rather than answering 404 on its behalf — the §2\n // change matrix's 404 is for when NO candidate resolves, not when the\n // newest one happens not to.\n if (canonical) {\n return {\n entityType: match.entityType,\n entityId: match.entityId,\n currentPath: canonical.path,\n }\n }\n }\n\n // Nothing resolved. If we also filled the candidate window, the answer may be\n // a TRUNCATION rather than a miss — say so, because the two are identical\n // from outside and only one of them is a URL somebody should look at.\n if (matches.length === MAX_REDIRECT_CANDIDATES) {\n logger?.warn?.(\n { locale, path, examined: matches.length },\n 'toolkit_paths: redirect lookup examined the full candidate window without ' +\n 'resolving one — answering 404, but a further candidate may exist',\n )\n }\n\n return null\n}\n","/**\n * The public path-resolution API (plan/routing PR06; phase\n * `05-resolution-and-enumeration-api.md` §2, phase `04` §2-4).\n *\n * `resolvePath` maps an incoming `(path, locale)` to a document, a redirect, or\n * a 404, applying the settled precedence:\n *\n * `/` homepage → canonical → manual redirect → historical redirect → none\n *\n * **Security (CLAUDE.md #1 priority).** These functions gate whether content is\n * reachable by URL. `resolvePath` publish-checks at every tier that resolves to\n * a document (homepage / canonical / historical) via the injected\n * {@link PublishChecker} — an UNPUBLISHED document in the requested locale is\n * NOT routable (`{ kind: 'none' }`). A canonical row \"claims\" its path even in\n * draft, so an unpublished canonical returns `none` WITHOUT falling through to\n * manual/historical (nothing else may resolve there). `resolveDraftPath` is the\n * ONLY function that skips publish checks — it is admin/preview-only. Its one\n * sanctioned HTTP caller is `./routes.ts` (`GET /api/admin/paths/resolve`,\n * added for #775: permission-gated `path:resolve` PLUS a per-document `view`\n * re-check). It must NEVER be wired to a `content-api` endpoint — that surface\n * is public and published-only.\n *\n * **Leaf module.** All cross-package state (path/redirect readers, the publish\n * checker, the homepage reader) is injected via `deps` — this module imports no\n * `@murumets-ee/core` and no `server-only` graph. The concrete `deps` are built\n * from an app in `./resolve-context.ts`.\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport type { RedirectTarget } from '../redirects/target.js'\nimport { normalizePath } from './generate-path.js'\nimport type { PathIndexReader } from './index-reader.js'\nimport type { HomepagePointer } from './homepage.js'\nimport { findRedirectTarget } from './redirect-target.js'\n\n// ---------------------------------------------------------------------------\n// Result type (phase 05 §2 — exact shape)\n// ---------------------------------------------------------------------------\n\n/** The document that serves the requested path. */\nexport interface ResolveDocumentResult {\n kind: 'document'\n entityType: string\n id: string\n}\n/** A redirect to another URL/path. `301` permanent, `302` temporary. */\nexport interface ResolveRedirectResult {\n kind: 'redirect'\n to: string\n status: 301 | 302\n}\n/** Nothing routes here (404). */\nexport interface ResolveNoneResult {\n kind: 'none'\n}\nexport type ResolveResult = ResolveDocumentResult | ResolveRedirectResult | ResolveNoneResult\n\n// ---------------------------------------------------------------------------\n// Dependency-injection surface\n// ---------------------------------------------------------------------------\n\n/**\n * Is `(entityType, entityId)` published in `locale`? Backed by\n * `QueryClient.findById`, which applies the publish filter for any principal\n * WITHOUT `view_unpublished` (D017) — so the answer is only \"published\" if the\n * injected client's context withholds that action, which the real one\n * (`resolve-context.ts`'s reach context) does. See {@link createPublishChecker}.\n * `resolveDraftPath` bypasses this entirely.\n */\nexport interface PublishChecker {\n isPublished(entityType: string, entityId: string, locale: string): Promise<boolean>\n}\n\n/** Resolve an entity TYPE NAME to its `Entity`, or `undefined` when unregistered. */\nexport type EntityResolver = (entityType: string) => Entity | undefined\n\n/** The `QueryClient.findById` surface the publish checker needs (publish filter is internal). */\nexport interface PublishQueryClient {\n findById(id: string, options?: { locale?: string | undefined }): Promise<unknown>\n}\n\n/** Build a publish-filtering read client for an entity (real: `new QueryClient(...)`). */\nexport type QueryClientFactory = (entity: Entity) => PublishQueryClient\n\n/** Read the current homepage pointer, or `null` when unset/malformed. */\nexport type HomepageReader = () => Promise<HomepagePointer | null>\n\n/** The manual-redirect lookup surface (locale-specific + catch-all rows). */\nexport interface RedirectLookupReader {\n findOne(where: {\n fromPath: string\n /** A concrete locale, or `null` for the catch-all (all-locales) row. */\n locale: string | null\n }): Promise<{ target: RedirectTarget; status: number } | null>\n}\n\n/** Deps for {@link getCanonicalPath} — the minimal index read. */\nexport interface CanonicalPathDeps {\n pathsClient: PathIndexReader\n}\n\n/** Deps for {@link resolvePath} / {@link resolveDraftPath}. */\nexport interface ResolvePathDeps extends CanonicalPathDeps {\n redirectsClient: RedirectLookupReader\n publishChecker: PublishChecker\n homepageReader: HomepageReader\n /**\n * Optional so the unit-test deps builders stay unchanged. Its only use is\n * letting the historical-redirect lookup report a truncated candidate window\n * — see {@link findRedirectTarget}.\n */\n logger?: Logger\n}\n\n/** Deps for {@link getAlternates}. */\nexport interface AlternatesDeps extends CanonicalPathDeps {\n publishChecker: PublishChecker\n /** The locales to enumerate (configured content locales). */\n locales: readonly string[]\n}\n\n/**\n * Hop cap on manual-redirect `path`-chain following at REQUEST time (F021 /\n * F027 point 4). `collapsePathTarget` only collapses ONE hop at save time, so a\n * chain created out-of-order can still need re-hopping here; exceeding the cap\n * resolves to `none` rather than looping or 500ing.\n */\nexport const MAX_REDIRECT_HOPS = 5\n\n// ---------------------------------------------------------------------------\n// getCanonicalPath — forward index read, NO publish filter (D-note below)\n// ---------------------------------------------------------------------------\n\n/**\n * The current canonical path for `(entityType, entityId, locale)`, or `null`\n * when the document has no canonical row in that locale.\n *\n * **No publish filter — deliberate.** This matches the CURRENT behavior of\n * `resolveEntityHref` / search's URL builder (both call `AdminClient.findById`,\n * which does zero publish filtering); Task 2 re-points those consumers onto this\n * function, so changing the filtering here would be an unplanned behavior change.\n * Callers that need a publish gate compose one explicitly (resolution's canonical\n * tier does).\n */\nexport async function getCanonicalPath(\n entityType: string,\n entityId: string,\n locale: string,\n deps: CanonicalPathDeps,\n): Promise<string | null> {\n const row = await deps.pathsClient.findOne({ entityType, entityId, locale, isCanonical: true })\n return row?.path ?? null\n}\n\n// ---------------------------------------------------------------------------\n// Publish checker\n// ---------------------------------------------------------------------------\n\n/**\n * Build a {@link PublishChecker} from an entity resolver + a query-client\n * factory. `isPublished` is `false` for an unknown entity type, else\n * `findById(id, { locale }) !== null` — a non-null result means \"published in\n * that locale\" because `QueryClient` applies the publish filter (and excludes\n * trashed rows) for every principal without `view_unpublished` (D017). The\n * factory therefore has to hand back a client whose context withholds that\n * action; `resolve-context.ts`'s reach context is the one in-repo\n * implementation and does. F027 point 1.\n */\nexport function createPublishChecker(deps: {\n entityResolver: EntityResolver\n queryClientFactory: QueryClientFactory\n}): PublishChecker {\n return {\n async isPublished(entityType, entityId, locale) {\n const entity = deps.entityResolver(entityType)\n if (entity === undefined) return false\n const client = deps.queryClientFactory(entity)\n const row = await client.findById(entityId, { locale })\n return row !== null && row !== undefined\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// resolvePath / resolveDraftPath\n// ---------------------------------------------------------------------------\n\n/** The publish predicate threaded through the tiers (real, or always-true for draft). */\ntype PublishPredicate = (entityType: string, entityId: string, locale: string) => Promise<boolean>\n\n/**\n * Resolve `(path, locale)` to a document / redirect / none, publish-checking at\n * every document-resolving tier. Public request surface (phase 05 §2).\n */\nexport function resolvePath(\n path: string,\n locale: string,\n deps: ResolvePathDeps,\n): Promise<ResolveResult> {\n return resolveInternal(path, locale, deps, (entityType, entityId, loc) =>\n deps.publishChecker.isPublished(entityType, entityId, loc),\n )\n}\n\n/**\n * Admin/preview resolution — identical tier structure to {@link resolvePath} but\n * SKIPS every publish check (draft and published documents both resolve). Per\n * phase 05 §2 this surface is admin-only. Exactly ONE HTTP route may call it —\n * `./routes.ts`'s `GET /api/admin/paths/resolve` (#775), which gates on\n * `path:resolve` and re-checks the caller's `view` grant on whatever it\n * resolves. NEVER wire it to `content-api` (public, published-only).\n */\nexport function resolveDraftPath(\n path: string,\n locale: string,\n deps: ResolvePathDeps,\n): Promise<ResolveResult> {\n // The ONLY difference from resolvePath: the publish predicate is always true.\n return resolveInternal(path, locale, deps, async () => true)\n}\n\n/**\n * Shared tier engine for `resolvePath` / `resolveDraftPath`. The two differ ONLY\n * in `checkPublished` (real checker vs. always-true), so the precedence + every\n * branch stay byte-identical between the public and admin surfaces.\n */\nasync function resolveInternal(\n rawPath: string,\n locale: string,\n deps: ResolvePathDeps,\n checkPublished: PublishPredicate,\n): Promise<ResolveResult> {\n const path = normalizePath(rawPath)\n const homepage = await deps.homepageReader()\n\n // ── Tier 1: `/` homepage special case ────────────────────────────────────\n if (path === '/') {\n if (homepage === null) return { kind: 'none' }\n if (!(await checkPublished(homepage.entity, homepage.id, locale))) return { kind: 'none' }\n return { kind: 'document', entityType: homepage.entity, id: homepage.id }\n }\n\n // ── Tier 2: canonical ─────────────────────────────────────────────────────\n const canonical = await deps.pathsClient.findOne({ locale, path, isCanonical: true })\n if (canonical) {\n // The path is CLAIMED by this document even in draft — an unpublished\n // canonical returns `none` and does NOT fall through to manual/historical.\n if (!(await checkPublished(canonical.entityType, canonical.entityId, locale))) {\n return { kind: 'none' }\n }\n // D004 dedup: the homepage document's own pattern URL always 301s to `/`.\n if (isHomepage(homepage, canonical.entityType, canonical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'document', entityType: canonical.entityType, id: canonical.entityId }\n }\n\n // ── Tier 3: manual redirects ──────────────────────────────────────────────\n const manual = await resolveManualRedirect(path, locale, deps, homepage)\n if (manual !== null) return manual\n\n // ── Tier 4: historical (automatic rename) redirects ───────────────────────\n const historical = await findRedirectTarget({\n pathsClient: deps.pathsClient,\n locale,\n path,\n ...(deps.logger !== undefined && { logger: deps.logger }),\n })\n if (historical === null) return { kind: 'none' }\n if (!(await checkPublished(historical.entityType, historical.entityId, locale))) {\n return { kind: 'none' }\n }\n if (isHomepage(homepage, historical.entityType, historical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'redirect', to: historical.currentPath, status: 301 }\n}\n\n/**\n * Tier 3 — resolve a manual `toolkit_redirects` row at `fromPath`. Returns:\n * - `null` when NO manual redirect exists at `fromPath` (caller falls to tier 4).\n * - a {@link ResolveResult} otherwise (including `{ kind: 'none' }` for a target\n * that resolves to nothing).\n *\n * `entity`-kind targets resolve via `getCanonicalPath` with NO publish check\n * (F027 point 3 — an unpublished target 404s on the FOLLOWING request via tier\n * 2's own check). `path`-kind targets are re-resolved at request time, bounded\n * by {@link MAX_REDIRECT_HOPS}, so an out-of-order chain (F021) can't loop.\n */\nasync function resolveManualRedirect(\n fromPath: string,\n locale: string,\n deps: ResolvePathDeps,\n homepage: HomepagePointer | null,\n): Promise<ResolveResult | null> {\n let row = await lookupManualRedirect(deps.redirectsClient, fromPath, locale)\n if (row === null) return null // no manual redirect here → fall through to tier 4\n\n for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop++) {\n const target = row.target\n const status = toRedirectStatus(row.status)\n\n if (target.kind === 'url') {\n return { kind: 'redirect', to: target.url, status } // terminal, external\n }\n\n if (target.kind === 'entity') {\n // Homepage-collision (D004), same as tiers 2/4 + the path-walk-hits-canonical\n // case: if the redirect points AT the homepage document, go straight to `/`\n // rather than its pattern URL (which would then 301 to `/` anyway — a pointless\n // double hop). Keep the manual redirect's own status.\n if (isHomepage(homepage, target.entity, target.id)) {\n return { kind: 'redirect', to: '/', status }\n }\n const to = await getCanonicalPath(target.entity, target.id, locale, deps)\n if (to === null) return { kind: 'none' } // target has no canonical row in this locale\n return { kind: 'redirect', to, status }\n }\n\n // target.kind === 'path' — re-resolve at request time (bounded).\n const nextPath = normalizePath(target.path)\n const nextRow = await lookupManualRedirect(deps.redirectsClient, nextPath, locale)\n if (nextRow !== null) {\n row = nextRow // hop to the next redirect and keep walking\n continue\n }\n // No further redirect at `nextPath` — does it serve canonical content?\n const canonical = await deps.pathsClient.findOne({\n locale,\n path: nextPath,\n isCanonical: true,\n })\n if (canonical) {\n if (isHomepage(homepage, canonical.entityType, canonical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'redirect', to: nextPath, status }\n }\n return { kind: 'none' } // path target points at neither a redirect nor content\n }\n\n return { kind: 'none' } // exceeded MAX_REDIRECT_HOPS — never loop, never 500\n}\n\n/**\n * Look up a manual redirect at `fromPath`: a locale-specific row wins, else the\n * catch-all (`locale = null`) row. Mirrors `collapsePathTarget`'s precedence.\n */\nasync function lookupManualRedirect(\n client: RedirectLookupReader,\n fromPath: string,\n locale: string,\n): Promise<{ target: RedirectTarget; status: number } | null> {\n const exact = await client.findOne({ fromPath, locale })\n if (exact) return exact\n return client.findOne({ fromPath, locale: null })\n}\n\n// ---------------------------------------------------------------------------\n// getAlternates\n// ---------------------------------------------------------------------------\n\n/**\n * The published per-locale canonical URLs for a document — the `hreflang`\n * alternates set (phase 05 §2, \"published locales only\"). A locale is included\n * only when the document has a canonical row AND is published there. Bounded by\n * the (small, fixed) configured-locale count — no fan-out concern.\n */\nexport async function getAlternates(\n entityType: string,\n id: string,\n deps: AlternatesDeps,\n): Promise<Array<{ locale: string; path: string }>> {\n const alternates: Array<{ locale: string; path: string }> = []\n for (const locale of deps.locales) {\n const path = await getCanonicalPath(entityType, id, locale, deps)\n if (path === null) continue\n if (!(await deps.publishChecker.isPublished(entityType, id, locale))) continue\n alternates.push({ locale, path })\n }\n return alternates\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Is `(entityType, entityId)` the configured homepage document? */\nfunction isHomepage(\n homepage: HomepagePointer | null,\n entityType: string,\n entityId: string,\n): boolean {\n return homepage !== null && homepage.entity === entityType && homepage.id === entityId\n}\n\n/**\n * Narrow the DB `status` integer (validated `301 | 302` at write time) to the\n * result union. Defaults to `301` for any unexpected value — never a raw number\n * leaking into the typed result.\n */\nfunction toRedirectStatus(status: number): 301 | 302 {\n return status === 302 ? 302 : 301\n}\n","/**\n * `toolkit_redirects` — the manual-redirect table (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3). One row per admin-authored redirect:\n * a `from_path` (locale-relative source URL) → a {@link RedirectTarget}\n * (entity / url / path). Distinct from the AUTOMATIC redirect history, which lives\n * as demoted (`is_canonical = false`) rows in `toolkit_paths` — this table is only\n * for the URLs automatic history can't express (campaign URLs, migrated-site\n * paths). Owned by `@murumets-ee/content`.\n *\n * Uses `defineTable` (per plan/routing C3, CLAUDE.md \"no raw SQL / typed APIs\n * only\") so reads/writes go through a typed `TableClient` — Task 2's CRUD routes\n * and the save-time validation (`./validation.ts`) never touch raw SQL.\n *\n * ## Uniqueness — TWO partial-unique indexes (F020 point 4)\n *\n * `locale` is nullable: `null` means \"applies to all locales\" (a catch-all\n * redirect). Postgres treats `NULL`s as DISTINCT in an ordinary unique constraint,\n * so a naive `UNIQUE (locale, from_path)` would silently allow duplicate catch-all\n * rows at the same path. Two partial-uniques express the real intent:\n *\n * - `UNIQUE (locale, from_path) WHERE locale IS NOT NULL` — a locale-specific\n * redirect is unique per (locale, from_path).\n * - `UNIQUE (from_path) WHERE locale IS NULL` — at most ONE catch-all redirect per\n * from_path.\n *\n * A locale-specific row and a catch-all row MAY legitimately coexist at the same\n * `from_path` (intentional per-locale override layering) — the two indexes don't\n * forbid that, only duplicates WITHIN each stratum. Both indexes are keyed on\n * `from_path`, so the \"does this from_path already exist as ANY redirect\"\n * collapse-lookup (`./validation.ts`) is already covered — no extra plain index\n * needed.\n *\n * Both use the callback `where` form (F007's `defineTable` partial-unique support)\n * with an INLINE `IS NULL` / `IS NOT NULL` literal — never `eq()`, which would bind\n * a `$1` param that is invalid in DDL (CLAUDE.md partial-unique gotcha; same form\n * `toolkit_paths` uses).\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\nimport type { RedirectTarget } from './target.js'\n\nexport const toolkitRedirectsTable = defineTable({\n name: 'toolkit_redirects',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n /** BCP47 locale, or `null` = applies to all locales (catch-all). */\n locale: column.varchar({ length: 10 }),\n /** Locale-relative source URL: leading '/', no trailing '/' (same convention as `toolkit_paths.path`). */\n fromPath: column.text({ notNull: true, pgName: 'from_path' }),\n /** The redirect destination — see {@link RedirectTarget}. Stored as the discriminated-union object. */\n target: column.jsonb<RedirectTarget, true>({ notNull: true }),\n /** HTTP status. `301 | 302` is validated at the Zod boundary in Task 2's route layer. */\n status: column.integer({ notNull: true, default: 301 }),\n /**\n * The admin who authored the redirect. VARCHAR, never UUID — better-auth user\n * ids are random strings (CLAUDE.md). Nullable: system-created rows (future\n * migration/import tooling) have no natural actor, matching how other\n * audit-style columns in `defineTable` tables stay nullable.\n */\n createdBy: column.varchar({ length: 255, pgName: 'created_by' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [\n // A locale-specific redirect is unique per (locale, from_path). Excludes\n // catch-all rows (locale IS NULL) from this stratum.\n { on: ['locale', 'fromPath'], where: (c) => sql`${c.locale} IS NOT NULL` },\n // At most ONE catch-all (all-locales) redirect per from_path. Postgres would\n // otherwise treat every NULL locale as distinct and allow duplicates.\n { on: ['fromPath'], where: (c) => sql`${c.locale} IS NULL` },\n ],\n})\n\n/** Backwards-compatible raw pgTable export (for the plugin `tables:` slot + migrations). */\nexport const toolkitRedirects = toolkitRedirectsTable.table\n\n/** The full `DefinedTable` for `toolkit_redirects`. */\nexport type ToolkitRedirectsTable = typeof toolkitRedirectsTable\n\n/** The typed `TableClient` for `toolkit_redirects` (`toolkitRedirectsTable.makeClient(db)`). */\nexport type ToolkitRedirectsClient = ReturnType<typeof toolkitRedirectsTable.makeClient>\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{matchReservedPrefix as e}from"@murumets-ee/entity";import{column as t,defineTable as n}from"@murumets-ee/db";import{sql as r}from"drizzle-orm";function i(e){let t=e;for(let e=0;t&&e<5;e++){if(t.code===`23505`)return!0;t=t.cause}return!1}const a=new Set([`not-routable`,`parent-not-indexed`,`row-unreadable`]);async function o(t){let{pathsClient:n,entityType:r,entityId:o,locale:c,path:l,reservedPathPrefixes:u,logger:d}=t;if(l===null){let e=t.skipReason??null,n=e!==null&&(t.suppressNoteworthy?.includes(e)??!1);return e!==null&&!n&&a.has(e)&&d.warn({entity:r,id:o,locale:c,skipReason:e},`toolkit_paths: no canonical path computed for this (document, locale) — ${e}. The document has no toolkit_paths row and will 404 on the public site until this is resolved (fix the input and re-save, or run \`lumi paths:rebuild\`).`),{outcome:`skipped-null`,oldPath:null,newPath:null,conflictWith:null,skipReason:e}}let f=e(l,u);if(f!==void 0)return d.warn({entity:r,id:o,locale:c,path:l,reserved:f},`toolkit_paths: computed path falls under a reserved prefix — skipping this locale (kept out of the routable index, see F013)`),{outcome:`skipped-reserved`,oldPath:null,newPath:l,conflictWith:null,skipReason:null};let p=await n.findOne({entityType:r,entityId:o,locale:c,isCanonical:!0}),m=p?.path??null;if(p!==null&&p.path===l)return{outcome:`unchanged`,oldPath:m,newPath:l,conflictWith:null,skipReason:null};if(t.dryRun){let e=await n.findOne({locale:c,path:l,isCanonical:!0});return e!==null&&!(e.entityType===r&&e.entityId===o)?{outcome:`conflict`,oldPath:m,newPath:l,conflictWith:{entityType:e.entityType,entityId:e.entityId},skipReason:null}:{outcome:p===null?`created`:`renamed`,oldPath:m,newPath:l,conflictWith:null,skipReason:null}}p!==null&&await n.update({id:p.id},{isCanonical:!1,updatedAt:new Date});let h=!1;try{await n.insert({entityType:r,entityId:o,locale:c,path:l,isCanonical:!0}),h=!0}catch(e){if(!i(e))throw e}if(!h){if(p!==null)try{await n.update({id:p.id},{isCanonical:!0,updatedAt:new Date})}catch(e){if(!i(e))throw e;d.warn({entity:r,id:o,locale:c,path:l},`toolkit_paths: could not re-promote the old canonical after a failed insert — a canonical row already exists for this document+locale (concurrent write); leaving history as-is`)}let e=await n.findOne({locale:c,path:l,isCanonical:!0}),t=e!==null&&!(e.entityType===r&&e.entityId===o)?{entityType:e.entityType,entityId:e.entityId}:null;return d.warn({entity:r,id:o,locale:c,path:l},`toolkit_paths: canonical (locale, path) already claimed by another document — skipping (old canonical restored / left in place)`),{outcome:`conflict`,oldPath:m,newPath:l,conflictWith:t,skipReason:null}}return p===null?{outcome:`created`,oldPath:null,newPath:l,conflictWith:null,skipReason:null}:(await s(n,r,o,c),{outcome:`renamed`,oldPath:m,newPath:l,conflictWith:null,skipReason:null})}async function s(e,t,n,r){let i=await e.count({entityType:t,entityId:n,locale:r,isCanonical:!1})-25;if(i<=0)return;let a=(await e.findMany({where:{entityType:t,entityId:n,locale:r,isCanonical:!1},orderBy:[{column:`updatedAt`,dir:`asc`},{column:`createdAt`,dir:`asc`}],limit:Math.min(i,1e3)})).map(e=>e.id);a.length>0&&await e.deleteMany({id:{in:a}})}async function c(e,t,n){let r=e.length;if(r===0)return;let i=0,a=Math.max(1,Math.min(Number.isFinite(t)?Math.floor(t):1,r));async function o(){for(;;){let t=i;if(i+=1,t>=r)return;await n(e[t],t)}}await Promise.all(Array.from({length:a},()=>o()))}const l=n({name:`toolkit_paths`,columns:{id:t.uuid({primaryKey:!0,defaultRandom:!0}),entityType:t.varchar({length:100,notNull:!0,pgName:`entity_type`}),entityId:t.uuid({notNull:!0,pgName:`entity_id`}),locale:t.varchar({length:10,notNull:!0}),path:t.text({notNull:!0}),isCanonical:t.boolean({notNull:!0,default:!0,pgName:`is_canonical`}),createdAt:t.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:t.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`locale`,`path`],where:e=>r`${e.isCanonical} = true`},{on:[`entityType`,`entityId`,`locale`],where:e=>r`${e.isCanonical} = true`}],indexes:[{on:[`entityType`,`entityId`,`locale`]}]}),u=l.table;export{c as a,i,l as n,o as r,u as t};
|
|
2
|
+
//# sourceMappingURL=schema-D8sBQozw.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-D8sBQozw.mjs","names":[],"sources":["../src/paths/apply-path.ts","../src/paths/schema.ts"],"sourcesContent":["/**\n * Shared `toolkit_paths` canonical-row apply/plan logic (plan/routing PR04).\n *\n * PR04 Task 1 inlined a find-canonical → insert/demote sequence inside\n * `createPathIndexWriter`'s per-locale closure. Task 2 (the descendant-cascade\n * + full-rebuild queue jobs) needs the EXACT same \"recompute-and-demote-if-\n * changed\" write so a renamed ancestor's descendants — and a `paths:rebuild`\n * sweep — mint redirect history identically to a first-party entity write.\n *\n * Rather than reimplement it (and risk drift in the routing invariant), the\n * sequence lives here once as {@link applyCanonicalPath}; the entity-hook\n * writer, the cascade job, and the rebuild job all call it. The writer adds the\n * one thing the jobs must NOT do — firing the cascade seam — on a `renamed`\n * outcome; the jobs write descendant paths DIRECTLY through this helper (never\n * via `AdminClient.update`) precisely so they DON'T re-fire the seam and trip\n * the queue's self-enqueue guard.\n *\n * NOTE: on the content plugin's `init` load path (via `writers.ts` →\n * `plugin.ts`), so it must stay free of `import 'server-only'`. Every import\n * here is server-only-free; `Logger` is a TYPE-only import from core.\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport { matchReservedPrefix } from '@murumets-ee/entity'\nimport type { ToolkitPathsClient } from './schema.js'\n\n/**\n * Per-document redirect-history cap (plan/routing §2 \"capped at 25/document\").\n * When a rename demotes the old canonical row, non-canonical rows beyond this\n * many (per document+locale) are pruned oldest-demoted-first to bound growth.\n * Moved here from `writers.ts` so both the entity-hook writer and the jobs\n * share ONE cap constant.\n */\nexport const MAX_REDIRECT_HISTORY = 25\n\n/**\n * Steady-state overflow is 1 (we trim on every demote); this clamp only guards\n * a pathological backlog (e.g. after a rebuild that demoted many at once) from\n * tripping the `findMany` MAX_LIMIT throw — the remainder self-heals on the\n * next write.\n */\nconst HISTORY_TRIM_MAX = 1000\n\n/** True iff `err` (or any `.cause` in its chain) is a Postgres unique violation (23505). */\nexport function isUniqueViolation(err: unknown): boolean {\n let cursor: unknown = err\n for (let depth = 0; cursor && depth < 5; depth++) {\n if ((cursor as { code?: unknown }).code === '23505') return true\n cursor = (cursor as { cause?: unknown }).cause\n }\n return false\n}\n\n/**\n * WHY a (document, locale) has no computed path — carried alongside the\n * `skipped-null` outcome so a caller/operator can tell \"this row has no path\n * by design\" from \"this row should have had one\" (F075). Produced by\n * {@link import('./generate-path.js').computeCanonicalPath}, except\n * `row-unreadable`, which the writer sets itself when it could not even READ\n * the row (see `createPathIndexWriter` in `writers.ts`).\n *\n * - `not-routable` — `entity.frontend?.path` is undefined. Defensive: nothing\n * should call `computeCanonicalPath` for a non-routable entity in the\n * first place, so this reason firing at all is itself the anomaly.\n * - `no-pattern-for-locale` — `resolvePattern` has no pattern for this\n * locale. EXPECTED for an entity that is intentionally routable in only\n * some locales.\n * - `parent-not-indexed` — the pattern needs `[parent.path]`, the row has a\n * parent, but the parent has no canonical `toolkit_paths` row YET.\n * Ordering-sensitive (PR23's collection-order defect produced exactly\n * this) — a well-ordered write should not hit it in steady state, so it is\n * treated as noteworthy rather than expected.\n * - `unresolved-token` — `buildEntityHref` returned null (a required token,\n * e.g. `[slug]`, has no value yet). EXPECTED for a document mid-creation.\n * - `row-unreadable` — the writer's recompute read came back `null` (the row\n * is trashed, gone, or — before F074's fix — the caller lacked `view` on\n * it). Always noteworthy: a row that legitimately has no path never\n * reaches this branch, because there is no row to read a path FOR.\n */\nexport type PathSkipReason =\n | 'not-routable'\n | 'no-pattern-for-locale'\n | 'parent-not-indexed'\n | 'unresolved-token'\n | 'row-unreadable'\n\n/**\n * `skipReason`s worth a `logger.warn`. The other reasons are legitimate,\n * everyday states — a document mid-creation with no slug yet, an entity\n * routable in only some locales — and warning on every one of THOSE would\n * train operators to ignore the message, which is worse than the silence\n * F075 replaces (CLAUDE.md \"negative-test every gate\": a check nobody reads\n * is not a check). These are the reasons a WELL-FORMED write should never\n * produce, so seeing one at all is the signal.\n */\nconst NOTEWORTHY_SKIP_REASONS: ReadonlySet<PathSkipReason> = new Set([\n 'not-routable',\n 'parent-not-indexed',\n 'row-unreadable',\n])\n\n/**\n * What {@link applyCanonicalPath} did (or, in `dryRun`, WOULD do) for one\n * (document, locale):\n * - `created` — no canonical row existed → a fresh canonical was inserted.\n * - `unchanged`— the canonical row already held this exact path (no-op).\n * - `renamed` — the path changed → new canonical inserted, old demoted to\n * history (redirect), history pruned to the cap.\n * - `skipped-null` — no path was available for this (document, locale) — see\n * {@link ApplyPathResult.skipReason} for WHY.\n * - `skipped-reserved` — the computed path falls under a reserved prefix; it\n * is kept OUT of the routable index (never persisted).\n * - `conflict` — the target `(locale, path)` canonical is owned by a DIFFERENT\n * document; the old row is left canonical (nothing written).\n */\nexport type ApplyPathOutcome =\n | 'created'\n | 'unchanged'\n | 'renamed'\n | 'skipped-null'\n | 'skipped-reserved'\n | 'conflict'\n\n/** Structured outcome of one canonical-path apply/plan. */\nexport interface ApplyPathResult {\n outcome: ApplyPathOutcome\n /** The document's previous canonical path, or `null` when it had none. */\n oldPath: string | null\n /** The freshly computed canonical path, or `null` for `skipped-null`. */\n newPath: string | null\n /** For `conflict`: the document already owning the target URL (else `null`). */\n conflictWith: { entityType: string; entityId: string } | null\n /**\n * WHY the outcome is `skipped-null` (F075) — `null` for every other\n * outcome, and also `null` for a `skipped-null` whose caller didn't supply\n * a reason (older/test call sites; treated as \"nothing to report\").\n */\n skipReason: PathSkipReason | null\n}\n\nexport interface ApplyCanonicalPathArgs {\n pathsClient: ToolkitPathsClient\n entityType: string\n entityId: string\n locale: string\n /**\n * Precomputed canonical path (from `computeCanonicalPath`), or `null` for a\n * typed broken link. Kept as an explicit input so the caller owns the\n * entity+row → path resolution (the writer, cascade, and rebuild each source\n * `row` differently) and this helper stays a pure `toolkit_paths` mutator.\n */\n path: string | null\n /**\n * WHY `path` is `null` (F075) — required when `path` is `null` so the\n * `skipped-null` outcome is never reported with no explanation; omit\n * entirely when `path` is non-null; it is ignored either way.\n */\n skipReason?: PathSkipReason\n /**\n * Reasons this CALLER already knows are transient in its own context, so a\n * hit should not warn even though {@link NOTEWORTHY_SKIP_REASONS} would\n * normally treat it as one. The one caller that needs this today:\n * `runPathsRebuild`'s multi-pass convergence sweep scans documents in\n * ascending id (uncorrelated with hierarchy depth — see its own docblock),\n * so on a genuinely COLD `toolkit_paths` index a descendant scanned before\n * its not-yet-created ancestor legitimately hits `parent-not-indexed` on\n * pass 1 and self-heals by the ancestor's own pass. `runPathsCascade`'s BFS\n * is ancestor-first by construction and the entity-hook writer never\n * recomputes an unindexed ancestor's descendant in the same call, so\n * NEITHER passes this — for them, `parent-not-indexed` staying noteworthy\n * is exactly the signal that caught PR23's own collection-order defect.\n */\n suppressNoteworthy?: readonly PathSkipReason[]\n reservedPathPrefixes: readonly string[]\n logger: Logger\n /**\n * When `true`, compute the outcome + diff but perform NO writes (the rebuild\n * job's dry-run preview). Conflict is then detected via a lookup rather than\n * the insert-and-catch used on the real-write path.\n */\n dryRun?: boolean\n}\n\n/**\n * Recompute-and-demote-if-changed for ONE (document, locale) — the shared core\n * of PR04's redirect-history writer, reused by the entity hook AND the cascade\n * / rebuild jobs.\n *\n * Real-write path (default): the old canonical is DEMOTED FIRST\n * (`is_canonical = false`), then the new canonical is inserted. This ordering is\n * DB-SAFE because `toolkit_paths` carries a partial unique\n * `UNIQUE (entity_type, entity_id, locale) WHERE is_canonical` — so at every\n * point in time Postgres guarantees at most ONE canonical row per\n * (document, locale). (An insert-FIRST order would transiently hold two\n * canonical rows and therefore VIOLATE that partial unique on every rename.)\n *\n * If the insert then fails with a unique violation — either the pre-existing\n * `(locale, path)` conflict (another document owns the target URL, F010's known\n * tolerance) or, under a genuine concurrent double-write, the\n * `(entity_type, entity_id, locale)` partial unique — the old row is\n * RE-PROMOTED (`is_canonical = true`) so the document is never left with zero\n * canonical rows for the locale. That corrective re-promote can itself hit the\n * partial unique in the concurrent case (another writer already re-inserted a\n * canonical for this document); that is tolerated + logged, because the document\n * already HAS a canonical then. On success the old row's demotion stands and\n * history is pruned to {@link MAX_REDIRECT_HISTORY}.\n *\n * It NEVER fires the cascade seam — the caller decides that on a `renamed`\n * outcome (only the entity hook does; the jobs must not, to avoid re-entrant\n * self-enqueue).\n *\n * `dryRun` path performs no writes and detects a cross-document conflict with a\n * `(locale, path, isCanonical)` lookup.\n */\nexport async function applyCanonicalPath(args: ApplyCanonicalPathArgs): Promise<ApplyPathResult> {\n const { pathsClient, entityType, entityId, locale, path, reservedPathPrefixes, logger } = args\n\n if (path === null) {\n // F075 — a `skipped-null` used to leave no trace at all. Most reasons are\n // legitimate everyday states (see NOTEWORTHY_SKIP_REASONS's docblock) and\n // stay silent; the reasons a well-formed write should never hit get a\n // `warn` so an operator debugging \"this page is a 404\" has somewhere to\n // look instead of reasoning about the code from scratch.\n const skipReason = args.skipReason ?? null\n const isSuppressed = skipReason !== null && (args.suppressNoteworthy?.includes(skipReason) ?? false)\n if (skipReason !== null && !isSuppressed && NOTEWORTHY_SKIP_REASONS.has(skipReason)) {\n logger.warn(\n { entity: entityType, id: entityId, locale, skipReason },\n `toolkit_paths: no canonical path computed for this (document, locale) — ${skipReason}. ` +\n 'The document has no toolkit_paths row and will 404 on the public site until this is ' +\n 'resolved (fix the input and re-save, or run `lumi paths:rebuild`).',\n )\n }\n return { outcome: 'skipped-null', oldPath: null, newPath: null, conflictWith: null, skipReason }\n }\n\n // Never persist a reserved path (F013). The write-time guard only screens the\n // write's OWN locale, and a rebuild sweeps every locale — so re-check here.\n const reserved = matchReservedPrefix(path, reservedPathPrefixes)\n if (reserved !== undefined) {\n logger.warn(\n { entity: entityType, id: entityId, locale, path, reserved },\n 'toolkit_paths: computed path falls under a reserved prefix — skipping this locale ' +\n '(kept out of the routable index, see F013)',\n )\n return {\n outcome: 'skipped-reserved',\n oldPath: null,\n newPath: path,\n conflictWith: null,\n skipReason: null,\n }\n }\n\n // The document's CURRENT canonical row for this locale. The `isCanonical`\n // filter is REQUIRED since PR04 keeps historical (`is_canonical = false`) rows.\n const currentCanonical = await pathsClient.findOne({\n entityType,\n entityId,\n locale,\n isCanonical: true,\n })\n const oldPath = currentCanonical?.path ?? null\n\n // (b) Same path → unrelated field changed; a no-op. Leave the row (and its\n // updatedAt) alone; don't mint spurious history.\n if (currentCanonical !== null && currentCanonical.path === path) {\n return { outcome: 'unchanged', oldPath, newPath: path, conflictWith: null, skipReason: null }\n }\n\n if (args.dryRun) {\n const owner = await pathsClient.findOne({ locale, path, isCanonical: true })\n if (owner !== null && !(owner.entityType === entityType && owner.entityId === entityId)) {\n return {\n outcome: 'conflict',\n oldPath,\n newPath: path,\n conflictWith: { entityType: owner.entityType, entityId: owner.entityId },\n skipReason: null,\n }\n }\n return {\n outcome: currentCanonical === null ? 'created' : 'renamed',\n oldPath,\n newPath: path,\n conflictWith: null,\n skipReason: null,\n }\n }\n\n // Real write. DEMOTE the old canonical FIRST so the new insert can't\n // transiently create a second canonical row — the\n // `(entity_type, entity_id, locale) WHERE is_canonical` partial unique would\n // otherwise reject the insert on every rename. Demoting first keeps at most one\n // canonical per (document, locale) at all times (enforced by Postgres).\n if (currentCanonical !== null) {\n await pathsClient.update(\n { id: currentCanonical.id },\n { isCanonical: false, updatedAt: new Date() },\n )\n }\n\n // Insert the new canonical. On a unique violation the target URL is already\n // owned by another document (F010) — or, under a concurrent double-write, this\n // document's canonical was just re-inserted by the other writer.\n let inserted = false\n try {\n await pathsClient.insert({ entityType, entityId, locale, path, isCanonical: true })\n inserted = true\n } catch (err) {\n if (!isUniqueViolation(err)) throw err\n }\n\n if (!inserted) {\n // Re-promote the old row so the document is never left with ZERO canonical\n // rows for this locale (this is what the demote-first order must protect\n // against). The re-promote can ITSELF hit the partial unique when a\n // concurrent writer has already re-inserted a canonical for this document —\n // in which case the document already has a canonical and no repair is\n // needed; tolerate + log (F010/F013 log-and-tolerate posture).\n if (currentCanonical !== null) {\n try {\n await pathsClient.update(\n { id: currentCanonical.id },\n { isCanonical: true, updatedAt: new Date() },\n )\n } catch (err) {\n if (!isUniqueViolation(err)) throw err\n logger.warn(\n { entity: entityType, id: entityId, locale, path },\n 'toolkit_paths: could not re-promote the old canonical after a failed insert — ' +\n 'a canonical row already exists for this document+locale (concurrent write); ' +\n 'leaving history as-is',\n )\n }\n }\n // Best-effort: identify who owns the URL for structured reporting.\n const owner = await pathsClient.findOne({ locale, path, isCanonical: true })\n const conflictWith =\n owner !== null && !(owner.entityType === entityType && owner.entityId === entityId)\n ? { entityType: owner.entityType, entityId: owner.entityId }\n : null\n logger.warn(\n { entity: entityType, id: entityId, locale, path },\n 'toolkit_paths: canonical (locale, path) already claimed by another document — ' +\n 'skipping (old canonical restored / left in place)',\n )\n return { outcome: 'conflict', oldPath, newPath: path, conflictWith, skipReason: null }\n }\n\n if (currentCanonical === null) {\n return { outcome: 'created', oldPath: null, newPath: path, conflictWith: null, skipReason: null }\n }\n\n // Old canonical was demoted above; prune redirect history to the cap.\n await enforceHistoryCap(pathsClient, entityType, entityId, locale)\n return { outcome: 'renamed', oldPath, newPath: path, conflictWith: null, skipReason: null }\n}\n\n/**\n * Prune redirect history for (document, locale) back down to\n * {@link MAX_REDIRECT_HISTORY}, trimming the rows demoted longest ago first\n * (smallest `updated_at`). Extracted verbatim from Task 1's writer closure.\n */\nasync function enforceHistoryCap(\n pathsClient: ToolkitPathsClient,\n entityType: string,\n entityId: string,\n locale: string,\n): Promise<void> {\n const historical = await pathsClient.count({\n entityType,\n entityId,\n locale,\n isCanonical: false,\n })\n const overflow = historical - MAX_REDIRECT_HISTORY\n if (overflow <= 0) return\n const oldest = await pathsClient.findMany({\n where: { entityType, entityId, locale, isCanonical: false },\n orderBy: [\n { column: 'updatedAt', dir: 'asc' },\n { column: 'createdAt', dir: 'asc' },\n ],\n limit: Math.min(overflow, HISTORY_TRIM_MAX),\n })\n const trimIds = oldest.map((r) => r.id)\n if (trimIds.length > 0) {\n await pathsClient.deleteMany({ id: { in: trimIds } })\n }\n}\n\n/**\n * Run `fn` over `items` with bounded concurrency (a fixed worker pool pulling\n * from a shared cursor) — the `runMeritBatch` shape (CLAUDE.md's\n * no-unbounded-fan-out rule; never `Promise.all` over an unbounded list). `fn`\n * MUST isolate its own per-item errors (this helper does not catch) so one bad\n * item can't abort the batch.\n *\n * Increments/tallies inside `fn` are safe without a lock: JS is single-threaded,\n * so a `count++` between `await` points is atomic.\n */\nexport async function mapBounded<I>(\n items: readonly I[],\n concurrency: number,\n fn: (item: I, index: number) => Promise<void>,\n): Promise<void> {\n const total = items.length\n if (total === 0) return\n let next = 0\n // `Math.floor` guards a fractional/NaN concurrency (config-derived) from\n // producing a zero-length worker array that silently no-ops the batch.\n const safe = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1\n const workerCount = Math.max(1, Math.min(safe, total))\n async function worker(): Promise<void> {\n for (;;) {\n const index = next\n next += 1\n if (index >= total) return\n await fn(items[index] as I, index)\n }\n }\n await Promise.all(Array.from({ length: workerCount }, () => worker()))\n}\n","/**\n * `toolkit_paths` — the central materialized path index (plan/routing D005/D006,\n * F007). One row per (document, locale) mapping a routable entity's row to its\n * locale-relative canonical URL. Owned by `@murumets-ee/content`; maintained by\n * the `pathIndexWriter` (post-commit) + `pathConflictGuard` (pre-commit) seams\n * on `@murumets-ee/entity`'s `AdminClient`.\n *\n * Uses `defineTable` (per plan/routing C3) so both seams read/write through a\n * typed `TableClient` — zero raw SQL.\n *\n * Uniqueness + lookup (two partial-uniques + one plain index):\n * - `UNIQUE (locale, path) WHERE is_canonical` — no two documents can claim the\n * same canonical URL in the same locale (F007's partial-unique callback form,\n * exactly as Task 1 shipped `defineTable` support for). `is_canonical = false`\n * rows (redirect history) are excluded, so an old path can coexist with the\n * live one holding the same URL. THIS is the routing invariant; it is\n * unchanged across PR03→PR04.\n * - `UNIQUE (entity_type, entity_id, locale) WHERE is_canonical` — at most ONE\n * canonical row per (document, locale). PR03 enforced this as a FULL\n * `UNIQUE (entity_type, entity_id, locale)` (no history yet, so exactly one\n * row per (document, locale) held). PR04 introduced redirect history — a\n * rename DEMOTES the old canonical (`is_canonical = false`) and INSERTS a\n * fresh one — so the FULL unique could no longer hold and migration 0058\n * dropped it. But dropping it outright left NOTHING enforcing \"one canonical\n * per document\": the demote/insert sequence, or two concurrent writers on the\n * same document, could leave TWO permanently-canonical rows — a silent,\n * permanent violation of the invariant resolution / `entity_refs` / the admin\n * UI's \"the URL for a document\" all depend on. This PARTIAL unique restores\n * the guarantee while still allowing history: only `is_canonical = true` rows\n * are in scope, so any number of demoted historical rows may coexist. It also\n * makes the writer's demote-FIRST → insert ordering DB-safe (see\n * `apply-path.ts`).\n * - `INDEX (entity_type, entity_id, locale)` — a PLAIN (non-unique) index, the\n * natural \"all rows of this document\" lookup key used by the delete sweep,\n * the parent-path resolution query, and the writer's per-locale\n * demote-then-insert (PR04). The partial unique above only covers CANONICAL\n * rows, so this plain index is still needed for the history-inclusive\n * lookups. (Migration 0058 drops the old full `..._unique` constraint and\n * creates this plain index in its place; a later PR04 migration adds the\n * partial unique.)\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\n\nexport const toolkitPathsTable = defineTable({\n name: 'toolkit_paths',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityType: column.varchar({ length: 100, notNull: true, pgName: 'entity_type' }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true }),\n /** Locale-relative canonical path: leading '/', no trailing '/'. */\n path: column.text({ notNull: true }),\n isCanonical: column.boolean({ notNull: true, default: true, pgName: 'is_canonical' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [\n // No two documents share a canonical URL per locale (F007 partial-unique).\n // The one true routing invariant — unchanged from PR03.\n { on: ['locale', 'path'], where: (c) => sql`${c.isCanonical} = true` },\n // At most ONE canonical row per (document, locale). Restores (as a partial\n // unique, so redirect history still accumulates) the \"one canonical path per\n // document\" guarantee the full unique dropped in migration 0058 used to give.\n // Historical (is_canonical = false) rows are excluded from the predicate.\n { on: ['entityType', 'entityId', 'locale'], where: (c) => sql`${c.isCanonical} = true` },\n ],\n indexes: [\n // \"All rows of this document\" — canonical + historical. Plain (non-unique)\n // because PR04's redirect history keeps demoted rows here (see file header).\n { on: ['entityType', 'entityId', 'locale'] },\n ],\n})\n\n/** Backwards-compatible raw pgTable export (for the plugin `tables:` slot + migrations). */\nexport const toolkitPaths = toolkitPathsTable.table\n\n/** The full `DefinedTable` for `toolkit_paths`. */\nexport type ToolkitPathsTable = typeof toolkitPathsTable\n\n/** The typed `TableClient` for `toolkit_paths` (`toolkitPathsTable.makeClient(db)`). */\nexport type ToolkitPathsClient = ReturnType<typeof toolkitPathsTable.makeClient>\n"],"mappings":"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"}
|
package/dist/seed.d.mts
CHANGED
|
@@ -137,6 +137,25 @@
|
|
|
137
137
|
declare function seedSiteChrome(): Promise<{
|
|
138
138
|
created: number;
|
|
139
139
|
}>;
|
|
140
|
+
/**
|
|
141
|
+
* Forget every `toolkit_paths` row — canonical and redirect history — for the
|
|
142
|
+
* named entities. For a seeder that empties those entities' tables in bulk.
|
|
143
|
+
*
|
|
144
|
+
* `AdminClient.delete` drops a document's paths when the row is physically
|
|
145
|
+
* removed (the path-index writer's `'delete'` action). A bulk reset that skips
|
|
146
|
+
* the client — the scaffold's demo `content` seeder TRUNCATEs its tables so it
|
|
147
|
+
* can re-run — leaves every old URL claimed by a document that no longer
|
|
148
|
+
* exists. The re-seeded documents then collide with their own ghosts and come
|
|
149
|
+
* back as `/about-2`, `/welcome-2`, and the old URLs keep answering as
|
|
150
|
+
* redirects to nothing (lumi-site PR04).
|
|
151
|
+
*
|
|
152
|
+
* Seeder-only, like the reset it follows: it asks no permission of its own,
|
|
153
|
+
* exactly as a TRUNCATE does not. The seeders that call it already run as an
|
|
154
|
+
* admin — `lumi seed` under `runAsCli`, the Seed page behind its admin gate.
|
|
155
|
+
*
|
|
156
|
+
* Returns how many rows were removed, per entity, so a seeder can log it.
|
|
157
|
+
*/
|
|
158
|
+
declare function forgetEntityPaths(entityNames: readonly string[]): Promise<Record<string, number>>;
|
|
140
159
|
//#endregion
|
|
141
|
-
export { seedSiteChrome };
|
|
160
|
+
export { forgetEntityPaths, seedSiteChrome };
|
|
142
161
|
//# sourceMappingURL=seed.d.mts.map
|
package/dist/seed.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seed.d.mts","names":[],"sources":["../src/seed.ts"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"seed.d.mts","names":[],"sources":["../src/seed.ts"],"mappings":";;AAuOA;;;;AAAyD;AAkFzD;;;;;;;;AAEiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBApFK,cAAA,CAAA,GAAkB,OAAO;EAAG,OAAO;AAAA;;;;;;;;;;;;;;;;;;;iBAkFnC,iBAAA,CACpB,WAAA,sBACC,OAAO,CAAC,MAAA"}
|