@taprootio/site-authoring 0.2.0 → 0.4.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 +106 -13
- package/examples/riverbend-wellness/manifest.fixture.json +79 -0
- package/examples/riverbend-wellness/nav.json +26 -0
- package/examples/riverbend-wellness/pages/index.md +93 -0
- package/examples/riverbend-wellness/pages/visit.md +33 -0
- package/examples/riverbend-wellness/redirects.json +33 -0
- package/examples/riverbend-wellness/settings/brand.json +7 -0
- package/examples/riverbend-wellness/settings/site-header.json +21 -0
- package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
- package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
- package/package.json +2 -1
- package/src/api.js +203 -3
- package/src/cli-release.js +152 -0
- package/src/cli.js +120 -11
- package/src/constants.js +59 -2
- package/src/contact-url.js +63 -0
- package/src/content/components.js +26 -4
- package/src/content/free-form-section-registry.json +3 -2
- package/src/content/inline-facts.js +5 -1
- package/src/content/tiptap-prosemirror.ts +26 -12
- package/src/credentials.js +39 -4
- package/src/errors.js +25 -0
- package/src/fixture-contract.js +64 -0
- package/src/footer-contract.js +28 -30
- package/src/json-path-diff.js +167 -0
- package/src/output.js +9 -0
- package/src/presentation-reference.js +5 -3
- package/src/redirects-contract.js +444 -0
- package/src/redirects-workspace.js +72 -0
- package/src/reference-help.js +262 -10
- package/src/session.js +161 -7
- package/src/transport.js +111 -6
- package/src/verbs/approve.js +7 -2
- package/src/verbs/deploy.js +29 -2
- package/src/verbs/footer-push.js +7 -2
- package/src/verbs/index.js +7 -1
- package/src/verbs/login.js +11 -1
- package/src/verbs/media-upload.js +7 -2
- package/src/verbs/nav-push.js +17 -11
- package/src/verbs/pages-push.js +93 -8
- package/src/verbs/preview-page.js +7 -2
- package/src/verbs/preview-revoke.js +7 -2
- package/src/verbs/pull.js +357 -22
- package/src/verbs/redirects-pull.js +90 -0
- package/src/verbs/redirects-push.js +100 -0
- package/src/verbs/status.js +90 -3
- package/src/verbs/theme-push.js +7 -2
- package/src/verbs/validate.js +93 -18
- package/src/verbs/whoami.js +79 -1
- package/src/workspace.js +153 -15
package/src/transport.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CAPABILITY_REFUSAL_FIELD,
|
|
3
|
+
CAPABILITY_REFUSAL_REASON,
|
|
2
4
|
CLI_NAME,
|
|
5
|
+
CLI_UPGRADE_REFUSAL_FIELD,
|
|
3
6
|
CLI_VERSION,
|
|
4
7
|
CREDENTIAL_REFUSAL_FIELD,
|
|
5
8
|
GRPC_RESOURCE_EXHAUSTED,
|
|
@@ -9,6 +12,8 @@ import {
|
|
|
9
12
|
LIMITS,
|
|
10
13
|
PLAN_LIMIT_REFUSAL_FIELD,
|
|
11
14
|
PUBLISH_KEY_ENVIRONMENT_VARIABLE,
|
|
15
|
+
REFUSAL_CAPABILITY_MISSING,
|
|
16
|
+
REFUSAL_CLI_OUTDATED,
|
|
12
17
|
REFUSAL_CREDENTIAL_REJECTED,
|
|
13
18
|
REFUSAL_PLAN_LIMIT,
|
|
14
19
|
REFUSAL_PLATFORM_PAUSED,
|
|
@@ -330,21 +335,95 @@ function fieldDescriptions(value) {
|
|
|
330
335
|
return descriptions;
|
|
331
336
|
}
|
|
332
337
|
|
|
338
|
+
/**
|
|
339
|
+
* A delegation capability or permission key as the server spells it. Anything
|
|
340
|
+
* outside this shape is dropped rather than repaired: these values reach a
|
|
341
|
+
* terminal and a machine-readable result, and a name the CLI cannot vouch for
|
|
342
|
+
* is worse than a name it does not print.
|
|
343
|
+
*/
|
|
344
|
+
const CAPABILITY_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/u;
|
|
345
|
+
const MAXIMUM_CAPABILITY_NAMES = 16;
|
|
346
|
+
|
|
347
|
+
function capabilityNames(value) {
|
|
348
|
+
if (typeof value !== "string" || value === "") return [];
|
|
349
|
+
return value
|
|
350
|
+
.split(",")
|
|
351
|
+
.map((name) => name.trim())
|
|
352
|
+
.filter((name) => name.length <= 200 && CAPABILITY_NAME.test(name))
|
|
353
|
+
.slice(0, MAXIMUM_CAPABILITY_NAMES);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The key-mode capability denial the authoring API attaches as a
|
|
358
|
+
* `google.rpc.ErrorInfo` detail (TR00691), or undefined.
|
|
359
|
+
*
|
|
360
|
+
* The same bounded breadth-first walk the field extractors use, for the same
|
|
361
|
+
* reason: transcoded `details[]` entries are `Any`-packed, and pinning one path
|
|
362
|
+
* through them is how a contract change silently empties an extractor. The
|
|
363
|
+
* `reason` is the contract — a stable, namespaced classifier — so the walk
|
|
364
|
+
* keys on it rather than on `@type`, which a future packing could reshape.
|
|
365
|
+
*/
|
|
366
|
+
export function capabilityRefusal(value) {
|
|
367
|
+
const queue = [{ value, depth: 0 }];
|
|
368
|
+
let work = 0;
|
|
369
|
+
while (queue.length > 0 && work < 1_000) {
|
|
370
|
+
const current = queue.shift();
|
|
371
|
+
work += 1;
|
|
372
|
+
if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
|
|
373
|
+
const metadata = current.value.metadata;
|
|
374
|
+
if (
|
|
375
|
+
current.value.reason === CAPABILITY_REFUSAL_REASON
|
|
376
|
+
&& metadata !== null
|
|
377
|
+
&& typeof metadata === "object"
|
|
378
|
+
&& !Array.isArray(metadata)
|
|
379
|
+
) {
|
|
380
|
+
const permission = capabilityNames(metadata.permission)[0];
|
|
381
|
+
// A detail naming no permission is not this refusal, whatever it says:
|
|
382
|
+
// the guidance is built entirely out of these three values, and half of
|
|
383
|
+
// one would be a message nobody could act on.
|
|
384
|
+
if (permission !== undefined) {
|
|
385
|
+
return Object.freeze({
|
|
386
|
+
permission,
|
|
387
|
+
granted: Object.freeze(capabilityNames(metadata.granted)),
|
|
388
|
+
required: Object.freeze(capabilityNames(metadata.required)),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const child of Object.values(current.value)) {
|
|
393
|
+
if (child !== null && typeof child === "object") queue.push({ value: child, depth: current.depth + 1 });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
|
|
333
399
|
export class ApiError extends SiteAuthoringError {
|
|
334
400
|
constructor(httpStatus, body) {
|
|
335
401
|
const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
|
|
336
402
|
const fields = fieldViolations(body);
|
|
403
|
+
const capability = capabilityRefusal(body);
|
|
337
404
|
super(
|
|
338
405
|
"api.request_rejected",
|
|
339
406
|
fields.length > 0
|
|
340
407
|
? `Taproot rejected the request field '${fields[0]}'.`
|
|
408
|
+
: capability !== undefined
|
|
409
|
+
? `Taproot refused the request: this credential does not carry a capability granting '${
|
|
410
|
+
capability.permission
|
|
411
|
+
}'.`
|
|
341
412
|
: `Taproot rejected the request with HTTP ${httpStatus}.`,
|
|
342
|
-
{
|
|
413
|
+
{
|
|
414
|
+
// `fields` stays exactly what the wire named. The capability field is
|
|
415
|
+
// the CLI's own label for a refusal that carries no field violation at
|
|
416
|
+
// all, so it lands on the error without joining the wire-derived set
|
|
417
|
+
// that `hasField()` classifies on.
|
|
418
|
+
field: fields[0] ?? (capability === undefined ? undefined : CAPABILITY_REFUSAL_FIELD),
|
|
419
|
+
status: grpcCode === undefined ? `http:${httpStatus}` : `grpc:${grpcCode}`,
|
|
420
|
+
},
|
|
343
421
|
);
|
|
344
422
|
this.httpStatus = httpStatus;
|
|
345
423
|
this.grpcCode = grpcCode;
|
|
346
424
|
this.fields = fields;
|
|
347
425
|
this.violationDescriptions = fieldDescriptions(body);
|
|
426
|
+
this.capability = capability;
|
|
348
427
|
}
|
|
349
428
|
|
|
350
429
|
hasField(field) {
|
|
@@ -366,10 +445,17 @@ export class ApiError extends SiteAuthoringError {
|
|
|
366
445
|
|
|
367
446
|
/**
|
|
368
447
|
* Classifies a refusal into the one thing the caller should do next. The
|
|
369
|
-
*
|
|
448
|
+
* six kinds are genuinely different behaviors, and collapsing them into
|
|
370
449
|
* "the request failed" is what makes an agent retry a revoked credential
|
|
371
450
|
* forever or treat a plan ceiling as a transient outage:
|
|
372
451
|
*
|
|
452
|
+
* - `cli_outdated` (field `CliUpgradeRequired`) — this CLI is behind the
|
|
453
|
+
* latest published release, and Taproot supports only the latest. **Upgrade
|
|
454
|
+
* this package**; nothing about the credential, the request, or the site is
|
|
455
|
+
* wrong, and no retry of this version can succeed. Classified first,
|
|
456
|
+
* because it is the one refusal that says the client itself is the problem:
|
|
457
|
+
* every other kind describes a state the current binary could act on
|
|
458
|
+
* (TR00703).
|
|
373
459
|
* - `platform_paused` (field `SiteAuthoringRollout`) — external authoring is
|
|
374
460
|
* switched off platform-wide. **Retry later** or ask the operator; the
|
|
375
461
|
* credential and the request are both fine.
|
|
@@ -393,6 +479,13 @@ export class ApiError extends SiteAuthoringError {
|
|
|
393
479
|
* never invents the numeric limit.
|
|
394
480
|
* - `throttled` (gRPC `ResourceExhausted` / HTTP 429) — the per-key
|
|
395
481
|
* throttle. **Back off** and retry with delay.
|
|
482
|
+
* - `capability_missing` (`ErrorInfo` reason `SITE_AUTHORING_CAPABILITY_MISSING`)
|
|
483
|
+
* — the credential is valid and correctly scoped, and simply was not
|
|
484
|
+
* exchanged for a capability this request needs. **Fix the verb table**,
|
|
485
|
+
* not the credential: the exchange minted exactly what the verb asked for,
|
|
486
|
+
* so a verb whose declared set is short of the requests it makes is the
|
|
487
|
+
* defect. Distinguished from `credential_rejected` precisely because
|
|
488
|
+
* re-issuing the same credential cannot help (TR00691).
|
|
396
489
|
*
|
|
397
490
|
* Anything else is `unclassified`, which is a signal to surface the raw
|
|
398
491
|
* status rather than to guess.
|
|
@@ -400,9 +493,13 @@ export class ApiError extends SiteAuthoringError {
|
|
|
400
493
|
refusalKind() {
|
|
401
494
|
// Fields first: they are the specific statement, and a named refusal
|
|
402
495
|
// outranks whatever status happens to carry it.
|
|
496
|
+
if (this.hasField(CLI_UPGRADE_REFUSAL_FIELD)) return REFUSAL_CLI_OUTDATED;
|
|
403
497
|
if (this.hasField(ROLLOUT_REFUSAL_FIELD)) return REFUSAL_PLATFORM_PAUSED;
|
|
404
498
|
if (this.hasField(CREDENTIAL_REFUSAL_FIELD)) return REFUSAL_CREDENTIAL_REJECTED;
|
|
405
499
|
if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD)) return REFUSAL_PLAN_LIMIT;
|
|
500
|
+
// The named detail ranks with the fields and above the status mappings, for
|
|
501
|
+
// the same reason: it is the specific statement about this refusal.
|
|
502
|
+
if (this.capability !== undefined) return REFUSAL_CAPABILITY_MISSING;
|
|
406
503
|
if (this.grpcCode === GRPC_RESOURCE_EXHAUSTED || this.httpStatus === HTTP_TOO_MANY_REQUESTS) {
|
|
407
504
|
return REFUSAL_THROTTLED;
|
|
408
505
|
}
|
|
@@ -413,8 +510,8 @@ export class ApiError extends SiteAuthoringError {
|
|
|
413
510
|
}
|
|
414
511
|
}
|
|
415
512
|
|
|
416
|
-
async function parseJsonResponse(response, signal) {
|
|
417
|
-
const bytes = await readBoundedBody(response, signal);
|
|
513
|
+
async function parseJsonResponse(response, signal, maximumBytes = LIMITS.apiResponseBytes) {
|
|
514
|
+
const bytes = await readBoundedBody(response, signal, maximumBytes);
|
|
418
515
|
try {
|
|
419
516
|
return JSON.parse(bytes.toString("utf8"));
|
|
420
517
|
} catch {
|
|
@@ -523,6 +620,11 @@ export class SiteApiClient {
|
|
|
523
620
|
attempts = LIMITS.requestAttempts,
|
|
524
621
|
deadline,
|
|
525
622
|
now = this.now,
|
|
623
|
+
// How large a body this one call may read. The default bounds every read
|
|
624
|
+
// the way it always has; a caller raises it only for the one contract
|
|
625
|
+
// whose valid answer is larger, and never above what its workspace file
|
|
626
|
+
// may hold.
|
|
627
|
+
maximumResponseBytes = LIMITS.apiResponseBytes,
|
|
526
628
|
} = {}, replaceableMutation) {
|
|
527
629
|
const url = new URL(path, this.apiBaseUrl);
|
|
528
630
|
if (
|
|
@@ -531,6 +633,9 @@ export class SiteApiClient {
|
|
|
531
633
|
|| !ALLOWED_METHODS.has(method)
|
|
532
634
|
|| (deadline !== undefined && !Number.isFinite(deadline))
|
|
533
635
|
|| typeof now !== "function"
|
|
636
|
+
|| !Number.isSafeInteger(maximumResponseBytes)
|
|
637
|
+
|| maximumResponseBytes <= 0
|
|
638
|
+
|| maximumResponseBytes > LIMITS.redirectMapResponseBytes
|
|
534
639
|
) {
|
|
535
640
|
throw new SiteAuthoringError(
|
|
536
641
|
"transport.request_contract",
|
|
@@ -608,7 +713,7 @@ export class SiteApiClient {
|
|
|
608
713
|
}
|
|
609
714
|
if (response.status >= 200 && response.status < 300 && !response.redirected) {
|
|
610
715
|
try {
|
|
611
|
-
const parsed = await parseJsonResponse(response, attemptSignal);
|
|
716
|
+
const parsed = await parseJsonResponse(response, attemptSignal, maximumResponseBytes);
|
|
612
717
|
if (remainingBudget(deadline, now) <= 0) throw deadlineError();
|
|
613
718
|
return parsed;
|
|
614
719
|
} catch (error) {
|
|
@@ -642,7 +747,7 @@ export class SiteApiClient {
|
|
|
642
747
|
}
|
|
643
748
|
let responseBody;
|
|
644
749
|
try {
|
|
645
|
-
responseBody = await parseJsonResponse(response, attemptSignal);
|
|
750
|
+
responseBody = await parseJsonResponse(response, attemptSignal, maximumResponseBytes);
|
|
646
751
|
} catch {
|
|
647
752
|
// A response arrived, so the mutation's fate is whatever its status
|
|
648
753
|
// says: a 4xx refused it, a 5xx may have committed it first.
|
package/src/verbs/approve.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "../api.js";
|
|
8
8
|
import { VERB_APPROVE } from "../constants.js";
|
|
9
9
|
import { SiteAuthoringError } from "../errors.js";
|
|
10
|
-
import { boundedList, openSession, successResult } from "../session.js";
|
|
10
|
+
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
11
11
|
import {
|
|
12
12
|
normalizePagePath,
|
|
13
13
|
readManifest,
|
|
@@ -36,7 +36,12 @@ const MAXIMUM_REPORTED = 200;
|
|
|
36
36
|
const MAXIMUM_BATCH = 100;
|
|
37
37
|
|
|
38
38
|
export async function approve(invocation) {
|
|
39
|
-
const
|
|
39
|
+
const session = await openSession(invocation);
|
|
40
|
+
const { client, config, siteId, onProgress } = session;
|
|
41
|
+
// One advisory line before this verb does any work, and only when the
|
|
42
|
+
// exchange said the platform is paused. It changes nothing else: the write
|
|
43
|
+
// still runs and its refusal still classifies as platform_paused (TR00692).
|
|
44
|
+
warnIfExternalWritesPaused(session, VERB_APPROVE);
|
|
40
45
|
const manifest = await readManifest(config.workspaceDir, siteId);
|
|
41
46
|
const manifestByPageId = new Map(
|
|
42
47
|
manifest.pages.filter((entry) => typeof entry?.pageId === "string").map((entry) => [entry.pageId, entry]),
|
package/src/verbs/deploy.js
CHANGED
|
@@ -17,10 +17,30 @@ import {
|
|
|
17
17
|
VERB_DEPLOY,
|
|
18
18
|
} from "../constants.js";
|
|
19
19
|
import { SiteAuthoringError } from "../errors.js";
|
|
20
|
-
import { boundedList, openSession, successResult } from "../session.js";
|
|
20
|
+
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
21
21
|
import { CANDIDATE_SELECTABLE_SETTINGS_TYPES } from "../settings-catalog.js";
|
|
22
22
|
import { readManifest, writeManifest } from "../workspace.js";
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* What a deploy does not finish (TR00702).
|
|
26
|
+
*
|
|
27
|
+
* A deploy writes the site's redirect and gone entries into the edge's
|
|
28
|
+
* key-value store as part of syncing routing. That store is eventually
|
|
29
|
+
* consistent, so a spot-check run the second a deploy reports success can
|
|
30
|
+
* briefly read the previous map, and taking that for "the redirect did not
|
|
31
|
+
* land" is the wrong conclusion to reach in front of a customer waiting to cut
|
|
32
|
+
* DNS over.
|
|
33
|
+
*
|
|
34
|
+
* No number is quoted, because there is none to quote: the coordinator holds
|
|
35
|
+
* nothing back for a standard site's redirects. Its propagation grace governs
|
|
36
|
+
* only the *deletion* of a superseded Docs pointer namespace; a standard site's
|
|
37
|
+
* rows are written and removed immediately.
|
|
38
|
+
*/
|
|
39
|
+
const REDIRECT_PROPAGATION_NOTE =
|
|
40
|
+
"Redirect and gone entries are written to the edge's key-value store when this deploy syncs routing. That "
|
|
41
|
+
+ "store is eventually consistent, so a spot-check run immediately afterwards can briefly still see the "
|
|
42
|
+
+ "previous map; re-check before concluding an entry is missing.";
|
|
43
|
+
|
|
24
44
|
/**
|
|
25
45
|
* `deploy --staging` / `deploy --production` — readiness, deploy, poll to
|
|
26
46
|
* completion.
|
|
@@ -202,7 +222,12 @@ export async function deploy(invocation) {
|
|
|
202
222
|
"deployTarget",
|
|
203
223
|
);
|
|
204
224
|
}
|
|
205
|
-
const
|
|
225
|
+
const session = await openSession(invocation);
|
|
226
|
+
const { client, config, siteId, now, onProgress } = session;
|
|
227
|
+
// One advisory line before this verb does any work, and only when the
|
|
228
|
+
// exchange said the platform is paused. It changes nothing else: the write
|
|
229
|
+
// still runs and its refusal still classifies as platform_paused (TR00692).
|
|
230
|
+
warnIfExternalWritesPaused(session, VERB_DEPLOY);
|
|
206
231
|
const manifest = await readManifest(config.workspaceDir, siteId, { required: false });
|
|
207
232
|
const selection = explicitSelection(invocation);
|
|
208
233
|
|
|
@@ -233,6 +258,7 @@ export async function deploy(invocation) {
|
|
|
233
258
|
now,
|
|
234
259
|
});
|
|
235
260
|
await recordDeployment(config, manifest, "production", completed);
|
|
261
|
+
onProgress(REDIRECT_PROPAGATION_NOTE);
|
|
236
262
|
return successResult(VERB_DEPLOY, siteId, {
|
|
237
263
|
target,
|
|
238
264
|
environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
|
|
@@ -310,6 +336,7 @@ export async function deploy(invocation) {
|
|
|
310
336
|
});
|
|
311
337
|
await recordDeployment(config, manifest, "staging", completed);
|
|
312
338
|
const stagingPreview = await inspectStagingPreview(client, siteId, onProgress);
|
|
339
|
+
onProgress(REDIRECT_PROPAGATION_NOTE);
|
|
313
340
|
return successResult(VERB_DEPLOY, siteId, {
|
|
314
341
|
target,
|
|
315
342
|
environment: DEPLOYMENT_ENVIRONMENT_STAGING,
|
package/src/verbs/footer-push.js
CHANGED
|
@@ -4,7 +4,7 @@ import { SiteAuthoringError } from "../errors.js";
|
|
|
4
4
|
import { projectFooterSettingsForWorkspace, validateFooterDocument } from "../footer-contract.js";
|
|
5
5
|
import { computeFooterDraftHash } from "../footer-draft-hash.js";
|
|
6
6
|
import { advanceFooterManifest, FOOTER_SETTINGS_FILE, readFooterWorkspaceContext } from "../footer-workspace.js";
|
|
7
|
-
import { openSession, successResult } from "../session.js";
|
|
7
|
+
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
8
8
|
import { SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
|
|
9
9
|
import { ApiError } from "../transport.js";
|
|
10
10
|
import { readWorkspaceJson, WORKSPACE_LIMITS, writeWorkspaceJson } from "../workspace.js";
|
|
@@ -56,7 +56,12 @@ function translateConcurrency(error) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export async function footerPush(invocation) {
|
|
59
|
-
const
|
|
59
|
+
const session = await openSession(invocation);
|
|
60
|
+
const { client, config, siteId, onProgress } = session;
|
|
61
|
+
// One advisory line before this verb does any work, and only when the
|
|
62
|
+
// exchange said the platform is paused. It changes nothing else: the write
|
|
63
|
+
// still runs and its refusal still classifies as platform_paused (TR00692).
|
|
64
|
+
warnIfExternalWritesPaused(session, VERB_FOOTER_PUSH);
|
|
60
65
|
const context = await readFooterWorkspaceContext(config.workspaceDir, siteId);
|
|
61
66
|
const { document: document_, footerSettings } = validateFooterWorkspaceDocument(
|
|
62
67
|
await readWorkspaceJson(config.workspaceDir, FOOTER_SETTINGS_FILE, WORKSPACE_LIMITS.settingsBytes),
|
package/src/verbs/index.js
CHANGED
|
@@ -8,9 +8,11 @@ import {
|
|
|
8
8
|
VERB_MEDIA_UPLOAD,
|
|
9
9
|
VERB_NAV_PUSH,
|
|
10
10
|
VERB_PAGES_PUSH,
|
|
11
|
-
VERB_PULL,
|
|
12
11
|
VERB_PREVIEW_PAGE,
|
|
13
12
|
VERB_PREVIEW_REVOKE,
|
|
13
|
+
VERB_PULL,
|
|
14
|
+
VERB_REDIRECTS_PULL,
|
|
15
|
+
VERB_REDIRECTS_PUSH,
|
|
14
16
|
VERB_SITES,
|
|
15
17
|
VERB_STATUS,
|
|
16
18
|
VERB_THEME_PUSH,
|
|
@@ -30,6 +32,8 @@ import { pagesPush } from "./pages-push.js";
|
|
|
30
32
|
import { pull } from "./pull.js";
|
|
31
33
|
import { previewPage } from "./preview-page.js";
|
|
32
34
|
import { previewRevoke } from "./preview-revoke.js";
|
|
35
|
+
import { redirectsPull } from "./redirects-pull.js";
|
|
36
|
+
import { redirectsPush } from "./redirects-push.js";
|
|
33
37
|
import { sites } from "./sites.js";
|
|
34
38
|
import { status } from "./status.js";
|
|
35
39
|
import { themePush } from "./theme-push.js";
|
|
@@ -110,6 +114,8 @@ export const VERB_HANDLERS = Object.freeze({
|
|
|
110
114
|
[VERB_PULL]: pull,
|
|
111
115
|
[VERB_PAGES_PUSH]: pagesPush,
|
|
112
116
|
[VERB_NAV_PUSH]: navPush,
|
|
117
|
+
[VERB_REDIRECTS_PULL]: redirectsPull,
|
|
118
|
+
[VERB_REDIRECTS_PUSH]: redirectsPush,
|
|
113
119
|
[VERB_THEME_PUSH]: themePush,
|
|
114
120
|
[VERB_FOOTER_PUSH]: footerPush,
|
|
115
121
|
[VERB_MEDIA_UPLOAD]: mediaUpload,
|
package/src/verbs/login.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
CLI_AUTHORIZATION_CLAIM_STATUS_PENDING,
|
|
10
10
|
poll,
|
|
11
11
|
startCliAuthorization,
|
|
12
|
+
announceRefusal,
|
|
12
13
|
} from "../api.js";
|
|
13
14
|
import {
|
|
14
15
|
AUTHORIZE_CLI_PATH,
|
|
@@ -328,7 +329,16 @@ export async function login(invocation) {
|
|
|
328
329
|
try {
|
|
329
330
|
authorization = await startCliAuthorization(client, { keyName });
|
|
330
331
|
} catch (error) {
|
|
331
|
-
|
|
332
|
+
// Announced only when the refusal survives translation: sign-in is the
|
|
333
|
+
// earlier of the two calls that carry this CLI's version, so it is where
|
|
334
|
+
// an outdated CLI is told to upgrade, and the remedy is a command the
|
|
335
|
+
// operator has to be handed rather than a field name (TR00703). A throttle
|
|
336
|
+
// is translated instead, because the shared guidance names a credential
|
|
337
|
+
// that does not exist yet, and announcing it first would contradict the
|
|
338
|
+
// translated line that follows.
|
|
339
|
+
const translated = translateStartApiError(error);
|
|
340
|
+
if (translated === error) announceRefusal(error, onProgress, "sign-in");
|
|
341
|
+
throw translated;
|
|
332
342
|
}
|
|
333
343
|
|
|
334
344
|
const verificationUrl = verificationUrlFor(apiBaseUrl);
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { LIMITS, VERB_MEDIA_UPLOAD } from "../constants.js";
|
|
13
13
|
import { SiteAuthoringError } from "../errors.js";
|
|
14
14
|
import { contentHash, inspectImageBytes } from "../image-metadata.js";
|
|
15
|
-
import { boundedList, openSession, successResult } from "../session.js";
|
|
15
|
+
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
16
16
|
import {
|
|
17
17
|
ensureWorkspaceRoot,
|
|
18
18
|
inspectWorkspaceEntry,
|
|
@@ -164,7 +164,12 @@ async function waitForProcessing(client, siteId, imageIds, { onProgress, now })
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
export async function mediaUpload(invocation) {
|
|
167
|
-
const
|
|
167
|
+
const session = await openSession(invocation);
|
|
168
|
+
const { client, config, siteId, now, onProgress } = session;
|
|
169
|
+
// One advisory line before this verb does any work, and only when the
|
|
170
|
+
// exchange said the platform is paused. It changes nothing else: the write
|
|
171
|
+
// still runs and its refusal still classifies as platform_paused (TR00692).
|
|
172
|
+
warnIfExternalWritesPaused(session, VERB_MEDIA_UPLOAD);
|
|
168
173
|
await ensureWorkspaceRoot(config);
|
|
169
174
|
|
|
170
175
|
// Positional arguments name the files or directories to upload — `cli.js`
|
package/src/verbs/nav-push.js
CHANGED
|
@@ -9,8 +9,13 @@ import {
|
|
|
9
9
|
withRefusalGuidance,
|
|
10
10
|
} from "../api.js";
|
|
11
11
|
import { VERB_NAV_PUSH } from "../constants.js";
|
|
12
|
+
import {
|
|
13
|
+
CONTACT_OR_WEB_URL_MAX_LENGTH,
|
|
14
|
+
CONTACT_OR_WEB_URL_REQUIREMENT,
|
|
15
|
+
isContactOrWebUrl,
|
|
16
|
+
} from "../contact-url.js";
|
|
12
17
|
import { SiteAuthoringError } from "../errors.js";
|
|
13
|
-
import { boundedList, openSession, successResult } from "../session.js";
|
|
18
|
+
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
14
19
|
import {
|
|
15
20
|
NAVIGATION_FILE_NAME,
|
|
16
21
|
readWorkspaceJson,
|
|
@@ -44,7 +49,7 @@ import {
|
|
|
44
49
|
const MAXIMUM_REPORTED = 100;
|
|
45
50
|
const MAXIMUM_ITEMS = 1_000;
|
|
46
51
|
const MAXIMUM_TITLE_LENGTH = 200;
|
|
47
|
-
const MAXIMUM_URL_LENGTH =
|
|
52
|
+
const MAXIMUM_URL_LENGTH = CONTACT_OR_WEB_URL_MAX_LENGTH;
|
|
48
53
|
const ITEM_KEYS = new Set(["id", "kind", "title", "resourceId", "externalUrl", "children"]);
|
|
49
54
|
|
|
50
55
|
// `SaveSiteNavigation` maps both `id` and `resourceId` through
|
|
@@ -141,14 +146,10 @@ function validateItem(item, itemPath, depth, seenIds, counters) {
|
|
|
141
146
|
if (!isBoundedText(item.externalUrl, MAXIMUM_URL_LENGTH)) {
|
|
142
147
|
throw navError("nav.external_url_missing", "an EXTERNAL_URL item needs a non-empty 'externalUrl'.", itemPath);
|
|
143
148
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
url = undefined;
|
|
149
|
-
}
|
|
150
|
-
if (url === undefined || (url.protocol !== "https:" && url.protocol !== "http:")) {
|
|
151
|
-
throw navError("nav.external_url_invalid", "'externalUrl' must be an absolute http or https URL.", itemPath);
|
|
149
|
+
// A header item may be a phone number or an email address, not just a web
|
|
150
|
+
// page; the value is stored verbatim so the number stays dialable.
|
|
151
|
+
if (!isContactOrWebUrl(item.externalUrl, MAXIMUM_URL_LENGTH)) {
|
|
152
|
+
throw navError("nav.external_url_invalid", `'externalUrl' ${CONTACT_OR_WEB_URL_REQUIREMENT}.`, itemPath);
|
|
152
153
|
}
|
|
153
154
|
if (!isEmpty(item.resourceId)) {
|
|
154
155
|
throw navError("nav.resource_unexpected", "an EXTERNAL_URL item must not carry 'resourceId'.", itemPath);
|
|
@@ -275,7 +276,12 @@ export function validateNavigationWorkspaceDocument(parsed, siteId, knownPages)
|
|
|
275
276
|
}
|
|
276
277
|
|
|
277
278
|
export async function navPush(invocation) {
|
|
278
|
-
const
|
|
279
|
+
const session = await openSession(invocation);
|
|
280
|
+
const { client, config, siteId, onProgress } = session;
|
|
281
|
+
// One advisory line before this verb does any work, and only when the
|
|
282
|
+
// exchange said the platform is paused. It changes nothing else: the write
|
|
283
|
+
// still runs and its refusal still classifies as platform_paused (TR00692).
|
|
284
|
+
warnIfExternalWritesPaused(session, VERB_NAV_PUSH);
|
|
279
285
|
if (!await workspaceFileExists(config.workspaceDir, NAVIGATION_FILE_NAME)) {
|
|
280
286
|
throw new SiteAuthoringError(
|
|
281
287
|
"nav.file_missing",
|
package/src/verbs/pages-push.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "../api.js";
|
|
10
10
|
import { VERB_PAGES_PUSH } from "../constants.js";
|
|
11
11
|
import { SiteAuthoringError } from "../errors.js";
|
|
12
|
-
import { boundedList, openSession, successResult } from "../session.js";
|
|
12
|
+
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
13
13
|
import { SETTINGS_TYPE_TAPROOT_STYLES } from "../settings-catalog.js";
|
|
14
14
|
import {
|
|
15
15
|
hasNamedFreeFormSectionContext,
|
|
@@ -18,7 +18,10 @@ import {
|
|
|
18
18
|
} from "../content/free-form-sections.js";
|
|
19
19
|
import { CONTENT_ERROR_CODES } from "../content/vocabulary.js";
|
|
20
20
|
import {
|
|
21
|
+
deleteWorkspaceFile,
|
|
22
|
+
internalPageObservedRevisionFile,
|
|
21
23
|
MEDIA_MANIFEST_FILE_NAME,
|
|
24
|
+
normalizePageBodyRevision,
|
|
22
25
|
normalizePagePath,
|
|
23
26
|
PAGE_SOURCE_EXTENSIONS,
|
|
24
27
|
PAGE_SOURCE_FORMAT_MARKDOWN,
|
|
@@ -28,6 +31,7 @@ import {
|
|
|
28
31
|
PAGES_DIRECTORY,
|
|
29
32
|
readManifest,
|
|
30
33
|
readMediaManifest,
|
|
34
|
+
readObservedPageRevision,
|
|
31
35
|
readOnlySystem404Projections,
|
|
32
36
|
readWorkspaceFile,
|
|
33
37
|
readWorkspaceJson,
|
|
@@ -38,6 +42,7 @@ import {
|
|
|
38
42
|
walkWorkspaceFiles,
|
|
39
43
|
WORKSPACE_LIMITS,
|
|
40
44
|
workspaceContentHash,
|
|
45
|
+
workspaceFileExists,
|
|
41
46
|
writeManifest,
|
|
42
47
|
} from "../workspace.js";
|
|
43
48
|
|
|
@@ -457,6 +462,45 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
|
|
|
457
462
|
};
|
|
458
463
|
}
|
|
459
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Refuses to overwrite a live page that moved since this workspace last
|
|
467
|
+
* reconciled with it.
|
|
468
|
+
*
|
|
469
|
+
* TR00622 left one detection gap open deliberately: a remote edit made between
|
|
470
|
+
* this workspace's own push and its next pull was adopted as the new baseline
|
|
471
|
+
* rather than reported, because the bytes a push sends are not comparable with
|
|
472
|
+
* the bytes a read returns and closing it needed a revision on the read
|
|
473
|
+
* contract. This is that revision, so the gap closes here: the site states the
|
|
474
|
+
* revision of each page's stored authoring state, the workspace records the one
|
|
475
|
+
* it last wrote or pulled, and a push into a page carrying a different one is
|
|
476
|
+
* refused before a single mutation is sent.
|
|
477
|
+
*
|
|
478
|
+
* The guard is armed by knowledge, not by policy. A page the workspace has
|
|
479
|
+
* never reconciled with records no revision, and a Taproot that predates the
|
|
480
|
+
* contract reports none; neither is evidence of a concurrent edit, and refusing
|
|
481
|
+
* on absence would make the ordinary first push of a workspace impossible.
|
|
482
|
+
* Both leave this exactly where TR00622 left it, which is where the caller
|
|
483
|
+
* already is.
|
|
484
|
+
*/
|
|
485
|
+
function requireReconciledRevision({ file, pagePath, target, entry, observedRevision }) {
|
|
486
|
+
// What the operator was shown by a refused pull outranks what the manifest
|
|
487
|
+
// last reconciled with. Without that, this guard would also refuse the pull
|
|
488
|
+
// conflict's own documented recovery, and the only way out of a conflict
|
|
489
|
+
// would be to abandon the local source.
|
|
490
|
+
const recorded = observedRevision ?? normalizePageBodyRevision(entry?.baseline?.revision);
|
|
491
|
+
const live = target.bodyRevision;
|
|
492
|
+
if (recorded === undefined || live === undefined || recorded === live) return;
|
|
493
|
+
const selector = pagePath || "/";
|
|
494
|
+
throw new SiteAuthoringError(
|
|
495
|
+
"pages.push_conflict",
|
|
496
|
+
`Page '${selector}' changed on the site since this workspace last reconciled with it: it now reports revision `
|
|
497
|
+
+ `${live}, and '${file}' was written against ${recorded}. No page was pushed. Run `
|
|
498
|
+
+ `'taproot-site pull' to see what differs and to adopt the site's version, or delete '${file}' and pull `
|
|
499
|
+
+ "again to take the site's document as this page's source.",
|
|
500
|
+
{ field: file, alternatives: [recorded, live] },
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
460
504
|
/**
|
|
461
505
|
* The metadata checks that are *not* part of deciding which page a source is.
|
|
462
506
|
*
|
|
@@ -557,7 +601,12 @@ export async function validateWorkspacePageDocument({
|
|
|
557
601
|
}
|
|
558
602
|
|
|
559
603
|
export async function pagesPush(invocation) {
|
|
560
|
-
const
|
|
604
|
+
const session = await openSession(invocation);
|
|
605
|
+
const { client, config, siteId, onProgress } = session;
|
|
606
|
+
// One advisory line before this verb does any work, and only when the
|
|
607
|
+
// exchange said the platform is paused. It changes nothing else: the write
|
|
608
|
+
// still runs and its refusal still classifies as platform_paused (TR00692).
|
|
609
|
+
warnIfExternalWritesPaused(session, VERB_PAGES_PUSH);
|
|
561
610
|
const allowRawHtml = invocation.allowRawHtml === true;
|
|
562
611
|
// Both manifests are bound to the site before anything is planned or sent:
|
|
563
612
|
// every id in them is site-scoped, and a workspace pulled from another site
|
|
@@ -837,6 +886,18 @@ export async function pagesPush(invocation) {
|
|
|
837
886
|
file,
|
|
838
887
|
);
|
|
839
888
|
}
|
|
889
|
+
// The record this push may spend has to be removable after the send:
|
|
890
|
+
// a directory or a symlink at its path is refused here, before any
|
|
891
|
+
// page is written, rather than discovered after the site has changed.
|
|
892
|
+
const observedRecordFile = internalPageObservedRevisionFile(target.pageId);
|
|
893
|
+
if (observedRecordFile !== undefined) await workspaceFileExists(config.workspaceDir, observedRecordFile);
|
|
894
|
+
requireReconciledRevision({
|
|
895
|
+
file,
|
|
896
|
+
pagePath,
|
|
897
|
+
target,
|
|
898
|
+
entry: manifestByPageId.get(target.pageId),
|
|
899
|
+
observedRevision: await readObservedPageRevision(config.workspaceDir, target.pageId),
|
|
900
|
+
});
|
|
840
901
|
}
|
|
841
902
|
|
|
842
903
|
// The duplicate-source check above only catches two workspace files
|
|
@@ -942,15 +1003,39 @@ export async function pagesPush(invocation) {
|
|
|
942
1003
|
file: page.file,
|
|
943
1004
|
sourceFormat: page.sourceFormat,
|
|
944
1005
|
// The page's remote body is now derived from exactly these bytes.
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
|
|
1006
|
+
// No remote *hash* is recorded: a read re-projects image delivery and
|
|
1007
|
+
// re-serializes component data, so a hash of what was sent is not
|
|
1008
|
+
// comparable with a hash of what a pull would read, and the next pull
|
|
1009
|
+
// establishes that half from what it actually reads.
|
|
1010
|
+
//
|
|
1011
|
+
// The revision is different in kind and is recorded here. It is the
|
|
1012
|
+
// site's own statement about the stored state this mutation just
|
|
1013
|
+
// wrote, so it is directly comparable with the revision the next read
|
|
1014
|
+
// reports — which is what lets the *next* push tell "nobody else
|
|
1015
|
+
// touched this page" from "someone did", without a pull in between.
|
|
1016
|
+
// Absent against a Taproot that predates the contract, and then
|
|
1017
|
+
// dropped rather than carried forward: this push moved the stored
|
|
1018
|
+
// state, so whatever was recorded describes a version that no longer
|
|
1019
|
+
// exists, and keeping it would refuse the *next* push over a change
|
|
1020
|
+
// this workspace made itself.
|
|
1021
|
+
baseline: {
|
|
1022
|
+
sourceHash: page.sourceHash,
|
|
1023
|
+
...(summary.bodyRevision === undefined ? {} : { revision: summary.bodyRevision }),
|
|
1024
|
+
},
|
|
951
1025
|
pendingApproval: true,
|
|
952
1026
|
});
|
|
953
1027
|
if (existing === undefined) manifest.pages.push(record);
|
|
1028
|
+
// The override this page's record permitted has now been taken, and
|
|
1029
|
+
// the baseline above records what the site holds because of it.
|
|
1030
|
+
// Keeping the record would let a *later* push overwrite somebody
|
|
1031
|
+
// else's edit without anyone having been shown it. Removed after the
|
|
1032
|
+
// manifest record is written, so a failure here can never leave a
|
|
1033
|
+
// successful mutation unrecorded; the record's shape was checked
|
|
1034
|
+
// before anything was sent.
|
|
1035
|
+
const observedRevisionFile = internalPageObservedRevisionFile(summary.pageId);
|
|
1036
|
+
if (observedRevisionFile !== undefined) {
|
|
1037
|
+
await deleteWorkspaceFile(config.workspaceDir, observedRevisionFile);
|
|
1038
|
+
}
|
|
954
1039
|
}
|
|
955
1040
|
} finally {
|
|
956
1041
|
if (manifestDirty) await writeManifest(config.workspaceDir, manifest);
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "../api.js";
|
|
8
8
|
import { VERB_PREVIEW_PAGE } from "../constants.js";
|
|
9
9
|
import { asSiteAuthoringError, isCanonicalUuid, SiteAuthoringError } from "../errors.js";
|
|
10
|
-
import { openSession, successResult } from "../session.js";
|
|
10
|
+
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
11
11
|
import { normalizePagePath, readManifest } from "../workspace.js";
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -40,7 +40,12 @@ export async function previewPage(invocation) {
|
|
|
40
40
|
{ field: "pageSelector", exitCode: 2 },
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
|
-
const
|
|
43
|
+
const session = await openSession(invocation);
|
|
44
|
+
const { client, config, siteId, now, onProgress } = session;
|
|
45
|
+
// One advisory line before this verb does any work, and only when the
|
|
46
|
+
// exchange said the platform is paused. It changes nothing else: the write
|
|
47
|
+
// still runs and its refusal still classifies as platform_paused (TR00692).
|
|
48
|
+
warnIfExternalWritesPaused(session, VERB_PREVIEW_PAGE);
|
|
44
49
|
let pageId = selector;
|
|
45
50
|
if (!isCanonicalUuid(selector)) {
|
|
46
51
|
const pagePath = normalizePagePath(selector);
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "../api.js";
|
|
7
7
|
import { VERB_PREVIEW_REVOKE } from "../constants.js";
|
|
8
8
|
import { isCanonicalUuid, SiteAuthoringError } from "../errors.js";
|
|
9
|
-
import { openSession, successResult } from "../session.js";
|
|
9
|
+
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
10
10
|
|
|
11
11
|
export async function previewRevoke(invocation) {
|
|
12
12
|
if (!Array.isArray(invocation.previewIds) || invocation.previewIds.length !== 2) {
|
|
@@ -31,7 +31,12 @@ export async function previewRevoke(invocation) {
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
const [pageId, snapshotId] = invocation.previewIds;
|
|
34
|
-
const
|
|
34
|
+
const session = await openSession(invocation);
|
|
35
|
+
const { client, siteId, onProgress } = session;
|
|
36
|
+
// One advisory line before this verb does any work, and only when the
|
|
37
|
+
// exchange said the platform is paused. It changes nothing else: the write
|
|
38
|
+
// still runs and its refusal still classifies as platform_paused (TR00692).
|
|
39
|
+
warnIfExternalWritesPaused(session, VERB_PREVIEW_REVOKE);
|
|
35
40
|
try {
|
|
36
41
|
return await withRefusalGuidance(onProgress, "authoring preview revocation", async () => {
|
|
37
42
|
onProgress("Revoking the authoring preview and scheduling its artifacts for cleanup.");
|