@jini-ai/cms 0.5.2 → 0.5.4

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.
Files changed (261) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +14 -8
  3. package/dist/comments/errors.d.ts +23 -0
  4. package/dist/comments/errors.js +25 -0
  5. package/dist/comments/express/index.d.ts +2 -0
  6. package/dist/comments/express/index.js +2 -0
  7. package/dist/comments/express/submit-handler.d.ts +30 -0
  8. package/dist/comments/express/submit-handler.js +85 -0
  9. package/dist/comments/hooks.d.ts +39 -0
  10. package/dist/comments/hooks.js +41 -0
  11. package/dist/comments/index.d.ts +11 -0
  12. package/dist/comments/index.js +11 -0
  13. package/dist/comments/ingress.d.ts +57 -0
  14. package/dist/comments/ingress.js +173 -0
  15. package/dist/comments/module.d.ts +94 -0
  16. package/dist/comments/module.js +73 -0
  17. package/dist/comments/ports.d.ts +184 -0
  18. package/dist/comments/ports.js +2 -0
  19. package/dist/comments/repo.memory.d.ts +70 -0
  20. package/dist/comments/repo.memory.js +120 -0
  21. package/dist/comments/sanitize.d.ts +15 -0
  22. package/dist/comments/sanitize.js +31 -0
  23. package/dist/comments/spam.heuristic.d.ts +8 -0
  24. package/dist/comments/spam.heuristic.js +54 -0
  25. package/dist/comments/sql/index.d.ts +3 -0
  26. package/dist/comments/sql/index.js +3 -0
  27. package/dist/comments/sql/repo.d.ts +77 -0
  28. package/dist/comments/sql/repo.js +190 -0
  29. package/dist/comments/sql/repo.rows.d.ts +49 -0
  30. package/dist/comments/sql/repo.rows.js +71 -0
  31. package/dist/comments/types.d.ts +187 -0
  32. package/dist/comments/types.js +39 -0
  33. package/dist/comments/write-service.d.ts +121 -0
  34. package/dist/comments/write-service.js +144 -0
  35. package/dist/core/commands/command.d.ts +2 -9
  36. package/dist/core/commands/command.js +2 -9
  37. package/dist/core/index.d.ts +2 -1
  38. package/dist/core/index.js +1 -0
  39. package/dist/core/keyset-cursor.d.ts +25 -0
  40. package/dist/core/keyset-cursor.js +20 -0
  41. package/dist/core/ports.d.ts +30 -0
  42. package/dist/core/ports.js +0 -1
  43. package/dist/core/tools/args.d.ts +1 -46
  44. package/dist/core/tools/args.js +1 -44
  45. package/dist/forms/delete-submission.d.ts +38 -0
  46. package/dist/forms/delete-submission.js +25 -0
  47. package/dist/forms/duplicate-slug.d.ts +81 -0
  48. package/dist/forms/duplicate-slug.js +115 -0
  49. package/dist/forms/errors.d.ts +53 -0
  50. package/dist/forms/errors.js +25 -0
  51. package/dist/forms/express/handler.d.ts +59 -0
  52. package/dist/forms/express/handler.js +217 -0
  53. package/dist/forms/express/index.d.ts +2 -0
  54. package/dist/forms/express/index.js +2 -0
  55. package/dist/forms/fields-json.d.ts +14 -0
  56. package/dist/forms/fields-json.js +11 -0
  57. package/dist/forms/forms.d.ts +59 -0
  58. package/dist/forms/forms.js +213 -0
  59. package/dist/forms/html/authoring-adapter.d.ts +34 -0
  60. package/dist/forms/html/authoring-adapter.js +48 -0
  61. package/dist/forms/html/html-authoring.d.ts +12 -0
  62. package/dist/forms/html/html-authoring.js +133 -0
  63. package/dist/forms/html/html-submission-adapter.d.ts +10 -0
  64. package/dist/forms/html/html-submission-adapter.js +15 -0
  65. package/dist/forms/html/index.d.ts +4 -0
  66. package/dist/forms/html/index.js +4 -0
  67. package/dist/forms/html-render.d.ts +33 -0
  68. package/dist/forms/html-render.js +31 -0
  69. package/dist/forms/index.d.ts +17 -0
  70. package/dist/forms/index.js +17 -0
  71. package/dist/forms/notify-subscriber.d.ts +32 -0
  72. package/dist/forms/notify-subscriber.js +60 -0
  73. package/dist/forms/ports.d.ts +168 -0
  74. package/dist/forms/ports.js +2 -0
  75. package/dist/forms/rate-limit-key.d.ts +8 -0
  76. package/dist/forms/rate-limit-key.js +7 -0
  77. package/dist/forms/repo.memory.d.ts +120 -0
  78. package/dist/forms/repo.memory.js +188 -0
  79. package/dist/forms/sql/index.d.ts +5 -0
  80. package/dist/forms/sql/index.js +5 -0
  81. package/dist/forms/sql/repo.d.ts +94 -0
  82. package/dist/forms/sql/repo.js +175 -0
  83. package/dist/forms/sql/repo.rows.d.ts +33 -0
  84. package/dist/forms/sql/repo.rows.js +64 -0
  85. package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
  86. package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
  87. package/dist/forms/sql/tables.d.ts +32 -0
  88. package/dist/forms/sql/tables.js +2 -0
  89. package/dist/forms/submission-attempts.d.ts +70 -0
  90. package/dist/forms/submission-attempts.js +70 -0
  91. package/dist/forms/submission-email-validation.d.ts +8 -0
  92. package/dist/forms/submission-email-validation.js +34 -0
  93. package/dist/forms/submission-ip-retention.d.ts +30 -0
  94. package/dist/forms/submission-ip-retention.js +27 -0
  95. package/dist/forms/submit-service.d.ts +78 -0
  96. package/dist/forms/submit-service.js +109 -0
  97. package/dist/forms/types.d.ts +49 -0
  98. package/dist/forms/types.js +2 -0
  99. package/dist/forms/write-service.d.ts +83 -0
  100. package/dist/forms/write-service.js +271 -0
  101. package/dist/media/index.d.ts +0 -3
  102. package/dist/media/index.js +1 -4
  103. package/dist/media/node.d.ts +5 -0
  104. package/dist/media/node.js +6 -0
  105. package/dist/redirects/capture.d.ts +80 -0
  106. package/dist/redirects/capture.js +66 -0
  107. package/dist/redirects/express/index.d.ts +27 -0
  108. package/dist/redirects/express/index.js +40 -0
  109. package/dist/redirects/hit-sink.d.ts +56 -0
  110. package/dist/redirects/hit-sink.js +44 -0
  111. package/dist/redirects/host-config/index.d.ts +40 -0
  112. package/dist/redirects/host-config/index.js +145 -0
  113. package/dist/redirects/index.d.ts +14 -0
  114. package/dist/redirects/index.js +14 -0
  115. package/dist/redirects/matcher.d.ts +58 -0
  116. package/dist/redirects/matcher.js +144 -0
  117. package/dist/redirects/order.d.ts +30 -0
  118. package/dist/redirects/order.js +26 -0
  119. package/dist/redirects/ports.d.ts +242 -0
  120. package/dist/redirects/ports.internal.d.ts +57 -0
  121. package/dist/redirects/ports.internal.js +14 -0
  122. package/dist/redirects/ports.js +2 -0
  123. package/dist/redirects/redirects.d.ts +226 -0
  124. package/dist/redirects/redirects.js +590 -0
  125. package/dist/redirects/referrer-alias.d.ts +31 -0
  126. package/dist/redirects/referrer-alias.js +33 -0
  127. package/dist/redirects/repo.memory.d.ts +51 -0
  128. package/dist/redirects/repo.memory.js +98 -0
  129. package/dist/redirects/reserved-destination.d.ts +58 -0
  130. package/dist/redirects/reserved-destination.js +59 -0
  131. package/dist/redirects/resolver.d.ts +100 -0
  132. package/dist/redirects/resolver.js +87 -0
  133. package/dist/redirects/spa/index.d.ts +30 -0
  134. package/dist/redirects/spa/index.js +27 -0
  135. package/dist/redirects/sql/index.d.ts +4 -0
  136. package/dist/redirects/sql/index.js +4 -0
  137. package/dist/redirects/sql/repo.d.ts +82 -0
  138. package/dist/redirects/sql/repo.js +143 -0
  139. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  140. package/dist/redirects/sql/repo.rows.js +79 -0
  141. package/dist/redirects/sql/row-types.d.ts +34 -0
  142. package/dist/redirects/sql/row-types.js +2 -0
  143. package/dist/redirects/target-gate.d.ts +30 -0
  144. package/dist/redirects/target-gate.js +85 -0
  145. package/dist/redirects/types.d.ts +213 -0
  146. package/dist/redirects/types.js +42 -0
  147. package/dist/seo/absolute-url.d.ts +58 -0
  148. package/dist/seo/absolute-url.js +117 -0
  149. package/dist/seo/dom/index.d.ts +14 -0
  150. package/dist/seo/dom/index.js +52 -0
  151. package/dist/seo/errors.d.ts +35 -0
  152. package/dist/seo/errors.js +35 -0
  153. package/dist/seo/feed.d.ts +49 -0
  154. package/dist/seo/feed.js +61 -0
  155. package/dist/seo/index.d.ts +13 -0
  156. package/dist/seo/index.js +13 -0
  157. package/dist/seo/media.d.ts +57 -0
  158. package/dist/seo/media.js +134 -0
  159. package/dist/seo/page-head.d.ts +18 -0
  160. package/dist/seo/page-head.js +114 -0
  161. package/dist/seo/ports.d.ts +221 -0
  162. package/dist/seo/ports.js +27 -0
  163. package/dist/seo/seo.d.ts +73 -0
  164. package/dist/seo/seo.js +230 -0
  165. package/dist/seo/sitemap.d.ts +139 -0
  166. package/dist/seo/sitemap.js +217 -0
  167. package/dist/seo/types.d.ts +278 -0
  168. package/dist/seo/types.js +2 -0
  169. package/dist/seo/write-service.d.ts +79 -0
  170. package/dist/seo/write-service.js +273 -0
  171. package/dist/seo/xml-escape.d.ts +16 -0
  172. package/dist/seo/xml-escape.js +19 -0
  173. package/dist/settings/express/cms-adapter.d.ts +20 -0
  174. package/dist/settings/express/cms-adapter.js +124 -0
  175. package/dist/settings/express/contracts.d.ts +135 -0
  176. package/dist/settings/express/contracts.js +2 -0
  177. package/dist/settings/express/http.d.ts +10 -0
  178. package/dist/settings/express/http.js +211 -0
  179. package/dist/settings/express/index.d.ts +5 -0
  180. package/dist/settings/express/index.js +5 -0
  181. package/dist/settings/express/shared.d.ts +68 -0
  182. package/dist/settings/express/shared.js +54 -0
  183. package/dist/settings/express/stream.d.ts +24 -0
  184. package/dist/settings/express/stream.js +147 -0
  185. package/dist/settings/index.d.ts +3 -38
  186. package/dist/settings/index.js +3 -35
  187. package/dist/taxonomy/write-service.d.ts +2 -30
  188. package/dist/widgets/authorize-helper.d.ts +42 -0
  189. package/dist/widgets/authorize-helper.js +16 -0
  190. package/dist/widgets/concurrency.d.ts +12 -0
  191. package/dist/widgets/concurrency.js +44 -0
  192. package/dist/widgets/config-validation.d.ts +34 -0
  193. package/dist/widgets/config-validation.js +125 -0
  194. package/dist/widgets/embed-service.d.ts +133 -0
  195. package/dist/widgets/embed-service.js +460 -0
  196. package/dist/widgets/embed-validation.d.ts +23 -0
  197. package/dist/widgets/embed-validation.js +38 -0
  198. package/dist/widgets/entry-payload.d.ts +103 -0
  199. package/dist/widgets/entry-payload.js +160 -0
  200. package/dist/widgets/errors.d.ts +136 -0
  201. package/dist/widgets/errors.js +136 -0
  202. package/dist/widgets/host-ports.d.ts +149 -0
  203. package/dist/widgets/host-ports.js +2 -0
  204. package/dist/widgets/html/html-embeds.d.ts +247 -0
  205. package/dist/widgets/html/html-embeds.js +408 -0
  206. package/dist/widgets/html/index.d.ts +5 -0
  207. package/dist/widgets/html/index.js +4 -0
  208. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  209. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  210. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  211. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  212. package/dist/widgets/index.d.ts +20 -0
  213. package/dist/widgets/index.js +19 -0
  214. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  215. package/dist/widgets/markers/html-attributes.js +216 -0
  216. package/dist/widgets/markers/index.d.ts +3 -0
  217. package/dist/widgets/markers/index.js +3 -0
  218. package/dist/widgets/markers/marker.d.ts +496 -0
  219. package/dist/widgets/markers/marker.js +780 -0
  220. package/dist/widgets/page-embed-types.d.ts +42 -0
  221. package/dist/widgets/page-embed-types.js +42 -0
  222. package/dist/widgets/ports.d.ts +142 -0
  223. package/dist/widgets/ports.js +2 -0
  224. package/dist/widgets/read-service.d.ts +81 -0
  225. package/dist/widgets/read-service.js +79 -0
  226. package/dist/widgets/region-area-service.d.ts +110 -0
  227. package/dist/widgets/region-area-service.js +180 -0
  228. package/dist/widgets/registry.d.ts +217 -0
  229. package/dist/widgets/registry.js +172 -0
  230. package/dist/widgets/repo.memory.d.ts +41 -0
  231. package/dist/widgets/repo.memory.js +48 -0
  232. package/dist/widgets/resolver-service.d.ts +209 -0
  233. package/dist/widgets/resolver-service.js +909 -0
  234. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  235. package/dist/widgets/resolvers/contact-form.js +54 -0
  236. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  237. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  238. package/dist/widgets/resolvers/index.d.ts +65 -0
  239. package/dist/widgets/resolvers/index.js +155 -0
  240. package/dist/widgets/resolvers/menu.d.ts +42 -0
  241. package/dist/widgets/resolvers/menu.js +43 -0
  242. package/dist/widgets/resolvers/ports.d.ts +82 -0
  243. package/dist/widgets/resolvers/ports.js +2 -0
  244. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  245. package/dist/widgets/resolvers/recent-entries.js +165 -0
  246. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  247. package/dist/widgets/resolvers/taxonomy.js +34 -0
  248. package/dist/widgets/sql/index.d.ts +3 -0
  249. package/dist/widgets/sql/index.js +3 -0
  250. package/dist/widgets/sql/repo.d.ts +41 -0
  251. package/dist/widgets/sql/repo.js +82 -0
  252. package/dist/widgets/sql/tables.d.ts +13 -0
  253. package/dist/widgets/sql/tables.js +2 -0
  254. package/dist/widgets/types.d.ts +213 -0
  255. package/dist/widgets/types.js +17 -0
  256. package/dist/widgets/where-used.d.ts +41 -0
  257. package/dist/widgets/where-used.js +11 -0
  258. package/dist/widgets/write-service.d.ts +204 -0
  259. package/dist/widgets/write-service.js +332 -0
  260. package/dist/workspace/delete.d.ts +1 -1
  261. package/package.json +220 -12
