@taprootio/site-authoring 0.18.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.
package/README.md CHANGED
@@ -524,6 +524,13 @@ Submissions are never pulled. `help forms` states the file shape and every
524
524
  refusal, and placing a form on a page is the free-form `form`
525
525
  component.
526
526
 
527
+ The optional top-level `notify_owner` boolean opts a form into hourly,
528
+ counts-only owner email. New forms default to false; omitting it on an
529
+ existing form preserves the site's preference. It is independent of the sink
530
+ and takes effect without publishing or a new form version. Delivery requires
531
+ Taproot's rollout and email provider readiness; opt-in does not enable either
532
+ or backfill earlier submissions. The CLI never pulls visitor answers.
533
+
527
534
  `preview page <page-path-or-id>` renders one persisted draft behind the
528
535
  staging gate and mints a browser handoff URL, reported only in the final JSON
529
536
  result. **Each handoff is single-use.** Opening the URL consumes it; a reused,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",
@@ -17,6 +17,11 @@
17
17
  "description": "The id of the email field whose value the API copies to subject_email. It must name an email field. Omit it when the form has one email field (that field is the contact); it is required with two or more.",
18
18
  "$ref": "#/$defs/id"
19
19
  },
20
+ "newsletter_audience_id": {
21
+ "description": "The active manual newsletter audience on the form's site that receives the reader after email confirmation. Omit it for newsletter only. The server checks ownership and availability when saving.",
22
+ "type": "string",
23
+ "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
24
+ },
20
25
  "submit_label": {
21
26
  "description": "The text on the submit button. Omit it for Taproot's default. Must contain more than whitespace; the validator also rejects a blank label, which a schema cannot express.",
22
27
  "type": "string",
@@ -106,7 +106,7 @@ export function contactFieldId(definition) {
106
106
  const emails = definition.fields.filter((field) => field.type === "email");
107
107
  return emails.length === 1 ? emails[0].id : undefined;
108
108
  }
109
- const DEFINITION_KEYS = ["fields", "contact_field", "submit_label", "after_submit"];
109
+ const DEFINITION_KEYS = ["fields", "contact_field", "submit_label", "after_submit", "newsletter_audience_id"];
110
110
  const BASE_FIELD_KEYS = ["id", "type", "label", "hint"];
111
111
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
112
112
  const isInteger = (value) => typeof value === "number" && Number.isInteger(value);
@@ -427,6 +427,10 @@ export function validateDefinition(definition) {
427
427
  }
428
428
  /** The button text and the after-submit choice, which sit beside the fields in a definition. */
429
429
  function validateFormSettings(definition, errors) {
430
+ const audience = definition.newsletter_audience_id;
431
+ if (audience !== undefined && (typeof audience !== "string" || !isResourceId(audience))) {
432
+ errors.push({ path: "newsletter_audience_id", code: "invalid_newsletter_audience" });
433
+ }
430
434
  const label = definition.submit_label;
431
435
  if (label !== undefined && (typeof label !== "string" || isBlank(label) || label.length > MAX_SUBMIT_LABEL_LENGTH)) {
432
436
  errors.push({ path: "submit_label", code: "invalid_submit_label" });
package/src/forms-api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { listSitePages, sitePath } from "./api.js";
2
2
  import { HTTP_NOT_FOUND } from "./constants.js";
3
3
  import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
4
- import { FORM_DEFAULT_SINK, FORM_SINKS, isFormKey, normalizePagePath } from "./forms-contract.js";
4
+ import { FORM_DEFAULT_NOTIFY_OWNER, FORM_DEFAULT_SINK, FORM_SINKS, isFormKey, normalizePagePath } from "./forms-contract.js";
5
5
  import { ApiError } from "./transport.js";
6
6
 
7
7
  /**
@@ -38,12 +38,16 @@ function normalizeForm(value) {
38
38
  if (!isCanonicalUuid(value.id) || !isFormKey(value.key)) {
39
39
  throw contract("Taproot returned a form with no usable id or a key this workspace cannot store.", "form");
40
40
  }
41
+ if (Object.hasOwn(value, "notifyOwner") && typeof value.notifyOwner !== "boolean") {
42
+ throw contract("Taproot returned an owner notification setting that is not a boolean.", "notifyOwner");
43
+ }
41
44
  return {
42
45
  id: value.id,
43
46
  key: value.key,
44
47
  name: typeof value.name === "string" ? value.name : "",
45
48
  sink: sinkFromWire(value.sink),
46
49
  retentionDays: Number.isSafeInteger(value.retentionDays) ? value.retentionDays : undefined,
50
+ notifyOwner: value.notifyOwner ?? FORM_DEFAULT_NOTIFY_OWNER,
47
51
  archived: value.status === STATUS_ARCHIVED_WIRE,
48
52
  currentVersion: Number.isSafeInteger(value.currentVersion) ? value.currentVersion : 0,
49
53
  };
@@ -140,6 +144,7 @@ export async function createSiteForm(client, siteId, form) {
140
144
  name: form.name,
141
145
  sink: SINK_WIRE[form.sink],
142
146
  retentionDays: form.retention_days,
147
+ notifyOwner: form.notify_owner,
143
148
  definition: JSON.stringify(form.definition),
144
149
  },
145
150
  maximumResponseBytes: FORMS_RESPONSE_BYTES,
@@ -160,12 +165,14 @@ export async function updateSiteFormSettings(client, siteId, formId, form, expec
160
165
  name: form.name,
161
166
  sink: SINK_WIRE[form.sink],
162
167
  retentionDays: form.retention_days,
168
+ notifyOwner: form.notify_owner,
163
169
  ...(expected === undefined
164
170
  ? {}
165
171
  : {
166
172
  expectedName: expected.name,
167
173
  expectedSink: SINK_WIRE[expected.sink],
168
174
  expectedRetentionDays: expected.retention_days,
175
+ expectedNotifyOwner: expected.notify_owner,
169
176
  }),
170
177
  },
171
178
  maximumResponseBytes: FORMS_RESPONSE_BYTES,
@@ -5,7 +5,7 @@ import { contactFieldId, validateDefinition } from "./field-validation.js";
5
5
  * The workspace contract for a form (TR01061).
6
6
  *
7
7
  * A form is one JSON file, `forms/<key>.json`, holding what an author decides:
8
- * its key, name, sink, retention and definition. The definition holds the fields
8
+ * its key, name, sink, retention, owner-notification preference and definition. The definition holds the fields
9
9
  * and the form's own settings (`contact_field`, `submit_label`, `after_submit`),
10
10
  * exactly as the site stores it, so one validator reads one shape. The
11
11
  * definition's own rules belong to `shared/field-validation.ts` and are run
@@ -27,6 +27,7 @@ export const FORM_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
27
27
  export const FORM_KEY_MAXIMUM_LENGTH = 64;
28
28
  export const FORM_SINKS = Object.freeze(["none", "newsletter"]);
29
29
  export const FORM_DEFAULT_SINK = "none";
30
+ export const FORM_DEFAULT_NOTIFY_OWNER = false;
30
31
  export const FORM_RETENTION_DAYS = Object.freeze({ minimum: 30, maximum: 730, default: 365 });
31
32
  export const FORM_LIMITS = Object.freeze({
32
33
  nameCharacters: 120,
@@ -35,7 +36,7 @@ export const FORM_LIMITS = Object.freeze({
35
36
  fileBytes: 256 * 1024,
36
37
  });
37
38
 
38
- const FORM_FILE_KEYS = ["key", "name", "sink", "retention_days", "definition"];
39
+ const FORM_FILE_KEYS = ["key", "name", "sink", "retention_days", "notify_owner", "definition"];
39
40
  // The order `forms pull` writes, which `forms push` writes back after a save.
40
41
  // Fixed rather than inherited from the response so the same form is always the
41
42
  // same bytes.
@@ -101,14 +102,24 @@ function describeDefinitionErrors(errors) {
101
102
  return `${shown.join("; ")}${more}`;
102
103
  }
103
104
 
105
+ /** Checks newsletter admission after the definition has passed shared validation. */
106
+ export function validateNewsletterDefinition(file, definition) {
107
+ const fields = definition.fields;
108
+ const contact = fields.find((field) => field.id === contactFieldId(definition));
109
+ if (contact?.type !== "email" || contact.required !== true || !fields.some((field) => field.type === "consent")) {
110
+ throw invalid(file, "a newsletter sink requires a required email contact field and at least one consent checkbox.", "definition");
111
+ }
112
+ }
113
+
104
114
  /**
105
115
  * Validates one parsed form file and returns it unchanged in meaning.
106
116
  *
107
117
  * `expectedKey` is the key the file's name promises; a mismatch is refused
108
118
  * because a rename on disk would otherwise push a different form than the one
109
119
  * the author thinks they are editing.
120
+ * Only `forms push` defers newsletter admission until it compares the site's baseline.
110
121
  */
111
- export function validateFormDocument(document_, expectedKey) {
122
+ export function validateFormDocument(document_, expectedKey, { deferNewsletterSemantics = false } = {}) {
112
123
  const file = formFileName(expectedKey);
113
124
  if (!isPlainObject(document_)) throw invalid(file, "the file must contain one JSON object.", "$");
114
125
  for (const name of Object.keys(document_)) {
@@ -138,6 +149,9 @@ export function validateFormDocument(document_, expectedKey) {
138
149
  if (document_.sink !== undefined && !FORM_SINKS.includes(document_.sink)) {
139
150
  throw invalid(file, `sink must be one of: ${FORM_SINKS.join(", ")}.`, "sink");
140
151
  }
152
+ if (Object.hasOwn(document_, "notify_owner") && typeof document_.notify_owner !== "boolean") {
153
+ throw invalid(file, "notify_owner must be true or false.", "notify_owner");
154
+ }
141
155
  const days = document_.retention_days;
142
156
  if (
143
157
  days !== undefined
@@ -156,6 +170,7 @@ export function validateFormDocument(document_, expectedKey) {
156
170
  if (errors.length > 0) {
157
171
  throw invalid(file, `the definition is invalid: ${describeDefinitionErrors(errors)}`, "definition");
158
172
  }
173
+ if (document_.sink === "newsletter" && !deferNewsletterSemantics) validateNewsletterDefinition(file, document_.definition);
159
174
  if (anyUnstorableString(document_.definition)) {
160
175
  throw invalid(file, "the definition cannot contain NUL or unpaired surrogate characters.", "definition");
161
176
  }
@@ -238,6 +253,16 @@ function rejectNonTarget(file, pages, id, field, name) {
238
253
  );
239
254
  }
240
255
 
256
+ /** Operational settings in the same order for workspace files and saved baselines. */
257
+ export function projectFormSettings(form) {
258
+ return {
259
+ name: form.name,
260
+ sink: form.sink ?? FORM_DEFAULT_SINK,
261
+ retention_days: form.retention_days ?? FORM_RETENTION_DAYS.default,
262
+ notify_owner: form.notify_owner ?? FORM_DEFAULT_NOTIFY_OWNER,
263
+ };
264
+ }
265
+
241
266
  function canonicalField(field) {
242
267
  const ordered = {};
243
268
  for (const name of FIELD_KEY_ORDER) if (field[name] !== undefined) ordered[name] = field[name];
@@ -266,9 +291,7 @@ export function projectFormForWorkspace(form, pathById) {
266
291
  }
267
292
  return {
268
293
  key: form.key,
269
- name: form.name,
270
- sink: form.sink ?? FORM_DEFAULT_SINK,
271
- retention_days: form.retention_days ?? FORM_RETENTION_DAYS.default,
294
+ ...projectFormSettings(form),
272
295
  definition,
273
296
  };
274
297
  }
@@ -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
 
@@ -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 }),
@@ -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
  }