@taprootio/site-authoring 0.16.0 → 0.19.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.
@@ -1,12 +1,12 @@
1
1
  import { SiteAuthoringError } from "./errors.js";
2
2
  import {
3
- FORM_DEFAULT_SINK,
3
+ FORM_DEFAULT_NOTIFY_OWNER,
4
4
  FORM_FILE_EXTENSION,
5
- FORM_RETENTION_DAYS,
6
5
  FORM_LIMITS,
7
6
  FORMS_DIRECTORY,
8
7
  formFileName,
9
8
  isFormKey,
9
+ projectFormSettings,
10
10
  validateFormDocument,
11
11
  } from "./forms-contract.js";
12
12
  import { readWorkspaceJson, walkWorkspaceFiles } from "./workspace.js";
@@ -18,8 +18,9 @@ import { readWorkspaceJson, walkWorkspaceFiles } from "./workspace.js";
18
18
  * typo never reads as a successful empty push. With none, every file under
19
19
  * `forms/` is read. A file whose name is not a form key is refused rather than
20
20
  * skipped: skipping it would report success with one form left behind.
21
+ * Newsletter admission is strict by default; `forms push` defers it for baseline comparison.
21
22
  */
22
- export async function readWorkspaceForms(workspaceDir, keys) {
23
+ export async function readWorkspaceForms(workspaceDir, keys, validationOptions = {}) {
23
24
  const files = await walkWorkspaceFiles(workspaceDir, FORMS_DIRECTORY, [FORM_FILE_EXTENSION]);
24
25
  const known = new Map();
25
26
  for (const file of files) {
@@ -46,7 +47,7 @@ export async function readWorkspaceForms(workspaceDir, keys) {
46
47
  );
47
48
  }
48
49
  const document_ = await readWorkspaceJson(workspaceDir, file, FORM_LIMITS.fileBytes);
49
- forms.push({ key, file, document: validateFormDocument(document_, key) });
50
+ forms.push({ key, file, document: validateFormDocument(document_, key, validationOptions) });
50
51
  }
51
52
  return forms;
52
53
  }
