@taprootio/site-authoring 0.12.0 → 0.12.1

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/README.md CHANGED
@@ -501,7 +501,7 @@ fingerprint supply no gating evidence. Typed preview failure diagnostics contain
501
501
  only error class and known page/node/attribute labels, never renderer messages.
502
502
 
503
503
  Staging deploys check the current redirect map through the real staging edge.
504
- Run `taproot-site redirects check` to repeat after eventual edge propagation.
504
+ Run `taproot-site redirects check` to repeat once the deployment has completed; a redirect is a file inside the release, so it is live exactly when its release is served.
505
505
  The Node CLI consumes a separate handoff, retains its short staging cookie in
506
506
  memory, and requests each map path without following redirects. Results report
507
507
  path, HTTP status and Location; `verified` requires all entries to match and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/cli.js CHANGED
@@ -251,7 +251,8 @@ const VERBS = Object.freeze([
251
251
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
252
252
  tokens: ["redirects", "check"],
253
253
  summary: "Check the current redirect map through the authenticated staging edge.",
254
- note: "Reports path, HTTP status and Location without following redirects. Re-run after edge propagation. "
254
+ note: "Reports path, HTTP status and Location without following redirects. A redirect is live only with the release "
255
+ + "that carries it, so this reads the staged release. "
255
256
  + "The result includes a fresh single-use staging handoff; keep it private.",
256
257
  },
257
258
  {
@@ -275,7 +276,8 @@ const VERBS = Object.freeze([
275
276
  summary: "Validate redirects.json and replace the whole redirect map.",
276
277
  note: "Run 'redirects pull' first. The whole file is validated locally by entry index — path normalization, "
277
278
  + "targets, statuses, duplicates, chains, and loops — before anything is sent, and the site refuses a "
278
- + "source a live page occupies. Entries take effect on the next deploy. "
279
+ + "source a live page occupies or a file the site generates. An entry is a file inside the next release, so it "
280
+ + "goes live with that deploy: staging first, production when promoted. "
279
281
  + "See 'taproot-site help redirects'.",
280
282
  },
281
283
  {
@@ -42,13 +42,16 @@ export const REDIRECT_STATUSES = Object.freeze([301, 302, 307, 308]);
42
42
 
43
43
  /**
44
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.
45
+ * is the map's own; the target bound is the edge's own, and a target must also
46
+ * fit as metadata on its redirect file once encoded.
47
47
  */
48
48
  export const REDIRECT_LIMITS = Object.freeze({
49
49
  entries: 2_000,
50
50
  pathBytes: 400,
51
51
  targetBytes: 2_048,
52
+ // Once percent-encoded, a target rides on its redirect file as object
53
+ // metadata; the server pins the same number (MaxMarkerTargetEncodedLength).
54
+ targetEncodedCharacters: 1_810,
52
55
  });
53
56
 
54
57
  // C0, DEL, and C1 — every code point the server's own `char.IsControl`
@@ -88,8 +91,7 @@ const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
88
91
  * between the API's .NET and the edge's WHATWG parser, so a spelling this pass
89
92
  * encoded would still not be the one the Worker computes and the layers would
90
93
  * 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.
94
+ * compares it unchanged — and the byte bounds then measure that same spelling.
93
95
  */
94
96
  function isRequestSpelling(value) {
95
97
  return !UNSENDABLE_SPELLING.test(value);
@@ -154,6 +156,70 @@ export function normalizeRedirectPath(value) {
154
156
  return trimmed === "" ? "/" : trimmed;
155
157
  }
156
158
 
159
+ // The root segment the published output reserves for integration data; the release
160
+ // inventory leaves everything under it out.
161
+ const RESERVED_INTEGRATION_DATA_SEGMENT = "integrations";
162
+
163
+ // Root-level files the site generates. A redirect is a marker file inside the
164
+ // published site (TR00968), so a source that names one has nowhere to live.
165
+ const GENERATED_ROOT_FILES = Object.freeze(["404.html", "sitemap.xml", "robots.txt", "favicon.ico"]);
166
+ // The longest decoded path segment a marker file name may have; the API rule and the
167
+ // generator use the same 200 bytes.
168
+ const MAX_MARKER_SEGMENT_BYTES = 200;
169
+ const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
170
+
171
+ function isWindowsDeviceSegment(segment) {
172
+ const stem = segment.split(".", 1)[0].toLowerCase();
173
+ return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
174
+ }
175
+
176
+ /**
177
+ * Why a normalized source path cannot be a redirect, or undefined when it can.
178
+ * A mirror of `SiteRedirectMapContract.SourceRefusalReason` on the server (the
179
+ * API is the authority; both run the same test vectors): `…/index.html` is
180
+ * answered by the platform for every page, the generator writes the files
181
+ * named above, and the marker is stored under the decoded spelling, so a path
182
+ * that cannot be stored once decoded (an escaped percent sign, a dot-prefixed
183
+ * segment, a reserved device name) is refused. The site root is left to the
184
+ * occupancy rule.
185
+ */
186
+ export function redirectSourceRefusalReason(path) {
187
+ if (path === "/") return undefined;
188
+ // Every rule reads the decoded spelling: it is the name the marker is stored
189
+ // under, and the name the generator's own files use (`/%69ndex.html` is
190
+ // `/index.html`).
191
+ let decoded;
192
+ try {
193
+ decoded = path.replace(/^\/+|\/+$/gu, "").split("/").map(decodeURIComponent).join("/");
194
+ } catch {
195
+ return "it holds a percent escape that does not decode.";
196
+ }
197
+ const segments = decoded.split("/");
198
+ const first = segments[0].toLowerCase();
199
+ if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
200
+ return "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
201
+ + "a redirect source, and the page or home file there is generated.";
202
+ }
203
+ if (first === RESERVED_INTEGRATION_DATA_SEGMENT) {
204
+ return `'/${segments[0]}' is reserved for integration data, so nothing under it can be a redirect source.`;
205
+ }
206
+ if (GENERATED_ROOT_FILES.includes(first)) {
207
+ return `'/${segments[0]}' is a file the site generates, so neither it nor anything under it can be a redirect source.`;
208
+ }
209
+ if (
210
+ decoded.includes("..")
211
+ || decoded !== decoded.normalize("NFC")
212
+ || segments.some((segment) =>
213
+ segment === "" || segment.startsWith(".") || /[%\\?#\u0000-\u001F\u007F-\u009F]/u.test(segment)
214
+ || isWindowsDeviceSegment(segment) || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES
215
+ )
216
+ ) {
217
+ return "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
218
+ + "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).";
219
+ }
220
+ return undefined;
221
+ }
222
+
157
223
  function looksAbsolute(value) {
158
224
  return !value.startsWith("/") && value.includes("://");
159
225
  }
@@ -235,14 +301,21 @@ function validateEntry(value, index, seen) {
235
301
  `${field}.path`,
236
302
  );
237
303
  }
238
- if (seen.has(path.toLowerCase())) {
304
+ const sourceRefusal = redirectSourceRefusalReason(path);
305
+ if (sourceRefusal !== undefined) {
306
+ throw entryError("redirects.path_unpublishable", `'${path}': ${sourceRefusal}`, `${field}.path`);
307
+ }
308
+ // The marker is stored under the decoded spelling, so `/a%41` and `/aA` are one
309
+ // file, and the second would silently replace the first.
310
+ const markerKey = decodeURIComponent(path).toLowerCase();
311
+ if (seen.has(markerKey)) {
239
312
  throw entryError(
240
313
  "redirects.path_duplicate",
241
314
  `'${path}' appears more than once; one path has one entry.`,
242
315
  `${field}.path`,
243
316
  );
244
317
  }
245
- seen.add(path.toLowerCase());
318
+ seen.add(markerKey);
246
319
 
247
320
  const kind = value.kind === undefined ? REDIRECT_KIND_REDIRECT : value.kind;
248
321
  if (!REDIRECT_KINDS.includes(kind)) {
@@ -296,6 +369,15 @@ function validateEntry(value, index, seen) {
296
369
  );
297
370
  }
298
371
 
372
+ if (encodeURIComponent(rawTarget).length > REDIRECT_LIMITS.targetEncodedCharacters) {
373
+ throw entryError(
374
+ "redirects.target_too_long_to_store",
375
+ "'target' is stored as metadata on the published site's redirect file and is too long to fit "
376
+ + `(${REDIRECT_LIMITS.targetEncodedCharacters} characters once percent-encoded).`,
377
+ `${field}.target`,
378
+ );
379
+ }
380
+
299
381
  let target;
300
382
  if (looksAbsolute(rawTarget)) {
301
383
  if (!isAbsoluteRedirectTarget(rawTarget)) {
@@ -341,13 +423,23 @@ function validateEntry(value, index, seen) {
341
423
  return { path, kind: REDIRECT_KIND_REDIRECT, target, status };
342
424
  }
343
425
 
426
+ // The marker is stored under the decoded spelling, so `/%61` and `/a` are one route
427
+ // and `/%61 -> /a` is a loop. A target's escape may not decode; it then compares raw.
428
+ function routeIdentity(path) {
429
+ try {
430
+ return decodeURIComponent(path).toLowerCase();
431
+ } catch {
432
+ return path.toLowerCase();
433
+ }
434
+ }
435
+
344
436
  function assertNoChains(entries) {
345
- const sources = new Set(entries.map((entry) => entry.path.toLowerCase()));
437
+ const sources = new Set(entries.map((entry) => routeIdentity(entry.path)));
346
438
  entries.forEach((entry, index) => {
347
439
  const targetPath = internalRedirectTargetPath(entry);
348
- if (targetPath === undefined || !sources.has(targetPath.toLowerCase())) return;
440
+ if (targetPath === undefined || !sources.has(routeIdentity(targetPath))) return;
349
441
  const field = `entries[${index}].target`;
350
- if (targetPath.toLowerCase() === entry.path.toLowerCase()) {
442
+ if (routeIdentity(targetPath) === routeIdentity(entry.path)) {
351
443
  throw entryError("redirects.loop", `'${entry.path}' redirects to itself.`, field);
352
444
  }
353
445
  throw entryError(
@@ -602,8 +602,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
602
602
  + "a separate staging handoff and checks the current map at the real edge, using an in-memory cookie and "
603
603
  + "manual redirects. It reports each path, HTTP status and Location without following targets.",
604
604
  "verified is true only if every entry matched and the map revision stayed unchanged during the check. "
605
- + "The check proves the current map at staging, not a historical deployment. A mismatch can be propagation "
606
- + "delay; re-run redirects check before promoting. Requests are bounded to 90 seconds, eight at a time; "
605
+ + "The check proves the current map at staging, not a historical deployment. A mismatch means the staged "
606
+ + "release does not carry the entry (yet); re-run redirects check after the deployment completes, before promoting. Requests are bounded to 90 seconds, eight at a time; "
607
607
  + "large JSON lists are truncated explicitly with failed entries first. Human output reports every entry.",
608
608
  "stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
609
609
  + "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
@@ -654,10 +654,15 @@ const WORKFLOW_REFERENCES = Object.freeze({
654
654
  "Converting an engagement's CSV into this file is the agent's job. The CLI's contract is JSON, on "
655
655
  + "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
656
656
  + "which are simply gone.",
657
- "Entries take effect on the next deploy, staging first. They are written to the edge's key-value store "
658
- + "when the deploy syncs routing, and that store is eventually consistent, so a spot-check run "
659
- + "immediately afterwards can briefly still see the previous map. Re-check before concluding an entry "
660
- + "did not land.",
657
+ "Entries take effect on the next deploy, staging first. Each is a file inside that release, so it goes live "
658
+ + "exactly when the release does: staging with the staged release, production when it is promoted. There is "
659
+ + "no separate propagation delay; an entry a check does not see belongs to a release that is not being "
660
+ + "served yet.",
661
+ "A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
662
+ + "'/sitemap.xml', '/robots.txt', '/favicon.ico', or a path under the sitemap or robots names. The platform "
663
+ + "already redirects every '…/index.html' address to its directory, so no entry is needed for one. A source "
664
+ + "that could not be stored as a file (a dot-prefixed segment, a reserved device name, an escaped percent "
665
+ + "sign) is refused too. 'validate' names the entry and the reason.",
661
666
  ]),
662
667
  example: Object.freeze({
663
668
  siteId: "<site-uuid>",
@@ -27,24 +27,18 @@ import { ApiError } from "../transport.js";
27
27
  import { readManifest, writeManifest } from "../workspace.js";
28
28
 
29
29
  /**
30
- * What a deploy does not finish (TR00702).
30
+ * When a redirect goes live (TR00702, TR00968).
31
31
  *
32
- * A deploy writes the site's redirect and gone entries into the edge's
33
- * key-value store as part of syncing routing. That store is eventually
34
- * consistent, so a spot-check run the second a deploy reports success can
35
- * briefly read the previous map, and taking that for "the redirect did not
36
- * land" is the wrong conclusion to reach in front of a customer waiting to cut
37
- * DNS over.
38
- *
39
- * No number is quoted, because there is none to quote: the coordinator holds
40
- * nothing back for a standard site's redirects. Its propagation grace governs
41
- * only the *deletion* of a superseded Docs pointer namespace; a standard site's
42
- * rows are written and removed immediately.
32
+ * A redirect or gone entry is a file inside the release this deploy publishes.
33
+ * It goes live with that release and no other way: staging shows it as soon as
34
+ * the staged release is served, production shows it when the release is
35
+ * promoted. There is no separate store to wait on, so a check that does not see
36
+ * an entry is looking at a release that does not carry it yet.
43
37
  */
44
- const REDIRECT_PROPAGATION_NOTE =
45
- "Redirect and gone entries are written to the edge's key-value store when this deploy syncs routing. That "
46
- + "store is eventually consistent, so a spot-check run immediately afterwards can briefly still see the "
47
- + "previous map; re-check before concluding an entry is missing.";
38
+ const REDIRECT_ACTIVATION_NOTE =
39
+ "Redirect and gone entries live inside the release this deploy publishes: staging serves them with the staged "
40
+ + "release and production serves them once that release is promoted. There is no separate propagation delay; "
41
+ + "an entry a check does not see belongs to a release that is not being served yet.";
48
42
 
49
43
  /**
50
44
  * `deploy --staging` / `deploy --production` — readiness, deploy, poll to
@@ -158,7 +152,7 @@ async function inspectStagingPreview(client, siteId, onProgress, now) {
158
152
  checks = await checkStagingRedirects(client, siteId, onProgress, now);
159
153
  if (!checks.redirects.verified) {
160
154
  onProgress(
161
- "Warning: staging redirects are not all verified. Edge propagation may still be pending; run 'taproot-site redirects check' again.",
155
+ "Warning: staging redirects are not all verified. Check the reported mismatches against the staged release; run 'taproot-site redirects check' again once the deployment has completed.",
162
156
  );
163
157
  }
164
158
  } catch {
@@ -288,7 +282,7 @@ export async function deploy(invocation) {
288
282
  now,
289
283
  });
290
284
  await recordDeployment(config, manifest, "production", completed);
291
- if (!presentationOnly) onProgress(REDIRECT_PROPAGATION_NOTE);
285
+ if (!presentationOnly) onProgress(REDIRECT_ACTIVATION_NOTE);
292
286
  return successResult(VERB_DEPLOY, siteId, {
293
287
  target,
294
288
  environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
@@ -398,7 +392,7 @@ export async function deploy(invocation) {
398
392
  });
399
393
  }
400
394
  const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
401
- onProgress(REDIRECT_PROPAGATION_NOTE);
395
+ onProgress(REDIRECT_ACTIVATION_NOTE);
402
396
  return successResult(VERB_DEPLOY, siteId, {
403
397
  target,
404
398
  environment: DEPLOYMENT_ENVIRONMENT_STAGING,
@@ -9,7 +9,7 @@ export async function redirectsCheck(invocation) {
9
9
  const checks = await checkStagingRedirects(client, siteId, onProgress, now);
10
10
  if (!checks.redirects.verified) {
11
11
  onProgress(
12
- "Warning: staging redirects are not verified. Check the reported mismatches and retry after edge propagation.",
12
+ "Warning: staging redirects are not verified. Check the reported mismatches against the staged release and retry once its deployment has completed.",
13
13
  );
14
14
  }
15
15
  const handoff = await mintStagingPreviewHandoff(client, siteId, { now });