@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.
@@ -323,12 +323,6 @@ const TYPE_DETAILS = Object.freeze({
323
323
  },
324
324
  });
325
325
 
326
- /**
327
- * The legal page type (TR01221). It is described here and nowhere else in the
328
- * CLI: it has no template name in `typed-pages.js` because a site-authoring
329
- * key cannot author it, so `pull` records it as a metadata-only page and
330
- * `pages push` never sees a source for it.
331
- */
332
326
  export const LEGAL_PAGE_TYPE = "legal";
333
327
 
334
328
  export const TYPED_PAGE_TYPES = Object.freeze([...Object.keys(TYPE_DETAILS), LEGAL_PAGE_TYPE]);
@@ -355,15 +349,14 @@ function legalReference() {
355
349
  displayName: "Legal page",
356
350
  summary:
357
351
  "A versioned legal document such as a privacy policy or terms: an automatic h1, a version and effective date, and "
358
- + "permanent links to each version. The CLI sees it as metadata only; it is created and published in the app.",
359
- readOnly: true,
352
+ + "permanent links to each version. The CLI can edit drafts with an explicit site grant; approval and publication happen in the app.",
353
+ readOnly: false,
360
354
  workspace: Object.freeze({
361
355
  directory: `${PAGES_DIRECTORY}/`,
362
356
  templateType: "TEMPLATE_TYPE_LEGAL",
363
357
  sourceRule:
364
- "A legal page has no source file. 'pull' lists it in .taproot-site-manifest.json with templateType "
365
- + "TEMPLATE_TYPE_LEGAL and workspaceMode \"metadata-only\" and writes nothing under pages/ for it, so 'pages push' has "
366
- + "nothing to send.",
358
+ "Pull writes a .pm.json source containing template legal and data with documentKey, documentRole, summaryOfChanges, material and body. "
359
+ + "Edit that source to change the draft; Markdown is not supported. To create one, add a .pm.json source and a manifest pages entry with file, title and path, without a pageId. Version, effectiveDate and supersedesVersion are assigned by Taproot and must not be authored.",
367
360
  }),
368
361
  fields: Object.freeze([
369
362
  Object.freeze({ name: "title", description: "The page title. It prints as the page's h1, so the body should not open with its own title." }),
@@ -374,13 +367,13 @@ function legalReference() {
374
367
  Object.freeze({
375
368
  name: "documentKey",
376
369
  description:
377
- "The document's stable identity, set in the app. The permanent version address exists only once the page has "
370
+ "The document's stable identity, fixed after creation. The permanent version address exists only once the page has "
378
371
  + "a document key and a version.",
379
372
  }),
380
373
  Object.freeze({
381
374
  name: "documentRole",
382
375
  description:
383
- "The typed purpose of the document, such as membership_terms or privacy_notice. Selected in the app; fixed for membership terms.",
376
+ "The typed purpose of the document, such as membership_terms or privacy_notice. Fixed after creation.",
384
377
  }),
385
378
  Object.freeze({ name: "summaryOfChanges", description: "A short note on what this version changed, written by the owner." }),
386
379
  Object.freeze({
@@ -402,14 +395,11 @@ function legalReference() {
402
395
  ]),
403
396
  authoring: Object.freeze({
404
397
  cli:
405
- "Read-only. 'pull' records the page's identity, path, title and status. No verb creates, edits, converts, "
406
- + "approves or publishes one. A bare 'approve' stages every tracked page with a draft, a pending legal draft "
407
- + "included, and the request holding it is refused (up to 100 pages go in one request): name the pages ('approve <path>').",
398
+ "Pull reads legal pages with ordinary Content access. Run 'taproot-site login --legal-drafts', then "
399
+ + "'taproot-site --legal-drafts pages push' to create or edit legal drafts. Approval and deletion are refused by the CLI; use the app. A bare 'approve' skips legal drafts; naming a legal page refuses the whole selection.",
408
400
  key:
409
- "A site-authoring key cannot create or publish a legal page, and a production deploy whose selected pages include "
410
- + "one is refused for a key, because the permission that governs legal documents (site.legal_documents.manage) is "
411
- + "outside the key's delegation.content, delegation.design and delegation.deployments capabilities. Until a "
412
- + "person promotes an app-approved legal page, every production promotion of a release staged after that approval is refused. This is how keys work today; whether a key should ever author a legal page is an open owner decision.",
401
+ "Only an explicit site-specific legal-drafts grant can save legal drafts. It does not grant site.legal_documents.manage, "
402
+ + "and cannot approve, delete or deploy legal documents.",
413
403
  app:
414
404
  "A signed-in person who may manage the site's legal documents creates the page, writes its body, and publishes it "
415
405
  + "with a production deployment in the app. Taproot assigns the version and effective date at that point.",
@@ -421,18 +411,21 @@ function legalReference() {
421
411
  freeForm:
422
412
  "A free-form page is an ordinary page. It has no version history, no effective date, no permanent version "
423
413
  + "addresses and no automatic heading: open its Markdown with a '# ' line holding the title (see "
424
- + `'${CLI_BINARY_NAME} help page free-form'). The CLI cannot author a legal page, so a policy written here is an ordinary `
425
- + "page, free-form unless another template fits: put the date it was last updated in its text, and tell the owner it is not versioned.",
414
+ + `'${CLI_BINARY_NAME} help page free-form'). A policy written as free-form is an ordinary page; put its last-updated date in its text and tell the owner it is not versioned.`,
426
415
  }),
427
416
  platformLink: LEGAL_PLATFORM_LINK_GUIDANCE,
428
417
  workflow: Object.freeze([
429
418
  Object.freeze({
430
419
  command: `${CLI_BINARY_NAME} pull`,
431
- result: "Records the page as metadata-only in the manifest; nothing to edit.",
420
+ result: "Writes the legal draft as a .pm.json source and records its identity in the manifest.",
421
+ }),
422
+ Object.freeze({
423
+ command: `${CLI_BINARY_NAME} login --legal-drafts`,
424
+ result: "Request a site-specific grant; a person approves it in the app.",
432
425
  }),
433
426
  Object.freeze({
434
- command: "the app",
435
- result: "A person with the legal permission edits the draft and promotes it with a production deployment.",
427
+ command: `${CLI_BINARY_NAME} --legal-drafts pages push`,
428
+ result: "Saves changed legal drafts. A person approves and publishes them in the app.",
436
429
  }),
437
430
  ]),
438
431
  });
@@ -516,7 +509,7 @@ function formatField(field) {
516
509
  }
517
510
 
518
511
  function formatLegalReference(page) {
519
- return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n\nFields (set in the app):\n${
512
+ return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n\nFields:\n${
520
513
  page.fields.map((field) => ` ${field.name}: ${field.description}`).join("\n")
521
514
  }\n\nHow it renders:\n${page.rendering.map((line) => ` - ${line}`).join("\n")}\n\nWho can author it:\n CLI ${page.authoring.cli}\n site-authoring key ${page.authoring.key}\n app ${page.authoring.app}\n\nLegal page or free-form page:\n legal ${page.choosing.legal}\n free-form ${page.choosing.freeForm}\n\nLinking Taproot: ${page.platformLink.rule}\n example ${page.platformLink.example}\n\nWorkflow:\n${
522
515
  page.workflow.map((step) => ` ${step.command}: ${step.result}`).join("\n")
@@ -30,6 +30,7 @@ export const TEMPLATE_ARTICLE = "article";
30
30
  export const TEMPLATE_RECIPE = "recipe";
31
31
  export const TEMPLATE_ALBUM = "album";
32
32
  export const TEMPLATE_PLACE_REVIEW = "place-review";
33
+ export const TEMPLATE_LEGAL = "legal";
33
34
  /**
34
35
  * A page Taproot generates (a tag, a month, a folder). It is update-only: the
35
36
  * owner's title, breadcrumb, description and introduction are authored, and
@@ -44,6 +45,7 @@ export const PAGE_TEMPLATES = Object.freeze({
44
45
  [TEMPLATE_RECIPE]: Object.freeze({ wireType: "TEMPLATE_TYPE_RECIPE", dataKey: "recipeData" }),
45
46
  [TEMPLATE_ALBUM]: Object.freeze({ wireType: "TEMPLATE_TYPE_ALBUM", dataKey: "albumData" }),
46
47
  [TEMPLATE_PLACE_REVIEW]: Object.freeze({ wireType: "TEMPLATE_TYPE_PLACE_REVIEW", dataKey: "placeReviewData" }),
48
+ [TEMPLATE_LEGAL]: Object.freeze({ wireType: "TEMPLATE_TYPE_LEGAL", dataKey: "legalDocumentData" }),
47
49
  [TEMPLATE_GENERATED]: Object.freeze({ wireType: "TEMPLATE_TYPE_GENERATED", dataKey: "generatedPageData" }),
48
50
  });
49
51
 
@@ -77,6 +79,7 @@ const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
77
79
  [TEMPLATE_PLACE_REVIEW]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "placeId", "rating"]),
78
80
  // Generated pages have no Markdown form.
79
81
  [TEMPLATE_GENERATED]: Object.freeze([]),
82
+ [TEMPLATE_LEGAL]: Object.freeze([]),
80
83
  });
81
84
 
82
85
  export const FRONT_MATTER_KEYS = Object.freeze(
@@ -172,7 +175,17 @@ export function isAuthorableTemplateType(wireType) {
172
175
  return templateNameForWireType(wireType) !== undefined;
173
176
  }
174
177
 
175
- /** Whether a wire `templateType` is a generated page. */
178
+ /** Whether this wire template type is a legal document. */
179
+ export function isLegalTemplateType(wireType) {
180
+ return wireType === PAGE_TEMPLATES[TEMPLATE_LEGAL].wireType;
181
+ }
182
+
183
+ /** The legal identity fields that remain fixed after creation. */
184
+ export function legalIdentity(data) {
185
+ return { documentKey: data.documentKey, documentRole: data.documentRole };
186
+ }
187
+
188
+ /** Whether this wire template type is a generated page. */
176
189
  export function isGeneratedTemplateType(wireType) {
177
190
  return wireType === PAGE_TEMPLATES[TEMPLATE_GENERATED].wireType;
178
191
  }
@@ -342,6 +355,13 @@ function canonicalIngredient(value) {
342
355
  * server's JSON omits defaults and may gain fields).
343
356
  */
344
357
  const DATA_BUILDERS = Object.freeze({
358
+ [TEMPLATE_LEGAL]: (raw) => ({
359
+ documentKey: raw.documentKey ?? "",
360
+ documentRole: raw.documentRole ?? "",
361
+ summaryOfChanges: raw.summaryOfChanges ?? "",
362
+ material: raw.material === true,
363
+ body: raw.body,
364
+ }),
345
365
  [TEMPLATE_ARTICLE]: (raw) => definedEntries([["body", raw.body], ["place", raw.place]]),
346
366
  [TEMPLATE_PLACE_REVIEW]: (raw) => ({ placeId: raw.placeId, rating: raw.rating, body: raw.body }),
347
367
  // Defaults are written out, so a page with no custom title reads the same on every pull.
@@ -415,6 +435,16 @@ function optionalPlace(value, file, path) {
415
435
  /** Strict checks on a source document's `data`, returning it in canonical form. */
416
436
  function canonicalSourceData(template, data, file) {
417
437
  const path = "data";
438
+ if (template === TEMPLATE_LEGAL) {
439
+ requireKeys(data, ["documentKey", "documentRole", "summaryOfChanges", "material", "body"], file, path);
440
+ for (const key of ["documentKey", "documentRole", "summaryOfChanges"]) {
441
+ if (data[key] !== undefined) requireString(data[key], file, `${path}.${key}`);
442
+ }
443
+ if (data.material !== undefined && typeof data.material !== "boolean") {
444
+ throw fail("pages.document_shape", `'${file}' ${path}.material must be true or false.`, file);
445
+ }
446
+ return DATA_BUILDERS[template]({ ...data, body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }) });
447
+ }
418
448
  if (template === TEMPLATE_ARTICLE) {
419
449
  requireKeys(data, ["body", "place"], file, path);
420
450
  return DATA_BUILDERS[template]({
@@ -564,10 +594,10 @@ export function typedDocumentFromJson(parsed, file) {
564
594
  file,
565
595
  );
566
596
  }
567
- if (template === TEMPLATE_GENERATED && (parsed.displayDate !== undefined || parsed.coverImageId !== undefined)) {
597
+ if ((template === TEMPLATE_GENERATED || template === TEMPLATE_LEGAL) && (parsed.displayDate !== undefined || parsed.coverImageId !== undefined)) {
568
598
  throw fail(
569
599
  "pages.document_shape",
570
- `'${file}' is a generated page, which has no displayDate or coverImageId of its own: its document is `
600
+ `'${file}' is a ${template} page, which has no displayDate or coverImageId of its own: its document is `
571
601
  + "{ template, data }.",
572
602
  file,
573
603
  );
@@ -602,6 +632,16 @@ const pagePlaceId = (value) =>
602
632
 
603
633
  /** Lenient projections of what the site returned for each template's data. */
604
634
  const PAGE_PROJECTIONS = Object.freeze({
635
+ [TEMPLATE_LEGAL]: (raw) => {
636
+ const body = presentDocument(raw.body);
637
+ return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_LEGAL]({
638
+ documentKey: typeof raw.documentKey === "string" ? raw.documentKey : "",
639
+ documentRole: typeof raw.documentRole === "string" ? raw.documentRole : "",
640
+ summaryOfChanges: typeof raw.summaryOfChanges === "string" ? raw.summaryOfChanges : "",
641
+ material: raw.material === true,
642
+ body,
643
+ });
644
+ },
605
645
  [TEMPLATE_ARTICLE]: (raw) => {
606
646
  const body = presentDocument(raw.body);
607
647
  return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_ARTICLE]({ body, place: pagePlaceId(raw.place) });
@@ -666,7 +706,7 @@ export function workspaceDocumentFromPage(page, listedTemplateType) {
666
706
  const data = PAGE_PROJECTIONS[name](raw);
667
707
  if (data === undefined) return undefined;
668
708
  // A generated page's date and cover are not part of its source: a push leaves them as the site holds them.
669
- const generated = name === TEMPLATE_GENERATED;
709
+ const generated = name === TEMPLATE_GENERATED || name === TEMPLATE_LEGAL;
670
710
  return typedDocument(name, {
671
711
  displayDate: !generated && typeof page.displayDate === "string" && page.displayDate !== ""
672
712
  ? page.displayDate
@@ -705,7 +745,7 @@ export function contentDocuments(document_) {
705
745
  /** Whether a push sends `displayDate` for pages of this wire template type (every template but free-form). */
706
746
  export function sentDisplayDate(wireType) {
707
747
  const name = templateNameForWireType(wireType);
708
- return name !== undefined && name !== TEMPLATE_FREE_FORM && name !== TEMPLATE_GENERATED;
748
+ return name !== undefined && name !== TEMPLATE_FREE_FORM && name !== TEMPLATE_GENERATED && name !== TEMPLATE_LEGAL;
709
749
  }
710
750
 
711
751
  // The nodes the API accepts in an album or recipe introduction and in a recipe
@@ -903,7 +943,7 @@ export function wireTemplate(document_) {
903
943
  * an update, and an empty string means "clear", which is what the API does.
904
944
  */
905
945
  export function wirePageFields(document_) {
906
- if (documentTemplate(document_) === TEMPLATE_FREE_FORM) return {};
946
+ if ([TEMPLATE_FREE_FORM, TEMPLATE_LEGAL].includes(documentTemplate(document_))) return {};
907
947
  return definedEntries([
908
948
  ["displayDate", document_.displayDate],
909
949
  ["coverImageId", document_.coverImageId],
@@ -1111,6 +1151,10 @@ function parseFlag(fields, key, file) {
1111
1151
  }
1112
1152
 
1113
1153
  /** Generated pages are edited in their .pm.json source only. */
1154
+ export function legalMarkdownFault(file) {
1155
+ return fail("pages.legal_markdown", `'${file}' declares the legal template, which has no Markdown form. Use a .pm.json document.`, file);
1156
+ }
1157
+
1114
1158
  export function generatedMarkdownFault(file) {
1115
1159
  return fail(
1116
1160
  "pages.generated_markdown",
@@ -1149,6 +1193,7 @@ export function templateFrontMatterFault(template, fieldNames, file) {
1149
1193
  */
1150
1194
  export async function typedDocumentFromMarkdown({ template, fields, markdown, file, convert, resolveImage }) {
1151
1195
  if (template === TEMPLATE_GENERATED) throw generatedMarkdownFault(file);
1196
+ if (template === TEMPLATE_LEGAL) throw legalMarkdownFault(file);
1152
1197
  const displayDate = fields.has("displayDate") ? requireDisplayDate(fields.get("displayDate"), file) : undefined;
1153
1198
  let coverImageId;
1154
1199
  if (fields.has("coverImage")) {
@@ -8,6 +8,7 @@ import {
8
8
  import { VERB_APPROVE } from "../constants.js";
9
9
  import { SiteAuthoringError } from "../errors.js";
10
10
  import { boundedByBytes, boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
11
+ import { isLegalTemplateType } from "../typed-pages.js";
11
12
  import { displayPagePath, normalizePagePath, readManifest, writeManifest } from "../workspace.js";
12
13
 
13
14
  /**
@@ -45,7 +46,7 @@ export async function approve(invocation) {
45
46
 
46
47
  // Positional arguments narrow the selection to those page paths; `cli.js`
47
48
  // hands them over as `pagePaths`, and a programmatic caller can supply the
48
- // same key directly. With none, every draft the manifest tracks is staged.
49
+ // same key directly. With none, legal drafts are skipped and the other tracked drafts are staged.
49
50
  //
50
51
  // A path this CLI cannot use is refused by name rather than dropped. Dropping
51
52
  // it silently is the worst available outcome: the remaining selection would
@@ -80,16 +81,29 @@ export async function approve(invocation) {
80
81
  );
81
82
  }
82
83
  const pathOf = (summary) => normalizePagePath(summary.path) ?? summary.path;
83
- const candidates = pages.filter((summary) =>
84
- !isGonePage(summary)
85
- && summary.hasDraft
86
- && manifestByPageId.has(summary.pageId)
87
- && (requestedPaths === undefined || requestedPaths.has(pathOf(summary))));
84
+ const tracked = pages.filter((summary) => !isGonePage(summary) && manifestByPageId.has(summary.pageId));
85
+ const isLegal = (page) => isLegalTemplateType(page.templateType)
86
+ || isLegalTemplateType(manifestByPageId.get(page.pageId)?.templateType);
87
+ if (requestedPaths !== undefined && tracked.some((page) => requestedPaths.has(pathOf(page)) && isLegal(page))) {
88
+ throw new SiteAuthoringError("approve.legal_not_allowed", "Legal drafts must be approved in the app. Nothing was approved.", { field: "pages" });
89
+ }
90
+ const selected = tracked.filter((summary) =>
91
+ summary.hasDraft && (requestedPaths === undefined || requestedPaths.has(pathOf(summary))));
92
+ const legalDrafts = requestedPaths === undefined ? selected.filter(isLegal) : [];
93
+ const candidates = selected.filter((page) => !isLegal(page));
88
94
 
89
95
  // A requested page with nothing to approve is reported, not refused, so
90
96
  // one already-approved path does not stop the rest of the batch. A path
91
97
  // that names no page this workspace tracks is still refused by name.
92
- const skipped = [];
98
+ const skipped = legalDrafts.map((page) => ({
99
+ pageId: page.pageId,
100
+ path: displayPagePath(pathOf(page)),
101
+ status: page.status,
102
+ reason: "legal_requires_app",
103
+ }));
104
+ if (legalDrafts.length > 0) {
105
+ onProgress(`Skipping ${legalDrafts.length} legal draft${legalDrafts.length === 1 ? "" : "s"}; approve ${legalDrafts.length === 1 ? "it" : "them"} in the app.`);
106
+ }
93
107
  if (requestedPaths !== undefined) {
94
108
  const matched = new Set(candidates.map(pathOf));
95
109
  const unknown = [];
@@ -137,7 +151,9 @@ export async function approve(invocation) {
137
151
 
138
152
  if (candidates.length === 0) {
139
153
  onProgress(
140
- skipped.length > 0
154
+ legalDrafts.length > 0
155
+ ? "No ordinary drafts to approve; approve the skipped legal drafts in the app."
156
+ : skipped.length > 0
141
157
  ? "Nothing to approve: the requested page(s) have no pending draft."
142
158
  : "No page in this workspace is carrying a draft; there is nothing to approve.",
143
159
  );
@@ -145,7 +161,7 @@ export async function approve(invocation) {
145
161
  approved: { total: 0, items: [] },
146
162
  ...skippedResult,
147
163
  stagedNotDeployed: true,
148
- nextStep: "deploy --staging",
164
+ nextStep: legalDrafts.length > 0 ? "approve legal drafts in the app" : "deploy --staging",
149
165
  });
150
166
  }
151
167
 
@@ -22,6 +22,7 @@ import {
22
22
  VERB_LOGIN,
23
23
  VERB_LOGOUT,
24
24
  VERB_SITES,
25
+ SURFACE_STANDARD,
25
26
  } from "../constants.js";
26
27
  import { assertCredentialCapacity, credentialApiOrigin, saveCredential } from "../credentials.js";
27
28
  import { asSiteAuthoringError, hasControlCharacter, SiteAuthoringError } from "../errors.js";
@@ -156,7 +157,7 @@ function describeMinutes(milliseconds) {
156
157
  * revoked — named by its exact id and display prefix when the server supplied
157
158
  * them, and by its display name otherwise.
158
159
  */
159
- function terminalFailure(answer, keyName) {
160
+ function terminalFailure(answer, keyName, loginCommand) {
160
161
  const status = answer.status;
161
162
  switch (status) {
162
163
  case CLI_AUTHORIZATION_CLAIM_STATUS_DENIED:
@@ -168,7 +169,7 @@ function terminalFailure(answer, keyName) {
168
169
  case CLI_AUTHORIZATION_CLAIM_STATUS_EXPIRED:
169
170
  return new SiteAuthoringError(
170
171
  "login.expired",
171
- `The authorization expired before it was approved. Run '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
172
+ `The authorization expired before it was approved. Run '${loginCommand}' again.`,
172
173
  { status },
173
174
  );
174
175
  case CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED:
@@ -179,7 +180,7 @@ function terminalFailure(answer, keyName) {
179
180
  ? `Revoke the credential with id ${answer.keyId}`
180
181
  + (answer.keyPrefix === undefined ? "" : ` (display prefix ${answer.keyPrefix})`)
181
182
  : `Revoke the credential named '${keyName}'`)
182
- + ` under Account -> Settings -> API keys, then run '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
183
+ + ` under Account -> Settings -> API keys, then run '${loginCommand}' again.`,
183
184
  { status },
184
185
  );
185
186
  default:
@@ -191,11 +192,11 @@ function terminalFailure(answer, keyName) {
191
192
  }
192
193
  }
193
194
 
194
- function translateClaimApiError(error) {
195
+ function translateClaimApiError(error, loginCommand) {
195
196
  if (!(error instanceof ApiError) || error.httpStatus !== 404) return error;
196
197
  return new SiteAuthoringError(
197
198
  "login.unknown",
198
- `Taproot does not recognize this authorization. Run '${CLI_BINARY_NAME} ${VERB_LOGIN}' to start a new one.`,
199
+ `Taproot does not recognize this authorization. Run '${loginCommand}' to start a new one.`,
199
200
  { status: error.status },
200
201
  );
201
202
  }
@@ -222,13 +223,13 @@ function translateStartApiError(error) {
222
223
  * display prefix, never by value — and direct the owner to revoke it, because
223
224
  * silence here is a live credential the owner does not know exists.
224
225
  */
225
- function unstoredCredentialFailure(error, claimed) {
226
+ function unstoredCredentialFailure(error, claimed, loginCommand) {
226
227
  const cause = asSiteAuthoringError(error);
227
228
  return new SiteAuthoringError(
228
229
  "login.credential_unstored",
229
230
  `A credential WAS issued (id ${claimed.keyId}, display prefix ${claimed.keyPrefix}) but could not `
230
231
  + `be stored: ${cause.message} Revoke it under Account -> Settings -> API keys, then run `
231
- + `'${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
232
+ + `'${loginCommand}' again.`,
232
233
  { ...(cause.status === undefined ? {} : { status: cause.status }) },
233
234
  );
234
235
  }
@@ -311,7 +312,12 @@ async function handOffToBrowser({ verificationUrl, userCode, onProgress, awaitEn
311
312
 
312
313
  export async function login(invocation) {
313
314
  const keyName = normalizeKeyName(invocation.keyName);
314
- const { apiBaseUrl, client, now, onProgress } = await openAnonymousSession(invocation);
315
+ const { config, apiBaseUrl, client, now, onProgress } = await openAnonymousSession(invocation);
316
+ const legalDraftSiteId = invocation.legalDrafts ? config?.siteId : undefined;
317
+ if (invocation.legalDrafts && (!legalDraftSiteId || (config.authoringSurface !== undefined && config.authoringSurface !== SURFACE_STANDARD))) {
318
+ throw new SiteAuthoringError("auth.legal_site_required", "Select a standard site with taproot-site use before requesting legal draft access.");
319
+ }
320
+ const loginCommand = `${CLI_BINARY_NAME} ${VERB_LOGIN}${legalDraftSiteId === undefined ? "" : " --legal-drafts"}`;
315
321
  const environment = invocation.environment ?? process.env;
316
322
  const apiOrigin = credentialApiOrigin(apiBaseUrl);
317
323
 
@@ -323,11 +329,11 @@ export async function login(invocation) {
323
329
  // now rather than at the write, which is the same failure arriving after the
324
330
  // damage. A residual write failure can still happen (the disk can fill during
325
331
  // the poll), which is what `unstoredCredentialFailure` below is for.
326
- await assertCredentialCapacity(environment, apiOrigin);
332
+ await assertCredentialCapacity(environment, apiOrigin, { siteId: legalDraftSiteId });
327
333
 
328
334
  let authorization;
329
335
  try {
330
- authorization = await startCliAuthorization(client, { keyName });
336
+ authorization = await startCliAuthorization(client, { keyName, legalDraftSiteId });
331
337
  } catch (error) {
332
338
  // Announced only when the refusal survives translation: sign-in is the
333
339
  // earlier of the two calls that carry this CLI's version, so it is where
@@ -390,7 +396,7 @@ export async function login(invocation) {
390
396
  // total wall time.
391
397
  read: async () => {
392
398
  try {
393
- return await claimCliAuthorization(client, authorization.deviceCode);
399
+ return await claimCliAuthorization(client, authorization.deviceCode, {}, { legalDraftSiteId, now });
394
400
  } catch (error) {
395
401
  // A throttle is the endpoint doing its job, not a verdict on this
396
402
  // authorization. Back off within the same deadline instead of
@@ -441,7 +447,7 @@ export async function login(invocation) {
441
447
  if (error.ambiguousMutation === true) claimResponseLost = true;
442
448
  throw error;
443
449
  }
444
- throw translateClaimApiError(error);
450
+ throw translateClaimApiError(error, loginCommand);
445
451
  }
446
452
  },
447
453
  evaluate: (value) => {
@@ -464,7 +470,7 @@ export async function login(invocation) {
464
470
  // each line pushes the code the operator is still reading off screen.
465
471
  return { done: false };
466
472
  }
467
- throw terminalFailure(value, keyName);
473
+ throw terminalFailure(value, keyName, loginCommand);
468
474
  },
469
475
  // A deadline reached after a lost answer cannot honestly say nothing was
470
476
  // issued: one of those lost checks may have consumed the approval and
@@ -477,12 +483,12 @@ export async function login(invocation) {
477
483
  "The polling deadline passed, and at least one check's answer was lost in transit — a credential "
478
484
  + `MAY have been issued without this command receiving it. Nothing was stored locally; check `
479
485
  + `Account -> Settings -> API keys for a credential named '${keyName}' and revoke it before running `
480
- + `'${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
486
+ + `'${loginCommand}' again.`,
481
487
  )
482
488
  : new SiteAuthoringError(
483
489
  "login.timeout",
484
490
  "The approval was not completed before this command's polling deadline. Nothing was stored; "
485
- + `run '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
491
+ + `run '${loginCommand}' again.`,
486
492
  ),
487
493
  });
488
494
  } catch (error) {
@@ -498,9 +504,12 @@ export async function login(invocation) {
498
504
  "site.cancelled",
499
505
  "The login was cancelled after a claim's answer was lost — a credential MAY have been issued without "
500
506
  + `this command receiving it. Check Account -> Settings -> API keys for a credential named '${keyName}' `
501
- + `and revoke it before running '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
507
+ + `and revoke it before running '${loginCommand}' again.`,
502
508
  );
503
509
  }
510
+ if (error instanceof SiteAuthoringError && error.code === "site.cancelled" && legalDraftSiteId !== undefined) {
511
+ throw new SiteAuthoringError("site.cancelled", `${error.message} Run '${loginCommand}' again.`);
512
+ }
504
513
  throw error;
505
514
  }
506
515
 
@@ -510,6 +519,7 @@ export async function login(invocation) {
510
519
  environment,
511
520
  {
512
521
  apiOrigin,
522
+ ...(legalDraftSiteId === undefined ? {} : { purpose: claimed.purpose, siteId: claimed.siteId, capabilities: claimed.capabilities, legalDraftPlatformSite: claimed.legalDraftPlatformSite }),
513
523
  accountId: claimed.accountId,
514
524
  key: claimed.key,
515
525
  keyId: claimed.keyId,
@@ -519,7 +529,13 @@ export async function login(invocation) {
519
529
  { now },
520
530
  );
521
531
  } catch (error) {
522
- throw unstoredCredentialFailure(error, claimed);
532
+ throw unstoredCredentialFailure(error, claimed, loginCommand);
533
+ }
534
+ if (legalDraftSiteId !== undefined) {
535
+ onProgress(`Stored legal draft access for site ${legalDraftSiteId} in ${credentialPath}. The credential itself is never displayed.`);
536
+ onProgress(`Use '${CLI_BINARY_NAME} --legal-drafts pages push' to save drafts; approve and publish them in Taproot.`);
537
+ onProgress(`Discard this local grant with '${CLI_BINARY_NAME} --legal-drafts ${VERB_LOGOUT}'; revoke it in Account -> Settings -> API keys.`);
538
+ return successResult(VERB_LOGIN, legalDraftSiteId, { accountId: claimed.accountId, keyId: claimed.keyId, keyPrefix: claimed.keyPrefix, keyExpiresAt: claimed.keyExpiresAt, userCode: authorization.userCode, verificationUrl, credentialPath, legalDrafts: true });
523
539
  }
524
540
  onProgress(`Stored the sign-in in ${credentialPath}. The credential itself is never displayed.`);
525
541
  onProgress(`Run '${CLI_BINARY_NAME} ${VERB_SITES}' to see the sites you can author.`);
@@ -1,6 +1,7 @@
1
1
  import { CLI_BINARY_NAME, VERB_LOGOUT } from "../constants.js";
2
2
  import { credentialApiOrigin, removeCredential } from "../credentials.js";
3
3
  import { openAnonymousSession, successResult } from "../session.js";
4
+ import { SiteAuthoringError } from "../errors.js";
4
5
 
5
6
  /**
6
7
  * `taproot-site logout` — local discard, and nothing more.
@@ -19,11 +20,20 @@ import { openAnonymousSession, successResult } from "../session.js";
19
20
  * discards is addressed by API origin.
20
21
  */
21
22
  export async function logout(invocation) {
22
- const { apiBaseUrl, onProgress } = await openAnonymousSession(invocation);
23
+ const { config, apiBaseUrl, onProgress } = await openAnonymousSession(invocation);
24
+ if (invocation.legalDrafts && !config?.siteId) {
25
+ throw new SiteAuthoringError("config.site_missing", "Select a site before discarding its legal draft grant.");
26
+ }
23
27
  const { path: credentialPath, removed } = await removeCredential(
24
28
  invocation.environment ?? process.env,
25
29
  credentialApiOrigin(apiBaseUrl),
30
+ { siteId: invocation.legalDrafts ? config.siteId : undefined },
26
31
  );
32
+ if (invocation.legalDrafts) {
33
+ onProgress(removed ? `Discarded the legal draft grant for site ${config.siteId} from ${credentialPath}.` : `No legal draft grant for site ${config.siteId} was found in ${credentialPath}.`);
34
+ if (removed) onProgress("This is a local discard; revoke the credential in Account -> Settings -> API keys.");
35
+ return successResult(VERB_LOGOUT, config.siteId, { removed, credentialPath, legalDrafts: true });
36
+ }
27
37
  onProgress(
28
38
  removed
29
39
  ? `Discarded the stored Taproot sign-in from ${credentialPath}.`
@@ -35,5 +45,9 @@ export async function logout(invocation) {
35
45
  + `Account -> Settings -> API keys; '${CLI_BINARY_NAME} ${VERB_LOGOUT}' cannot revoke it.`,
36
46
  );
37
47
  }
48
+ onProgress(
49
+ "Separate site legal draft grants, if any, remain stored. Select each site and run "
50
+ + `'${CLI_BINARY_NAME} --legal-drafts ${VERB_LOGOUT}' to discard its grant, or revoke the keys in Account -> Settings -> API keys.`,
51
+ );
38
52
  return successResult(VERB_LOGOUT, undefined, { removed, credentialPath });
39
53
  }
@@ -14,7 +14,7 @@ import { FOOTER_SETTINGS_FILE } from "../footer-workspace.js";
14
14
  import { afterSubmitPage, FORM_FILE_EXTENSION, FORMS_DIRECTORY, normalizePagePath as normalizeFormPagePath } from "../forms-contract.js";
15
15
  import { normalizeRedirectPath } from "../site-checks.js";
16
16
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
17
- import { isAuthorableTemplateType, isGeneratedTemplateType } from "../typed-pages.js";
17
+ import { isAuthorableTemplateType, isGeneratedTemplateType, isLegalTemplateType } from "../typed-pages.js";
18
18
  import {
19
19
  deleteWorkspaceFile,
20
20
  displayPagePath,
@@ -194,10 +194,10 @@ function requireDeletable(manifest, entry, pagePath) {
194
194
  shown,
195
195
  );
196
196
  }
197
- if (!isAuthorableTemplateType(entry.templateType)) {
197
+ if (isLegalTemplateType(entry.templateType) || !isAuthorableTemplateType(entry.templateType)) {
198
198
  throw refusal(
199
199
  "pages.delete_not_authorable",
200
- `'${shown}' is a ${entry.templateType ?? "system-managed"} page, which this CLI does not author; delete it in the app.`,
200
+ `'${shown}' is a ${entry.templateType ?? "system-managed"} page, which this CLI cannot delete; delete it in the app.`,
201
201
  shown,
202
202
  );
203
203
  }
@@ -241,6 +241,9 @@ export async function pagesDelete(invocation) {
241
241
  shown,
242
242
  );
243
243
  }
244
+ if (live !== undefined && isLegalTemplateType(live.templateType)) {
245
+ throw refusal("pages.delete_not_authorable", `'${shown}' is a legal page; delete it in the app.`, shown);
246
+ }
244
247
  const alreadyGone = live === undefined || isGonePage(live);
245
248
  if (!alreadyGone) {
246
249
  // The fence `pages push` uses, against the revision this workspace last reconciled with.