@taprootio/site-authoring 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+
3
+ /**
4
+ * The redirect map's workspace contract (TR00702).
5
+ *
6
+ * A migrated site's old URLs are the only thing standing between its search
7
+ * history and a wall of 404s, so this file is authored deliberately rather than
8
+ * inferred: `redirects pull` writes it, `redirects push` replaces the whole map
9
+ * from it, and `validate` proves it offline. Every rule the API enforces is
10
+ * enforced here first, by entry index, because a hundred-row import that comes
11
+ * back as one unnamed 400 is a hunt rather than a fix.
12
+ *
13
+ * Where a rule depends on what the site actually serves — path occupancy, and
14
+ * the path bound for an entry the site itself recorded — the site is the
15
+ * authority and this pass defers to it rather than refusing a map the site
16
+ * would have accepted.
17
+ *
18
+ * Converting an engagement's spreadsheet into this file is the agent's job. The
19
+ * CLI's contract is JSON; it deliberately does not parse CSV, because a real
20
+ * migration list needs judgement about which old URLs still deserve a
21
+ * destination and which are simply gone.
22
+ */
23
+
24
+ export const REDIRECTS_FILE_NAME = "redirects.json";
25
+
26
+ export const REDIRECT_KIND_REDIRECT = "redirect";
27
+ export const REDIRECT_KIND_GONE = "gone";
28
+ export const REDIRECT_KINDS = Object.freeze([REDIRECT_KIND_REDIRECT, REDIRECT_KIND_GONE]);
29
+
30
+ export const REDIRECT_ORIGIN_AUTHORED = "authored";
31
+ export const REDIRECT_ORIGIN_PATH_HISTORY = "path_history";
32
+ export const REDIRECT_ORIGINS = Object.freeze([REDIRECT_ORIGIN_AUTHORED, REDIRECT_ORIGIN_PATH_HISTORY]);
33
+
34
+ /** The status a redirect entry serves when it declares none. */
35
+ export const DEFAULT_REDIRECT_STATUS = 301;
36
+
37
+ /** The status a gone entry serves. It is not declarable as anything else. */
38
+ export const GONE_STATUS = 410;
39
+
40
+ /** The statuses the published-site edge value contract accepts. */
41
+ export const REDIRECT_STATUSES = Object.freeze([301, 302, 307, 308]);
42
+
43
+ /**
44
+ * Bounds, mirrored from `SiteRedirectMapContract` on the server. The path bound
45
+ * is what fits a Workers KV key beside its `redirect:{environment}:{site}:`
46
+ * envelope; the target bound is the edge's own.
47
+ */
48
+ export const REDIRECT_LIMITS = Object.freeze({
49
+ entries: 2_000,
50
+ pathBytes: 400,
51
+ targetBytes: 2_048,
52
+ });
53
+
54
+ // C0, DEL, and C1 — every code point the server's own `char.IsControl`
55
+ // refuses, so a value the site would reject is named here by entry index
56
+ // rather than surviving the offline pass. A path or target carrying one is
57
+ // not a URL a browser could have requested, and normalizing it away would
58
+ // hide the mistake.
59
+ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/u;
60
+ const ENTRY_KEYS = new Set(["path", "kind", "target", "status", "origin"]);
61
+ const SHA256 = /^[0-9a-f]{64}$/u;
62
+
63
+ // An empty interior segment, or a dot or slash hidden behind percent-encoding.
64
+ // '//' is also where a protocol-relative URL hides, which is why the leading
65
+ // case is covered by this rule rather than by a separate prefix check.
66
+ const RESOLVABLE_SPELLING = /\/\/|%2e|%2f/iu;
67
+
68
+ // A spelling no browser would send as a pathname: anything outside printable
69
+ // ASCII, one of the characters a URL parser escapes on sight, or a '%' that is
70
+ // not an escape.
71
+ // No `i` flag: under Unicode case folding it would let U+212A (KELVIN SIGN)
72
+ // and U+017F (LONG S) through `[^!-~]`, which the site refuses.
73
+ const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
74
+
75
+ /**
76
+ * Whether a path is already written the way a browser sends a pathname.
77
+ *
78
+ * The published-site edge keys on `url.pathname`, which the URL parser has
79
+ * already percent-encoded, and its `normalizePathname` decodes nothing — while
80
+ * every rule here compares the string as written. So '/old%20page -> /old page'
81
+ * is two different strings to loop detection and one path to the edge, which
82
+ * matches '/old%20page', resolves the target with `new URL` back to
83
+ * '/old%20page', and serves a permanent self-loop. A source written with a
84
+ * literal space or a non-ASCII character is the same mismatch the other way,
85
+ * stored under a key no request pathname ever matches.
86
+ *
87
+ * Refused rather than encoded here. URL escaping is not reproducibly canonical
88
+ * between the API's .NET and the edge's WHATWG parser, so a spelling this pass
89
+ * encoded would still not be the one the Worker computes and the layers would
90
+ * go on comparing different strings. Write what a browser sends and every layer
91
+ * compares it unchanged — and the byte bounds then measure the spelling the KV
92
+ * key actually costs.
93
+ */
94
+ function isRequestSpelling(value) {
95
+ return !UNSENDABLE_SPELLING.test(value);
96
+ }
97
+
98
+ /**
99
+ * Whether a path names one thing to this pass and another wherever it is
100
+ * resolved.
101
+ *
102
+ * Every rule here compares the string as written, and nothing downstream does:
103
+ * the generator resolves a source with path.resolve, so '/x/../visit' writes
104
+ * over the page rendered at '/visit', and the published-site edge resolves a
105
+ * target with `new URL`, so '/a -> /x/../a' is the self-loop that loop
106
+ * detection just passed. Percent-encoded dots and slashes are the same trick
107
+ * spelled so a segment scan cannot see it, so they are refused wherever they
108
+ * appear rather than decoded and re-examined.
109
+ */
110
+ function resolvesAway(value) {
111
+ if (RESOLVABLE_SPELLING.test(value)) return true;
112
+ return value.split("/").some((segment) => segment === "." || segment === "..");
113
+ }
114
+
115
+ /** Whether the value is a redirect-map revision as the server mints one. */
116
+ export function isRedirectMapRevision(value) {
117
+ return typeof value === "string" && SHA256.test(value);
118
+ }
119
+
120
+ function entryError(code, message, field) {
121
+ return new SiteAuthoringError(code, `${field}: ${message}`, { field });
122
+ }
123
+
124
+ function isPlainObject(value) {
125
+ return value !== null && typeof value === "object" && !Array.isArray(value);
126
+ }
127
+
128
+ function utf8Length(value) {
129
+ return Buffer.byteLength(value, "utf8");
130
+ }
131
+
132
+ /**
133
+ * Normalizes a source path exactly as the published-site Worker's
134
+ * `normalizePathname` does: require the leading slash, strip trailing slashes,
135
+ * change nothing else. A `.html` suffix survives, which is the whole point —
136
+ * `/faqs.html` is a real legacy URL and has to be representable. A spelling
137
+ * some other layer would resolve away is refused rather than repaired (see
138
+ * `resolvesAway`), and so is one no browser would send as a pathname (see
139
+ * `isRequestSpelling`, which subsumes the control-character refusal).
140
+ */
141
+ export function normalizeRedirectPath(value) {
142
+ if (typeof value !== "string") return undefined;
143
+ const candidate = value.trim();
144
+ if (
145
+ candidate === ""
146
+ || /[?#\\]/u.test(candidate)
147
+ || !isRequestSpelling(candidate)
148
+ || resolvesAway(candidate)
149
+ ) {
150
+ return undefined;
151
+ }
152
+ const withSlash = candidate.startsWith("/") ? candidate : `/${candidate}`;
153
+ const trimmed = withSlash.replace(/\/+$/u, "");
154
+ return trimmed === "" ? "/" : trimmed;
155
+ }
156
+
157
+ function looksAbsolute(value) {
158
+ return !value.startsWith("/") && value.includes("://");
159
+ }
160
+
161
+ /** Whether the value is an absolute, credential-free http(s) URL. */
162
+ export function isAbsoluteRedirectTarget(value) {
163
+ let url;
164
+ try {
165
+ url = new URL(value);
166
+ } catch {
167
+ return false;
168
+ }
169
+ return (url.protocol === "https:" || url.protocol === "http:")
170
+ && url.username === ""
171
+ && url.password === "";
172
+ }
173
+
174
+ /**
175
+ * The site path an internal target names, or undefined when the target leaves
176
+ * the site. Only internal targets take part in chain and loop detection: an
177
+ * absolute URL is another origin's business even when it happens to spell this
178
+ * site's own domain.
179
+ *
180
+ * The query string and fragment are cut before the comparison, never before
181
+ * storage. The edge keys a redirect on the pathname alone, so '/b?x=1' and
182
+ * '/b' are one route to it; normalizing the whole target would return
183
+ * undefined for either (a source may carry no query, a target may) and
184
+ * '/a -> /b?x=1' beside '/b -> /a?y=2' would pass as a pair of unrelated
185
+ * entries and loop at the edge.
186
+ */
187
+ export function internalRedirectTargetPath(entry) {
188
+ if (entry.kind === REDIRECT_KIND_GONE || isAbsoluteRedirectTarget(entry.target)) return undefined;
189
+ if (typeof entry.target !== "string") return undefined;
190
+ const cut = entry.target.search(/[?#]/u);
191
+ return normalizeRedirectPath(cut === -1 ? entry.target : entry.target.slice(0, cut));
192
+ }
193
+
194
+ function validateEntry(value, index, seen) {
195
+ const field = `entries[${index}]`;
196
+ if (!isPlainObject(value)) {
197
+ throw entryError("redirects.entry_invalid", "a redirect entry must be an object.", field);
198
+ }
199
+ const unknown = Object.keys(value).filter((key) => !ENTRY_KEYS.has(key)).sort();
200
+ if (unknown.length > 0) {
201
+ throw entryError(
202
+ "redirects.unknown_field",
203
+ `unsupported redirect field '${unknown[0]}'; an entry carries ${[...ENTRY_KEYS].join(", ")}.`,
204
+ field,
205
+ );
206
+ }
207
+
208
+ const path = normalizeRedirectPath(value.path);
209
+ if (path === undefined) {
210
+ throw entryError(
211
+ "redirects.path_invalid",
212
+ "'path' must be an absolute site path such as /faqs.html, with no query string, fragment, or host, no '.' "
213
+ + "or '..' segment, no empty segment, and no percent-encoded dot or slash. Write the percent-encoded "
214
+ + "spelling a browser sends (/caf%C3%A9, /old%20page), never the raw one.",
215
+ `${field}.path`,
216
+ );
217
+ }
218
+ // The site root is not refused here. A home page rename records an entry at
219
+ // '/', so refusing it offline would make a pulled map unpushable for a site
220
+ // whose home page moved. Occupancy is the rule that belongs, and it is the
221
+ // site's to enforce: '/' is refused for exactly as long as a home page is
222
+ // served there.
223
+ //
224
+ // The path bound is the same shape of problem. A page may sit at a path
225
+ // longer than the map allows a source to be, and renaming it records a
226
+ // path-history entry at that length which 'redirects pull' returns. Refusing
227
+ // it here would make the site's own map fail 'validate' and unpushable
228
+ // without dropping a live redirect, so an entry the site reports as
229
+ // path_history is exempt and the site decides: it alone knows whether the
230
+ // push is introducing that path or merely carrying it back unchanged.
231
+ if (value.origin !== REDIRECT_ORIGIN_PATH_HISTORY && utf8Length(path) > REDIRECT_LIMITS.pathBytes) {
232
+ throw entryError(
233
+ "redirects.path_too_long",
234
+ `'path' cannot exceed ${REDIRECT_LIMITS.pathBytes} bytes.`,
235
+ `${field}.path`,
236
+ );
237
+ }
238
+ if (seen.has(path.toLowerCase())) {
239
+ throw entryError(
240
+ "redirects.path_duplicate",
241
+ `'${path}' appears more than once; one path has one entry.`,
242
+ `${field}.path`,
243
+ );
244
+ }
245
+ seen.add(path.toLowerCase());
246
+
247
+ const kind = value.kind === undefined ? REDIRECT_KIND_REDIRECT : value.kind;
248
+ if (!REDIRECT_KINDS.includes(kind)) {
249
+ throw entryError(
250
+ "redirects.kind_invalid",
251
+ `'kind' must be one of ${REDIRECT_KINDS.join(", ")}.`,
252
+ `${field}.kind`,
253
+ );
254
+ }
255
+
256
+ if (value.origin !== undefined && !REDIRECT_ORIGINS.includes(value.origin)) {
257
+ throw entryError(
258
+ "redirects.origin_invalid",
259
+ `'origin' must be one of ${REDIRECT_ORIGINS.join(", ")} when present. It is what the site reports, `
260
+ + "not something a push can set.",
261
+ `${field}.origin`,
262
+ );
263
+ }
264
+
265
+ if (kind === REDIRECT_KIND_GONE) {
266
+ if (value.target !== undefined && value.target !== "") {
267
+ throw entryError(
268
+ "redirects.gone_target",
269
+ "a gone entry answers 410 and carries no target.",
270
+ `${field}.target`,
271
+ );
272
+ }
273
+ if (value.status !== undefined && value.status !== GONE_STATUS) {
274
+ throw entryError(
275
+ "redirects.gone_status",
276
+ `a gone entry serves ${GONE_STATUS}; no other status can be declared for it.`,
277
+ `${field}.status`,
278
+ );
279
+ }
280
+ return { path, kind: REDIRECT_KIND_GONE, target: "", status: GONE_STATUS };
281
+ }
282
+
283
+ if (typeof value.target !== "string" || value.target.trim() === "") {
284
+ throw entryError(
285
+ "redirects.target_missing",
286
+ "a redirect entry needs a 'target': a site-relative path such as /classes, or an absolute http(s) URL.",
287
+ `${field}.target`,
288
+ );
289
+ }
290
+ const rawTarget = value.target.trim();
291
+ if (utf8Length(rawTarget) > REDIRECT_LIMITS.targetBytes) {
292
+ throw entryError(
293
+ "redirects.target_too_long",
294
+ `'target' cannot exceed ${REDIRECT_LIMITS.targetBytes} bytes.`,
295
+ `${field}.target`,
296
+ );
297
+ }
298
+
299
+ let target;
300
+ if (looksAbsolute(rawTarget)) {
301
+ if (!isAbsoluteRedirectTarget(rawTarget)) {
302
+ throw entryError(
303
+ "redirects.target_invalid",
304
+ "an absolute 'target' must be an http or https URL carrying no credentials.",
305
+ `${field}.target`,
306
+ );
307
+ }
308
+ target = rawTarget;
309
+ } else {
310
+ // Only the path component is held to the alias and spelling rules: a query
311
+ // string or fragment legitimately carries percent-encoded slashes and whole
312
+ // URLs, and the edge keys on the pathname alone, as
313
+ // internalRedirectTargetPath cuts it.
314
+ const targetPath = rawTarget.split(/[?#]/u)[0];
315
+ if (
316
+ rawTarget.includes("\\")
317
+ || CONTROL_CHARACTERS.test(rawTarget)
318
+ || !isRequestSpelling(targetPath)
319
+ || resolvesAway(targetPath)
320
+ ) {
321
+ throw entryError(
322
+ "redirects.target_invalid",
323
+ "a site-relative 'target' must be a single-slash absolute path such as /classes, whose path carries no "
324
+ + "'.' or '..' segment and no percent-encoded dot or slash (a query string may). Write its path in the "
325
+ + "percent-encoded spelling a browser sends (/caf%C3%A9, /old%20page), never the raw one.",
326
+ `${field}.target`,
327
+ );
328
+ }
329
+ target = rawTarget.startsWith("/") ? rawTarget : `/${rawTarget}`;
330
+ }
331
+
332
+ const status = value.status === undefined ? DEFAULT_REDIRECT_STATUS : value.status;
333
+ if (!REDIRECT_STATUSES.includes(status)) {
334
+ throw entryError(
335
+ "redirects.status_invalid",
336
+ `'status' must be one of ${REDIRECT_STATUSES.join(", ")}.`,
337
+ `${field}.status`,
338
+ );
339
+ }
340
+
341
+ return { path, kind: REDIRECT_KIND_REDIRECT, target, status };
342
+ }
343
+
344
+ function assertNoChains(entries) {
345
+ const sources = new Set(entries.map((entry) => entry.path.toLowerCase()));
346
+ entries.forEach((entry, index) => {
347
+ const targetPath = internalRedirectTargetPath(entry);
348
+ if (targetPath === undefined || !sources.has(targetPath.toLowerCase())) return;
349
+ const field = `entries[${index}].target`;
350
+ if (targetPath.toLowerCase() === entry.path.toLowerCase()) {
351
+ throw entryError("redirects.loop", `'${entry.path}' redirects to itself.`, field);
352
+ }
353
+ throw entryError(
354
+ "redirects.chain",
355
+ `'${entry.path}' targets '${targetPath}', which is itself a redirect source. Point it at the final `
356
+ + "destination instead; Taproot refuses chains rather than following them.",
357
+ field,
358
+ );
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Validates the whole `redirects.json` document and returns the normalized map
364
+ * a push sends. The document is `{ siteId, revision, entries }` — the same
365
+ * shape `redirects pull` writes — so a workspace file always says which site
366
+ * and which read it belongs to.
367
+ *
368
+ * `baselineEntries` is how many entries the last pull recorded in the manifest,
369
+ * and it scopes the entry-count bound exactly as the site scopes it: a map that
370
+ * was already over the cap when it was pulled validates and pushes back, so
371
+ * accumulated path history never has to be deleted to make a push land, while a
372
+ * document authored past the cap is still refused offline. A caller with no
373
+ * baseline — a fixture, whose entries are all authored — passes none and gets
374
+ * the bare cap.
375
+ */
376
+ export function validateRedirectsDocument(
377
+ document_,
378
+ siteId,
379
+ { requireRevision = true, baselineEntries = 0 } = {},
380
+ ) {
381
+ if (!isPlainObject(document_)) {
382
+ throw new SiteAuthoringError(
383
+ "redirects.document_invalid",
384
+ `${REDIRECTS_FILE_NAME} must be the redirect document written by pull: { siteId, revision, entries }.`,
385
+ { field: REDIRECTS_FILE_NAME },
386
+ );
387
+ }
388
+ if (document_.siteId !== siteId) {
389
+ throw new SiteAuthoringError(
390
+ "redirects.site_mismatch",
391
+ `${REDIRECTS_FILE_NAME} is not bound to this site. Run 'taproot-site pull' again.`,
392
+ { field: "siteId" },
393
+ );
394
+ }
395
+ if (requireRevision && !isRedirectMapRevision(document_.revision)) {
396
+ throw new SiteAuthoringError(
397
+ "redirects.revision_invalid",
398
+ `${REDIRECTS_FILE_NAME} does not record the map revision it was read at. Run 'taproot-site redirects pull' `
399
+ + "again before pushing; without it a push could delete an entry a page rename recorded since.",
400
+ { field: "revision" },
401
+ );
402
+ }
403
+ if (!Array.isArray(document_.entries)) {
404
+ throw new SiteAuthoringError(
405
+ "redirects.entries_invalid",
406
+ `${REDIRECTS_FILE_NAME} must carry an 'entries' array.`,
407
+ { field: "entries" },
408
+ );
409
+ }
410
+ const baseline = Number.isInteger(baselineEntries) && baselineEntries > 0 ? baselineEntries : 0;
411
+ if (document_.entries.length > Math.max(REDIRECT_LIMITS.entries, baseline)) {
412
+ throw new SiteAuthoringError(
413
+ "redirects.too_many_entries",
414
+ `A site's redirect map cannot hold more than ${REDIRECT_LIMITS.entries} entries, and the last pull `
415
+ + `recorded ${baseline}, so a push may carry that many back or fewer; this document holds `
416
+ + `${document_.entries.length}.`,
417
+ { field: "entries" },
418
+ );
419
+ }
420
+
421
+ const seen = new Set();
422
+ const entries = document_.entries.map((entry, index) => validateEntry(entry, index, seen));
423
+ assertNoChains(entries);
424
+ return {
425
+ siteId,
426
+ revision: document_.revision,
427
+ entries: entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)),
428
+ };
429
+ }
430
+
431
+ /** Projects a map read from the API into the document shape pull writes. */
432
+ export function projectRedirectMapForWorkspace(siteId, map) {
433
+ return {
434
+ siteId,
435
+ revision: map.revision,
436
+ entries: map.entries.map((entry) => ({
437
+ path: entry.path,
438
+ kind: entry.kind,
439
+ ...(entry.kind === REDIRECT_KIND_GONE ? {} : { target: entry.target }),
440
+ status: entry.status,
441
+ origin: entry.origin,
442
+ })),
443
+ };
444
+ }
@@ -0,0 +1,72 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+ import { isRedirectMapRevision, REDIRECTS_FILE_NAME } from "./redirects-contract.js";
3
+ import { readManifest, writeManifest } from "./workspace.js";
4
+
5
+ /**
6
+ * The redirect map's workspace baseline (TR00702).
7
+ *
8
+ * `nav.json` needs no baseline because navigation is last-write-wins; the
9
+ * redirect map cannot be, because it holds entries nobody typed. Every page and
10
+ * tag rename records one, so a whole-map push built on a stale read would
11
+ * delete the entry that keeps the renamed page's old URL alive. The manifest
12
+ * therefore records the revision the map was read at, exactly as it records the
13
+ * footer's draft hash, and a push that cannot produce one refuses toward pull
14
+ * rather than guessing.
15
+ */
16
+
17
+ export const REDIRECTS_MANIFEST_KEY = "redirects";
18
+
19
+ function pullRequired(message, field) {
20
+ return new SiteAuthoringError("redirects.pull_required", message, { field });
21
+ }
22
+
23
+ function requireRedirectsMetadata(manifest) {
24
+ const redirects = manifest[REDIRECTS_MANIFEST_KEY];
25
+ if (redirects === null || typeof redirects !== "object" || Array.isArray(redirects)) {
26
+ throw pullRequired(
27
+ "The pull manifest does not record a redirect-map baseline. Run 'taproot-site redirects pull' "
28
+ + "(or 'taproot-site pull') again.",
29
+ REDIRECTS_MANIFEST_KEY,
30
+ );
31
+ }
32
+ if (redirects.file !== REDIRECTS_FILE_NAME || !isRedirectMapRevision(redirects.revision)) {
33
+ throw pullRequired(
34
+ "The pull manifest has an invalid redirect-map baseline. Run 'taproot-site redirects pull' again.",
35
+ `${REDIRECTS_MANIFEST_KEY}.revision`,
36
+ );
37
+ }
38
+ return redirects;
39
+ }
40
+
41
+ export async function readRedirectsWorkspaceContext(workspaceDir, siteId) {
42
+ const manifest = await readManifest(workspaceDir, siteId);
43
+ return { manifest, redirects: requireRedirectsMetadata(manifest) };
44
+ }
45
+
46
+ /** The manifest entry `pull` writes for a freshly read map. */
47
+ export function redirectsManifestEntry(map) {
48
+ return {
49
+ file: REDIRECTS_FILE_NAME,
50
+ revision: map.revision,
51
+ entries: map.entries.length,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Records the revision the site reported after a successful replace, so the
57
+ * next push is fenced against that state rather than the one before it.
58
+ */
59
+ export async function advanceRedirectsManifest(workspaceDir, siteId, map) {
60
+ const manifest = await readManifest(workspaceDir, siteId);
61
+ requireRedirectsMetadata(manifest);
62
+ if (!isRedirectMapRevision(map.revision)) {
63
+ throw new SiteAuthoringError(
64
+ "api.redirects_contract",
65
+ "Taproot returned a redirect map whose revision could not be recorded.",
66
+ { field: "revision" },
67
+ );
68
+ }
69
+ manifest[REDIRECTS_MANIFEST_KEY] = redirectsManifestEntry(map);
70
+ await writeManifest(workspaceDir, manifest);
71
+ return map.revision;
72
+ }