@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,100 @@
1
+ import { replaceSiteRedirectMap, withRefusalGuidance } from "../api.js";
2
+ import { VERB_REDIRECTS_PUSH } from "../constants.js";
3
+ import { SiteAuthoringError } from "../errors.js";
4
+ import {
5
+ projectRedirectMapForWorkspace,
6
+ REDIRECT_KIND_GONE,
7
+ REDIRECTS_FILE_NAME,
8
+ validateRedirectsDocument,
9
+ } from "../redirects-contract.js";
10
+ import { advanceRedirectsManifest, readRedirectsWorkspaceContext } from "../redirects-workspace.js";
11
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
12
+ import { ApiError } from "../transport.js";
13
+ import { readWorkspaceJson, WORKSPACE_LIMITS, writeWorkspaceJson } from "../workspace.js";
14
+
15
+ const MAXIMUM_REPORTED = 200;
16
+
17
+ /**
18
+ * `redirects push` — replace the whole map from the workspace file.
19
+ *
20
+ * The revision travels with the write. A push whose baseline predates a page
21
+ * rename is refused as a conflict rather than committing a map that silently
22
+ * drops the rename's entry, and the guidance says to re-pull and reconcile
23
+ * rather than to retry.
24
+ */
25
+ function translateConflict(error) {
26
+ if (error instanceof ApiError && error.hasField("ExpectedRevision")) {
27
+ return new SiteAuthoringError(
28
+ "redirects.concurrent_modification",
29
+ "The redirect map changed after this workspace read it — most often a page or tag rename recorded its own "
30
+ + `entry. Run 'taproot-site redirects pull', reconcile ${REDIRECTS_FILE_NAME}, and retry. Nothing was `
31
+ + "written.",
32
+ { field: "revision", status: error.status },
33
+ );
34
+ }
35
+ return error;
36
+ }
37
+
38
+ export async function redirectsPush(invocation) {
39
+ const session = await openSession(invocation);
40
+ const { client, config, siteId, onProgress } = session;
41
+ warnIfExternalWritesPaused(session, VERB_REDIRECTS_PUSH);
42
+ const context = await readRedirectsWorkspaceContext(config.workspaceDir, siteId);
43
+ const validated = validateRedirectsDocument(
44
+ await readWorkspaceJson(config.workspaceDir, REDIRECTS_FILE_NAME, WORKSPACE_LIMITS.redirectsBytes),
45
+ siteId,
46
+ // The entry cap is scoped against the map this workspace pulled, the way
47
+ // the site scopes it against the rows it holds: a site whose accumulated
48
+ // path history is already over the cap can push its own map back, or a
49
+ // smaller one, without deleting a live redirect to satisfy a number.
50
+ { baselineEntries: context.redirects.entries },
51
+ );
52
+ onProgress(
53
+ `Validated ${validated.entries.length} redirect entr${validated.entries.length === 1 ? "y" : "ies"}: `
54
+ + "path normalization, targets, statuses, duplicates, chains, and loops.",
55
+ );
56
+
57
+ // The manifest baseline wins over the file's own `revision` field. The file
58
+ // is authored by hand and its revision is a record of a read, not a claim a
59
+ // caller gets to make; taking it from the manifest keeps a hand-edited value
60
+ // from widening what a push may overwrite.
61
+ const expectedRevision = context.redirects.revision;
62
+
63
+ let map;
64
+ try {
65
+ map = await withRefusalGuidance(
66
+ onProgress,
67
+ "redirects push",
68
+ async () => await replaceSiteRedirectMap(client, siteId, expectedRevision, validated.entries),
69
+ );
70
+ } catch (error) {
71
+ throw translateConflict(error);
72
+ }
73
+
74
+ const document_ = projectRedirectMapForWorkspace(siteId, map);
75
+ try {
76
+ // The document lands before its baseline, exactly as `footer push` orders
77
+ // its two writes: if the manifest write fails, a stale revision refuses the
78
+ // next push rather than letting it overwrite the map just saved.
79
+ await writeWorkspaceJson(config.workspaceDir, REDIRECTS_FILE_NAME, document_);
80
+ await advanceRedirectsManifest(config.workspaceDir, siteId, map);
81
+ } catch (error) {
82
+ if (error instanceof SiteAuthoringError) throw error.withCompletedWrites(["redirects"]);
83
+ throw error;
84
+ }
85
+ onProgress("Replaced the site's redirect map and recorded its new revision.");
86
+
87
+ const reported = boundedList(document_.entries, MAXIMUM_REPORTED);
88
+ return successResult(VERB_REDIRECTS_PUSH, siteId, {
89
+ redirectsFile: REDIRECTS_FILE_NAME,
90
+ revision: map.revision,
91
+ redirects: {
92
+ total: map.entries.length,
93
+ gone: map.entries.filter((entry) => entry.kind === REDIRECT_KIND_GONE).length,
94
+ items: reported.items,
95
+ ...(reported.truncated ? { itemsTruncated: true } : {}),
96
+ },
97
+ written: { items: ["redirects"], count: 1 },
98
+ nextStep: "deploy --staging",
99
+ });
100
+ }
@@ -6,11 +6,22 @@ import {
6
6
  listSiteImages,
7
7
  withRefusalGuidance,
8
8
  } from "../api.js";
