@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
package/src/reference-help.js
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { TEMPLATE_TYPE_FREE_FORM } from "./api.js";
|
|
1
4
|
import { CLI_BINARY_NAME } from "./constants.js";
|
|
5
|
+
import {
|
|
6
|
+
FIXTURE_CONTRACT_VERSION,
|
|
7
|
+
FIXTURE_DELIVERY_ORIGIN_DOMAIN,
|
|
8
|
+
FIXTURE_MANIFEST_FILE_NAME,
|
|
9
|
+
FIXTURE_MAXIMUM_DELIVERY_ORIGINS,
|
|
10
|
+
FIXTURE_METADATA_FIELDS,
|
|
11
|
+
FIXTURE_OPTIONAL_ROOT_FIELDS,
|
|
12
|
+
FIXTURE_REQUIRED_ROOT_FIELDS,
|
|
13
|
+
SHIPPED_FIXTURE_NAME,
|
|
14
|
+
shippedFixtureDirectory,
|
|
15
|
+
} from "./fixture-contract.js";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_REDIRECT_STATUS,
|
|
18
|
+
GONE_STATUS,
|
|
19
|
+
REDIRECT_LIMITS,
|
|
20
|
+
REDIRECT_STATUSES,
|
|
21
|
+
REDIRECTS_FILE_NAME,
|
|
22
|
+
} from "./redirects-contract.js";
|
|
23
|
+
import { SETTINGS_GROUPS } from "./settings-catalog.js";
|
|
2
24
|
import {
|
|
3
25
|
canonicalizeComponentData,
|
|
4
26
|
COMPONENT_TYPES,
|
|
@@ -20,14 +42,21 @@ import {
|
|
|
20
42
|
} from "./presentation-reference.js";
|
|
21
43
|
import {
|
|
22
44
|
INTERNAL_PAGE_BASELINE_DIRECTORY,
|
|
45
|
+
MANIFEST_VERSION,
|
|
46
|
+
NAVIGATION_FILE_NAME,
|
|
23
47
|
PAGE_READ_ONLY_REASON_SYSTEM_404,
|
|
48
|
+
PAGE_SOURCE_EXTENSIONS,
|
|
49
|
+
PAGE_WORKSPACE_MODE_EDITABLE,
|
|
24
50
|
PAGE_WORKSPACE_MODE_READ_ONLY,
|
|
51
|
+
pageSourceFormat,
|
|
52
|
+
PAGES_DIRECTORY,
|
|
53
|
+
SETTINGS_DIRECTORY,
|
|
25
54
|
SYSTEM_PAGE_NOT_FOUND_PATH,
|
|
26
55
|
} from "./workspace.js";
|
|
27
56
|
|
|
28
57
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
29
58
|
|
|
30
|
-
export const REFERENCE_VERSION =
|
|
59
|
+
export const REFERENCE_VERSION = 17;
|
|
31
60
|
export const PAGE_TYPES = Object.freeze(["free-form"]);
|
|
32
61
|
export const REFERENCE_TOPICS = Object.freeze([
|
|
33
62
|
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types." }),
|
|
@@ -47,6 +76,11 @@ export const REFERENCE_TOPICS = Object.freeze([
|
|
|
47
76
|
summary: "Describe one component schema and show a valid example.",
|
|
48
77
|
}),
|
|
49
78
|
Object.freeze({ name: "nav", usage: `${CLI_BINARY_NAME} help nav`, summary: "Describe nav.json item shapes." }),
|
|
79
|
+
Object.freeze({
|
|
80
|
+
name: "redirects",
|
|
81
|
+
usage: `${CLI_BINARY_NAME} help redirects`,
|
|
82
|
+
summary: "Author redirects.json: entry shape, normalization, and the refusals.",
|
|
83
|
+
}),
|
|
50
84
|
Object.freeze({
|
|
51
85
|
name: "media",
|
|
52
86
|
usage: `${CLI_BINARY_NAME} help media`,
|
|
@@ -72,6 +106,11 @@ export const REFERENCE_TOPICS = Object.freeze([
|
|
|
72
106
|
usage: `${CLI_BINARY_NAME} help footer`,
|
|
73
107
|
summary: "Author and concurrency-safely push the complete footer document.",
|
|
74
108
|
}),
|
|
109
|
+
Object.freeze({
|
|
110
|
+
name: "fixture",
|
|
111
|
+
usage: `${CLI_BINARY_NAME} help fixture`,
|
|
112
|
+
summary: "State the offline fixture manifest contract and locate the shipped example.",
|
|
113
|
+
}),
|
|
75
114
|
]);
|
|
76
115
|
|
|
77
116
|
const WORKFLOW_REFERENCES = Object.freeze({
|
|
@@ -84,6 +123,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
84
123
|
"kind is NAV_ITEM_KIND_PAGE, NAV_ITEM_KIND_EXTERNAL_URL, or NAV_ITEM_KIND_GROUP_HEADER.",
|
|
85
124
|
"PAGE uses resourceId from .taproot-site-manifest.json, never pageId or path.",
|
|
86
125
|
"EXTERNAL_URL uses externalUrl; GROUP_HEADER has no target; every kind may carry children.",
|
|
126
|
+
"externalUrl is an absolute credential-free http/https URL, or a mailto:/tel: contact URL such as "
|
|
127
|
+
+ "tel:+15555550123, published exactly as authored.",
|
|
87
128
|
]),
|
|
88
129
|
example: Object.freeze({
|
|
89
130
|
siteId: "<site-uuid>",
|
|
@@ -96,6 +137,81 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
96
137
|
})]),
|
|
97
138
|
}),
|
|
98
139
|
}),
|
|
140
|
+
redirects: Object.freeze({
|
|
141
|
+
title: "Redirect map workspace contract",
|
|
142
|
+
summary:
|
|
143
|
+
`${REDIRECTS_FILE_NAME} is the site's whole redirect map. 'redirects push' replaces it, fenced by the `
|
|
144
|
+
+ "revision 'redirects pull' recorded, so a stale write is refused rather than deleting an entry a page "
|
|
145
|
+
+ "rename added.",
|
|
146
|
+
usage: `${CLI_BINARY_NAME} redirects pull | ${CLI_BINARY_NAME} redirects push`,
|
|
147
|
+
details: Object.freeze([
|
|
148
|
+
`${REDIRECTS_FILE_NAME} is { siteId, revision, entries }, not a bare array. 'redirects pull' writes it and `
|
|
149
|
+
+ "records the revision in .taproot-site-manifest.json; 'pull' writes it too.",
|
|
150
|
+
"An entry is { path, kind, target, status }. kind is 'redirect' (the default) or 'gone'; a gone entry "
|
|
151
|
+
+ `carries no target and serves ${GONE_STATUS}.`,
|
|
152
|
+
`status defaults to ${DEFAULT_REDIRECT_STATUS} and accepts ${REDIRECT_STATUSES.join(", ")}. A migration `
|
|
153
|
+
+ `wants ${DEFAULT_REDIRECT_STATUS}: it is what tells search engines the old URL has moved for good.`,
|
|
154
|
+
"path is normalized exactly as the published-site edge normalizes a request: a leading slash is added, "
|
|
155
|
+
+ "trailing slashes are stripped, and nothing else changes — so a legacy source keeps its suffix and "
|
|
156
|
+
+ "'/faqs.html' is a real, representable path. It carries no query string, fragment, or host.",
|
|
157
|
+
"Write a path — and the path part of a site-relative target — in the percent-encoded spelling a browser "
|
|
158
|
+
+ "sends ('/caf%C3%A9', '/old%20page'), never the raw one: the edge keys on the encoded pathname, so a raw "
|
|
159
|
+
+ "space or non-ASCII character is stored under a key no request matches, and a raw target beside an "
|
|
160
|
+
+ "encoded source is the self-loop loop detection cannot see.",
|
|
161
|
+
"target is a site-relative path such as '/classes', or an absolute credential-free http(s) URL. A "
|
|
162
|
+
+ "site-relative target keeps its trailing slash: it is a Location a browser follows, not a route key.",
|
|
163
|
+
"A path, or the path part of a site-relative target, is refused when any segment is '.' or '..', when two "
|
|
164
|
+
+ "slashes run together, or when a dot or slash is percent-encoded ('%2e', '%2f'); a target's query string "
|
|
165
|
+
+ "may carry any of those. The edge and the generator resolve "
|
|
166
|
+
+ "those away, so '/x/../visit' would be checked as itself and served as '/visit' — past the occupancy, "
|
|
167
|
+
+ "chain, and loop rules that were asked about a different path. Write the resolved path instead.",
|
|
168
|
+
"origin is reported by the site and ignored on push: 'authored' is what you declared, 'path_history' is "
|
|
169
|
+
+ "what a page or tag rename recorded. Pull the map, edit it, push the whole thing back — an entry you "
|
|
170
|
+
+ "did not change keeps its origin.",
|
|
171
|
+
"Refused, offline and again at the site, each naming the entry index: a chain (a target that is itself a "
|
|
172
|
+
+ "source in the same map); a loop (an entry targeting itself); a duplicate path; a gone entry carrying a "
|
|
173
|
+
+ "target; and a status outside the allowed set. A chain or loop is judged on the target's path alone, so "
|
|
174
|
+
+ "'/b?x=1' and '/b' are the same destination to it.",
|
|
175
|
+
"Refused by the site, which alone knows what it serves: a path a live page, Docs resource, or generated "
|
|
176
|
+
+ "resource already occupies. '/' is such a path while the site's home page is served there — and a "
|
|
177
|
+
+ "usable source once the home page has moved, which is the entry a home-page rename records.",
|
|
178
|
+
`Bounded: at most ${REDIRECT_LIMITS.entries} entries, ${REDIRECT_LIMITS.pathBytes} bytes of path, and `
|
|
179
|
+
+ `${REDIRECT_LIMITS.targetBytes} bytes of target, matching the edge's own key and value limits.`,
|
|
180
|
+
`The ${REDIRECT_LIMITS.pathBytes}-byte path bound governs what you declare, not what the site already `
|
|
181
|
+
+ "holds. A page may sit at a longer path, and renaming it records a path_history entry there that "
|
|
182
|
+
+ "'redirects pull' returns; such an entry passes 'validate' and pushes back unchanged. The site is the "
|
|
183
|
+
+ "authority on that — it alone knows whether a push introduces the path or carries it back — so a push "
|
|
184
|
+
+ "that lengthens or changes one is still refused there.",
|
|
185
|
+
`The ${REDIRECT_LIMITS.entries}-entry bound is scoped the same way: a pulled map already holding more `
|
|
186
|
+
+ "than that validates and pushes back unchanged, and only a document holding more entries than both the "
|
|
187
|
+
+ "bound and the count the last pull recorded is refused — so accumulated path history never has to be "
|
|
188
|
+
+ "deleted to make a push land.",
|
|
189
|
+
"A push whose recorded revision predates a page rename is refused as redirects.concurrent_modification. "
|
|
190
|
+
+ `Run 'redirects pull', reconcile ${REDIRECTS_FILE_NAME}, and retry; nothing was written.`,
|
|
191
|
+
"Converting an engagement's CSV into this file is the agent's job. The CLI's contract is JSON, on "
|
|
192
|
+
+ "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
|
|
193
|
+
+ "which are simply gone.",
|
|
194
|
+
"Entries take effect on the next deploy, staging first. They are written to the edge's key-value store "
|
|
195
|
+
+ "when the deploy syncs routing, and that store is eventually consistent, so a spot-check run "
|
|
196
|
+
+ "immediately afterwards can briefly still see the previous map. Re-check before concluding an entry "
|
|
197
|
+
+ "did not land.",
|
|
198
|
+
]),
|
|
199
|
+
example: Object.freeze({
|
|
200
|
+
siteId: "<site-uuid>",
|
|
201
|
+
revision: "<revision-recorded-by-redirects-pull>",
|
|
202
|
+
entries: Object.freeze([
|
|
203
|
+
Object.freeze({ path: "/faqs.html", kind: "redirect", target: "/faq", status: 301 }),
|
|
204
|
+
Object.freeze({ path: "/old-blog", kind: "redirect", target: "/journal", status: 301 }),
|
|
205
|
+
Object.freeze({
|
|
206
|
+
path: "/book",
|
|
207
|
+
kind: "redirect",
|
|
208
|
+
target: "https://booking.example.test/riverbend",
|
|
209
|
+
status: 302,
|
|
210
|
+
}),
|
|
211
|
+
Object.freeze({ path: "/summer-2019-retreat.html", kind: "gone" }),
|
|
212
|
+
]),
|
|
213
|
+
}),
|
|
214
|
+
}),
|
|
99
215
|
media: Object.freeze({
|
|
100
216
|
title: "Media upload contract",
|
|
101
217
|
summary: "Upload workspace-root-relative raster files and receive component-ready media objects.",
|
|
@@ -125,6 +241,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
125
241
|
details: Object.freeze([
|
|
126
242
|
"A page path resolves through .taproot-site-manifest.json; a canonical page UUID works directly.",
|
|
127
243
|
"The homepage's manifest path is empty; address it as '/', which resolves to that empty root path.",
|
|
244
|
+
"Preview before approving: approve consumes the draft, so an approved page has no draft left to render and answers preview.no_draft. Review it on staging after a deploy instead.",
|
|
128
245
|
"Creation prints pageId, snapshotId, and expiresAt before the bounded render wait starts.",
|
|
129
246
|
"The default stored-preview cap is 10 per authoring key and site; operators can tune it.",
|
|
130
247
|
"Success returns READY with url, snapshotId, expiresAt, storedPreviewCap, storedPreviewCount, and evictedPreviews.",
|
|
@@ -134,10 +251,100 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
134
251
|
]),
|
|
135
252
|
example: Object.freeze({ command: `${CLI_BINARY_NAME} preview page classes` }),
|
|
136
253
|
}),
|
|
254
|
+
fixture: Object.freeze({
|
|
255
|
+
title: "Offline fixture manifest contract",
|
|
256
|
+
summary:
|
|
257
|
+
`${FIXTURE_MANIFEST_FILE_NAME} binds a directory laid out like a pulled workspace to deterministic identities, `
|
|
258
|
+
+ "so validate can prove it with no credential, no network, and no write.",
|
|
259
|
+
usage: `${CLI_BINARY_NAME} validate <fixture-directory>`,
|
|
260
|
+
details: Object.freeze([
|
|
261
|
+
`Required root fields: ${FIXTURE_REQUIRED_ROOT_FIELDS.join(", ")}.`,
|
|
262
|
+
`Accepted and not read: ${FIXTURE_OPTIONAL_ROOT_FIELDS.join(", ")} — pull writes them, and neither the snapshot `
|
|
263
|
+
+ "time nor a recorded deployment can be checked offline. Any other root field is refused.",
|
|
264
|
+
`manifestVersion must be ${MANIFEST_VERSION}; fixture.contractVersion must be ${FIXTURE_CONTRACT_VERSION}.`,
|
|
265
|
+
"siteId, every pageId and resourceId, every settings entityId, and every fixture.imageIds entry is a canonical "
|
|
266
|
+
+ "lowercase UUID. They are deterministic fixture identities, not identities from a live site.",
|
|
267
|
+
`Each pages[] entry binds one editable free-form source: pageId, resourceId, path, title, templateType `
|
|
268
|
+
+ `${TEMPLATE_TYPE_FREE_FORM}, file, sourceFormat, and workspaceMode ${PAGE_WORKSPACE_MODE_EDITABLE}. `
|
|
269
|
+
+ "status, hasDraft, isGenerated, and description are recorded by pull and not read: the page key set "
|
|
270
|
+
+ "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string.",
|
|
271
|
+
`file lives under '${PAGES_DIRECTORY}/' and ends in ${PAGE_SOURCE_EXTENSIONS.join(" or ")}; sourceFormat must be `
|
|
272
|
+
+ `the format that extension implies (${
|
|
273
|
+
PAGE_SOURCE_EXTENSIONS.map((extension) => `${extension} is '${pageSourceFormat(`page${extension}`)}'`).join(
|
|
274
|
+
", ",
|
|
275
|
+
)
|
|
276
|
+
}).`,
|
|
277
|
+
"No two page entries share a pageId, resourceId, file, or path; every declared source must exist; every "
|
|
278
|
+
+ `source under '${PAGES_DIRECTORY}/' must be declared; and each entry's path must equal the path its source `
|
|
279
|
+
+ "declares — a Markdown source's front-matter 'path:' wins, and the entry's value applies only when the "
|
|
280
|
+
+ "front matter omits it.",
|
|
281
|
+
"pages must declare at least one entry, pagesTruncated must be false, and settingsSkipped must be empty: a "
|
|
282
|
+
+ "partial snapshot is not a fixture.",
|
|
283
|
+
`navigation binds { file: "${NAVIGATION_FILE_NAME}", items }, and items must equal the tree's own item count.`,
|
|
284
|
+
`redirects binds { file: "${REDIRECTS_FILE_NAME}", revision, entries }, and entries must equal the map's own `
|
|
285
|
+
+ "entry count. The revision is a deterministic 64-character lowercase hex placeholder, like every other "
|
|
286
|
+
+ "identity in a fixture: it proves the shape, never a live read. No redirect source may be a page in the "
|
|
287
|
+
+ `same fixture. See '${CLI_BINARY_NAME} help redirects' for the entry contract.`,
|
|
288
|
+
`settings binds all ${SETTINGS_GROUPS.length} authorable groups, each with settingsType, file, and entityId: ${
|
|
289
|
+
SETTINGS_GROUPS.map((group) => `${group.settingsType} to '${SETTINGS_DIRECTORY}/${group.file}'`).join("; ")
|
|
290
|
+
}.`,
|
|
291
|
+
`The fixture block carries ${FIXTURE_METADATA_FIELDS.join(", ")}. Every image a page references by imageId must `
|
|
292
|
+
+ "be listed in fixture.imageIds, and every absolute delivery URL a page uses must sit on a declared origin.",
|
|
293
|
+
`Each deliveryOrigins entry is an origin-only HTTPS URL (no path, query, fragment, or credentials) on `
|
|
294
|
+
+ `${FIXTURE_DELIVERY_ORIGIN_DOMAIN} or a subdomain of it, at most ${FIXTURE_MAXIMUM_DELIVERY_ORIGINS} of them, `
|
|
295
|
+
+ "with no duplicates. Fixtures are copied and shipped, so a real delivery host in one would be a live "
|
|
296
|
+
+ "reference in every copy.",
|
|
297
|
+
"validate reads the fixture and writes nothing to it. Copy the directory somewhere writable before editing it.",
|
|
298
|
+
]),
|
|
299
|
+
}),
|
|
137
300
|
});
|
|
138
301
|
|
|
302
|
+
// The box, rather than the value, is the cache: an absent fixture memoizes
|
|
303
|
+
// `undefined` instead of being re-read on every call.
|
|
304
|
+
let shippedFixtureManifestCache;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The shipped fixture's own manifest, read on demand and memoized, or
|
|
308
|
+
* `undefined` when this copy of the package has no readable `examples/`.
|
|
309
|
+
*
|
|
310
|
+
* The read is lazy because `examples/` ships but is not load-bearing. This
|
|
311
|
+
* module sits on the path of every verb (`bin/taproot-site.js` imports
|
|
312
|
+
* `src/cli.js`, which imports this) and of every library consumer through
|
|
313
|
+
* `src/index.js`, while `examples/` is exactly what a `node_modules` pruner or
|
|
314
|
+
* an image-slimming step drops. A static import would turn such a pruned
|
|
315
|
+
* install into an `ERR_MODULE_NOT_FOUND` at module load for `login`, `pull`,
|
|
316
|
+
* `push`, and every other verb; reading here degrades `help fixture` alone.
|
|
317
|
+
*/
|
|
318
|
+
function shippedFixtureManifest() {
|
|
319
|
+
shippedFixtureManifestCache ??= { value: readShippedFixtureManifest() };
|
|
320
|
+
return shippedFixtureManifestCache.value;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function readShippedFixtureManifest() {
|
|
324
|
+
try {
|
|
325
|
+
return JSON.parse(readFileSync(
|
|
326
|
+
new URL(`../examples/${SHIPPED_FIXTURE_NAME}/${FIXTURE_MANIFEST_FILE_NAME}`, import.meta.url),
|
|
327
|
+
"utf8",
|
|
328
|
+
));
|
|
329
|
+
} catch {
|
|
330
|
+
// Missing, unreadable, or not JSON. The contract the topic states is
|
|
331
|
+
// derived from constants and stays complete without an example, so
|
|
332
|
+
// `help fixture` answers rather than fails.
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
139
337
|
export function getWorkflowReference(topic) {
|
|
140
|
-
|
|
338
|
+
const reference = WORKFLOW_REFERENCES[topic];
|
|
339
|
+
if (topic !== "fixture") return reference;
|
|
340
|
+
const example = shippedFixtureManifest();
|
|
341
|
+
// The directory is resolved from this package rather than from the caller's
|
|
342
|
+
// directory, so the path is right for a global install as well as a
|
|
343
|
+
// checkout. Both it and the example are reported only when the fixture is
|
|
344
|
+
// actually present: a pruned install has nothing to locate or to validate.
|
|
345
|
+
return example === undefined
|
|
346
|
+
? reference
|
|
347
|
+
: Object.freeze({ ...reference, fixtureDirectory: shippedFixtureDirectory(), example });
|
|
141
348
|
}
|
|
142
349
|
|
|
143
350
|
function componentSummary(type) {
|
|
@@ -324,6 +531,8 @@ function decorationReference() {
|
|
|
324
531
|
}
|
|
325
532
|
|
|
326
533
|
const INLINE_FACTS_DEFINITION = FREE_FORM_SECTION_REGISTRY.inlineFacts;
|
|
534
|
+
// The third fact carries no label on purpose: a phone number names itself, and
|
|
535
|
+
// the row is allowed to mix labelled and standalone facts.
|
|
327
536
|
const INLINE_FACT_ITEMS_EXAMPLE = Object.freeze([
|
|
328
537
|
Object.freeze({ value: "4.9 ★", label: "Community rating" }),
|
|
329
538
|
Object.freeze({
|
|
@@ -331,7 +540,7 @@ const INLINE_FACT_ITEMS_EXAMPLE = Object.freeze([
|
|
|
331
540
|
label: "Elm Harbor",
|
|
332
541
|
url: "https://maps.example.test/?q=100%20Riverside%20Way%20Elm%20Harbor",
|
|
333
542
|
}),
|
|
334
|
-
Object.freeze({ value: "(555) 013-7788",
|
|
543
|
+
Object.freeze({ value: "(555) 013-7788", url: "tel:+15550137788" }),
|
|
335
544
|
Object.freeze({ value: "Open today until 8:30 PM", label: "Today's hours", url: "/classes" }),
|
|
336
545
|
]);
|
|
337
546
|
|
|
@@ -353,6 +562,12 @@ function inlineFactsReference() {
|
|
|
353
562
|
}),
|
|
354
563
|
}),
|
|
355
564
|
placement: Object.freeze({ ...INLINE_FACTS_DEFINITION.placement }),
|
|
565
|
+
valuePolicy:
|
|
566
|
+
`Required non-whitespace plain text; maximum ${items.fields.value.maxScalars} Unicode scalar values.`,
|
|
567
|
+
labelPolicy:
|
|
568
|
+
`Optional non-whitespace plain text; maximum ${items.fields.label.maxScalars} Unicode scalar values. `
|
|
569
|
+
+ "Omit it when the value already names itself, such as a rating, an address, or a phone number; a row may "
|
|
570
|
+
+ "mix labelled and standalone facts. An empty string is rejected, so omitting a label stays explicit.",
|
|
356
571
|
urlPolicy:
|
|
357
572
|
"Optional safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL. Empty/whitespace-only values, other schemes, backslashes, and ASCII controls are rejected; tel: remains a native link.",
|
|
358
573
|
markdown: Object.freeze({
|
|
@@ -566,9 +781,11 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
566
781
|
pull:
|
|
567
782
|
"pull keeps a tracked source that is still on disk and never writes a second editable file for the same page.",
|
|
568
783
|
internalState:
|
|
569
|
-
`${INTERNAL_PAGE_BASELINE_DIRECTORY}/<pageId>.pm.json holds the site's document for a page tracked as Markdown
|
|
570
|
-
+ "
|
|
571
|
-
+ "
|
|
784
|
+
`${INTERNAL_PAGE_BASELINE_DIRECTORY}/<pageId>.pm.json holds the site's document for a page tracked as Markdown, `
|
|
785
|
+
+ "and <pageId>.revision.json beside it records the revision a refused pull already showed you — which is what "
|
|
786
|
+
+ "lets the refusal's own recovery push proceed. Both are internal state: never discovered as a page source "
|
|
787
|
+
+ "and never pushed, and a pull that reconciles the page rewrites or removes them, so they are safe to ignore "
|
|
788
|
+
+ "in version control.",
|
|
572
789
|
formatChange:
|
|
573
790
|
"Remove the tracked source, then author the other format beside it. Two editable sources for one path is a "
|
|
574
791
|
+ "refusal, not a guess.",
|
|
@@ -582,6 +799,24 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
582
799
|
+ "refuses before changing anything under pages/ and preserves the site's version as internal state. A "
|
|
583
800
|
+ "locally edited .pm.json is kept rather than overwritten, and only collides when the site changed too.",
|
|
584
801
|
conflictError: "pages.pull_conflict",
|
|
802
|
+
conflictDetail:
|
|
803
|
+
"A reported conflict names the JSON paths at which the site's document differs from the copy this workspace "
|
|
804
|
+
+ "last reconciled with, in error.differences, which carries three states. Absent: the two documents were "
|
|
805
|
+
+ "never compared, because this workspace kept no copy of the site's previous version. Empty: they were "
|
|
806
|
+
+ "compared and the body is identical, so the change was to the page's title, path, or description. "
|
|
807
|
+
+ "Non-empty: the JSON paths at which the body differs.",
|
|
808
|
+
pushConflict:
|
|
809
|
+
"pages push fails closed on a concurrent remote edit: the site reports each page's stored-state revision, the "
|
|
810
|
+
+ "workspace records the revision of what it last wrote or pulled, and a push whose target now carries a "
|
|
811
|
+
+ "different revision is refused before any page is sent. The refusal carries both revisions in "
|
|
812
|
+
+ "error.alternatives, recorded first and live second. The guard is armed once both are known — a page this "
|
|
813
|
+
+ "workspace has never reconciled with, or a Taproot that predates the revision contract, leaves it unarmed "
|
|
814
|
+
+ "rather than refusing every push. A pull that refuses records the revision it showed you, so the pull "
|
|
815
|
+
+ "conflict's own recovery push still goes through; a page that moved again since is refused again.",
|
|
816
|
+
pushConflictError: "pages.push_conflict",
|
|
817
|
+
revisionSource:
|
|
818
|
+
"baseline.revision in the manifest, from bodyRevision on the page read, the page listing, and the create and "
|
|
819
|
+
+ "update responses. It is opaque: compare it, never derive or parse it.",
|
|
585
820
|
conflictRecovery:
|
|
586
821
|
"Either push the local source to make the site match it, or delete the local source and pull again to adopt "
|
|
587
822
|
+ "the site's document as that page's source.",
|
|
@@ -869,7 +1104,13 @@ export function formatReferenceResult(result) {
|
|
|
869
1104
|
sourceRule.renamedSource
|
|
870
1105
|
} (${sourceRule.renamedSourceError})\n conflicts ${
|
|
871
1106
|
sourceRule.conflict
|
|
872
|
-
} (${sourceRule.conflictError})\n
|
|
1107
|
+
} (${sourceRule.conflictError})\n conflict detail ${
|
|
1108
|
+
sourceRule.conflictDetail
|
|
1109
|
+
}\n push conflicts ${
|
|
1110
|
+
sourceRule.pushConflict
|
|
1111
|
+
} (${sourceRule.pushConflictError})\n revision ${
|
|
1112
|
+
sourceRule.revisionSource
|
|
1113
|
+
}\n recovery ${
|
|
873
1114
|
sourceRule.conflictRecovery
|
|
874
1115
|
}\n push selection ${sourceRule.pushSelection}\n\nSystem page projections:\n${
|
|
875
1116
|
page.workspace.systemPages.map((systemPage) =>
|
|
@@ -897,7 +1138,7 @@ export function formatReferenceResult(result) {
|
|
|
897
1138
|
page.sections.placementMatrix.map(formatPlacement).join("\n")
|
|
898
1139
|
}\n\nSemantic tables:\n placement document root or directly inside a top-level section; wide/full-well measure\n rows exactly ${page.tables.structure.headerRows} header row, then ${page.tables.limits.dataRows.min} through ${page.tables.limits.dataRows.max} data rows\n columns ${page.tables.limits.columns.min} through ${page.tables.limits.columns.max}; delimiter and every row must match the header width\n caption optional non-empty plain text; maximum ${page.tables.limits.captionScalars.max} Unicode scalar values; syntax ${page.tables.markdown.captionSyntax}\n caption guidance ${page.tables.captionGuidance}\n cells exactly ${page.tables.structure.paragraphChildrenPerCell} paragraph; headers need non-whitespace text; data cells may be empty; maximum ${page.tables.limits.cellTextScalars.max} text scalars\n inline ${page.tables.structure.inlineNodes.join(", ")}; marks ${page.tables.structure.marks.join(", ")}\n pipes outer pipes ${page.tables.markdown.outerPipes}; write a literal pipe as ${page.tables.markdown.literalPipeEscape}\n spans unsupported: ${page.tables.structure.unsupportedSpanAttrs.join(", ")}\n alignment ${page.tables.markdown.alignment}; delimiter cells contain hyphens only\n termination a blank line ends the table before another block\n\nRate table example:\n${page.tables.examples.rateTable}\n\nComparison table example:\n${page.tables.examples.comparisonTable}\n\nRejected alignment example (${CONTENT_ERROR_CODES.markdownTableAlignment}):\n${page.tables.examples.rejectedAlignment}\nRemove the ':' characters from the delimiter row.\n\nDirect ProseMirror table example:\n${JSON.stringify(page.tables.examples.proseMirror, null, 2)}\n\nTable error corrections:\n${
|
|
899
1140
|
page.tables.corrections.map((entry) => ` ${entry.code.padEnd(34)} ${entry.correction}`).join("\n")
|
|
900
|
-
}\n\nInline facts:\n placement document root or directly inside a top-level section; ${page.inlineFacts.placement.measure} measure\n items ${page.inlineFacts.attrs.items.minItems} through ${page.inlineFacts.attrs.items.maxItems}; closed objects in
|
|
1141
|
+
}\n\nInline facts:\n placement document root or directly inside a top-level section; ${page.inlineFacts.placement.measure} measure\n items ${page.inlineFacts.attrs.items.minItems} through ${page.inlineFacts.attrs.items.maxItems}; closed objects in ${page.inlineFacts.attrs.items.itemFieldOrder.join(", ")} order\n value ${page.inlineFacts.valuePolicy}\n label ${page.inlineFacts.labelPolicy}\n url ${page.inlineFacts.urlPolicy}\n Markdown fenced ${page.inlineFacts.markdown.fence} block whose body is a JSON array\n\nInline-facts Markdown example:\n${page.inlineFacts.markdown.example}\n\nDirect ProseMirror inlineFacts example:\n${JSON.stringify(page.inlineFacts.examples.proseMirror, null, 2)}\n\nRaw HTML: ${page.document.rawHtml.default} by default. For a tracked ${page.document.rawHtml.trackedProseMirror.format} document only: ${page.document.rawHtml.trackedProseMirror.behavior} Opt in with ${page.document.rawHtml.trackedProseMirror.optIn}. For ${page.document.rawHtml.markdown.format}, ${page.document.rawHtml.markdown.behavior} ${page.document.rawHtml.warning}\n\nComponents:\n${
|
|
901
1142
|
page.components.map((component) => ` ${component.type.padEnd(16)} ${component.summary}`).join("\n")
|
|
902
1143
|
}\n\nWorkflow:\n${
|
|
903
1144
|
page.workflow.map((step) => ` ${step.command}: ${step.result}`).join("\n")
|
|
@@ -915,9 +1156,20 @@ export function formatReferenceResult(result) {
|
|
|
915
1156
|
}
|
|
916
1157
|
case "workflow": {
|
|
917
1158
|
const reference = result.reference;
|
|
918
|
-
|
|
1159
|
+
// Only the fixture reference carries a shipped directory; every other
|
|
1160
|
+
// workflow topic renders exactly as it did.
|
|
1161
|
+
const shipped = reference.fixtureDirectory === undefined
|
|
1162
|
+
? ""
|
|
1163
|
+
: `\nShipped example: ${reference.fixtureDirectory}\nValidate it: ${CLI_BINARY_NAME} validate "${reference.fixtureDirectory}"\n`;
|
|
1164
|
+
// Every other topic's example is a literal in this module and is always
|
|
1165
|
+
// present; the fixture topic's is a shipped file, so it is omitted the
|
|
1166
|
+
// same way the shipped-directory lines are when the file is not there.
|
|
1167
|
+
const example = reference.example === undefined
|
|
1168
|
+
? ""
|
|
1169
|
+
: `\n\nExample:\n${JSON.stringify(reference.example, null, 2)}`;
|
|
1170
|
+
return `${reference.title}\n${reference.summary}\n\nUsage: ${reference.usage}\n${shipped}\n${
|
|
919
1171
|
reference.details.map((detail) => ` - ${detail}`).join("\n")
|
|
920
|
-
}
|
|
1172
|
+
}${example}\n`;
|
|
921
1173
|
}
|
|
922
1174
|
case "presentation":
|
|
923
1175
|
return formatPresentationReference(result.reference);
|
package/src/session.js
CHANGED
|
@@ -3,12 +3,17 @@ import {
|
|
|
3
3
|
CLI_NAME,
|
|
4
4
|
CLI_VERSION,
|
|
5
5
|
DEFAULT_API_BASE_URL,
|
|
6
|
+
EXTERNAL_WRITES_SETTING_KEY,
|
|
7
|
+
EXTERNAL_WRITES_SETTING_LOCATION,
|
|
6
8
|
PUBLISH_KEY_ENVIRONMENT_VARIABLE,
|
|
9
|
+
REFUSAL_PLATFORM_PAUSED,
|
|
7
10
|
RESULT_SCHEMA_VERSION,
|
|
11
|
+
ROLLOUT_REFUSAL_FIELD,
|
|
8
12
|
VERB_LOGIN,
|
|
9
13
|
VERB_USE,
|
|
10
14
|
} from "./constants.js";
|
|
11
15
|
import { loadSiteConfig } from "./config.js";
|
|
16
|
+
import { cliOutdatedError, compareCliVersions, isBehindLatest } from "./cli-release.js";
|
|
12
17
|
import {
|
|
13
18
|
credentialApiOrigin,
|
|
14
19
|
readCredentialStore,
|
|
@@ -16,7 +21,7 @@ import {
|
|
|
16
21
|
updateCredentialMetadata,
|
|
17
22
|
} from "./credentials.js";
|
|
18
23
|
import { SiteAuthoringError } from "./errors.js";
|
|
19
|
-
import { exchangeSiteAuthoringToken } from "./api.js";
|
|
24
|
+
import { exchangeSiteAuthoringToken, withRefusalGuidance } from "./api.js";
|
|
20
25
|
import { SiteApiClient } from "./transport.js";
|
|
21
26
|
import { readStoredApiBaseUrl } from "./settings.js";
|
|
22
27
|
|
|
@@ -38,6 +43,26 @@ import { readStoredApiBaseUrl } from "./settings.js";
|
|
|
38
43
|
/** The optional seams a test (or a future caller) may inject. */
|
|
39
44
|
const CLIENT_OPTION_KEYS = ["fetch", "signal", "sleep", "now", "timeoutSignal"];
|
|
40
45
|
|
|
46
|
+
/**
|
|
47
|
+
* What a session knows about the platform's authoring rollout switch when
|
|
48
|
+
* nothing told it (TR00692).
|
|
49
|
+
*
|
|
50
|
+
* Three states, not two, and the third is the honest one: `TAPROOT_SITE_KEY`
|
|
51
|
+
* skips the exchange entirely and no other read on the contract exposes the
|
|
52
|
+
* switch, so those runs know nothing about it. Collapsing unknown into "paused"
|
|
53
|
+
* would warn on every write against a healthy platform; collapsing it into
|
|
54
|
+
* "enabled" would claim a fact nobody supplied.
|
|
55
|
+
*/
|
|
56
|
+
const UNKNOWN_PLATFORM = Object.freeze({ externalWritesEnabled: undefined });
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What a session knows about the latest published release when nothing told it
|
|
60
|
+
* (TR00703) — the same three-state shape, and unknown for the same reasons: a
|
|
61
|
+
* `TAPROOT_SITE_KEY` run never exchanges, and a Taproot that predates the field
|
|
62
|
+
* answers with nothing.
|
|
63
|
+
*/
|
|
64
|
+
const UNKNOWN_CLI_RELEASE = Object.freeze({ latestVersion: undefined });
|
|
65
|
+
|
|
41
66
|
function progressSink(invocation) {
|
|
42
67
|
return typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
|
|
43
68
|
}
|
|
@@ -166,7 +191,19 @@ export async function openSession(invocation = {}) {
|
|
|
166
191
|
if (!config.siteId) throw siteMissingError();
|
|
167
192
|
const client = invocation.client
|
|
168
193
|
?? new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, environmentToken));
|
|
169
|
-
|
|
194
|
+
// No exchange happens on this path, and nothing else on the contract reads
|
|
195
|
+
// the platform switch — so its state is genuinely unknown here rather than
|
|
196
|
+
// assumed. Reporting it as enabled would be a guess, and reporting it as
|
|
197
|
+
// paused would warn on every write against a healthy platform.
|
|
198
|
+
return {
|
|
199
|
+
config,
|
|
200
|
+
client,
|
|
201
|
+
siteId: config.siteId,
|
|
202
|
+
platform: UNKNOWN_PLATFORM,
|
|
203
|
+
release: UNKNOWN_CLI_RELEASE,
|
|
204
|
+
now,
|
|
205
|
+
onProgress,
|
|
206
|
+
};
|
|
170
207
|
}
|
|
171
208
|
|
|
172
209
|
let config;
|
|
@@ -192,14 +229,33 @@ export async function openSession(invocation = {}) {
|
|
|
192
229
|
// A caller may supply the site client directly (tests, and the verbs that
|
|
193
230
|
// already hold one); otherwise the sign-in is exchanged for one.
|
|
194
231
|
if (invocation.client) {
|
|
195
|
-
return {
|
|
232
|
+
return {
|
|
233
|
+
config,
|
|
234
|
+
client: invocation.client,
|
|
235
|
+
siteId: config.siteId,
|
|
236
|
+
platform: UNKNOWN_PLATFORM,
|
|
237
|
+
release: UNKNOWN_CLI_RELEASE,
|
|
238
|
+
now,
|
|
239
|
+
onProgress,
|
|
240
|
+
};
|
|
196
241
|
}
|
|
197
242
|
|
|
198
243
|
const accountClient = new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, signIn.key));
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
244
|
+
// Announced here, not left to the generic failure line. The exchange is the
|
|
245
|
+
// first request every site verb makes and it sits outside each verb's own
|
|
246
|
+
// `withRefusalGuidance`, so a refusal raised by it used to reach the operator
|
|
247
|
+
// as a field name and nothing else. That matters most for `cli_outdated`,
|
|
248
|
+
// whose whole remedy is a command the operator has to be handed (TR00703),
|
|
249
|
+
// but it is true of every classified refusal the exchange can raise.
|
|
250
|
+
const exchanged = await withRefusalGuidance(
|
|
251
|
+
onProgress,
|
|
252
|
+
"sign-in token exchange",
|
|
253
|
+
async () =>
|
|
254
|
+
await exchangeSiteAuthoringToken(accountClient, {
|
|
255
|
+
siteId: config.siteId,
|
|
256
|
+
capabilities: invocation.capabilities ?? [],
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
203
259
|
// Two non-secret facts the store should now carry: the sign-in's slid
|
|
204
260
|
// deadline, and what this exchange actually produced — the latter is the only
|
|
205
261
|
// thing that lets `whoami` answer offline with the real grant rather than the
|
|
@@ -224,6 +280,20 @@ export async function openSession(invocation = {}) {
|
|
|
224
280
|
siteId: exchanged.siteId,
|
|
225
281
|
capabilities: exchanged.capabilities,
|
|
226
282
|
expiresAt: exchanged.expiresAt,
|
|
283
|
+
// When this exchange happened, so `whoami` can date what it reports
|
|
284
|
+
// rather than presenting a snapshot as current fact. Recorded for
|
|
285
|
+
// every exchange, not only the ones that saw a platform state, so the
|
|
286
|
+
// field has one meaning.
|
|
287
|
+
at: new Date(now()).toISOString(),
|
|
288
|
+
...(exchanged.externalWritesEnabled === undefined
|
|
289
|
+
? {}
|
|
290
|
+
: { externalWritesEnabled: exchanged.externalWritesEnabled }),
|
|
291
|
+
// The latest published release Taproot named (TR00703). Recorded for
|
|
292
|
+
// the offline verbs, which have no request to be refused and would
|
|
293
|
+
// otherwise keep validating against a contract that has moved.
|
|
294
|
+
...(exchanged.latestCliVersion === undefined
|
|
295
|
+
? {}
|
|
296
|
+
: { latestCliVersion: exchanged.latestCliVersion }),
|
|
227
297
|
},
|
|
228
298
|
},
|
|
229
299
|
);
|
|
@@ -242,11 +312,95 @@ export async function openSession(invocation = {}) {
|
|
|
242
312
|
expiresAt: exchanged.expiresAt,
|
|
243
313
|
capabilities: exchanged.capabilities,
|
|
244
314
|
},
|
|
315
|
+
platform: Object.freeze({ externalWritesEnabled: exchanged.externalWritesEnabled }),
|
|
316
|
+
release: Object.freeze({ latestVersion: exchanged.latestCliVersion }),
|
|
245
317
|
now,
|
|
246
318
|
onProgress,
|
|
247
319
|
};
|
|
248
320
|
}
|
|
249
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Refuses an offline verb when the last exchange said a newer CLI exists
|
|
324
|
+
* (TR00703).
|
|
325
|
+
*
|
|
326
|
+
* **Only for the verbs that make no request.** `help`, `validate`, and `whoami`
|
|
327
|
+
* are the whole set: every other verb reaches the server, and the server's own
|
|
328
|
+
* refusal is both authoritative and better informed than this recording. Gating
|
|
329
|
+
* them here would only duplicate an answer — and gating `login`, `logout`, or
|
|
330
|
+
* `env` would break the commands an operator needs *while* they are outdated.
|
|
331
|
+
*
|
|
332
|
+
* **Best effort, and silent on every uncertainty.** Nothing recorded proceeds,
|
|
333
|
+
* so a clean machine can still validate a fixture. A store this process cannot
|
|
334
|
+
* read or parse proceeds too: an unreadable file is not evidence that this CLI
|
|
335
|
+
* is behind, and turning a corrupt store into a refusal to run `help` would
|
|
336
|
+
* take away the command that explains how to fix it. Only a recorded version
|
|
337
|
+
* this side can parse and compare stops anything.
|
|
338
|
+
*
|
|
339
|
+
* The store holds one record per API origin, and the highest recorded version
|
|
340
|
+
* across all of them is what counts. npm publishes one package: whichever
|
|
341
|
+
* Taproot last answered, it was naming the same registry, so the newest answer
|
|
342
|
+
* is the current fact rather than a per-origin opinion. Taking the maximum also
|
|
343
|
+
* keeps a stale development Taproot from masking production's answer.
|
|
344
|
+
*/
|
|
345
|
+
export async function assertCliCurrent(environment = process.env) {
|
|
346
|
+
let store;
|
|
347
|
+
try {
|
|
348
|
+
store = await readCredentialStore(environment);
|
|
349
|
+
} catch {
|
|
350
|
+
// Deliberately swallowed; see above.
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
let latest;
|
|
354
|
+
for (const credential of store.credentials) {
|
|
355
|
+
const recorded = credential.lastExchange?.latestCliVersion;
|
|
356
|
+
if (recorded === undefined) continue;
|
|
357
|
+
if (latest === undefined || (compareCliVersions(recorded, latest) ?? 0) > 0) latest = recorded;
|
|
358
|
+
}
|
|
359
|
+
if (latest === undefined || !isBehindLatest(latest)) return undefined;
|
|
360
|
+
throw cliOutdatedError(latest);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The one pre-write warning: the platform has external site authoring paused,
|
|
365
|
+
* so this verb's write is going to be refused (TR00692).
|
|
366
|
+
*
|
|
367
|
+
* **Advisory, never a gate.** The value is a snapshot the token exchange took
|
|
368
|
+
* before this run did any work, and the authority is still the transactional
|
|
369
|
+
* freeze inside the write itself — so a paused platform warns and then proceeds
|
|
370
|
+
* exactly as before, and the refusal that follows keeps its `platform_paused`
|
|
371
|
+
* classification for automation to branch on. An operator who flipped the
|
|
372
|
+
* switch back on between the exchange and the write gets their write.
|
|
373
|
+
*
|
|
374
|
+
* Silent in the two cases that are not "paused": enabled, and unknown. Unknown
|
|
375
|
+
* is the `TAPROOT_SITE_KEY` path, which never exchanges and therefore was never
|
|
376
|
+
* told — see {@link UNKNOWN_PLATFORM}.
|
|
377
|
+
*
|
|
378
|
+
* **Contract.** Call it once, immediately after `openSession` and before any
|
|
379
|
+
* validation, reading, or wire work, passing the whole session object and the
|
|
380
|
+
* verb's own name (its `VERB_*` constant). It prints at most one progress line
|
|
381
|
+
* and returns whether it printed one; it never throws, never reads, and never
|
|
382
|
+
* changes what the verb does next.
|
|
383
|
+
*
|
|
384
|
+
* @param {{ platform?: { externalWritesEnabled?: boolean }, onProgress?: (message: string) => void }} session
|
|
385
|
+
* The session `openSession` returned.
|
|
386
|
+
* @param {string} [verb] The verb's display name, for the warning's first clause.
|
|
387
|
+
* @returns {boolean} Whether a warning was printed.
|
|
388
|
+
*/
|
|
389
|
+
export function warnIfExternalWritesPaused(session, verb = "This write") {
|
|
390
|
+
// The guard settles both questions: only a session object can carry `false`
|
|
391
|
+
// here, so nothing below needs to defend against a missing one.
|
|
392
|
+
if (session?.platform?.externalWritesEnabled !== false) return false;
|
|
393
|
+
const onProgress = progressSink(session);
|
|
394
|
+
onProgress(
|
|
395
|
+
`WARNING: Taproot has external site authoring paused platform-wide, so '${verb}' is expected to be refused `
|
|
396
|
+
+ `at the write (refusal=${REFUSAL_PLATFORM_PAUSED}, field=${ROLLOUT_REFUSAL_FIELD}). The credential and the `
|
|
397
|
+
+ `request are both fine. A Taproot administrator re-enables it with the platform setting `
|
|
398
|
+
+ `'${EXTERNAL_WRITES_SETTING_KEY}' (${EXTERNAL_WRITES_SETTING_LOCATION}). Continuing anyway: this is what `
|
|
399
|
+
+ "Taproot reported when this run's credential was exchanged, and the write itself is the authority.",
|
|
400
|
+
);
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
250
404
|
/**
|
|
251
405
|
* The credential-free half of the session seam, for the verbs that manage the
|
|
252
406
|
* credential rather than use it.
|