@taprootio/site-authoring 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -464,7 +464,8 @@ moved since this workspace last reconciled with it, naming both revisions;
464
464
  names and reports predictions with a session token; `places select
465
465
  <googlePlaceId> <sessionToken>` records the match as a Taproot place and
466
466
  reports the `placeId` a place review's front matter needs. An article or an
467
- album can name its place the same way with `place:`.
467
+ album can name its place the same way with `place:`. `places category set
468
+ <placeId> <category>` changes the category this site shows for a place.
468
469
 
469
470
  `plan` checks the whole workspace against the live site — pages, the media
470
471
  they reference and the images the settings name, the footer and the pages it
@@ -567,8 +568,10 @@ paused; retry later), `credential_rejected` (stop and re-issue),
567
568
  not exchanged for a capability the request needs; the refusal carries
568
569
  `field: "GrantedCapabilities"`, and the CLI's human progress names the
569
570
  granted and required capabilities, so report the verb's declared set rather
570
- than re-issuing the key), `plan_limit` (the plan's published-page allowance,
571
- surfaced at deploy), and `throttled` (back off).
571
+ than re-issuing the key), `plan_limit` (the plan's published-page allowance or
572
+ its pages-generated allowance, or the bandwidth pause on deploys,
573
+ surfaced at deploy; the allowance shows its reset time, while the bandwidth pause shows the earliest
574
+ time it can lift, since it lifts only after a month ends under the allowance or on upgrade), and `throttled` (back off).
572
575
 
573
576
  When `GITHUB_OUTPUT` names the runner's existing output file, every
574
577
  operational verb appends the same JSON under `taproot_site_result` through a
@@ -635,17 +638,49 @@ Other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal `<br>` tags are rejecte
635
638
  with the exact fact field path. A label is optional and stays outside the value's link. See
636
639
  `taproot-site help page free-form` and `taproot-site help component cta`.
637
640
 
638
- ### Link rel in 0.15.0
641
+ ### Place categories in 0.18.0
639
642
 
640
- A link publishes followed unless its author chooses otherwise. A link mark's
641
- `rel` takes space-separated link types (`nofollow`, `sponsored`, `ugc`, `me`,
642
- and the other standard types except `opener`), on any link; `target: "_blank"`
643
- opens a new tab with `noopener noreferrer` added. In Markdown, write
644
- `[Shop](https://shop.example){rel="sponsored nofollow" target="_blank"}`
645
- (either attribute alone works). Other tokens and
646
- targets are refused (`content.link_rel`, `content.link_target`). The editor's
647
- old default, `noopener noreferrer nofollow`, reads as no choice. See
648
- `taproot-site help page free-form`.
643
+ A place has one category for every site, taken from Google's types, and a place
644
+ that Taproot could not classify is filed under `Other`. A site can file a place
645
+ under a category of its own:
646
+
647
+ ```
648
+ taproot-site places category list
649
+ taproot-site places category set <placeId> park
650
+ taproot-site places category clear <placeId>
651
+ ```
652
+
653
+ `list` prints the closed list `set` accepts; a name outside it is refused, and
654
+ capitalization is ignored. The category is the site's alone and applies to every
655
+ review of that place on the site: the place itself and other sites do not
656
+ change. Place-review bylines and the `/places/<category>` pages use it from the
657
+ site's next deployment, including reviews nobody has edited. `clear` removes it.
658
+ Both need the same page-editing permission as `places select`.
659
+
660
+ ### Pages generated in 0.18.0
661
+
662
+ A site's licence allows a number of pages generated per billing period, and
663
+ `deploy --staging` is charged by the pages it generates, not by the deployment.
664
+ The deploy asks Taproot to plan the candidate first and reports the plan before
665
+ it sends anything: `This will generate 74 pages · 940 of 1,000 left this period
666
+ (resets 2026-11-01T00:00:00.000Z)`. The same figures are in `readiness.pageEstimate`
667
+ (`plannedPageCount`, `pagesGeneratedLimit`, `pagesGeneratedRemaining`, `periodEnd`
668
+ and `exceedsAllowance`; a limit and what remains are `null` on an unlimited
669
+ licence). The number shown is the number charged. Promoting a staged release to
670
+ production generates nothing and costs no pages.
671
+
672
+ A candidate that does not fit stops with `deploy.page_allowance_exceeded`, naming
673
+ the pages it needs, the pages left, and the reset. A deploy refused by the server
674
+ for the same reason, or for the bandwidth pause, reports `plan_limit` with the
675
+ time it can lift (and `requested_pages` and `remaining_pages` for the allowance):
676
+ the allowance's reset, or for the bandwidth pause the earliest month end that can
677
+ clear it (an upgrade clears it at once).
678
+
679
+ ### Deployment allowances in 0.18.0
680
+
681
+ A deploy refused for the bandwidth pause reports `plan_limit` with the time it
682
+ can lift: the earliest month end that can clear it (an upgrade clears it at
683
+ once).
649
684
 
650
685
  ### Authoring feedback in 0.16.0
651
686
 
@@ -666,6 +701,18 @@ old default, `noopener noreferrer nofollow`, reads as no choice. See
666
701
  generated pages navigation links to), and `validate` and `pages push` warn on
667
702
  links that look made by autolinking (`content.link_autolinked`).
668
703
 
