@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.
- package/README.md +106 -13
- package/examples/riverbend-wellness/manifest.fixture.json +79 -0
- package/examples/riverbend-wellness/nav.json +26 -0
- package/examples/riverbend-wellness/pages/index.md +93 -0
- package/examples/riverbend-wellness/pages/visit.md +33 -0
- package/examples/riverbend-wellness/redirects.json +33 -0
- package/examples/riverbend-wellness/settings/brand.json +7 -0
- package/examples/riverbend-wellness/settings/site-header.json +21 -0
- package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
- package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
- package/package.json +2 -1
- package/src/api.js +203 -3
- package/src/cli-release.js +152 -0
- package/src/cli.js +120 -11
- package/src/constants.js +59 -2
- package/src/contact-url.js +63 -0
- package/src/content/components.js +26 -4
- package/src/content/free-form-section-registry.json +3 -2
- package/src/content/inline-facts.js +5 -1
- package/src/content/tiptap-prosemirror.ts +26 -12
- package/src/credentials.js +39 -4
- package/src/errors.js +25 -0
- package/src/fixture-contract.js +64 -0
- package/src/footer-contract.js +28 -30
- package/src/json-path-diff.js +167 -0
- package/src/output.js +9 -0
- package/src/presentation-reference.js +5 -3
- package/src/redirects-contract.js +444 -0
- package/src/redirects-workspace.js +72 -0
- package/src/reference-help.js +262 -10
- package/src/session.js +161 -7
- package/src/transport.js +111 -6
- 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 +17 -11
- 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 +357 -22
- 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 +93 -18
- package/src/verbs/whoami.js +79 -1
- package/src/workspace.js +153 -15
|
@@ -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
|
+
}
|
package/src/verbs/status.js
CHANGED
|
@@ -6,11 +6,22 @@ import {
|
|
|
6
6
|
listSiteImages,
|
|
7
7
|
withRefusalGuidance,
|
|
8
8
|
} from "../api.js";
|
|
9
|
-
import {
|
|
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` —
|
|
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,
|
package/src/verbs/theme-push.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/verbs/validate.js
CHANGED
|
@@ -5,7 +5,22 @@ import { NAVIGATION_MAXIMUM_DEPTH, TEMPLATE_TYPE_FREE_FORM } from "../api.js";
|
|
|
5
5
|
import { freeFormRootPresentation, sharedThemeContextNames } from "../content/free-form-sections.js";
|
|
6
6
|
import { VERB_VALIDATE } from "../constants.js";
|
|
7
7
|
import { isCanonicalUuid, SiteAuthoringError } from "../errors.js";
|
|
8
|
+
import {
|
|
9
|
+
FIXTURE_CONTRACT_VERSION,
|
|
10
|
+
FIXTURE_DELIVERY_ORIGIN_DOMAIN,
|
|
11
|
+
FIXTURE_MANIFEST_FILE_NAME,
|
|
12
|
+
FIXTURE_MAXIMUM_DELIVERY_ORIGINS,
|
|
13
|
+
FIXTURE_METADATA_FIELDS,
|
|
14
|
+
FIXTURE_ROOT_FIELDS,
|
|
15
|
+
} from "../fixture-contract.js";
|
|
8
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";
|
|
9
24
|
import { boundedList, successResult } from "../session.js";
|
|
10
25
|
import { SETTINGS_GROUPS, SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
|
|
11
26
|
import { validateFooterWorkspaceDocument } from "./footer-push.js";
|
|
@@ -26,22 +41,9 @@ import {
|
|
|
26
41
|
WORKSPACE_LIMITS,
|
|
27
42
|
} from "../workspace.js";
|
|
28
43
|
|
|
29
|
-
export const FIXTURE_MANIFEST_FILE_NAME = "manifest.fixture.json";
|
|
30
|
-
export const FIXTURE_CONTRACT_VERSION = 1;
|
|
31
44
|
const MAXIMUM_REPORTED = 200;
|
|
32
|
-
const FIXTURE_ROOT_KEYS = new Set(
|
|
33
|
-
|
|
34
|
-
"siteId",
|
|
35
|
-
"pulledAt",
|
|
36
|
-
"pages",
|
|
37
|
-
"pagesTruncated",
|
|
38
|
-
"navigation",
|
|
39
|
-
"settings",
|
|
40
|
-
"settingsSkipped",
|
|
41
|
-
"deployments",
|
|
42
|
-
"fixture",
|
|
43
|
-
]);
|
|
44
|
-
const FIXTURE_METADATA_KEYS = new Set(["contractVersion", "imageIds", "deliveryOrigins"]);
|
|
45
|
+
const FIXTURE_ROOT_KEYS = new Set(FIXTURE_ROOT_FIELDS);
|
|
46
|
+
const FIXTURE_METADATA_KEYS = new Set(FIXTURE_METADATA_FIELDS);
|
|
45
47
|
|
|
46
48
|
function fail(code, message, field, exitCode) {
|
|
47
49
|
throw new SiteAuthoringError(code, message, { field, exitCode });
|
|
@@ -123,6 +125,23 @@ function validateFixtureManifest(manifest) {
|
|
|
123
125
|
"navigation",
|
|
124
126
|
);
|
|
125
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
|
+
}
|
|
126
145
|
if (!Array.isArray(manifest.settingsSkipped) || manifest.settingsSkipped.length !== 0) {
|
|
127
146
|
fail(
|
|
128
147
|
"fixture.settings_invalid",
|
|
@@ -163,7 +182,10 @@ function validateFixtureManifest(manifest) {
|
|
|
163
182
|
if (imageIds.size !== manifest.fixture.imageIds.length) {
|
|
164
183
|
fail("fixture.image_ids_invalid", "fixture.imageIds must not contain duplicates.", "fixture.imageIds");
|
|
165
184
|
}
|
|
166
|
-
if (
|
|
185
|
+
if (
|
|
186
|
+
!Array.isArray(manifest.fixture.deliveryOrigins)
|
|
187
|
+
|| manifest.fixture.deliveryOrigins.length > FIXTURE_MAXIMUM_DELIVERY_ORIGINS
|
|
188
|
+
) {
|
|
167
189
|
fail(
|
|
168
190
|
"fixture.delivery_origins_invalid",
|
|
169
191
|
"fixture.deliveryOrigins must be a bounded list of reserved HTTPS example origins.",
|
|
@@ -186,11 +208,14 @@ function validateFixtureManifest(manifest) {
|
|
|
186
208
|
|| url.pathname !== "/"
|
|
187
209
|
|| url.search !== ""
|
|
188
210
|
|| url.hash !== ""
|
|
189
|
-
|| !(
|
|
211
|
+
|| !(
|
|
212
|
+
url.hostname === FIXTURE_DELIVERY_ORIGIN_DOMAIN
|
|
213
|
+
|| url.hostname.endsWith(`.${FIXTURE_DELIVERY_ORIGIN_DOMAIN}`)
|
|
214
|
+
)
|
|
190
215
|
) {
|
|
191
216
|
fail(
|
|
192
217
|
"fixture.delivery_origins_invalid",
|
|
193
|
-
|
|
218
|
+
`Every fixture delivery origin must be an origin-only HTTPS URL under the reserved ${FIXTURE_DELIVERY_ORIGIN_DOMAIN} domain.`,
|
|
194
219
|
`fixture.deliveryOrigins[${index}]`,
|
|
195
220
|
);
|
|
196
221
|
}
|
|
@@ -452,6 +477,51 @@ export async function validateFixture(invocation = {}) {
|
|
|
452
477
|
);
|
|
453
478
|
}
|
|
454
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
|
+
|
|
455
525
|
onProgress("Validating the closed footer document and local page/image targets.");
|
|
456
526
|
validateFooterWorkspaceDocument(
|
|
457
527
|
await readWorkspaceJson(fixtureRoot, FOOTER_SETTINGS_FILE, WORKSPACE_LIMITS.settingsBytes),
|
|
@@ -482,6 +552,10 @@ export async function validateFixture(invocation = {}) {
|
|
|
482
552
|
...(reportedPages.truncated ? { itemsTruncated: true } : {}),
|
|
483
553
|
},
|
|
484
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
|
+
},
|
|
485
559
|
themes: 2,
|
|
486
560
|
appearanceSettings: presentation.scalarOperations.length,
|
|
487
561
|
footer: true,
|
|
@@ -496,6 +570,7 @@ export async function validateFixture(invocation = {}) {
|
|
|
496
570
|
"fixture structure and bounded files",
|
|
497
571
|
"page content and named theme contexts",
|
|
498
572
|
"navigation shape and local page-resource references",
|
|
573
|
+
"redirect-map shape, normalization, chains, loops, and fixture-local path occupancy",
|
|
499
574
|
"complete theme, appearance, header, brand, and footer semantics",
|
|
500
575
|
"fixture-local image identities and reserved delivery origins",
|
|
501
576
|
],
|
package/src/verbs/whoami.js
CHANGED
|
@@ -1,9 +1,67 @@
|
|
|
1
|
-
import {
|
|
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 } : {}),
|