@taprootio/site-authoring 0.24.0 → 0.26.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 +50 -2
- package/package.json +2 -2
- package/src/analytics-campaign.json +31 -0
- package/src/analytics-link-label.json +22 -0
- package/src/analytics-source-vocabulary.json +17 -0
- package/src/capabilities.js +1 -1
- package/src/changelog.json +24 -0
- package/src/cli.js +115 -1
- package/src/constants.js +3 -0
- package/src/marketing-api.js +179 -0
- package/src/marketing-contract.js +289 -0
- package/src/marketing-workspace.js +77 -0
- package/src/redirects-contract.js +0 -3
- package/src/reference-help.js +107 -1
- package/src/session.js +1 -1
- package/src/site-check-findings.js +0 -1
- package/src/site-check-rules.json +0 -1
- package/src/site-checks.js +0 -6
- package/src/utm-link.js +214 -0
- package/src/verbs/index.js +12 -0
- package/src/verbs/link-build.js +195 -0
- package/src/verbs/marketing-pull.js +80 -0
- package/src/verbs/marketing-push.js +250 -0
- package/src/verbs/pull.js +8 -3
- package/src/verbs/whoami.js +32 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { withRefusalGuidance } from "../api.js";
|
|
4
|
+
import { VERB_MARKETING_PULL, VERB_MARKETING_PUSH } from "../constants.js";
|
|
5
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
6
|
+
import {
|
|
7
|
+
createMarketingRecord,
|
|
8
|
+
listMarketingRecords,
|
|
9
|
+
setMarketingRecordArchived,
|
|
10
|
+
updateMarketingRecord,
|
|
11
|
+
withSiteWording,
|
|
12
|
+
} from "../marketing-api.js";
|
|
13
|
+
import { MARKETING_KINDS, projectMarketingDocument } from "../marketing-contract.js";
|
|
14
|
+
import {
|
|
15
|
+
baselineEntry,
|
|
16
|
+
readWorkspaceMarketing,
|
|
17
|
+
recordedMarketing,
|
|
18
|
+
sameLabels,
|
|
19
|
+
withRecordedMarketing,
|
|
20
|
+
} from "../marketing-workspace.js";
|
|
21
|
+
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
22
|
+
import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
|
|
23
|
+
|
|
24
|
+
const MAXIMUM_REPORTED = 50;
|
|
25
|
+
|
|
26
|
+
function pullRequired(kind, key, reason) {
|
|
27
|
+
const { file } = MARKETING_KINDS[kind];
|
|
28
|
+
return new SiteAuthoringError(
|
|
29
|
+
"marketing.pull_required",
|
|
30
|
+
`${file}: ${reason} Run '${VERB_MARKETING_PULL}', reconcile '${key}', and retry. Nothing was written.`,
|
|
31
|
+
{ field: `${file}:${key}` },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a recorded baseline already says what the site's record says, including its state: a release that
|
|
37
|
+
* finished since the last write moves a key from publishing to live, and the file is rewritten with that, so the
|
|
38
|
+
* baseline `link build` reads must follow it.
|
|
39
|
+
*/
|
|
40
|
+
function currentBaseline(recorded, remote, kind) {
|
|
41
|
+
return recorded !== undefined && recorded.id === remote.id && recorded.archived === remote.archived
|
|
42
|
+
&& recorded.state === remote.state && sameLabels(kind, recorded, remote);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function changedOnTheSite(kind, key) {
|
|
46
|
+
const { file, noun } = MARKETING_KINDS[kind];
|
|
47
|
+
return new SiteAuthoringError(
|
|
48
|
+
"marketing.concurrent_modification",
|
|
49
|
+
`${file}: the ${noun} '${key}' changed on the site after this workspace read it. Run '${VERB_MARKETING_PULL}', `
|
|
50
|
+
+ "reconcile the file, and retry. Nothing was written.",
|
|
51
|
+
{ field: `${file}:${key}` },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decides what one kind needs, from the file, the site's records and the baseline the last pull recorded.
|
|
57
|
+
* Throws before anything is written.
|
|
58
|
+
*
|
|
59
|
+
* A key is public URL text and never changes or returns to use, so a changed key in the file is a new record
|
|
60
|
+
* (a create) and the old one is only archived when the push says so. A record the file leaves out is archived
|
|
61
|
+
* only under --archive, and only when this workspace pulled it: a record the workspace never saw is reported,
|
|
62
|
+
* never touched.
|
|
63
|
+
*/
|
|
64
|
+
function planKind(kind, fileRecords, remoteRecords, baseline, archive) {
|
|
65
|
+
const remoteByKey = new Map(remoteRecords.map((record) => [record.key, record]));
|
|
66
|
+
const actions = [];
|
|
67
|
+
const named = new Set();
|
|
68
|
+
for (const desired of fileRecords) {
|
|
69
|
+
named.add(desired.key);
|
|
70
|
+
const remote = remoteByKey.get(desired.key);
|
|
71
|
+
const recorded = baseline.get(desired.key);
|
|
72
|
+
if (remote === undefined) {
|
|
73
|
+
if (recorded !== undefined) {
|
|
74
|
+
throw pullRequired(kind, desired.key, "This workspace recorded it, but the site no longer has it.");
|
|
75
|
+
}
|
|
76
|
+
actions.push({ type: "create", key: desired.key, desired });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// A write whose answer was lost leaves the site holding what the file asks for while the baseline is stale
|
|
80
|
+
// (or missing, for a create). Nothing needs writing then, so the site's record is adopted as the baseline
|
|
81
|
+
// rather than sending the author to a pull that would discard the file's other edits.
|
|
82
|
+
if (!remote.archived && sameLabels(kind, desired, remote)) {
|
|
83
|
+
actions.push({ type: "unchanged", key: desired.key, desired, remote, adopt: true });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (recorded === undefined) {
|
|
87
|
+
throw pullRequired(kind, desired.key, "The site already has one with this key that this workspace never pulled.");
|
|
88
|
+
}
|
|
89
|
+
// An archived record whose labels already equal the file's had its update land before a restore whose answer
|
|
90
|
+
// was lost; the restore is all that is left, so the drift from the baseline is this push's own.
|
|
91
|
+
const ownUpdate = remote.archived && sameLabels(kind, desired, remote);
|
|
92
|
+
if (recorded.id !== remote.id || recorded.archived !== remote.archived || (!ownUpdate && !sameLabels(kind, recorded, remote))) {
|
|
93
|
+
throw changedOnTheSite(kind, desired.key);
|
|
94
|
+
}
|
|
95
|
+
const labelsChanged = !sameLabels(kind, desired, remote);
|
|
96
|
+
if (remote.archived) {
|
|
97
|
+
actions.push({ type: "restore", key: desired.key, desired, remote, labelsChanged });
|
|
98
|
+
} else {
|
|
99
|
+
actions.push({ type: labelsChanged ? "update" : "unchanged", key: desired.key, desired, remote });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const unlisted = [];
|
|
104
|
+
const unpulled = [];
|
|
105
|
+
for (const remote of remoteRecords) {
|
|
106
|
+
if (named.has(remote.key) || remote.archived) continue;
|
|
107
|
+
const recorded = baseline.get(remote.key);
|
|
108
|
+
if (recorded === undefined) {
|
|
109
|
+
unpulled.push(remote.key);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!archive) {
|
|
113
|
+
unlisted.push(remote.key);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (recorded.id !== remote.id || recorded.archived !== remote.archived || !sameLabels(kind, recorded, remote)) {
|
|
117
|
+
throw changedOnTheSite(kind, remote.key);
|
|
118
|
+
}
|
|
119
|
+
actions.push({ type: "archive", key: remote.key, remote });
|
|
120
|
+
}
|
|
121
|
+
return { actions, unlisted: unlisted.sort(), unpulled: unpulled.sort() };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `marketing push` — make the site's campaigns and placements match the workspace's files.
|
|
126
|
+
*
|
|
127
|
+
* Every file is validated and every record compared with the site before the first write, so a conflict on the
|
|
128
|
+
* third record refuses the whole push rather than landing two. Archives run first, so a push that swaps a key
|
|
129
|
+
* for a new one stays inside the site's quota. Each write advances the baseline straight away, so a failure
|
|
130
|
+
* partway leaves a workspace the next push can resume from. Creating or restoring a key queues the silent
|
|
131
|
+
* release that starts counting it; this verb waits for nothing.
|
|
132
|
+
*
|
|
133
|
+
* There is no plan/apply step. Like the forms push it models, it reads the site, refuses a stale baseline, and
|
|
134
|
+
* writes; there is no remote state to freeze that the baseline does not already fence.
|
|
135
|
+
*/
|
|
136
|
+
export async function marketingPush(invocation) {
|
|
137
|
+
const session = await openSession(invocation);
|
|
138
|
+
const { client, config, siteId, onProgress } = session;
|
|
139
|
+
warnIfExternalWritesPaused(session, VERB_MARKETING_PUSH);
|
|
140
|
+
const archive = invocation.archive === true;
|
|
141
|
+
|
|
142
|
+
const files = await readWorkspaceMarketing(config.workspaceDir);
|
|
143
|
+
if (files.size === 0) {
|
|
144
|
+
throw new SiteAuthoringError(
|
|
145
|
+
"marketing.file_missing",
|
|
146
|
+
"The workspace has no marketing/campaigns.json or marketing/placements.json. "
|
|
147
|
+
+ `Run '${VERB_MARKETING_PULL}' to write them.`,
|
|
148
|
+
{ field: "marketing" },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
onProgress(`Validated ${[...files.keys()].map((kind) => MARKETING_KINDS[kind].file).join(" and ")}.`);
|
|
152
|
+
let manifest = await readManifest(config.workspaceDir, siteId);
|
|
153
|
+
|
|
154
|
+
const plans = new Map();
|
|
155
|
+
for (const [kind, records] of files) {
|
|
156
|
+
const listing = await withRefusalGuidance(
|
|
157
|
+
onProgress,
|
|
158
|
+
"marketing push",
|
|
159
|
+
async () => await listMarketingRecords(client, siteId, kind),
|
|
160
|
+
);
|
|
161
|
+
plans.set(kind, { ...planKind(kind, records, listing.records, recordedMarketing(manifest, kind), archive), remote: listing.records });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const results = {};
|
|
165
|
+
let changedCount = 0;
|
|
166
|
+
const completedWrites = [];
|
|
167
|
+
for (const [kind, plan] of plans) {
|
|
168
|
+
const { noun } = MARKETING_KINDS[kind];
|
|
169
|
+
const baseline = recordedMarketing(manifest, kind);
|
|
170
|
+
// What the site holds for each key after the writes, so the file and the baseline are written from it.
|
|
171
|
+
const view = new Map(plan.remote.map((record) => [record.key, record]));
|
|
172
|
+
const applied = { created: [], updated: [], restored: [], archived: [], unchanged: [] };
|
|
173
|
+
|
|
174
|
+
const write = async (action, verb, work) => {
|
|
175
|
+
let label;
|
|
176
|
+
try {
|
|
177
|
+
label = await withRefusalGuidance(onProgress, "marketing push", work);
|
|
178
|
+
} catch (caught) {
|
|
179
|
+
const error = withSiteWording(caught, kind, action.key);
|
|
180
|
+
if (error instanceof SiteAuthoringError && completedWrites.length > 0) {
|
|
181
|
+
throw error.withCompletedWrites(completedWrites);
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
completedWrites.push(`${kind}:${action.key}:${verb}`);
|
|
186
|
+
view.set(action.key, label);
|
|
187
|
+
baseline.set(action.key, baselineEntry(kind, label));
|
|
188
|
+
manifest = withRecordedMarketing(manifest, kind, baseline);
|
|
189
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
190
|
+
onProgress(`${action.key}: ${verb} the ${noun}.`);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
for (const action of plan.actions.filter((candidate) => candidate.adopt && !currentBaseline(baseline.get(candidate.key), candidate.remote, kind))) {
|
|
194
|
+
baseline.set(action.key, baselineEntry(kind, action.remote));
|
|
195
|
+
manifest = withRecordedMarketing(manifest, kind, baseline);
|
|
196
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Archives first, so swapping a key for a new one stays inside the quota; creates last.
|
|
200
|
+
for (const action of plan.actions.filter((candidate) => candidate.type === "archive")) {
|
|
201
|
+
await write(action, "archived", () => setMarketingRecordArchived(client, siteId, kind, action.remote.id, true));
|
|
202
|
+
applied.archived.push(action.key);
|
|
203
|
+
}
|
|
204
|
+
for (const action of plan.actions.filter((candidate) => candidate.type === "update" || candidate.labelsChanged)) {
|
|
205
|
+
await write(action, "updated", () => updateMarketingRecord(client, siteId, kind, action.remote.id, action.desired));
|
|
206
|
+
// A restore that also changes labels is reported as a restore.
|
|
207
|
+
if (action.type === "update") applied.updated.push(action.key);
|
|
208
|
+
}
|
|
209
|
+
for (const action of plan.actions.filter((candidate) => candidate.type === "restore")) {
|
|
210
|
+
await write(action, "restored", () => setMarketingRecordArchived(client, siteId, kind, action.remote.id, false));
|
|
211
|
+
applied.restored.push(action.key);
|
|
212
|
+
}
|
|
213
|
+
for (const action of plan.actions.filter((candidate) => candidate.type === "create")) {
|
|
214
|
+
await write(action, "created", () => createMarketingRecord(client, siteId, kind, randomUUID(), action.desired));
|
|
215
|
+
applied.created.push(action.key);
|
|
216
|
+
}
|
|
217
|
+
applied.unchanged = plan.actions.filter((action) => action.type === "unchanged").map((action) => action.key);
|
|
218
|
+
|
|
219
|
+
// The file is rewritten from what the site now holds, in canonical form, so the workspace and the site
|
|
220
|
+
// agree byte for byte and the read-only state and counted_from are current.
|
|
221
|
+
const kept = plan.actions.filter((action) => action.type !== "archive").map((action) => view.get(action.key));
|
|
222
|
+
await writeWorkspaceJson(config.workspaceDir, MARKETING_KINDS[kind].file, projectMarketingDocument(kind, kept));
|
|
223
|
+
// Every list is bounded so a site with thousands of records still fits the result; `counts` says how many.
|
|
224
|
+
const lists = { ...applied, unlisted: plan.unlisted, unpulled: plan.unpulled };
|
|
225
|
+
const capped = Object.entries(lists).filter(([, keys]) => keys.length > MAXIMUM_REPORTED).map(([name]) => name);
|
|
226
|
+
results[kind] = {
|
|
227
|
+
...Object.fromEntries(Object.entries(lists).map(([name, keys]) => [name, keys.slice(0, MAXIMUM_REPORTED)])),
|
|
228
|
+
counts: Object.fromEntries(Object.entries(lists).map(([name, keys]) => [name, keys.length])),
|
|
229
|
+
...(capped.length > 0 ? { truncated: capped } : {}),
|
|
230
|
+
};
|
|
231
|
+
changedCount += applied.created.length + applied.updated.length + applied.restored.length + applied.archived.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const changed = Object.entries(results).flatMap(([kind, value]) =>
|
|
235
|
+
["created", "updated", "restored", "archived"].flatMap((name) => value[name].map((key) => `${kind}:${key}`))
|
|
236
|
+
);
|
|
237
|
+
for (const [kind, value] of Object.entries(results)) {
|
|
238
|
+
if (value.counts.unlisted > 0) {
|
|
239
|
+
onProgress(
|
|
240
|
+
`${MARKETING_KINDS[kind].file}: left ${value.unlisted.slice(0, 20).join(", ")}`
|
|
241
|
+
+ `${value.counts.unlisted > 20 ? ` and ${value.counts.unlisted - 20} more` : ""} on the site because the file `
|
|
242
|
+
+ "omits it. Push with --archive to archive a record the file leaves out.",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return successResult(VERB_MARKETING_PUSH, siteId, {
|
|
247
|
+
marketing: results,
|
|
248
|
+
written: { items: changed, count: changedCount, ...(changed.length < changedCount ? { itemsTruncated: true } : {}) },
|
|
249
|
+
});
|
|
250
|
+
}
|
package/src/verbs/pull.js
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from "../settings-catalog.js";
|
|
37
37
|
import { ApiError } from "../transport.js";
|
|
38
38
|
import { placesFormIds, readFormIndex, withFormKeys } from "../form-references.js";
|
|
39
|
+
import { MARKETING_MANIFEST_KEY } from "../marketing-workspace.js";
|
|
39
40
|
import {
|
|
40
41
|
generatedIdentity,
|
|
41
42
|
legalIdentity,
|
|
@@ -1395,10 +1396,13 @@ export async function pull(invocation) {
|
|
|
1395
1396
|
|
|
1396
1397
|
// `forms pull` owns the forms baseline, and a plain pull does not read forms.
|
|
1397
1398
|
// Carrying it forward keeps the next `forms push` fenced instead of refusing
|
|
1398
|
-
// it as never pulled.
|
|
1399
|
-
|
|
1399
|
+
// it as never pulled. The marketing baseline is carried for the same reason
|
|
1400
|
+
// (TR01301). An unreadable old manifest has nothing to carry.
|
|
1401
|
+
const previousManifest = await readManifest(config.workspaceDir, siteId, { required: false }).catch(() =>
|
|
1400
1402
|
undefined
|
|
1401
|
-
)
|
|
1403
|
+
);
|
|
1404
|
+
const formsBaseline = previousManifest?.forms;
|
|
1405
|
+
const marketingBaseline = previousManifest?.[MARKETING_MANIFEST_KEY];
|
|
1402
1406
|
const manifest = {
|
|
1403
1407
|
manifestVersion: MANIFEST_VERSION,
|
|
1404
1408
|
siteId,
|
|
@@ -1420,6 +1424,7 @@ export async function pull(invocation) {
|
|
|
1420
1424
|
),
|
|
1421
1425
|
...(presentationRevision === undefined ? {} : { presentation: presentationManifestEntry(presentationRevision) }),
|
|
1422
1426
|
...(formsBaseline === undefined ? {} : { forms: formsBaseline }),
|
|
1427
|
+
...(marketingBaseline === undefined ? {} : { [MARKETING_MANIFEST_KEY]: marketingBaseline }),
|
|
1423
1428
|
pages: manifestPages,
|
|
1424
1429
|
...(retiredGeneratedSources.length === 0 ? {} : { retiredGeneratedSources }),
|
|
1425
1430
|
};
|
package/src/verbs/whoami.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "../constants.js";
|
|
13
13
|
import { isBehindLatest } from "../cli-release.js";
|
|
14
14
|
import { credentialApiOrigin, findCredential } from "../credentials.js";
|
|
15
|
-
import { SITE_AUTHORING_CAPABILITIES } from "../capabilities.js";
|
|
15
|
+
import { CAPABILITY_CONTENT, SITE_AUTHORING_CAPABILITIES } from "../capabilities.js";
|
|
16
16
|
import { openAnonymousSession, successResult } from "../session.js";
|
|
17
17
|
import { environmentNameFor } from "../settings.js";
|
|
18
18
|
import { describeSurface } from "../surface.js";
|
|
@@ -64,6 +64,33 @@ function cliReleaseLine(lastExchange) {
|
|
|
64
64
|
: `Latest CLI release ${when}: ${lastExchange.latestCliVersion}. This CLI is ${CLI_VERSION}.`;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Whether the credential can manage the site's campaigns and placements (TR01301), from what is known locally.
|
|
69
|
+
*
|
|
70
|
+
* The Content capability carries both permissions those verbs need, so the answer is whether the last exchange
|
|
71
|
+
* held it. Like the rest of this verb it is a recording: `granted` is "what that exchange held", and `exchange`
|
|
72
|
+
* says no exchange has happened yet, when the next command that needs it will mint one.
|
|
73
|
+
*/
|
|
74
|
+
function marketingAccess(lastExchange, environmentOverride) {
|
|
75
|
+
if (environmentOverride) return { capability: CAPABILITY_CONTENT, access: "unknown" };
|
|
76
|
+
if (lastExchange === undefined) return { capability: CAPABILITY_CONTENT, access: "on_exchange" };
|
|
77
|
+
return {
|
|
78
|
+
capability: CAPABILITY_CONTENT,
|
|
79
|
+
access: lastExchange.capabilities.includes(CAPABILITY_CONTENT) ? "granted" : "not_in_last_exchange",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function marketingLine({ capability, access }) {
|
|
84
|
+
const verbs = "'marketing pull', 'marketing push' and 'link build'";
|
|
85
|
+
return access === "granted"
|
|
86
|
+
? `Campaigns and placements (${verbs}): allowed; the last exchange held ${capability}.`
|
|
87
|
+
: access === "not_in_last_exchange"
|
|
88
|
+
? `Campaigns and placements (${verbs}): the last exchange did not hold ${capability}, which the next command that needs it requests; a sign-in's ceiling includes it.`
|
|
89
|
+
: access === "on_exchange"
|
|
90
|
+
? `Campaigns and placements (${verbs}): carried by ${capability}, which the next command that needs it requests.`
|
|
91
|
+
: `Campaigns and placements (${verbs}): needs ${capability}; ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} supplies the credential, so this command cannot tell.`;
|
|
92
|
+
}
|
|
93
|
+
|
|
67
94
|
/**
|
|
68
95
|
* `taproot-site whoami` — which Taproot, which account, which site, until when.
|
|
69
96
|
*
|
|
@@ -155,6 +182,9 @@ export async function whoami(invocation) {
|
|
|
155
182
|
onProgress(cliReleaseLine(lastExchange));
|
|
156
183
|
}
|
|
157
184
|
|
|
185
|
+
const marketing = marketingAccess(lastExchange, environmentOverride);
|
|
186
|
+
onProgress(marketingLine(marketing));
|
|
187
|
+
|
|
158
188
|
return successResult(VERB_WHOAMI, config?.siteId, {
|
|
159
189
|
apiOrigin,
|
|
160
190
|
// Named as well as spelled out, so "am I on local right now" is answerable
|
|
@@ -176,6 +206,7 @@ export async function whoami(invocation) {
|
|
|
176
206
|
// Named for what each is. `capabilities` alone would conflate the ceiling
|
|
177
207
|
// with the grant, which are different numbers and differently trustworthy.
|
|
178
208
|
capabilityCeiling: SITE_AUTHORING_CAPABILITIES,
|
|
209
|
+
marketing,
|
|
179
210
|
...(environmentOverride || lastExchange === undefined ? {} : {
|
|
180
211
|
lastExchange: {
|
|
181
212
|
siteId: lastExchange.siteId,
|