704
+ ### Link rel in 0.15.0
705
+
706
+ A link publishes followed unless its author chooses otherwise. A link mark's
707
+ `rel` takes space-separated link types (`nofollow`, `sponsored`, `ugc`, `me`,
708
+ and the other standard types except `opener`), on any link; `target: "_blank"`
709
+ opens a new tab with `noopener noreferrer` added. In Markdown, write
710
+ `[Shop](https://shop.example){rel="sponsored nofollow" target="_blank"}`
711
+ (either attribute alone works). Other tokens and
712
+ targets are refused (`content.link_rel`, `content.link_target`). The editor's
713
+ old default, `noopener noreferrer nofollow`, reads as no choice. See
714
+ `taproot-site help page free-form`.
715
+
669
716
  ### Staging verification and deployment evidence
670
717
 
671
718
  `deploy --staging` selects the same changed pages, settings groups and navigation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { normalizeLatestCliVersion } from "./cli-release.js";
2
2
  import {
3
+ BANDWIDTH_ALLOWANCE_REFUSAL_REASON,
3
4
  CANONICAL_TIMESTAMP,
4
5
  CAPABILITY_REFUSAL_FIELD,
5
6
  CLI_UPGRADE_COMMAND,
@@ -437,6 +438,34 @@ export function announceRefusal(error, onProgress, action = "request") {
437
438
  }
438
439
  if (kind === REFUSAL_PLAN_LIMIT) {
439
440
  onProgress("");
441
+ if (error.planLimit !== undefined) {
442
+ // The allowance and bandwidth refusals ride an ErrorInfo detail, not the
443
+ // UpgradePrompt field, and name when they can next lift on their own.
444
+ const bandwidth = error.planLimit.reason === BANDWIDTH_ALLOWANCE_REFUSAL_REASON;
445
+ onProgress(`PLAN LIMIT (refusal=plan_limit, reason=${error.planLimit.reason})`);
446
+ onProgress(
447
+ bandwidth
448
+ ? ` Taproot paused this ${action}: the site's measured bandwidth was over its monthly allowance for two `
449
+ + "months in a row. The published site keeps serving; only new deployments wait."
450
+ : error.planLimit.requestedPages === undefined
451
+ ? ` Taproot refused this ${action}: it generates more pages than the site's allowance for this billing period has left.`
452
+ : ` Taproot refused this ${action}: it generates ${pagesLabel(error.planLimit.requestedPages)} and the site has ${
453
+ (error.planLimit.remainingPages ?? 0).toLocaleString("en-US")
454
+ } left for this billing period.`,
455
+ );
456
+ // The bandwidth pause is not an unconditional reset: it lifts only after
457
+ // a month ends under the allowance (or on upgrade), so the instant is
458
+ // the earliest that can happen.
459
+ onProgress(
460
+ error.planLimit.resetsAt === undefined
461
+ ? " Upgrade the site's plan, then run the request again."
462
+ : bandwidth
463
+ ? ` Upgrade the site's plan, or deployments resume after a month ends under the allowance (no earlier than ${error.planLimit.resetsAt}).`
464
+ : ` Resets at: ${error.planLimit.resetsAt}. Upgrade the site's plan to continue sooner.`,
465
+ );
466
+ onProgress("");
467
+ return;
468
+ }
440
469
  onProgress("PLAN LIMIT (refusal=plan_limit, field=UpgradePrompt)");
441
470
  onProgress(
442
471
  ` Taproot refused this ${action} against a plan ceiling rather than a content problem.`,
@@ -996,6 +1025,45 @@ export async function selectPlace(client, siteId, { googlePlaceId, sessionToken
996
1025
  };
997
1026
  }
998
1027
 
1028
+ function normalizeSiteCategory(value) {
1029
+ const state = requireObject(value, "api.place_contract", "site place category");
1030
+ return {
1031
+ placeId: requireIdentifier(state.placeId, "api.place_contract", "placeId"),
1032
+ category: text(state.category),
1033
+ placeCategory: text(state.placeCategory),
1034
+ isOverride: state.isOverride === true,
1035
+ };
1036
+ }
1037
+
1038
+ /** Sets this site's own category for a place; the place itself and other sites are unchanged. */
1039
+ export async function setPlaceCategory(client, siteId, { placeId, category }) {
1040
+ return normalizeSiteCategory(
1041
+ await client.request(sitePath(siteId, `places/${encodeURIComponent(placeId)}/category`), {
1042
+ method: "PUT",
1043
+ body: { siteId, placeId, category },
1044
+ }),
1045
+ );
1046
+ }
1047
+
1048
+ /** Removes this site's category for a place, so the place's own category applies again. */
1049
+ export async function clearPlaceCategory(client, siteId, { placeId }) {
1050
+ return normalizeSiteCategory(
1051
+ await client.request(sitePath(siteId, `places/${encodeURIComponent(placeId)}/category`), { method: "DELETE" }),
1052
+ );
1053
+ }
1054
+
1055
+ /** The closed list of place categories, in Taproot's order. */
1056
+ export async function listPlaceCategories(client) {
1057
+ const response = requireObject(
1058
+ await client.request(`v1/standard-types${query([["typeGroup", "place_category"], ["maxResults", 1000]])}`),
1059
+ "api.place_contract",
1060
+ "place categories",
1061
+ );
1062
+ return (Array.isArray(response.standardTypes) ? response.standardTypes : [])
1063
+ .map((type) => text(requireObject(type, "api.place_contract", "place category").name))
1064
+ .filter((name) => name !== "");
1065
+ }
1066
+
999
1067
  /**
1000
1068
  * Processing is observed through `ListSiteImages`; `GetImageById` is
1001
1069
  * session-only under TR00602's read list, so a key-authorized client watches
@@ -1122,18 +1190,20 @@ export async function getDeploySelection(client, siteId) {
1122
1190
  }
1123
1191
 
1124
1192
  export async function getPublishingReadiness(client, siteId, selection = {}) {
1193
+ // A body, not a query: the exact selection can run past a URL's limit, and the
1194
+ // estimate must plan the pages the deploy will stage (TR01201).
1125
1195
  const response = requireObject(
1126
- await client.request(sitePath(
1127
- siteId,
1128
- `publishing/readiness${
1129
- query([
1130
- ["stagedPageIds", selection.stagedPageIds],
1131
- ["selectedSettingsTypes", selection.selectedSettingsTypes],
1132
- ["includeNavigation", selection.includeNavigation],
1133
- ["useExactPageSelection", selection.useExactPageSelection],
1134
- ])
1135
- }`,
1136
- )),
1196
+ await client.request(sitePath(siteId, "publishing/readiness"), {
1197
+ method: "POST",
1198
+ body: {
1199
+ stagedPageIds: selection.stagedPageIds,
1200
+ selectedSettingsTypes: selection.selectedSettingsTypes,
1201
+ includeNavigation: selection.includeNavigation,
1202
+ useExactPageSelection: selection.useExactPageSelection,
1203
+ includePageEstimate: selection.includePageEstimate,
1204
+ allowFailedPreview: selection.allowFailedPreview,
1205
+ },
1206
+ }),
1137
1207
  "api.readiness_contract",
1138
1208
  "publishing readiness",
1139
1209
  );
@@ -1154,10 +1224,50 @@ export async function getPublishingReadiness(client, siteId, selection = {}) {
1154
1224
  redirectsChanged: response.redirectsChanged === true,
1155
1225
  formsChanged: response.formsChanged === true,
1156
1226
  videosChanged: response.videosChanged === true,
1227
+ // The pages staging this candidate would generate, which is the number charged (TR01201).
1228
+ // Absent unless asked for and the candidate could be planned.
1229
+ pageEstimate: pageEstimate(response),
1157
1230
  blockers,
1158
1231
  };
1159
1232
  }
1160
1233
 
1234
+ /**
1235
+ * The server's plan for a candidate: the pages staging it generates, the
1236
+ * allowance left this period and when it resets. The CLI never computes any of
1237
+ * it; limit, remaining and period end are null for an unlimited licence.
1238
+ */
1239
+ function pageEstimate(response) {
1240
+ if (!Number.isSafeInteger(response.plannedPageCount) || response.plannedPageCount < 0) return null;
1241
+ const periodEnd = typeof response.pagesGeneratedPeriodEnd === "string"
1242
+ ? new Date(response.pagesGeneratedPeriodEnd)
1243
+ : undefined;
1244
+ return {
1245
+ plannedPageCount: response.plannedPageCount,
1246
+ pagesGeneratedLimit: Number.isSafeInteger(response.pagesGeneratedLimit) ? response.pagesGeneratedLimit : null,
1247
+ pagesGeneratedRemaining: Number.isSafeInteger(response.pagesGeneratedRemaining)
1248
+ ? response.pagesGeneratedRemaining
1249
+ : null,
1250
+ periodEnd: periodEnd !== undefined && !Number.isNaN(periodEnd.getTime()) ? periodEnd.toISOString() : null,
1251
+ exceedsAllowance: response.plannedPagesExceedAllowance === true,
1252
+ };
1253
+ }
1254
+
1255
+ const pagesLabel = (count) => `${count.toLocaleString("en-US")} ${count === 1 ? "page" : "pages"}`;
1256
+
1257
+ /**
1258
+ * The one line that states a candidate's plan in pages (TR01201), shared by the
1259
+ * progress output and the refusal so the two never word it differently.
1260
+ */
1261
+ export function describePageEstimate(estimate) {
1262
+ const generates = `This will generate ${pagesLabel(estimate.plannedPageCount)}`;
1263
+ if (estimate.pagesGeneratedLimit === null || estimate.pagesGeneratedRemaining === null) return generates;
1264
+ const left = `${estimate.pagesGeneratedRemaining.toLocaleString("en-US")} of ${
1265
+ estimate.pagesGeneratedLimit.toLocaleString("en-US")
1266
+ } left this period`;
1267
+ const resets = estimate.periodEnd === null ? "" : ` (resets ${estimate.periodEnd})`;
1268
+ return `${generates} · ${left}${resets}`;
1269
+ }
1270
+
1161
1271
  export async function getStagingPreviewStatus(client, siteId) {
1162
1272
  const response = requireObject(
1163
1273
  await client.request(sitePath(siteId, "staging-preview/status")),
@@ -1353,6 +1463,14 @@ export async function deploySite(client, siteId, body) {
1353
1463
  { field: "AuthoringPreviewFailed", status: error.status },
1354
1464
  );
1355
1465
  }
1466
+ if (error instanceof ApiError && error.hasField("ExpectedPageCount")) {
1467
+ throw new SiteAuthoringError(
1468
+ "deploy.page_estimate_changed",
1469
+ `${error.descriptionFor("ExpectedPageCount") ?? "The pages this publish generates have grown since the estimate."} `
1470
+ + "Nothing was staged; run the command again to confirm the new count.",
1471
+ { field: "ExpectedPageCount", status: error.status },
1472
+ );
1473
+ }
1356
1474
  throw error;
1357
1475
  }
1358
1476
  const deployment = normalizeDeployment(response.deployment);
package/src/cli.js CHANGED
@@ -34,6 +34,9 @@ import {
34
34
  VERB_NAV_PUSH,
35
35
  VERB_PAGES_META_SET,
36
36
  VERB_PAGES_PUSH,
37
+ VERB_PLACES_CATEGORY_CLEAR,
38
+ VERB_PLACES_CATEGORY_LIST,
39
+ VERB_PLACES_CATEGORY_SET,
37
40
  VERB_PLACES_SEARCH,
38
41
  VERB_PLACES_SELECT,
39
42
  VERB_PLAN,
@@ -350,6 +353,39 @@ const VERBS = Object.freeze([
350
353
  + "once for every site, so selecting it again returns the same placeId. Use it as placeId in a place review, "
351
354
  + "or as place in an article or album.",
352
355
  },
356
+ {
357
+ name: VERB_PLACES_CATEGORY_SET,
358
+ surface: SURFACE_STANDARD,
359
+ // Changes what the site's place reviews publish, so it needs page editing.
360
+ capabilities: [CAPABILITY_CONTENT],
361
+ tokens: ["places", "category", "set"],
362
+ positionals: "placeCategory",
363
+ summary: "Set this site's category for a place, from the closed category list.",
364
+ note: "Takes the placeId from 'places select' (or a page's front matter), then one category from "
365
+ + "'places category list', which prints the closed list; a name outside it is refused, and capitalization is "
366
+ + "ignored. Use it when Google's own category for a place is not the one this site wants, such as a park filed "
367
+ + "under Other. The category is this site's alone and applies to every review of that place here: the place "
368
+ + "itself and other sites do not change. Place-review bylines and the /places/<category> pages use it from the "
369
+ + "next deployment, which a changed category alone is enough to start. Undo it with 'places category clear'.",
370
+ },
371
+ {
372
+ name: VERB_PLACES_CATEGORY_CLEAR,
373
+ surface: SURFACE_STANDARD,
374
+ capabilities: [CAPABILITY_CONTENT],
375
+ tokens: ["places", "category", "clear"],
376
+ positionals: "placeId",
377
+ summary: "Remove this site's category for a place, so the place's own category applies again.",
378
+ note: "Takes the placeId. Clearing a place this site never set succeeds and changes nothing. Pages use the "
379
+ + "place's own category from the next deployment.",
380
+ },
381
+ {
382
+ name: VERB_PLACES_CATEGORY_LIST,
383
+ surface: SURFACE_STANDARD,
384
+ capabilities: [CAPABILITY_CONTENT],
385
+ tokens: ["places", "category", "list"],
386
+ summary: "List the place categories 'places category set' accepts.",
387
+ note: "The list is closed: a category outside it is refused.",
388
+ },
353
389
  {
354
390
  name: VERB_PLAN,
355
391
  surface: SURFACE_STANDARD,
@@ -713,7 +749,7 @@ upgrade. Once a run has recorded a newer release, help, validate, and whoami
713
749
  refuse the same way offline. --version and --help always answer.
714
750
 
715
751
  Verbs:
716
- ${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(14)} ${verb.summary}`).join("\n")}
752
+ ${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(20)} ${verb.summary}`).join("\n")}
717
753
 
718
754
  Configuration:
719
755
  Site verbs read taproot-site.json, found by walking up from the current
@@ -756,6 +792,10 @@ function verbHelp(verb) {
756
792
  ? " <query...>"
757
793
  : verb.positionals === "placeSelection"
758
794
  ? " <google-place-id> <session-token>"
795
+ : verb.positionals === "placeCategory"
796
+ ? " <place-id> <category>"
797
+ : verb.positionals === "placeId"
798
+ ? " <place-id>"
759
799
  : verb.positionals === "metaPagePath"
760
800
  ? " <page-path> [--title <text>] [--description <text>] [--author <handle-or-email>]"
761
801
  : verb.positionals === "authorHandle"
package/src/constants.js CHANGED
@@ -110,6 +110,9 @@ export const VERB_PLAN = "plan";
110
110
  export const VERB_APPLY = "apply";
111
111
  export const VERB_PLACES_SEARCH = "places search";
112
112
  export const VERB_PLACES_SELECT = "places select";
113
+ export const VERB_PLACES_CATEGORY_SET = "places category set";
114
+ export const VERB_PLACES_CATEGORY_CLEAR = "places category clear";
115
+ export const VERB_PLACES_CATEGORY_LIST = "places category list";
113
116
  /** The identity `plan` reports and `apply --plan` requires. */
114
117
  export const PLAN_HASH = /^sha256:[0-9a-f]{64}$/u;
115
118
  export const VERB_ENV = "env";
@@ -204,6 +207,19 @@ export const CAPABILITY_REFUSAL_REASON = "SITE_AUTHORING_CAPABILITY_MISSING";
204
207
  */
205
208
  export const CAPABILITY_REFUSAL_FIELD = "GrantedCapabilities";
206
209
 
210
+ /**
211
+ * The `google.rpc.ErrorInfo` reasons a deploy carries when the site has hit a
212
+ * commercial ceiling that no retry can clear (TR00445): the deployments-per-
213
+ * billing-period allowance, or the bandwidth pause. Wire identities matched
214
+ * exactly, within {@link SITE_DEPLOYMENT_REFUSAL_DOMAIN}. The refusal is
215
+ * classified `plan_limit`, the same class as the page-count `UpgradePrompt`
216
+ * field, because the right next move is the same: upgrade, or wait for the
217
+ * reset the server names in `resets_at`.
218
+ */
219
+ export const DEPLOYMENT_ALLOWANCE_REFUSAL_REASON = "DEPLOYMENT_PERIOD_ALLOWANCE_EXCEEDED";
220
+ export const BANDWIDTH_ALLOWANCE_REFUSAL_REASON = "BANDWIDTH_ALLOWANCE_EXCEEDED";
221
+ export const SITE_DEPLOYMENT_REFUSAL_DOMAIN = "sites.taproot.io";
222
+
207
223
  // gRPC `ResourceExhausted`. Transcoded responses carry the numeric code in the
208
224
  // body; the HTTP mapping is 429.
209
225
  export const GRPC_RESOURCE_EXHAUSTED = 8;
@@ -86,7 +86,7 @@ import {
86
86
 
87
87
  export { getAppearanceReference, getFooterReference, getThemeReference };
88
88
 
89
- export const REFERENCE_VERSION = 35;
89
+ export const REFERENCE_VERSION = 36;
90
90
 
91
91
  // The date-limit examples `help forms` prints; tests run them through the form validator.
92
92
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
package/src/transport.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import {
2
+ BANDWIDTH_ALLOWANCE_REFUSAL_REASON,
2
3
  CAPABILITY_REFUSAL_FIELD,
3
4
  CAPABILITY_REFUSAL_REASON,
4
5
  CLI_NAME,
5
6
  CLI_UPGRADE_REFUSAL_FIELD,
6
7
  CLI_VERSION,
7
8
  CREDENTIAL_REFUSAL_FIELD,
9
+ DEPLOYMENT_ALLOWANCE_REFUSAL_REASON,
8
10
  GRPC_RESOURCE_EXHAUSTED,
9
11
  GRPC_UNAUTHENTICATED,
10
12
  HTTP_TOO_MANY_REQUESTS,
@@ -21,6 +23,7 @@ import {
21
23
  REFUSAL_THROTTLED,
22
24
  REFUSAL_UNCLASSIFIED,
23
25
  ROLLOUT_REFUSAL_FIELD,
26
+ SITE_DEPLOYMENT_REFUSAL_DOMAIN,
24
27
  } from "./constants.js";
25
28
  import { Readable } from "node:stream";
26
29
 
@@ -409,15 +412,10 @@ function capabilityNames(value) {
409
412
  * keys on it rather than on `@type`, which a future packing could reshape.
410
413
  */
411
414
  export function capabilityRefusal(value) {
412
- const queue = [{ value, depth: 0 }];
413
- let work = 0;
414
- while (queue.length > 0 && work < 1_000) {
415
- const current = queue.shift();
416
- work += 1;
417
- if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
418
- const metadata = current.value.metadata;
415
+ return findErrorInfo(value, (detail) => {
416
+ const metadata = detail.metadata;
419
417
  if (
420
- current.value.reason === CAPABILITY_REFUSAL_REASON
418
+ detail.reason === CAPABILITY_REFUSAL_REASON
421
419
  && metadata !== null
422
420
  && typeof metadata === "object"
423
421
  && !Array.isArray(metadata)
@@ -434,6 +432,61 @@ export function capabilityRefusal(value) {
434
432
  });
435
433
  }
436
434
  }
435
+ return undefined;
436
+ });
437
+ }
438
+
439
+ /**
440
+ * The pages-generated allowance or bandwidth-pause refusal the deploy API attaches
441
+ * as a `google.rpc.ErrorInfo` detail (TR00445, TR01201), or undefined. Keyed on the
442
+ * namespaced domain and reason, never the message sentence. `resetsAt` is the
443
+ * server's `resets_at` re-serialized as an ISO-8601 UTC instant, or undefined
444
+ * when it is absent (a licence that grants no deployments has no period) or
445
+ * does not parse. `requestedPages` and `remainingPages` are present on the
446
+ * allowance refusal only.
447
+ */
448
+ export function planLimitRefusal(value) {
449
+ return findErrorInfo(value, (detail) => {
450
+ if (
451
+ detail.domain !== SITE_DEPLOYMENT_REFUSAL_DOMAIN
452
+ || (detail.reason !== DEPLOYMENT_ALLOWANCE_REFUSAL_REASON && detail.reason !== BANDWIDTH_ALLOWANCE_REFUSAL_REASON)
453
+ ) {
454
+ return undefined;
455
+ }
456
+ const metadata = detail.metadata !== null && typeof detail.metadata === "object" ? detail.metadata : {};
457
+ const resetsAt = typeof metadata.resets_at === "string" ? new Date(metadata.resets_at) : undefined;
458
+ return Object.freeze({
459
+ reason: detail.reason,
460
+ resetsAt: resetsAt !== undefined && !Number.isNaN(resetsAt.getTime()) ? resetsAt.toISOString() : undefined,
461
+ // The pages the refused request needed and the period had left (TR01201).
462
+ requestedPages: pageCount(metadata.requested_pages),
463
+ remainingPages: pageCount(metadata.remaining_pages),
464
+ });
465
+ });
466
+ }
467
+
468
+ /** A page count the server reported as a decimal string, or undefined. */
469
+ function pageCount(value) {
470
+ const count = typeof value === "string" && /^[0-9]{1,9}$/u.test(value) ? Number(value) : undefined;
471
+ return Number.isSafeInteger(count) ? count : undefined;
472
+ }
473
+
474
+ /**
475
+ * The first object in `value`, breadth first, for which `match` returns a
476
+ * result. Transcoded `details[]` entries are `Any`-packed and nested, so one
477
+ * pinned path through them would silently empty every extractor on a contract
478
+ * change; the walk is bounded in depth and in work so a hostile body cannot
479
+ * stall it.
480
+ */
481
+ function findErrorInfo(value, match) {
482
+ const queue = [{ value, depth: 0 }];
483
+ let work = 0;
484
+ while (queue.length > 0 && work < 1_000) {
485
+ const current = queue.shift();
486
+ work += 1;
487
+ if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
488
+ const found = match(current.value);
489
+ if (found !== undefined) return found;
437
490
  for (const child of Object.values(current.value)) {
438
491
  if (child !== null && typeof child === "object") queue.push({ value: child, depth: current.depth + 1 });
439
492
  }
@@ -459,6 +512,7 @@ export class ApiError extends SiteAuthoringError {
459
512
  const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
460
513
  const fields = fieldViolations(body);
461
514
  const capability = capabilityRefusal(body);
515
+ const planLimit = planLimitRefusal(body);
462
516
  // Only the known upgrade refusal may supply a human message. Other server
463
517
  // errors can contain internal diagnostics and retain their generic output.
464
518
  const upgradeMessage = fields.includes(CLI_UPGRADE_REFUSAL_FIELD)
@@ -472,6 +526,10 @@ export class ApiError extends SiteAuthoringError {
472
526
  "api.request_rejected",
473
527
  upgradeMessage || (fields.length > 0
474
528
  ? `Taproot rejected the request field '${fields[0]}'.`
529
+ : planLimit !== undefined
530
+ ? planLimit.reason === BANDWIDTH_ALLOWANCE_REFUSAL_REASON
531
+ ? "Taproot paused deployments: the site used more than its monthly bandwidth for two months in a row."
532
+ : "Taproot refused the request: it generates more pages than the site's allowance for this billing period has left."
475
533
  : capability !== undefined
476
534
  ? `Taproot refused the request: this credential does not carry a capability granting '${
477
535
  capability.permission
@@ -491,6 +549,7 @@ export class ApiError extends SiteAuthoringError {
491
549
  this.fields = fields;
492
550
  this.violationDescriptions = fieldDescriptions(body);
493
551
  this.capability = capability;
552
+ this.planLimit = planLimit;
494
553
  // The `retry-after` trailer (TR00839's deployment throttle, or any other
495
554
  // ResourceExhausted refusal that sets it) transcodes to an ordinary
496
555
  // response header. Undefined when absent or unparseable — callers must
@@ -545,10 +604,12 @@ export class ApiError extends SiteAuthoringError {
545
604
  * left it unclassified: no `refusal` in the JSON result, and nothing said
546
605
  * on the human channel. The field arrives only from the in-transaction
547
606
  * re-validation, which does attach `ExternalApiKey`.
548
- * - `plan_limit` (field `UpgradePrompt`) — a commercial ceiling, surfaced at
607
+ * - `plan_limit` (field `UpgradePrompt`, or `ErrorInfo` reason
608
+ * `DEPLOYMENT_PERIOD_ALLOWANCE_EXCEEDED` / `BANDWIDTH_ALLOWANCE_EXCEEDED`
609
+ * with the server's `resets_at`) — a commercial ceiling, surfaced at
549
610
  * deploy time because no pre-flight read of the published-page quota
550
- * exists on the contract. **Upgrade** (or publish fewer pages); the CLI
551
- * never invents the numeric limit.
611
+ * exists on the contract. **Upgrade** (or publish fewer pages, or wait for
612
+ * the named reset); the CLI never invents the numeric limit.
552
613
  * - `throttled` (gRPC `ResourceExhausted` / HTTP 429) — the per-key
553
614
  * throttle. **Back off** and retry with delay.
554
615
  * - `capability_missing` (`ErrorInfo` reason `SITE_AUTHORING_CAPABILITY_MISSING`)
@@ -568,7 +629,7 @@ export class ApiError extends SiteAuthoringError {
568
629
  if (this.hasField(CLI_UPGRADE_REFUSAL_FIELD)) return REFUSAL_CLI_OUTDATED;
569
630
  if (this.hasField(ROLLOUT_REFUSAL_FIELD)) return REFUSAL_PLATFORM_PAUSED;
570
631
  if (this.hasField(CREDENTIAL_REFUSAL_FIELD)) return REFUSAL_CREDENTIAL_REJECTED;
571
- if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD)) return REFUSAL_PLAN_LIMIT;
632
+ if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD) || this.planLimit !== undefined) return REFUSAL_PLAN_LIMIT;
572
633
  // The named detail ranks with the fields and above the status mappings, for
573
634
  // the same reason: it is the specific statement about this refusal.
574
635
  if (this.capability !== undefined) return REFUSAL_CAPABILITY_MISSING;
@@ -76,7 +76,9 @@ const FIELD_HELP = Object.freeze({
76
76
  required: true,
77
77
  description:
78
78
  "The Taproot place being reviewed. The site fills in its name, address and coordinates from this record. Find "
79
- + "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'.",
79
+ + "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'. The site publishes the "
80
+ + "place's category in the byline and files the review under /places/<category>; change it for this site with "
81
+ + "'places category set <placeId> <category>'.",
80
82
  },
81
83
  place: {
82
84
  type: "UUID",
@@ -4,6 +4,7 @@ import {
4
4
  DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
5
5
  DEPLOYMENT_STATUS_COMPLETED,
6
6
  deploySite,
7
+ describePageEstimate,
7
8
  getDeploySelection,
8
9
  getPublishingReadiness,
9
10
  listDeployments,
@@ -67,9 +68,6 @@ const REDIRECT_ACTIVATION_NOTE =
67
68
  */
68
69
 
69
70
  const MAXIMUM_REPORTED_BLOCKERS = 50;
70
- // Staged page ids ride in the readiness query string, so a very large candidate
71
- // is checked at site level instead of pushing an unbounded URL at the API.
72
- const MAXIMUM_READINESS_PAGE_IDS = 100;
73
71
 
74
72
  function usageError(code, message, field) {
75
73
  return new SiteAuthoringError(code, message, { field, exitCode: 2 });
@@ -157,6 +155,8 @@ function reportReadiness(readiness) {
157
155
  redirectsChanged: readiness.redirectsChanged,
158
156
  formsChanged: readiness.formsChanged,
159
157
  videosChanged: readiness.videosChanged,
158
+ // The pages this deployment generates and the allowance left: what staging is charged (TR01201).
159
+ ...(readiness.pageEstimate === null ? {} : { pageEstimate: readiness.pageEstimate }),
160
160
  blockers: blockers.items,
161
161
  ...(blockers.truncated ? { blockersTruncated: true } : {}),
162
162
  };
@@ -341,8 +341,10 @@ export async function deploy(invocation) {
341
341
  );
342
342
  const readiness = await getPublishingReadiness(client, siteId, {
343
343
  ...candidate,
344
- stagedPageIds: stagedPageIds.length > MAXIMUM_READINESS_PAGE_IDS ? undefined : stagedPageIds,
345
344
  useExactPageSelection: stagedPageIds.length === 0,
345
+ includePageEstimate: true,
346
+ // The estimate must recognize the request it is about to be followed by.
347
+ ...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
346
348
  });
347
349
  if (readiness.blockers.length > 0) {
348
350
  throw new SiteAuthoringError(
@@ -368,11 +370,29 @@ export async function deploy(invocation) {
368
370
  if (!readiness.hasCandidateChanges) {
369
371
  onProgress("Taproot reports this candidate contains no changes; deploying it anyway would be a no-op.");
370
372
  }
373
+ // The plan is the charge: the same page count admission uses, shown before anything is sent.
374
+ if (readiness.pageEstimate !== null) {
375
+ onProgress(describePageEstimate(readiness.pageEstimate));
376
+ if (readiness.pageEstimate.exceedsAllowance) {
377
+ const estimate = readiness.pageEstimate;
378
+ throw new SiteAuthoringError(
379
+ "deploy.page_allowance_exceeded",
380
+ `This candidate generates ${estimate.plannedPageCount.toLocaleString("en-US")} ${
381
+ estimate.plannedPageCount === 1 ? "page" : "pages"
382
+ } but the site has ${(estimate.pagesGeneratedRemaining ?? 0).toLocaleString("en-US")} left for this billing `
383
+ + `period${estimate.periodEnd === null ? "" : ` (it resets ${estimate.periodEnd})`}. Stage a smaller change, `
384
+ + "or upgrade the site's plan for a larger allowance. Nothing was sent.",
385
+ { field: "plannedPageCount" },
386
+ );
387
+ }
388
+ }
371
389
 
372
390
  const created = await deploySite(client, siteId, {
373
391
  siteId,
374
392
  environment: DEPLOYMENT_ENVIRONMENT_STAGING,
375
393
  ...candidate,
394
+ // Confirms the number just shown: the server refuses if the plan has grown since.
395
+ ...(readiness.pageEstimate === null ? {} : { expectedPageCount: readiness.pageEstimate.plannedPageCount }),
376
396
  ...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
377
397
  });
378
398
  onProgress(`Staging deployment ${created.id} accepted; waiting for it to complete.`);
@@ -16,6 +16,9 @@ import {
16
16
  VERB_NAV_PUSH,
17
17
  VERB_PAGES_META_SET,
18
18
  VERB_PAGES_PUSH,
19
+ VERB_PLACES_CATEGORY_CLEAR,
20
+ VERB_PLACES_CATEGORY_LIST,
21
+ VERB_PLACES_CATEGORY_SET,
19
22
  VERB_PLACES_SEARCH,
20
23
  VERB_PLACES_SELECT,
21
24
  VERB_PLAN,
@@ -48,7 +51,7 @@ import { mediaUpload } from "./media-upload.js";
48
51
  import { navPush } from "./nav-push.js";
49
52
  import { pagesMetaSet } from "./pages-meta-set.js";
50
53
  import { pagesPush } from "./pages-push.js";
51
- import { placesSearch, placesSelect } from "./places.js";
54
+ import { placesCategoryClear, placesCategoryList, placesCategorySet, placesSearch, placesSelect } from "./places.js";
52
55
  import { apply, plan } from "./plan.js";
53
56
  import { previewPage } from "./preview-page.js";
54
57
  import { previewRevoke } from "./preview-revoke.js";
@@ -106,6 +109,8 @@ import { whoami } from "./whoami.js";
106
109
  * // with none, every file under forms/
107
110
  * placeQuery, // places search: the words of the query
108
111
  * placeSelection, // places select: [googlePlaceId, sessionToken]
112
+ * placeCategory, // places category set: [placeId, category]
113
+ * placeId, // places category clear: [placeId]
109
114
  * pagePaths, // approve: narrow to these page paths; with none, every
110
115
  * // draft the workspace manifest tracks is staged
111
116
  * authorHandle, // authors add: the handle of the site author to create
@@ -148,6 +153,9 @@ export const VERB_HANDLERS = Object.freeze({
148
153
  [VERB_PLAN]: plan,
149
154
  [VERB_PLACES_SEARCH]: placesSearch,
150
155
  [VERB_PLACES_SELECT]: placesSelect,
156
+ [VERB_PLACES_CATEGORY_SET]: placesCategorySet,
157
+ [VERB_PLACES_CATEGORY_CLEAR]: placesCategoryClear,
158
+ [VERB_PLACES_CATEGORY_LIST]: placesCategoryList,
151
159
  [VERB_APPLY]: apply,
152
160
  [VERB_REDIRECTS_CHECK]: redirectsCheck,
153
161
  [VERB_REDIRECTS_PULL]: redirectsPull,
@@ -354,7 +354,6 @@ const VIDEO_REFUSAL_FIELDS = Object.freeze([
354
354
  "Video",
355
355
  "UpgradePrompt",
356
356
  "VideoNotIncluded",
357
- "VideoPendingDowngrade",
358
357
  ]);
359
358
 
360
359
  /**
@@ -1,7 +1,21 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
- import { isGooglePlaceId, searchPlaces, selectPlace, withRefusalGuidance } from "../api.js";
4
- import { VERB_PLACES_SEARCH, VERB_PLACES_SELECT } from "../constants.js";
3
+ import {
4
+ clearPlaceCategory,
5
+ isGooglePlaceId,
6
+ listPlaceCategories,
7
+ searchPlaces,
8
+ selectPlace,
9
+ setPlaceCategory,
10
+ withRefusalGuidance,
11
+ } from "../api.js";
12
+ import {
13
+ VERB_PLACES_CATEGORY_CLEAR,
14
+ VERB_PLACES_CATEGORY_LIST,
15
+ VERB_PLACES_CATEGORY_SET,
16
+ VERB_PLACES_SEARCH,
17
+ VERB_PLACES_SELECT,
18
+ } from "../constants.js";
5
19
  import { SiteAuthoringError } from "../errors.js";
6
20
  import { openSession, successResult } from "../session.js";
7
21
 
@@ -18,6 +32,9 @@ import { openSession, successResult } from "../session.js";
18
32
  */
19
33
 
20
34
  const MAXIMUM_QUERY_LENGTH = 200;
35
+ const MAXIMUM_CATEGORY_LENGTH = 100;
36
+ // Any UUID version: the server mints place ids from a sequential-style generator.
37
+ const PLACE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
21
38
  const SESSION_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
22
39
 
23
40
  export async function placesSearch(invocation) {
@@ -59,3 +76,56 @@ export async function placesSelect(invocation) {
59
76
  onProgress(`Selected ${place.name}: use placeId ${place.placeId} in the page's front matter.`);
60
77
  return successResult(VERB_PLACES_SELECT, siteId, { place });
61
78
  }
79
+
80
+ /**
81
+ * `places category set|clear|list` — this site's own category for a place
82
+ * (TR01203).
83
+ *
84
+ * A place's category is shared by every site, and Google's types do not always
85
+ * give the one an owner wants. The override is the site's alone: its place
86
+ * reviews and `/places/<category>` pages use it from the next deployment, and
87
+ * the platform-wide place is never changed. The server owns the closed list, so
88
+ * the CLI sends the name as given and lets the server check it.
89
+ */
90
+ export async function placesCategorySet(invocation) {
91
+ const [placeId, category, ...extra] = Array.isArray(invocation.placeCategory) ? invocation.placeCategory : [];
92
+ if (!PLACE_ID.test(placeId ?? "") || extra.length > 0 || typeof category !== "string"
93
+ || category.trim() === "" || category.length > MAXIMUM_CATEGORY_LENGTH) {
94
+ throw new SiteAuthoringError(
95
+ "places.category_invalid",
96
+ "places category set takes the placeId, then one category from 'places category list'.",
97
+ { field: "category", exitCode: 2 },
98
+ );
99
+ }
100
+ const session = await openSession(invocation);
101
+ const { client, siteId, onProgress } = session;
102
+ const state = await withRefusalGuidance(onProgress, "place category", async () =>
103
+ await setPlaceCategory(client, siteId, { placeId, category: category.trim() }));
104
+ onProgress(`This site now files ${placeId} under ${state.category}; deploy for pages to use it.`);
105
+ return successResult(VERB_PLACES_CATEGORY_SET, siteId, { placeCategory: state });
106
+ }
107
+
108
+ export async function placesCategoryClear(invocation) {
109
+ const [placeId, ...extra] = Array.isArray(invocation.placeId) ? invocation.placeId : [];
110
+ if (!PLACE_ID.test(placeId ?? "") || extra.length > 0) {
111
+ throw new SiteAuthoringError(
112
+ "places.category_invalid",
113
+ "places category clear takes the placeId of the place whose category this site set.",
114
+ { field: "placeId", exitCode: 2 },
115
+ );
116
+ }
117
+ const session = await openSession(invocation);
118
+ const { client, siteId, onProgress } = session;
119
+ const state = await withRefusalGuidance(onProgress, "place category", async () =>
120
+ await clearPlaceCategory(client, siteId, { placeId }));
121
+ onProgress(`This site files ${placeId} under ${state.category} again; deploy for pages to use it.`);
122
+ return successResult(VERB_PLACES_CATEGORY_CLEAR, siteId, { placeCategory: state });
123
+ }
124
+
125
+ export async function placesCategoryList(invocation) {
126
+ const session = await openSession(invocation);
127
+ const { client, siteId, onProgress } = session;
128
+ const categories = await withRefusalGuidance(onProgress, "place categories", async () =>
129
+ await listPlaceCategories(client));
130
+ return successResult(VERB_PLACES_CATEGORY_LIST, siteId, { categories });
131
+ }
@@ -132,10 +132,7 @@ export async function status(invocation) {
132
132
  const selection = presentationOnly
133
133
  ? { ...reviewed, stagedPageIds: [], includeNavigation: false }
134
134
  : reviewed;
135
- const readiness = await getPublishingReadiness(client, siteId, {
136
- ...selection,
137
- stagedPageIds: selection.stagedPageIds.length > 100 ? undefined : selection.stagedPageIds,
138
- });
135
+ const readiness = await getPublishingReadiness(client, siteId, selection);
139
136
  onProgress("Reading the deployment log.");
140
137
  const {
141
138
  deployments,