9
- import { VERB_STATUS } from "../constants.js";
9
+ import {
10
+ CLI_UPGRADE_COMMAND,
11
+ CLI_VERSION,
12
+ EXTERNAL_WRITES_SETTING_KEY,
13
+ EXTERNAL_WRITES_SETTING_LOCATION,
14
+ PUBLISH_KEY_ENVIRONMENT_VARIABLE,
15
+ REFUSAL_CLI_OUTDATED,
16
+ REFUSAL_PLATFORM_PAUSED,
17
+ VERB_STATUS,
18
+ } from "../constants.js";
19
+ import { isBehindLatest } from "../cli-release.js";
10
20
  import { boundedList, openSession, successResult } from "../session.js";
11
21
 
12
22
  /**
13
- * `status` — deployments, publishing readiness, and image processing.
23
+ * `status` — the platform authoring switch, the CLI release, deployments,
24
+ * publishing readiness, and image processing.
14
25
  *
15
26
  * Broken references are deliberately absent. `GetSiteBrokenReferences` is
16
27
  * session-only under TR00602's read list, and TR00604 adds no server surface,
@@ -26,8 +37,82 @@ const MAXIMUM_BLOCKERS = 50;
26
37
  const BROKEN_REFERENCES_NOTE =
27
38
  "GetSiteBrokenReferences is session-only on the shipped contract, so a key-authorized CLI cannot read it.";
28
39
 
40
+ const PLATFORM_UNKNOWN_NOTE =
41
+ "Taproot never reported the platform authoring switch on this run: either no sign-in token exchange happened "
42
+ + `(${PUBLISH_KEY_ENVIRONMENT_VARIABLE} supplies the site credential directly and skips it), or the Taproot that `
43
+ + "answered the exchange predates the field.";
44
+
45
+ const CLI_RELEASE_UNKNOWN_NOTE =
46
+ "Taproot never named the latest published CLI release on this run: either no sign-in token exchange happened "
47
+ + `(${PUBLISH_KEY_ENVIRONMENT_VARIABLE} supplies the site credential directly and skips it), or the Taproot that `
48
+ + "answered the exchange predates the field.";
49
+
50
+ /**
51
+ * Which release this CLI is against the latest Taproot named (TR00703).
52
+ *
53
+ * The same three-state shape as the platform switch, and reported the same way:
54
+ * a known answer carries `latestVersion`, an unknown one carries
55
+ * `latestKnown: false` and the reason. It is named `cliRelease` rather than
56
+ * `cli` because every result already carries a top-level `cli` naming this
57
+ * package and its version, and shadowing that would change what a field means
58
+ * depending on which verb produced it.
59
+ *
60
+ * `behind: true` is not reachable through a live exchange, and deliberately not
61
+ * removed: the exchange that supplies this value refuses an outdated CLI before
62
+ * it answers, so a run that gets here was accepted. What the branch protects is
63
+ * the case where those two facts ever come apart — a server that reports a
64
+ * latest it does not enforce — where reporting `behind: false` would be a
65
+ * confident lie rather than a missing field.
66
+ */
67
+ function reportCliRelease(release, onProgress) {
68
+ const latestVersion = release?.latestVersion;
69
+ if (latestVersion === undefined) {
70
+ onProgress(`CLI release: ${CLI_VERSION}. Latest not known on this run. ${CLI_RELEASE_UNKNOWN_NOTE}`);
71
+ return { version: CLI_VERSION, latestKnown: false, reason: CLI_RELEASE_UNKNOWN_NOTE };
72
+ }
73
+ const behind = isBehindLatest(latestVersion);
74
+ onProgress(
75
+ behind
76
+ ? `CLI release: ${CLI_VERSION} is BEHIND the latest published release ${latestVersion}, and Taproot accepts `
77
+ + `only the latest, so every online verb is refused (refusal=${REFUSAL_CLI_OUTDATED}). Upgrade with: `
78
+ + CLI_UPGRADE_COMMAND
79
+ : `CLI release: ${CLI_VERSION}; latest published release ${latestVersion}.`,
80
+ );
81
+ return { version: CLI_VERSION, latestVersion, behind };
82
+ }
83
+
84
+ /**
85
+ * The rollout switch as `status` reports it (TR00692), on both channels.
86
+ *
87
+ * Three states, and the JSON says which it is by shape rather than by a
88
+ * three-valued boolean: a known state carries `externalWritesEnabled`, and an
89
+ * unknown one carries `externalWritesKnown: false` and the reason. An
90
+ * automation reading `platform.externalWritesEnabled === false` is therefore
91
+ * never reading an absent field as "paused".
92
+ */
93
+ function reportPlatform(platform, onProgress) {
94
+ const enabled = platform?.externalWritesEnabled;
95
+ if (enabled === undefined) {
96
+ onProgress(`Platform authoring switch: not known on this run. ${PLATFORM_UNKNOWN_NOTE}`);
97
+ return { externalWritesKnown: false, reason: PLATFORM_UNKNOWN_NOTE };
98
+ }
99
+ onProgress(
100
+ enabled
101
+ ? "Platform authoring switch: external site authoring writes are enabled."
102
+ : "Platform authoring switch: external site authoring writes are PAUSED platform-wide, so every write verb "
103
+ + `is refused (refusal=${REFUSAL_PLATFORM_PAUSED}). A Taproot administrator re-enables the platform `
104
+ + `setting '${EXTERNAL_WRITES_SETTING_KEY}' (${EXTERNAL_WRITES_SETTING_LOCATION}).`,
105
+ );
106
+ return { externalWritesEnabled: enabled };
107
+ }
108
+
29
109
  export async function status(invocation) {
30
- const { client, siteId, onProgress } = await openSession(invocation);
110
+ const { client, siteId, platform, release, onProgress } = await openSession(invocation);
111
+ // Said first, and before any wire read: an operator running `status` because
112
+ // writes are being refused should not have to wait on three network calls to
113
+ // learn that the platform is paused — and this answer needs none of them.
114
+ const platformReport = reportPlatform(platform, onProgress);
115
+ const cliReleaseReport = reportCliRelease(release, onProgress);
31
116
 
32
117
  return await withRefusalGuidance(onProgress, "status check", async () => {
33
118
  onProgress("Reading publishing readiness.");
@@ -60,6 +145,8 @@ export async function status(invocation) {
60
145
  onProgress(`Broken references are not reported: ${BROKEN_REFERENCES_NOTE}`);
61
146
 
62
147
  return successResult(VERB_STATUS, siteId, {
148
+ platform: platformReport,
149
+ cliRelease: cliReleaseReport,
63
150
  readiness: {
64
151
  state: readiness.state,
65
152
  approvedPageCount: readiness.approvedPageCount,
@@ -13,7 +13,7 @@ import {
13
13
  readAppearanceWorkspaceContext,
14
14
  requireFooterContentPushed,
15
15
  } from "../footer-workspace.js";
16
- import { boundedList, openSession, successResult } from "../session.js";
16
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
17
17
  import {
18
18
  SETTINGS_TYPE_BRAND,
19
19
  SETTINGS_TYPE_SITE_HEADER,
@@ -108,7 +108,12 @@ export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds
108
108
  }
109
109
 
110
110
  export async function themePush(invocation) {
111
- const { client, config, siteId, onProgress } = await openSession(invocation);
111
+ const session = await openSession(invocation);
112
+ const { client, config, siteId, onProgress } = session;
113
+ // One advisory line before this verb does any work, and only when the
114
+ // exchange said the platform is paused. It changes nothing else: the write
115
+ // still runs and its refusal still classifies as platform_paused (TR00692).
116
+ warnIfExternalWritesPaused(session, VERB_THEME_PUSH);
112
117
  const appearanceContext = await readAppearanceWorkspaceContext(config.workspaceDir, siteId);
113
118
  const { publishing, themes, scalarOperations, footerColors } = await validateThemeWorkspace(
114
119
  config.workspaceDir,
@@ -14,6 +14,13 @@ import {
14
14
  FIXTURE_ROOT_FIELDS,
15
15
  } from "../fixture-contract.js";
16
16
  import { FOOTER_SETTINGS_FILE } from "../footer-workspace.js";
17
+ import {
18
+ isRedirectMapRevision,
19
+ REDIRECT_KIND_GONE,
20
+ REDIRECTS_FILE_NAME,
21
+ validateRedirectsDocument,
22
+ normalizeRedirectPath,
23
+ } from "../redirects-contract.js";
17
24
  import { boundedList, successResult } from "../session.js";
18
25
  import { SETTINGS_GROUPS, SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
19
26
  import { validateFooterWorkspaceDocument } from "./footer-push.js";
@@ -118,6 +125,23 @@ function validateFixtureManifest(manifest) {
118
125
  "navigation",
119
126
  );
120
127
  }
128
+ // The redirect baseline is bound the way navigation is, with the revision a
129
+ // push is fenced by. A fixture's revision is a deterministic placeholder like
130
+ // every other identity in one — it proves the shape, never a live read.
131
+ if (
132
+ !isPlainObject(manifest.redirects)
133
+ || manifest.redirects.file !== REDIRECTS_FILE_NAME
134
+ || !isRedirectMapRevision(manifest.redirects.revision)
135
+ || !Number.isSafeInteger(manifest.redirects.entries)
136
+ || manifest.redirects.entries < 0
137
+ ) {
138
+ fail(
139
+ "fixture.redirects_invalid",
140
+ `The fixture manifest must bind '${REDIRECTS_FILE_NAME}', a 64-character lowercase hex revision, and its `
141
+ + "non-negative entry count.",
142
+ "redirects",
143
+ );
144
+ }
121
145
  if (!Array.isArray(manifest.settingsSkipped) || manifest.settingsSkipped.length !== 0) {
122
146
  fail(
123
147
  "fixture.settings_invalid",
@@ -453,6 +477,51 @@ export async function validateFixture(invocation = {}) {
453
477
  );
454
478
  }
455
479
 
480
+ onProgress("Validating the redirect map: paths, targets, statuses, chains, and loops.");
481
+ const redirectsDocument = await readWorkspaceJson(
482
+ fixtureRoot,
483
+ REDIRECTS_FILE_NAME,
484
+ WORKSPACE_LIMITS.redirectsBytes,
485
+ );
486
+ const redirects = validateRedirectsDocument(redirectsDocument, manifest.siteId);
487
+ if (redirects.entries.length !== manifest.redirects.entries) {
488
+ fail(
489
+ "fixture.redirects_count_mismatch",
490
+ `${REDIRECTS_FILE_NAME} contains ${redirects.entries.length} entr${
491
+ redirects.entries.length === 1 ? "y" : "ies"
492
+ }, but the fixture manifest records ${manifest.redirects.entries}.`,
493
+ "redirects.entries",
494
+ );
495
+ }
496
+ // A fixture is self-contained, so the occupancy rule the site enforces can be
497
+ // checked here too: a source the fixture's own pages occupy would be refused
498
+ // at the API, and saying so offline is the whole point of this verb. Paths
499
+ // compare case-insensitively, as the site's citext path column does, and a
500
+ // refusal names the entry where the author wrote it: the validated map is
501
+ // sorted by path, so its index is not the document's.
502
+ const fixturePagePaths = new Set(
503
+ manifest.pages.map((entry) =>
504
+ (`/${normalizePagePath(entry.path) ?? ""}`.replace(/\/+$/u, "") || "/").toLowerCase()
505
+ ),
506
+ );
507
+ const authoredIndexByPath = new Map();
508
+ for (const [index, entry] of redirectsDocument.entries.entries()) {
509
+ const path = normalizeRedirectPath(entry?.path);
510
+ if (path !== undefined && !authoredIndexByPath.has(path.toLowerCase())) {
511
+ authoredIndexByPath.set(path.toLowerCase(), index);
512
+ }
513
+ }
514
+ for (const entry of redirects.entries) {
515
+ const key = entry.path.toLowerCase();
516
+ if (fixturePagePaths.has(key)) {
517
+ fail(
518
+ "fixture.redirect_path_occupied",
519
+ `Redirect source '${entry.path}' is a page in this fixture, so it cannot also be a redirect source.`,
520
+ `entries[${authoredIndexByPath.get(key) ?? 0}].path`,
521
+ );
522
+ }
523
+ }
524
+
456
525
  onProgress("Validating the closed footer document and local page/image targets.");
457
526
  validateFooterWorkspaceDocument(
458
527
  await readWorkspaceJson(fixtureRoot, FOOTER_SETTINGS_FILE, WORKSPACE_LIMITS.settingsBytes),
@@ -483,6 +552,10 @@ export async function validateFixture(invocation = {}) {
483
552
  ...(reportedPages.truncated ? { itemsTruncated: true } : {}),
484
553
  },
485
554
  navigation: { items: navigation.items, maximumDepth: NAVIGATION_MAXIMUM_DEPTH },
555
+ redirects: {
556
+ entries: redirects.entries.length,
557
+ gone: redirects.entries.filter((entry) => entry.kind === REDIRECT_KIND_GONE).length,
558
+ },
486
559
  themes: 2,
487
560
  appearanceSettings: presentation.scalarOperations.length,
488
561
  footer: true,
@@ -497,6 +570,7 @@ export async function validateFixture(invocation = {}) {
497
570
  "fixture structure and bounded files",
498
571
  "page content and named theme contexts",
499
572
  "navigation shape and local page-resource references",
573
+ "redirect-map shape, normalization, chains, loops, and fixture-local path occupancy",
500
574
  "complete theme, appearance, header, brand, and footer semantics",
501
575
  "fixture-local image identities and reserved delivery origins",
502
576
  ],
@@ -1,9 +1,67 @@
1
- import { CLI_BINARY_NAME, PUBLISH_KEY_ENVIRONMENT_VARIABLE, VERB_LOGIN, VERB_USE, VERB_WHOAMI } from "../constants.js";
1
+ import {
2
+ CLI_BINARY_NAME,
3
+ CLI_UPGRADE_COMMAND,
4
+ CLI_VERSION,
5
+ EXTERNAL_WRITES_SETTING_KEY,
6
+ EXTERNAL_WRITES_SETTING_LOCATION,
7
+ PUBLISH_KEY_ENVIRONMENT_VARIABLE,
8
+ VERB_LOGIN,
9
+ VERB_STATUS,
10
+ VERB_USE,
11
+ VERB_WHOAMI,
12
+ } from "../constants.js";
13
+ import { isBehindLatest } from "../cli-release.js";
2
14
  import { credentialApiOrigin, findCredential } from "../credentials.js";
3
15
  import { SITE_AUTHORING_CAPABILITIES } from "../capabilities.js";
4
16
  import { openAnonymousSession, successResult } from "../session.js";
5
17
  import { environmentNameFor } from "../settings.js";
6
18
 
19
+ /**
20
+ * What the last exchange recorded about the platform authoring switch, dated
21
+ * with that exchange (TR00692).
22
+ *
23
+ * Never presented as the current state: this verb makes no request, so the
24
+ * value is as old as the last exchange, and `status` is where a current answer
25
+ * comes from. A record written before the field existed says so plainly rather
26
+ * than reading as "enabled".
27
+ */
28
+ function platformLine(lastExchange) {
29
+ const when = lastExchange.at === undefined ? "at that exchange" : `as of ${lastExchange.at}`;
30
+ if (lastExchange.externalWritesEnabled === undefined) {
31
+ return `Platform authoring switch: not recorded by that exchange. Run '${CLI_BINARY_NAME} ${VERB_STATUS}' `
32
+ + "for a current answer.";
33
+ }
34
+ return lastExchange.externalWritesEnabled
35
+ ? `Platform authoring switch ${when}: external site authoring writes were enabled. This is a recording, not a `
36
+ + `live read; run '${CLI_BINARY_NAME} ${VERB_STATUS}' for the current answer.`
37
+ : `Platform authoring switch ${when}: external site authoring writes were PAUSED platform-wide. A Taproot `
38
+ + `administrator re-enables the platform setting '${EXTERNAL_WRITES_SETTING_KEY}' `
39
+ + `(${EXTERNAL_WRITES_SETTING_LOCATION}). This is a recording, not a live read; run `
40
+ + `'${CLI_BINARY_NAME} ${VERB_STATUS}' for the current answer.`;
41
+ }
42
+
43
+ /**
44
+ * The latest published CLI release that exchange recorded (TR00703), dated the
45
+ * same way and for the same reason.
46
+ *
47
+ * A run that is actually behind never reaches here — the gate in `runCli`
48
+ * refuses `whoami` outright — so this line is what a *current* CLI reads as
49
+ * confirmation, and what a CLI whose store predates the field reads as "not
50
+ * recorded". It is still stated as a recording rather than as live fact,
51
+ * because that is what it is.
52
+ */
53
+ function cliReleaseLine(lastExchange) {
54
+ if (lastExchange.latestCliVersion === undefined) {
55
+ return `Latest CLI release: not recorded by that exchange. This CLI is ${CLI_VERSION}; run `
56
+ + `'${CLI_BINARY_NAME} ${VERB_STATUS}' for a current answer.`;
57
+ }
58
+ const when = lastExchange.at === undefined ? "at that exchange" : `as of ${lastExchange.at}`;
59
+ return isBehindLatest(lastExchange.latestCliVersion)
60
+ ? `Latest CLI release ${when}: ${lastExchange.latestCliVersion}. This CLI is ${CLI_VERSION} and is behind it; `
61
+ + `Taproot accepts only the latest. Upgrade with: ${CLI_UPGRADE_COMMAND}`
62
+ : `Latest CLI release ${when}: ${lastExchange.latestCliVersion}. This CLI is ${CLI_VERSION}.`;
63
+ }
64
+
7
65
  /**
8
66
  * `taproot-site whoami` — which Taproot, which account, which site, until when.
9
67
  *
@@ -75,6 +133,12 @@ export async function whoami(invocation) {
75
133
  `That credential ${lapsed ? "expired" : "expires"} ${lastExchange.expiresAt}`
76
134
  + `${lapsed ? "; the next command mints another." : "."}`,
77
135
  );
136
+ // The platform authoring switch as of that exchange, dated (TR00692). It is
137
+ // reported as a recording rather than as the current state, because this
138
+ // command is offline by design and an operator reading an undated flag
139
+ // would take a snapshot from last week for a fact about right now.
140
+ onProgress(platformLine(lastExchange));
141
+ onProgress(cliReleaseLine(lastExchange));
78
142
  }
79
143
 
80
144
  return successResult(VERB_WHOAMI, config?.siteId, {
@@ -101,6 +165,20 @@ export async function whoami(invocation) {
101
165
  capabilities: lastExchange.capabilities,
102
166
  expiresAt: lastExchange.expiresAt,
103
167
  expired: Date.parse(lastExchange.expiresAt) <= now,
168
+ // Both absent on a record written before TR00692, and
169
+ // `externalWritesEnabled` also absent when the server that answered the
170
+ // exchange predated the field. Absent means "not recorded", never
171
+ // "enabled": an automation that wants a current answer reads
172
+ // `platform` off `status`, which says which of the three states it is.
173
+ ...(lastExchange.at === undefined ? {} : { at: lastExchange.at }),
174
+ ...(lastExchange.externalWritesEnabled === undefined
175
+ ? {}
176
+ : { externalWritesEnabled: lastExchange.externalWritesEnabled }),
177
+ // Absent on a record written before TR00703, and on one whose server
178
+ // predated the field. Absent means "not recorded", never "current".
179
+ ...(lastExchange.latestCliVersion === undefined
180
+ ? {}
181
+ : { latestCliVersion: lastExchange.latestCliVersion }),
104
182
  },
105
183
  }),
106
184
  ...(environmentOverride ? { capabilitiesKnown: false } : {}),
package/src/workspace.js CHANGED
@@ -4,7 +4,7 @@ import { lstat, mkdir, open, readdir, unlink } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
6
  import { atomicWriteFile } from "./atomic-file.js";
7
- import { hasAsciiControl, SiteAuthoringError } from "./errors.js";
7
+ import { hasAsciiControl, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
8
8
 
9
9
  /**
10
10
  * The local workspace: what `pull` writes, what the push verbs read back, and
@@ -103,15 +103,141 @@ export function internalPageBaselineFile(pageId) {
103
103
  : undefined;
104
104
  }
105
105
 
106
- // Version 5 makes the page list a source registry: each entry names the one
107
- // authoritative `file` for its path, the `sourceFormat` that file carries, and
108
- // the `baseline` hashes pull compares against to detect a remote edit it
109
- // cannot reconcile. Version 4 classified pulled page files as editable,
110
- // metadata-only, or an integrity-checked read-only projection but recorded no
111
- // registry, so a push against one cannot tell an intentional format change
112
- // from a stale entry. The package is in-repo and unreleased, so an older
113
- // manifest is re-pulled rather than migrated.
114
- export const MANIFEST_VERSION = 5;
106
+ /**
107
+ * Where the revision of a *preserved* remote body is recorded, beside the body
108
+ * itself.
109
+ *
110
+ * The manifest records what this workspace last **reconciled** with, and a
111
+ * refused pull reconciles nothing — it writes no manifest at all, which is what
112
+ * makes the same conflict repeat until somebody resolves it. But the refusal
113
+ * does show the operator the site's version, and after that the site's version
114
+ * is no longer news. Without somewhere to record that, the push refusal added
115
+ * for a concurrent remote edit would also refuse the pull conflict's own
116
+ * documented recovery — "push the local source to make the site match it" —
117
+ * and the only way out of a conflict would be to give up the local source.
118
+ *
119
+ * So this records what the workspace last **saw**, which is a different fact.
120
+ * A push into a page whose site revision equals the one already shown is the
121
+ * operator's informed override; a push into one that has moved *again* is still
122
+ * refused. Any pull that reconciles the page removes it, because the manifest
123
+ * then carries the newer and stronger claim.
124
+ */
125
+ export function internalPageObservedRevisionFile(pageId) {
126
+ return typeof pageId === "string" && SAFE_SEGMENT.test(pageId)
127
+ ? `${INTERNAL_PAGE_BASELINE_DIRECTORY}/${pageId}.revision.json`
128
+ : undefined;
129
+ }
130
+
131
+ // Version 6 records the site's own `baseline.revision` for every tracked page:
132
+ // the opaque revision of the stored authoring state the site reported when this
133
+ // workspace last agreed with it. Version 5 could only compare one pull's body
134
+ // against the next, because the bytes a push sends are not the bytes a read
135
+ // returns — so a push could not tell whether the page it was about to
136
+ // overwrite had moved since, and a change in the API's image-delivery
137
+ // projection between two pulls read as a remote edit. Version 5 made the page
138
+ // list a source registry (`file`, `sourceFormat`, `baseline`); version 4
139
+ // classified pulled files but recorded no registry. The package is in-repo and
140
+ // its manifests are workspace state, so an older one is re-pulled rather than
141
+ // migrated.
142
+ export const MANIFEST_VERSION = 6;
143
+
144
+ /**
145
+ * The shape of a page body revision as the site reports it: a scheme name and
146
+ * a hex digest. The CLI never derives one — it only records what it was told
147
+ * and compares it with what it is told next — so this is a containment check
148
+ * on a server string that reaches the manifest and the terminal, not a claim
149
+ * about how the value is computed.
150
+ */
151
+ export const PAGE_BODY_REVISION = /^[a-z][a-z0-9]{0,15}:[0-9a-f]{16,128}$/u;
152
+
153
+ /** A revision as recorded or reported, or `undefined` when there is not one. */
154
+ export function normalizePageBodyRevision(value) {
155
+ return typeof value === "string" && PAGE_BODY_REVISION.test(value) ? value : undefined;
156
+ }
157
+
158
+ /**
159
+ * The revision of the remote body this workspace last saw for a page, or
160
+ * `undefined` when it has not seen one it did not also reconcile with.
161
+ *
162
+ * Damaged or absent internal state is simply "nothing seen": it costs the
163
+ * operator one pull, which is the same position a workspace that has never
164
+ * conflicted is in, and is never worth refusing a verb over.
165
+ */
166
+ export async function readObservedPageRevision(workspaceDir, pageId) {
167
+ return (await readObservedPageRecord(workspaceDir, pageId))?.revision;
168
+ }
169
+
170
+ /**
171
+ * Everything a refused pull recorded for a page: the version the operator was
172
+ * shown, and the differences the refusal named beside it.
173
+ *
174
+ * The version is named either way it can be named. A site that reports a
175
+ * revision is recorded by `revision`; a site that predates the revision
176
+ * contract is compared by the canonical hash of its body, and is recorded by
177
+ * `remoteHash` instead — the same value that comparison is made against. A
178
+ * hash-only record therefore reports no revision, which is what keeps
179
+ * `readObservedPageRevision` — and so the push guard it arms — untouched by
180
+ * this: an override is a statement about a revision, and there is none here.
181
+ *
182
+ * The differences are recorded rather than recomputed because the refusal
183
+ * overwrites the preserved baseline with the version it is refusing. On the
184
+ * next pull, with nothing further changed on the site, a fresh comparison would
185
+ * find the preserved copy and the site's document identical and report "the
186
+ * change is in the page's title, path, or description" for what was a body
187
+ * edit. The record is what lets a repeated refusal say the same true thing the
188
+ * first one did; a page that has moved *again* carries a different revision, so
189
+ * the record no longer applies and the comparison is redone.
190
+ *
191
+ * Fail-soft on everything, exactly like the revision read it backs: this is
192
+ * workspace state a hand edit or an interrupted write can reach, and no verb is
193
+ * worth refusing over a diagnostic.
194
+ */
195
+ export async function readObservedPageRecord(workspaceDir, pageId) {
196
+ const file = internalPageObservedRevisionFile(pageId);
197
+ if (file === undefined) return undefined;
198
+ let parsed;
199
+ try {
200
+ // The existence check is inside the guard too: a directory or a symlink
201
+ // where the record should be is "nothing seen", not a refusal.
202
+ if (!await workspaceFileExists(workspaceDir, file)) return undefined;
203
+ parsed = await readWorkspaceJson(workspaceDir, file, WORKSPACE_LIMITS.settingsBytes);
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.pageId !== pageId) {
208
+ return undefined;
209
+ }
210
+ const revision = normalizePageBodyRevision(parsed.revision);
211
+ const remoteHash = typeof parsed.remoteHash === "string" && WORKSPACE_CONTENT_HASH.test(parsed.remoteHash)
212
+ ? parsed.remoteHash
213
+ : undefined;
214
+ // A record naming neither version says nothing about what was shown, which is
215
+ // the same position as no record at all.
216
+ if (revision === undefined && remoteHash === undefined) return undefined;
217
+ return { revision, remoteHash, differences: normalizeObservedDifferences(parsed.differences) };
218
+ }
219
+
220
+ /**
221
+ * How many recorded paths one record carries back. The same bound
222
+ * `SiteAuthoringError#withDifferences` applies, because these paths reach a
223
+ * refusal's message and its `differences` field by that route.
224
+ */
225
+ const OBSERVED_DIFFERENCE_PATHS = 100;
226
+
227
+ /** A recorded `{ paths, truncated }`, or `undefined` when there is not one. */
228
+ function normalizeObservedDifferences(value) {
229
+ if (value === null || typeof value !== "object" || Array.isArray(value) || !Array.isArray(value.paths)) {
230
+ return undefined;
231
+ }
232
+ return {
233
+ paths: value.paths
234
+ .filter((entry) => typeof entry === "string")
235
+ .slice(0, OBSERVED_DIFFERENCE_PATHS)
236
+ .map((entry) => sanitizeDiagnostic(entry, "path")),
237
+ truncated: value.truncated === true,
238
+ };
239
+ }
240
+
115
241
  export const PAGE_WORKSPACE_MODE_EDITABLE = "editable";
116
242
  export const PAGE_WORKSPACE_MODE_METADATA_ONLY = "metadata-only";
117
243
  export const PAGE_WORKSPACE_MODE_READ_ONLY = "read-only";
@@ -129,6 +255,10 @@ export const WORKSPACE_LIMITS = Object.freeze({
129
255
  // One authored page: a Markdown source or a ProseMirror document.
130
256
  documentBytes: 2 * 1024 * 1024,
131
257
  navigationBytes: 1024 * 1024,
258
+ // The redirect map: sized from the contract's own bounds (entries, path
259
+ // and target bytes, pretty-printed JSON overhead) rather than borrowed from
260
+ // the navigation tree, so the site's own pulled map always reads back.
261
+ redirectsBytes: 8 * 1024 * 1024,
132
262
  settingsBytes: 256 * 1024,
133
263
  manifestBytes: 8 * 1024 * 1024,
134
264
  mediaBytes: 32 * 1024 * 1024,
@@ -223,10 +353,13 @@ function isDiscoverablePageSource(file) {
223
353
  * - An entry whose `file` is missing, unrecognized, or not a file the workspace
224
354
  * walk could have produced is **dropped**. It names no source this package
225
355
  * would ever read, so there is nothing to protect.
226
- * - A `baseline` that is not a usable pair of hashes is **discarded and
227
- * reported**. Losing it costs one pull's worth of conflict detection and
356
+ * - A `baseline` carrying no usable hash and no usable revision is **discarded
357
+ * and reported**. Losing it costs one pull's worth of conflict detection and
228
358
  * nothing else: the next pull re-establishes it from what it reads, and the
229
- * authored source is never written over in the meantime.
359
+ * authored source is never written over in the meantime. Individual members
360
+ * are dropped the same way — an unusable `revision` beside two good hashes
361
+ * leaves the entry compared the way version 5 compared it, which is the same
362
+ * position a workspace that has not pulled since is in.
230
363
  * - A `sourceFormat` that disagrees with the file's own extension is recorded
231
364
  * as `formatMismatch` and the extension wins. `pages push` refuses on it —
232
365
  * it is a hand-edited manifest and re-pulling is the fix — while `pull`
@@ -254,9 +387,14 @@ export function pageSourceRegistry(manifest) {
254
387
  const declared = entry.baseline;
255
388
  const remoteHash = normalizeBaselineHash(declared?.remoteHash);
256
389
  const sourceHash = normalizeBaselineHash(declared?.sourceHash);
257
- const baseline = remoteHash === undefined && sourceHash === undefined
390
+ const revision = normalizePageBodyRevision(declared?.revision);
391
+ const baseline = remoteHash === undefined && sourceHash === undefined && revision === undefined
258
392
  ? undefined
259
- : { ...(remoteHash === undefined ? {} : { remoteHash }), ...(sourceHash === undefined ? {} : { sourceHash }) };
393
+ : {
394
+ ...(remoteHash === undefined ? {} : { remoteHash }),
395
+ ...(sourceHash === undefined ? {} : { sourceHash }),
396
+ ...(revision === undefined ? {} : { revision }),
397
+ };
260
398
  const record = {
261
399
  pageId: entry.pageId,
262
400
  file: entry.file,