@taprootio/site-authoring 0.1.1 → 0.3.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 +26 -5
- package/examples/riverbend-wellness/manifest.fixture.json +74 -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/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 +3 -2
- package/src/api.js +28 -0
- package/src/appearance-contract.js +39 -1
- package/src/cli.js +50 -6
- package/src/constants.js +23 -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/fixture-contract.js +63 -0
- package/src/footer-contract.js +28 -30
- package/src/presentation-reference.js +31 -5
- package/src/reference-help.js +141 -6
- package/src/settings-catalog.js +5 -0
- package/src/transport.js +89 -2
- package/src/verbs/nav-push.js +10 -9
- package/src/verbs/pull.js +7 -4
- package/src/verbs/validate.js +53 -19
package/src/footer-contract.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTACT_OR_WEB_URL_MAX_LENGTH,
|
|
3
|
+
CONTACT_OR_WEB_URL_REQUIREMENT,
|
|
4
|
+
isContactOrWebUrl,
|
|
5
|
+
} from "./contact-url.js";
|
|
1
6
|
import { hasControlCharacter, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
|
|
2
7
|
import { FOOTER_COLOR_TOKENS, requireAppearanceColor } from "./theme-validation.js";
|
|
3
8
|
|
|
@@ -15,7 +20,7 @@ export const FOOTER_LIMITS = Object.freeze({
|
|
|
15
20
|
linkLabelLength: 120,
|
|
16
21
|
bottomTextLength: 500,
|
|
17
22
|
featureImageAltLength: 300,
|
|
18
|
-
externalUrlLength:
|
|
23
|
+
externalUrlLength: CONTACT_OR_WEB_URL_MAX_LENGTH,
|
|
19
24
|
backgroundRepeatHeightMinimum: 64,
|
|
20
25
|
backgroundRepeatHeightMaximum: 512,
|
|
21
26
|
backgroundRepeatHeightStep: 16,
|
|
@@ -198,33 +203,17 @@ function stableId(value, path, seen, label) {
|
|
|
198
203
|
return value;
|
|
199
204
|
}
|
|
200
205
|
|
|
206
|
+
// A `tel:` or `mailto:` target is returned exactly as authored: the number's
|
|
207
|
+
// punctuation and the address's plus-tag are content, and `URL.toString()`
|
|
208
|
+
// re-encodes both. Only the whitespace the server would strip is removed.
|
|
201
209
|
function safeExternalUrl(value, path, tolerateStoredValues) {
|
|
202
210
|
if (typeof value !== "string") {
|
|
203
|
-
fail("footer.url_invalid", `${path}
|
|
211
|
+
fail("footer.url_invalid", `${path} ${CONTACT_OR_WEB_URL_REQUIREMENT}.`, path);
|
|
204
212
|
}
|
|
205
213
|
const normalized = trimLikeServer(value);
|
|
206
214
|
if (tolerateStoredValues) return normalized;
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|| normalized.length > FOOTER_LIMITS.externalUrlLength
|
|
210
|
-
|| hasControlCharacter(normalized)
|
|
211
|
-
|| normalized.includes("\\")
|
|
212
|
-
) {
|
|
213
|
-
fail("footer.url_invalid", `${path} must be a bounded absolute http or https URL.`, path);
|
|
214
|
-
}
|
|
215
|
-
let parsed;
|
|
216
|
-
try {
|
|
217
|
-
parsed = new URL(normalized);
|
|
218
|
-
} catch {
|
|
219
|
-
fail("footer.url_invalid", `${path} must be an absolute http or https URL.`, path);
|
|
220
|
-
}
|
|
221
|
-
if (
|
|
222
|
-
(parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
223
|
-
|| parsed.username !== ""
|
|
224
|
-
|| parsed.password !== ""
|
|
225
|
-
|| parsed.hostname === ""
|
|
226
|
-
) {
|
|
227
|
-
fail("footer.url_invalid", `${path} must use http or https without embedded credentials.`, path);
|
|
215
|
+
if (!isContactOrWebUrl(normalized, FOOTER_LIMITS.externalUrlLength)) {
|
|
216
|
+
fail("footer.url_invalid", `${path} ${CONTACT_OR_WEB_URL_REQUIREMENT}.`, path);
|
|
228
217
|
}
|
|
229
218
|
return normalized;
|
|
230
219
|
}
|
|
@@ -618,16 +607,25 @@ export function footerImageIds(value) {
|
|
|
618
607
|
export const FOOTER_EXAMPLE = Object.freeze({
|
|
619
608
|
enabled: true,
|
|
620
609
|
showBrand: true,
|
|
610
|
+
// The aside CTA is where a local business puts the thing a visitor came for.
|
|
611
|
+
// A phone number is a `tel:` target, emitted verbatim so it stays dialable.
|
|
621
612
|
asideCta: Object.freeze({
|
|
622
613
|
id: "103e726d-3152-49c8-bf14-c947b1bd8a14",
|
|
623
|
-
label: "
|
|
624
|
-
externalUrl: "
|
|
614
|
+
label: "Call the studio",
|
|
615
|
+
externalUrl: "tel:+15555550123",
|
|
625
616
|
}),
|
|
626
|
-
bottomLinks: Object.freeze([
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
617
|
+
bottomLinks: Object.freeze([
|
|
618
|
+
Object.freeze({
|
|
619
|
+
id: "28363e08-b341-42fc-bc83-8dd73fa87904",
|
|
620
|
+
label: "Privacy",
|
|
621
|
+
externalUrl: "https://example.com/privacy",
|
|
622
|
+
}),
|
|
623
|
+
Object.freeze({
|
|
624
|
+
id: "9e2d1f04-4bb0-4a2d-9d1a-6d3b0d0a4c77",
|
|
625
|
+
label: "Email us",
|
|
626
|
+
externalUrl: "mailto:hello@example.com",
|
|
627
|
+
}),
|
|
628
|
+
]),
|
|
631
629
|
light: Object.freeze({
|
|
632
630
|
backgroundColor: "--esp-color-layer-1",
|
|
633
631
|
textColor: "--esp-color-text",
|
|
@@ -57,6 +57,12 @@ function getEspalierVersion() {
|
|
|
57
57
|
|
|
58
58
|
const OPTIONAL_THEME_FIELDS = Object.freeze([
|
|
59
59
|
"explicitMappingTokens",
|
|
60
|
+
// Espalier 4.7.0 menu typography: documented so the theme help covers the
|
|
61
|
+
// installed contract, optional so pulled baselines that predate it stay
|
|
62
|
+
// complete (the server seeds and requires the fields listed in
|
|
63
|
+
// DefaultSiteTheme.RequiredProperties only).
|
|
64
|
+
"fontMenu",
|
|
65
|
+
"fontWeightMenu",
|
|
60
66
|
"boxBackgroundImage",
|
|
61
67
|
"boxBackgroundImageOpacity",
|
|
62
68
|
"vellumOpacity",
|
|
@@ -201,8 +207,12 @@ const THEME_GROUPS = Object.freeze([
|
|
|
201
207
|
...["fontBody", "fontHeadings", "fontBrand", "fontMonospace"].map((name) =>
|
|
202
208
|
themeField(name, "CSS font-family; empty inherits the consuming surface's fallback.")
|
|
203
209
|
),
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
themeField(
|
|
211
|
+
"fontMenu",
|
|
212
|
+
"CSS font-family for navigation menu items and group labels; empty falls back to fontBody, never to headings.",
|
|
213
|
+
),
|
|
214
|
+
...["fontWeightBody", "fontWeightHeadings", "fontWeightBrand", "fontWeightMonospace", "fontWeightMenu"].map(
|
|
215
|
+
(name) => themeField(name, "CSS font-weight: 1–1000, normal/bold/lighter/bolder, or a CSS-wide keyword."),
|
|
206
216
|
),
|
|
207
217
|
themeField("rootFontSize", "Root font size in px.", { minimum: 1, warningMaximum: 100 }),
|
|
208
218
|
themeField("typeRatio", "Modular type-scale ratio.", { exclusiveMinimum: 1, warningMaximum: 1.3 }),
|
|
@@ -330,6 +340,18 @@ export function getAppearanceReference() {
|
|
|
330
340
|
"Use site-owned image ids retained from pull or returned by media upload. URLs are server projections, never authored inputs.",
|
|
331
341
|
logoContract:
|
|
332
342
|
"lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting.",
|
|
343
|
+
headerWidthContract:
|
|
344
|
+
"headerWidth 'contained' keeps the brand and the header buttons at the page content edges; "
|
|
345
|
+
+ "'wide' moves them to the viewport edges. Pages with root-band components (image-banner) span the viewport, "
|
|
346
|
+
+ "so full-bleed designs usually pair headerWidth 'wide' with headerLayout 'centered-menu', which keeps the "
|
|
347
|
+
+ "brand left, the buttons right, and the navigation centered. validate prints a hint, never a failure, when a "
|
|
348
|
+
+ "root-band page ships with a contained header.",
|
|
349
|
+
menuContract:
|
|
350
|
+
"navDrawerStyle 'full-screen' (the published default) opens the mobile menu over the whole viewport with the "
|
|
351
|
+
+ "brand centered above large centered items and navDrawerTransition choosing how it appears (fade, slide-down, "
|
|
352
|
+
+ "slide-up, slide-left, or slide-right); 'panel' keeps the side panel, which exists for application-style "
|
|
353
|
+
+ "menus with many groups. fontMenu and fontWeightMenu set the navigation face the way fontBrand sets the "
|
|
354
|
+
+ "brand; an empty fontMenu falls back to the body font, never to headings.",
|
|
333
355
|
mutationOrder: [
|
|
334
356
|
"fresh footer read plus concurrency-protected ten-color overlay",
|
|
335
357
|
"default scheme, brand fonts and assets, favicon, and header scalars",
|
|
@@ -390,7 +412,7 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
|
|
|
390
412
|
"FooterLink.label": { type: "plain-text", required: true, maximumLength: FOOTER_LIMITS.linkLabelLength },
|
|
391
413
|
"FooterLink.pageResourceId": { type: "tracked-page-resource-uuid", exclusiveWith: "externalUrl" },
|
|
392
414
|
"FooterLink.externalUrl": {
|
|
393
|
-
type: "
|
|
415
|
+
type: "contact-or-web-url",
|
|
394
416
|
exclusiveWith: "pageResourceId",
|
|
395
417
|
maximumLength: FOOTER_LIMITS.externalUrlLength,
|
|
396
418
|
},
|
|
@@ -403,7 +425,7 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
|
|
|
403
425
|
"FooterRichTextRun.link": { type: "FooterInlineLink|null", default: null },
|
|
404
426
|
"FooterInlineLink.pageResourceId": { type: "tracked-page-resource-uuid", exclusiveWith: "externalUrl" },
|
|
405
427
|
"FooterInlineLink.externalUrl": {
|
|
406
|
-
type: "
|
|
428
|
+
type: "contact-or-web-url",
|
|
407
429
|
exclusiveWith: "pageResourceId",
|
|
408
430
|
maximumLength: FOOTER_LIMITS.externalUrlLength,
|
|
409
431
|
},
|
|
@@ -485,7 +507,9 @@ export function getFooterReference() {
|
|
|
485
507
|
totalGroupMaximum: FOOTER_LIMITS.linkGroups,
|
|
486
508
|
totalRichTextRunsMaximum: FOOTER_LIMITS.richTextRuns,
|
|
487
509
|
targetRule:
|
|
488
|
-
"Each link has exactly one target: a tracked pageResourceId, or an
|
|
510
|
+
"Each link has exactly one target: a tracked pageResourceId, or an externalUrl that is an absolute "
|
|
511
|
+
+ "credential-free http/https URL, or a mailto:/tel: contact URL such as tel:+15555550123. A contact URL is "
|
|
512
|
+
+ "published exactly as authored, so write the number the way it should be dialled.",
|
|
489
513
|
imageRule:
|
|
490
514
|
"Image ids come from pull or media upload. Feature images require alt text; background art is decorative.",
|
|
491
515
|
readOnlyProjections: FOOTER_READ_ONLY_FIELDS,
|
|
@@ -577,6 +601,8 @@ export function formatPresentationReference(reference) {
|
|
|
577
601
|
+ "\n\nRead-only server projections:\n"
|
|
578
602
|
+ reference.readOnlyProjections.map((field) => " " + field.file + " :: " + field.path).join("\n")
|
|
579
603
|
+ "\n\nImages: " + reference.imageReferences + "\nLogos: " + reference.logoContract
|
|
604
|
+
+ "\nHeader width: " + reference.headerWidthContract
|
|
605
|
+
+ "\nMobile menu and menu font: " + reference.menuContract
|
|
580
606
|
+ "\nFooter: " + reference.footerBoundary
|
|
581
607
|
+ "\nFooter content guard: " + reference.footerContentGuard + "\n\nMutation order (non-atomic):\n"
|
|
582
608
|
+ reference.mutationOrder.map((step, index) => " " + (index + 1) + ". " + step).join("\n")
|
package/src/reference-help.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
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 { SETTINGS_GROUPS } from "./settings-catalog.js";
|
|
2
17
|
import {
|
|
3
18
|
canonicalizeComponentData,
|
|
4
19
|
COMPONENT_TYPES,
|
|
@@ -20,14 +35,21 @@ import {
|
|
|
20
35
|
} from "./presentation-reference.js";
|
|
21
36
|
import {
|
|
22
37
|
INTERNAL_PAGE_BASELINE_DIRECTORY,
|
|
38
|
+
MANIFEST_VERSION,
|
|
39
|
+
NAVIGATION_FILE_NAME,
|
|
23
40
|
PAGE_READ_ONLY_REASON_SYSTEM_404,
|
|
41
|
+
PAGE_SOURCE_EXTENSIONS,
|
|
42
|
+
PAGE_WORKSPACE_MODE_EDITABLE,
|
|
24
43
|
PAGE_WORKSPACE_MODE_READ_ONLY,
|
|
44
|
+
pageSourceFormat,
|
|
45
|
+
PAGES_DIRECTORY,
|
|
46
|
+
SETTINGS_DIRECTORY,
|
|
25
47
|
SYSTEM_PAGE_NOT_FOUND_PATH,
|
|
26
48
|
} from "./workspace.js";
|
|
27
49
|
|
|
28
50
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
29
51
|
|
|
30
|
-
export const REFERENCE_VERSION =
|
|
52
|
+
export const REFERENCE_VERSION = 16;
|
|
31
53
|
export const PAGE_TYPES = Object.freeze(["free-form"]);
|
|
32
54
|
export const REFERENCE_TOPICS = Object.freeze([
|
|
33
55
|
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types." }),
|
|
@@ -72,6 +94,11 @@ export const REFERENCE_TOPICS = Object.freeze([
|
|
|
72
94
|
usage: `${CLI_BINARY_NAME} help footer`,
|
|
73
95
|
summary: "Author and concurrency-safely push the complete footer document.",
|
|
74
96
|
}),
|
|
97
|
+
Object.freeze({
|
|
98
|
+
name: "fixture",
|
|
99
|
+
usage: `${CLI_BINARY_NAME} help fixture`,
|
|
100
|
+
summary: "State the offline fixture manifest contract and locate the shipped example.",
|
|
101
|
+
}),
|
|
75
102
|
]);
|
|
76
103
|
|
|
77
104
|
const WORKFLOW_REFERENCES = Object.freeze({
|
|
@@ -84,6 +111,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
84
111
|
"kind is NAV_ITEM_KIND_PAGE, NAV_ITEM_KIND_EXTERNAL_URL, or NAV_ITEM_KIND_GROUP_HEADER.",
|
|
85
112
|
"PAGE uses resourceId from .taproot-site-manifest.json, never pageId or path.",
|
|
86
113
|
"EXTERNAL_URL uses externalUrl; GROUP_HEADER has no target; every kind may carry children.",
|
|
114
|
+
"externalUrl is an absolute credential-free http/https URL, or a mailto:/tel: contact URL such as "
|
|
115
|
+
+ "tel:+15555550123, published exactly as authored.",
|
|
87
116
|
]),
|
|
88
117
|
example: Object.freeze({
|
|
89
118
|
siteId: "<site-uuid>",
|
|
@@ -125,6 +154,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
125
154
|
details: Object.freeze([
|
|
126
155
|
"A page path resolves through .taproot-site-manifest.json; a canonical page UUID works directly.",
|
|
127
156
|
"The homepage's manifest path is empty; address it as '/', which resolves to that empty root path.",
|
|
157
|
+
"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
158
|
"Creation prints pageId, snapshotId, and expiresAt before the bounded render wait starts.",
|
|
129
159
|
"The default stored-preview cap is 10 per authoring key and site; operators can tune it.",
|
|
130
160
|
"Success returns READY with url, snapshotId, expiresAt, storedPreviewCap, storedPreviewCount, and evictedPreviews.",
|
|
@@ -134,10 +164,96 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
134
164
|
]),
|
|
135
165
|
example: Object.freeze({ command: `${CLI_BINARY_NAME} preview page classes` }),
|
|
136
166
|
}),
|
|
167
|
+
fixture: Object.freeze({
|
|
168
|
+
title: "Offline fixture manifest contract",
|
|
169
|
+
summary:
|
|
170
|
+
`${FIXTURE_MANIFEST_FILE_NAME} binds a directory laid out like a pulled workspace to deterministic identities, `
|
|
171
|
+
+ "so validate can prove it with no credential, no network, and no write.",
|
|
172
|
+
usage: `${CLI_BINARY_NAME} validate <fixture-directory>`,
|
|
173
|
+
details: Object.freeze([
|
|
174
|
+
`Required root fields: ${FIXTURE_REQUIRED_ROOT_FIELDS.join(", ")}.`,
|
|
175
|
+
`Accepted and not read: ${FIXTURE_OPTIONAL_ROOT_FIELDS.join(", ")} — pull writes them, and neither the snapshot `
|
|
176
|
+
+ "time nor a recorded deployment can be checked offline. Any other root field is refused.",
|
|
177
|
+
`manifestVersion must be ${MANIFEST_VERSION}; fixture.contractVersion must be ${FIXTURE_CONTRACT_VERSION}.`,
|
|
178
|
+
"siteId, every pageId and resourceId, every settings entityId, and every fixture.imageIds entry is a canonical "
|
|
179
|
+
+ "lowercase UUID. They are deterministic fixture identities, not identities from a live site.",
|
|
180
|
+
`Each pages[] entry binds one editable free-form source: pageId, resourceId, path, title, templateType `
|
|
181
|
+
+ `${TEMPLATE_TYPE_FREE_FORM}, file, sourceFormat, and workspaceMode ${PAGE_WORKSPACE_MODE_EDITABLE}. `
|
|
182
|
+
+ "status, hasDraft, isGenerated, and description are recorded by pull and not read: the page key set "
|
|
183
|
+
+ "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string.",
|
|
184
|
+
`file lives under '${PAGES_DIRECTORY}/' and ends in ${PAGE_SOURCE_EXTENSIONS.join(" or ")}; sourceFormat must be `
|
|
185
|
+
+ `the format that extension implies (${
|
|
186
|
+
PAGE_SOURCE_EXTENSIONS.map((extension) => `${extension} is '${pageSourceFormat(`page${extension}`)}'`).join(
|
|
187
|
+
", ",
|
|
188
|
+
)
|
|
189
|
+
}).`,
|
|
190
|
+
"No two page entries share a pageId, resourceId, file, or path; every declared source must exist; every "
|
|
191
|
+
+ `source under '${PAGES_DIRECTORY}/' must be declared; and each entry's path must equal the path its source `
|
|
192
|
+
+ "declares — a Markdown source's front-matter 'path:' wins, and the entry's value applies only when the "
|
|
193
|
+
+ "front matter omits it.",
|
|
194
|
+
"pages must declare at least one entry, pagesTruncated must be false, and settingsSkipped must be empty: a "
|
|
195
|
+
+ "partial snapshot is not a fixture.",
|
|
196
|
+
`navigation binds { file: "${NAVIGATION_FILE_NAME}", items }, and items must equal the tree's own item count.`,
|
|
197
|
+
`settings binds all ${SETTINGS_GROUPS.length} authorable groups, each with settingsType, file, and entityId: ${
|
|
198
|
+
SETTINGS_GROUPS.map((group) => `${group.settingsType} to '${SETTINGS_DIRECTORY}/${group.file}'`).join("; ")
|
|
199
|
+
}.`,
|
|
200
|
+
`The fixture block carries ${FIXTURE_METADATA_FIELDS.join(", ")}. Every image a page references by imageId must `
|
|
201
|
+
+ "be listed in fixture.imageIds, and every absolute delivery URL a page uses must sit on a declared origin.",
|
|
202
|
+
`Each deliveryOrigins entry is an origin-only HTTPS URL (no path, query, fragment, or credentials) on `
|
|
203
|
+
+ `${FIXTURE_DELIVERY_ORIGIN_DOMAIN} or a subdomain of it, at most ${FIXTURE_MAXIMUM_DELIVERY_ORIGINS} of them, `
|
|
204
|
+
+ "with no duplicates. Fixtures are copied and shipped, so a real delivery host in one would be a live "
|
|
205
|
+
+ "reference in every copy.",
|
|
206
|
+
"validate reads the fixture and writes nothing to it. Copy the directory somewhere writable before editing it.",
|
|
207
|
+
]),
|
|
208
|
+
}),
|
|
137
209
|
});
|
|
138
210
|
|
|
211
|
+
// The box, rather than the value, is the cache: an absent fixture memoizes
|
|
212
|
+
// `undefined` instead of being re-read on every call.
|
|
213
|
+
let shippedFixtureManifestCache;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The shipped fixture's own manifest, read on demand and memoized, or
|
|
217
|
+
* `undefined` when this copy of the package has no readable `examples/`.
|
|
218
|
+
*
|
|
219
|
+
* The read is lazy because `examples/` ships but is not load-bearing. This
|
|
220
|
+
* module sits on the path of every verb (`bin/taproot-site.js` imports
|
|
221
|
+
* `src/cli.js`, which imports this) and of every library consumer through
|
|
222
|
+
* `src/index.js`, while `examples/` is exactly what a `node_modules` pruner or
|
|
223
|
+
* an image-slimming step drops. A static import would turn such a pruned
|
|
224
|
+
* install into an `ERR_MODULE_NOT_FOUND` at module load for `login`, `pull`,
|
|
225
|
+
* `push`, and every other verb; reading here degrades `help fixture` alone.
|
|
226
|
+
*/
|
|
227
|
+
function shippedFixtureManifest() {
|
|
228
|
+
shippedFixtureManifestCache ??= { value: readShippedFixtureManifest() };
|
|
229
|
+
return shippedFixtureManifestCache.value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readShippedFixtureManifest() {
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(readFileSync(
|
|
235
|
+
new URL(`../examples/${SHIPPED_FIXTURE_NAME}/${FIXTURE_MANIFEST_FILE_NAME}`, import.meta.url),
|
|
236
|
+
"utf8",
|
|
237
|
+
));
|
|
238
|
+
} catch {
|
|
239
|
+
// Missing, unreadable, or not JSON. The contract the topic states is
|
|
240
|
+
// derived from constants and stays complete without an example, so
|
|
241
|
+
// `help fixture` answers rather than fails.
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
139
246
|
export function getWorkflowReference(topic) {
|
|
140
|
-
|
|
247
|
+
const reference = WORKFLOW_REFERENCES[topic];
|
|
248
|
+
if (topic !== "fixture") return reference;
|
|
249
|
+
const example = shippedFixtureManifest();
|
|
250
|
+
// The directory is resolved from this package rather than from the caller's
|
|
251
|
+
// directory, so the path is right for a global install as well as a
|
|
252
|
+
// checkout. Both it and the example are reported only when the fixture is
|
|
253
|
+
// actually present: a pruned install has nothing to locate or to validate.
|
|
254
|
+
return example === undefined
|
|
255
|
+
? reference
|
|
256
|
+
: Object.freeze({ ...reference, fixtureDirectory: shippedFixtureDirectory(), example });
|
|
141
257
|
}
|
|
142
258
|
|
|
143
259
|
function componentSummary(type) {
|
|
@@ -324,6 +440,8 @@ function decorationReference() {
|
|
|
324
440
|
}
|
|
325
441
|
|
|
326
442
|
const INLINE_FACTS_DEFINITION = FREE_FORM_SECTION_REGISTRY.inlineFacts;
|
|
443
|
+
// The third fact carries no label on purpose: a phone number names itself, and
|
|
444
|
+
// the row is allowed to mix labelled and standalone facts.
|
|
327
445
|
const INLINE_FACT_ITEMS_EXAMPLE = Object.freeze([
|
|
328
446
|
Object.freeze({ value: "4.9 ★", label: "Community rating" }),
|
|
329
447
|
Object.freeze({
|
|
@@ -331,7 +449,7 @@ const INLINE_FACT_ITEMS_EXAMPLE = Object.freeze([
|
|
|
331
449
|
label: "Elm Harbor",
|
|
332
450
|
url: "https://maps.example.test/?q=100%20Riverside%20Way%20Elm%20Harbor",
|
|
333
451
|
}),
|
|
334
|
-
Object.freeze({ value: "(555) 013-7788",
|
|
452
|
+
Object.freeze({ value: "(555) 013-7788", url: "tel:+15550137788" }),
|
|
335
453
|
Object.freeze({ value: "Open today until 8:30 PM", label: "Today's hours", url: "/classes" }),
|
|
336
454
|
]);
|
|
337
455
|
|
|
@@ -353,6 +471,12 @@ function inlineFactsReference() {
|
|
|
353
471
|
}),
|
|
354
472
|
}),
|
|
355
473
|
placement: Object.freeze({ ...INLINE_FACTS_DEFINITION.placement }),
|
|
474
|
+
valuePolicy:
|
|
475
|
+
`Required non-whitespace plain text; maximum ${items.fields.value.maxScalars} Unicode scalar values.`,
|
|
476
|
+
labelPolicy:
|
|
477
|
+
`Optional non-whitespace plain text; maximum ${items.fields.label.maxScalars} Unicode scalar values. `
|
|
478
|
+
+ "Omit it when the value already names itself, such as a rating, an address, or a phone number; a row may "
|
|
479
|
+
+ "mix labelled and standalone facts. An empty string is rejected, so omitting a label stays explicit.",
|
|
356
480
|
urlPolicy:
|
|
357
481
|
"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
482
|
markdown: Object.freeze({
|
|
@@ -897,7 +1021,7 @@ export function formatReferenceResult(result) {
|
|
|
897
1021
|
page.sections.placementMatrix.map(formatPlacement).join("\n")
|
|
898
1022
|
}\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
1023
|
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
|
|
1024
|
+
}\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
1025
|
page.components.map((component) => ` ${component.type.padEnd(16)} ${component.summary}`).join("\n")
|
|
902
1026
|
}\n\nWorkflow:\n${
|
|
903
1027
|
page.workflow.map((step) => ` ${step.command}: ${step.result}`).join("\n")
|
|
@@ -915,9 +1039,20 @@ export function formatReferenceResult(result) {
|
|
|
915
1039
|
}
|
|
916
1040
|
case "workflow": {
|
|
917
1041
|
const reference = result.reference;
|
|
918
|
-
|
|
1042
|
+
// Only the fixture reference carries a shipped directory; every other
|
|
1043
|
+
// workflow topic renders exactly as it did.
|
|
1044
|
+
const shipped = reference.fixtureDirectory === undefined
|
|
1045
|
+
? ""
|
|
1046
|
+
: `\nShipped example: ${reference.fixtureDirectory}\nValidate it: ${CLI_BINARY_NAME} validate "${reference.fixtureDirectory}"\n`;
|
|
1047
|
+
// Every other topic's example is a literal in this module and is always
|
|
1048
|
+
// present; the fixture topic's is a shipped file, so it is omitted the
|
|
1049
|
+
// same way the shipped-directory lines are when the file is not there.
|
|
1050
|
+
const example = reference.example === undefined
|
|
1051
|
+
? ""
|
|
1052
|
+
: `\n\nExample:\n${JSON.stringify(reference.example, null, 2)}`;
|
|
1053
|
+
return `${reference.title}\n${reference.summary}\n\nUsage: ${reference.usage}\n${shipped}\n${
|
|
919
1054
|
reference.details.map((detail) => ` - ${detail}`).join("\n")
|
|
920
|
-
}
|
|
1055
|
+
}${example}\n`;
|
|
921
1056
|
}
|
|
922
1057
|
case "presentation":
|
|
923
1058
|
return formatPresentationReference(result.reference);
|
package/src/settings-catalog.js
CHANGED
|
@@ -58,6 +58,8 @@ export const SETTINGS_GROUPS = Object.freeze([
|
|
|
58
58
|
"defaultScheme",
|
|
59
59
|
"fontBrand",
|
|
60
60
|
"fontWeightBrand",
|
|
61
|
+
"fontMenu",
|
|
62
|
+
"fontWeightMenu",
|
|
61
63
|
"lightLogoId",
|
|
62
64
|
"lightLogoUrl",
|
|
63
65
|
"darkLogoId",
|
|
@@ -86,6 +88,9 @@ export const SETTINGS_GROUPS = Object.freeze([
|
|
|
86
88
|
fields: Object.freeze([
|
|
87
89
|
...stringFields(
|
|
88
90
|
"headerLayout",
|
|
91
|
+
"headerWidth",
|
|
92
|
+
"navDrawerStyle",
|
|
93
|
+
"navDrawerTransition",
|
|
89
94
|
"brandText",
|
|
90
95
|
"brandColor",
|
|
91
96
|
"logoAlt",
|
package/src/transport.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CAPABILITY_REFUSAL_FIELD,
|
|
3
|
+
CAPABILITY_REFUSAL_REASON,
|
|
2
4
|
CLI_NAME,
|
|
3
5
|
CLI_VERSION,
|
|
4
6
|
CREDENTIAL_REFUSAL_FIELD,
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
LIMITS,
|
|
10
12
|
PLAN_LIMIT_REFUSAL_FIELD,
|
|
11
13
|
PUBLISH_KEY_ENVIRONMENT_VARIABLE,
|
|
14
|
+
REFUSAL_CAPABILITY_MISSING,
|
|
12
15
|
REFUSAL_CREDENTIAL_REJECTED,
|
|
13
16
|
REFUSAL_PLAN_LIMIT,
|
|
14
17
|
REFUSAL_PLATFORM_PAUSED,
|
|
@@ -330,21 +333,95 @@ function fieldDescriptions(value) {
|
|
|
330
333
|
return descriptions;
|
|
331
334
|
}
|
|
332
335
|
|
|
336
|
+
/**
|
|
337
|
+
* A delegation capability or permission key as the server spells it. Anything
|
|
338
|
+
* outside this shape is dropped rather than repaired: these values reach a
|
|
339
|
+
* terminal and a machine-readable result, and a name the CLI cannot vouch for
|
|
340
|
+
* is worse than a name it does not print.
|
|
341
|
+
*/
|
|
342
|
+
const CAPABILITY_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/u;
|
|
343
|
+
const MAXIMUM_CAPABILITY_NAMES = 16;
|
|
344
|
+
|
|
345
|
+
function capabilityNames(value) {
|
|
346
|
+
if (typeof value !== "string" || value === "") return [];
|
|
347
|
+
return value
|
|
348
|
+
.split(",")
|
|
349
|
+
.map((name) => name.trim())
|
|
350
|
+
.filter((name) => name.length <= 200 && CAPABILITY_NAME.test(name))
|
|
351
|
+
.slice(0, MAXIMUM_CAPABILITY_NAMES);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The key-mode capability denial the authoring API attaches as a
|
|
356
|
+
* `google.rpc.ErrorInfo` detail (TR00691), or undefined.
|
|
357
|
+
*
|
|
358
|
+
* The same bounded breadth-first walk the field extractors use, for the same
|
|
359
|
+
* reason: transcoded `details[]` entries are `Any`-packed, and pinning one path
|
|
360
|
+
* through them is how a contract change silently empties an extractor. The
|
|
361
|
+
* `reason` is the contract — a stable, namespaced classifier — so the walk
|
|
362
|
+
* keys on it rather than on `@type`, which a future packing could reshape.
|
|
363
|
+
*/
|
|
364
|
+
export function capabilityRefusal(value) {
|
|
365
|
+
const queue = [{ value, depth: 0 }];
|
|
366
|
+
let work = 0;
|
|
367
|
+
while (queue.length > 0 && work < 1_000) {
|
|
368
|
+
const current = queue.shift();
|
|
369
|
+
work += 1;
|
|
370
|
+
if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
|
|
371
|
+
const metadata = current.value.metadata;
|
|
372
|
+
if (
|
|
373
|
+
current.value.reason === CAPABILITY_REFUSAL_REASON
|
|
374
|
+
&& metadata !== null
|
|
375
|
+
&& typeof metadata === "object"
|
|
376
|
+
&& !Array.isArray(metadata)
|
|
377
|
+
) {
|
|
378
|
+
const permission = capabilityNames(metadata.permission)[0];
|
|
379
|
+
// A detail naming no permission is not this refusal, whatever it says:
|
|
380
|
+
// the guidance is built entirely out of these three values, and half of
|
|
381
|
+
// one would be a message nobody could act on.
|
|
382
|
+
if (permission !== undefined) {
|
|
383
|
+
return Object.freeze({
|
|
384
|
+
permission,
|
|
385
|
+
granted: Object.freeze(capabilityNames(metadata.granted)),
|
|
386
|
+
required: Object.freeze(capabilityNames(metadata.required)),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const child of Object.values(current.value)) {
|
|
391
|
+
if (child !== null && typeof child === "object") queue.push({ value: child, depth: current.depth + 1 });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
333
397
|
export class ApiError extends SiteAuthoringError {
|
|
334
398
|
constructor(httpStatus, body) {
|
|
335
399
|
const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
|
|
336
400
|
const fields = fieldViolations(body);
|
|
401
|
+
const capability = capabilityRefusal(body);
|
|
337
402
|
super(
|
|
338
403
|
"api.request_rejected",
|
|
339
404
|
fields.length > 0
|
|
340
405
|
? `Taproot rejected the request field '${fields[0]}'.`
|
|
406
|
+
: capability !== undefined
|
|
407
|
+
? `Taproot refused the request: this credential does not carry a capability granting '${
|
|
408
|
+
capability.permission
|
|
409
|
+
}'.`
|
|
341
410
|
: `Taproot rejected the request with HTTP ${httpStatus}.`,
|
|
342
|
-
{
|
|
411
|
+
{
|
|
412
|
+
// `fields` stays exactly what the wire named. The capability field is
|
|
413
|
+
// the CLI's own label for a refusal that carries no field violation at
|
|
414
|
+
// all, so it lands on the error without joining the wire-derived set
|
|
415
|
+
// that `hasField()` classifies on.
|
|
416
|
+
field: fields[0] ?? (capability === undefined ? undefined : CAPABILITY_REFUSAL_FIELD),
|
|
417
|
+
status: grpcCode === undefined ? `http:${httpStatus}` : `grpc:${grpcCode}`,
|
|
418
|
+
},
|
|
343
419
|
);
|
|
344
420
|
this.httpStatus = httpStatus;
|
|
345
421
|
this.grpcCode = grpcCode;
|
|
346
422
|
this.fields = fields;
|
|
347
423
|
this.violationDescriptions = fieldDescriptions(body);
|
|
424
|
+
this.capability = capability;
|
|
348
425
|
}
|
|
349
426
|
|
|
350
427
|
hasField(field) {
|
|
@@ -366,7 +443,7 @@ export class ApiError extends SiteAuthoringError {
|
|
|
366
443
|
|
|
367
444
|
/**
|
|
368
445
|
* Classifies a refusal into the one thing the caller should do next. The
|
|
369
|
-
*
|
|
446
|
+
* five kinds are genuinely different behaviors, and collapsing them into
|
|
370
447
|
* "the request failed" is what makes an agent retry a revoked credential
|
|
371
448
|
* forever or treat a plan ceiling as a transient outage:
|
|
372
449
|
*
|
|
@@ -393,6 +470,13 @@ export class ApiError extends SiteAuthoringError {
|
|
|
393
470
|
* never invents the numeric limit.
|
|
394
471
|
* - `throttled` (gRPC `ResourceExhausted` / HTTP 429) — the per-key
|
|
395
472
|
* throttle. **Back off** and retry with delay.
|
|
473
|
+
* - `capability_missing` (`ErrorInfo` reason `SITE_AUTHORING_CAPABILITY_MISSING`)
|
|
474
|
+
* — the credential is valid and correctly scoped, and simply was not
|
|
475
|
+
* exchanged for a capability this request needs. **Fix the verb table**,
|
|
476
|
+
* not the credential: the exchange minted exactly what the verb asked for,
|
|
477
|
+
* so a verb whose declared set is short of the requests it makes is the
|
|
478
|
+
* defect. Distinguished from `credential_rejected` precisely because
|
|
479
|
+
* re-issuing the same credential cannot help (TR00691).
|
|
396
480
|
*
|
|
397
481
|
* Anything else is `unclassified`, which is a signal to surface the raw
|
|
398
482
|
* status rather than to guess.
|
|
@@ -403,6 +487,9 @@ export class ApiError extends SiteAuthoringError {
|
|
|
403
487
|
if (this.hasField(ROLLOUT_REFUSAL_FIELD)) return REFUSAL_PLATFORM_PAUSED;
|
|
404
488
|
if (this.hasField(CREDENTIAL_REFUSAL_FIELD)) return REFUSAL_CREDENTIAL_REJECTED;
|
|
405
489
|
if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD)) return REFUSAL_PLAN_LIMIT;
|
|
490
|
+
// The named detail ranks with the fields and above the status mappings, for
|
|
491
|
+
// the same reason: it is the specific statement about this refusal.
|
|
492
|
+
if (this.capability !== undefined) return REFUSAL_CAPABILITY_MISSING;
|
|
406
493
|
if (this.grpcCode === GRPC_RESOURCE_EXHAUSTED || this.httpStatus === HTTP_TOO_MANY_REQUESTS) {
|
|
407
494
|
return REFUSAL_THROTTLED;
|
|
408
495
|
}
|
package/src/verbs/nav-push.js
CHANGED
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
withRefusalGuidance,
|
|
10
10
|
} from "../api.js";
|
|
11
11
|
import { VERB_NAV_PUSH } from "../constants.js";
|
|
12
|
+
import {
|
|
13
|
+
CONTACT_OR_WEB_URL_MAX_LENGTH,
|
|
14
|
+
CONTACT_OR_WEB_URL_REQUIREMENT,
|
|
15
|
+
isContactOrWebUrl,
|
|
16
|
+
} from "../contact-url.js";
|
|
12
17
|
import { SiteAuthoringError } from "../errors.js";
|
|
13
18
|
import { boundedList, openSession, successResult } from "../session.js";
|
|
14
19
|
import {
|
|
@@ -44,7 +49,7 @@ import {
|
|
|
44
49
|
const MAXIMUM_REPORTED = 100;
|
|
45
50
|
const MAXIMUM_ITEMS = 1_000;
|
|
46
51
|
const MAXIMUM_TITLE_LENGTH = 200;
|
|
47
|
-
const MAXIMUM_URL_LENGTH =
|
|
52
|
+
const MAXIMUM_URL_LENGTH = CONTACT_OR_WEB_URL_MAX_LENGTH;
|
|
48
53
|
const ITEM_KEYS = new Set(["id", "kind", "title", "resourceId", "externalUrl", "children"]);
|
|
49
54
|
|
|
50
55
|
// `SaveSiteNavigation` maps both `id` and `resourceId` through
|
|
@@ -141,14 +146,10 @@ function validateItem(item, itemPath, depth, seenIds, counters) {
|
|
|
141
146
|
if (!isBoundedText(item.externalUrl, MAXIMUM_URL_LENGTH)) {
|
|
142
147
|
throw navError("nav.external_url_missing", "an EXTERNAL_URL item needs a non-empty 'externalUrl'.", itemPath);
|
|
143
148
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
url = undefined;
|
|
149
|
-
}
|
|
150
|
-
if (url === undefined || (url.protocol !== "https:" && url.protocol !== "http:")) {
|
|
151
|
-
throw navError("nav.external_url_invalid", "'externalUrl' must be an absolute http or https URL.", itemPath);
|
|
149
|
+
// A header item may be a phone number or an email address, not just a web
|
|
150
|
+
// page; the value is stored verbatim so the number stays dialable.
|
|
151
|
+
if (!isContactOrWebUrl(item.externalUrl, MAXIMUM_URL_LENGTH)) {
|
|
152
|
+
throw navError("nav.external_url_invalid", `'externalUrl' ${CONTACT_OR_WEB_URL_REQUIREMENT}.`, itemPath);
|
|
152
153
|
}
|
|
153
154
|
if (!isEmpty(item.resourceId)) {
|
|
154
155
|
throw navError("nav.resource_unexpected", "an EXTERNAL_URL item must not carry 'resourceId'.", itemPath);
|
package/src/verbs/pull.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
TEMPLATE_TYPE_FREE_FORM,
|
|
10
10
|
withRefusalGuidance,
|
|
11
11
|
} from "../api.js";
|
|
12
|
-
import { REFUSAL_UNCLASSIFIED, VERB_PULL } from "../constants.js";
|
|
12
|
+
import { REFUSAL_CAPABILITY_MISSING, REFUSAL_UNCLASSIFIED, VERB_PULL } from "../constants.js";
|
|
13
13
|
import { SiteAuthoringError } from "../errors.js";
|
|
14
14
|
import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
|
|
15
15
|
import { boundedList, openSession, successResult } from "../session.js";
|
|
@@ -94,8 +94,10 @@ import {
|
|
|
94
94
|
|
|
95
95
|
const MAXIMUM_REPORTED = 200;
|
|
96
96
|
// A settings group the credential's envelope does not reach is a narrower
|
|
97
|
-
// snapshot, not a failed pull.
|
|
98
|
-
//
|
|
97
|
+
// snapshot, not a failed pull. The server names that shortfall as a
|
|
98
|
+
// capability denial (TR00691), and an older server answers a bare 403; both
|
|
99
|
+
// mean the same thing here. Any other classified refusal — a rejected
|
|
100
|
+
// credential, the rollout switch, a plan ceiling — always propagates.
|
|
99
101
|
const UNAVAILABLE_SETTINGS_STATUSES = new Set([403, 404]);
|
|
100
102
|
const APPEARANCE_SETTINGS_TYPES = Object.freeze([
|
|
101
103
|
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
@@ -435,7 +437,8 @@ export async function pull(invocation) {
|
|
|
435
437
|
} catch (error) {
|
|
436
438
|
if (
|
|
437
439
|
error instanceof ApiError
|
|
438
|
-
&& error.refusalKind() === REFUSAL_UNCLASSIFIED
|
|
440
|
+
&& (error.refusalKind() === REFUSAL_UNCLASSIFIED
|
|
441
|
+
|| error.refusalKind() === REFUSAL_CAPABILITY_MISSING)
|
|
439
442
|
&& UNAVAILABLE_SETTINGS_STATUSES.has(error.httpStatus)
|
|
440
443
|
) {
|
|
441
444
|
onProgress(`Settings group ${group.settingsType} is not readable by this credential; skipping it.`);
|