@taprootio/site-authoring 0.23.0 → 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 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 and revokes nothing; the
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. The structured legal page (a versioned
410
- privacy policy or terms with an effective date) is created and published in the
411
- app; the CLI records it as a metadata-only page, and `taproot-site help page
412
- legal` says what a site-authoring key can and cannot do with one.
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.23.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
@@ -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,
@@ -1,5 +1,27 @@
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
+ },
17
+ {
18
+ "version": "0.23.1",
19
+ "note": "Image uploads support Taproot’s single-use upload gateway and send an operation key with each upload request.",
20
+ "behavior": [
21
+ "media upload sends an operation key when requesting an image upload. It accepts a single-use gateway capability only for the configured Taproot API origin and image-upload path, and never automatically retries that capability after a transport failure or retryable response. Direct signed object-store uploads retain their existing retry behavior."
22
+ ],
23
+ "help": []
24
+ },
3
25
  {
4
26
  "version": "0.23.0",
5
27
  "note": "theme resolve prints the colors Espalier compiles from the theme, per scheme and context, with their contrast ratios, offline; the help and the staging review result say how long a review lasts and what ends it; pages delete with a dry run; nav push fenced by a navigation revision.",
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
- + "It needs no site and no taproot-site.json: what it authorizes is the account, so it works in any directory "
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 commandArguments = arguments_;
1378
- if (arguments_[0] === "--config") {
1379
- configPath = parseConfigOption(arguments_, 0);
1380
- commandArguments = arguments_.slice(2);
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 = 2;
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
 
@@ -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
- return { path: filePath, credentials: parsed.credentials.map(normalizeEntry) };
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.apiOrigin !== apiOrigin);
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.apiOrigin !== record.apiOrigin);
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.apiOrigin !== apiOrigin);
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 remaining = store.credentials.filter((entry) => entry.apiOrigin !== apiOrigin);
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 };
@@ -88,7 +88,7 @@ import {
88
88
 
89
89
  export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
90
90
 
91
- export const REFERENCE_VERSION = 41;
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 the read-only legal page." }),
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, which the CLI cannot author: see '${CLI_BINARY_NAME} help page legal'.`,
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,