@murumets-ee/content 0.94.0 → 0.95.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.
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/content-config-store.ts","../src/plugin.ts"],"mappings":";;;;;;;iBA0CgB,gBAAA,CAAA,GAAoB,aAAa;;;;;;iBCkCjC,OAAA,CAAQ,MAAA,EAAQ,aAAA,GAAa,IAAA,6BAAA,MAAA;EAAA"}
1
+ {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/content-config-store.ts","../src/plugin.ts"],"mappings":";;;;;;;iBA0CgB,gBAAA,CAAA,GAAoB,aAAa;;;;;;iBCmCjC,OAAA,CAAQ,MAAA,EAAQ,aAAA,GAAa,IAAA,6BAAA,MAAA;EAAA"}
package/dist/plugin.mjs CHANGED
@@ -1 +1 @@
1
- import{n as e,r as t,t as n}from"./locale-entity-CoSq3C77.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i,r as a,t as o}from"./routing-strategy-D6B4ngT9.mjs";import{t as s}from"./plugin-C8ETWu3n.mjs";export{n as Locale,a as Message,e as ROUTING_STRATEGIES,s as content,r as getContentConfig,t as i18nSettings,o as localePrefixForStrategy,i as prefixDefaultLocaleForStrategy};
1
+ import{n as e,r as t,t as n}from"./locale-entity-CoSq3C77.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i,r as a,t as o}from"./routing-strategy-D6B4ngT9.mjs";import{t as s}from"./plugin-ChCEq_Qy.mjs";export{n as Locale,a as Message,e as ROUTING_STRATEGIES,s as content,r as getContentConfig,t as i18nSettings,o as localePrefixForStrategy,i as prefixDefaultLocaleForStrategy};
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{t}from"./content-config-store-8omQF5p5.mjs";import{n}from"./schema-BYrYzsmO.mjs";import{i as r,n as i}from"./schema-IML1rRU_.mjs";import{getEntityTitleField as a,isSiteBound as o,isValidSiteHandle as s}from"@murumets-ee/entity";import{createSystemReachResolver as c,getContext as l,readSiteRecord as u,requireRequestSite as d}from"@murumets-ee/core";import{QueryClient as f}from"@murumets-ee/entity/query";import{inArray as p}from"drizzle-orm";import{z as m}from"zod";const h=m.object({entity:m.string().min(1),id:m.string().uuid()});function g(e){let t=h.safeParse(e);return t.success?t.data:null}function _(e){return()=>d(`toolkit_paths`,e)}function v(e){if(!s(e))throw Error(`'${String(e)}' is not a valid site handle`);let t=l()?.site;if(t!==void 0&&t!==e)throw Error(`A request acting on site '${t}' cannot read site '${e}' — a request is confined to its site; start per-site work from a site-less frame.`);return e}function y(e){return v(e),async()=>v(e)}function b(e,t){return t===void 0?_(e):y(t)}function x(e,t){return()=>(v(t),Object.freeze({...e(),site:t}))}function S(e,t){return{findOne:async r=>n.makeClient(e,await t()).findOne(r),findMany:async r=>n.makeClient(e,await t()).findMany(r)}}function C(e,t){return{findOne:async n=>i.makeClient(e,await t()).findOne(n),findMany:async n=>i.makeClient(e,await t()).findMany(n)}}var w=e({buildBreadcrumbDeps:()=>I,buildCanonicalPathDeps:()=>D,buildCanonicalPathsBatchDeps:()=>j,buildEnumerateDeps:()=>A,buildPublishChecker:()=>O,buildRedirectsListDeps:()=>P,buildResolveDeps:()=>F,buildSitemapDeps:()=>N,createHomepageReader:()=>k});const T=c(`path-resolver`);function E(e){return e===void 0?T:x(T,e)}function D(e,t={}){return{pathsClient:S(e.db.readOnly,b(e,t.site))}}function O(e,t={}){let n=E(t.site);return r({entityResolver:t=>e.entities.get(t),queryClientFactory:t=>new f({entity:t,db:e.db.readOnly,logger:e.logger.child({resolve:`publish-check`,entity:t.name}),contextResolver:n})})}function k(e,t){return async()=>{let n=await u(e,await t()),r=n?g(n.homeDocument):null;if(!r)return null;let i=e.entities.get(r.entity);return!i?.frontend?.path||e.config.sites?.multiple===!0&&!o(i)?null:r}}function A(e,t={}){let n=e.db.readOnly,r=E(t.site);return{pathsClient:S(n,b(e,t.site)),filterPublished:async(t,i,a)=>{if(i.length===0)return new Set;let o=e.entities.get(t);if(o===void 0)return new Set;let s=new f({entity:o,db:n,logger:e.logger.child({resolve:`enumerate-publish`,entity:o.name}),contextResolver:r}),c=s.getTable().id,l=await s.findMany({where:p(c,[...i]),locale:a}),u=new Set;for(let e of l){let t=e.id;typeof t==`string`&&u.add(t)}return u}}}function j(e,t={}){return{pathsClient:S(e.db.readOnly,b(e,t.site))}}function M(e,t={}){let n=e.db.readOnly,r=E(t.site);return async(t,i,a)=>{if(i.length===0)return new Set;let o=e.entities.get(t);if(o===void 0||!o.behaviors?.some(e=>e.name===`seoMeta`))return new Set;let s=new f({entity:o,db:n,logger:e.logger.child({resolve:`sitemap-noindex`,entity:o.name}),contextResolver:r}),c=s.getTable().id,l=await s.findMany({where:p(c,[...i]),locale:a}),u=new Set;for(let e of l){let t=e.id,n=e.noIndex;typeof t==`string`&&n===!0&&u.add(t)}return u}}function N(e,t={}){return{enumerate:A(e,t),canonicalBatch:j(e,t),filterNoIndex:M(e,t)}}function P(e,t={}){let n=A(e,t);return{redirectsClient:C(e.db.readOnly,b(e,t.site)),pathsClient:n.pathsClient,canonicalBatch:j(e,t),filterPublished:n.filterPublished}}function F(e,n={}){let r=e.db.readOnly,i=b(e,n.site);return{pathsClient:S(r,i),redirectsClient:C(r,i),publishChecker:O(e,n),homepageReader:k(e,i),locales:t().locales.map(e=>e.code),logger:e.logger}}function I(e,t={}){let n=e.db.readOnly,r=E(t.site);return{pathsClient:S(n,b(e,t.site)),publishedTitles:async(t,i,o)=>{let s=new Map;if(i.length===0)return s;let c=e.entities.get(t);if(c===void 0)return s;let l=a(c);if(l===`id`)return s;let u=new f({entity:c,db:n,logger:e.logger.child({resolve:`breadcrumb-titles`,entity:c.name}),contextResolver:r}),d=u.getTable().id,m=await u.findMany({where:p(d,[...i]),locale:o,limit:i.length});for(let e of m){let t=e,n=t.id,r=t[l];typeof n==`string`&&typeof r==`string`&&r.trim()!==``&&s.set(n,r.trim())}return s}}}export{O as a,N as c,h as d,g as f,A as i,k as l,D as n,P as o,j as r,F as s,I as t,w as u};
2
+ //# sourceMappingURL=resolve-context-D8UXwWwr.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-context-D8UXwWwr.mjs","names":[],"sources":["../src/paths/homepage.ts","../src/paths/site-scope.ts","../src/paths/resolve-context.ts"],"sourcesContent":["/**\n * Homepage pointer — the document that serves a site's `/` (plan/routing PR06,\n * D004; per site since plan `multisite` phase 03, D007).\n *\n * The value is the site registry row's `homeDocument` JSON (it replaced the\n * global `site.homeDocument` setting, which the registry migration copied onto\n * the `default` site; written by `setSiteHomepage` / `lumi site:set-home`). The\n * resolution layer parses it HERE, defensively (CLAUDE.md \"validate at the boundary\"):\n * an unset row OR a value that fails this schema means \"no homepage configured\"\n * (`/` resolves to `none`) — never a thrown error, since this runs on every `/`\n * request.\n *\n * This module is a LEAF (zod only) so the parse is unit-testable without a DB and\n * the resolution core stays free of settings/core imports; the actual\n * registry read is wired in `./resolve-context.ts`.\n */\n\nimport { z } from 'zod'\n\n/** A resolved pointer at the document that serves `/`. */\nexport interface HomepagePointer {\n entity: string\n id: string\n}\n\n/**\n * Shape of the `homeDocument` JSONB value. `id` is a UUID — entity row ids\n * are UUIDs across this codebase (only USER ids are varchar; CLAUDE.md).\n */\nexport const homepagePointerSchema = z.object({\n entity: z.string().min(1),\n id: z.string().uuid(),\n})\n\n/**\n * Parse a raw `homeDocument` value into a {@link HomepagePointer}, or `null`\n * when it's unset / malformed. Never throws.\n */\nexport function parseHomepagePointer(value: unknown): HomepagePointer | null {\n const parsed = homepagePointerSchema.safeParse(value)\n return parsed.success ? parsed.data : null\n}\n","/**\n * Which site a path-index / redirect access acts on (plan `multisite` phase 03).\n *\n * `toolkit_paths` and `toolkit_redirects` are partitioned by site: a client\n * cannot be built without one (`makeClient(db, site)`). This module is how\n * content's resolution doors get one without every caller threading it:\n *\n * - A {@link SiteSource} answers \"which site?\" lazily, AT CALL TIME — so a deps\n * bundle built once per app (the block resolver's, a menu resolver's) still\n * reads the site of the request that is running, never the site of whoever\n * built it first.\n * - {@link requestSiteSource} is the request's site: its explicit\n * `RequestContext.site`, else the single-site answer (D010), else a\n * `SiteRequiredError`. A multi-site app never gets a default.\n * - {@link pinnedSiteSource} is an explicit site (a per-site sitemap). It is a\n * narrowing only: inside a request already acting on ANOTHER site it throws,\n * because reading a second site from a request confined to the first is a\n * lateral move, not a narrowing.\n *\n * The readers built from a source ({@link sitePathsReader},\n * {@link siteRedirectsReader}) build a partitioned client per call — a\n * constructor, no I/O — so they are exactly as confined as the partition.\n */\n\nimport { getContext, requireRequestSite, type ToolkitApp } from '@murumets-ee/core'\nimport type { SecurityContext } from '@murumets-ee/entity'\nimport { isValidSiteHandle } from '@murumets-ee/entity'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { type ToolkitRedirectsClient, toolkitRedirectsTable } from '../redirects/schema.js'\nimport { type ToolkitPathsClient, toolkitPathsTable } from './schema.js'\n\n/** Resolves the site an access acts on — see the module docblock. */\nexport type SiteSource = () => Promise<string>\n\n/** The subject named in a `SiteRequiredError` from these doors. */\nconst SUBJECT = 'toolkit_paths'\n\n/** The request's site, resolved at call time. */\nexport function requestSiteSource(app?: ToolkitApp): SiteSource {\n return () => requireRequestSite(SUBJECT, app)\n}\n\n/** Assert `site` is a handle and that the ambient request is not confined to another site. */\nexport function assertPinnableSite(site: string): string {\n if (!isValidSiteHandle(site)) throw new Error(`'${String(site)}' is not a valid site handle`)\n const ambient = getContext()?.site\n if (ambient !== undefined && ambient !== site) {\n throw new Error(\n `A request acting on site '${ambient}' cannot read site '${site}' — a request is ` +\n 'confined to its site; start per-site work from a site-less frame.',\n )\n }\n return site\n}\n\n/** An explicit site — see the module docblock. Throws at CALL time on a lateral move. */\nexport function pinnedSiteSource(site: string): SiteSource {\n assertPinnableSite(site)\n return async () => assertPinnableSite(site)\n}\n\n/** `pinned` when given, else the request's site. */\nexport function siteSourceFor(app: ToolkitApp, pinned?: string): SiteSource {\n return pinned === undefined ? requestSiteSource(app) : pinnedSiteSource(pinned)\n}\n\n/**\n * A reach resolver confined to `site` — for the publish / noIndex checks behind\n * a pinned deps bundle, which must read the SAME site as the paths they check.\n * Carries the base resolver's checker (reach only) and nothing else of the\n * ambient request.\n */\nexport function pinReachResolver(base: () => SecurityContext, site: string): () => SecurityContext {\n return () => {\n assertPinnableSite(site)\n // An explicit `site` wins over any implicit-site resolver the base carries\n // (the data layer consults that only when no site is named).\n return Object.freeze({ ...base(), site })\n }\n}\n\n/** `toolkit_paths` reads for the source's site. */\nexport function sitePathsReader(\n db: PostgresJsDatabase,\n siteOf: SiteSource,\n): Pick<ToolkitPathsClient, 'findOne' | 'findMany'> {\n return {\n findOne: async (where) => toolkitPathsTable.makeClient(db, await siteOf()).findOne(where),\n findMany: async (options) => toolkitPathsTable.makeClient(db, await siteOf()).findMany(options),\n }\n}\n\n/** `toolkit_redirects` reads for the source's site. */\nexport function siteRedirectsReader(\n db: PostgresJsDatabase,\n siteOf: SiteSource,\n): Pick<ToolkitRedirectsClient, 'findOne' | 'findMany'> {\n return {\n findOne: async (where) => toolkitRedirectsTable.makeClient(db, await siteOf()).findOne(where),\n findMany: async (options) =>\n toolkitRedirectsTable.makeClient(db, await siteOf()).findMany(options),\n }\n}\n","/**\n * App-binding for the path-resolution core (plan/routing PR06).\n *\n * `./resolve.ts` is a LEAF — it takes every dependency (path/redirect readers,\n * publish checker, homepage reader) as injected params so it imports no\n * `@murumets-ee/core` graph. THIS module is the outer seam that holds those\n * imports and builds concrete `deps` from a live `ToolkitApp`. Only the\n * request-time resolution / enumeration surfaces (future admin routes,\n * content-api, a frontend catch-all) reach this module — never the `lumi` CLI\n * command graph (F027 guardrail; keeps the `server-only`-adjacent imports out of\n * `paths/jobs.ts`).\n *\n * **Publish check runs under an ELEVATED system context** (mirrors\n * `../locale-registry.ts`'s `SYSTEM_CONTEXT`), NOT the ambient request context.\n * Rationale + security: URL resolution is a SYSTEM derivation that must work for\n * any caller — an anonymous public request, or a build-time enumeration with no\n * request context at all — and must not fail just because the caller lacks a\n * per-entity `view` grant. The elevated context only bypasses the\n * permission/scope check; the PUBLISH FILTER (`QueryClient.buildPublishFilter`)\n * and the trashed-row exclusion still apply, so an unpublished / trashed\n * document still resolves to `null` → not routable.\n *\n * Since D017 that publish filter is CONDITIONAL — it holds for any principal\n * WITHOUT `view_unpublished`, and is lifted for one that holds it. What makes\n * the paragraph above true is therefore not the filter being unconditional but\n * {@link SYSTEM_CONTEXT} WITHHOLDING that action (and every write action with\n * it). No content data is returned from the check (only a boolean), so\n * elevation leaks nothing — the downstream content fetch still runs under the\n * caller's real permissions.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { createSystemReachResolver, readSiteRecord } from '@murumets-ee/core'\nimport {\n type Entity,\n getEntityTitleField,\n isSiteBound,\n type SecurityContext,\n} from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { inArray } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { getContentConfig } from '../content-config-store.js'\nimport type { BreadcrumbTrailDeps } from './breadcrumbs.js'\nimport type { CanonicalPathsBatchDeps, EnumeratePathsDeps, PublishFilter } from './enumerate.js'\nimport { parseHomepagePointer } from './homepage.js'\nimport type { RedirectsListDeps } from './redirects-list.js'\nimport {\n type AlternatesDeps,\n type CanonicalPathDeps,\n createPublishChecker,\n type HomepageReader,\n type PublishChecker,\n type ResolvePathDeps,\n} from './resolve.js'\nimport {\n pinReachResolver,\n type SiteSource,\n sitePathsReader,\n siteRedirectsReader,\n siteSourceFor,\n} from './site-scope.js'\nimport type { SitemapDeps } from './sitemap.js'\n\n/**\n * Every builder below is SITE-CONFINED (plan `multisite` phase 03): its\n * `toolkit_paths` / `toolkit_redirects` readers, its publish checks and its\n * homepage reader all act on ONE site.\n *\n * - Omit `site` and that is the site of the request RUNNING the call (resolved\n * at call time, so a bundle built once per app still follows each request) —\n * its explicit `RequestContext.site`, else the single-site answer, else a\n * `SiteRequiredError`. This is every existing caller, unchanged.\n * - Pass `site` to pin one explicitly (a per-site sitemap or warmer run from a\n * site-less job). Pinning inside a request confined to another site throws.\n */\nexport interface SiteScopeOptions {\n site?: string\n}\n\n/**\n * Elevated **reach** resolver for the publish check — the roles/locale-registry\n * -read analog (see file header for why this is safe).\n *\n * It grants `view` and NOTHING else — in particular not `view_unpublished`,\n * which is what makes the file header's claim true. Until D017 the header's\n * \"the publish filter is applied by `QueryClient` regardless\" held for ANY\n * checker; now that publish-visibility IS a permission, a `checker: () => true`\n * here would answer \"yes\" to `view_unpublished` and every door this module backs\n * (`_resolve`, `_alternates`, `_paths`, sitemap, redirects) would start\n * resolving drafts to public URLs. {@link createSystemReachResolver} owns that\n * exception centrally so it can't be re-derived (or forgotten) per call site.\n *\n * The explicit id is the audit identity: it must be distinguishable from the\n * menu resolver's, or two elevated readers are one indistinguishable `'system'`\n * in the log.\n */\nconst SYSTEM_CONTEXT: () => SecurityContext = createSystemReachResolver('path-resolver')\n\n/** {@link SYSTEM_CONTEXT}, confined to `site` when one is pinned. */\nfunction systemContextFor(site: string | undefined): () => SecurityContext {\n return site === undefined ? SYSTEM_CONTEXT : pinReachResolver(SYSTEM_CONTEXT, site)\n}\n\n/**\n * Build the minimal deps for {@link import('./resolve.js').getCanonicalPath} —\n * just the `toolkit_paths` reader. Task 2's `resolveEntityHref` / search\n * url-builder re-points use this (no publish checker / settings machinery).\n */\nexport function buildCanonicalPathDeps(\n app: ToolkitApp,\n options: SiteScopeOptions = {},\n): CanonicalPathDeps {\n return { pathsClient: sitePathsReader(app.db.readOnly, siteSourceFor(app, options.site)) }\n}\n\n/**\n * Build a live {@link PublishChecker} that reads through an elevated-context\n * `QueryClient` per entity (publish filter auto-applied). See file header.\n */\nexport function buildPublishChecker(\n app: ToolkitApp,\n options: SiteScopeOptions = {},\n): PublishChecker {\n const contextResolver = systemContextFor(options.site)\n return createPublishChecker({\n entityResolver: (name) => app.entities.get(name),\n queryClientFactory: (entity: Entity) =>\n new QueryClient({\n entity,\n db: app.db.readOnly,\n logger: app.logger.child({ resolve: 'publish-check', entity: entity.name }),\n contextResolver,\n }),\n })\n}\n\n/**\n * Build the homepage reader for a site (plan `multisite` D007): the site\n * registry row's `homeDocument` (`readSiteRecord` — read with reach, cached\n * briefly), zod-parsed defensively (unset / malformed → `null`, never a throw\n * — this runs on every `/` request).\n *\n * The pointer is only as good as the resolution that follows it: `/` publish-\n * checks the document through a client confined to the SAME site, so a pointer\n * at another site's document (legacy data, a hand-edited row) resolves to\n * `none` instead of serving it (D006, defence in depth — writes are refused\n * by `setSiteHomepage`).\n */\nexport function createHomepageReader(app: ToolkitApp, siteOf: SiteSource): HomepageReader {\n return async () => {\n const record = await readSiteRecord(app, await siteOf())\n const pointer = record ? parseHomepagePointer(record.homeDocument) : null\n if (!pointer) return null\n // Only a routable document may serve `/` — and on an app serving several\n // sites only a site-bound one (a shared row belongs to no site's `/`).\n const entity = app.entities.get(pointer.entity)\n if (!entity?.frontend?.path) return null\n if (app.config.sites?.multiple === true && !isSiteBound(entity)) return null\n return pointer\n }\n}\n\n/**\n * Build the deps for {@link import('./enumerate.js').enumeratePaths} — the\n * `toolkit_paths` reader plus a BATCHED publish filter. `filterPublished` runs\n * ONE elevated-context `QueryClient.findMany({ where: inArray(getTable().id,\n * ids), locale })` per entity-type group (not per row) — the trash filter\n * applies unconditionally, and the publish filter applies because the elevated\n * context WITHHOLDS `view_unpublished` (D017; see file header), so no\n * unpublished/trashed id is returned. An unknown/unregistered entity type yields the empty set (its\n * rows are treated as unpublished → dropped, fail-closed).\n */\nexport function buildEnumerateDeps(\n app: ToolkitApp,\n options: SiteScopeOptions = {},\n): EnumeratePathsDeps {\n const db = app.db.readOnly\n const contextResolver = systemContextFor(options.site)\n return {\n pathsClient: sitePathsReader(db, siteSourceFor(app, options.site)),\n filterPublished: async (entityType, entityIds, locale) => {\n if (entityIds.length === 0) return new Set()\n const entity = app.entities.get(entityType)\n if (entity === undefined) return new Set()\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'enumerate-publish', entity: entity.name }),\n contextResolver,\n })\n // Dynamic `id` column ref for the batched IN filter. `getTable()` is\n // `PgTableWithColumns<any>`; the structural cast narrows to the one column\n // we filter on (the same idiom paths/jobs.ts uses for dynamic column refs).\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n const rows = await client.findMany({ where: inArray(idColumn, [...entityIds]), locale })\n const published = new Set<string>()\n for (const row of rows) {\n const id = (row as { id?: unknown }).id\n if (typeof id === 'string') published.add(id)\n }\n return published\n },\n }\n}\n\n/**\n * Build the deps for {@link import('./enumerate.js').getCanonicalPaths} — just\n * the `toolkit_paths` reader (batched, NO publish filter, like\n * {@link buildCanonicalPathDeps}). Task 2's search url re-point uses this to\n * resolve a search page's result urls from the index in one query.\n */\nexport function buildCanonicalPathsBatchDeps(\n app: ToolkitApp,\n options: SiteScopeOptions = {},\n): CanonicalPathsBatchDeps {\n return { pathsClient: sitePathsReader(app.db.readOnly, siteSourceFor(app, options.site)) }\n}\n\n/**\n * Build the `filterNoIndex` dep for {@link import('./sitemap.js').generateSitemap}\n * (PR 11's `seoMeta()`) — mirrors {@link buildEnumerateDeps}'s `filterPublished`\n * construction EXACTLY (elevated-context `QueryClient`, one `findMany` per\n * entity-type group), with two differences:\n *\n * 1. Short-circuits to the empty set when the entity does NOT declare\n * `seoMeta()` — most routable entities won't have this behavior, and\n * querying a `noIndex` column that doesn't exist would throw.\n * 2. Filters on the LOCALE-MERGED DTO rather than a raw SQL column, because\n * `noIndex` is `translatable: true` — its live per-locale value only\n * exists after `QueryClient.findMany`'s `mergeTranslations` step, not as a\n * column on the main table itself (unlike `filterPublished`'s `status`,\n * which is never translatable). The `id IN (...)` filter is still pushed\n * to SQL; only the `noIndex` check runs over the (batch-bounded) merged\n * rows in memory.\n */\nfunction buildNoIndexFilter(app: ToolkitApp, options: SiteScopeOptions = {}): PublishFilter {\n const db = app.db.readOnly\n const contextResolver = systemContextFor(options.site)\n return async (entityType, entityIds, locale) => {\n if (entityIds.length === 0) return new Set()\n const entity = app.entities.get(entityType)\n if (entity === undefined) return new Set()\n if (!entity.behaviors?.some((b) => b.name === 'seoMeta')) return new Set()\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'sitemap-noindex', entity: entity.name }),\n contextResolver,\n })\n // Dynamic `id` column ref for the batched IN filter — same idiom as\n // `buildEnumerateDeps.filterPublished`.\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n const rows = await client.findMany({ where: inArray(idColumn, [...entityIds]), locale })\n const noIndexed = new Set<string>()\n for (const row of rows) {\n const id = (row as { id?: unknown }).id\n const noIndex = (row as { noIndex?: unknown }).noIndex\n if (typeof id === 'string' && noIndex === true) noIndexed.add(id)\n }\n return noIndexed\n }\n}\n\n/**\n * Build the deps for {@link import('./sitemap.js').generateSitemap} — the\n * one-stop bundle of {@link buildEnumerateDeps} (per-locale enumeration + its\n * BATCHED publish filter, reused for the cross-locale alternate publish gate),\n * {@link buildCanonicalPathsBatchDeps} (the batched cross-locale forward index\n * read), and {@link buildNoIndexFilter} (PR 11's batched `noIndex` exclusion).\n * Mirrors `buildResolveDeps`'s one-stop-deps pattern.\n */\nexport function buildSitemapDeps(app: ToolkitApp, options: SiteScopeOptions = {}): SitemapDeps {\n return {\n enumerate: buildEnumerateDeps(app, options),\n canonicalBatch: buildCanonicalPathsBatchDeps(app, options),\n filterNoIndex: buildNoIndexFilter(app, options),\n }\n}\n\n/**\n * Build the deps for {@link import('./redirects-list.js').getRedirects} — the\n * one-stop bundle for the build-time redirect list (PR08). Composes\n * {@link buildEnumerateDeps} (its `toolkit_paths` reader powers both the\n * historical keyset and the `path`-kind existence check; its BATCHED\n * `filterPublished` is the shared publish gate), {@link buildCanonicalPathsBatchDeps}\n * (the batched owning-document current-canonical lookup), and a\n * `toolkit_redirects` reader for the manual-redirect keyset. Mirrors\n * {@link buildSitemapDeps}'s one-stop-deps style.\n */\nexport function buildRedirectsListDeps(\n app: ToolkitApp,\n options: SiteScopeOptions = {},\n): RedirectsListDeps {\n const enumerate = buildEnumerateDeps(app, options)\n return {\n redirectsClient: siteRedirectsReader(app.db.readOnly, siteSourceFor(app, options.site)),\n pathsClient: enumerate.pathsClient,\n canonicalBatch: buildCanonicalPathsBatchDeps(app, options),\n filterPublished: enumerate.filterPublished,\n }\n}\n\n/**\n * Build the full {@link ResolvePathDeps} (+ {@link AlternatesDeps}) from an app —\n * the one-stop deps for `resolvePath` / `resolveDraftPath` / `getAlternates`.\n * `locales` are the statically-configured content locales (v1 — matches the\n * redirect routes' own configured-locale source; DB-merged locale overrides are\n * a later refinement).\n */\nexport function buildResolveDeps(\n app: ToolkitApp,\n options: SiteScopeOptions = {},\n): ResolvePathDeps & AlternatesDeps {\n const db = app.db.readOnly\n const siteOf = siteSourceFor(app, options.site)\n return {\n pathsClient: sitePathsReader(db, siteOf),\n redirectsClient: siteRedirectsReader(db, siteOf),\n publishChecker: buildPublishChecker(app, options),\n homepageReader: createHomepageReader(app, siteOf),\n locales: getContentConfig().locales.map((l) => l.code),\n // Only consumer: the historical-redirect lookup's truncation warning.\n logger: app.logger,\n }\n}\n\n/**\n * Build the deps for {@link import('./breadcrumbs.js').resolveBreadcrumbTrail}:\n * the site-confined `toolkit_paths` reader and a publish-gated TITLE read — the\n * same elevated REACH read `buildEnumerateDeps.filterPublished` makes (it\n * withholds `view_unpublished`, so an unpublished or trashed ancestor comes\n * back as nothing), projecting the entity's title column instead of just the id.\n */\nexport function buildBreadcrumbDeps(\n app: ToolkitApp,\n options: SiteScopeOptions = {},\n): BreadcrumbTrailDeps {\n const db = app.db.readOnly\n const contextResolver = systemContextFor(options.site)\n return {\n pathsClient: sitePathsReader(db, siteSourceFor(app, options.site)),\n publishedTitles: async (entityType, ids, locale) => {\n const titles = new Map<string, string>()\n if (ids.length === 0) return titles\n const entity = app.entities.get(entityType)\n if (entity === undefined) return titles\n const titleField = getEntityTitleField(entity)\n // A uuid is not a title: an entity with no title column is no breadcrumb.\n if (titleField === 'id') return titles\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'breadcrumb-titles', entity: entity.name }),\n contextResolver,\n })\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n const rows = await client.findMany({\n where: inArray(idColumn, [...ids]),\n locale,\n limit: ids.length,\n })\n for (const row of rows) {\n const record = row as Record<string, unknown>\n const id = record.id\n const title = record[titleField]\n if (typeof id === 'string' && typeof title === 'string' && title.trim() !== '') {\n titles.set(id, title.trim())\n }\n }\n return titles\n },\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"resolve.d.mts","names":[],"sources":["../src/redirects/target.ts","../src/paths/index-reader.ts","../src/paths/homepage.ts","../src/paths/resolve.ts","../src/paths/enumerate.ts","../src/paths/sitemap.ts","../src/paths/site-sitemap.ts","../src/paths/redirects-list.ts","../src/paths/site-scope.ts","../src/paths/resolve-context.ts"],"mappings":"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"}
1
+ {"version":3,"file":"resolve.d.mts","names":[],"sources":["../src/redirects/target.ts","../src/paths/index-reader.ts","../src/paths/homepage.ts","../src/paths/resolve.ts","../src/paths/enumerate.ts","../src/paths/sitemap.ts","../src/paths/site-sitemap.ts","../src/paths/redirects-list.ts","../src/paths/site-scope.ts","../src/paths/resolve-context.ts"],"mappings":"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"}
package/dist/resolve.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import{i as e,t}from"./locale-registry-Bn6e_nM7.mjs";import{c as n}from"./schema-BYrYzsmO.mjs";import{a as r,c as i,d as a,i as o,o as s,r as c,s as l}from"./schema-IML1rRU_.mjs";import{a as u,c as d,d as f,i as p,n as m,o as h,r as g,s as _,t as v,u as y}from"./resolve-context-Cvc1aNJe.mjs";import{r as b,t as x}from"./enumerate-CYHJKQz6.mjs";import{readSiteRecord as S}from"@murumets-ee/core";const C=4e4,w=`sitemap.xml`;async function*T(e,t,n={}){let r=I(e.maxUrlsPerFile,C),i=I(n.batchSize,200),a=N(e.baseUrl),o=[];for(let s of e.locales){let c=[],l=null,u=0,d=()=>{if(c.length===0)return null;let e=F(s,u),t=(l??new Date).toISOString(),n={kind:`urlset`,filename:e,xml:O(c)};return o.push({filename:e,lastmod:t}),c=[],l=null,u+=1,n},f=x({locale:s},t.enumerate,n.enumerate);for await(let n of L(f,i)){let i=await D(n,s,t);if(i.length===0)continue;let o=await E(i,s,e,t);for(let t of i){let n=o.get(t.id)??[{locale:s,path:t.path}];if(c.push(k(t,s,n,e,a)),(l===null||t.updatedAt>l)&&(l=t.updatedAt),c.length>=r){let e=d();e&&(yield e)}}}let p=d();p&&(yield p)}o.length>1&&(yield{kind:`index`,filename:w,xml:A(o,a)})}async function E(e,t,n,r){let i=new Map;for(let n of e)i.set(n.id,[{locale:t,path:n.path}]);let a=n.locales.filter(e=>e!==t);if(a.length===0)return i;let o=new Map;for(let t of e){let e=o.get(t.entityType);e?e.push(t.id):o.set(t.entityType,[t.id])}for(let e of a)for(let[t,n]of o){let a=await b(t,n,e,r.canonicalBatch);if(a.size===0)continue;let o=[...a.keys()],s=await r.enumerate.filterPublished(t,o,e),c=r.filterNoIndex===void 0?void 0:await r.filterNoIndex(t,o,e);for(let[t,n]of a){if(!s.has(t)||c?.has(t))continue;let r=i.get(t);r&&r.push({locale:e,path:n})}}return i}async function D(e,t,r){let i=r.filterNoIndex;if(i===void 0)return[...e];let a=new Map;for(let t of e){let e=a.get(t.entityType);e?e.push(t.id):a.set(t.entityType,[t.id])}let o=new Map;return await n([...a.entries()],5,async([e,n])=>{o.set(e,await i(e,n,t))}),e.filter(e=>!o.get(e.entityType)?.has(e.id))}function O(e){return`<?xml version="1.0" encoding="UTF-8"?>
1
+ import{i as e,t}from"./locale-registry-Bn6e_nM7.mjs";import{c as n}from"./schema-BYrYzsmO.mjs";import{a as r,c as i,d as a,i as o,o as s,r as c,s as l}from"./schema-IML1rRU_.mjs";import{a as u,c as d,d as f,f as p,i as m,l as h,n as g,o as _,r as v,s as y}from"./resolve-context-D8UXwWwr.mjs";import{r as b,t as x}from"./enumerate-CYHJKQz6.mjs";import{readSiteRecord as S}from"@murumets-ee/core";const C=4e4,w=`sitemap.xml`;async function*T(e,t,n={}){let r=I(e.maxUrlsPerFile,C),i=I(n.batchSize,200),a=N(e.baseUrl),o=[];for(let s of e.locales){let c=[],l=null,u=0,d=()=>{if(c.length===0)return null;let e=F(s,u),t=(l??new Date).toISOString(),n={kind:`urlset`,filename:e,xml:O(c)};return o.push({filename:e,lastmod:t}),c=[],l=null,u+=1,n},f=x({locale:s},t.enumerate,n.enumerate);for await(let n of L(f,i)){let i=await D(n,s,t);if(i.length===0)continue;let o=await E(i,s,e,t);for(let t of i){let n=o.get(t.id)??[{locale:s,path:t.path}];if(c.push(k(t,s,n,e,a)),(l===null||t.updatedAt>l)&&(l=t.updatedAt),c.length>=r){let e=d();e&&(yield e)}}}let p=d();p&&(yield p)}o.length>1&&(yield{kind:`index`,filename:w,xml:A(o,a)})}async function E(e,t,n,r){let i=new Map;for(let n of e)i.set(n.id,[{locale:t,path:n.path}]);let a=n.locales.filter(e=>e!==t);if(a.length===0)return i;let o=new Map;for(let t of e){let e=o.get(t.entityType);e?e.push(t.id):o.set(t.entityType,[t.id])}for(let e of a)for(let[t,n]of o){let a=await b(t,n,e,r.canonicalBatch);if(a.size===0)continue;let o=[...a.keys()],s=await r.enumerate.filterPublished(t,o,e),c=r.filterNoIndex===void 0?void 0:await r.filterNoIndex(t,o,e);for(let[t,n]of a){if(!s.has(t)||c?.has(t))continue;let r=i.get(t);r&&r.push({locale:e,path:n})}}return i}async function D(e,t,r){let i=r.filterNoIndex;if(i===void 0)return[...e];let a=new Map;for(let t of e){let e=a.get(t.entityType);e?e.push(t.id):a.set(t.entityType,[t.id])}let o=new Map;return await n([...a.entries()],5,async([e,n])=>{o.set(e,await i(e,n,t))}),e.filter(e=>!o.get(e.entityType)?.has(e.id))}function O(e){return`<?xml version="1.0" encoding="UTF-8"?>
2
2
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${e.join(`
3
3
  `)}\n</urlset>\n`}function k(e,t,n,r,i){let a=M(i,r.prefix,t,e.path),o=n.map(e=>` ${j(e.locale,M(i,r.prefix,e.locale,e.path))}`),s=n.find(e=>e.locale===r.defaultLocale);return s&&o.push(` ${j(`x-default`,M(i,r.prefix,s.locale,s.path))}`),` <url>
4
4
  <loc>${P(a)}</loc>\n <lastmod>${e.updatedAt.toISOString()}</lastmod>\n${o.join(`
5
5
  `)}\n </url>`}function A(e,t){return`<?xml version="1.0" encoding="UTF-8"?>
6
6
  <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${e.map(e=>` <sitemap>
7
7
  <loc>${P(`${t}/${e.filename}`)}</loc>\n <lastmod>${e.lastmod}</lastmod>\n </sitemap>`).join(`
8
- `)}\n</sitemapindex>\n`}function j(e,t){return`<xhtml:link rel="alternate" hreflang="${P(e)}" href="${P(t)}"/>`}function M(e,t,n,r){return`${e}${t?t(n):``}${r}`}function N(e){return e.replace(/\/+$/,``)}function P(e){return e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&apos;`)}function F(e,t){return t===0?`sitemap-${e}.xml`:`sitemap-${e}-${t+1}.xml`}function I(e,t){if(e===void 0||!Number.isFinite(e))return t;let n=Math.floor(e);return n>=1?n:t}async function*L(e,t){let n=[];for await(let r of e)n.push(r),n.length>=t&&(yield n,n=[]);n.length>0&&(yield n)}async function*R(n,r,i={},a={}){let o=await S(n,r);if(!o)throw Error(`There is no site '${r}'`);let s=o.origins[0];if(s===void 0)throw Error(`Site '${r}' declares no public origin — its sitemap has no URLs to give`);let{entries:c,i18n:l}=await e(n,{site:r});yield*T({locales:t(c),defaultLocale:l.defaultLocale,baseUrl:s,...i.prefix!==void 0&&{prefix:i.prefix},...i.maxUrlsPerFile!==void 0&&{maxUrlsPerFile:i.maxUrlsPerFile}},_(n,{site:r}),a)}async function*z(e,t,n={}){let{locale:r}=e,i=n.pageSize??200,a=new Set;yield*B(r,r,t,i,a),yield*B(r,null,t,i,a),yield*U(r,t,i,a)}async function*B(e,t,n,r,i){let o;for(;;){let s={locale:t};o!==void 0&&(s.id={gt:o});let c=await n.redirectsClient.findMany({where:s,orderBy:[{column:`id`,dir:`asc`}],limit:r});if(c.length===0)break;let l=[];for(let e of c)i.has(e.fromPath)||(i.add(e.fromPath),l.push(e));let u=await V(l,e,n);for(let t of l){let r=W(t.status),i=t.target;if(i.kind===`url`){yield{from:t.fromPath,to:i.url,status:r};continue}if(i.kind===`entity`){let e=u.get(t.id);e!==void 0&&e!==t.fromPath&&(yield{from:t.fromPath,to:e,status:r});continue}let o=a(i.path);o!==t.fromPath&&await H(n.pathsClient,e,o)&&(yield{from:t.fromPath,to:o,status:r})}let d=c[c.length-1];if(!d||(o=d.id,c.length<r))break}}async function V(e,t,n){let r=new Map,i=new Map;for(let t of e){if(t.target.kind!==`entity`)continue;let e={rowId:t.id,targetId:t.target.id},n=i.get(t.target.entity);n?n.push(e):i.set(t.target.entity,[e])}for(let[e,a]of i){let i=await b(e,a.map(e=>e.targetId),t,n.canonicalBatch),o=[...i.keys()];if(o.length===0)continue;let s=await n.filterPublished(e,o,t);for(let{rowId:e,targetId:t}of a){let n=i.get(t);n!==void 0&&s.has(t)&&r.set(e,n)}}return r}async function H(e,t,n){return(await e.findMany({where:{locale:t,path:n,isCanonical:!0},limit:1})).length>0}async function*U(e,t,n,r){let i=new Set,a;for(;;){let o={locale:e,isCanonical:!1};a!==void 0&&(o.id={gt:a});let s=await t.pathsClient.findMany({where:o,orderBy:[{column:`id`,dir:`asc`}],limit:n});if(s.length===0)break;let c=new Map;for(let e of s){let t=c.get(e.entityType);t?t.push(e):c.set(e.entityType,[e])}let l=new Map;for(let[n,r]of c){let i=await b(n,r.map(e=>e.entityId),e,t.canonicalBatch),a=[...i.keys()];if(a.length===0)continue;let o=await t.filterPublished(n,a,e);for(let[e,t]of i)o.has(e)&&l.set(e,t)}for(let e of s){if(r.has(e.path)||i.has(e.path))continue;let t=l.get(e.entityId);t!==void 0&&t!==e.path&&(i.add(e.path),yield{from:e.path,to:t,status:301})}let u=s[s.length-1];if(!u||(a=u.id,s.length<n))break}}function W(e){return e===302?302:301}export{C as DEFAULT_MAX_URLS_PER_FILE,c as MAX_REDIRECT_HOPS,w as SITEMAP_INDEX_FILENAME,v as buildCanonicalPathDeps,m as buildCanonicalPathsBatchDeps,g as buildEnumerateDeps,p as buildPublishChecker,u as buildRedirectsListDeps,h as buildResolveDeps,_ as buildSitemapDeps,d as createHomepageReader,o as createPublishChecker,x as enumeratePaths,R as generateSiteSitemap,T as generateSitemap,r as getAlternates,s as getCanonicalPath,b as getCanonicalPaths,z as getRedirects,y as homepagePointerSchema,f as parseHomepagePointer,l as resolveDraftPath,i as resolvePath};
8
+ `)}\n</sitemapindex>\n`}function j(e,t){return`<xhtml:link rel="alternate" hreflang="${P(e)}" href="${P(t)}"/>`}function M(e,t,n,r){return`${e}${t?t(n):``}${r}`}function N(e){return e.replace(/\/+$/,``)}function P(e){return e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&apos;`)}function F(e,t){return t===0?`sitemap-${e}.xml`:`sitemap-${e}-${t+1}.xml`}function I(e,t){if(e===void 0||!Number.isFinite(e))return t;let n=Math.floor(e);return n>=1?n:t}async function*L(e,t){let n=[];for await(let r of e)n.push(r),n.length>=t&&(yield n,n=[]);n.length>0&&(yield n)}async function*R(n,r,i={},a={}){let o=await S(n,r);if(!o)throw Error(`There is no site '${r}'`);let s=o.origins[0];if(s===void 0)throw Error(`Site '${r}' declares no public origin — its sitemap has no URLs to give`);let{entries:c,i18n:l}=await e(n,{site:r});yield*T({locales:t(c),defaultLocale:l.defaultLocale,baseUrl:s,...i.prefix!==void 0&&{prefix:i.prefix},...i.maxUrlsPerFile!==void 0&&{maxUrlsPerFile:i.maxUrlsPerFile}},d(n,{site:r}),a)}async function*z(e,t,n={}){let{locale:r}=e,i=n.pageSize??200,a=new Set;yield*B(r,r,t,i,a),yield*B(r,null,t,i,a),yield*U(r,t,i,a)}async function*B(e,t,n,r,i){let o;for(;;){let s={locale:t};o!==void 0&&(s.id={gt:o});let c=await n.redirectsClient.findMany({where:s,orderBy:[{column:`id`,dir:`asc`}],limit:r});if(c.length===0)break;let l=[];for(let e of c)i.has(e.fromPath)||(i.add(e.fromPath),l.push(e));let u=await V(l,e,n);for(let t of l){let r=W(t.status),i=t.target;if(i.kind===`url`){yield{from:t.fromPath,to:i.url,status:r};continue}if(i.kind===`entity`){let e=u.get(t.id);e!==void 0&&e!==t.fromPath&&(yield{from:t.fromPath,to:e,status:r});continue}let o=a(i.path);o!==t.fromPath&&await H(n.pathsClient,e,o)&&(yield{from:t.fromPath,to:o,status:r})}let d=c[c.length-1];if(!d||(o=d.id,c.length<r))break}}async function V(e,t,n){let r=new Map,i=new Map;for(let t of e){if(t.target.kind!==`entity`)continue;let e={rowId:t.id,targetId:t.target.id},n=i.get(t.target.entity);n?n.push(e):i.set(t.target.entity,[e])}for(let[e,a]of i){let i=await b(e,a.map(e=>e.targetId),t,n.canonicalBatch),o=[...i.keys()];if(o.length===0)continue;let s=await n.filterPublished(e,o,t);for(let{rowId:e,targetId:t}of a){let n=i.get(t);n!==void 0&&s.has(t)&&r.set(e,n)}}return r}async function H(e,t,n){return(await e.findMany({where:{locale:t,path:n,isCanonical:!0},limit:1})).length>0}async function*U(e,t,n,r){let i=new Set,a;for(;;){let o={locale:e,isCanonical:!1};a!==void 0&&(o.id={gt:a});let s=await t.pathsClient.findMany({where:o,orderBy:[{column:`id`,dir:`asc`}],limit:n});if(s.length===0)break;let c=new Map;for(let e of s){let t=c.get(e.entityType);t?t.push(e):c.set(e.entityType,[e])}let l=new Map;for(let[n,r]of c){let i=await b(n,r.map(e=>e.entityId),e,t.canonicalBatch),a=[...i.keys()];if(a.length===0)continue;let o=await t.filterPublished(n,a,e);for(let[e,t]of i)o.has(e)&&l.set(e,t)}for(let e of s){if(r.has(e.path)||i.has(e.path))continue;let t=l.get(e.entityId);t!==void 0&&t!==e.path&&(i.add(e.path),yield{from:e.path,to:t,status:301})}let u=s[s.length-1];if(!u||(a=u.id,s.length<n))break}}function W(e){return e===302?302:301}export{C as DEFAULT_MAX_URLS_PER_FILE,c as MAX_REDIRECT_HOPS,w as SITEMAP_INDEX_FILENAME,g as buildCanonicalPathDeps,v as buildCanonicalPathsBatchDeps,m as buildEnumerateDeps,u as buildPublishChecker,_ as buildRedirectsListDeps,y as buildResolveDeps,d as buildSitemapDeps,h as createHomepageReader,o as createPublishChecker,x as enumeratePaths,R as generateSiteSitemap,T as generateSitemap,r as getAlternates,s as getCanonicalPath,b as getCanonicalPaths,z as getRedirects,f as homepagePointerSchema,p as parseHomepagePointer,l as resolveDraftPath,i as resolvePath};
9
9
  //# sourceMappingURL=resolve.mjs.map
@@ -1,2 +1,2 @@
1
- import{t as e}from"./content-config-store-8omQF5p5.mjs";import{n as t,s as n}from"./schema-BYrYzsmO.mjs";import{d as r,n as i,s as a}from"./schema-IML1rRU_.mjs";import{matchReservedPrefix as o}from"@murumets-ee/entity";import{combineAdminRoutes as s,defineAdminRoute as c,getContext as l,requireRequestSite as u,safeAudit as d}from"@murumets-ee/core";import{and as f,eq as p}from"drizzle-orm";import{z as m}from"zod";import{entityRefs as h}from"@murumets-ee/entity/refs";const g=`path:resolve`,ee=[`admin`,`editor`];function _(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}const v={kind:`none`};function y(e){return e===null||e.length===0?{error:`query param 'path' is required`}:e.length>2048?{error:`query param 'path' is too long`}:e.startsWith(`/`)?e.startsWith(`//`)?{error:`query param 'path' must not be protocol-relative`}:e.includes(`?`)||e.includes(`#`)?{error:`query param 'path' must not include a query or fragment`}:{path:e}:{error:`query param 'path' must start with '/'`}}function b(t,n){let r=e(),i=t??n??r.defaultLocale;return(l()?.availableLocales??r.locales.map(e=>e.code)).includes(i)?{locale:i}:{error:`query param 'locale' is not a content locale`}}const x=async(e,t)=>{let n=new URL(e.url).searchParams,r=y(n.get(`path`));if(`error`in r)return _({error:r.error},400);let i=b(n.get(`locale`),t.locale??t.defaultLocale);if(`error`in i)return _({error:i.error},400);let{locale:o}=i,{buildResolveDeps:s}=await import(`./resolve-context-Cvc1aNJe.mjs`).then(e=>e.l),c=s(t.app),l=(e,n)=>t.checkPermission(e,`view`)?{kind:`document`,entityType:e,id:n}:v,u=await a(r.path,o,c);if(u.kind===`document`)return _(l(u.entityType,u.id));if(u.kind===`none`)return _(v);if(!(u.to.startsWith(`/`)&&!u.to.startsWith(`//`)))return _({kind:`redirect`,to:u.to});let d=await a(u.to,o,c);return _(d.kind===`document`?l(d.entityType,d.id):v)};function S(){return s([c({prefix:`paths`,path:`resolve`,method:`GET`,permission:g,defaultRoles:ee,description:`Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).`,handler:x})])}var C=class extends Error{fromPath;prefix;constructor(e,t){super(`Redirect source "${e}" falls under the reserved prefix "${t}" and cannot be used — that path range is owned by the framework.`),this.name=`RedirectReservedPrefixError`,this.fromPath=e,this.prefix=t}},w=class extends Error{fromPath;locale;conflictingLocale;constructor(e,t,n){super(`Redirect source "${e}" already serves content in locale "${n}" (it is a live canonical URL). Choose a different source path, or unpublish/rename the content first.`),this.name=`RedirectServesContentError`,this.fromPath=e,this.locale=t,this.conflictingLocale=n}},T=class extends Error{entity;constructor(e){super(`Redirect target references entity type "${e}", which is not a registered entity. Check the entity name.`),this.name=`RedirectUnknownEntityError`,this.entity=e}},E=class extends Error{entity;id;constructor(e,t){super(`Redirect target ${e} "${t}" does not exist. Redirect to a document that exists, or use a URL/path target instead.`),this.name=`RedirectTargetNotFoundError`,this.entity=e,this.id=t}},D=class extends Error{fromPath;locale;constructor(e,t){super(`Redirect source "${e}"${t===null?``:` (locale "${t}")`} resolves back to itself — a redirect cannot point at its own source path. Choose a different destination.`),this.name=`RedirectSelfReferenceError`,this.fromPath=e,this.locale=t}};const O=`toolkit_redirects`;async function k(e,t,n){n.kind===`entity`&&await e.insert(h).values({sourceEntity:O,sourceId:t,sourceField:`target`,targetEntity:n.entity,targetId:n.id,relationKind:`link`}).onConflictDoNothing()}async function A(e,t){await e.delete(h).where(f(p(h.sourceEntity,O),p(h.sourceId,t)))}const j=m.discriminatedUnion(`kind`,[m.object({kind:m.literal(`entity`),entity:m.string().min(1),id:m.string().uuid()}),m.object({kind:m.literal(`url`),url:m.string().max(2048).url()}),m.object({kind:m.literal(`path`),path:m.string().min(1).max(2048).startsWith(`/`)})]);function M(e){return r(e)}function N(e){let t=M(e.fromPath),n=o(t,e.reservedPathPrefixes);if(n!==void 0)throw new C(t,n)}async function te(e){let t=M(e.fromPath);if(e.locale!==null){let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:e.locale});if(n)throw new w(t,e.locale,n.locale);return}let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:{in:[...e.configuredLocales]}});if(n)throw new w(t,null,n.locale)}async function ne(e){if(e.target.kind!==`path`)return e.target;let t=M(e.target.path),n=e.excludeId===void 0?{}:{id:{ne:e.excludeId}};if(e.locale!==null){let r=await e.redirectsClient.findOne({fromPath:t,locale:e.locale,...n});if(r)return r.target}let r=await e.redirectsClient.findOne({fromPath:t,locale:null,...n});return r?r.target:{kind:`path`,path:t}}async function P(e){if(e.target.kind!==`entity`)return;let{entity:t,id:n}=e.target,r=e.resolveClient(t);if(r===void 0)throw new T(t);if(await r.findById(n)==null)throw new E(t,n)}function F(){return u(`toolkit_redirects`)}const I=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,L=/^[a-zA-Z0-9:_.-]{1,100}$/,R=`redirect:manage`,z=[`admin`];function B(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function V(e,t){return B({error:e},t)}async function H(e,t){let n;try{n=await e.json()}catch{return{response:V(`Invalid JSON body`,400)}}let r=t.safeParse(n);if(!r.success){let e=r.error.issues[0];return{response:V(e?`${e.path.join(`.`)||`body`}: ${e.message}`:`Invalid body`,400)}}return{data:r.data}}function U(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function W(e){return e.name===void 0?{userId:e.id}:{userId:e.id,userName:e.name}}async function G(){let{getApp:e}=await import(`@murumets-ee/core`);return e()}async function K(){return(await G()).db.readWrite}async function q(){let e=await G();return t=>{let n=e.entities.get(t);return n?e.getClient(n):void 0}}function J(e){return e}const Y=m.union([m.literal(301),m.literal(302)]),X=m.string().min(1).max(2048).startsWith(`/`),re=m.object({locale:m.string().min(1).nullable(),fromPath:X,target:j,status:Y.default(301)}),ie=m.object({locale:m.string().min(1).nullable().optional(),fromPath:X.optional(),target:j.optional(),status:Y.optional()});function Z(t){return t===null||e().locales.map(e=>e.code).includes(t)?null:V(`Unknown locale "${t}" — not one of the configured content locales.`,400)}function Q(e){if(e instanceof C)return B({error:e.message,fromPath:e.fromPath,prefix:e.prefix},409);if(e instanceof w)return B({error:e.message,fromPath:e.fromPath,locale:e.locale,conflictingLocale:e.conflictingLocale},409);if(e instanceof D)return B({error:e.message,fromPath:e.fromPath,locale:e.locale},409);if(e instanceof T)return B({error:e.message,entity:e.entity},400);if(e instanceof E)return B({error:e.message,entity:e.entity,id:e.id},400);if(n(e))return B({error:`A redirect already exists for this locale + source path.`},409);throw e}async function $(n){let r=e(),a=r.reservedPathPrefixes??[],o=r.locales.map(e=>e.code),s=M(n.fromPath);N({fromPath:s,reservedPathPrefixes:a}),await P({target:n.target,resolveClient:n.resolveClient});let c=await K(),l=await F();return c.transaction(async e=>{let r=J(e),a=i.makeClient(r,l),c=t.makeClient(r,l);await te({fromPath:s,locale:n.locale,configuredLocales:o,pathsClient:c});let u=await ne({target:n.target,locale:n.locale,redirectsClient:a,...n.id===null?{}:{excludeId:n.id}});if(u.kind===`path`&&M(u.path)===s)throw new D(s,n.locale);if(n.id===null){let e=await a.insert({locale:n.locale,fromPath:s,target:u,status:n.status,createdBy:n.userId});return await k(r,e.id,u),e}let d=await a.update({id:n.id},{locale:n.locale,fromPath:s,target:u,status:n.status,updatedAt:new Date});return d?(await A(r,n.id),await k(r,n.id,u),d):null})}const ae=async e=>{let t=new URL(e.url),n=t.searchParams.get(`locale`),r=Math.min(Math.max(U(t.searchParams.get(`limit`),50),1),200),a=Math.min(U(t.searchParams.get(`offset`),0),1e5),o;if(n!==null&&n!==``){let e=Z(n);if(e)return e;o=n}let s=i.makeClient(await K(),await F());return B({items:o===void 0?await s.findMany({orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:a}):await s.findMany({where:{locale:o},orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:a}),total:o===void 0?await s.count():await s.count({locale:o})})},oe=async(e,t)=>{let n=await H(e,re);if(`response`in n)return n.response;let r=n.data,i=Z(r.locale);if(i)return i;try{let e=await q(),n=await $({id:null,locale:r.locale,fromPath:r.fromPath,target:r.target,status:r.status,userId:t.user.id,resolveClient:e});return n?(d(t,{action:`redirect.created`,entityType:`redirect`,entityId:n.id,...W(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),B({item:n},201)):V(`Failed to create redirect`,500)}catch(e){return Q(e)}},se=async(e,t)=>{let n=t.segments[0];if(!n||!I.test(n))return V(`Invalid id`,400);let r=await H(e,ie);if(`response`in r)return r.response;let a=r.data,o=await i.makeClient(await K(),await F()).findOne({id:n});if(!o)return V(`Not found`,404);let s=a.locale===void 0?o.locale:a.locale,c=a.fromPath===void 0?o.fromPath:a.fromPath,l=a.target===void 0?o.target:a.target,u=a.status===void 0?o.status:a.status,f=Z(s);if(f)return f;try{let e=await q(),r=await $({id:n,locale:s,fromPath:c,target:l,status:u,userId:t.user.id,resolveClient:e});return r?(d(t,{action:`redirect.updated`,entityType:`redirect`,entityId:r.id,...W(t.user),metadata:{locale:r.locale,fromPath:r.fromPath,targetKind:r.target.kind}}),B({item:r})):V(`Not found`,404)}catch(e){return Q(e)}},ce=async(e,t)=>{let n=t.segments[0];if(!n||!I.test(n))return V(`Invalid id`,400);let r=await K(),a=await F(),o=await r.transaction(async e=>{let t=J(e),r=await i.makeClient(t,a).delete({id:n});return r&&await A(t,n),r});return o?(d(t,{action:`redirect.deleted`,entityType:`redirect`,entityId:n,...W(t.user),metadata:{locale:o.locale,fromPath:o.fromPath}}),B({ok:!0})):V(`Not found`,404)},le=async e=>{let n=new URL(e.url),r=n.searchParams.get(`entityType`),i=n.searchParams.get(`entityId`);if(!r||!L.test(r))return V(`entityType is required`,400);if(!i||!I.test(i))return V(`entityId is required and must be a UUID`,400);let a=n.searchParams.get(`locale`),o;if(a!==null&&a!==``){let e=Z(a);if(e)return e;o=a}return B({items:await t.makeClient(await K(),await F()).findMany({where:{entityType:r,entityId:i,isCanonical:!1,...o!==void 0&&{locale:o}},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:100})})};function ue(){return s([c({prefix:`redirects`,path:``,method:`GET`,permission:R,defaultRoles:z,description:`List manual URL redirects (paginated, optional locale filter).`,handler:ae}),c({prefix:`redirects`,path:`history`,method:`GET`,permission:R,defaultRoles:z,description:`Read-only automatic-redirect (rename) history for one document.`,handler:le}),c({prefix:`redirects`,path:``,method:`POST`,permission:R,defaultRoles:z,description:`Create a manual URL redirect.`,handler:oe}),c({prefix:`redirects`,path:``,method:`PATCH`,matchAnyPath:!0,permission:R,defaultRoles:z,description:`Update a manual URL redirect by id.`,handler:se}),c({prefix:`redirects`,path:``,method:`DELETE`,matchAnyPath:!0,permission:R,defaultRoles:z,description:`Delete a manual URL redirect by id.`,handler:ce})])}export{g as n,S as r,ue as t};
2
- //# sourceMappingURL=routes-BmNSYyuT.mjs.map
1
+ import{t as e}from"./content-config-store-8omQF5p5.mjs";import{n as t,s as n}from"./schema-BYrYzsmO.mjs";import{d as r,n as i,s as a}from"./schema-IML1rRU_.mjs";import{matchReservedPrefix as o}from"@murumets-ee/entity";import{combineAdminRoutes as s,defineAdminRoute as c,getContext as l,requireRequestSite as u,safeAudit as d}from"@murumets-ee/core";import{and as f,eq as p}from"drizzle-orm";import{z as m}from"zod";import{entityRefs as h}from"@murumets-ee/entity/refs";const g=`path:resolve`,ee=[`admin`,`editor`];function _(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}const v={kind:`none`};function y(e){return e===null||e.length===0?{error:`query param 'path' is required`}:e.length>2048?{error:`query param 'path' is too long`}:e.startsWith(`/`)?e.startsWith(`//`)?{error:`query param 'path' must not be protocol-relative`}:e.includes(`?`)||e.includes(`#`)?{error:`query param 'path' must not include a query or fragment`}:{path:e}:{error:`query param 'path' must start with '/'`}}function b(t,n){let r=e(),i=t??n??r.defaultLocale;return(l()?.availableLocales??r.locales.map(e=>e.code)).includes(i)?{locale:i}:{error:`query param 'locale' is not a content locale`}}const x=async(e,t)=>{let n=new URL(e.url).searchParams,r=y(n.get(`path`));if(`error`in r)return _({error:r.error},400);let i=b(n.get(`locale`),t.locale??t.defaultLocale);if(`error`in i)return _({error:i.error},400);let{locale:o}=i,{buildResolveDeps:s}=await import(`./resolve-context-D8UXwWwr.mjs`).then(e=>e.u),c=s(t.app),l=(e,n)=>t.checkPermission(e,`view`)?{kind:`document`,entityType:e,id:n}:v,u=await a(r.path,o,c);if(u.kind===`document`)return _(l(u.entityType,u.id));if(u.kind===`none`)return _(v);if(!(u.to.startsWith(`/`)&&!u.to.startsWith(`//`)))return _({kind:`redirect`,to:u.to});let d=await a(u.to,o,c);return _(d.kind===`document`?l(d.entityType,d.id):v)};function S(){return s([c({prefix:`paths`,path:`resolve`,method:`GET`,permission:g,defaultRoles:ee,description:`Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).`,handler:x})])}var C=class extends Error{fromPath;prefix;constructor(e,t){super(`Redirect source "${e}" falls under the reserved prefix "${t}" and cannot be used — that path range is owned by the framework.`),this.name=`RedirectReservedPrefixError`,this.fromPath=e,this.prefix=t}},w=class extends Error{fromPath;locale;conflictingLocale;constructor(e,t,n){super(`Redirect source "${e}" already serves content in locale "${n}" (it is a live canonical URL). Choose a different source path, or unpublish/rename the content first.`),this.name=`RedirectServesContentError`,this.fromPath=e,this.locale=t,this.conflictingLocale=n}},T=class extends Error{entity;constructor(e){super(`Redirect target references entity type "${e}", which is not a registered entity. Check the entity name.`),this.name=`RedirectUnknownEntityError`,this.entity=e}},E=class extends Error{entity;id;constructor(e,t){super(`Redirect target ${e} "${t}" does not exist. Redirect to a document that exists, or use a URL/path target instead.`),this.name=`RedirectTargetNotFoundError`,this.entity=e,this.id=t}},D=class extends Error{fromPath;locale;constructor(e,t){super(`Redirect source "${e}"${t===null?``:` (locale "${t}")`} resolves back to itself — a redirect cannot point at its own source path. Choose a different destination.`),this.name=`RedirectSelfReferenceError`,this.fromPath=e,this.locale=t}};const O=`toolkit_redirects`;async function k(e,t,n){n.kind===`entity`&&await e.insert(h).values({sourceEntity:O,sourceId:t,sourceField:`target`,targetEntity:n.entity,targetId:n.id,relationKind:`link`}).onConflictDoNothing()}async function A(e,t){await e.delete(h).where(f(p(h.sourceEntity,O),p(h.sourceId,t)))}const j=m.discriminatedUnion(`kind`,[m.object({kind:m.literal(`entity`),entity:m.string().min(1),id:m.string().uuid()}),m.object({kind:m.literal(`url`),url:m.string().max(2048).url()}),m.object({kind:m.literal(`path`),path:m.string().min(1).max(2048).startsWith(`/`)})]);function M(e){return r(e)}function N(e){let t=M(e.fromPath),n=o(t,e.reservedPathPrefixes);if(n!==void 0)throw new C(t,n)}async function te(e){let t=M(e.fromPath);if(e.locale!==null){let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:e.locale});if(n)throw new w(t,e.locale,n.locale);return}let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:{in:[...e.configuredLocales]}});if(n)throw new w(t,null,n.locale)}async function ne(e){if(e.target.kind!==`path`)return e.target;let t=M(e.target.path),n=e.excludeId===void 0?{}:{id:{ne:e.excludeId}};if(e.locale!==null){let r=await e.redirectsClient.findOne({fromPath:t,locale:e.locale,...n});if(r)return r.target}let r=await e.redirectsClient.findOne({fromPath:t,locale:null,...n});return r?r.target:{kind:`path`,path:t}}async function P(e){if(e.target.kind!==`entity`)return;let{entity:t,id:n}=e.target,r=e.resolveClient(t);if(r===void 0)throw new T(t);if(await r.findById(n)==null)throw new E(t,n)}function F(){return u(`toolkit_redirects`)}const I=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,L=/^[a-zA-Z0-9:_.-]{1,100}$/,R=`redirect:manage`,z=[`admin`];function B(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function V(e,t){return B({error:e},t)}async function H(e,t){let n;try{n=await e.json()}catch{return{response:V(`Invalid JSON body`,400)}}let r=t.safeParse(n);if(!r.success){let e=r.error.issues[0];return{response:V(e?`${e.path.join(`.`)||`body`}: ${e.message}`:`Invalid body`,400)}}return{data:r.data}}function U(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function W(e){return e.name===void 0?{userId:e.id}:{userId:e.id,userName:e.name}}async function G(){let{getApp:e}=await import(`@murumets-ee/core`);return e()}async function K(){return(await G()).db.readWrite}async function q(){let e=await G();return t=>{let n=e.entities.get(t);return n?e.getClient(n):void 0}}function J(e){return e}const Y=m.union([m.literal(301),m.literal(302)]),X=m.string().min(1).max(2048).startsWith(`/`),re=m.object({locale:m.string().min(1).nullable(),fromPath:X,target:j,status:Y.default(301)}),ie=m.object({locale:m.string().min(1).nullable().optional(),fromPath:X.optional(),target:j.optional(),status:Y.optional()});function Z(t){return t===null||e().locales.map(e=>e.code).includes(t)?null:V(`Unknown locale "${t}" — not one of the configured content locales.`,400)}function Q(e){if(e instanceof C)return B({error:e.message,fromPath:e.fromPath,prefix:e.prefix},409);if(e instanceof w)return B({error:e.message,fromPath:e.fromPath,locale:e.locale,conflictingLocale:e.conflictingLocale},409);if(e instanceof D)return B({error:e.message,fromPath:e.fromPath,locale:e.locale},409);if(e instanceof T)return B({error:e.message,entity:e.entity},400);if(e instanceof E)return B({error:e.message,entity:e.entity,id:e.id},400);if(n(e))return B({error:`A redirect already exists for this locale + source path.`},409);throw e}async function $(n){let r=e(),a=r.reservedPathPrefixes??[],o=r.locales.map(e=>e.code),s=M(n.fromPath);N({fromPath:s,reservedPathPrefixes:a}),await P({target:n.target,resolveClient:n.resolveClient});let c=await K(),l=await F();return c.transaction(async e=>{let r=J(e),a=i.makeClient(r,l),c=t.makeClient(r,l);await te({fromPath:s,locale:n.locale,configuredLocales:o,pathsClient:c});let u=await ne({target:n.target,locale:n.locale,redirectsClient:a,...n.id===null?{}:{excludeId:n.id}});if(u.kind===`path`&&M(u.path)===s)throw new D(s,n.locale);if(n.id===null){let e=await a.insert({locale:n.locale,fromPath:s,target:u,status:n.status,createdBy:n.userId});return await k(r,e.id,u),e}let d=await a.update({id:n.id},{locale:n.locale,fromPath:s,target:u,status:n.status,updatedAt:new Date});return d?(await A(r,n.id),await k(r,n.id,u),d):null})}const ae=async e=>{let t=new URL(e.url),n=t.searchParams.get(`locale`),r=Math.min(Math.max(U(t.searchParams.get(`limit`),50),1),200),a=Math.min(U(t.searchParams.get(`offset`),0),1e5),o;if(n!==null&&n!==``){let e=Z(n);if(e)return e;o=n}let s=i.makeClient(await K(),await F());return B({items:o===void 0?await s.findMany({orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:a}):await s.findMany({where:{locale:o},orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:a}),total:o===void 0?await s.count():await s.count({locale:o})})},oe=async(e,t)=>{let n=await H(e,re);if(`response`in n)return n.response;let r=n.data,i=Z(r.locale);if(i)return i;try{let e=await q(),n=await $({id:null,locale:r.locale,fromPath:r.fromPath,target:r.target,status:r.status,userId:t.user.id,resolveClient:e});return n?(d(t,{action:`redirect.created`,entityType:`redirect`,entityId:n.id,...W(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),B({item:n},201)):V(`Failed to create redirect`,500)}catch(e){return Q(e)}},se=async(e,t)=>{let n=t.segments[0];if(!n||!I.test(n))return V(`Invalid id`,400);let r=await H(e,ie);if(`response`in r)return r.response;let a=r.data,o=await i.makeClient(await K(),await F()).findOne({id:n});if(!o)return V(`Not found`,404);let s=a.locale===void 0?o.locale:a.locale,c=a.fromPath===void 0?o.fromPath:a.fromPath,l=a.target===void 0?o.target:a.target,u=a.status===void 0?o.status:a.status,f=Z(s);if(f)return f;try{let e=await q(),r=await $({id:n,locale:s,fromPath:c,target:l,status:u,userId:t.user.id,resolveClient:e});return r?(d(t,{action:`redirect.updated`,entityType:`redirect`,entityId:r.id,...W(t.user),metadata:{locale:r.locale,fromPath:r.fromPath,targetKind:r.target.kind}}),B({item:r})):V(`Not found`,404)}catch(e){return Q(e)}},ce=async(e,t)=>{let n=t.segments[0];if(!n||!I.test(n))return V(`Invalid id`,400);let r=await K(),a=await F(),o=await r.transaction(async e=>{let t=J(e),r=await i.makeClient(t,a).delete({id:n});return r&&await A(t,n),r});return o?(d(t,{action:`redirect.deleted`,entityType:`redirect`,entityId:n,...W(t.user),metadata:{locale:o.locale,fromPath:o.fromPath}}),B({ok:!0})):V(`Not found`,404)},le=async e=>{let n=new URL(e.url),r=n.searchParams.get(`entityType`),i=n.searchParams.get(`entityId`);if(!r||!L.test(r))return V(`entityType is required`,400);if(!i||!I.test(i))return V(`entityId is required and must be a UUID`,400);let a=n.searchParams.get(`locale`),o;if(a!==null&&a!==``){let e=Z(a);if(e)return e;o=a}return B({items:await t.makeClient(await K(),await F()).findMany({where:{entityType:r,entityId:i,isCanonical:!1,...o!==void 0&&{locale:o}},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:100})})};function ue(){return s([c({prefix:`redirects`,path:``,method:`GET`,permission:R,defaultRoles:z,description:`List manual URL redirects (paginated, optional locale filter).`,handler:ae}),c({prefix:`redirects`,path:`history`,method:`GET`,permission:R,defaultRoles:z,description:`Read-only automatic-redirect (rename) history for one document.`,handler:le}),c({prefix:`redirects`,path:``,method:`POST`,permission:R,defaultRoles:z,description:`Create a manual URL redirect.`,handler:oe}),c({prefix:`redirects`,path:``,method:`PATCH`,matchAnyPath:!0,permission:R,defaultRoles:z,description:`Update a manual URL redirect by id.`,handler:se}),c({prefix:`redirects`,path:``,method:`DELETE`,matchAnyPath:!0,permission:R,defaultRoles:z,description:`Delete a manual URL redirect by id.`,handler:ce})])}export{g as n,S as r,ue as t};
2
+ //# sourceMappingURL=routes-DlvqU8bO.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"routes-BmNSYyuT.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 requireRequestSite,\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'\n\n/**\n * The site these routes act on (multisite phase 03): redirects and the path\n * history they read are kept per site, so every route reads and writes the\n * request's site only — never a default on an app serving several sites.\n */\nfunction requestSite(): Promise<string> {\n return requireRequestSite('toolkit_redirects')\n}\n\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 // Resolved before the transaction: the single-site lookup reads the pool.\n const site = await requestSite()\n return db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n const redirectsClient = toolkitRedirectsTable.makeClient(tx, site)\n const pathsClient = toolkitPathsTable.makeClient(tx, site)\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(), await requestSite())\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\n .makeClient(await getDb(), await requestSite())\n .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 site = await requestSite()\n const deleted = await db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n // Delete (confined to the request's site) THEN clear its refs, same tx — no\n // dangling entity_refs row survives, and a redirect id of ANOTHER site\n // deletes nothing and clears nothing (multisite phase 03).\n const row = await toolkitRedirectsTable.makeClient(tx, site).delete({ id })\n if (row) await clearRedirectRefs(tx, id)\n return row\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(), await requestSite())\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-DlvqU8bO.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 requireRequestSite,\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'\n\n/**\n * The site these routes act on (multisite phase 03): redirects and the path\n * history they read are kept per site, so every route reads and writes the\n * request's site only — never a default on an app serving several sites.\n */\nfunction requestSite(): Promise<string> {\n return requireRequestSite('toolkit_redirects')\n}\n\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 // Resolved before the transaction: the single-site lookup reads the pool.\n const site = await requestSite()\n return db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n const redirectsClient = toolkitRedirectsTable.makeClient(tx, site)\n const pathsClient = toolkitPathsTable.makeClient(tx, site)\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(), await requestSite())\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\n .makeClient(await getDb(), await requestSite())\n .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 site = await requestSite()\n const deleted = await db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n // Delete (confined to the request's site) THEN clear its refs, same tx — no\n // dangling entity_refs row survives, and a redirect id of ANOTHER site\n // deletes nothing and clears nothing (multisite phase 03).\n const row = await toolkitRedirectsTable.makeClient(tx, site).delete({ id })\n if (row) await clearRedirectRefs(tx, id)\n return row\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(), await requestSite())\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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"}