@@ -0,0 +1,590 @@
1
+ import { nowIso as clockNowIso } from "@jini-ai/core/primitives";
2
+ /**
3
+ * @file THE `redirects` write chokepoint (SPEC-009 REQ-01/04/05/06/07/08/13/
4
+ * 14/22/26; ADR-PIPE-009 C-001..C-004).
5
+ *
6
+ * Purpose:
7
+ * `createRedirect`/`updateRedirect`/`tombstoneRedirect`/`importRedirects` —
8
+ * the single manual-write entry point for redirect rules. Validates the
9
+ * pattern (via `RedirectMatcher.validatePattern`), rejects unsafe targets —
10
+ * absolute ones against the origin allowlist and site-relative ones against
11
+ * the admin application's own URL space (write-path open-redirect oracle,
12
+ * REQ-08; see `assertTargetAllowed`) — collapses/rejects
13
+ * one-hop chains (INV-04), enforces the exact-match dedup rule
14
+ * (behavior.spec.md §5.1), and writes the record + its revision in one
15
+ * transaction via `ports.internal.ts`'s shared `insertRedirectAndRevision`
16
+ * (INV-01/INV-07 — no other file writes `redirects`/`redirect_revisions`).
17
+ *
18
+ * Architectural role:
19
+ * Feature logic. No Express/route code — route handlers (Phase 2) call these
20
+ * functions directly and map thrown typed errors to HTTP codes per
21
+ * errors.spec.md.
22
+ */
23
+ import {} from "@jini-ai/core/primitives";
24
+ import { assertEntityLive } from "../core/index.js";
25
+ import { callerSafeErrorMessage } from "@jini-ai/core/model-facing-tool-errors";
26
+ import { hasForbiddenRawUrlCharacter } from "@jini-ai/http-kit/verified-origin";
27
+ import { checkSiteRelativeTarget } from "@jini-ai/http-kit/verified-origin";
28
+ import { insertRedirectAndRevision } from "./ports.internal.js";
29
+ import { isReferrerAliasLocation } from "./referrer-alias.js";
30
+ import { checkSameOriginDestination } from "./reserved-destination.js";
31
+ import { RedirectConflictError, RedirectLoopError, RedirectNotFoundError, RedirectTargetNotAllowedError, RedirectValidationError, } from "./types.js";
32
+ // Exported (unchanged values) so `agent-tools.ts`'s published JSON Schema can reuse the exact
33
+ // bounds this chokepoint validates against, rather than restating them — same discipline as
34
+ // `seo/write-service.ts`'s identical export note.
35
+ export const MIN_TARGET_LENGTH = 1;
36
+ export const MAX_TARGET_LENGTH = 2048;
37
+ export const MIN_PRIORITY = 0;
38
+ export const MAX_PRIORITY = 1000;
39
+ const DEFAULT_PRIORITY = 0;
40
+ export const VALID_STATUS_CODES = [301, 302, 307, 308];
41
+ const VALID_STATUSES = ["active", "disabled"];
42
+ export const MAX_IMPORT_BATCH_SIZE = 500;
43
+ function isAbsoluteOrProtocolRelative(target) {
44
+ return target.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(target);
45
+ }
46
+ function validateStatusCode(statusCode) {
47
+ if (!VALID_STATUS_CODES.includes(statusCode)) {
48
+ throw new RedirectValidationError(`statusCode must be one of ${VALID_STATUS_CODES.join(", ")}`);
49
+ }
50
+ }
51
+ /**
52
+ * `RedirectStatus`'s union isn't enforced at the HTTP boundary — the admin PATCH route
53
+ * (`routes/redirects/update.ts`) passes `body.status` straight through as a cast, so a
54
+ * non-canonical spelling (`"Disabled"`, `"ACTIVE"`, `""`) would otherwise be stored as-is. Both
55
+ * repo backends read only `status === "active"` rows, so a garbage value fails closed for
56
+ * matching — but it also misses {@link isDisableOnlyUpdate}'s exact-string fast path, silently
57
+ * routing that PATCH through the full field re-validation instead of the disable-only skip.
58
+ */
59
+ function validateStatus(status) {
60
+ if (!VALID_STATUSES.includes(status)) {
61
+ throw new RedirectValidationError(`status must be one of ${VALID_STATUSES.join(", ")}`);
62
+ }
63
+ }
64
+ function validateToTargetLength(toTarget) {
65
+ if (toTarget.length < MIN_TARGET_LENGTH || toTarget.length > MAX_TARGET_LENGTH) {
66
+ throw new RedirectValidationError(`toTarget must be ${MIN_TARGET_LENGTH}-${MAX_TARGET_LENGTH} characters (got ${toTarget.length})`);
67
+ }
68
+ }
69
+ function validatePriority(priority) {
70
+ if (!Number.isInteger(priority) || priority < MIN_PRIORITY || priority > MAX_PRIORITY) {
71
+ throw new RedirectValidationError(`priority must be an integer ${MIN_PRIORITY}-${MAX_PRIORITY} (got ${priority})`);
72
+ }
73
+ }
74
+ /**
75
+ * Resolve the actual target this rule should persist, applying one-hop
76
+ * collapse (AC-16) and cycle rejection (AC-17): if `toTarget` (relative)
77
+ * itself matches an existing active rule's `fromPattern`, collapse to that
78
+ * rule's own target — the request path never chases more than one hop
79
+ * (INV-04). If the collapsed target equals `fromPattern`, this create/update
80
+ * would introduce a cycle; reject it instead of writing a self-loop.
81
+ */
82
+ async function resolveCollapsedTarget(repo, workspaceId, fromPattern, toTarget) {
83
+ let finalTarget = toTarget;
84
+ if (!isAbsoluteOrProtocolRelative(finalTarget)) {
85
+ const chained = await repo.findByFromPattern({ workspaceId, fromPattern: finalTarget });
86
+ if (chained && chained.status === "active") {
87
+ finalTarget = chained.toTarget;
88
+ }
89
+ }
90
+ if (finalTarget === fromPattern) {
91
+ throw new RedirectLoopError(`redirect from '${fromPattern}' would create a cycle via '${toTarget}' (resolves back to '${fromPattern}')`);
92
+ }
93
+ // The collapse above only follows ONE hop (INV-04's own bound on what a request resolution
94
+ // chases). But a create/update whose finalTarget lands on an EXISTING rule can still complete a
95
+ // longer cycle through rules this call never collapses through — walk the rest of the chain (up
96
+ // to 32 hops, matching REQ-26) so e.g. /b->/c, /c->/a, then creating /a->/b is rejected too.
97
+ // Only a walk that reaches `fromPattern` itself is THIS write's cycle. A loop among other rules
98
+ // (legacy data written before this walk existed) ends the walk instead: this write neither
99
+ // creates it nor would removing this rule break it, and naming `fromPattern` would be false.
100
+ let cur = finalTarget;
101
+ const seen = new Set();
102
+ for (let hop = 0; hop < 32; hop++) {
103
+ if (isAbsoluteOrProtocolRelative(cur))
104
+ break;
105
+ if (cur === fromPattern) {
106
+ throw new RedirectLoopError(`redirect from '${fromPattern}' would create a cycle via '${toTarget}' (following existing redirects leads back to '${fromPattern}')`);
107
+ }
108
+ if (seen.has(cur))
109
+ break;
110
+ seen.add(cur);
111
+ const rule = await repo.findByFromPattern({ workspaceId, fromPattern: cur });
112
+ if (!rule || rule.status !== "active")
113
+ break;
114
+ cur = rule.toTarget;
115
+ }
116
+ return finalTarget;
117
+ }
118
+ /**
119
+ * Why a site-relative target was refused, phrased for the operator or agent that wrote it.
120
+ *
121
+ * Split out of {@link assertTargetAllowed} so the refusal wording lives next to the verdict union
122
+ * it exhausts — a new arm in `SiteRelativeTargetCheck` becomes a type error here rather than a
123
+ * silently generic message.
124
+ *
125
+ * @param check - The verdict from `checkSiteRelativeTarget`, or from `checkSameOriginDestination`
126
+ * when `assertTargetAllowed` phrases the absolute same-origin verdict (t91 B1, 2026-09-16) — its
127
+ * arms are a subset of this parameter's, so no separate phrasing function is needed.
128
+ * @returns The reason clause, or `null` when the target is allowed.
129
+ * @complexity O(1).
130
+ */
131
+ function siteRelativeRefusalReason(check) {
132
+ switch (check.kind) {
133
+ case "ok":
134
+ return null;
135
+ case "reserved":
136
+ return `it resolves to '/${check.surface}', which serves the authenticated admin application rather than this site's public pages`;
137
+ case "off-origin":
138
+ return "it looks site-relative but resolves to a different host (a URL parser reads '\\' as '/')";
139
+ case "unparseable":
140
+ return "it is not a parseable URL path";
141
+ case "malformed-encoding":
142
+ return "it contains a malformed percent-encoding";
143
+ case "disallowed-character":
144
+ return "it contains, or decodes to, a backslash or a control character";
145
+ }
146
+ }
147
+ /**
148
+ * The site-relative half of the write gate: routing's structural + reserved-path verdict, then the
149
+ * redirect oracle's own raw-character rule. The read path hands a relative location to that oracle
150
+ * by concatenation, so a target it refuses (a raw backslash, any whitespace) would be stored and
151
+ * never fire; refusing it here keeps write and read agreeing. Checked SECOND so the more specific
152
+ * routing reasons (off-origin, control character, reserved) keep their own wording — a bare
153
+ * backslash target is `ok` to `checkSiteRelativeTarget` (on this site, `\` really does resolve to
154
+ * `/`), so only this second check ever refuses it (t91 B2, 2026-09-16). Last, the exact target
155
+ * `back`, which Express serves as the visitor's `Referer` instead of a path (see
156
+ * `./referrer-alias.ts`; t91 review F1, 2026-09-16).
157
+ *
158
+ * Exported as THE check for any caller-chosen, site-relative `Location` (t91 open-redirect fix,
159
+ * 2026-09-16): `routes/site/store.ts`'s `returnTo` had its own `startsWith("/") && !startsWith("//")`
160
+ * copy, which `/\evil.example` walks straight through. A caller that needs a path-absolute value
161
+ * still checks `startsWith("/")` itself — this function also accepts relative references (`new`,
162
+ * `?x`), which stay on-site but resolve against the current URL.
163
+ *
164
+ * @param required.target - The untrusted reference exactly as received, before any `new URL()` parse. Anything
165
+ * that is not site-relative (an absolute or protocol-relative URL, `javascript:`) is refused too.
166
+ * @returns The reason clause, or `null` when the target is allowed.
167
+ * @complexity O(n) in the target length.
168
+ * @example siteRelativeTargetReason({ target: "/products", reservedSegments }, {}); // => null
169
+ * @example siteRelativeTargetReason({ target: "/\\evil.example", reservedSegments }, {}) !== null; // => true
170
+ */
171
+ export function siteRelativeTargetReason({ target, reservedSegments }, _optional = {}) {
172
+ const reason = siteRelativeRefusalReason(checkSiteRelativeTarget({ raw: target, reservedSegments }, {}));
173
+ if (reason !== null)
174
+ return reason;
175
+ if (hasForbiddenRawUrlCharacter({ raw: target })) {
176
+ return "it contains a backslash or whitespace, which the redirect origin check refuses (write a space as '%20')";
177
+ }
178
+ if (isReferrerAliasLocation({ location: target }, {})) {
179
+ return "it is 'back', which the server replaces with the visitor's Referer header (a redirect to whatever page linked here)";
180
+ }
181
+ return null;
182
+ }
183
+ /**
184
+ * The write-path target gate (REQ-08), in two halves that close the SAME hole from opposite sides.
185
+ *
186
+ * An ABSOLUTE or protocol-relative target names a host, so the question is "is that host allowed"
187
+ * and `OriginRegistryPort` answers it. A SITE-RELATIVE target names no host, so the origin
188
+ * allowlist has nothing to say about it — and that used to mean it was not checked at all, which
189
+ * left the admin application's own URL space (`/admin`, `/api/...`, served by this same Express
190
+ * app) usable as a redirect destination from a public-site rule, including through the
191
+ * agent-callable `redirects_create`. `checkSiteRelativeTarget` is the second half; it is the same
192
+ * rule `site-inspection`'s `published_page_fetch` applies to a caller-supplied site path, shared
193
+ * from `platform/routing/reserved-paths.ts` rather than restated here.
194
+ *
195
+ * Note for a workspace that predates this gate: a stored rule whose target is now refused can
196
+ * still be turned off with `tombstoneRedirect` (which only flips `status` and never re-validates
197
+ * the target) or with an `updateRedirect` that changes nothing but `status -> disabled` (see
198
+ * `isDisableOnlyUpdate`), or repointed with `updateRedirect` by supplying a new `toTarget`.
199
+ *
200
+ * An absolute target that names the workspace's OWN origin passes the host oracle trivially (its
201
+ * own host is always allowed there), so it also gets the reserved-path rule — via the same
202
+ * `checkSameOriginDestination` the read path applies to the interpolated `Location` (t91 B1,
203
+ * 2026-09-16). Before this, `/admin`/`/api/...` written as an absolute same-origin target was
204
+ * stored and simply never served, because only the read gate refused it.
205
+ *
206
+ * @throws {RedirectTargetNotAllowedError} Naming which half refused, and why.
207
+ * @complexity O(n) in the target length, plus one or two `OriginRegistryPort`/`canonicalOrigin`
208
+ * reads for absolute targets.
209
+ */
210
+ async function assertTargetAllowed(originRegistry, workspaceId, target, reservedSegments) {
211
+ if (!isAbsoluteOrProtocolRelative(target)) {
212
+ const reason = siteRelativeTargetReason({ target, reservedSegments }, {});
213
+ if (reason === null)
214
+ return;
215
+ throw new RedirectTargetNotAllowedError(`toTarget '${target}' is not an allowed redirect destination: ${reason}`);
216
+ }
217
+ const ctx = { workspaceId };
218
+ const allowed = await originRegistry.isAllowedRedirectTarget({ context: ctx, url: target });
219
+ if (!allowed) {
220
+ throw new RedirectTargetNotAllowedError(`toTarget '${target}' is not an allowed redirect destination`);
221
+ }
222
+ // The host oracle only answers "is that host allowed" — a same-origin target passes it by
223
+ // construction, so it still needs the reserved-path check. No try/catch around
224
+ // canonicalOrigin: isAllowedRedirectTarget above only returns true once a canonical origin
225
+ // exists, so a throw here is a real repo/infra error and must surface — the write does not
226
+ // happen (fail closed), not get reworded as a target verdict.
227
+ const verdict = checkSameOriginDestination({ candidate: target, canonical: await originRegistry.canonicalOrigin(ctx), reservedSegments }, {});
228
+ const reason = siteRelativeRefusalReason(verdict);
229
+ if (reason === null)
230
+ return;
231
+ throw new RedirectTargetNotAllowedError(`toTarget '${target}' is not an allowed redirect destination: ${reason}`);
232
+ }
233
+ async function assertNoDuplicate(repo, workspaceId, matchType, fromPattern, excludeId) {
234
+ if (matchType !== "exact")
235
+ return;
236
+ const dup = await repo.lookupExact({ workspaceId, path: fromPattern, includeOverrideOnly: false });
237
+ if (dup && dup.id !== excludeId) {
238
+ throw new RedirectConflictError(`an active exact rule for '${fromPattern}' already exists`);
239
+ }
240
+ }
241
+ /**
242
+ * Pure precondition check mirroring every validator `createRedirect`/`updateRedirect` run, with NO
243
+ * write — `features/redirects/publish-content.ts`'s `precheck()` reuses this so the two can never
244
+ * drift onto different rulesets. Returns the refusal message a create/update against these exact
245
+ * fields would throw, or `null` when nothing would block.
246
+ *
247
+ * @param excludeId Mirrors {@link assertNoDuplicate}'s own parameter — the destination's own row id,
248
+ * when checking an update against itself.
249
+ * @complexity O(n) in `toTarget`'s length, plus the repo/origin reads each check makes — identical
250
+ * cost to `createRedirect`'s own validation pass, since this runs the exact same sequence.
251
+ */
252
+ export async function checkRedirectFieldsWouldWrite({ deps, fields }, { excludeId } = {}) {
253
+ try {
254
+ const patternCheck = deps.matcher.validatePattern({ matchType: fields.matchType, fromPattern: fields.fromPattern });
255
+ if (!patternCheck.ok)
256
+ throw new RedirectValidationError(patternCheck.reason);
257
+ validateToTargetLength(fields.toTarget);
258
+ validateStatusCode(fields.statusCode);
259
+ validatePriority(fields.priority);
260
+ await assertTargetAllowed(deps.originRegistry, fields.workspaceId, fields.toTarget, deps.reservedSegments);
261
+ const finalTarget = await resolveCollapsedTarget(deps.repo, fields.workspaceId, fields.fromPattern, fields.toTarget);
262
+ if (finalTarget !== fields.toTarget) {
263
+ await assertTargetAllowed(deps.originRegistry, fields.workspaceId, finalTarget, deps.reservedSegments);
264
+ }
265
+ await assertNoDuplicate(deps.repo, fields.workspaceId, fields.matchType, fields.fromPattern, excludeId);
266
+ return null;
267
+ }
268
+ catch (err) {
269
+ return err instanceof Error ? err.message : String(err);
270
+ }
271
+ }
272
+ async function enqueueMutatedEvent(deps, record, change) {
273
+ const event = {
274
+ id: deps.idGen.newId(),
275
+ name: `redirect.${change}`,
276
+ occurredAt: clockNowIso({ clock: deps.clock }),
277
+ aggregateId: record.id,
278
+ workspaceId: record.workspaceId,
279
+ actorId: record.createdByPrincipal,
280
+ payload: { workspaceId: record.workspaceId, redirectId: record.id, change },
281
+ };
282
+ // `OutboxPort.enqueue`'s `DomainEvent` parameter defaults its payload generic to
283
+ // `Record<string, unknown>`; a named `interface` payload (as opposed to a type alias/object
284
+ // literal) isn't automatically assignable to that without an explicit index signature — the
285
+ // same TS quirk affects every other typed-payload event in this codebase (e.g.
286
+ // `menu-service.ts`'s `NavMenuChangedPayload`). `ports.ts`'s `RedirectMutatedEventPayload` stays
287
+ // unchanged (ADR-PIPE-009 says `ports.ts` is frozen), so the widening is a call-site cast.
288
+ await deps.outbox.enqueue(event);
289
+ }
290
+ /** C-001: manual-create entry point (REQ-01). */
291
+ export async function createRedirect(required, _optional = {}) {
292
+ const { deps, input, source = "manual" } = required;
293
+ const patternCheck = deps.matcher.validatePattern({
294
+ matchType: input.matchType,
295
+ fromPattern: input.fromPattern,
296
+ });
297
+ if (!patternCheck.ok)
298
+ throw new RedirectValidationError(patternCheck.reason);
299
+ validateToTargetLength(input.toTarget);
300
+ validateStatusCode(input.statusCode);
301
+ const priority = input.priority ?? DEFAULT_PRIORITY;
302
+ validatePriority(priority);
303
+ await assertTargetAllowed(deps.originRegistry, input.workspaceId, input.toTarget, deps.reservedSegments);
304
+ const finalTarget = await resolveCollapsedTarget(deps.repo, input.workspaceId, input.fromPattern, input.toTarget);
305
+ if (finalTarget !== input.toTarget) {
306
+ await assertTargetAllowed(deps.originRegistry, input.workspaceId, finalTarget, deps.reservedSegments);
307
+ }
308
+ await assertNoDuplicate(deps.repo, input.workspaceId, input.matchType, input.fromPattern);
309
+ const now = clockNowIso({ clock: deps.clock });
310
+ const id = deps.idGen.newId();
311
+ const record = {
312
+ id,
313
+ workspaceId: input.workspaceId,
314
+ matchType: input.matchType,
315
+ fromPattern: input.fromPattern,
316
+ toTarget: finalTarget,
317
+ statusCode: input.statusCode,
318
+ status: "active",
319
+ override: input.override ?? false,
320
+ priority,
321
+ source,
322
+ createdByPrincipal: input.actorId,
323
+ createdByPluginId: input.pluginId,
324
+ createdAt: now,
325
+ updatedAt: now,
326
+ version: 1,
327
+ };
328
+ const revision = {
329
+ redirectId: id,
330
+ workspaceId: input.workspaceId,
331
+ seq: 1,
332
+ state: record,
333
+ tombstoned: false,
334
+ actorId: input.actorId,
335
+ pluginId: input.pluginId,
336
+ recordedAt: now,
337
+ };
338
+ await deps.transaction(async () => {
339
+ await insertRedirectAndRevision({ db: deps.db, record, revision });
340
+ });
341
+ await enqueueMutatedEvent(deps, record, "created");
342
+ return { record };
343
+ }
344
+ /** Merges a partial `UpdateRedirectInput` over the existing record's own values — C-002's
345
+ * "apply only what changed" PATCH semantics, isolated as its own defaulting decision so
346
+ * {@link updateRedirect} itself only has to sequence validate → write against the result. */
347
+ function resolveUpdateFields(input, existing) {
348
+ return {
349
+ matchType: input.matchType ?? existing.matchType,
350
+ fromPattern: input.fromPattern ?? existing.fromPattern,
351
+ toTarget: input.toTarget ?? existing.toTarget,
352
+ statusCode: input.statusCode ?? existing.statusCode,
353
+ priority: input.priority ?? existing.priority,
354
+ override: input.override ?? existing.override,
355
+ status: input.status ?? existing.status,
356
+ };
357
+ }
358
+ /** Every PATCH-able field except `status`, for {@link isDisableOnlyUpdate}'s "nothing else changed" test. */
359
+ const NON_STATUS_UPDATABLE_FIELDS = [
360
+ "matchType",
361
+ "fromPattern",
362
+ "toTarget",
363
+ "statusCode",
364
+ "priority",
365
+ "override",
366
+ ];
367
+ /**
368
+ * Whether this update does nothing but switch the rule OFF (t91 review F2, coordinator ruling
369
+ * 2026-09-16). The admin Redirects screen's on/off toggle PATCHes `status` alone, and a legacy rule
370
+ * whose stored target the gate now refuses used to fail that toggle on the target check. A disabled
371
+ * rule is never served, so turning one off only reduces risk; re-enabling it, or changing any other
372
+ * field, is a full update and runs every check. A field sent with the value it already has counts as
373
+ * unchanged, so a client that PATCHes the whole record gets the same answer as one that sends only
374
+ * `status`.
375
+ *
376
+ * @param fields - {@link resolveUpdateFields}'s merge of the input over `existing`.
377
+ * @param existing - The stored record being updated.
378
+ * @returns `true` only when `fields.status` is `disabled` and every other field equals `existing`'s.
379
+ * @complexity O(1) — a fixed set of scalar comparisons.
380
+ */
381
+ function isDisableOnlyUpdate(fields, existing) {
382
+ return fields.status === "disabled" && NON_STATUS_UPDATABLE_FIELDS.every((key) => fields[key] === existing[key]);
383
+ }
384
+ /**
385
+ * Whether this update states nothing but `status: "active"` (S7, web-high fix plan 2026-09-24) — a
386
+ * pure re-enable, which on a rule that is in the Trash means "restore it," the same intent the admin
387
+ * Redirects screen's Enable toggle already carries for an ordinary disabled rule. Checked against
388
+ * the INPUT actually sent, not {@link resolveUpdateFields}'s merge over `existing` — unlike
389
+ * {@link isDisableOnlyUpdate}'s "unchanged" test, a trashed row's OWN stored fields cannot be used as
390
+ * the comparison basis here, since nothing about being in the Trash guarantees they are still valid
391
+ * (the whole point of the `else` branch below is that a trashed row may not be edited sight-unseen).
392
+ *
393
+ * @complexity O(1) — a fixed set of `undefined` checks.
394
+ */
395
+ function isRestoreOnlyUpdate(input) {
396
+ return input.status === "active" && NON_STATUS_UPDATABLE_FIELDS.every((key) => input[key] === undefined);
397
+ }
398
+ /**
399
+ * Runs every field check a create runs against the merged update, and returns the target to store
400
+ * (one-hop collapsed, AC-16). Split out of {@link updateRedirect} so a disable-only update can skip
401
+ * it as one decision.
402
+ *
403
+ * @throws {RedirectValidationError | RedirectTargetNotAllowedError | RedirectLoopError | RedirectConflictError}
404
+ * @complexity O(n) in the target length, plus the repo and origin reads each check makes.
405
+ */
406
+ async function validateUpdateFields(deps, workspaceId, fields, existingId) {
407
+ const patternCheck = deps.matcher.validatePattern({ matchType: fields.matchType, fromPattern: fields.fromPattern });
408
+ if (!patternCheck.ok)
409
+ throw new RedirectValidationError(patternCheck.reason);
410
+ validateToTargetLength(fields.toTarget);
411
+ validateStatusCode(fields.statusCode);
412
+ validateStatus(fields.status);
413
+ validatePriority(fields.priority);
414
+ await assertTargetAllowed(deps.originRegistry, workspaceId, fields.toTarget, deps.reservedSegments);
415
+ const finalTarget = await resolveCollapsedTarget(deps.repo, workspaceId, fields.fromPattern, fields.toTarget);
416
+ if (finalTarget !== fields.toTarget) {
417
+ await assertTargetAllowed(deps.originRegistry, workspaceId, finalTarget, deps.reservedSegments);
418
+ }
419
+ await assertNoDuplicate(deps.repo, workspaceId, fields.matchType, fields.fromPattern, existingId);
420
+ return finalTarget;
421
+ }
422
+ /**
423
+ * C-002: symmetric chokepoint entry for updates (REQ-04). A disable-only update
424
+ * ({@link isDisableOnlyUpdate}) writes the status change without re-validating the unchanged fields
425
+ * — it stores the existing target as-is, with no one-hop collapse.
426
+ *
427
+ * S7 (web-high fix plan 2026-09-24): a rule with a Trash index row (`deps.isInTrash`, distinct from
428
+ * `status === "disabled"` — see {@link RedirectsWriteDeps.isInTrash}'s own doc) refuses every update
429
+ * EXCEPT a pure `status: "active"` PATCH ({@link isRestoreOnlyUpdate}), which restores it instead of
430
+ * editing a row the caller cannot see the current shape of.
431
+ *
432
+ * @throws {EntityNotLiveError} On a non-restore update to a rule in the Trash.
433
+ * @throws {RedirectConflictError} If the Trash row's version moved before the restore landed.
434
+ */
435
+ export async function updateRedirect(required, _optional = {}) {
436
+ const { deps, input } = required;
437
+ const existing = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.id });
438
+ if (!existing)
439
+ throw new RedirectNotFoundError(`redirect '${input.id}' was not found`);
440
+ const inTrash = await deps.isInTrash({ workspaceId: input.workspaceId, id: input.id });
441
+ if (inTrash) {
442
+ if (!isRestoreOnlyUpdate(input)) {
443
+ assertEntityLive({ entityType: "redirect", entityId: input.id, state: "trashed" });
444
+ }
445
+ const outcome = await deps.restore({
446
+ workspaceId: input.workspaceId,
447
+ id: input.id,
448
+ at: clockNowIso({ clock: deps.clock }),
449
+ actor: { principalId: input.actorId, pluginId: input.pluginId ?? null },
450
+ });
451
+ if (outcome === "not-found")
452
+ throw new RedirectNotFoundError(`redirect '${input.id}' was not found`);
453
+ if (outcome === "version-changed") {
454
+ throw new RedirectConflictError(`redirect '${input.id}' changed while it was being restored`);
455
+ }
456
+ if (outcome === "adapter-unavailable") {
457
+ throw new Error(`trash: no Trash adapter is registered for entity type 'redirect' (id '${input.id}')`);
458
+ }
459
+ const restored = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.id });
460
+ if (!restored)
461
+ throw new RedirectNotFoundError(`redirect '${input.id}' was not found`);
462
+ await enqueueMutatedEvent(deps, restored, "updated");
463
+ return { record: restored };
464
+ }
465
+ const fields = resolveUpdateFields(input, existing);
466
+ const finalTarget = isDisableOnlyUpdate(fields, existing)
467
+ ? existing.toTarget
468
+ : await validateUpdateFields(deps, input.workspaceId, fields, existing.id);
469
+ const now = clockNowIso({ clock: deps.clock });
470
+ const version = existing.version + 1;
471
+ const record = { ...existing, ...fields, toTarget: finalTarget, updatedAt: now, version };
472
+ const revision = {
473
+ redirectId: existing.id,
474
+ workspaceId: input.workspaceId,
475
+ seq: version,
476
+ state: record,
477
+ tombstoned: false,
478
+ actorId: input.actorId,
479
+ pluginId: input.pluginId,
480
+ recordedAt: now,
481
+ };
482
+ await deps.transaction(async () => {
483
+ await insertRedirectAndRevision({ db: deps.db, record, revision });
484
+ });
485
+ await enqueueMutatedEvent(deps, record, "updated");
486
+ return { record };
487
+ }
488
+ /** C-003: soft-delete (REQ-05). */
489
+ export async function tombstoneRedirect(required, _optional = {}) {
490
+ const { deps, input } = required;
491
+ const existing = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.id });
492
+ if (!existing)
493
+ throw new RedirectNotFoundError(`redirect '${input.id}' was not found`);
494
+ if (existing.status === "disabled") {
495
+ // Idempotent no-op: already tombstoned, no new revision needed.
496
+ return { record: existing };
497
+ }
498
+ const now = clockNowIso({ clock: deps.clock });
499
+ const version = existing.version + 1;
500
+ const record = { ...existing, status: "disabled", updatedAt: now, version };
501
+ const revision = {
502
+ redirectId: existing.id,
503
+ workspaceId: input.workspaceId,
504
+ seq: version,
505
+ state: record,
506
+ tombstoned: true,
507
+ actorId: input.actorId,
508
+ pluginId: input.pluginId,
509
+ recordedAt: now,
510
+ };
511
+ // The marker flip, the Trash index row and the revision append are one unit. `deps.remove` joins
512
+ // this transaction rather than opening its own, so a tombstone can never land without its Trash
513
+ // row (which would make the redirect unrecoverable through the UI) or vice versa.
514
+ await deps.transaction(async () => {
515
+ const removed = await deps.remove({
516
+ workspaceId: input.workspaceId,
517
+ id: input.id,
518
+ // From columns already loaded above — no second read.
519
+ display: { title: existing.fromPattern, subtitle: existing.toTarget },
520
+ at: now,
521
+ expectedVersion: existing.version,
522
+ actor: { principalId: input.actorId, pluginId: input.pluginId ?? null },
523
+ });
524
+ if (!removed.ok) {
525
+ if (removed.reason === "not-found")
526
+ throw new RedirectNotFoundError(`redirect '${input.id}' was not found`);
527
+ throw new RedirectConflictError(`redirect '${input.id}' changed while it was being tombstoned`);
528
+ }
529
+ await deps.db.insertRevision(revision);
530
+ });
531
+ await enqueueMutatedEvent(deps, record, "tombstoned");
532
+ return { record };
533
+ }
534
+ function errorToCode(err) {
535
+ if (err instanceof RedirectValidationError)
536
+ return "REDIRECT_VALIDATION_ERROR";
537
+ if (err instanceof RedirectTargetNotAllowedError)
538
+ return "REDIRECT_TARGET_NOT_ALLOWED";
539
+ if (err instanceof RedirectConflictError)
540
+ return "REDIRECT_CONFLICT";
541
+ if (err instanceof RedirectLoopError)
542
+ return "REDIRECT_LOOP_DETECTED";
543
+ return "INTERNAL_ERROR";
544
+ }
545
+ /**
546
+ * The classes whose message a per-rule import failure may carry verbatim — the same four
547
+ * {@link errorToCode} names (wm S16, 2026-10-04). `failed[]` is a RETURNED result (the agent tool
548
+ * and the admin import route both hand it out as-is), so it bypasses the transport's
549
+ * INTERNAL_ERROR redaction; before this, an infrastructure error (a driver failure quoting a file
550
+ * path, say) put its raw message straight into the model's and the admin's view.
551
+ */
552
+ const IMPORT_FAILURE_SAFE_RULES = [
553
+ { error: RedirectValidationError },
554
+ { error: RedirectTargetNotAllowedError },
555
+ { error: RedirectConflictError },
556
+ { error: RedirectLoopError },
557
+ ];
558
+ /**
559
+ * C-004: batch-create reusing the same chokepoint, no bypass (REQ-26). Each
560
+ * item is processed independently through `createRedirect` — NOT one shared
561
+ * transaction across items (EC-08); a per-item failure does not abort
562
+ * already-written prior items.
563
+ */
564
+ export async function importRedirects(required, _optional = {}) {
565
+ const { deps, input } = required;
566
+ if (input.rules.length < 1 || input.rules.length > MAX_IMPORT_BATCH_SIZE) {
567
+ throw new RedirectValidationError(`import batch size must be 1-${MAX_IMPORT_BATCH_SIZE} (got ${input.rules.length})`);
568
+ }
569
+ const created = [];
570
+ const failed = [];
571
+ for (const [index, rule] of input.rules.entries()) {
572
+ try {
573
+ const { record } = await createRedirect({
574
+ deps,
575
+ input: { ...rule, workspaceId: input.workspaceId, actorId: input.actorId },
576
+ source: "import",
577
+ });
578
+ created.push(record);
579
+ }
580
+ catch (err) {
581
+ failed.push({
582
+ index,
583
+ code: errorToCode(err),
584
+ message: callerSafeErrorMessage({ err, rules: IMPORT_FAILURE_SAFE_RULES, fallback: "an internal error occurred" }),
585
+ });
586
+ }
587
+ }
588
+ return { created, failed };
589
+ }
590
+ //# sourceMappingURL=redirects.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @file The one redirect `Location` value the HTTP layer does not send as written (t91 review F1,
3
+ * 2026-09-16).
4
+ *
5
+ * Purpose:
6
+ * Express 4's `res.location()` — called by the `res.redirect(statusCode, location)` that
7
+ * `server/inbound/public-http/routes/site/pages.ts` serves every matched rule with — replaces the
8
+ * exact string `back` with the request's `Referer` header (or `/`). A rule whose finished location
9
+ * is `back` therefore sends the visitor to whatever page linked to this site: an open redirect the
10
+ * origin oracle never sees, because the oracle is asked about `<canonical origin>/back`. The value
11
+ * can be stored literally or produced by interpolation (a wildcard `/go/*` -> `$1` plus a request
12
+ * for `/go/back`), so the WRITE gate (`redirects.ts`) refuses the stored form and the READ gate
13
+ * (`phase-handler.ts`) refuses the finished form.
14
+ *
15
+ * Architectural role:
16
+ * Feature logic, pure, no I/O.
17
+ */
18
+ /**
19
+ * Whether `location` is the value Express would replace with the request's `Referer` header.
20
+ *
21
+ * @param required.location - A redirect target or a fully interpolated `Location`, exactly as it would be
22
+ * handed to `res.redirect`.
23
+ * @returns `true` only for the exact string `back` — Express's own comparison is case-sensitive and
24
+ * untrimmed, so `/back`, `BACK` and `backup` are sent as written.
25
+ * @complexity O(1).
26
+ * @example isReferrerAliasLocation({ location: "back" }, {}); // => true
27
+ */
28
+ export declare function isReferrerAliasLocation({ location }: {
29
+ location: string;
30
+ }, _optional?: Record<string, never>): boolean;
31
+ //# sourceMappingURL=referrer-alias.d.ts.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @file The one redirect `Location` value the HTTP layer does not send as written (t91 review F1,
3
+ * 2026-09-16).
4
+ *
5
+ * Purpose:
6
+ * Express 4's `res.location()` — called by the `res.redirect(statusCode, location)` that
7
+ * `server/inbound/public-http/routes/site/pages.ts` serves every matched rule with — replaces the
8
+ * exact string `back` with the request's `Referer` header (or `/`). A rule whose finished location
9
+ * is `back` therefore sends the visitor to whatever page linked to this site: an open redirect the
10
+ * origin oracle never sees, because the oracle is asked about `<canonical origin>/back`. The value
11
+ * can be stored literally or produced by interpolation (a wildcard `/go/*` -> `$1` plus a request
12
+ * for `/go/back`), so the WRITE gate (`redirects.ts`) refuses the stored form and the READ gate
13
+ * (`phase-handler.ts`) refuses the finished form.
14
+ *
15
+ * Architectural role:
16
+ * Feature logic, pure, no I/O.
17
+ */
18
+ /** The string Express 4's `res.location()` swaps for the request's `Referer`. Compared with `===` there. */
19
+ const EXPRESS_REFERRER_ALIAS = "back";
20
+ /**
21
+ * Whether `location` is the value Express would replace with the request's `Referer` header.
22
+ *
23
+ * @param required.location - A redirect target or a fully interpolated `Location`, exactly as it would be
24
+ * handed to `res.redirect`.
25
+ * @returns `true` only for the exact string `back` — Express's own comparison is case-sensitive and
26
+ * untrimmed, so `/back`, `BACK` and `backup` are sent as written.
27
+ * @complexity O(1).
28
+ * @example isReferrerAliasLocation({ location: "back" }, {}); // => true
29
+ */
30
+ export function isReferrerAliasLocation({ location }, _optional = {}) {
31
+ return location === EXPRESS_REFERRER_ALIAS;
32
+ }
33
+ //# sourceMappingURL=referrer-alias.js.map