@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/src/transport.js CHANGED
@@ -51,6 +51,10 @@ const IDEMPOTENT_METHODS = new Set(["GET"]);
51
51
 
52
52
  /** An exchanged credential is renewed once it is this close to expiring. */
53
53
  const CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS = 5 * 60_000;
54
+ const GATEWAY_UPLOAD_ATTEMPTS = 4;
55
+ const GATEWAY_CAPACITY_WINDOW_MILLISECONDS = 15_000;
56
+ const UPLOAD_CAPACITY_HEADER = "x-taproot-upload-admission";
57
+ const UPLOAD_CAPACITY_REFUSAL = "capacity-before-claim";
54
58
 
55
59
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
56
60
  const RETRYABLE_RESPONSE_ERRORS = new Set([
@@ -965,15 +969,16 @@ export class SiteApiClient {
965
969
  }
966
970
 
967
971
  /**
968
- * Performs one presigned whole-object PUT. The signed headers are echoed
972
+ * Performs a whole-object PUT. Signed S3 headers are echoed
969
973
  * exactly as the server returned them and are never recomputed: the values
970
974
  * are covered by the SigV4 signature, so a "helpfully" regenerated
971
975
  * Content-Type or an added header turns into an opaque S3 403. The bearer is
972
976
  * never forwarded — the signature is the authorization, and sending the
973
977
  * site key to an object store would leak it outside the reviewed origin.
974
978
  *
975
- * The PUT of an immutable object is idempotent, so this is the one mutation
976
- * shape that may be replayed.
979
+ * S3 create-only uploads retain their existing retry contract. A gateway
980
+ * capability is consumed once before the body is read. Only an explicit
981
+ * capacity refusal before that claim permits a bounded retry.
977
982
  */
978
983
  async upload({ url: uploadUrl, requiredHeaders } = {}, bodyBytes) {
979
984
  // A video original is streamed from disk: `{ byteLength, stream, timeoutMilliseconds }`,
@@ -1040,7 +1045,8 @@ export class SiteApiClient {
1040
1045
  names.add(normalizedName);
1041
1046
  headers.set(name, value);
1042
1047
  }
1043
- if (!headers.has("content-type") || !headers.has("content-length")) {
1048
+ const gateway = headers.has("x-taproot-upload-capability");
1049
+ if (!headers.has("content-type") || (!gateway && !headers.has("content-length"))) {
1044
1050
  throw new SiteAuthoringError(
1045
1051
  "upload.headers_invalid",
1046
1052
  "The signed upload capability is missing required content headers.",
@@ -1049,31 +1055,48 @@ export class SiteApiClient {
1049
1055
  // The declared length is bound into the signature, so a body of any other
1050
1056
  // size cannot succeed. Failing here names the real problem instead of
1051
1057
  // surfacing an S3 signature error.
1052
- if (headers.get("content-length") !== String(bodyBytes.byteLength)) {
1058
+ if (gateway && (
1059
+ url.origin !== this.apiOrigin
1060
+ || !url.pathname.startsWith(this.apiPathPrefix)
1061
+ || !/^\/api\/v1\/images\/uploads\/[0-9a-f-]{36}\/content$/u.test(url.pathname)
1062
+ || url.search !== ""
1063
+ )) {
1064
+ throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an invalid gateway URL.");
1065
+ }
1066
+ if (!gateway && headers.get("content-length") !== String(bodyBytes.byteLength)) {
1053
1067
  throw new SiteAuthoringError(
1054
1068
  "upload.content_length_invalid",
1055
1069
  "The signed upload content length does not match the bytes being uploaded.",
1056
1070
  );
1057
1071
  }
1058
1072
 
1059
- for (let attempt = 0; attempt < LIMITS.uploadAttempts; attempt += 1) {
1073
+ const maximumAttempts = gateway ? GATEWAY_UPLOAD_ATTEMPTS : LIMITS.uploadAttempts;
1074
+ const startedAt = this.now();
1075
+ const uploadDeadline = startedAt + ((streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds);
1076
+ for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
1077
+ if (this.signal?.aborted) throw cancelledError(false);
1078
+ const remaining = uploadDeadline - this.now();
1079
+ if (gateway && remaining <= 0) {
1080
+ throw new SiteAuthoringError("upload.capacity_exhausted", "The image upload exhausted its attempt window.");
1081
+ }
1060
1082
  let response;
1083
+ const attemptSignal = operationSignal(
1084
+ this.signal,
1085
+ gateway ? remaining : (streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
1086
+ this.timeoutSignal,
1087
+ );
1061
1088
  try {
1062
- response = await this.fetch(url, {
1089
+ response = await awaitWithSignal(this.fetch(url, {
1063
1090
  method: "PUT",
1064
1091
  redirect: "error",
1065
- signal: operationSignal(
1066
- this.signal,
1067
- (streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
1068
- this.timeoutSignal,
1069
- ),
1092
+ signal: attemptSignal,
1070
1093
  headers,
1071
1094
  ...(streamed
1072
1095
  ? { body: Readable.toWeb(bodyBytes.stream()), duplex: "half" }
1073
1096
  : { body: bodyBytes }),
1074
- });
1097
+ }), attemptSignal);
1075
1098
  } catch {
1076
- if (attempt + 1 < LIMITS.uploadAttempts && !this.signal?.aborted) {
1099
+ if (!gateway && attempt + 1 < maximumAttempts && !this.signal?.aborted) {
1077
1100
  await this.sleep(delayForAttempt(attempt), this.signal);
1078
1101
  continue;
1079
1102
  }
@@ -1082,14 +1105,29 @@ export class SiteApiClient {
1082
1105
  "The whole-object upload ended without an authoritative response.",
1083
1106
  );
1084
1107
  }
1085
- await cancelBody(response);
1108
+ await cancelBody(response, attemptSignal);
1086
1109
  if (response.status >= 200 && response.status < 300 && !response.redirected) return;
1087
- if (RETRYABLE_STATUS.has(response.status) && attempt + 1 < LIMITS.uploadAttempts) {
1110
+ const capacityRefused = gateway && !response.redirected && response.status === HTTP_TOO_MANY_REQUESTS
1111
+ && response.headers.get(UPLOAD_CAPACITY_HEADER) === UPLOAD_CAPACITY_REFUSAL;
1112
+ const retryAfter = response.headers.get("retry-after") ?? "";
1113
+ if (capacityRefused && /^[1-5]$/u.test(retryAfter) && attempt + 1 < maximumAttempts) {
1114
+ const delay = Number(retryAfter) * 1000;
1115
+ const capacityRemaining = Math.min(uploadDeadline, startedAt + GATEWAY_CAPACITY_WINDOW_MILLISECONDS) - this.now();
1116
+ if (delay < capacityRemaining) {
1117
+ // TR01295: the server guarantees that this rejected PUT did not
1118
+ // claim the capability. Network failures and 5xx never retry it.
1119
+ await awaitWithSignal(this.sleep(delay, this.signal), operationSignal(
1120
+ this.signal, capacityRemaining, this.timeoutSignal,
1121
+ ));
1122
+ if (this.now() - startedAt < GATEWAY_CAPACITY_WINDOW_MILLISECONDS) continue;
1123
+ }
1124
+ }
1125
+ if (!gateway && RETRYABLE_STATUS.has(response.status) && attempt + 1 < maximumAttempts) {
1088
1126
  await this.sleep(delayForAttempt(attempt), this.signal);
1089
1127
  continue;
1090
1128
  }
1091
1129
  throw new SiteAuthoringError(
1092
- RETRYABLE_STATUS.has(response.status) ? "upload.ambiguous" : "upload.rejected",
1130
+ RETRYABLE_STATUS.has(response.status) && !capacityRefused ? "upload.ambiguous" : "upload.rejected",
1093
1131
  `The whole-object upload was rejected with HTTP ${response.status}.`,
1094
1132
  { status: `http:${response.status}` },
1095
1133
  );
@@ -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.`);