@taprootio/site-authoring 0.23.1 → 0.26.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 +78 -7
- package/package.json +2 -2
- package/src/analytics-campaign.json +31 -0
- package/src/analytics-link-label.json +22 -0
- package/src/analytics-source-vocabulary.json +17 -0
- package/src/api.js +27 -5
- package/src/capabilities.js +2 -1
- package/src/changelog.json +38 -0
- package/src/cli.js +155 -7
- package/src/constants.js +4 -1
- package/src/credentials.js +55 -14
- package/src/marketing-api.js +179 -0
- package/src/marketing-contract.js +289 -0
- package/src/marketing-workspace.js +77 -0
- package/src/redirects-contract.js +0 -3
- package/src/reference-help.js +109 -3
- package/src/session.js +35 -1
- package/src/site-check-findings.js +0 -1
- package/src/site-check-rules.json +0 -1
- package/src/site-checks.js +0 -6
- package/src/typed-page-reference.js +19 -26
- package/src/typed-pages.js +51 -6
- package/src/utm-link.js +214 -0
- package/src/verbs/approve.js +25 -9
- package/src/verbs/index.js +12 -0
- package/src/verbs/link-build.js +195 -0
- package/src/verbs/login.js +33 -17
- package/src/verbs/logout.js +15 -1
- package/src/verbs/marketing-pull.js +80 -0
- package/src/verbs/marketing-push.js +250 -0
- package/src/verbs/pages-delete.js +6 -3
- package/src/verbs/pages-push.js +41 -3
- package/src/verbs/plan.js +6 -1
- package/src/verbs/pull.js +63 -9
- package/src/verbs/whoami.js +42 -2
- package/src/workspace.js +3 -0
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
LOGIN_KEY_NAME_MAXIMUM,
|
|
15
15
|
PLAN_HASH,
|
|
16
16
|
PUBLISH_KEY_ENVIRONMENT_VARIABLE,
|
|
17
|
+
REFUSAL_CREDENTIAL_REJECTED,
|
|
17
18
|
RESULT_SCHEMA_VERSION,
|
|
18
19
|
STAGING_REVIEW_SESSION_NOTE,
|
|
19
20
|
SURFACE_DOCS_PRESENTATION,
|
|
@@ -32,8 +33,11 @@ import {
|
|
|
32
33
|
VERB_FORMS_PUSH,
|
|
33
34
|
VERB_FORMS_VALIDATE,
|
|
34
35
|
VERB_HELP,
|
|
36
|
+
VERB_LINK_BUILD,
|
|
35
37
|
VERB_LOGIN,
|
|
36
38
|
VERB_LOGOUT,
|
|
39
|
+
VERB_MARKETING_PULL,
|
|
40
|
+
VERB_MARKETING_PUSH,
|
|
37
41
|
VERB_MEDIA_UPLOAD,
|
|
38
42
|
VERB_NAV_PUSH,
|
|
39
43
|
VERB_PAGES_DELETE,
|
|
@@ -87,11 +91,15 @@ import {
|
|
|
87
91
|
REFERENCE_VERSION,
|
|
88
92
|
} from "./reference-help.js";
|
|
89
93
|
import { FONT_CATEGORIES, getFontCatalogReference } from "./font-catalog.js";
|
|
94
|
+
import { LINK_SOURCES } from "./marketing-contract.js";
|
|
90
95
|
import { assertCliCurrent } from "./session.js";
|
|
96
|
+
import { ApiError } from "./transport.js";
|
|
91
97
|
import { formatThemeResolve } from "./theme-resolve.js";
|
|
92
98
|
import { MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS } from "./theme-validation.js";
|
|
93
99
|
import { VERB_HANDLERS } from "./verbs/index.js";
|
|
94
100
|
|
|
101
|
+
const LINK_SOURCE_KEYS = LINK_SOURCES.map((source) => source.key);
|
|
102
|
+
|
|
95
103
|
/**
|
|
96
104
|
* The verbs the local version gate applies to (TR00703).
|
|
97
105
|
*
|
|
@@ -161,8 +169,9 @@ const VERBS = Object.freeze([
|
|
|
161
169
|
note: "Starts a device-authorization exchange, prints the approval URL and an eight-character code, and waits for "
|
|
162
170
|
+ "an owner to enter that code and approve in a browser they are already signed in to. Typing the code — rather "
|
|
163
171
|
+ "than following a prefilled link — is what proves the approver can see this terminal. "
|
|
164
|
-
+ "
|
|
172
|
+
+ "Ordinary login needs no site and no taproot-site.json: what it authorizes is the account, so it works in any directory "
|
|
165
173
|
+ "immediately after install. Choose a site afterwards with 'sites' and 'use'. "
|
|
174
|
+
+ "With --legal-drafts it requests a separate 24-hour grant for the selected standard site; use the global --legal-drafts option before site commands to select that grant. "
|
|
166
175
|
+ "The approval URL is composed from the reviewed API origin and is never taken from a response. "
|
|
167
176
|
+ "The issued sign-in is written to credentials.json under $XDG_CONFIG_HOME/taproot-site/ (falling back to "
|
|
168
177
|
+ "~/.config/taproot-site/), directory 0700 and file 0600, one per API origin, replacing any sign-in already "
|
|
@@ -588,6 +597,45 @@ const VERBS = Object.freeze([
|
|
|
588
597
|
+ "that the caller may write forms. "
|
|
589
598
|
+ "See 'taproot-site help forms'.",
|
|
590
599
|
},
|
|
600
|
+
{
|
|
601
|
+
name: VERB_MARKETING_PULL,
|
|
602
|
+
surface: SURFACE_STANDARD,
|
|
603
|
+
// Campaigns and placements ride the content capability, which carries the analytics-view permission that
|
|
604
|
+
// reads them and the publish permission that changes them (TR01301).
|
|
605
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
606
|
+
tokens: ["marketing", "pull"],
|
|
607
|
+
summary: "Write the site's live campaigns and placements to marketing/ and record what was read.",
|
|
608
|
+
note: "Needs a pulled workspace ('pull' first). It replaces marketing/campaigns.json and marketing/placements.json "
|
|
609
|
+
+ "with the site's records, each with its read-only state and the day its key was first counted from, and records "
|
|
610
|
+
+ "a baseline that 'marketing push' fences against. Archived records are listed and not written. "
|
|
611
|
+
+ "See 'taproot-site help marketing'.",
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
name: VERB_MARKETING_PUSH,
|
|
615
|
+
surface: SURFACE_STANDARD,
|
|
616
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
617
|
+
tokens: ["marketing", "push"],
|
|
618
|
+
archiveOption: true,
|
|
619
|
+
summary: "Create, update and restore the campaigns and placements the marketing files name.",
|
|
620
|
+
note: "Every file is validated and compared with the site before anything is written. A key is public URL text "
|
|
621
|
+
+ "and never changes, so a new key is a new record; a record the file leaves out is archived only with "
|
|
622
|
+
+ "--archive, and only if this workspace pulled it. A file that names an archived key restores it. Creating or "
|
|
623
|
+
+ "restoring a key queues the silent release that starts counting it; run 'marketing pull' later to see it live. "
|
|
624
|
+
+ "A quota or key refusal is reported as Taproot returns it. See 'taproot-site help marketing'.",
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
name: VERB_LINK_BUILD,
|
|
628
|
+
surface: SURFACE_STANDARD,
|
|
629
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
630
|
+
tokens: ["link", "build"],
|
|
631
|
+
linkOptions: true,
|
|
632
|
+
summary: "Print one tracked address for a page, a source, and an optional campaign and placement.",
|
|
633
|
+
note: "Saves nothing. The medium is set by the source ('reader' is not offered: a published page's own share "
|
|
634
|
+
+ "action sets it). The host is the signed-in site's public address; pass --domain when only a site credential "
|
|
635
|
+
+ "is available. A campaign or placement is checked against marketing/*.json when the workspace has the file, "
|
|
636
|
+
+ "and otherwise against the live site; one that is not live yet is allowed with a note that page views count "
|
|
637
|
+
+ "under the source only until it is. See 'taproot-site help link'.",
|
|
638
|
+
},
|
|
591
639
|
{
|
|
592
640
|
name: VERB_THEME_PUSH,
|
|
593
641
|
surface: SURFACE_DOCS_PRESENTATION,
|
|
@@ -847,7 +895,11 @@ The site credential is taken from ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} when it is
|
|
|
847
895
|
set; otherwise it is minted for each run by exchanging the account sign-in that
|
|
848
896
|
login stores outside the repository. There is no flag for either. The sign-in
|
|
849
897
|
authorizes nothing on any site: it lists sites and buys short-lived site
|
|
850
|
-
credentials, and it expires 24 hours after it is issued.
|
|
898
|
+
credentials, and it expires 24 hours after it is issued. To edit legal drafts,
|
|
899
|
+
run 'login --legal-drafts' for the selected standard site, then put
|
|
900
|
+
'--legal-drafts' before pull, pages push, plan, or apply. This selects a
|
|
901
|
+
separate site grant that expires within 24 hours. Legal approval and publishing
|
|
902
|
+
remain in the app.
|
|
851
903
|
|
|
852
904
|
Verbs write one JSON result to stdout and human progress to stderr. validate,
|
|
853
905
|
help, whoami, and env are offline and read-only. login, logout, sites, use,
|
|
@@ -971,6 +1023,17 @@ function verbHelp(verb) {
|
|
|
971
1023
|
const dryRunOption = verb.dryRun
|
|
972
1024
|
? "\n --dry-run Read the site and report what a push would change, without writing."
|
|
973
1025
|
: "";
|
|
1026
|
+
const archiveOption = verb.archiveOption
|
|
1027
|
+
? "\n --archive Archive a campaign or placement the file leaves out (never done without this)."
|
|
1028
|
+
: "";
|
|
1029
|
+
const linkOptions = verb.linkOptions
|
|
1030
|
+
? `\n --path <page> The page on this site; / or '' for the home page. A query or #fragment you add is kept (required).
|
|
1031
|
+
--source <key> Where you will post the link: ${LINK_SOURCE_KEYS.join(", ")} (required).
|
|
1032
|
+
--campaign <key> A campaign key to group links from one push (optional).
|
|
1033
|
+
--placement <key> A placement key naming the spot the link will appear (optional).
|
|
1034
|
+
Write --option=<value> for a value that starts with hyphens.
|
|
1035
|
+
--domain <host> The site's public host; for a site credential with no sign-in to read it from.`
|
|
1036
|
+
: "";
|
|
974
1037
|
const planOption = verb.planHash
|
|
975
1038
|
? "\n --plan <hash> The planHash 'plan' reported; required."
|
|
976
1039
|
: "";
|
|
@@ -1038,8 +1101,11 @@ function verbHelp(verb) {
|
|
|
1038
1101
|
// contradicts the note two paragraphs below it is worse than either alone.
|
|
1039
1102
|
? `Options:
|
|
1040
1103
|
--config <path> Place before the verb; bypass parent discovery.
|
|
1104
|
+
--legal-drafts Authorize a separate, short-lived grant for the selected standard site.
|
|
1041
1105
|
--help Show this help.
|
|
1042
1106
|
--version Show the package version.`
|
|
1107
|
+
: ["logout", "whoami", "pull", "pages push", "plan", "apply"].includes(verb.name)
|
|
1108
|
+
? COMMON_OPTIONS.replace(" --quiet", " --legal-drafts Use the selected site's legal draft grant (before the verb).\n --quiet")
|
|
1043
1109
|
: COMMON_OPTIONS;
|
|
1044
1110
|
const deliveryTargetOption = verb.deliveryOptions
|
|
1045
1111
|
? `\n --staging Check the staging target.
|
|
@@ -1050,7 +1116,7 @@ function verbHelp(verb) {
|
|
|
1050
1116
|
${verb.summary}
|
|
1051
1117
|
${boundary}
|
|
1052
1118
|
|
|
1053
|
-
${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${resolveOptions}${dryRunOption}${planOption}${nameOption}${note}
|
|
1119
|
+
${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${resolveOptions}${dryRunOption}${archiveOption}${linkOptions}${planOption}${nameOption}${note}
|
|
1054
1120
|
`;
|
|
1055
1121
|
}
|
|
1056
1122
|
|
|
@@ -1067,6 +1133,39 @@ function matchVerb(arguments_) {
|
|
|
1067
1133
|
return matched;
|
|
1068
1134
|
}
|
|
1069
1135
|
|
|
1136
|
+
/** The `link build` options and the invocation field each fills. */
|
|
1137
|
+
const LINK_OPTION_FIELDS = Object.freeze({
|
|
1138
|
+
"--path": "linkPath",
|
|
1139
|
+
"--source": "linkSource",
|
|
1140
|
+
"--campaign": "linkCampaign",
|
|
1141
|
+
"--placement": "linkPlacement",
|
|
1142
|
+
"--domain": "linkDomain",
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* The value after a `link build` option. An option-like value is refused so a missing value never swallows the
|
|
1147
|
+
* next flag; only --path may be empty, which names the home page.
|
|
1148
|
+
*/
|
|
1149
|
+
function parseLinkOption(rest, index, argument) {
|
|
1150
|
+
// `--campaign=--launch` is the one way to pass a value that starts with `--`, which a valid key may (a key may
|
|
1151
|
+
// begin with a hyphen); a separated value that looks like an option is refused so a missing value never swallows
|
|
1152
|
+
// the next flag.
|
|
1153
|
+
const [name, inline] = argument.includes("=") ? [argument.slice(0, argument.indexOf("=")), argument.slice(argument.indexOf("=") + 1)] : [argument, undefined];
|
|
1154
|
+
const value = inline ?? rest[index + 1];
|
|
1155
|
+
const field = name.slice(2);
|
|
1156
|
+
if (typeof value !== "string" || (inline === undefined && value.startsWith("--")) || (value === "" && name !== "--path")) {
|
|
1157
|
+
throw usageError("cli.option_value", `${name} requires a value.`, { field });
|
|
1158
|
+
}
|
|
1159
|
+
if (Buffer.byteLength(value, "utf8") > LIMITS.configPathBytes || hasAsciiControl(value)) {
|
|
1160
|
+
throw usageError(
|
|
1161
|
+
"cli.option_value",
|
|
1162
|
+
`${name} must be at most ${LIMITS.configPathBytes} bytes with no control characters.`,
|
|
1163
|
+
{ field },
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
return { name, value, consumed: inline === undefined ? 1 : 0 };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1070
1169
|
function usageError(code, message, options = {}) {
|
|
1071
1170
|
return new SiteAuthoringError(code, message, { ...options, exitCode: 2 });
|
|
1072
1171
|
}
|
|
@@ -1116,6 +1215,8 @@ function parseReferenceArguments(arguments_) {
|
|
|
1116
1215
|
|| topic === "fixture"
|
|
1117
1216
|
|| topic === "redirects"
|
|
1118
1217
|
|| topic === "forms"
|
|
1218
|
+
|| topic === "marketing"
|
|
1219
|
+
|| topic === "link"
|
|
1119
1220
|
|| topic === "walkthrough"
|
|
1120
1221
|
|| topic === "delivery"
|
|
1121
1222
|
|| topic === "import"
|
|
@@ -1187,6 +1288,8 @@ function referenceResult(parsed) {
|
|
|
1187
1288
|
case "nav":
|
|
1188
1289
|
case "redirects":
|
|
1189
1290
|
case "forms":
|
|
1291
|
+
case "marketing":
|
|
1292
|
+
case "link":
|
|
1190
1293
|
case "media":
|
|
1191
1294
|
case "preview":
|
|
1192
1295
|
case "fixture":
|
|
@@ -1374,11 +1477,20 @@ function parseArguments(arguments_) {
|
|
|
1374
1477
|
if (arguments_.length === 1 && arguments_[0] === "--version") return { mode: "version" };
|
|
1375
1478
|
|
|
1376
1479
|
let configPath;
|
|
1377
|
-
let
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1480
|
+
let legalDrafts = false;
|
|
1481
|
+
let globalIndex = 0;
|
|
1482
|
+
while (arguments_[globalIndex] === "--config" || arguments_[globalIndex] === "--legal-drafts") {
|
|
1483
|
+
if (arguments_[globalIndex] === "--config") {
|
|
1484
|
+
if (configPath !== undefined) throw usageError("cli.duplicate_option", "--config may be supplied only once.");
|
|
1485
|
+
configPath = parseConfigOption(arguments_, globalIndex);
|
|
1486
|
+
globalIndex += 2;
|
|
1487
|
+
} else {
|
|
1488
|
+
if (legalDrafts) throw usageError("cli.duplicate_option", "--legal-drafts may be supplied only once.");
|
|
1489
|
+
legalDrafts = true;
|
|
1490
|
+
globalIndex += 1;
|
|
1491
|
+
}
|
|
1381
1492
|
}
|
|
1493
|
+
const commandArguments = arguments_.slice(globalIndex);
|
|
1382
1494
|
|
|
1383
1495
|
const verb = matchVerb(commandArguments);
|
|
1384
1496
|
if (!verb) {
|
|
@@ -1389,6 +1501,7 @@ function parseArguments(arguments_) {
|
|
|
1389
1501
|
}
|
|
1390
1502
|
const rest = commandArguments.slice(verb.tokens.length);
|
|
1391
1503
|
if (verb.name === VERB_HELP) {
|
|
1504
|
+
if (legalDrafts) throw usageError("cli.legal_option", "--legal-drafts applies only to site authoring commands.");
|
|
1392
1505
|
if (configPath !== undefined) {
|
|
1393
1506
|
throw usageError("cli.config_option", "--config applies only to operational verbs.", {
|
|
1394
1507
|
field: "configPath",
|
|
@@ -1431,6 +1544,8 @@ function parseArguments(arguments_) {
|
|
|
1431
1544
|
let fromSite = false;
|
|
1432
1545
|
let keyName;
|
|
1433
1546
|
let planHash;
|
|
1547
|
+
let archive = false;
|
|
1548
|
+
const linkValues = {};
|
|
1434
1549
|
let metaTitle;
|
|
1435
1550
|
let metaDescription;
|
|
1436
1551
|
let metaAuthor;
|
|
@@ -1439,6 +1554,11 @@ function parseArguments(arguments_) {
|
|
|
1439
1554
|
const positionals = [];
|
|
1440
1555
|
for (let index = 0; index < rest.length; index += 1) {
|
|
1441
1556
|
const argument = rest[index];
|
|
1557
|
+
if (verb.name === VERB_LOGIN && argument === "--legal-drafts") {
|
|
1558
|
+
if (legalDrafts) throw usageError("cli.duplicate_option", "--legal-drafts may be supplied only once.");
|
|
1559
|
+
legalDrafts = true;
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1442
1562
|
if (verb.name === VERB_VALIDATE && argument === "--init") {
|
|
1443
1563
|
if (init) throw usageError("cli.duplicate_option", "--init may be supplied only once.");
|
|
1444
1564
|
init = true;
|
|
@@ -1528,6 +1648,21 @@ function parseArguments(arguments_) {
|
|
|
1528
1648
|
index += 1;
|
|
1529
1649
|
continue;
|
|
1530
1650
|
}
|
|
1651
|
+
if (verb.archiveOption && argument === "--archive") {
|
|
1652
|
+
if (archive) throw usageError("cli.duplicate_option", "--archive may be supplied only once.");
|
|
1653
|
+
archive = true;
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
if (verb.linkOptions && Object.hasOwn(LINK_OPTION_FIELDS, argument.split("=")[0])) {
|
|
1657
|
+
const { name, value, consumed } = parseLinkOption(rest, index, argument);
|
|
1658
|
+
const field = LINK_OPTION_FIELDS[name];
|
|
1659
|
+
if (linkValues[field] !== undefined) {
|
|
1660
|
+
throw usageError("cli.duplicate_option", `${name} may be supplied only once.`, { field: name.slice(2) });
|
|
1661
|
+
}
|
|
1662
|
+
linkValues[field] = value;
|
|
1663
|
+
index += consumed;
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1531
1666
|
if (verb.dryRun && argument === "--dry-run") {
|
|
1532
1667
|
if (dryRun) throw usageError("cli.duplicate_option", "--dry-run may be supplied only once.");
|
|
1533
1668
|
dryRun = true;
|
|
@@ -1718,6 +1853,9 @@ function parseArguments(arguments_) {
|
|
|
1718
1853
|
field: "planHash",
|
|
1719
1854
|
});
|
|
1720
1855
|
}
|
|
1856
|
+
if (legalDrafts && !["login", "logout", "whoami", "pull", "pages push", "plan", "apply"].includes(verb.name)) {
|
|
1857
|
+
throw usageError("cli.legal_option", "--legal-drafts applies to login, logout, whoami, pull, pages push, plan, or apply.");
|
|
1858
|
+
}
|
|
1721
1859
|
if (quiet && verb.name === VERB_LOGIN) {
|
|
1722
1860
|
// The approval URL and user code reach the operator only as progress
|
|
1723
1861
|
// lines, and the JSON result is serialized only after polling ends — so a
|
|
@@ -1750,6 +1888,7 @@ function parseArguments(arguments_) {
|
|
|
1750
1888
|
surface: verb.surface,
|
|
1751
1889
|
surfaceCapabilities: verb.surfaceCapabilities,
|
|
1752
1890
|
configPath,
|
|
1891
|
+
legalDrafts,
|
|
1753
1892
|
quiet,
|
|
1754
1893
|
deployTarget,
|
|
1755
1894
|
allowFailedPreview,
|
|
@@ -1767,6 +1906,8 @@ function parseArguments(arguments_) {
|
|
|
1767
1906
|
...(themeContext === undefined ? {} : { themeContext }),
|
|
1768
1907
|
...(fromSite ? { fromSite } : {}),
|
|
1769
1908
|
...(planHash === undefined ? {} : { planHash }),
|
|
1909
|
+
...(archive ? { archive } : {}),
|
|
1910
|
+
...linkValues,
|
|
1770
1911
|
...(metaTitle === undefined ? {} : { metaTitle }),
|
|
1771
1912
|
...(metaDescription === undefined ? {} : { metaDescription }),
|
|
1772
1913
|
...(metaAuthor === undefined ? {} : { metaAuthor }),
|
|
@@ -1850,6 +1991,7 @@ export async function runCli({
|
|
|
1850
1991
|
cwd,
|
|
1851
1992
|
environment,
|
|
1852
1993
|
configPath: parsed.configPath,
|
|
1994
|
+
legalDrafts: parsed.legalDrafts,
|
|
1853
1995
|
deployTarget: parsed.deployTarget,
|
|
1854
1996
|
allowFailedPreview: parsed.allowFailedPreview,
|
|
1855
1997
|
...(parsed.handoff === undefined ? {} : { handoff: parsed.handoff }),
|
|
@@ -1863,6 +2005,8 @@ export async function runCli({
|
|
|
1863
2005
|
...(parsed.themeContext === undefined ? {} : { themeContext: parsed.themeContext }),
|
|
1864
2006
|
...(parsed.fromSite === undefined ? {} : { fromSite: parsed.fromSite }),
|
|
1865
2007
|
...(parsed.planHash === undefined ? {} : { planHash: parsed.planHash }),
|
|
2008
|
+
...(parsed.archive ? { archive: true } : {}),
|
|
2009
|
+
...Object.fromEntries(Object.values(LINK_OPTION_FIELDS).filter((field) => parsed[field] !== undefined).map((field) => [field, parsed[field]])),
|
|
1866
2010
|
// Only when given, like the other optional fields, so each verb keeps its default (TR00925).
|
|
1867
2011
|
...(parsed.deliveryUrl === undefined ? {} : { deliveryUrl: parsed.deliveryUrl }),
|
|
1868
2012
|
...(parsed.propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds: parsed.propagationWaitSeconds }),
|
|
@@ -1905,6 +2049,10 @@ export async function runCli({
|
|
|
1905
2049
|
return 0;
|
|
1906
2050
|
} catch (unknownError) {
|
|
1907
2051
|
progress?.stop();
|
|
2052
|
+
if (parsed?.legalDrafts && unknownError instanceof ApiError
|
|
2053
|
+
&& unknownError.refusalKind() === REFUSAL_CREDENTIAL_REJECTED) {
|
|
2054
|
+
stderr.write(`The legal draft grant was rejected. Run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' again.\n`);
|
|
2055
|
+
}
|
|
1908
2056
|
const error = asSiteAuthoringError(unknownError);
|
|
1909
2057
|
if (completedPreviewRecovery) error.withPreviewRecovery(completedPreviewRecovery);
|
|
1910
2058
|
const result = failureResult(error);
|
package/src/constants.js
CHANGED
|
@@ -49,7 +49,7 @@ export const CREDENTIAL_FILE_NAME = "credentials.json";
|
|
|
49
49
|
// store is refused with a message naming `login` rather than migrated —
|
|
50
50
|
// the CLI is unpublished, so the only v1 stores are in this repository, and
|
|
51
51
|
// a migration path would be code that exists for nobody.
|
|
52
|
-
export const CREDENTIAL_STORE_VERSION =
|
|
52
|
+
export const CREDENTIAL_STORE_VERSION = 3;
|
|
53
53
|
export const CREDENTIAL_DIRECTORY_MODE = 0o700;
|
|
54
54
|
export const CREDENTIAL_FILE_MODE = 0o600;
|
|
55
55
|
|
|
@@ -94,6 +94,9 @@ export const VERB_REDIRECTS_PUSH = "redirects push";
|
|
|
94
94
|
export const VERB_FORMS_PULL = "forms pull";
|
|
95
95
|
export const VERB_FORMS_PUSH = "forms push";
|
|
96
96
|
export const VERB_FORMS_VALIDATE = "forms validate";
|
|
97
|
+
export const VERB_MARKETING_PULL = "marketing pull";
|
|
98
|
+
export const VERB_MARKETING_PUSH = "marketing push";
|
|
99
|
+
export const VERB_LINK_BUILD = "link build";
|
|
97
100
|
export const VERB_THEME_PUSH = "theme push";
|
|
98
101
|
export const VERB_THEME_RESOLVE = "theme resolve";
|
|
99
102
|
export const VERB_FOOTER_PUSH = "footer push";
|
package/src/credentials.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { atomicWriteFile } from "./atomic-file.js";
|
|
7
7
|
import { readBoundedFile } from "./bounded-file.js";
|
|
8
|
-
import { SITE_AUTHORING_CAPABILITIES } from "./capabilities.js";
|
|
8
|
+
import { CAPABILITY_LEGAL_DRAFTS, SITE_AUTHORING_CAPABILITIES } from "./capabilities.js";
|
|
9
9
|
import { parseCliVersion } from "./cli-release.js";
|
|
10
10
|
|
|
11
11
|
import {
|
|
@@ -172,8 +172,30 @@ function normalizeEntry(entry) {
|
|
|
172
172
|
|| parseCliVersion(entry.lastExchange.latestCliVersion) !== undefined)),
|
|
173
173
|
"A stored credential record has an invalid lastExchange.",
|
|
174
174
|
);
|
|
175
|
+
const legal = entry.purpose === "legal-drafts";
|
|
176
|
+
const legalExpiry = legal && typeof entry.keyExpiresAt === "string" ? Date.parse(entry.keyExpiresAt) : Number.NaN;
|
|
177
|
+
requireStoreShape(entry.purpose === undefined || legal, "A stored credential record has an invalid purpose.");
|
|
178
|
+
requireStoreShape(legal
|
|
179
|
+
? isCanonicalUuid(entry.siteId)
|
|
180
|
+
&& typeof entry.legalDraftPlatformSite === "boolean"
|
|
181
|
+
&& entry.keyExpiresAt !== undefined
|
|
182
|
+
&& Number.isFinite(legalExpiry)
|
|
183
|
+
&& new Date(legalExpiry).toISOString().slice(0, 19) === entry.keyExpiresAt.slice(0, 19)
|
|
184
|
+
&& Array.isArray(entry.capabilities)
|
|
185
|
+
&& entry.capabilities.length === SITE_AUTHORING_CAPABILITIES.length + 1
|
|
186
|
+
&& new Set(entry.capabilities).size === entry.capabilities.length
|
|
187
|
+
&& [...SITE_AUTHORING_CAPABILITIES, CAPABILITY_LEGAL_DRAFTS].every((value) => entry.capabilities.includes(value))
|
|
188
|
+
&& entry.lastExchange === undefined
|
|
189
|
+
: entry.siteId === undefined && entry.capabilities === undefined && entry.legalDraftPlatformSite === undefined,
|
|
190
|
+
"A stored credential record has invalid scope.");
|
|
175
191
|
return Object.freeze({
|
|
176
192
|
apiOrigin: entry.apiOrigin,
|
|
193
|
+
...(legal ? {
|
|
194
|
+
purpose: entry.purpose,
|
|
195
|
+
siteId: entry.siteId,
|
|
196
|
+
capabilities: Object.freeze([...entry.capabilities]),
|
|
197
|
+
legalDraftPlatformSite: entry.legalDraftPlatformSite,
|
|
198
|
+
} : {}),
|
|
177
199
|
accountId: entry.accountId,
|
|
178
200
|
key: entry.key,
|
|
179
201
|
keyId: entry.keyId,
|
|
@@ -262,14 +284,22 @@ export async function readCredentialStore(environment = {}) {
|
|
|
262
284
|
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed),
|
|
263
285
|
"The stored credential file is not a JSON object.",
|
|
264
286
|
);
|
|
265
|
-
if (parsed.schemaVersion !== CREDENTIAL_STORE_VERSION) {
|
|
287
|
+
if (parsed.schemaVersion !== CREDENTIAL_STORE_VERSION && parsed.schemaVersion !== 2) {
|
|
266
288
|
throw new SiteAuthoringError(
|
|
267
289
|
"credentials.unsupported_version",
|
|
268
290
|
`The stored credential file must declare schemaVersion ${CREDENTIAL_STORE_VERSION}.`,
|
|
269
291
|
);
|
|
270
292
|
}
|
|
271
293
|
requireStoreShape(Array.isArray(parsed.credentials), "The stored credential file has no credentials array.");
|
|
272
|
-
|
|
294
|
+
if (parsed.schemaVersion === 2) {
|
|
295
|
+
requireStoreShape(parsed.credentials.every((entry) => entry && entry.purpose === undefined
|
|
296
|
+
&& entry.siteId === undefined && entry.capabilities === undefined && entry.legalDraftPlatformSite === undefined),
|
|
297
|
+
"An older credential store has an invalid scope.");
|
|
298
|
+
}
|
|
299
|
+
const credentials = parsed.credentials.map(normalizeEntry);
|
|
300
|
+
const identities = credentials.map((entry) => JSON.stringify([entry.apiOrigin, entry.purpose ?? "account", entry.siteId ?? ""]));
|
|
301
|
+
requireStoreShape(new Set(identities).size === identities.length, "The stored credential file has duplicate records.");
|
|
302
|
+
return { path: filePath, credentials };
|
|
273
303
|
} catch (error) {
|
|
274
304
|
throw namedStoreFailure(error, filePath);
|
|
275
305
|
}
|
|
@@ -283,13 +313,18 @@ export async function readCredentialStore(environment = {}) {
|
|
|
283
313
|
* site; the sign-in credential authorizes an account, and an operator has one
|
|
284
314
|
* of those per Taproot they use.
|
|
285
315
|
*/
|
|
286
|
-
export function selectCredential(credentials, apiOrigin) {
|
|
287
|
-
return credentials.find((entry) => entry.apiOrigin === apiOrigin
|
|
316
|
+
export function selectCredential(credentials, apiOrigin, { siteId } = {}) {
|
|
317
|
+
return credentials.find((entry) => entry.apiOrigin === apiOrigin
|
|
318
|
+
&& (siteId === undefined ? entry.purpose === undefined : entry.purpose === "legal-drafts" && entry.siteId === siteId));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function sameCredentialScope(left, right) {
|
|
322
|
+
return left.apiOrigin === right.apiOrigin && left.purpose === right.purpose && left.siteId === right.siteId;
|
|
288
323
|
}
|
|
289
324
|
|
|
290
|
-
export async function findCredential(environment, apiOrigin) {
|
|
325
|
+
export async function findCredential(environment, apiOrigin, scope = {}) {
|
|
291
326
|
const store = await readCredentialStore(environment);
|
|
292
|
-
return { path: store.path, credential: selectCredential(store.credentials, apiOrigin) };
|
|
327
|
+
return { path: store.path, credential: selectCredential(store.credentials, apiOrigin, scope) };
|
|
293
328
|
}
|
|
294
329
|
|
|
295
330
|
/**
|
|
@@ -486,7 +521,7 @@ async function withStoreLock(filePath, action, { sleep = defaultLockSleep } = {}
|
|
|
486
521
|
* someone with a nearly full store to log out of a Taproot they no longer use;
|
|
487
522
|
* under-refusing means orphaning a credential. The asymmetry picks the side.
|
|
488
523
|
*/
|
|
489
|
-
export async function assertCredentialCapacity(environment, apiOrigin) {
|
|
524
|
+
export async function assertCredentialCapacity(environment, apiOrigin, { siteId } = {}) {
|
|
490
525
|
const store = await readCredentialStore(environment);
|
|
491
526
|
const projected = normalizeEntry({
|
|
492
527
|
apiOrigin,
|
|
@@ -494,7 +529,7 @@ export async function assertCredentialCapacity(environment, apiOrigin) {
|
|
|
494
529
|
key: "k".repeat(MAXIMUM_TOKEN_LENGTH),
|
|
495
530
|
keyId: PROJECTION_UUID,
|
|
496
531
|
keyPrefix: "p".repeat(PROJECTION_KEY_PREFIX_LENGTH),
|
|
497
|
-
keyExpiresAt: PROJECTION_TIMESTAMP,
|
|
532
|
+
keyExpiresAt: siteId === undefined ? PROJECTION_TIMESTAMP : "2000-01-02T00:00:00.000Z",
|
|
498
533
|
lastExchange: {
|
|
499
534
|
siteId: PROJECTION_UUID,
|
|
500
535
|
capabilities: [...SITE_AUTHORING_CAPABILITIES],
|
|
@@ -505,9 +540,14 @@ export async function assertCredentialCapacity(environment, apiOrigin) {
|
|
|
505
540
|
externalWritesEnabled: false,
|
|
506
541
|
latestCliVersion: PROJECTION_LATEST_CLI_VERSION,
|
|
507
542
|
},
|
|
543
|
+
...(siteId === undefined ? {} : {
|
|
544
|
+
purpose: "legal-drafts", siteId, legalDraftPlatformSite: false,
|
|
545
|
+
capabilities: [...SITE_AUTHORING_CAPABILITIES, CAPABILITY_LEGAL_DRAFTS],
|
|
546
|
+
lastExchange: undefined,
|
|
547
|
+
}),
|
|
508
548
|
createdAt: PROJECTION_TIMESTAMP,
|
|
509
549
|
});
|
|
510
|
-
const others = store.credentials.filter((entry) => entry
|
|
550
|
+
const others = store.credentials.filter((entry) => !sameCredentialScope(entry, projected));
|
|
511
551
|
requireStoreFits(serializeStore([...others, projected]));
|
|
512
552
|
return store.path;
|
|
513
553
|
}
|
|
@@ -525,7 +565,7 @@ export async function saveCredential(environment, credential, { now = Date.now,
|
|
|
525
565
|
const filePath = credentialStorePath(environment);
|
|
526
566
|
return withStoreLock(filePath, async () => {
|
|
527
567
|
const store = await readCredentialStore(environment);
|
|
528
|
-
const others = store.credentials.filter((entry) => entry
|
|
568
|
+
const others = store.credentials.filter((entry) => !sameCredentialScope(entry, record));
|
|
529
569
|
const ordered = [...others, record].sort((left, right) => left.apiOrigin.localeCompare(right.apiOrigin));
|
|
530
570
|
await writeCredentialStore(store.path, ordered);
|
|
531
571
|
return store.path;
|
|
@@ -554,7 +594,7 @@ export async function updateCredentialMetadata(environment, apiOrigin, keyId, me
|
|
|
554
594
|
if (current === undefined || current.keyId !== keyId) return false;
|
|
555
595
|
|
|
556
596
|
const updated = normalizeEntry({ ...current, ...metadata });
|
|
557
|
-
const others = store.credentials.filter((entry) => entry
|
|
597
|
+
const others = store.credentials.filter((entry) => !sameCredentialScope(entry, current));
|
|
558
598
|
const ordered = [...others, updated].sort((left, right) => left.apiOrigin.localeCompare(right.apiOrigin));
|
|
559
599
|
await writeCredentialStore(store.path, ordered);
|
|
560
600
|
return true;
|
|
@@ -567,11 +607,12 @@ export async function updateCredentialMetadata(environment, apiOrigin, keyId, me
|
|
|
567
607
|
* asked for is the state they end up in. The read-modify-write runs under the
|
|
568
608
|
* same store lock the save takes.
|
|
569
609
|
*/
|
|
570
|
-
export async function removeCredential(environment, apiOrigin, { sleep } = {}) {
|
|
610
|
+
export async function removeCredential(environment, apiOrigin, { sleep, siteId } = {}) {
|
|
571
611
|
const filePath = credentialStorePath(environment);
|
|
572
612
|
return withStoreLock(filePath, async () => {
|
|
573
613
|
const store = await readCredentialStore(environment);
|
|
574
|
-
const
|
|
614
|
+
const current = selectCredential(store.credentials, apiOrigin, { siteId });
|
|
615
|
+
const remaining = store.credentials.filter((entry) => current === undefined || !sameCredentialScope(entry, current));
|
|
575
616
|
if (remaining.length === store.credentials.length) return { path: store.path, removed: false };
|
|
576
617
|
await writeCredentialStore(store.path, remaining);
|
|
577
618
|
return { path: store.path, removed: true };
|