@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.
@@ -9,6 +9,7 @@ import {
9
9
  CLI_AUTHORIZATION_CLAIM_STATUS_PENDING,
10
10
  poll,
11
11
  startCliAuthorization,
12
+ announceRefusal,
12
13
  } from "../api.js";
13
14
  import {
14
15
  AUTHORIZE_CLI_PATH,
@@ -328,7 +329,16 @@ export async function login(invocation) {
328
329
  try {
329
330
  authorization = await startCliAuthorization(client, { keyName });
330
331
  } catch (error) {
331
- throw translateStartApiError(error);
332
+ // Announced only when the refusal survives translation: sign-in is the
333
+ // earlier of the two calls that carry this CLI's version, so it is where
334
+ // an outdated CLI is told to upgrade, and the remedy is a command the
335
+ // operator has to be handed rather than a field name (TR00703). A throttle
336
+ // is translated instead, because the shared guidance names a credential
337
+ // that does not exist yet, and announcing it first would contradict the
338
+ // translated line that follows.
339
+ const translated = translateStartApiError(error);
340
+ if (translated === error) announceRefusal(error, onProgress, "sign-in");
341
+ throw translated;
332
342
  }
333
343
 
334
344
  const verificationUrl = verificationUrlFor(apiBaseUrl);
@@ -12,7 +12,7 @@ import {
12
12
  import { LIMITS, VERB_MEDIA_UPLOAD } from "../constants.js";
13
13
  import { SiteAuthoringError } from "../errors.js";
14
14
  import { contentHash, inspectImageBytes } from "../image-metadata.js";
15
- import { boundedList, openSession, successResult } from "../session.js";
15
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
16
16
  import {
17
17
  ensureWorkspaceRoot,
18
18
  inspectWorkspaceEntry,
@@ -164,7 +164,12 @@ async function waitForProcessing(client, siteId, imageIds, { onProgress, now })
164
164
  }
165
165
 
166
166
  export async function mediaUpload(invocation) {
167
- const { client, config, siteId, now, onProgress } = await openSession(invocation);
167
+ const session = await openSession(invocation);
168
+ const { client, config, siteId, now, onProgress } = session;
169
+ // One advisory line before this verb does any work, and only when the
170
+ // exchange said the platform is paused. It changes nothing else: the write
171
+ // still runs and its refusal still classifies as platform_paused (TR00692).
172
+ warnIfExternalWritesPaused(session, VERB_MEDIA_UPLOAD);
168
173
  await ensureWorkspaceRoot(config);
169
174
 
170
175
  // Positional arguments name the files or directories to upload — `cli.js`
@@ -15,7 +15,7 @@ import {
15
15
  isContactOrWebUrl,
16
16
  } from "../contact-url.js";
17
17
  import { SiteAuthoringError } from "../errors.js";
18
- import { boundedList, openSession, successResult } from "../session.js";
18
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
19
19
  import {
20
20
  NAVIGATION_FILE_NAME,
21
21
  readWorkspaceJson,
@@ -276,7 +276,12 @@ export function validateNavigationWorkspaceDocument(parsed, siteId, knownPages)
276
276
  }
277
277
 
278
278
  export async function navPush(invocation) {
279
- const { client, config, siteId, onProgress } = await openSession(invocation);
279
+ const session = await openSession(invocation);
280
+ const { client, config, siteId, onProgress } = session;
281
+ // One advisory line before this verb does any work, and only when the
282
+ // exchange said the platform is paused. It changes nothing else: the write
283
+ // still runs and its refusal still classifies as platform_paused (TR00692).
284
+ warnIfExternalWritesPaused(session, VERB_NAV_PUSH);
280
285
  if (!await workspaceFileExists(config.workspaceDir, NAVIGATION_FILE_NAME)) {
281
286
  throw new SiteAuthoringError(
282
287
  "nav.file_missing",
@@ -9,7 +9,7 @@ import {
9
9
  } from "../api.js";
10
10
  import { VERB_PAGES_PUSH } from "../constants.js";
11
11
  import { SiteAuthoringError } from "../errors.js";
12
- import { boundedList, openSession, successResult } from "../session.js";
12
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
13
13
  import { SETTINGS_TYPE_TAPROOT_STYLES } from "../settings-catalog.js";
14
14
  import {
15
15
  hasNamedFreeFormSectionContext,
@@ -18,7 +18,10 @@ import {
18
18
  } from "../content/free-form-sections.js";
19
19
  import { CONTENT_ERROR_CODES } from "../content/vocabulary.js";
20
20
  import {
21
+ deleteWorkspaceFile,
22
+ internalPageObservedRevisionFile,
21
23
  MEDIA_MANIFEST_FILE_NAME,
24
+ normalizePageBodyRevision,
22
25
  normalizePagePath,
23
26
  PAGE_SOURCE_EXTENSIONS,
24
27
  PAGE_SOURCE_FORMAT_MARKDOWN,
@@ -28,6 +31,7 @@ import {
28
31
  PAGES_DIRECTORY,
29
32
  readManifest,
30
33
  readMediaManifest,
34
+ readObservedPageRevision,
31
35
  readOnlySystem404Projections,
32
36
  readWorkspaceFile,
33
37
  readWorkspaceJson,
@@ -38,6 +42,7 @@ import {
38
42
  walkWorkspaceFiles,
39
43
  WORKSPACE_LIMITS,
40
44
  workspaceContentHash,
45
+ workspaceFileExists,
41
46
  writeManifest,
42
47
  } from "../workspace.js";
43
48
 
@@ -457,6 +462,45 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
457
462
  };
458
463
  }
459
464
 
465
+ /**
466
+ * Refuses to overwrite a live page that moved since this workspace last
467
+ * reconciled with it.
468
+ *
469
+ * TR00622 left one detection gap open deliberately: a remote edit made between
470
+ * this workspace's own push and its next pull was adopted as the new baseline
471
+ * rather than reported, because the bytes a push sends are not comparable with
472
+ * the bytes a read returns and closing it needed a revision on the read
473
+ * contract. This is that revision, so the gap closes here: the site states the
474
+ * revision of each page's stored authoring state, the workspace records the one
475
+ * it last wrote or pulled, and a push into a page carrying a different one is
476
+ * refused before a single mutation is sent.
477
+ *
478
+ * The guard is armed by knowledge, not by policy. A page the workspace has
479
+ * never reconciled with records no revision, and a Taproot that predates the
480
+ * contract reports none; neither is evidence of a concurrent edit, and refusing
481
+ * on absence would make the ordinary first push of a workspace impossible.
482
+ * Both leave this exactly where TR00622 left it, which is where the caller
483
+ * already is.
484
+ */
485
+ function requireReconciledRevision({ file, pagePath, target, entry, observedRevision }) {
486
+ // What the operator was shown by a refused pull outranks what the manifest
487
+ // last reconciled with. Without that, this guard would also refuse the pull
488
+ // conflict's own documented recovery, and the only way out of a conflict
489
+ // would be to abandon the local source.
490
+ const recorded = observedRevision ?? normalizePageBodyRevision(entry?.baseline?.revision);
491
+ const live = target.bodyRevision;
492
+ if (recorded === undefined || live === undefined || recorded === live) return;
493
+ const selector = pagePath || "/";
494
+ throw new SiteAuthoringError(
495
+ "pages.push_conflict",
496
+ `Page '${selector}' changed on the site since this workspace last reconciled with it: it now reports revision `
497
+ + `${live}, and '${file}' was written against ${recorded}. No page was pushed. Run `
498
+ + `'taproot-site pull' to see what differs and to adopt the site's version, or delete '${file}' and pull `
499
+ + "again to take the site's document as this page's source.",
500
+ { field: file, alternatives: [recorded, live] },
501
+ );
502
+ }
503
+
460
504
  /**
461
505
  * The metadata checks that are *not* part of deciding which page a source is.
462
506
  *
@@ -557,7 +601,12 @@ export async function validateWorkspacePageDocument({
557
601
  }
558
602
 
559
603
  export async function pagesPush(invocation) {
560
- const { client, config, siteId, onProgress } = await openSession(invocation);
604
+ const session = await openSession(invocation);
605
+ const { client, config, siteId, onProgress } = session;
606
+ // One advisory line before this verb does any work, and only when the
607
+ // exchange said the platform is paused. It changes nothing else: the write
608
+ // still runs and its refusal still classifies as platform_paused (TR00692).
609
+ warnIfExternalWritesPaused(session, VERB_PAGES_PUSH);
561
610
  const allowRawHtml = invocation.allowRawHtml === true;
562
611
  // Both manifests are bound to the site before anything is planned or sent:
563
612
  // every id in them is site-scoped, and a workspace pulled from another site
@@ -837,6 +886,18 @@ export async function pagesPush(invocation) {
837
886
  file,
838
887
  );
839
888
  }
889
+ // The record this push may spend has to be removable after the send:
890
+ // a directory or a symlink at its path is refused here, before any
891
+ // page is written, rather than discovered after the site has changed.
892
+ const observedRecordFile = internalPageObservedRevisionFile(target.pageId);
893
+ if (observedRecordFile !== undefined) await workspaceFileExists(config.workspaceDir, observedRecordFile);
894
+ requireReconciledRevision({
895
+ file,
896
+ pagePath,
897
+ target,
898
+ entry: manifestByPageId.get(target.pageId),
899
+ observedRevision: await readObservedPageRevision(config.workspaceDir, target.pageId),
900
+ });
840
901
  }
841
902
 
842
903
  // The duplicate-source check above only catches two workspace files
@@ -942,15 +1003,39 @@ export async function pagesPush(invocation) {
942
1003
  file: page.file,
943
1004
  sourceFormat: page.sourceFormat,
944
1005
  // The page's remote body is now derived from exactly these bytes.
945
- // Only the local half of the baseline is recorded: a read re-projects
946
- // image delivery and re-serializes component data, so a hash of what
947
- // was sent is not comparable with a hash of what a pull would read.
948
- // The next pull establishes the remote half from what it actually
949
- // reads, which is the only comparison that holds.
950
- baseline: { sourceHash: page.sourceHash },
1006
+ // No remote *hash* is recorded: a read re-projects image delivery and
1007
+ // re-serializes component data, so a hash of what was sent is not
1008
+ // comparable with a hash of what a pull would read, and the next pull
1009
+ // establishes that half from what it actually reads.
1010
+ //
1011
+ // The revision is different in kind and is recorded here. It is the
1012
+ // site's own statement about the stored state this mutation just
1013
+ // wrote, so it is directly comparable with the revision the next read
1014
+ // reports — which is what lets the *next* push tell "nobody else
1015
+ // touched this page" from "someone did", without a pull in between.
1016
+ // Absent against a Taproot that predates the contract, and then
1017
+ // dropped rather than carried forward: this push moved the stored
1018
+ // state, so whatever was recorded describes a version that no longer
1019
+ // exists, and keeping it would refuse the *next* push over a change
1020
+ // this workspace made itself.
1021
+ baseline: {
1022
+ sourceHash: page.sourceHash,
1023
+ ...(summary.bodyRevision === undefined ? {} : { revision: summary.bodyRevision }),
1024
+ },
951
1025
  pendingApproval: true,
952
1026
  });
953
1027
  if (existing === undefined) manifest.pages.push(record);
1028
+ // The override this page's record permitted has now been taken, and
1029
+ // the baseline above records what the site holds because of it.
1030
+ // Keeping the record would let a *later* push overwrite somebody
1031
+ // else's edit without anyone having been shown it. Removed after the
1032
+ // manifest record is written, so a failure here can never leave a
1033
+ // successful mutation unrecorded; the record's shape was checked
1034
+ // before anything was sent.
1035
+ const observedRevisionFile = internalPageObservedRevisionFile(summary.pageId);
1036
+ if (observedRevisionFile !== undefined) {
1037
+ await deleteWorkspaceFile(config.workspaceDir, observedRevisionFile);
1038
+ }
954
1039
  }
955
1040
  } finally {
956
1041
  if (manifestDirty) await writeManifest(config.workspaceDir, manifest);
@@ -7,7 +7,7 @@ import {
7
7
  } from "../api.js";
8
8
  import { VERB_PREVIEW_PAGE } from "../constants.js";
9
9
  import { asSiteAuthoringError, isCanonicalUuid, SiteAuthoringError } from "../errors.js";
10
- import { openSession, successResult } from "../session.js";
10
+ import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
11
11
  import { normalizePagePath, readManifest } from "../workspace.js";
12
12
 
13
13
  /**
@@ -40,7 +40,12 @@ export async function previewPage(invocation) {
40
40
  { field: "pageSelector", exitCode: 2 },
41
41
  );
42
42
  }
43
- const { client, config, siteId, now, onProgress } = await openSession(invocation);
43
+ const session = await openSession(invocation);
44
+ const { client, config, siteId, now, onProgress } = session;
45
+ // One advisory line before this verb does any work, and only when the
46
+ // exchange said the platform is paused. It changes nothing else: the write
47
+ // still runs and its refusal still classifies as platform_paused (TR00692).
48
+ warnIfExternalWritesPaused(session, VERB_PREVIEW_PAGE);
44
49
  let pageId = selector;
45
50
  if (!isCanonicalUuid(selector)) {
46
51
  const pagePath = normalizePagePath(selector);
@@ -6,7 +6,7 @@ import {
6
6
  } from "../api.js";
7
7
  import { VERB_PREVIEW_REVOKE } from "../constants.js";
8
8
  import { isCanonicalUuid, SiteAuthoringError } from "../errors.js";
9
- import { openSession, successResult } from "../session.js";
9
+ import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
10
10
 
11
11
  export async function previewRevoke(invocation) {
12
12
  if (!Array.isArray(invocation.previewIds) || invocation.previewIds.length !== 2) {
@@ -31,7 +31,12 @@ export async function previewRevoke(invocation) {
31
31
  );
32
32
  }
33
33
  const [pageId, snapshotId] = invocation.previewIds;
34
- const { client, siteId, onProgress } = await openSession(invocation);
34
+ const session = await openSession(invocation);
35
+ const { client, siteId, onProgress } = session;
36
+ // One advisory line before this verb does any work, and only when the
37
+ // exchange said the platform is paused. It changes nothing else: the write
38
+ // still runs and its refusal still classifies as platform_paused (TR00692).
39
+ warnIfExternalWritesPaused(session, VERB_PREVIEW_REVOKE);
35
40
  try {
36
41
  return await withRefusalGuidance(onProgress, "authoring preview revocation", async () => {
37
42
  onProgress("Revoking the authoring preview and scheduling its artifacts for cleanup.");