@@ -54,10 +55,9 @@ export async function readWorkspaceForms(workspaceDir, keys) {
54
55
  /**
55
56
  * The forms baseline in the pull manifest (TR01061).
56
57
  *
57
- * The site has no compare-and-set for a form, so `forms push` fences itself:
58
- * it writes a form only when the site's current version is the one the last
59
- * pull or push recorded here. A form edited in the browser since then makes
60
- * the push refuse toward pull instead of overwriting it.
58
+ * A browser edit since the last pull or push makes the next push refuse rather
59
+ * than overwrite it. Each mutation also sends this baseline so the site can
60
+ * refuse an edit made after the CLI compared them.
61
61
  */
62
62
  export const FORMS_MANIFEST_KEY = "forms";
63
63
 
@@ -67,15 +67,16 @@ function isObject(value) {
67
67
 
68
68
  /**
69
69
  * The settings half of a baseline, from the form exactly as the site returned
70
- * it. Name, sink and retention change without appending a version, so the
70
+ * it. Operational settings change without appending a version, so the
71
71
  * version alone cannot say they moved.
72
72
  */
73
73
  export function siteSettingsBaseline(siteForm) {
74
- return {
74
+ return projectFormSettings({
75
75
  name: siteForm.name,
76
- sink: siteForm.sink ?? FORM_DEFAULT_SINK,
77
- retention_days: siteForm.retentionDays ?? FORM_RETENTION_DAYS.default,
78
- };
76
+ sink: siteForm.sink,
77
+ retention_days: siteForm.retentionDays,
78
+ notify_owner: siteForm.notifyOwner,
79
+ });
79
80
  }
80
81
 
81
82
  /** The recorded baseline for one key, or undefined when this workspace never saw that form. */
@@ -85,7 +86,15 @@ export function recordedForm(manifest, key) {
85
86
  const entry = items[key];
86
87
  return isObject(entry) && typeof entry.id === "string" && Number.isSafeInteger(entry.version)
87
88
  && isObject(entry.settings)
88
- ? { id: entry.id, version: entry.version, settings: entry.settings }
89
+ ? {
90
+ id: entry.id,
91
+ version: entry.version,
92
+ // Old baselines can prove only the default opt-out, never a remote opt-in.
93
+ settings: {
94
+ ...entry.settings,
95
+ notify_owner: Object.hasOwn(entry.settings, "notify_owner") ? entry.settings.notify_owner : FORM_DEFAULT_NOTIFY_OWNER,
96
+ },
97
+ }
89
98
  : undefined;
90
99
  }
91
100
 
@@ -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({
@@ -773,14 +773,19 @@ const WORKFLOW_REFERENCES = Object.freeze({
773
773
  details: Object.freeze([
774
774
  `Run 'pull' once first: 'forms pull' and 'forms push' need the pulled workspace's manifest, which records the `
775
775
  + "version of each form this workspace last saw. 'forms validate' needs only taproot-site.json.",
776
- `The file is { key, name, sink, retention_days, definition }. key must equal the file's name `
776
+ `The file is { key, name, sink, retention_days, notify_owner, definition }. key must equal the file's name `
777
777
  + `and is lowercase letters, digits, and single hyphens, at most ${FORM_KEY_MAXIMUM_LENGTH} characters. name is `
778
778
  + `1 to ${FORM_LIMITS.nameCharacters} characters. sink is one of ${FORM_SINKS.join(", ")} and defaults to `
779
- + "'none' (store submissions only); 'newsletter' also subscribes the contact address to the site's newsletter. "
779
+ + "'none' (store submissions only); 'newsletter' requests double-opt-in confirmation for the contact address. "
780
+ + "A newsletter sink requires a required email contact field and at least one consent checkbox; every consent checkbox must be checked. "
780
781
  + `retention_days is ${FORM_RETENTION_DAYS.minimum} to ${FORM_RETENTION_DAYS.maximum} (a site's plan may cap it lower): `
781
782
  + "submissions are deleted that many days after they arrive. Leave it out and the site chooses on creation (its "
782
783
  + "plan's default, usually 365) and keeps the current value on an update. Shortening it deletes the submissions that fall outside it, "
783
784
  + "so a key cannot do it; the site refuses it, and an owner or admin must make that change.",
785
+ "notify_owner is an optional top-level boolean, independent of the sink. New forms default to false; omitting it "
786
+ + "on an existing form preserves the site's preference. It opts into hourly counts-only email to the current site "
787
+ + "account owner, never visitor answers or addresses. This setting takes effect without a new version or publishing. "
788
+ + "Delivery also requires Taproot's rollout and email provider readiness; opt-in enables neither, and earlier submissions are not backfilled.",
784
789
  `definition is { fields, contact_field?, submit_label?, after_submit? } (contact_field only with several email fields). Each field is { id, type, label, hint?, ...constraints }; the closed `
785
790
  + `field types are ${FIELD_TYPES.join(", ")}. hint is optional helper text of up to ${MAX_HINT_LENGTH} `
786
791
  + "characters, drawn under the field on the published form; it is the only author-defined text printed there, so a required "
@@ -835,7 +840,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
835
840
  + "'unchanged' and leaves the bytes as they were. Write your own files in that order and a push rewrites "
836
841
  + "nothing you did not change.",
837
842
  "'forms push' validates every file and compares every form with the site before the first write. It refuses "
838
- + "forms.concurrent_modification when the site's version moved since this workspace last read it, "
843
+ + "forms.concurrent_modification when the site's version or operational settings moved since this workspace last read it, "
839
844
  + "forms.pull_required when the site has a form with that key this workspace never pulled, and forms.archived "
840
845
  + "when the form is archived on the site. Reconcile with 'forms pull' and retry; nothing was written.",
841
846
  "Submissions are never pulled or pushed. They are visitors' personal data: read, mark, export and delete "
@@ -854,6 +859,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
854
859
  name: "Contact",
855
860
  sink: "none",
856
861
  retention_days: 365,
862
+ notify_owner: false,
857
863
  definition: Object.freeze({
858
864
  fields: Object.freeze([
859
865
  Object.freeze({ id: "name", type: "text", label: "Your name", required: true, max_length: 120 }),
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.`);
@@ -41,9 +41,7 @@ export async function formsPull(invocation) {
41
41
  }
42
42
  const document_ = projectFormForWorkspace({
43
43
  key: form.key,
44
- name: form.name,
45
- sink: form.sink,
46
- retention_days: form.retentionDays,
44
+ ...siteSettingsBaseline(form),
47
45
  definition: version.definition,
48
46
  }, pages?.pathById);
49
47
  await writeWorkspaceJson(config.workspaceDir, formFileName(form.key), document_);
@@ -8,19 +8,24 @@ import {
8
8
  saveSiteFormVersion,
9
9
  updateSiteFormSettings,
10
10
  } from "../forms-api.js";
11
- import { afterSubmitPage, formFileName, projectFormForWorkspace, storedDefinition } from "../forms-contract.js";
11
+ import { afterSubmitPage, formFileName, projectFormForWorkspace, storedDefinition, validateNewsletterDefinition } from "../forms-contract.js";
12
12
  import { readWorkspaceForms, recordedForm, siteSettingsBaseline, withRecordedForm } from "../forms-workspace.js";
13
13
  import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
14
14
  import { ApiError } from "../transport.js";
15
15
  import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
16
16
 
17
- function changedOnTheSite(key, cause, settingsApplied) {
17
+ function changedOnTheSite(key, cause, applied) {
18
+ const outcome = applied.settings && applied.definition
19
+ ? "Its settings and definition were applied."
20
+ : applied.settings
21
+ ? "Its settings were applied; its definition was not."
22
+ : applied.definition
23
+ ? "Its definition was applied; its settings were not."
24
+ : "This form was not changed.";
18
25
  return new SiteAuthoringError(
19
26
  "forms.concurrent_modification",
20
27
  `${formFileName(key)}: the form changed on the site while it was being pushed. Run '${VERB_FORMS_PULL}', `
21
- + `reconcile the file, and retry. ${
22
- settingsApplied ? "Its settings were applied; its definition was not." : "This form was not changed."
23
- }`,
28
+ + `reconcile the file, and retry. ${outcome}`,
24
29
  { field: formFileName(key), status: cause.status },
25
30
  );
26
31
  }
@@ -44,6 +49,7 @@ function planForm(form, remote, baseline, pages) {
44
49
  const desired = projectFormForWorkspace({
45
50
  ...form.document,
46
51
  retention_days: requestedRetention ?? remote?.form.retentionDays,
52
+ notify_owner: form.document.notify_owner ?? remote?.form.notifyOwner,
47
53
  }, pages?.pathById);
48
54
  if (remote === undefined) {
49
55
  if (baseline !== undefined) {
@@ -66,28 +72,27 @@ function planForm(form, remote, baseline, pages) {
66
72
  if (
67
73
  baseline.id !== remote.form.id
68
74
  || baseline.version !== remote.version.version
69
- // Name, sink and retention change without a new version, so they are
75
+ // Operational settings change without a new version, so they are
70
76
  // fenced separately; a stale file would otherwise revert them.
71
77
  || JSON.stringify(baseline.settings) !== JSON.stringify(siteSettings)
72
78
  ) {
73
79
  throw new SiteAuthoringError(
74
80
  "forms.concurrent_modification",
75
81
  `${formFileName(form.key)}: the form changed on the site after this workspace read it (recorded version `
76
- + `${baseline.version}, site has ${remote.version.version}; its name, sink or retention may also have changed). Run '${VERB_FORMS_PULL}', reconcile the file, `
82
+ + `${baseline.version}, site has ${remote.version.version}; its settings may also have changed). Run '${VERB_FORMS_PULL}', reconcile the file, `
77
83
  + "and retry. Nothing was written.",
78
84
  { field: formFileName(form.key) },
79
85
  );
80
86
  }
81
87
  const current = projectFormForWorkspace({
82
88
  key: remote.form.key,
83
- name: remote.form.name,
84
- sink: remote.form.sink,
85
- retention_days: remote.form.retentionDays,
89
+ ...siteSettings,
86
90
  definition: remote.version.definition,
87
91
  }, pages?.pathById);
88
92
  const settingsChanged = current.name !== desired.name
89
93
  || current.sink !== desired.sink
90
- || current.retention_days !== desired.retention_days;
94
+ || current.retention_days !== desired.retention_days
95
+ || current.notify_owner !== desired.notify_owner;
91
96
  const versionChanged = JSON.stringify(current.definition) !== JSON.stringify(desired.definition);
92
97
  return {
93
98
  form,
@@ -114,7 +119,7 @@ export async function formsPush(invocation) {
114
119
  const session = await openSession(invocation);
115
120
  const { client, config, siteId, onProgress } = session;
116
121
  warnIfExternalWritesPaused(session, VERB_FORMS_PUSH);
117
- const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys);
122
+ const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys, { deferNewsletterSemantics: true });
118
123
  onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"}.`);
119
124
  let manifest = await readManifest(config.workspaceDir, siteId);
120
125
 
@@ -132,6 +137,14 @@ export async function formsPush(invocation) {
132
137
  pages = await withRefusalGuidance(onProgress, "forms push", async () => await getPageIndex(client, siteId, { onProgress }));
133
138
  }
134
139
  const plan = planForm(form, remote, recordedForm(manifest, form.key), pages);
140
+ // Legacy newsletter definitions may stay only after the baseline matches.
141
+ // New capture or a changed definition must satisfy today's admission rules.
142
+ if (
143
+ plan.desired.sink === "newsletter"
144
+ && (plan.action === "create" || plan.remote.form.sink !== "newsletter" || plan.versionChanged)
145
+ ) {
146
+ validateNewsletterDefinition(form.file, plan.desired.definition);
147
+ }
135
148
  // A page_path the site does not have is refused here, with the rest of the checks, not partway
136
149
  // through the writes. Only a definition that is going to be sent is resolved: a settings-only
137
150
  // update of a form whose page was deleted never sends it.
@@ -151,7 +164,7 @@ export async function formsPush(invocation) {
151
164
  // The baseline is what the site stored, not what the file said: the site
152
165
  // normalizes some values, and the next push is fenced against its copy.
153
166
  let settings = plan.remote === undefined ? undefined : siteSettingsBaseline(plan.remote.form);
154
- let settingsApplied = false;
167
+ const applied = { settings: false, definition: false };
155
168
  try {
156
169
  if (plan.action === "create") {
157
170
  const created = await withRefusalGuidance(
@@ -166,43 +179,48 @@ export async function formsPush(invocation) {
166
179
  // and normalized the name.
167
180
  desired = projectFormForWorkspace({
168
181
  ...desired,
169
- name: created.form.name,
170
- sink: created.form.sink,
171
- retention_days: created.form.retentionDays,
182
+ ...settings,
172
183
  }, pages?.pathById);
173
184
  } else if (plan.action === "update") {
174
- if (plan.settingsChanged) {
175
- settings = siteSettingsBaseline(
176
- await withRefusalGuidance(
185
+ // Enabling newsletter capture checks the stored definition, so its new
186
+ // email and consent fields must land first. Disabling must do the reverse.
187
+ const definitionFirst = plan.settingsChanged && plan.versionChanged
188
+ && settings.sink === "none" && desired.sink === "newsletter";
189
+ for (const mutation of definitionFirst ? ["definition", "settings"] : ["settings", "definition"]) {
190
+ if (mutation === "settings") {
191
+ if (!plan.settingsChanged) continue;
192
+ settings = siteSettingsBaseline(
193
+ await withRefusalGuidance(
194
+ onProgress,
195
+ "forms push",
196
+ async () => await updateSiteFormSettings(client, siteId, formId, desired, plan.baseline.settings),
197
+ ),
198
+ );
199
+ desired = projectFormForWorkspace({ ...desired, ...settings }, pages?.pathById);
200
+ } else {
201
+ if (!plan.versionChanged) continue;
202
+ version = (await withRefusalGuidance(
177
203
  onProgress,
178
204
  "forms push",
179
- async () => await updateSiteFormSettings(client, siteId, formId, desired, plan.baseline.settings),
180
- ),
181
- );
182
- settingsApplied = true;
183
- // Recorded at once: if the version save below fails, the next push
184
- // must not read the settings it just applied as someone else's edit.
205
+ async () => await saveSiteFormVersion(client, siteId, formId, { ...desired, definition: plan.wireDefinition }, plan.baseline.version),
206
+ )).version;
207
+ }
208
+ applied[mutation] = true;
209
+ // A retry must recognize either write as its own, even if the next
210
+ // mutation fails. Keep the other half of the baseline until it lands.
185
211
  manifest = withRecordedForm(manifest, form.key, { id: formId, version, settings });
186
212
  await writeManifest(config.workspaceDir, manifest);
187
213
  }
188
- if (plan.versionChanged) {
189
- version = (await withRefusalGuidance(
190
- onProgress,
191
- "forms push",
192
- async () => await saveSiteFormVersion(client, siteId, formId, { ...desired, definition: plan.wireDefinition }, plan.baseline.version),
193
- )).version;
194
- }
195
214
  }
196
215
  } catch (caught) {
197
216
  // The site checks the recorded version and settings under its own lock,
198
217
  // which is what closes the gap between the comparison above and the write.
199
218
  const error = caught instanceof ApiError && (caught.hasField("ExpectedVersion") || caught.hasField("ExpectedSettings"))
200
- ? changedOnTheSite(form.key, caught, settingsApplied)
219
+ ? changedOnTheSite(form.key, caught, applied)
201
220
  : caught;
202
221
  if (error instanceof SiteAuthoringError) {
203
222
  const completed = results.filter((item) => item.action !== "unchanged").map((item) => item.key);
204
- // The settings write stands even though the version save after it failed.
205
- if (settingsApplied) completed.push(form.key);
223
+ if (applied.settings || applied.definition) completed.push(form.key);
206
224
  if (completed.length > 0) throw error.withCompletedWrites(completed);
207
225
  }
208
226
  throw error;
@@ -222,6 +240,6 @@ export async function formsPush(invocation) {
222
240
  return successResult(VERB_FORMS_PUSH, siteId, {
223
241
  forms: { count: results.length, items: results },
224
242
  written: { items: written, count: written.length },
225
- ...(written.length > 0 ? { nextStep: "deploy --staging" } : {}),
243
+ ...(plans.some((plan) => plan.action === "create" || plan.versionChanged) ? { nextStep: "deploy --staging" } : {}),
226
244
  });
227
245
  }
@@ -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
  /**