@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 +28 -5
- package/package.json +1 -1
- package/src/api.js +27 -5
- package/src/capabilities.js +1 -0
- package/src/changelog.json +22 -0
- package/src/cli.js +40 -6
- package/src/constants.js +1 -1
- package/src/credentials.js +55 -14
- package/src/reference-help.js +3 -3
- package/src/session.js +34 -0
- package/src/transport.js +55 -17
- package/src/typed-page-reference.js +19 -26
- package/src/typed-pages.js +51 -6
- package/src/verbs/approve.js +25 -9
- package/src/verbs/login.js +33 -17
- package/src/verbs/logout.js +15 -1
- package/src/verbs/media-upload.js +2 -0
- package/src/verbs/pages-delete.js +6 -3
- package/src/verbs/pages-push.js +41 -3
- package/src/verbs/plan.js +6 -1
- package/src/verbs/pull.js +55 -6
- package/src/verbs/whoami.js +10 -1
- package/src/workspace.js +3 -0
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
|
|
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
|
-
*
|
|
976
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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 <
|
|
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
|
-
|
|
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
|
|
359
|
-
readOnly:
|
|
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
|
-
"
|
|
365
|
-
+ "
|
|
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,
|
|
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.
|
|
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
|
-
"
|
|
406
|
-
+ "
|
|
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
|
-
"
|
|
410
|
-
+ "
|
|
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').
|
|
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: "
|
|
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:
|
|
435
|
-
result: "
|
|
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
|
|
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")
|
package/src/typed-pages.js
CHANGED
|
@@ -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
|
|
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
|
|
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_)
|
|
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")) {
|
package/src/verbs/approve.js
CHANGED
|
@@ -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,
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
package/src/verbs/login.js
CHANGED
|
@@ -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 '${
|
|
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 '${
|
|
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 '${
|
|
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
|
-
+ `'${
|
|
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
|
-
+ `'${
|
|
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 '${
|
|
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 '${
|
|
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.`);
|