@taprootio/site-authoring 0.23.1 → 0.24.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 +28 -5
- package/package.json +1 -1
- package/src/api.js +27 -5
- package/src/capabilities.js +1 -0
- package/src/changelog.json +14 -0
- package/src/cli.js +40 -6
- package/src/constants.js +1 -1
- package/src/credentials.js +55 -14
- package/src/reference-help.js +3 -3
- package/src/session.js +34 -0
- package/src/typed-page-reference.js +19 -26
- package/src/typed-pages.js +51 -6
- package/src/verbs/approve.js +25 -9
- package/src/verbs/login.js +33 -17
- package/src/verbs/logout.js +15 -1
- 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 +55 -6
- package/src/verbs/whoami.js +10 -1
- package/src/workspace.js +3 -0
package/README.md
CHANGED
|
@@ -246,7 +246,9 @@ answers Not found like any site the account can no longer author, and the
|
|
|
246
246
|
CLI adds a hint to run `use` again because the recording says the site was
|
|
247
247
|
authorable before.
|
|
248
248
|
|
|
249
|
-
`logout` discards the stored sign-in locally
|
|
249
|
+
`logout` discards only the stored account sign-in locally. Separately stored
|
|
250
|
+
site legal draft grants remain; select each site and use
|
|
251
|
+
`taproot-site --legal-drafts logout` to discard its grant. Logout revokes nothing; the
|
|
250
252
|
credential stays valid until an owner revokes it under Account → Settings →
|
|
251
253
|
API keys. The secret itself is never displayed, logged, or placed in any
|
|
252
254
|
result: credentials are named by id and display prefix only.
|
|
@@ -396,6 +398,12 @@ the site's own document is kept as internal state under
|
|
|
396
398
|
`.taproot-site-state/`, which is never a page source and never pushed. A page
|
|
397
399
|
edited on the site since the last pull is a `pages.pull_conflict` refusal
|
|
398
400
|
before anything under `pages/` changes, with both recovery paths named.
|
|
401
|
+
Pull before creating a new JSON source. Once a new page is declared with a
|
|
402
|
+
file, title or path but has no server page ID, save it with `pages push` before
|
|
403
|
+
pulling again, or back up both the source and its manifest entry and move them
|
|
404
|
+
out of the workspace. Pull refuses these pending entries rather than dropping
|
|
405
|
+
their metadata. It also refuses an untracked legal or unreadable JSON source
|
|
406
|
+
that would be replaced by a new remote page.
|
|
399
407
|
|
|
400
408
|
Pages are not only free-form. A source chooses its template with `template:` in
|
|
401
409
|
its front matter — `article`, `recipe`, `album` or `place-review`; free-form is
|
|
@@ -406,10 +414,25 @@ its original publication date) and `coverImage`, which the site requires to be a
|
|
|
406
414
|
image the page itself uses (place it in the body too; `pages.cover_image_unused`). A page's template is fixed when
|
|
407
415
|
it is created: a source for another template is refused with
|
|
408
416
|
`pages.template_immutable`. `taproot-site help page <type>` gives each
|
|
409
|
-
template's fields and one worked example.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
417
|
+
template's fields and one worked example. Pull includes structured legal pages and their
|
|
418
|
+
bodies as `.pm.json` with the usual Content read permission. Create and update
|
|
419
|
+
legal drafts in JSON only; Markdown conversion is not supported. The document
|
|
420
|
+
key, role and path are immutable, and publication assigns the version, effective
|
|
421
|
+
date and superseded version. See `taproot-site help page legal` for the fields.
|
|
422
|
+
|
|
423
|
+
Select a standard site and run `taproot-site login --legal-drafts`; the owner
|
|
424
|
+
reviews and approves a site-specific grant in the app, valid for at most 24
|
|
425
|
+
hours. Ordinary account login stays separate. Use the global option before the
|
|
426
|
+
verb, for example `taproot-site --legal-drafts pages push`,
|
|
427
|
+
`taproot-site --legal-drafts plan`, or `taproot-site --legal-drafts apply`.
|
|
428
|
+
It also selects the grant for `pull`, although ordinary Content access can read
|
|
429
|
+
legal pages. Changed or new legal drafts require this grant before writes begin;
|
|
430
|
+
unchanged legal pages do not block ordinary work. `TAPROOT_SITE_KEY` still takes
|
|
431
|
+
precedence, and legal writes with an unclassified environment override are
|
|
432
|
+
refused: unset it and use the explicit site login. Saving a draft does not
|
|
433
|
+
approve it. Legal approval, deletion and deployment remain in the app. A bare
|
|
434
|
+
`taproot-site approve` skips legal drafts and stages other tracked drafts; explicitly
|
|
435
|
+
naming a legal page refuses the whole selection.
|
|
413
436
|
A privacy policy or other legal page that names Taproot should link the first
|
|
414
437
|
mention to <https://www.taproot.io>; `help page free-form` and `help page legal`
|
|
415
438
|
give a sample sentence.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
package/src/api.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
// must equal acceptance on save — a prefix admitted here but refused there
|
|
26
26
|
// would turn a successful mint into an orphaned key.
|
|
27
27
|
import { KEY_PREFIX } from "./credentials.js";
|
|
28
|
+
import { CAPABILITY_LEGAL_DRAFTS, SITE_AUTHORING_CAPABILITIES } from "./capabilities.js";
|
|
28
29
|
import { isCanonicalUuid, normalizePreviewDiagnostic, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
29
30
|
import { normalizeAuthorListing, parseAuthorReference } from "./authors-contract.js";
|
|
30
31
|
import { docsPublicationModeFromWire, siteKindFromWire, surfaceFromWire } from "./surface.js";
|
|
@@ -2238,7 +2239,7 @@ function requireBoundedSeconds(value, minimum, maximum, field) {
|
|
|
2238
2239
|
* caller from the already-allowlisted API origin, so a compromised or
|
|
2239
2240
|
* misconfigured server cannot steer the owner somewhere else to approve.
|
|
2240
2241
|
*/
|
|
2241
|
-
export async function startCliAuthorization(client, { keyName }, requestOptions = {}) {
|
|
2242
|
+
export async function startCliAuthorization(client, { keyName, legalDraftSiteId }, requestOptions = {}) {
|
|
2242
2243
|
const response = requireObject(
|
|
2243
2244
|
// No site: TR00645 made the account-level sign-in the front door, so the
|
|
2244
2245
|
// CLI no longer has to know a site id before it can authorize at all.
|
|
@@ -2248,7 +2249,7 @@ export async function startCliAuthorization(client, { keyName }, requestOptions
|
|
|
2248
2249
|
await client.request(CLI_AUTHORIZATION_PATH, {
|
|
2249
2250
|
...requestOptions,
|
|
2250
2251
|
method: "POST",
|
|
2251
|
-
body: { keyName, cliVersion: CLI_VERSION },
|
|
2252
|
+
body: { keyName, cliVersion: CLI_VERSION, ...(legalDraftSiteId === undefined ? {} : { legalDraftSiteId }) },
|
|
2252
2253
|
}),
|
|
2253
2254
|
"login.start_contract",
|
|
2254
2255
|
"CLI authorization",
|
|
@@ -2263,9 +2264,13 @@ export async function startCliAuthorization(client, { keyName }, requestOptions
|
|
|
2263
2264
|
field: "userCode",
|
|
2264
2265
|
});
|
|
2265
2266
|
}
|
|
2267
|
+
if (legalDraftSiteId !== undefined && response.legalDraftSiteId !== legalDraftSiteId) {
|
|
2268
|
+
throw new SiteAuthoringError("login.start_contract", "Taproot did not confirm the selected legal draft site. Update the server and try again.", { field: "legalDraftSiteId" });
|
|
2269
|
+
}
|
|
2266
2270
|
return {
|
|
2267
2271
|
deviceCode: response.deviceCode,
|
|
2268
2272
|
userCode: response.userCode,
|
|
2273
|
+
...(legalDraftSiteId === undefined ? {} : { legalDraftSiteId }),
|
|
2269
2274
|
expiresInSeconds: requireBoundedSeconds(
|
|
2270
2275
|
response.expiresInSeconds,
|
|
2271
2276
|
1,
|
|
@@ -2387,7 +2392,7 @@ export async function exchangeSiteAuthoringToken(client, { siteId, capabilities
|
|
|
2387
2392
|
};
|
|
2388
2393
|
}
|
|
2389
2394
|
|
|
2390
|
-
function normalizeIssuedCredential(response) {
|
|
2395
|
+
function normalizeIssuedCredential(response, legalDraftSiteId) {
|
|
2391
2396
|
if (!isWellFormedCredential(response.rawKey)) {
|
|
2392
2397
|
// The value is never quoted, here or anywhere: it is the secret.
|
|
2393
2398
|
throw new SiteAuthoringError("login.claim_unusable", "Taproot returned an invalid issued key.", {
|
|
@@ -2411,7 +2416,24 @@ function normalizeIssuedCredential(response) {
|
|
|
2411
2416
|
|| response.keyExpiresAt === ""
|
|
2412
2417
|
? undefined
|
|
2413
2418
|
: requireCanonicalTimestamp(response.keyExpiresAt, "login.claim_unusable", "keyExpiresAt").value;
|
|
2419
|
+
if (legalDraftSiteId !== undefined) {
|
|
2420
|
+
const capabilities = response.capabilities;
|
|
2421
|
+
if (response.legalDraftSiteId !== legalDraftSiteId || !Array.isArray(capabilities)
|
|
2422
|
+
|| capabilities.length !== SITE_AUTHORING_CAPABILITIES.length + 1
|
|
2423
|
+
|| new Set(capabilities).size !== capabilities.length
|
|
2424
|
+
|| ![...SITE_AUTHORING_CAPABILITIES, CAPABILITY_LEGAL_DRAFTS].every((value) => capabilities.includes(value))
|
|
2425
|
+
|| (response.legalDraftPlatformSite !== undefined && typeof response.legalDraftPlatformSite !== "boolean")
|
|
2426
|
+
|| keyExpiresAt === undefined) {
|
|
2427
|
+
throw new SiteAuthoringError("login.claim_unusable", "Taproot returned an invalid legal draft grant.");
|
|
2428
|
+
}
|
|
2429
|
+
} else if (response.legalDraftSiteId || (Array.isArray(response.capabilities) && response.capabilities.length > 0)) {
|
|
2430
|
+
throw new SiteAuthoringError("login.claim_unusable", "Taproot returned an unexpected site grant.");
|
|
2431
|
+
}
|
|
2414
2432
|
return {
|
|
2433
|
+
...(legalDraftSiteId === undefined ? {} : {
|
|
2434
|
+
purpose: "legal-drafts", siteId: legalDraftSiteId, capabilities: [...response.capabilities],
|
|
2435
|
+
legalDraftPlatformSite: response.legalDraftPlatformSite ?? false,
|
|
2436
|
+
}),
|
|
2415
2437
|
key: response.rawKey,
|
|
2416
2438
|
keyId,
|
|
2417
2439
|
keyPrefix: response.keyPrefix,
|
|
@@ -2427,7 +2449,7 @@ function normalizeIssuedCredential(response) {
|
|
|
2427
2449
|
* `..._CONSUMED`, and the caller reports the orphaned credential honestly
|
|
2428
2450
|
* rather than hiding it behind a network error.
|
|
2429
2451
|
*/
|
|
2430
|
-
export async function claimCliAuthorization(client, deviceCode, requestOptions = {}) {
|
|
2452
|
+
export async function claimCliAuthorization(client, deviceCode, requestOptions = {}, { legalDraftSiteId } = {}) {
|
|
2431
2453
|
// Every `login.claim_contract` raised here — a non-object envelope, an
|
|
2432
2454
|
// absent status, a status string this vocabulary does not know — is an
|
|
2433
2455
|
// answer this client could not read, not proof the claim was refused. A
|
|
@@ -2451,7 +2473,7 @@ export async function claimCliAuthorization(client, deviceCode, requestOptions =
|
|
|
2451
2473
|
);
|
|
2452
2474
|
}
|
|
2453
2475
|
if (status === CLI_AUTHORIZATION_CLAIM_STATUS_ISSUED) {
|
|
2454
|
-
return { status, credential: normalizeIssuedCredential(response) };
|
|
2476
|
+
return { status, credential: normalizeIssuedCredential(response, legalDraftSiteId) };
|
|
2455
2477
|
}
|
|
2456
2478
|
if (status === CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED) {
|
|
2457
2479
|
// A consumed answer names the credential an earlier claim minted, so the
|
package/src/capabilities.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
export const CAPABILITY_CONTENT = "delegation.content";
|
|
15
15
|
export const CAPABILITY_DESIGN = "delegation.design";
|
|
16
16
|
export const CAPABILITY_DEPLOYMENTS = "delegation.deployments";
|
|
17
|
+
export const CAPABILITY_LEGAL_DRAFTS = "delegation.legal-drafts";
|
|
17
18
|
|
|
18
19
|
export const SITE_AUTHORING_CAPABILITIES = Object.freeze([
|
|
19
20
|
CAPABILITY_CONTENT,
|
package/src/changelog.json
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"entries": [
|
|
3
|
+
{
|
|
4
|
+
"version": "0.24.0",
|
|
5
|
+
"note": "Site-specific consent enables legal draft authoring through JSON; legal approval and publication remain in the app.",
|
|
6
|
+
"behavior": [
|
|
7
|
+
"Select a standard site, then run login --legal-drafts and approve the pinned site grant in the app. It lasts at most 24 hours and is separate from ordinary account login. Use the global --legal-drafts option before pull, pages push, plan or apply to select that grant.",
|
|
8
|
+
"Pull includes legal bodies as .pm.json with ordinary Content read access. Legal create and update accept JSON only; document key, role and path are immutable, and version, effective date and superseded version are assigned by publication. A changed or new legal draft needs the explicit grant before writes begin.",
|
|
9
|
+
"TAPROOT_SITE_KEY takes precedence; legal writes with an unclassified environment key are refused. Legal approval, deletion and deployment remain app-only. Saving a draft does not approve it.",
|
|
10
|
+
"The help reference version is 42."
|
|
11
|
+
],
|
|
12
|
+
"help": [
|
|
13
|
+
"page",
|
|
14
|
+
"pages"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
3
17
|
{
|
|
4
18
|
"version": "0.23.1",
|
|
5
19
|
"note": "Image uploads support Taproot’s single-use upload gateway and send an operation key with each upload request.",
|
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,
|
|
@@ -88,6 +89,7 @@ import {
|
|
|
88
89
|
} from "./reference-help.js";
|
|
89
90
|
import { FONT_CATEGORIES, getFontCatalogReference } from "./font-catalog.js";
|
|
90
91
|
import { assertCliCurrent } from "./session.js";
|
|
92
|
+
import { ApiError } from "./transport.js";
|
|
91
93
|
import { formatThemeResolve } from "./theme-resolve.js";
|
|
92
94
|
import { MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS } from "./theme-validation.js";
|
|
93
95
|
import { VERB_HANDLERS } from "./verbs/index.js";
|
|
@@ -161,8 +163,9 @@ const VERBS = Object.freeze([
|
|
|
161
163
|
note: "Starts a device-authorization exchange, prints the approval URL and an eight-character code, and waits for "
|
|
162
164
|
+ "an owner to enter that code and approve in a browser they are already signed in to. Typing the code — rather "
|
|
163
165
|
+ "than following a prefilled link — is what proves the approver can see this terminal. "
|
|
164
|
-
+ "
|
|
166
|
+
+ "Ordinary login needs no site and no taproot-site.json: what it authorizes is the account, so it works in any directory "
|
|
165
167
|
+ "immediately after install. Choose a site afterwards with 'sites' and 'use'. "
|
|
168
|
+
+ "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
169
|
+ "The approval URL is composed from the reviewed API origin and is never taken from a response. "
|
|
167
170
|
+ "The issued sign-in is written to credentials.json under $XDG_CONFIG_HOME/taproot-site/ (falling back to "
|
|
168
171
|
+ "~/.config/taproot-site/), directory 0700 and file 0600, one per API origin, replacing any sign-in already "
|
|
@@ -847,7 +850,11 @@ The site credential is taken from ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} when it is
|
|
|
847
850
|
set; otherwise it is minted for each run by exchanging the account sign-in that
|
|
848
851
|
login stores outside the repository. There is no flag for either. The sign-in
|
|
849
852
|
authorizes nothing on any site: it lists sites and buys short-lived site
|
|
850
|
-
credentials, and it expires 24 hours after it is issued.
|
|
853
|
+
credentials, and it expires 24 hours after it is issued. To edit legal drafts,
|
|
854
|
+
run 'login --legal-drafts' for the selected standard site, then put
|
|
855
|
+
'--legal-drafts' before pull, pages push, plan, or apply. This selects a
|
|
856
|
+
separate site grant that expires within 24 hours. Legal approval and publishing
|
|
857
|
+
remain in the app.
|
|
851
858
|
|
|
852
859
|
Verbs write one JSON result to stdout and human progress to stderr. validate,
|
|
853
860
|
help, whoami, and env are offline and read-only. login, logout, sites, use,
|
|
@@ -1038,8 +1045,11 @@ function verbHelp(verb) {
|
|
|
1038
1045
|
// contradicts the note two paragraphs below it is worse than either alone.
|
|
1039
1046
|
? `Options:
|
|
1040
1047
|
--config <path> Place before the verb; bypass parent discovery.
|
|
1048
|
+
--legal-drafts Authorize a separate, short-lived grant for the selected standard site.
|
|
1041
1049
|
--help Show this help.
|
|
1042
1050
|
--version Show the package version.`
|
|
1051
|
+
: ["logout", "whoami", "pull", "pages push", "plan", "apply"].includes(verb.name)
|
|
1052
|
+
? COMMON_OPTIONS.replace(" --quiet", " --legal-drafts Use the selected site's legal draft grant (before the verb).\n --quiet")
|
|
1043
1053
|
: COMMON_OPTIONS;
|
|
1044
1054
|
const deliveryTargetOption = verb.deliveryOptions
|
|
1045
1055
|
? `\n --staging Check the staging target.
|
|
@@ -1374,11 +1384,20 @@ function parseArguments(arguments_) {
|
|
|
1374
1384
|
if (arguments_.length === 1 && arguments_[0] === "--version") return { mode: "version" };
|
|
1375
1385
|
|
|
1376
1386
|
let configPath;
|
|
1377
|
-
let
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1387
|
+
let legalDrafts = false;
|
|
1388
|
+
let globalIndex = 0;
|
|
1389
|
+
while (arguments_[globalIndex] === "--config" || arguments_[globalIndex] === "--legal-drafts") {
|
|
1390
|
+
if (arguments_[globalIndex] === "--config") {
|
|
1391
|
+
if (configPath !== undefined) throw usageError("cli.duplicate_option", "--config may be supplied only once.");
|
|
1392
|
+
configPath = parseConfigOption(arguments_, globalIndex);
|
|
1393
|
+
globalIndex += 2;
|
|
1394
|
+
} else {
|
|
1395
|
+
if (legalDrafts) throw usageError("cli.duplicate_option", "--legal-drafts may be supplied only once.");
|
|
1396
|
+
legalDrafts = true;
|
|
1397
|
+
globalIndex += 1;
|
|
1398
|
+
}
|
|
1381
1399
|
}
|
|
1400
|
+
const commandArguments = arguments_.slice(globalIndex);
|
|
1382
1401
|
|
|
1383
1402
|
const verb = matchVerb(commandArguments);
|
|
1384
1403
|
if (!verb) {
|
|
@@ -1389,6 +1408,7 @@ function parseArguments(arguments_) {
|
|
|
1389
1408
|
}
|
|
1390
1409
|
const rest = commandArguments.slice(verb.tokens.length);
|
|
1391
1410
|
if (verb.name === VERB_HELP) {
|
|
1411
|
+
if (legalDrafts) throw usageError("cli.legal_option", "--legal-drafts applies only to site authoring commands.");
|
|
1392
1412
|
if (configPath !== undefined) {
|
|
1393
1413
|
throw usageError("cli.config_option", "--config applies only to operational verbs.", {
|
|
1394
1414
|
field: "configPath",
|
|
@@ -1439,6 +1459,11 @@ function parseArguments(arguments_) {
|
|
|
1439
1459
|
const positionals = [];
|
|
1440
1460
|
for (let index = 0; index < rest.length; index += 1) {
|
|
1441
1461
|
const argument = rest[index];
|
|
1462
|
+
if (verb.name === VERB_LOGIN && argument === "--legal-drafts") {
|
|
1463
|
+
if (legalDrafts) throw usageError("cli.duplicate_option", "--legal-drafts may be supplied only once.");
|
|
1464
|
+
legalDrafts = true;
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1442
1467
|
if (verb.name === VERB_VALIDATE && argument === "--init") {
|
|
1443
1468
|
if (init) throw usageError("cli.duplicate_option", "--init may be supplied only once.");
|
|
1444
1469
|
init = true;
|
|
@@ -1718,6 +1743,9 @@ function parseArguments(arguments_) {
|
|
|
1718
1743
|
field: "planHash",
|
|
1719
1744
|
});
|
|
1720
1745
|
}
|
|
1746
|
+
if (legalDrafts && !["login", "logout", "whoami", "pull", "pages push", "plan", "apply"].includes(verb.name)) {
|
|
1747
|
+
throw usageError("cli.legal_option", "--legal-drafts applies to login, logout, whoami, pull, pages push, plan, or apply.");
|
|
1748
|
+
}
|
|
1721
1749
|
if (quiet && verb.name === VERB_LOGIN) {
|
|
1722
1750
|
// The approval URL and user code reach the operator only as progress
|
|
1723
1751
|
// lines, and the JSON result is serialized only after polling ends — so a
|
|
@@ -1750,6 +1778,7 @@ function parseArguments(arguments_) {
|
|
|
1750
1778
|
surface: verb.surface,
|
|
1751
1779
|
surfaceCapabilities: verb.surfaceCapabilities,
|
|
1752
1780
|
configPath,
|
|
1781
|
+
legalDrafts,
|
|
1753
1782
|
quiet,
|
|
1754
1783
|
deployTarget,
|
|
1755
1784
|
allowFailedPreview,
|
|
@@ -1850,6 +1879,7 @@ export async function runCli({
|
|
|
1850
1879
|
cwd,
|
|
1851
1880
|
environment,
|
|
1852
1881
|
configPath: parsed.configPath,
|
|
1882
|
+
legalDrafts: parsed.legalDrafts,
|
|
1853
1883
|
deployTarget: parsed.deployTarget,
|
|
1854
1884
|
allowFailedPreview: parsed.allowFailedPreview,
|
|
1855
1885
|
...(parsed.handoff === undefined ? {} : { handoff: parsed.handoff }),
|
|
@@ -1905,6 +1935,10 @@ export async function runCli({
|
|
|
1905
1935
|
return 0;
|
|
1906
1936
|
} catch (unknownError) {
|
|
1907
1937
|
progress?.stop();
|
|
1938
|
+
if (parsed?.legalDrafts && unknownError instanceof ApiError
|
|
1939
|
+
&& unknownError.refusalKind() === REFUSAL_CREDENTIAL_REJECTED) {
|
|
1940
|
+
stderr.write(`The legal draft grant was rejected. Run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' again.\n`);
|
|
1941
|
+
}
|
|
1908
1942
|
const error = asSiteAuthoringError(unknownError);
|
|
1909
1943
|
if (completedPreviewRecovery) error.withPreviewRecovery(completedPreviewRecovery);
|
|
1910
1944
|
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
|
|
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 };
|
package/src/reference-help.js
CHANGED
|
@@ -88,7 +88,7 @@ import {
|
|
|
88
88
|
|
|
89
89
|
export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
|
|
90
90
|
|
|
91
|
-
export const REFERENCE_VERSION =
|
|
91
|
+
export const REFERENCE_VERSION = 42;
|
|
92
92
|
|
|
93
93
|
// The date-limit examples `help forms` prints; tests run them through the form validator.
|
|
94
94
|
export const FORM_DATE_WINDOW_FIELD = Object.freeze({
|
|
@@ -389,7 +389,7 @@ export function getDesignBlueprint(type) {
|
|
|
389
389
|
return DESIGN_BLUEPRINTS.find((blueprint) => blueprint.type === type);
|
|
390
390
|
}
|
|
391
391
|
export const REFERENCE_TOPICS = Object.freeze([
|
|
392
|
-
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List page types: free-form, article, recipe, album, place-review, generated, and
|
|
392
|
+
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List page types: free-form, article, recipe, album, place-review, generated, and legal draft pages." }),
|
|
393
393
|
Object.freeze({
|
|
394
394
|
name: "page",
|
|
395
395
|
usage: `${CLI_BINARY_NAME} help page <page-type>`,
|
|
@@ -1728,7 +1728,7 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
1728
1728
|
legalAndPrivacyPages: Object.freeze({
|
|
1729
1729
|
...LEGAL_PLATFORM_LINK_GUIDANCE,
|
|
1730
1730
|
choosing:
|
|
1731
|
-
`A policy that needs a version and an effective date is a legal page
|
|
1731
|
+
`A policy that needs a version and an effective date is a legal page; see '${CLI_BINARY_NAME} help page legal' for draft editing and app approval.`,
|
|
1732
1732
|
}),
|
|
1733
1733
|
componentNode: Object.freeze({
|
|
1734
1734
|
type: "componentBlock",
|
package/src/session.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
ROLLOUT_REFUSAL_FIELD,
|
|
12
12
|
VERB_LOGIN,
|
|
13
13
|
VERB_USE,
|
|
14
|
+
SURFACE_STANDARD,
|
|
14
15
|
} from "./constants.js";
|
|
15
16
|
import { loadSiteConfig } from "./config.js";
|
|
16
17
|
import { cliOutdatedError, compareCliVersions, isBehindLatest } from "./cli-release.js";
|
|
@@ -219,6 +220,7 @@ export async function resolveApiBaseUrl(environment) {
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
export async function openSession(invocation = {}) {
|
|
223
|
+
if (invocation.resolvedSession !== undefined) return invocation.resolvedSession;
|
|
222
224
|
const environment = invocation.environment ?? process.env;
|
|
223
225
|
const onProgress = progressSink(invocation);
|
|
224
226
|
const now = invocation.now ?? Date.now;
|
|
@@ -231,6 +233,9 @@ export async function openSession(invocation = {}) {
|
|
|
231
233
|
// at all.
|
|
232
234
|
const environmentToken = environment?.[PUBLISH_KEY_ENVIRONMENT_VARIABLE];
|
|
233
235
|
if (environmentToken !== undefined) {
|
|
236
|
+
if (invocation.legalDrafts) {
|
|
237
|
+
throw new SiteAuthoringError("auth.legal_environment_override", `Unset ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} and run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' to use the selected site's legal draft grant.`);
|
|
238
|
+
}
|
|
234
239
|
const apiBaseUrl = await resolveApiBaseUrl(environment);
|
|
235
240
|
const config = await loadConfig(invocation);
|
|
236
241
|
if (!config.siteId) throw siteMissingError();
|
|
@@ -244,6 +249,7 @@ export async function openSession(invocation = {}) {
|
|
|
244
249
|
return {
|
|
245
250
|
config,
|
|
246
251
|
client,
|
|
252
|
+
legalDrafts: false,
|
|
247
253
|
siteId: config.siteId,
|
|
248
254
|
surface: config.authoringSurface,
|
|
249
255
|
platform: UNKNOWN_PLATFORM,
|
|
@@ -253,6 +259,32 @@ export async function openSession(invocation = {}) {
|
|
|
253
259
|
};
|
|
254
260
|
}
|
|
255
261
|
|
|
262
|
+
if (invocation.legalDrafts) {
|
|
263
|
+
const apiBaseUrl = await resolveApiBaseUrl(environment);
|
|
264
|
+
const config = await loadConfig(invocation);
|
|
265
|
+
if (!config.siteId) throw siteMissingError();
|
|
266
|
+
if (config.authoringSurface !== undefined && config.authoringSurface !== SURFACE_STANDARD) {
|
|
267
|
+
throw new SiteAuthoringError("auth.legal_site_required", "Legal draft access requires a standard site.");
|
|
268
|
+
}
|
|
269
|
+
requireRecordedSurface(invocation, config);
|
|
270
|
+
const store = await readCredentialStore(environment);
|
|
271
|
+
const credential = selectCredential(store.credentials, credentialApiOrigin(apiBaseUrl), { siteId: config.siteId });
|
|
272
|
+
if (credential === undefined) {
|
|
273
|
+
throw new SiteAuthoringError("auth.legal_grant_missing", `Legal draft access is missing. Run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' again.`);
|
|
274
|
+
}
|
|
275
|
+
if (Date.parse(credential.keyExpiresAt) <= now()) {
|
|
276
|
+
onProgress(
|
|
277
|
+
`The stored legal draft grant expired at ${credential.keyExpiresAt}. `
|
|
278
|
+
+ `If Taproot refuses it, run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' again.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
config, client: invocation.client ?? new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, credential.key)),
|
|
283
|
+
legalDrafts: true, siteId: config.siteId, surface: config.authoringSurface,
|
|
284
|
+
platform: UNKNOWN_PLATFORM, release: UNKNOWN_CLI_RELEASE, now, onProgress,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
256
288
|
let config;
|
|
257
289
|
let apiBaseUrl;
|
|
258
290
|
const signIn = await requireSignIn(environment, async () => {
|
|
@@ -280,6 +312,7 @@ export async function openSession(invocation = {}) {
|
|
|
280
312
|
return {
|
|
281
313
|
config,
|
|
282
314
|
client: invocation.client,
|
|
315
|
+
legalDrafts: false,
|
|
283
316
|
siteId: config.siteId,
|
|
284
317
|
surface: config.authoringSurface,
|
|
285
318
|
platform: UNKNOWN_PLATFORM,
|
|
@@ -403,6 +436,7 @@ export async function openSession(invocation = {}) {
|
|
|
403
436
|
config,
|
|
404
437
|
client,
|
|
405
438
|
siteId: config.siteId,
|
|
439
|
+
legalDrafts: false,
|
|
406
440
|
surface: exchanged.authoringSurface,
|
|
407
441
|
exchanged: {
|
|
408
442
|
keyId: exchanged.keyId,
|