@taprootio/site-authoring 0.2.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.
Files changed (50) hide show
  1. package/README.md +106 -13
  2. package/examples/riverbend-wellness/manifest.fixture.json +79 -0
  3. package/examples/riverbend-wellness/nav.json +26 -0
  4. package/examples/riverbend-wellness/pages/index.md +93 -0
  5. package/examples/riverbend-wellness/pages/visit.md +33 -0
  6. package/examples/riverbend-wellness/redirects.json +33 -0
  7. package/examples/riverbend-wellness/settings/brand.json +7 -0
  8. package/examples/riverbend-wellness/settings/site-header.json +21 -0
  9. package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
  10. package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
  11. package/package.json +2 -1
  12. package/src/api.js +203 -3
  13. package/src/cli-release.js +152 -0
  14. package/src/cli.js +120 -11
  15. package/src/constants.js +59 -2
  16. package/src/contact-url.js +63 -0
  17. package/src/content/components.js +26 -4
  18. package/src/content/free-form-section-registry.json +3 -2
  19. package/src/content/inline-facts.js +5 -1
  20. package/src/content/tiptap-prosemirror.ts +26 -12
  21. package/src/credentials.js +39 -4
  22. package/src/errors.js +25 -0
  23. package/src/fixture-contract.js +64 -0
  24. package/src/footer-contract.js +28 -30
  25. package/src/json-path-diff.js +167 -0
  26. package/src/output.js +9 -0
  27. package/src/presentation-reference.js +5 -3
  28. package/src/redirects-contract.js +444 -0
  29. package/src/redirects-workspace.js +72 -0
  30. package/src/reference-help.js +262 -10
  31. package/src/session.js +161 -7
  32. package/src/transport.js +111 -6
  33. package/src/verbs/approve.js +7 -2
  34. package/src/verbs/deploy.js +29 -2
  35. package/src/verbs/footer-push.js +7 -2
  36. package/src/verbs/index.js +7 -1
  37. package/src/verbs/login.js +11 -1
  38. package/src/verbs/media-upload.js +7 -2
  39. package/src/verbs/nav-push.js +17 -11
  40. package/src/verbs/pages-push.js +93 -8
  41. package/src/verbs/preview-page.js +7 -2
  42. package/src/verbs/preview-revoke.js +7 -2
  43. package/src/verbs/pull.js +357 -22
  44. package/src/verbs/redirects-pull.js +90 -0
  45. package/src/verbs/redirects-push.js +100 -0
  46. package/src/verbs/status.js +90 -3
  47. package/src/verbs/theme-push.js +7 -2
  48. package/src/verbs/validate.js +93 -18
  49. package/src/verbs/whoami.js +79 -1
  50. package/src/workspace.js +153 -15
