@taprootio/site-authoring 0.3.0 → 0.5.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.
- package/README.md +81 -9
- package/examples/riverbend-wellness/manifest.fixture.json +6 -1
- package/examples/riverbend-wellness/redirects.json +33 -0
- package/package.json +2 -2
- package/src/api.js +175 -3
- package/src/cli-release.js +152 -0
- package/src/cli.js +70 -5
- package/src/constants.js +37 -1
- package/src/content/components.js +8 -5
- package/src/credentials.js +39 -4
- package/src/errors.js +25 -0
- package/src/fixture-contract.js +1 -0
- package/src/json-path-diff.js +167 -0
- package/src/output.js +9 -0
- package/src/redirects-contract.js +444 -0
- package/src/redirects-workspace.js +72 -0
- package/src/reference-help.js +122 -5
- package/src/session.js +161 -7
- package/src/transport.js +23 -5
- package/src/verbs/approve.js +7 -2
- package/src/verbs/deploy.js +29 -2
- package/src/verbs/footer-push.js +7 -2
- package/src/verbs/index.js +7 -1
- package/src/verbs/login.js +11 -1
- package/src/verbs/media-upload.js +7 -2
- package/src/verbs/nav-push.js +7 -2
- package/src/verbs/pages-push.js +93 -8
- package/src/verbs/preview-page.js +7 -2
- package/src/verbs/preview-revoke.js +7 -2
- package/src/verbs/pull.js +350 -18
- package/src/verbs/redirects-pull.js +90 -0
- package/src/verbs/redirects-push.js +100 -0
- package/src/verbs/status.js +90 -3
- package/src/verbs/theme-push.js +7 -2
- package/src/verbs/validate.js +74 -0
- package/src/verbs/whoami.js +79 -1
- package/src/workspace.js +153 -15
package/src/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CLI_BINARY_NAME,
|
|
3
3
|
CLI_NAME,
|
|
4
|
+
CLI_UPGRADE_COMMAND,
|
|
4
5
|
CLI_VERSION,
|
|
5
6
|
DEFAULT_LOGIN_KEY_NAME,
|
|
6
7
|
DEPLOY_TARGET_PRODUCTION,
|
|
@@ -22,6 +23,8 @@ import {
|
|
|
22
23
|
VERB_PREVIEW_PAGE,
|
|
23
24
|
VERB_PREVIEW_REVOKE,
|
|
24
25
|
VERB_PULL,
|
|
26
|
+
VERB_REDIRECTS_PULL,
|
|
27
|
+
VERB_REDIRECTS_PUSH,
|
|
25
28
|
VERB_SITES,
|
|
26
29
|
VERB_STATUS,
|
|
27
30
|
VERB_THEME_PUSH,
|
|
@@ -56,8 +59,21 @@ import {
|
|
|
56
59
|
CAPABILITY_DEPLOYMENTS,
|
|
57
60
|
CAPABILITY_DESIGN,
|
|
58
61
|
} from "./capabilities.js";
|
|
62
|
+
import { assertCliCurrent } from "./session.js";
|
|
59
63
|
import { VERB_HANDLERS } from "./verbs/index.js";
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The verbs the local version gate applies to (TR00703).
|
|
67
|
+
*
|
|
68
|
+
* Exactly the three that make no request. Every other verb reaches Taproot,
|
|
69
|
+
* whose own refusal is authoritative and better informed than this recording,
|
|
70
|
+
* so gating them here would only duplicate an answer — and gating `login`,
|
|
71
|
+
* `logout`, or `env` would take away the commands an operator needs while they
|
|
72
|
+
* are outdated. `--help` and `--version` are outside it for the same reason:
|
|
73
|
+
* they are how someone finds out what they have.
|
|
74
|
+
*/
|
|
75
|
+
const VERSION_GATED_OFFLINE_VERBS = Object.freeze([VERB_HELP, VERB_VALIDATE, VERB_WHOAMI]);
|
|
76
|
+
|
|
61
77
|
// One entry per verb, in help order. `tokens` is the exact leading positional
|
|
62
78
|
// sequence; matching prefers the longest sequence, so a two-token family can
|
|
63
79
|
// never be shadowed by a one-token verb.
|
|
@@ -197,8 +213,9 @@ const VERBS = Object.freeze([
|
|
|
197
213
|
+ "The homepage is recorded with an empty path, so address it as '/'. "
|
|
198
214
|
+ "A selected path resolves to its one authoritative source from metadata alone, and that page is then validated "
|
|
199
215
|
+ "exactly as a whole push would validate it — site binding, manifest integrity, live create-or-update "
|
|
200
|
-
+ "resolution, system-page rules, path uniqueness against the live site, media ownership,
|
|
201
|
-
+ "
|
|
216
|
+
+ "resolution, system-page rules, path uniqueness against the live site, media ownership, a live page whose "
|
|
217
|
+
+ "stored-state revision moved since this workspace last reconciled with it (pages.push_conflict), and two "
|
|
218
|
+
+ "workspace files claiming one path all still fail closed. What a selection does not do is convert or validate the "
|
|
202
219
|
+ "documents of pages it is not sending: an unrelated page left on an obsolete contract is reported by the "
|
|
203
220
|
+ "whole-workspace push, not used to block this one. The result states the selection and how many sources were "
|
|
204
221
|
+ "discovered and validated. "
|
|
@@ -219,6 +236,28 @@ const VERBS = Object.freeze([
|
|
|
219
236
|
tokens: ["nav", "push"],
|
|
220
237
|
summary: "Replace the whole navigation tree from the local workspace.",
|
|
221
238
|
},
|
|
239
|
+
{
|
|
240
|
+
name: VERB_REDIRECTS_PULL,
|
|
241
|
+
// A redirect is a content path, and both halves of the map — read and
|
|
242
|
+
// replace — are gated on site.pages.edit_any, which only Content carries.
|
|
243
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
244
|
+
tokens: ["redirects", "pull"],
|
|
245
|
+
summary: "Snapshot the site's whole redirect map into redirects.json.",
|
|
246
|
+
note: "Also part of 'pull'. Run it on its own to re-read the map after a page rename, or after a push was "
|
|
247
|
+
+ "refused as a conflict, without re-pulling every page. It records the map's revision in the pull "
|
|
248
|
+
+ "manifest; 'redirects push' sends that revision so a stale replace is refused rather than deleting an "
|
|
249
|
+
+ "entry a rename recorded. See 'taproot-site help redirects'.",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: VERB_REDIRECTS_PUSH,
|
|
253
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
254
|
+
tokens: ["redirects", "push"],
|
|
255
|
+
summary: "Validate redirects.json and replace the whole redirect map.",
|
|
256
|
+
note: "Run 'redirects pull' first. The whole file is validated locally by entry index — path normalization, "
|
|
257
|
+
+ "targets, statuses, duplicates, chains, and loops — before anything is sent, and the site refuses a "
|
|
258
|
+
+ "source a live page occupies. Entries take effect on the next deploy. "
|
|
259
|
+
+ "See 'taproot-site help redirects'.",
|
|
260
|
+
},
|
|
222
261
|
{
|
|
223
262
|
name: VERB_THEME_PUSH,
|
|
224
263
|
capabilities: [CAPABILITY_DESIGN],
|
|
@@ -304,7 +343,7 @@ const VERBS = Object.freeze([
|
|
|
304
343
|
// Content.
|
|
305
344
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
306
345
|
tokens: ["status"],
|
|
307
|
-
summary: "Report deployments, readiness, and image processing.",
|
|
346
|
+
summary: "Report the platform authoring switch, the CLI release, deployments, readiness, and image processing.",
|
|
308
347
|
note: "Broken references are not included: that read remains"
|
|
309
348
|
+ " session-only on the server, so the result reports it as uncovered"
|
|
310
349
|
+ " rather than pretending an empty list means a clean site.",
|
|
@@ -353,6 +392,15 @@ whoami, and env need no configuration and no site. The offline help family is
|
|
|
353
392
|
human-readable by default; add --json for stable reference data. Exit codes: 0
|
|
354
393
|
success, 1 failure, 2 usage fault.
|
|
355
394
|
|
|
395
|
+
Troubleshooting: a command that fails with field=CliUpgradeRequired (or
|
|
396
|
+
refusal=cli_outdated) means this CLI is behind the latest published release,
|
|
397
|
+
which is the only release Taproot accepts. Nothing is wrong with the
|
|
398
|
+
credential, the request, or the site, and no retry of this version succeeds.
|
|
399
|
+
Run '${CLI_UPGRADE_COMMAND}' and try again; if the
|
|
400
|
+
release is minutes old, npm may not serve it yet, so wait and retry the
|
|
401
|
+
upgrade. Once a run has recorded a newer release, help, validate, and whoami
|
|
402
|
+
refuse the same way offline. --version and --help always answer.
|
|
403
|
+
|
|
356
404
|
Verbs:
|
|
357
405
|
${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(14)} ${verb.summary}`).join("\n")}
|
|
358
406
|
|
|
@@ -408,11 +456,22 @@ function verbHelp(verb) {
|
|
|
408
456
|
// would have had to gain.
|
|
409
457
|
const selfContained = verb.offline && !verb.readsLocalState;
|
|
410
458
|
const prefix = selfContained ? CLI_BINARY_NAME : `${CLI_BINARY_NAME} [--config <path>]`;
|
|
459
|
+
// The offline boundary states the version gate too (TR00703). It is the one
|
|
460
|
+
// thing these verbs read that is not their own input, and a boundary sentence
|
|
461
|
+
// that omitted it would be describing a verb that no longer exists.
|
|
462
|
+
// `env` is offline too and deliberately outside the gate — switching Taproots
|
|
463
|
+
// is one of the things an outdated CLI must still be able to do — so the
|
|
464
|
+
// sentence is attached per verb rather than to offline-ness.
|
|
465
|
+
const upgradeGate = VERSION_GATED_OFFLINE_VERBS.includes(verb.name)
|
|
466
|
+
? " It refuses only when a previous sign-in exchange recorded a newer published release than this CLI, because "
|
|
467
|
+
+ "Taproot accepts only the latest; with nothing recorded it runs."
|
|
468
|
+
: "";
|
|
411
469
|
const boundary = selfContained
|
|
412
|
-
? "This offline verb reads no configuration
|
|
470
|
+
? "This offline verb uses no credential and reads no configuration, and performs no network request and no "
|
|
471
|
+
+ `write.${upgradeGate}`
|
|
413
472
|
: verb.offline
|
|
414
473
|
? "This verb answers entirely from local state — the stored sign-in and the configuration — and makes no "
|
|
415
|
-
+
|
|
474
|
+
+ `network request and no write.${upgradeGate}`
|
|
416
475
|
: verb.credentialFree
|
|
417
476
|
? `This verb reads the configuration but requires no existing credential. `
|
|
418
477
|
+ `${PUBLISH_KEY_ENVIRONMENT_VARIABLE} always takes precedence over the stored credential, so setting it leaves `
|
|
@@ -500,6 +559,7 @@ function parseReferenceArguments(arguments_) {
|
|
|
500
559
|
|| topic === "appearance"
|
|
501
560
|
|| topic === "footer"
|
|
502
561
|
|| topic === "fixture"
|
|
562
|
+
|| topic === "redirects"
|
|
503
563
|
)
|
|
504
564
|
&& (subject !== undefined || extra.length > 0)) {
|
|
505
565
|
throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
|
|
@@ -549,6 +609,7 @@ function referenceResult(parsed) {
|
|
|
549
609
|
return { ...result, topic: "component", component };
|
|
550
610
|
}
|
|
551
611
|
case "nav":
|
|
612
|
+
case "redirects":
|
|
552
613
|
case "media":
|
|
553
614
|
case "preview":
|
|
554
615
|
case "fixture":
|
|
@@ -855,10 +916,14 @@ export async function runCli({
|
|
|
855
916
|
return 0;
|
|
856
917
|
}
|
|
857
918
|
if (parsed.mode === "reference") {
|
|
919
|
+
await assertCliCurrent(environment);
|
|
858
920
|
const result = referenceResult(parsed);
|
|
859
921
|
stdout.write(parsed.json ? `${serializeResult(result)}\n` : formatReferenceResult(result));
|
|
860
922
|
return 0;
|
|
861
923
|
}
|
|
924
|
+
if (VERSION_GATED_OFFLINE_VERBS.includes(parsed.verb)) {
|
|
925
|
+
await assertCliCurrent(environment);
|
|
926
|
+
}
|
|
862
927
|
// Own properties only: a verb name must never resolve through the
|
|
863
928
|
// prototype chain to something like `constructor`.
|
|
864
929
|
const handler = Object.hasOwn(handlers, parsed.verb) ? handlers[parsed.verb] : undefined;
|
package/src/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const CLI_NAME = "@taprootio/site-authoring";
|
|
2
|
-
export const CLI_VERSION = "0.
|
|
2
|
+
export const CLI_VERSION = "0.5.0";
|
|
3
3
|
export const CLI_BINARY_NAME = "taproot-site";
|
|
4
4
|
export const RESULT_SCHEMA_VERSION = 1;
|
|
5
5
|
export const CONFIG_FILE_NAME = "taproot-site.json";
|
|
@@ -82,6 +82,8 @@ export const VERB_LOGIN = "login";
|
|
|
82
82
|
export const VERB_LOGOUT = "logout";
|
|
83
83
|
export const VERB_PAGES_PUSH = "pages push";
|
|
84
84
|
export const VERB_NAV_PUSH = "nav push";
|
|
85
|
+
export const VERB_REDIRECTS_PULL = "redirects pull";
|
|
86
|
+
export const VERB_REDIRECTS_PUSH = "redirects push";
|
|
85
87
|
export const VERB_THEME_PUSH = "theme push";
|
|
86
88
|
export const VERB_FOOTER_PUSH = "footer push";
|
|
87
89
|
export const VERB_MEDIA_UPLOAD = "media upload";
|
|
@@ -109,6 +111,7 @@ export const REFUSAL_CREDENTIAL_REJECTED = "credential_rejected";
|
|
|
109
111
|
export const REFUSAL_PLAN_LIMIT = "plan_limit";
|
|
110
112
|
export const REFUSAL_THROTTLED = "throttled";
|
|
111
113
|
export const REFUSAL_CAPABILITY_MISSING = "capability_missing";
|
|
114
|
+
export const REFUSAL_CLI_OUTDATED = "cli_outdated";
|
|
112
115
|
export const REFUSAL_UNCLASSIFIED = "unclassified";
|
|
113
116
|
|
|
114
117
|
export const REFUSAL_KINDS = Object.freeze([
|
|
@@ -117,6 +120,7 @@ export const REFUSAL_KINDS = Object.freeze([
|
|
|
117
120
|
REFUSAL_PLAN_LIMIT,
|
|
118
121
|
REFUSAL_THROTTLED,
|
|
119
122
|
REFUSAL_CAPABILITY_MISSING,
|
|
123
|
+
REFUSAL_CLI_OUTDATED,
|
|
120
124
|
REFUSAL_UNCLASSIFIED,
|
|
121
125
|
]);
|
|
122
126
|
|
|
@@ -127,6 +131,34 @@ export const ROLLOUT_REFUSAL_FIELD = "SiteAuthoringRollout";
|
|
|
127
131
|
export const CREDENTIAL_REFUSAL_FIELD = "ExternalApiKey";
|
|
128
132
|
export const PLAN_LIMIT_REFUSAL_FIELD = "UpgradePrompt";
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* The field Taproot refuses an outdated CLI with (TR00703).
|
|
136
|
+
*
|
|
137
|
+
* Named so the name alone is the instruction, because for every CLI published
|
|
138
|
+
* before this check that is the whole message: an unknown refusal field prints
|
|
139
|
+
* as "Taproot rejected the request field '<name>'." and nothing else.
|
|
140
|
+
*/
|
|
141
|
+
export const CLI_UPGRADE_REFUSAL_FIELD = "CliUpgradeRequired";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* How an operator gets current. Composed from {@link CLI_NAME} so the command
|
|
145
|
+
* in a refusal cannot drift from the package that refusal is about.
|
|
146
|
+
*/
|
|
147
|
+
export const CLI_UPGRADE_COMMAND = `npm install -g ${CLI_NAME}@latest`;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The platform switch behind every `platform_paused` refusal, and where the one
|
|
151
|
+
* person who can undo it goes to look (TR00692).
|
|
152
|
+
*
|
|
153
|
+
* Held here rather than written out at each site because four surfaces say it —
|
|
154
|
+
* the pre-write warning, `status`, `whoami`, and the refusal guidance — and a
|
|
155
|
+
* setting key spelled four ways is a key an operator cannot search for. The key
|
|
156
|
+
* is a wire identity and is never reworded; the location is display text.
|
|
157
|
+
*/
|
|
158
|
+
export const EXTERNAL_WRITES_SETTING_KEY = "site_authoring.external_writes_enabled";
|
|
159
|
+
export const EXTERNAL_WRITES_SETTING_LOCATION =
|
|
160
|
+
"Admin → Platform settings → Site Authoring (\"External site authoring writes enabled\")";
|
|
161
|
+
|
|
130
162
|
/**
|
|
131
163
|
* The `google.rpc.ErrorInfo` reason a key-mode permission denial carries
|
|
132
164
|
* (TR00691, `SiteAuthoringKeyDenial` on the server). A wire identity, matched
|
|
@@ -165,6 +197,10 @@ export const LIMITS = Object.freeze({
|
|
|
165
197
|
configDiscoveryParents: 32,
|
|
166
198
|
workspacePathBytes: 4 * 1024,
|
|
167
199
|
apiResponseBytes: 1024 * 1024,
|
|
200
|
+
// The redirect map is the one read whose valid size exceeds the ordinary
|
|
201
|
+
// response bound: two thousand entries with long absolute targets. Sized
|
|
202
|
+
// with the workspace file that holds it (WORKSPACE_LIMITS.redirectsBytes).
|
|
203
|
+
redirectMapResponseBytes: 8 * 1024 * 1024,
|
|
168
204
|
requestMilliseconds: 60_000,
|
|
169
205
|
uploadMilliseconds: 5 * 60_000,
|
|
170
206
|
deploymentMilliseconds: 30 * 60_000,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Espalier 4.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// Espalier 4.8.0 publishes the texture registry as a side-effect-free subpath
|
|
2
|
+
// (TR00630). Import it from there, never from `@taprootio/espalier/root`: that
|
|
3
|
+
// module registers `esp-root`, so a process already holding another Espalier
|
|
4
|
+
// copy — the generator's Playwright Node process, for one — cannot load this
|
|
5
|
+
// pipeline at all once a component module is in the graph.
|
|
6
|
+
import { BUILT_IN_IMAGE_TEXTURES } from "@taprootio/espalier/shared/image-texture-registry";
|
|
5
7
|
import { hasAsciiControl } from "../errors.js";
|
|
6
8
|
import {
|
|
7
9
|
CONTENT_ERROR_CODES as CODES,
|
|
@@ -195,7 +197,8 @@ const TESTIMONIAL = Object.freeze({
|
|
|
195
197
|
borderWidth: num({
|
|
196
198
|
integer: true,
|
|
197
199
|
minimum: 0,
|
|
198
|
-
description:
|
|
200
|
+
description:
|
|
201
|
+
"Pixel border around each quotation, as on a card grid; a carousel shows the visible slide's own box and is never framed. 0 boxes nothing.",
|
|
199
202
|
}),
|
|
200
203
|
});
|
|
201
204
|
|
package/src/credentials.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import { atomicWriteFile } from "./atomic-file.js";
|
|
7
7
|
import { readBoundedFile } from "./bounded-file.js";
|
|
8
8
|
import { SITE_AUTHORING_CAPABILITIES } from "./capabilities.js";
|
|
9
|
+
import { parseCliVersion } from "./cli-release.js";
|
|
9
10
|
|
|
10
11
|
import {
|
|
11
12
|
CANONICAL_TIMESTAMP,
|
|
@@ -63,6 +64,11 @@ export const KEY_PREFIX = /^[\w.-]{1,64}$/u;
|
|
|
63
64
|
const PROJECTION_UUID = "00000000-0000-4000-8000-000000000000";
|
|
64
65
|
const PROJECTION_TIMESTAMP = "2000-01-01T00:00:00.000Z";
|
|
65
66
|
const PROJECTION_KEY_PREFIX_LENGTH = 64;
|
|
67
|
+
// A version string as long as the reader admits (MAXIMUM_VERSION_LENGTH in
|
|
68
|
+
// cli-release.js, 64 characters), for the same reason the prefix above is
|
|
69
|
+
// padded: the projection is an upper bound on the record a first exchange will
|
|
70
|
+
// write (TR00703).
|
|
71
|
+
const PROJECTION_LATEST_CLI_VERSION = `999999.999999.999999-${"a".repeat(43)}`;
|
|
66
72
|
|
|
67
73
|
function usableDirectory(value) {
|
|
68
74
|
return typeof value === "string"
|
|
@@ -134,9 +140,17 @@ function normalizeEntry(entry) {
|
|
|
134
140
|
"A stored credential record has an invalid createdAt.",
|
|
135
141
|
);
|
|
136
142
|
// What the last exchange produced. Entirely non-secret — a site id, the
|
|
137
|
-
// capabilities that exchange asked for,
|
|
138
|
-
//
|
|
139
|
-
//
|
|
143
|
+
// capabilities that exchange asked for, when the credential it returned dies,
|
|
144
|
+
// when the exchange happened, and what Taproot then said about the
|
|
145
|
+
// platform-wide authoring switch — and the only way `whoami` can answer "what
|
|
146
|
+
// can this actually do" without a network round trip. Absent before the first
|
|
147
|
+
// exchange.
|
|
148
|
+
//
|
|
149
|
+
// `at` and `externalWritesEnabled` are optional on top of that (TR00692), and
|
|
150
|
+
// `latestCliVersion` on top of those (TR00703): a record written by an
|
|
151
|
+
// earlier version carries none of them, and a server that predates either
|
|
152
|
+
// field carries no value for it. Refusing those would make a routine upgrade
|
|
153
|
+
// look like a corrupt store.
|
|
140
154
|
requireStoreShape(
|
|
141
155
|
entry.lastExchange === undefined
|
|
142
156
|
|| (entry.lastExchange !== null
|
|
@@ -146,7 +160,16 @@ function normalizeEntry(entry) {
|
|
|
146
160
|
&& Array.isArray(entry.lastExchange.capabilities)
|
|
147
161
|
&& entry.lastExchange.capabilities.every((value) => typeof value === "string")
|
|
148
162
|
&& typeof entry.lastExchange.expiresAt === "string"
|
|
149
|
-
&& CANONICAL_TIMESTAMP.test(entry.lastExchange.expiresAt)
|
|
163
|
+
&& CANONICAL_TIMESTAMP.test(entry.lastExchange.expiresAt)
|
|
164
|
+
&& (entry.lastExchange.at === undefined
|
|
165
|
+
|| (typeof entry.lastExchange.at === "string" && CANONICAL_TIMESTAMP.test(entry.lastExchange.at)))
|
|
166
|
+
&& (entry.lastExchange.externalWritesEnabled === undefined
|
|
167
|
+
|| typeof entry.lastExchange.externalWritesEnabled === "boolean")
|
|
168
|
+
// Only a version this side can compare is admitted, because the value's
|
|
169
|
+
// whole job is to be compared: a record carrying something unparseable
|
|
170
|
+
// could never gate anything, so storing it would be storing noise.
|
|
171
|
+
&& (entry.lastExchange.latestCliVersion === undefined
|
|
172
|
+
|| parseCliVersion(entry.lastExchange.latestCliVersion) !== undefined)),
|
|
150
173
|
"A stored credential record has an invalid lastExchange.",
|
|
151
174
|
);
|
|
152
175
|
return Object.freeze({
|
|
@@ -161,6 +184,13 @@ function normalizeEntry(entry) {
|
|
|
161
184
|
siteId: entry.lastExchange.siteId,
|
|
162
185
|
capabilities: Object.freeze([...entry.lastExchange.capabilities]),
|
|
163
186
|
expiresAt: entry.lastExchange.expiresAt,
|
|
187
|
+
...(entry.lastExchange.at === undefined ? {} : { at: entry.lastExchange.at }),
|
|
188
|
+
...(entry.lastExchange.externalWritesEnabled === undefined
|
|
189
|
+
? {}
|
|
190
|
+
: { externalWritesEnabled: entry.lastExchange.externalWritesEnabled }),
|
|
191
|
+
...(entry.lastExchange.latestCliVersion === undefined
|
|
192
|
+
? {}
|
|
193
|
+
: { latestCliVersion: entry.lastExchange.latestCliVersion }),
|
|
164
194
|
}),
|
|
165
195
|
}),
|
|
166
196
|
createdAt: entry.createdAt,
|
|
@@ -469,6 +499,11 @@ export async function assertCredentialCapacity(environment, apiOrigin) {
|
|
|
469
499
|
siteId: PROJECTION_UUID,
|
|
470
500
|
capabilities: [...SITE_AUTHORING_CAPABILITIES],
|
|
471
501
|
expiresAt: PROJECTION_TIMESTAMP,
|
|
502
|
+
at: PROJECTION_TIMESTAMP,
|
|
503
|
+
// `false` rather than `true`: it serializes one byte longer, and the
|
|
504
|
+
// projection's whole job is to be an upper bound on the real record.
|
|
505
|
+
externalWritesEnabled: false,
|
|
506
|
+
latestCliVersion: PROJECTION_LATEST_CLI_VERSION,
|
|
472
507
|
},
|
|
473
508
|
createdAt: PROJECTION_TIMESTAMP,
|
|
474
509
|
});
|
package/src/errors.js
CHANGED
|
@@ -112,6 +112,31 @@ export class SiteAuthoringError extends Error {
|
|
|
112
112
|
return this;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Attach the JSON paths at which two documents differ.
|
|
117
|
+
*
|
|
118
|
+
* Separate from `alternatives`, which is the bounded list of other values or
|
|
119
|
+
* targets a caller can act on — the files in a pull conflict, but equally the
|
|
120
|
+
* help topics, page types, or revisions another failure offers. These name
|
|
121
|
+
* locations inside one document, decide nothing, and exist so a conflict can
|
|
122
|
+
* be triaged without re-fetching both sides by hand.
|
|
123
|
+
*
|
|
124
|
+
* A list and no list are different facts, so anything that is not a list
|
|
125
|
+
* leaves the field unset rather than becoming an empty one: `[]` means the
|
|
126
|
+
* documents were compared and found identical, and absent means there was
|
|
127
|
+
* nothing to compare them against. Collapsing the second into the first would
|
|
128
|
+
* tell an agent the body is unchanged for a comparison that never ran.
|
|
129
|
+
*/
|
|
130
|
+
withDifferences(paths) {
|
|
131
|
+
this.differences = Array.isArray(paths)
|
|
132
|
+
? paths
|
|
133
|
+
.filter((value) => typeof value === "string")
|
|
134
|
+
.slice(0, 100)
|
|
135
|
+
.map((value) => sanitizeDiagnostic(value, "path"))
|
|
136
|
+
: undefined;
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
/** Attach the non-secret identity needed to inspect or revoke a created preview. */
|
|
116
141
|
withPreviewRecovery(preview) {
|
|
117
142
|
const recovery = normalizePreviewRecovery(preview);
|
package/src/fixture-contract.js
CHANGED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Says *what* differs between two page documents, in JSON paths.
|
|
3
|
+
*
|
|
4
|
+
* A conflict that only says "this page changed on the site" leaves the operator
|
|
5
|
+
* with a choice nobody can make well: re-push identical content, or abandon the
|
|
6
|
+
* local source. On the SHY production home page (2026-09-03) the entire
|
|
7
|
+
* difference was `$.content[0].attrs.decoration.image.src` and the sibling
|
|
8
|
+
* `.urls` — two fields the API re-projects on every read from live image state
|
|
9
|
+
* — and a list of paths would have shown that in one line.
|
|
10
|
+
*
|
|
11
|
+
* This decides nothing. The revision comparison decides; this explains it. So
|
|
12
|
+
* it is deliberately generous about what counts as a difference and strictly
|
|
13
|
+
* bounded in what it will spend finding out: a diagnostic that hangs on a
|
|
14
|
+
* hostile document is worse than one that says "and more".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** How many differing paths one report carries before it says "and more". */
|
|
18
|
+
export const JSON_DIFFERENCE_LIMIT = 20;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How deep the comparison descends. `canonicalDocumentHash` refuses a document
|
|
22
|
+
* nesting deeper than this, and every document compared here has already been
|
|
23
|
+
* through it, so the bound is unreachable for real content. A subtree at the
|
|
24
|
+
* bound is reported as differing rather than skipped in silence: over-reporting
|
|
25
|
+
* a diagnostic is safe, and quietly not comparing is not.
|
|
26
|
+
*/
|
|
27
|
+
const MAXIMUM_DEPTH = 100;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A ceiling on total work, because both sides are documents this process did
|
|
31
|
+
* not author: one is the site's, the other a file on disk.
|
|
32
|
+
*/
|
|
33
|
+
const MAXIMUM_VISITS = 200_000;
|
|
34
|
+
|
|
35
|
+
/** How much of one object key a path segment carries. */
|
|
36
|
+
const MAXIMUM_SEGMENT_SCALARS = 64;
|
|
37
|
+
|
|
38
|
+
/** How long one rendered path may be before its middle is elided. */
|
|
39
|
+
const MAXIMUM_PATH_SCALARS = 200;
|
|
40
|
+
|
|
41
|
+
const PLAIN_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
42
|
+
|
|
43
|
+
function boundSegment(key) {
|
|
44
|
+
const scalars = [...key];
|
|
45
|
+
return scalars.length <= MAXIMUM_SEGMENT_SCALARS
|
|
46
|
+
? key
|
|
47
|
+
: `${scalars.slice(0, MAXIMUM_SEGMENT_SCALARS).join("")}…`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function childPath(path, key) {
|
|
51
|
+
const segment = boundSegment(key);
|
|
52
|
+
return PLAIN_KEY.test(segment) ? `${path}.${segment}` : `${path}[${JSON.stringify(segment)}]`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function indexPath(path, index) {
|
|
56
|
+
return `${path}[${index}]`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function boundPath(path) {
|
|
60
|
+
const scalars = [...path];
|
|
61
|
+
if (scalars.length <= MAXIMUM_PATH_SCALARS) return path;
|
|
62
|
+
const head = scalars.slice(0, MAXIMUM_PATH_SCALARS - 20).join("");
|
|
63
|
+
const tail = scalars.slice(-19).join("");
|
|
64
|
+
return `${head}…${tail}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isPlainObject(value) {
|
|
68
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The paths at which two parsed JSON values differ.
|
|
73
|
+
*
|
|
74
|
+
* Returns `{ paths, truncated }`. `paths` is empty when the two documents are
|
|
75
|
+
* structurally identical — which, on a page whose revision moved, is the useful
|
|
76
|
+
* answer: the stored state changed in a field this comparison does not cover.
|
|
77
|
+
*
|
|
78
|
+
* `truncated` means the walk stopped early — either more than `limit`
|
|
79
|
+
* differences exist, or the documents are large enough to have exhausted the
|
|
80
|
+
* work ceiling — so the list is a sample rather than the whole story.
|
|
81
|
+
*/
|
|
82
|
+
export function describeJsonDifferences(left, right, { limit = JSON_DIFFERENCE_LIMIT } = {}) {
|
|
83
|
+
const bound = Number.isSafeInteger(limit) && limit > 0 ? limit : JSON_DIFFERENCE_LIMIT;
|
|
84
|
+
// One past the reported bound. Stopping *at* the bound could not tell "these
|
|
85
|
+
// are all of them" from "there were more", and a sample presented as a
|
|
86
|
+
// complete list is the kind of diagnostic that sends someone looking in the
|
|
87
|
+
// wrong place.
|
|
88
|
+
const cap = bound + 1;
|
|
89
|
+
const paths = [];
|
|
90
|
+
let exhausted = false;
|
|
91
|
+
let visits = 0;
|
|
92
|
+
|
|
93
|
+
const record = (path) => {
|
|
94
|
+
paths.push(boundPath(path));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const walk = (leftValue, rightValue, path, depth) => {
|
|
98
|
+
if (paths.length >= cap || exhausted) return;
|
|
99
|
+
visits += 1;
|
|
100
|
+
if (visits > MAXIMUM_VISITS) {
|
|
101
|
+
exhausted = true;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (depth > MAXIMUM_DEPTH) {
|
|
105
|
+
record(path);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (Array.isArray(leftValue) && Array.isArray(rightValue)) {
|
|
110
|
+
const shared = Math.min(leftValue.length, rightValue.length);
|
|
111
|
+
for (let index = 0; index < shared; index += 1) {
|
|
112
|
+
walk(leftValue[index], rightValue[index], indexPath(path, index), depth + 1);
|
|
113
|
+
if (paths.length >= cap || exhausted) return;
|
|
114
|
+
}
|
|
115
|
+
// An added or removed element is named by its own index rather than by
|
|
116
|
+
// the array, so "one section was appended" reads differently from "every
|
|
117
|
+
// section moved".
|
|
118
|
+
for (let index = shared; index < Math.max(leftValue.length, rightValue.length); index += 1) {
|
|
119
|
+
record(indexPath(path, index));
|
|
120
|
+
if (paths.length >= cap || exhausted) return;
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isPlainObject(leftValue) && isPlainObject(rightValue)) {
|
|
126
|
+
// Sorted so two runs over the same pair of documents report the same
|
|
127
|
+
// paths in the same order, whatever order the members arrived in.
|
|
128
|
+
const keys = [...new Set([...Object.keys(leftValue), ...Object.keys(rightValue)])].sort();
|
|
129
|
+
for (const key of keys) {
|
|
130
|
+
const inLeft = Object.hasOwn(leftValue, key);
|
|
131
|
+
const inRight = Object.hasOwn(rightValue, key);
|
|
132
|
+
if (!inLeft || !inRight) {
|
|
133
|
+
record(childPath(path, key));
|
|
134
|
+
} else {
|
|
135
|
+
walk(leftValue[key], rightValue[key], childPath(path, key), depth + 1);
|
|
136
|
+
}
|
|
137
|
+
if (paths.length >= cap || exhausted) return;
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Different kinds, or two scalars. `Object.is` rather than `===` so two
|
|
143
|
+
// NaN bodies do not read as a difference; JSON has no NaN, but these
|
|
144
|
+
// documents reach here through `JSON.parse` of untrusted bytes and a
|
|
145
|
+
// caller may hand over something else.
|
|
146
|
+
if (!Object.is(leftValue, rightValue)) record(path);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
walk(left, right, "$", 0);
|
|
150
|
+
return { paths: paths.slice(0, bound), truncated: exhausted || paths.length > bound };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The paths a conflict may claim, or `undefined` when no claim is honest.
|
|
155
|
+
*
|
|
156
|
+
* An empty list is the statement "compared, and the body is identical", which
|
|
157
|
+
* an agent reads as "look at the title, path, or description instead". That
|
|
158
|
+
* statement cannot be made when nothing was compared, and it cannot be made
|
|
159
|
+
* when the walk exhausted its budget before it reached a difference: two
|
|
160
|
+
* documents that agree for the first two hundred thousand nodes and differ
|
|
161
|
+
* afterwards are different, not identical.
|
|
162
|
+
*/
|
|
163
|
+
export function reportableDifferencePaths(differences) {
|
|
164
|
+
if (differences === undefined) return undefined;
|
|
165
|
+
if (differences.paths.length === 0 && differences.truncated) return undefined;
|
|
166
|
+
return differences.paths;
|
|
167
|
+
}
|
package/src/output.js
CHANGED
|
@@ -42,6 +42,15 @@ export function failureResult(error) {
|
|
|
42
42
|
if (Array.isArray(error.completedWrites) && error.completedWrites.length > 0) {
|
|
43
43
|
result.error.completedWrites = error.completedWrites;
|
|
44
44
|
}
|
|
45
|
+
// Three states, and an agent acts differently on each. Absent: the two
|
|
46
|
+
// documents were never compared, because this workspace kept no copy of the
|
|
47
|
+
// site's previous version. Empty: they were compared and the body is
|
|
48
|
+
// identical, so the change that raised the conflict is in the page's title,
|
|
49
|
+
// path, or description. Non-empty: the JSON paths at which they differ.
|
|
50
|
+
// Suppressing the empty array would collapse the first two into one.
|
|
51
|
+
if (Array.isArray(error.differences)) {
|
|
52
|
+
result.error.differences = error.differences;
|
|
53
|
+
}
|
|
45
54
|
const previewRecovery = normalizePreviewRecovery(error.previewRecovery);
|
|
46
55
|
if (previewRecovery) result.error.preview = previewRecovery;
|
|
47
56
|
const refusal = typeof error.refusalKind === "function" ? error.refusalKind() : undefined;
|