@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.
- package/CHANGELOG.md +15 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- package/dist/forms/delete-submission.d.ts +38 -0
- package/dist/forms/delete-submission.js +25 -0
- package/dist/forms/duplicate-slug.d.ts +81 -0
- package/dist/forms/duplicate-slug.js +115 -0
- package/dist/forms/errors.d.ts +53 -0
- package/dist/forms/errors.js +25 -0
- package/dist/forms/express/handler.d.ts +59 -0
- package/dist/forms/express/handler.js +217 -0
- package/dist/forms/express/index.d.ts +2 -0
- package/dist/forms/express/index.js +2 -0
- package/dist/forms/fields-json.d.ts +14 -0
- package/dist/forms/fields-json.js +11 -0
- package/dist/forms/forms.d.ts +59 -0
- package/dist/forms/forms.js +213 -0
- package/dist/forms/html/authoring-adapter.d.ts +34 -0
- package/dist/forms/html/authoring-adapter.js +48 -0
- package/dist/forms/html/html-authoring.d.ts +12 -0
- package/dist/forms/html/html-authoring.js +133 -0
- package/dist/forms/html/html-submission-adapter.d.ts +10 -0
- package/dist/forms/html/html-submission-adapter.js +15 -0
- package/dist/forms/html/index.d.ts +4 -0
- package/dist/forms/html/index.js +4 -0
- package/dist/forms/html-render.d.ts +33 -0
- package/dist/forms/html-render.js +31 -0
- package/dist/forms/index.d.ts +17 -0
- package/dist/forms/index.js +17 -0
- package/dist/forms/notify-subscriber.d.ts +32 -0
- package/dist/forms/notify-subscriber.js +60 -0
- package/dist/forms/ports.d.ts +168 -0
- package/dist/forms/ports.js +2 -0
- package/dist/forms/rate-limit-key.d.ts +8 -0
- package/dist/forms/rate-limit-key.js +7 -0
- package/dist/forms/repo.memory.d.ts +120 -0
- package/dist/forms/repo.memory.js +188 -0
- package/dist/forms/sql/index.d.ts +5 -0
- package/dist/forms/sql/index.js +5 -0
- package/dist/forms/sql/repo.d.ts +94 -0
- package/dist/forms/sql/repo.js +175 -0
- package/dist/forms/sql/repo.rows.d.ts +33 -0
- package/dist/forms/sql/repo.rows.js +64 -0
- package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
- package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
- package/dist/forms/sql/tables.d.ts +32 -0
- package/dist/forms/sql/tables.js +2 -0
- package/dist/forms/submission-attempts.d.ts +70 -0
- package/dist/forms/submission-attempts.js +70 -0
- package/dist/forms/submission-email-validation.d.ts +8 -0
- package/dist/forms/submission-email-validation.js +34 -0
- package/dist/forms/submission-ip-retention.d.ts +30 -0
- package/dist/forms/submission-ip-retention.js +27 -0
- package/dist/forms/submit-service.d.ts +78 -0
- package/dist/forms/submit-service.js +109 -0
- package/dist/forms/types.d.ts +49 -0
- package/dist/forms/types.js +2 -0
- package/dist/forms/write-service.d.ts +83 -0
- package/dist/forms/write-service.js +271 -0
- package/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- 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
|