package/src/transport.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import {
2
+ CAPABILITY_REFUSAL_FIELD,
3
+ CAPABILITY_REFUSAL_REASON,
2
4
  CLI_NAME,
5
+ CLI_UPGRADE_REFUSAL_FIELD,
3
6
  CLI_VERSION,
4
7
  CREDENTIAL_REFUSAL_FIELD,
5
8
  GRPC_RESOURCE_EXHAUSTED,
@@ -9,6 +12,8 @@ import {
9
12
  LIMITS,
10
13
  PLAN_LIMIT_REFUSAL_FIELD,
11
14
  PUBLISH_KEY_ENVIRONMENT_VARIABLE,
15
+ REFUSAL_CAPABILITY_MISSING,
16
+ REFUSAL_CLI_OUTDATED,
12
17
  REFUSAL_CREDENTIAL_REJECTED,
13
18
  REFUSAL_PLAN_LIMIT,
14
19
  REFUSAL_PLATFORM_PAUSED,
@@ -330,21 +335,95 @@ function fieldDescriptions(value) {
330
335
  return descriptions;
331
336
  }
332
337
 
338
+ /**
339
+ * A delegation capability or permission key as the server spells it. Anything
340
+ * outside this shape is dropped rather than repaired: these values reach a
341
+ * terminal and a machine-readable result, and a name the CLI cannot vouch for
342
+ * is worse than a name it does not print.
343
+ */
344
+ const CAPABILITY_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/u;
345
+ const MAXIMUM_CAPABILITY_NAMES = 16;
346
+
347
+ function capabilityNames(value) {
348
+ if (typeof value !== "string" || value === "") return [];
349
+ return value
350
+ .split(",")
351
+ .map((name) => name.trim())
352
+ .filter((name) => name.length <= 200 && CAPABILITY_NAME.test(name))
353
+ .slice(0, MAXIMUM_CAPABILITY_NAMES);
354
+ }
355
+
356
+ /**
357
+ * The key-mode capability denial the authoring API attaches as a
358
+ * `google.rpc.ErrorInfo` detail (TR00691), or undefined.
359
+ *
360
+ * The same bounded breadth-first walk the field extractors use, for the same
361
+ * reason: transcoded `details[]` entries are `Any`-packed, and pinning one path
362
+ * through them is how a contract change silently empties an extractor. The
363
+ * `reason` is the contract — a stable, namespaced classifier — so the walk
364
+ * keys on it rather than on `@type`, which a future packing could reshape.
365
+ */
366
+ export function capabilityRefusal(value) {
367
+ const queue = [{ value, depth: 0 }];
368
+ let work = 0;
369
+ while (queue.length > 0 && work < 1_000) {
370
+ const current = queue.shift();
371
+ work += 1;
372
+ if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
373
+ const metadata = current.value.metadata;
374
+ if (
375
+ current.value.reason === CAPABILITY_REFUSAL_REASON
376
+ && metadata !== null
377
+ && typeof metadata === "object"
378
+ && !Array.isArray(metadata)
379
+ ) {
380
+ const permission = capabilityNames(metadata.permission)[0];
381
+ // A detail naming no permission is not this refusal, whatever it says:
382
+ // the guidance is built entirely out of these three values, and half of
383
+ // one would be a message nobody could act on.
384
+ if (permission !== undefined) {
385
+ return Object.freeze({
386
+ permission,
387
+ granted: Object.freeze(capabilityNames(metadata.granted)),
388
+ required: Object.freeze(capabilityNames(metadata.required)),
389
+ });
390
+ }
391
+ }
392
+ for (const child of Object.values(current.value)) {
393
+ if (child !== null && typeof child === "object") queue.push({ value: child, depth: current.depth + 1 });
394
+ }
395
+ }
396
+ return undefined;
397
+ }
398
+
333
399
  export class ApiError extends SiteAuthoringError {
334
400
  constructor(httpStatus, body) {
335
401
  const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
336
402
  const fields = fieldViolations(body);
403
+ const capability = capabilityRefusal(body);
337
404
  super(
338
405
  "api.request_rejected",
339
406
  fields.length > 0
340
407
  ? `Taproot rejected the request field '${fields[0]}'.`
408
+ : capability !== undefined
409
+ ? `Taproot refused the request: this credential does not carry a capability granting '${
410
+ capability.permission
411
+ }'.`
341
412
  : `Taproot rejected the request with HTTP ${httpStatus}.`,
342
- { field: fields[0], status: grpcCode === undefined ? `http:${httpStatus}` : `grpc:${grpcCode}` },
413
+ {
414
+ // `fields` stays exactly what the wire named. The capability field is
415
+ // the CLI's own label for a refusal that carries no field violation at
416
+ // all, so it lands on the error without joining the wire-derived set
417
+ // that `hasField()` classifies on.
418
+ field: fields[0] ?? (capability === undefined ? undefined : CAPABILITY_REFUSAL_FIELD),
419
+ status: grpcCode === undefined ? `http:${httpStatus}` : `grpc:${grpcCode}`,
420
+ },
343
421
  );
344
422
  this.httpStatus = httpStatus;
345
423
  this.grpcCode = grpcCode;
346
424
  this.fields = fields;
347
425
  this.violationDescriptions = fieldDescriptions(body);
426
+ this.capability = capability;
348
427
  }
349
428
 
350
429
  hasField(field) {
@@ -366,10 +445,17 @@ export class ApiError extends SiteAuthoringError {
366
445
 
367
446
  /**
368
447
  * Classifies a refusal into the one thing the caller should do next. The
369
- * four kinds are genuinely different behaviors, and collapsing them into
448
+ * six kinds are genuinely different behaviors, and collapsing them into
370
449
  * "the request failed" is what makes an agent retry a revoked credential
371
450
  * forever or treat a plan ceiling as a transient outage:
372
451
  *
452
+ * - `cli_outdated` (field `CliUpgradeRequired`) — this CLI is behind the
453
+ * latest published release, and Taproot supports only the latest. **Upgrade
454
+ * this package**; nothing about the credential, the request, or the site is
455
+ * wrong, and no retry of this version can succeed. Classified first,
456
+ * because it is the one refusal that says the client itself is the problem:
457
+ * every other kind describes a state the current binary could act on
458
+ * (TR00703).
373
459
  * - `platform_paused` (field `SiteAuthoringRollout`) — external authoring is
374
460
  * switched off platform-wide. **Retry later** or ask the operator; the
375
461
  * credential and the request are both fine.
@@ -393,6 +479,13 @@ export class ApiError extends SiteAuthoringError {
393
479
  * never invents the numeric limit.
394
480
  * - `throttled` (gRPC `ResourceExhausted` / HTTP 429) — the per-key
395
481
  * throttle. **Back off** and retry with delay.
482
+ * - `capability_missing` (`ErrorInfo` reason `SITE_AUTHORING_CAPABILITY_MISSING`)
483
+ * — the credential is valid and correctly scoped, and simply was not
484
+ * exchanged for a capability this request needs. **Fix the verb table**,
485
+ * not the credential: the exchange minted exactly what the verb asked for,
486
+ * so a verb whose declared set is short of the requests it makes is the
487
+ * defect. Distinguished from `credential_rejected` precisely because
488
+ * re-issuing the same credential cannot help (TR00691).
396
489
  *
397
490
  * Anything else is `unclassified`, which is a signal to surface the raw
398
491
  * status rather than to guess.
@@ -400,9 +493,13 @@ export class ApiError extends SiteAuthoringError {
400
493
  refusalKind() {
401
494
  // Fields first: they are the specific statement, and a named refusal
402
495
  // outranks whatever status happens to carry it.
496
+ if (this.hasField(CLI_UPGRADE_REFUSAL_FIELD)) return REFUSAL_CLI_OUTDATED;
403
497
  if (this.hasField(ROLLOUT_REFUSAL_FIELD)) return REFUSAL_PLATFORM_PAUSED;
404
498
  if (this.hasField(CREDENTIAL_REFUSAL_FIELD)) return REFUSAL_CREDENTIAL_REJECTED;
405
499
  if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD)) return REFUSAL_PLAN_LIMIT;
500
+ // The named detail ranks with the fields and above the status mappings, for
501
+ // the same reason: it is the specific statement about this refusal.
502
+ if (this.capability !== undefined) return REFUSAL_CAPABILITY_MISSING;
406
503
  if (this.grpcCode === GRPC_RESOURCE_EXHAUSTED || this.httpStatus === HTTP_TOO_MANY_REQUESTS) {
407
504
  return REFUSAL_THROTTLED;
408
505
  }
@@ -413,8 +510,8 @@ export class ApiError extends SiteAuthoringError {
413
510
  }
414
511
  }
415
512
 
416
- async function parseJsonResponse(response, signal) {
417
- const bytes = await readBoundedBody(response, signal);
513
+ async function parseJsonResponse(response, signal, maximumBytes = LIMITS.apiResponseBytes) {
514
+ const bytes = await readBoundedBody(response, signal, maximumBytes);
418
515
  try {
419
516
  return JSON.parse(bytes.toString("utf8"));
420
517
  } catch {
@@ -523,6 +620,11 @@ export class SiteApiClient {
523
620
  attempts = LIMITS.requestAttempts,
524
621
  deadline,
525
622
  now = this.now,
623
+ // How large a body this one call may read. The default bounds every read
624
+ // the way it always has; a caller raises it only for the one contract
625
+ // whose valid answer is larger, and never above what its workspace file
626
+ // may hold.
627
+ maximumResponseBytes = LIMITS.apiResponseBytes,
526
628
  } = {}, replaceableMutation) {
527
629
  const url = new URL(path, this.apiBaseUrl);
528
630
  if (
@@ -531,6 +633,9 @@ export class SiteApiClient {
531
633
  || !ALLOWED_METHODS.has(method)
532
634
  || (deadline !== undefined && !Number.isFinite(deadline))
533
635
  || typeof now !== "function"
636
+ || !Number.isSafeInteger(maximumResponseBytes)
637
+ || maximumResponseBytes <= 0
638
+ || maximumResponseBytes > LIMITS.redirectMapResponseBytes
534
639
  ) {
535
640
  throw new SiteAuthoringError(
536
641
  "transport.request_contract",
@@ -608,7 +713,7 @@ export class SiteApiClient {
608
713
  }
609
714
  if (response.status >= 200 && response.status < 300 && !response.redirected) {
610
715
  try {
611
- const parsed = await parseJsonResponse(response, attemptSignal);
716
+ const parsed = await parseJsonResponse(response, attemptSignal, maximumResponseBytes);
612
717
  if (remainingBudget(deadline, now) <= 0) throw deadlineError();
613
718
  return parsed;
614
719
  } catch (error) {
@@ -642,7 +747,7 @@ export class SiteApiClient {
642
747
  }
643
748
  let responseBody;
644
749
  try {
645
- responseBody = await parseJsonResponse(response, attemptSignal);
750
+ responseBody = await parseJsonResponse(response, attemptSignal, maximumResponseBytes);
646
751
  } catch {
647
752
  // A response arrived, so the mutation's fate is whatever its status
648
753
  // says: a 4xx refused it, a 5xx may have committed it first.
@@ -7,7 +7,7 @@ import {
7
7
  } from "../api.js";
8
8
  import { VERB_APPROVE } from "../constants.js";
9
9
  import { SiteAuthoringError } from "../errors.js";
10
- import { boundedList, openSession, successResult } from "../session.js";
10
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
11
11
  import {
12
12
  normalizePagePath,
13
13
  readManifest,
@@ -36,7 +36,12 @@ const MAXIMUM_REPORTED = 200;
36
36
  const MAXIMUM_BATCH = 100;
37
37
 
38
38
  export async function approve(invocation) {
39
- const { client, config, siteId, onProgress } = await openSession(invocation);
39
+ const session = await openSession(invocation);
40
+ const { client, config, siteId, onProgress } = session;
41
+ // One advisory line before this verb does any work, and only when the
42
+ // exchange said the platform is paused. It changes nothing else: the write
43
+ // still runs and its refusal still classifies as platform_paused (TR00692).
44
+ warnIfExternalWritesPaused(session, VERB_APPROVE);
40
45
  const manifest = await readManifest(config.workspaceDir, siteId);
41
46
  const manifestByPageId = new Map(
42
47
  manifest.pages.filter((entry) => typeof entry?.pageId === "string").map((entry) => [entry.pageId, entry]),
@@ -17,10 +17,30 @@ import {
17
17
  VERB_DEPLOY,
18
18
  } from "../constants.js";
19
19
  import { SiteAuthoringError } from "../errors.js";
20
- import { boundedList, openSession, successResult } from "../session.js";
20
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
21
21
  import { CANDIDATE_SELECTABLE_SETTINGS_TYPES } from "../settings-catalog.js";
22
22
  import { readManifest, writeManifest } from "../workspace.js";
23
23
 
24
+ /**
25
+ * What a deploy does not finish (TR00702).
26
+ *
27
+ * A deploy writes the site's redirect and gone entries into the edge's
28
+ * key-value store as part of syncing routing. That store is eventually
29
+ * consistent, so a spot-check run the second a deploy reports success can
30
+ * briefly read the previous map, and taking that for "the redirect did not
31
+ * land" is the wrong conclusion to reach in front of a customer waiting to cut
32
+ * DNS over.
33
+ *
34
+ * No number is quoted, because there is none to quote: the coordinator holds
35
+ * nothing back for a standard site's redirects. Its propagation grace governs
36
+ * only the *deletion* of a superseded Docs pointer namespace; a standard site's
37
+ * rows are written and removed immediately.
38
+ */
39
+ const REDIRECT_PROPAGATION_NOTE =
40
+ "Redirect and gone entries are written to the edge's key-value store when this deploy syncs routing. That "
41
+ + "store is eventually consistent, so a spot-check run immediately afterwards can briefly still see the "
42
+ + "previous map; re-check before concluding an entry is missing.";
43
+
24
44
  /**
25
45
  * `deploy --staging` / `deploy --production` — readiness, deploy, poll to
26
46
  * completion.
@@ -202,7 +222,12 @@ export async function deploy(invocation) {
202
222
  "deployTarget",
203
223
  );
204
224
  }
205
- const { client, config, siteId, now, onProgress } = await openSession(invocation);
225
+ const session = await openSession(invocation);
226
+ const { client, config, siteId, now, onProgress } = session;
227
+ // One advisory line before this verb does any work, and only when the
228
+ // exchange said the platform is paused. It changes nothing else: the write
229
+ // still runs and its refusal still classifies as platform_paused (TR00692).
230
+ warnIfExternalWritesPaused(session, VERB_DEPLOY);
206
231
  const manifest = await readManifest(config.workspaceDir, siteId, { required: false });
207
232
  const selection = explicitSelection(invocation);
208
233
 
@@ -233,6 +258,7 @@ export async function deploy(invocation) {
233
258
  now,
234
259
  });
235
260
  await recordDeployment(config, manifest, "production", completed);
261
+ onProgress(REDIRECT_PROPAGATION_NOTE);
236
262
  return successResult(VERB_DEPLOY, siteId, {
237
263
  target,
238
264
  environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
@@ -310,6 +336,7 @@ export async function deploy(invocation) {
310
336
  });
311
337
  await recordDeployment(config, manifest, "staging", completed);
312
338
  const stagingPreview = await inspectStagingPreview(client, siteId, onProgress);
339
+ onProgress(REDIRECT_PROPAGATION_NOTE);
313
340
  return successResult(VERB_DEPLOY, siteId, {
314
341
  target,
315
342
  environment: DEPLOYMENT_ENVIRONMENT_STAGING,
@@ -4,7 +4,7 @@ import { SiteAuthoringError } from "../errors.js";
4
4
  import { projectFooterSettingsForWorkspace, validateFooterDocument } from "../footer-contract.js";
5
5
  import { computeFooterDraftHash } from "../footer-draft-hash.js";
6
6
  import { advanceFooterManifest, FOOTER_SETTINGS_FILE, readFooterWorkspaceContext } from "../footer-workspace.js";
7
- import { openSession, successResult } from "../session.js";
7
+ import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
8
8
  import { SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
9
9
  import { ApiError } from "../transport.js";
10
10
  import { readWorkspaceJson, WORKSPACE_LIMITS, writeWorkspaceJson } from "../workspace.js";
@@ -56,7 +56,12 @@ function translateConcurrency(error) {
56
56
  }
57
57
 
58
58
  export async function footerPush(invocation) {
59
- const { client, config, siteId, onProgress } = await openSession(invocation);
59
+ const session = await openSession(invocation);
60
+ const { client, config, siteId, onProgress } = session;
61
+ // One advisory line before this verb does any work, and only when the
62
+ // exchange said the platform is paused. It changes nothing else: the write
63
+ // still runs and its refusal still classifies as platform_paused (TR00692).
64
+ warnIfExternalWritesPaused(session, VERB_FOOTER_PUSH);
60
65
  const context = await readFooterWorkspaceContext(config.workspaceDir, siteId);
61
66
  const { document: document_, footerSettings } = validateFooterWorkspaceDocument(
62
67
  await readWorkspaceJson(config.workspaceDir, FOOTER_SETTINGS_FILE, WORKSPACE_LIMITS.settingsBytes),
@@ -8,9 +8,11 @@ import {
8
8
  VERB_MEDIA_UPLOAD,
9
9
  VERB_NAV_PUSH,
10
10
  VERB_PAGES_PUSH,
11
- VERB_PULL,
12
11
  VERB_PREVIEW_PAGE,
13
12
  VERB_PREVIEW_REVOKE,
13
+ VERB_PULL,
14
+ VERB_REDIRECTS_PULL,
15
+ VERB_REDIRECTS_PUSH,
14
16
  VERB_SITES,
15
17
  VERB_STATUS,
16
18
  VERB_THEME_PUSH,
@@ -30,6 +32,8 @@ import { pagesPush } from "./pages-push.js";
30
32
  import { pull } from "./pull.js";
31
33
  import { previewPage } from "./preview-page.js";
32
34
  import { previewRevoke } from "./preview-revoke.js";
35
+ import { redirectsPull } from "./redirects-pull.js";
36
+ import { redirectsPush } from "./redirects-push.js";
33
37
  import { sites } from "./sites.js";
34
38
  import { status } from "./status.js";
35
39
  import { themePush } from "./theme-push.js";
@@ -110,6 +114,8 @@ export const VERB_HANDLERS = Object.freeze({
110
114
  [VERB_PULL]: pull,
111
115
  [VERB_PAGES_PUSH]: pagesPush,
112
116
  [VERB_NAV_PUSH]: navPush,
117
+ [VERB_REDIRECTS_PULL]: redirectsPull,
118
+ [VERB_REDIRECTS_PUSH]: redirectsPush,
113
119
  [VERB_THEME_PUSH]: themePush,
114
120
  [VERB_FOOTER_PUSH]: footerPush,
115
121
  [VERB_MEDIA_UPLOAD]: mediaUpload,
@@ -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`
@@ -9,8 +9,13 @@ import {
9
9
  withRefusalGuidance,
10
10
  } from "../api.js";
11
11
  import { VERB_NAV_PUSH } from "../constants.js";
12
+ import {
13
+ CONTACT_OR_WEB_URL_MAX_LENGTH,
14
+ CONTACT_OR_WEB_URL_REQUIREMENT,
15
+ isContactOrWebUrl,
16
+ } from "../contact-url.js";
12
17
  import { SiteAuthoringError } from "../errors.js";
13
- import { boundedList, openSession, successResult } from "../session.js";
18
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
14
19
  import {
15
20
  NAVIGATION_FILE_NAME,
16
21
  readWorkspaceJson,
@@ -44,7 +49,7 @@ import {
44
49
  const MAXIMUM_REPORTED = 100;
45
50
  const MAXIMUM_ITEMS = 1_000;
46
51
  const MAXIMUM_TITLE_LENGTH = 200;
47
- const MAXIMUM_URL_LENGTH = 2_048;
52
+ const MAXIMUM_URL_LENGTH = CONTACT_OR_WEB_URL_MAX_LENGTH;
48
53
  const ITEM_KEYS = new Set(["id", "kind", "title", "resourceId", "externalUrl", "children"]);
49
54
 
50
55
  // `SaveSiteNavigation` maps both `id` and `resourceId` through
@@ -141,14 +146,10 @@ function validateItem(item, itemPath, depth, seenIds, counters) {
141
146
  if (!isBoundedText(item.externalUrl, MAXIMUM_URL_LENGTH)) {
142
147
  throw navError("nav.external_url_missing", "an EXTERNAL_URL item needs a non-empty 'externalUrl'.", itemPath);
143
148
  }
144
- let url;
145
- try {
146
- url = new URL(item.externalUrl);
147
- } catch {
148
- url = undefined;
149
- }
150
- if (url === undefined || (url.protocol !== "https:" && url.protocol !== "http:")) {
151
- throw navError("nav.external_url_invalid", "'externalUrl' must be an absolute http or https URL.", itemPath);
149
+ // A header item may be a phone number or an email address, not just a web
150
+ // page; the value is stored verbatim so the number stays dialable.
151
+ if (!isContactOrWebUrl(item.externalUrl, MAXIMUM_URL_LENGTH)) {
152
+ throw navError("nav.external_url_invalid", `'externalUrl' ${CONTACT_OR_WEB_URL_REQUIREMENT}.`, itemPath);
152
153
  }
153
154
  if (!isEmpty(item.resourceId)) {
154
155
  throw navError("nav.resource_unexpected", "an EXTERNAL_URL item must not carry 'resourceId'.", itemPath);
@@ -275,7 +276,12 @@ export function validateNavigationWorkspaceDocument(parsed, siteId, knownPages)
275
276
  }
276
277
 
277
278
  export async function navPush(invocation) {
278
- 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);
279
285
  if (!await workspaceFileExists(config.workspaceDir, NAVIGATION_FILE_NAME)) {
280
286
  throw new SiteAuthoringError(
281
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.");