@aglyn/plugins-forms 1.0.0-beta.218 → 1.0.0-beta.219

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.
Files changed (42) hide show
  1. package/package.json +14 -13
  2. package/src/index.d.ts +1 -0
  3. package/src/index.js.map +1 -1
  4. package/src/lib/components/form-design-preview.component.d.ts +1 -1
  5. package/src/lib/components/form-design-preview.component.js +1 -1
  6. package/src/lib/components/form-design-preview.component.js.map +1 -1
  7. package/src/lib/components/form-detail-card.d.ts +1 -1
  8. package/src/lib/components/form-detail-card.js +5 -3
  9. package/src/lib/components/form-detail-card.js.map +1 -1
  10. package/src/lib/components/form.js +3 -3
  11. package/src/lib/components/form.js.map +1 -1
  12. package/src/lib/components/host-forms-card.component.js +7 -5
  13. package/src/lib/components/host-forms-card.component.js.map +1 -1
  14. package/src/lib/components/use-form-promote-api.d.ts +1 -1
  15. package/src/lib/components/use-form-promote-api.js +2 -2
  16. package/src/lib/components/use-form-promote-api.js.map +1 -1
  17. package/src/lib/constants/bundle-common.d.ts +8 -0
  18. package/src/lib/constants/bundle-common.js +7 -0
  19. package/src/lib/constants/bundle-common.js.map +1 -1
  20. package/src/lib/model/forms-record-routes.d.ts +22 -0
  21. package/src/lib/model/forms-record-routes.js +53 -0
  22. package/src/lib/model/forms-record-routes.js.map +1 -0
  23. package/src/lib/plan-entitlements.d.ts +41 -0
  24. package/src/lib/plan-entitlements.js +53 -0
  25. package/src/lib/plan-entitlements.js.map +1 -0
  26. package/src/lib/plugin.js +2 -0
  27. package/src/lib/plugin.js.map +1 -1
  28. package/src/lib/server/form-promote-route.d.ts +74 -0
  29. package/src/lib/server/form-promote-route.js +239 -0
  30. package/src/lib/server/form-promote-route.js.map +1 -0
  31. package/src/lib/server/form-promotion.d.ts +82 -0
  32. package/src/lib/server/form-promotion.js +133 -0
  33. package/src/lib/server/form-promotion.js.map +1 -0
  34. package/src/lib/server/form-publish-announce.d.ts +63 -0
  35. package/src/lib/server/form-publish-announce.js +142 -0
  36. package/src/lib/server/form-publish-announce.js.map +1 -0
  37. package/src/lib/server.d.ts +1 -0
  38. package/src/lib/server.js +6 -0
  39. package/src/lib/server.js.map +1 -1
  40. package/src/lib/usage-axes.d.ts +28 -0
  41. package/src/lib/usage-axes.js +52 -0
  42. package/src/lib/usage-axes.js.map +1 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginWebApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * PROMOTING A FORM: make one version the one the site serves.
20
+ *
21
+ * ## What promotion means, and why a form cannot borrow the component answer
22
+ *
23
+ * A reusable component's parent document IS the published copy — the tenant
24
+ * reads every component's tree in one collection query on each render, which
25
+ * is what keeps composing a page to a single read — so promoting a component
26
+ * is a copy of the version's tree onto the parent plus a move of the
27
+ * `versionId` pointer. A form is stored the same way for the same reason, so
28
+ * the mechanics look identical: copy `nodes`, carry `rootId`, move the
29
+ * pointer.
30
+ *
31
+ * They are not identical, and the difference is the whole reason this is a
32
+ * server route rather than one more `updateDoc` in a card. A component is only
33
+ * ever DRAWN: the worst a bad promotion does is make a page ugly, and the page
34
+ * still renders. A form is drawn AND it is a contract — `/api/forms/submit`
35
+ * keys submissions on the id the form node carries, reads marketing consent
36
+ * out of a field the document NAMES, and creates a lead from an address it
37
+ * expects to find. Every one of those couplings is resolved by NAME at submit
38
+ * time, so a design can break them, and breaking them is silent in every
39
+ * case: the form still renders, the visitor still submits, the row still
40
+ * lands, and the thing the merchant believed they were collecting is not
41
+ * there.
42
+ *
43
+ * So promotion RUNS `checkFormContract` against the tree it is about to write,
44
+ * and a violation refuses the write with the violations in the body. The
45
+ * besigner's own Save & publish runs the same check for the same reason; this
46
+ * route exists because promotion must also be reachable from the form's page,
47
+ * where an author picks a version out of history and never opens the canvas
48
+ * at all — and a second promotion path that skipped the check would make the
49
+ * check optional.
50
+ *
51
+ * ## Why the check runs HERE and not only in the browser
52
+ *
53
+ * The rules can express "only a publisher may move `versionId`". They cannot
54
+ * express "and only if the tree you are moving it to still names its own
55
+ * fields", because that is a walk of a compressed node map. A console-side
56
+ * check is advice; this is the enforcement, and it is the reason the route
57
+ * reads the version document itself rather than accepting a tree from the
58
+ * caller. Nothing about the design crosses the wire inbound — a client that
59
+ * could send its own `nodes` could send a tree that passes the check and
60
+ * publish a different one.
61
+ *
62
+ * `POST /api/forms/promote`, served by the console's plugin API dispatcher,
63
+ * which has already refused an unverified account, a locked-down site or
64
+ * organization and a site that switched Forms off before this runs. It is the
65
+ * route `plugins.config.json` names as a form's `besignerDocuments` publish,
66
+ * so the besigner's Save & publish and the form page's version history reach
67
+ * the same check.
68
+ *
69
+ * Body: `{ hostId, formId, versionId }`.
70
+ * 422 with `{ violations }` when the contract would break; the shape is the
71
+ * pure module's own, so the console renders the same sentences the besigner
72
+ * does without either side parsing prose.
73
+ */
74
+ export declare const formPromoteHandler: PluginWebApiHandler;
@@ -0,0 +1,239 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { encodeStoredNodes, hostRoleCanPublish, isHostPluginEnabled, pluginRequestFromWeb } from "@aglyn/aglyn/server";
18
+ import { emailUnverifiedResponse, firebaseAdmin, getOrgForHost, isImpersonationSession, logHostActivity } from "@aglyn/tenant-data-admin";
19
+ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
20
+ import { Timestamp } from "firebase-admin/firestore";
21
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
22
+ import { isFormPromotionRefusal, resolveFormPromotion } from "./form-promotion.js";
23
+ import { announceFormPublish } from "./form-publish-announce.js";
24
+ /**
25
+ * PROMOTING A FORM: make one version the one the site serves.
26
+ *
27
+ * ## What promotion means, and why a form cannot borrow the component answer
28
+ *
29
+ * A reusable component's parent document IS the published copy — the tenant
30
+ * reads every component's tree in one collection query on each render, which
31
+ * is what keeps composing a page to a single read — so promoting a component
32
+ * is a copy of the version's tree onto the parent plus a move of the
33
+ * `versionId` pointer. A form is stored the same way for the same reason, so
34
+ * the mechanics look identical: copy `nodes`, carry `rootId`, move the
35
+ * pointer.
36
+ *
37
+ * They are not identical, and the difference is the whole reason this is a
38
+ * server route rather than one more `updateDoc` in a card. A component is only
39
+ * ever DRAWN: the worst a bad promotion does is make a page ugly, and the page
40
+ * still renders. A form is drawn AND it is a contract — `/api/forms/submit`
41
+ * keys submissions on the id the form node carries, reads marketing consent
42
+ * out of a field the document NAMES, and creates a lead from an address it
43
+ * expects to find. Every one of those couplings is resolved by NAME at submit
44
+ * time, so a design can break them, and breaking them is silent in every
45
+ * case: the form still renders, the visitor still submits, the row still
46
+ * lands, and the thing the merchant believed they were collecting is not
47
+ * there.
48
+ *
49
+ * So promotion RUNS `checkFormContract` against the tree it is about to write,
50
+ * and a violation refuses the write with the violations in the body. The
51
+ * besigner's own Save & publish runs the same check for the same reason; this
52
+ * route exists because promotion must also be reachable from the form's page,
53
+ * where an author picks a version out of history and never opens the canvas
54
+ * at all — and a second promotion path that skipped the check would make the
55
+ * check optional.
56
+ *
57
+ * ## Why the check runs HERE and not only in the browser
58
+ *
59
+ * The rules can express "only a publisher may move `versionId`". They cannot
60
+ * express "and only if the tree you are moving it to still names its own
61
+ * fields", because that is a walk of a compressed node map. A console-side
62
+ * check is advice; this is the enforcement, and it is the reason the route
63
+ * reads the version document itself rather than accepting a tree from the
64
+ * caller. Nothing about the design crosses the wire inbound — a client that
65
+ * could send its own `nodes` could send a tree that passes the check and
66
+ * publish a different one.
67
+ *
68
+ * `POST /api/forms/promote`, served by the console's plugin API dispatcher,
69
+ * which has already refused an unverified account, a locked-down site or
70
+ * organization and a site that switched Forms off before this runs. It is the
71
+ * route `plugins.config.json` names as a form's `besignerDocuments` publish,
72
+ * so the besigner's Save & publish and the form page's version history reach
73
+ * the same check.
74
+ *
75
+ * Body: `{ hostId, formId, versionId }`.
76
+ * 422 with `{ violations }` when the contract would break; the shape is the
77
+ * pure module's own, so the console renders the same sentences the besigner
78
+ * does without either side parsing prose.
79
+ */ export const formPromoteHandler = async (request)=>{
80
+ var _headers_authorization, _ref, _ref1, _ref2;
81
+ const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request);
82
+ const headers = rawHeaders;
83
+ if (method !== 'POST') {
84
+ return Response.json({
85
+ error: 'Method not allowed'
86
+ }, {
87
+ status: 405
88
+ });
89
+ }
90
+ const authorization = (_headers_authorization = headers.authorization) != null ? _headers_authorization : '';
91
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
92
+ if (!idToken) return Response.json({
93
+ error: 'Unauthenticated'
94
+ }, {
95
+ status: 401
96
+ });
97
+ const hostId = String((_ref = body == null ? void 0 : body.hostId) != null ? _ref : '');
98
+ const formId = String((_ref1 = body == null ? void 0 : body.formId) != null ? _ref1 : '');
99
+ const versionId = String((_ref2 = body == null ? void 0 : body.versionId) != null ? _ref2 : '');
100
+ if (!hostId || !formId || !versionId) {
101
+ return Response.json({
102
+ error: 'Missing hostId, formId or versionId'
103
+ }, {
104
+ status: 400
105
+ });
106
+ }
107
+ try {
108
+ var _hostSnapshot_get, _ref3;
109
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
110
+ if (!decoded.email_verified && !isImpersonationSession(decoded)) {
111
+ return emailUnverifiedResponse();
112
+ }
113
+ const firestore = firebaseAdmin.app().firestore();
114
+ const hostRef = firestore.collection('hosts').doc(hostId);
115
+ const hostSnapshot = await hostRef.get();
116
+ if (!hostSnapshot.exists) {
117
+ return Response.json({
118
+ error: 'Unknown site'
119
+ }, {
120
+ status: 404
121
+ });
122
+ }
123
+ // PUBLISH, not write. The `author` role edits content and may not make it
124
+ // live — the same line the rules draw at `canPublishHostContent`, and the
125
+ // reason this is `hostRoleCanPublish` rather than the `hostRoleCanWrite`
126
+ // the version-create route next door uses: creating a draft version
127
+ // changes nothing a visitor sees, and moving the pointer is the act that
128
+ // does.
129
+ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
130
+ if (!hostRoleCanPublish(memberRole)) {
131
+ return Response.json({
132
+ error: 'Publishing requires the editor or admin role'
133
+ }, {
134
+ status: 403
135
+ });
136
+ }
137
+ const ownerOrg = await getOrgForHost(hostId);
138
+ const org = (_ref3 = ownerOrg == null ? void 0 : ownerOrg.org) != null ? _ref3 : {};
139
+ const formRef = hostRef.collection('forms').doc(formId);
140
+ const [formSnapshot, versionSnapshot] = await Promise.all([
141
+ formRef.get(),
142
+ formRef.collection('versions').doc(versionId).get()
143
+ ]);
144
+ if (!formSnapshot.exists) {
145
+ return Response.json({
146
+ error: 'Unknown form'
147
+ }, {
148
+ status: 404
149
+ });
150
+ }
151
+ if (!versionSnapshot.exists) {
152
+ return Response.json({
153
+ error: 'Unknown version'
154
+ }, {
155
+ status: 404
156
+ });
157
+ }
158
+ /*
159
+ * THE CHECK, BEFORE THE WRITE — and it is the same decision the besigner's
160
+ * publish makes, in one shared module, so the two promotion paths cannot
161
+ * disagree about which designs may ship.
162
+ */ const resolved = resolveFormPromotion({
163
+ formId,
164
+ form: formSnapshot.data(),
165
+ storedNodes: versionSnapshot.get('nodes'),
166
+ // The site's own plugin set (AGL-3029): a site that switched Forms off
167
+ // does not draw a form or accept its submissions, so none goes live.
168
+ formsOnForSite: isHostPluginEnabled(org, hostSnapshot.data(), BUNDLE_ID)
169
+ });
170
+ if (isFormPromotionRefusal(resolved)) {
171
+ return Response.json(resolved.body, {
172
+ status: resolved.status
173
+ });
174
+ }
175
+ await formRef.update(_extends({
176
+ // Compressed at rest (AGL-1151). `resolveFormPromotion` hands back the
177
+ // DECODED tree because the contract check has to walk it; the storage
178
+ // form is decided here, at the write, and matches what the besigner's
179
+ // own publish writes to this same document.
180
+ nodes: Buffer.from(encodeStoredNodes(resolved.nodes))
181
+ }, resolved.rootId ? {
182
+ rootId: resolved.rootId
183
+ } : {}, {
184
+ fields: resolved.fields,
185
+ versionId,
186
+ updatedAt: Timestamp.now()
187
+ }));
188
+ /*
189
+ * The pages that place this form are now serving fields it no longer has.
190
+ *
191
+ * Fired without awaiting, exactly as the component publish path does: the
192
+ * write above already succeeded, the scan reads every screen, layout and
193
+ * component on the site, and nobody is watching one URL for a form publish
194
+ * the way they watch a page after publishing it. `announceFormPublish`
195
+ * absorbs its own failures — the render cache's TTL is the backstop.
196
+ */ void announceFormPublish({
197
+ firestore,
198
+ hostId,
199
+ formId
200
+ });
201
+ // A form files under `content`: `HostActivityTarget['type']` is a
202
+ // PERSISTED value `activity-presenter.ts` branches on, and a member no
203
+ // presenter knows renders as an unlinked row.
204
+ await logHostActivity(hostId, {
205
+ uid: decoded.uid,
206
+ email: decoded.email ? String(decoded.email) : null
207
+ }, 'Published a version of the form', _extends({
208
+ type: 'content',
209
+ id: formId,
210
+ versionId
211
+ }, formSnapshot.get('displayName') ? {
212
+ name: String(formSnapshot.get('displayName'))
213
+ } : {}));
214
+ return Response.json({
215
+ ok: true,
216
+ versionId
217
+ }, {
218
+ status: 200
219
+ });
220
+ } catch (error) {
221
+ // A refused credential is a 401, not a fault of ours (AGL-1993); anything
222
+ // else keeps the answer below.
223
+ if (isRefusedIdToken(error)) {
224
+ return Response.json({
225
+ error: 'Unauthenticated'
226
+ }, {
227
+ status: 401
228
+ });
229
+ }
230
+ console.error('[forms] publish failed', error);
231
+ return Response.json({
232
+ error: 'Publish failed'
233
+ }, {
234
+ status: 500
235
+ });
236
+ }
237
+ };
238
+
239
+ //# sourceMappingURL=form-promote-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/server/form-promote-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n encodeStoredNodes,\n hostRoleCanPublish,\n isHostPluginEnabled,\n pluginRequestFromWeb,\n type PluginWebApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n getOrgForHost,\n isImpersonationSession,\n logHostActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { isFormPromotionRefusal, resolveFormPromotion } from './form-promotion'\nimport { announceFormPublish } from './form-publish-announce'\n\n/**\n * PROMOTING A FORM: make one version the one the site serves.\n *\n * ## What promotion means, and why a form cannot borrow the component answer\n *\n * A reusable component's parent document IS the published copy — the tenant\n * reads every component's tree in one collection query on each render, which\n * is what keeps composing a page to a single read — so promoting a component\n * is a copy of the version's tree onto the parent plus a move of the\n * `versionId` pointer. A form is stored the same way for the same reason, so\n * the mechanics look identical: copy `nodes`, carry `rootId`, move the\n * pointer.\n *\n * They are not identical, and the difference is the whole reason this is a\n * server route rather than one more `updateDoc` in a card. A component is only\n * ever DRAWN: the worst a bad promotion does is make a page ugly, and the page\n * still renders. A form is drawn AND it is a contract — `/api/forms/submit`\n * keys submissions on the id the form node carries, reads marketing consent\n * out of a field the document NAMES, and creates a lead from an address it\n * expects to find. Every one of those couplings is resolved by NAME at submit\n * time, so a design can break them, and breaking them is silent in every\n * case: the form still renders, the visitor still submits, the row still\n * lands, and the thing the merchant believed they were collecting is not\n * there.\n *\n * So promotion RUNS `checkFormContract` against the tree it is about to write,\n * and a violation refuses the write with the violations in the body. The\n * besigner's own Save & publish runs the same check for the same reason; this\n * route exists because promotion must also be reachable from the form's page,\n * where an author picks a version out of history and never opens the canvas\n * at all — and a second promotion path that skipped the check would make the\n * check optional.\n *\n * ## Why the check runs HERE and not only in the browser\n *\n * The rules can express \"only a publisher may move `versionId`\". They cannot\n * express \"and only if the tree you are moving it to still names its own\n * fields\", because that is a walk of a compressed node map. A console-side\n * check is advice; this is the enforcement, and it is the reason the route\n * reads the version document itself rather than accepting a tree from the\n * caller. Nothing about the design crosses the wire inbound — a client that\n * could send its own `nodes` could send a tree that passes the check and\n * publish a different one.\n *\n * `POST /api/forms/promote`, served by the console's plugin API dispatcher,\n * which has already refused an unverified account, a locked-down site or\n * organization and a site that switched Forms off before this runs. It is the\n * route `plugins.config.json` names as a form's `besignerDocuments` publish,\n * so the besigner's Save & publish and the form page's version history reach\n * the same check.\n *\n * Body: `{ hostId, formId, versionId }`.\n * 422 with `{ violations }` when the contract would break; the shape is the\n * pure module's own, so the console renders the same sentences the besigner\n * does without either side parsing prose.\n */\nexport const formPromoteHandler: PluginWebApiHandler = async (request) => {\n const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n const hostId = String(body?.hostId ?? '')\n const formId = String(body?.formId ?? '')\n const versionId = String(body?.versionId ?? '')\n if (!hostId || !formId || !versionId) {\n return Response.json(\n { error: 'Missing hostId, formId or versionId' },\n { status: 400 },\n )\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return Response.json({ error: 'Unknown site' }, { status: 404 })\n }\n // PUBLISH, not write. The `author` role edits content and may not make it\n // live — the same line the rules draw at `canPublishHostContent`, and the\n // reason this is `hostRoleCanPublish` rather than the `hostRoleCanWrite`\n // the version-create route next door uses: creating a draft version\n // changes nothing a visitor sees, and moving the pointer is the act that\n // does.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (!hostRoleCanPublish(memberRole)) {\n return Response.json(\n { error: 'Publishing requires the editor or admin role' },\n { status: 403 },\n )\n }\n\n const ownerOrg = await getOrgForHost(hostId)\n const org = (ownerOrg?.org ?? {}) as { enabledPlugins?: string[] }\n\n const formRef = hostRef.collection('forms').doc(formId)\n const [formSnapshot, versionSnapshot] = await Promise.all([\n formRef.get(),\n formRef.collection('versions').doc(versionId).get(),\n ])\n if (!formSnapshot.exists) {\n return Response.json({ error: 'Unknown form' }, { status: 404 })\n }\n if (!versionSnapshot.exists) {\n return Response.json({ error: 'Unknown version' }, { status: 404 })\n }\n\n /*\n * THE CHECK, BEFORE THE WRITE — and it is the same decision the besigner's\n * publish makes, in one shared module, so the two promotion paths cannot\n * disagree about which designs may ship.\n */\n const resolved = resolveFormPromotion({\n formId,\n form: formSnapshot.data() as Record<string, unknown>,\n storedNodes: versionSnapshot.get('nodes'),\n // The site's own plugin set (AGL-3029): a site that switched Forms off\n // does not draw a form or accept its submissions, so none goes live.\n formsOnForSite: isHostPluginEnabled(org, hostSnapshot.data(), BUNDLE_ID),\n })\n if (isFormPromotionRefusal(resolved)) {\n return Response.json(resolved.body, { status: resolved.status })\n }\n\n await formRef.update({\n // Compressed at rest (AGL-1151). `resolveFormPromotion` hands back the\n // DECODED tree because the contract check has to walk it; the storage\n // form is decided here, at the write, and matches what the besigner's\n // own publish writes to this same document.\n nodes: Buffer.from(encodeStoredNodes(resolved.nodes)!),\n ...(resolved.rootId ? { rootId: resolved.rootId } : {}),\n fields: resolved.fields,\n versionId,\n updatedAt: Timestamp.now(),\n })\n /*\n * The pages that place this form are now serving fields it no longer has.\n *\n * Fired without awaiting, exactly as the component publish path does: the\n * write above already succeeded, the scan reads every screen, layout and\n * component on the site, and nobody is watching one URL for a form publish\n * the way they watch a page after publishing it. `announceFormPublish`\n * absorbs its own failures — the render cache's TTL is the backstop.\n */\n void announceFormPublish({ firestore, hostId, formId })\n // A form files under `content`: `HostActivityTarget['type']` is a\n // PERSISTED value `activity-presenter.ts` branches on, and a member no\n // presenter knows renders as an unlinked row.\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ? String(decoded.email) : null },\n 'Published a version of the form',\n {\n type: 'content',\n id: formId,\n versionId,\n ...(formSnapshot.get('displayName')\n ? { name: String(formSnapshot.get('displayName')) }\n : {}),\n },\n )\n return Response.json({ ok: true, versionId }, { status: 200 })\n } catch (error) {\n // A refused credential is a 401, not a fault of ours (AGL-1993); anything\n // else keeps the answer below.\n if (isRefusedIdToken(error)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n console.error('[forms] publish failed', error)\n return Response.json({ error: 'Publish failed' }, { status: 500 })\n }\n}\n"],"names":["encodeStoredNodes","hostRoleCanPublish","isHostPluginEnabled","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","getOrgForHost","isImpersonationSession","logHostActivity","isRefusedIdToken","Timestamp","BUNDLE_ID","isFormPromotionRefusal","resolveFormPromotion","announceFormPublish","formPromoteHandler","request","headers","method","body","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","hostId","String","formId","versionId","hostSnapshot","decoded","app","auth","verifyIdToken","email_verified","firestore","hostRef","collection","doc","get","exists","memberRole","uid","ownerOrg","org","formRef","formSnapshot","versionSnapshot","Promise","all","resolved","form","data","storedNodes","formsOnForSite","update","nodes","Buffer","from","rootId","fields","updatedAt","now","email","type","id","name","ok","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,iBAAiB,EACjBC,kBAAkB,EAClBC,mBAAmB,EACnBC,oBAAoB,QAEf,sBAAqB;AAC5B,SACEC,uBAAuB,EACvBC,aAAa,EACbC,aAAa,EACbC,sBAAsB,EACtBC,eAAe,QACV,2BAA0B;AACjC,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SAASC,SAAS,QAAQ,2BAA0B;AACpD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SAASC,sBAAsB,EAAEC,oBAAoB,QAAQ,sBAAkB;AAC/E,SAASC,mBAAmB,QAAQ,6BAAyB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuDC,GACD,OAAO,MAAMC,qBAA0C,OAAOC;QAMtCC;IALtB,MAAM,EAAEC,MAAM,EAAEC,IAAI,EAAEF,SAASG,UAAU,EAAE,GAAG,MAAMjB,qBAAqBa;IACzE,MAAMC,UAAUG;IAChB,IAAIF,WAAW,QAAQ;QACrB,OAAOG,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAqB,GAAG;YAAEC,QAAQ;QAAI;IACtE;IACA,MAAMC,iBAAgBR,yBAAAA,QAAQQ,aAAa,YAArBR,yBAAyB;IAC/C,MAAMS,UAAUD,cAAcE,UAAU,CAAC,aACrCF,cAAcG,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS,OAAOL,SAASC,IAAI,CAAC;QAAEC,OAAO;IAAkB,GAAG;QAAEC,QAAQ;IAAI;IAE/E,MAAMO,SAASC,eAAOb,wBAAAA,KAAMY,MAAM,mBAAI;IACtC,MAAME,SAASD,gBAAOb,wBAAAA,KAAMc,MAAM,oBAAI;IACtC,MAAMC,YAAYF,gBAAOb,wBAAAA,KAAMe,SAAS,oBAAI;IAC5C,IAAI,CAACH,UAAU,CAACE,UAAU,CAACC,WAAW;QACpC,OAAOb,SAASC,IAAI,CAClB;YAAEC,OAAO;QAAsC,GAC/C;YAAEC,QAAQ;QAAI;IAElB;IAEA,IAAI;YAiBkBW;QAhBpB,MAAMC,UAAU,MAAM/B,cAAcgC,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACb;QAC/D,IAAI,CAACU,QAAQI,cAAc,IAAI,CAACjC,uBAAuB6B,UAAU;YAC/D,OAAOhC;QACT;QACA,MAAMqC,YAAYpC,cAAcgC,GAAG,GAAGI,SAAS;QAC/C,MAAMC,UAAUD,UAAUE,UAAU,CAAC,SAASC,GAAG,CAACb;QAClD,MAAMI,eAAe,MAAMO,QAAQG,GAAG;QACtC,IAAI,CAACV,aAAaW,MAAM,EAAE;YACxB,OAAOzB,SAASC,IAAI,CAAC;gBAAEC,OAAO;YAAe,GAAG;gBAAEC,QAAQ;YAAI;QAChE;QACA,0EAA0E;QAC1E,0EAA0E;QAC1E,yEAAyE;QACzE,oEAAoE;QACpE,yEAAyE;QACzE,QAAQ;QACR,MAAMuB,aAAa,EAACZ,oBAAAA,aAAaU,GAAG,CAAC,0BAAjBV,oBAAmC,CAAC,EAAE,CAACC,QAAQY,GAAG,CAAC;QACvE,IAAI,CAAC/C,mBAAmB8C,aAAa;YACnC,OAAO1B,SAASC,IAAI,CAClB;gBAAEC,OAAO;YAA+C,GACxD;gBAAEC,QAAQ;YAAI;QAElB;QAEA,MAAMyB,WAAW,MAAM3C,cAAcyB;QACrC,MAAMmB,eAAOD,4BAAAA,SAAUC,GAAG,oBAAI,CAAC;QAE/B,MAAMC,UAAUT,QAAQC,UAAU,CAAC,SAASC,GAAG,CAACX;QAChD,MAAM,CAACmB,cAAcC,gBAAgB,GAAG,MAAMC,QAAQC,GAAG,CAAC;YACxDJ,QAAQN,GAAG;YACXM,QAAQR,UAAU,CAAC,YAAYC,GAAG,CAACV,WAAWW,GAAG;SAClD;QACD,IAAI,CAACO,aAAaN,MAAM,EAAE;YACxB,OAAOzB,SAASC,IAAI,CAAC;gBAAEC,OAAO;YAAe,GAAG;gBAAEC,QAAQ;YAAI;QAChE;QACA,IAAI,CAAC6B,gBAAgBP,MAAM,EAAE;YAC3B,OAAOzB,SAASC,IAAI,CAAC;gBAAEC,OAAO;YAAkB,GAAG;gBAAEC,QAAQ;YAAI;QACnE;QAEA;;;;KAIC,GACD,MAAMgC,WAAW3C,qBAAqB;YACpCoB;YACAwB,MAAML,aAAaM,IAAI;YACvBC,aAAaN,gBAAgBR,GAAG,CAAC;YACjC,uEAAuE;YACvE,qEAAqE;YACrEe,gBAAgB1D,oBAAoBgD,KAAKf,aAAauB,IAAI,IAAI/C;QAChE;QACA,IAAIC,uBAAuB4C,WAAW;YACpC,OAAOnC,SAASC,IAAI,CAACkC,SAASrC,IAAI,EAAE;gBAAEK,QAAQgC,SAAShC,MAAM;YAAC;QAChE;QAEA,MAAM2B,QAAQU,MAAM,CAAC;YACnB,uEAAuE;YACvE,sEAAsE;YACtE,sEAAsE;YACtE,4CAA4C;YAC5CC,OAAOC,OAAOC,IAAI,CAAChE,kBAAkBwD,SAASM,KAAK;WAC/CN,SAASS,MAAM,GAAG;YAAEA,QAAQT,SAASS,MAAM;QAAC,IAAI,CAAC;YACrDC,QAAQV,SAASU,MAAM;YACvBhC;YACAiC,WAAWzD,UAAU0D,GAAG;;QAE1B;;;;;;;;KAQC,GACD,KAAKtD,oBAAoB;YAAE2B;YAAWV;YAAQE;QAAO;QACrD,kEAAkE;QAClE,uEAAuE;QACvE,8CAA8C;QAC9C,MAAMzB,gBACJuB,QACA;YAAEiB,KAAKZ,QAAQY,GAAG;YAAEqB,OAAOjC,QAAQiC,KAAK,GAAGrC,OAAOI,QAAQiC,KAAK,IAAI;QAAK,GACxE,mCACA;YACEC,MAAM;YACNC,IAAItC;YACJC;WACIkB,aAAaP,GAAG,CAAC,iBACjB;YAAE2B,MAAMxC,OAAOoB,aAAaP,GAAG,CAAC;QAAgB,IAChD,CAAC;QAGT,OAAOxB,SAASC,IAAI,CAAC;YAAEmD,IAAI;YAAMvC;QAAU,GAAG;YAAEV,QAAQ;QAAI;IAC9D,EAAE,OAAOD,OAAO;QACd,0EAA0E;QAC1E,+BAA+B;QAC/B,IAAId,iBAAiBc,QAAQ;YAC3B,OAAOF,SAASC,IAAI,CAAC;gBAAEC,OAAO;YAAkB,GAAG;gBAAEC,QAAQ;YAAI;QACnE;QACAkD,QAAQnD,KAAK,CAAC,0BAA0BA;QACxC,OAAOF,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAiB,GAAG;YAAEC,QAAQ;QAAI;IAClE;AACF,EAAC"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type FormContractViolation, type FormFieldDecl } from '@aglyn/aglyn/server';
18
+ /** A promotion that must not happen, and what to tell the author. */
19
+ export interface FormPromotionRefusal {
20
+ status: number;
21
+ body: {
22
+ error: string;
23
+ /**
24
+ * The contract violations, when that is what refused it.
25
+ *
26
+ * The pure module's own shape, so a caller renders the same sentences the
27
+ * besigner does without either side parsing prose. Absent on the two
28
+ * refusals that are about the CANVAS rather than the contract.
29
+ */
30
+ violations?: FormContractViolation[];
31
+ };
32
+ }
33
+ /** Exactly the fields a promotion writes onto the form document. */
34
+ export interface FormPromotionWrite {
35
+ rootId?: string;
36
+ nodes: Record<string, any>;
37
+ fields: FormFieldDecl[];
38
+ }
39
+ /** Whether this answer is the refusal. */
40
+ export declare function isFormPromotionRefusal(result: FormPromotionWrite | FormPromotionRefusal): result is FormPromotionRefusal;
41
+ /**
42
+ * WHAT A PROMOTION WOULD WRITE, OR WHY IT MUST NOT HAPPEN.
43
+ *
44
+ * Promoting a form is not "copy the drawing onto the parent". A reusable
45
+ * component's promotion is exactly that, because a component is only ever
46
+ * DRAWN — the worst a bad one does is make a page ugly, and the page still
47
+ * renders. A form is drawn AND it is a contract: `/api/forms/submit` keys
48
+ * submissions on the id the form node carries, reads marketing consent out of
49
+ * a field the document NAMES, and creates a lead from an address it expects to
50
+ * find. Every one of those couplings is resolved by NAME at submit time, so
51
+ * the design can break them, and every break is silent — the form renders, the
52
+ * visitor submits, the row lands, and the thing the merchant believed they
53
+ * were collecting is not there.
54
+ *
55
+ * So this decides, before any write exists to undo, and it decides in one
56
+ * place because there are two ways to reach a promotion: the besigner's Save &
57
+ * publish, and the version history on the form's own page. A second answer
58
+ * would be a second chance to promote a design the check never saw.
59
+ *
60
+ * Pure, and separate from the route, so the refusal can be exercised on real
61
+ * node maps rather than through a mocked Admin SDK — the property under test
62
+ * is which trees it says no to, not how a 422 is serialized.
63
+ *
64
+ * @param options.form the STORED form document. `routing` and
65
+ * `consentFieldName` are what the check reads; the
66
+ * stored `fields` are the previous design's output, not
67
+ * a contract the new one owes anything to — except the
68
+ * contact-field mappings drawn on them, which are
69
+ * carried across by field name (AGL-2601).
70
+ * @param options.storedNodes the version document's `nodes` in any stored
71
+ * form — a besigner save writes them compressed.
72
+ * @param options.formsOnForSite whether Forms runs on the site (AGL-3029):
73
+ * `false` refuses the promotion with the contract's own
74
+ * sentence, whatever the design.
75
+ */
76
+ export declare function resolveFormPromotion(options: {
77
+ formId: string;
78
+ form: Record<string, unknown> | null | undefined;
79
+ storedNodes: unknown;
80
+ formsOnForSite?: boolean;
81
+ }): FormPromotionWrite | FormPromotionRefusal;
82
+ export default resolveFormPromotion;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { canvasTreeToDefinition, carryContactFieldMappings, checkFormContract, decodeStoredNodes, formContractIsSatisfied, formFieldDeclsFromNodes, FORMS_OFF_FOR_SITE_VIOLATION } from "@aglyn/aglyn/server";
17
+ /** The canvas id a form's own node carries. */ const FORM_COMPONENT_ID = 'form';
18
+ /** Whether this answer is the refusal. */ export function isFormPromotionRefusal(result) {
19
+ return result.status !== undefined;
20
+ }
21
+ /**
22
+ * WHAT A PROMOTION WOULD WRITE, OR WHY IT MUST NOT HAPPEN.
23
+ *
24
+ * Promoting a form is not "copy the drawing onto the parent". A reusable
25
+ * component's promotion is exactly that, because a component is only ever
26
+ * DRAWN — the worst a bad one does is make a page ugly, and the page still
27
+ * renders. A form is drawn AND it is a contract: `/api/forms/submit` keys
28
+ * submissions on the id the form node carries, reads marketing consent out of
29
+ * a field the document NAMES, and creates a lead from an address it expects to
30
+ * find. Every one of those couplings is resolved by NAME at submit time, so
31
+ * the design can break them, and every break is silent — the form renders, the
32
+ * visitor submits, the row lands, and the thing the merchant believed they
33
+ * were collecting is not there.
34
+ *
35
+ * So this decides, before any write exists to undo, and it decides in one
36
+ * place because there are two ways to reach a promotion: the besigner's Save &
37
+ * publish, and the version history on the form's own page. A second answer
38
+ * would be a second chance to promote a design the check never saw.
39
+ *
40
+ * Pure, and separate from the route, so the refusal can be exercised on real
41
+ * node maps rather than through a mocked Admin SDK — the property under test
42
+ * is which trees it says no to, not how a 422 is serialized.
43
+ *
44
+ * @param options.form the STORED form document. `routing` and
45
+ * `consentFieldName` are what the check reads; the
46
+ * stored `fields` are the previous design's output, not
47
+ * a contract the new one owes anything to — except the
48
+ * contact-field mappings drawn on them, which are
49
+ * carried across by field name (AGL-2601).
50
+ * @param options.storedNodes the version document's `nodes` in any stored
51
+ * form — a besigner save writes them compressed.
52
+ * @param options.formsOnForSite whether Forms runs on the site (AGL-3029):
53
+ * `false` refuses the promotion with the contract's own
54
+ * sentence, whatever the design.
55
+ */ export function resolveFormPromotion(options) {
56
+ const { formId, form, storedNodes } = options;
57
+ if (options.formsOnForSite === false) {
58
+ return {
59
+ status: 422,
60
+ body: {
61
+ error: FORMS_OFF_FOR_SITE_VIOLATION.message,
62
+ violations: [
63
+ FORMS_OFF_FOR_SITE_VIOLATION
64
+ ]
65
+ }
66
+ };
67
+ }
68
+ const decoded = decodeStoredNodes(storedNodes);
69
+ if (!decoded || !Object.keys(decoded).length) {
70
+ return {
71
+ status: 422,
72
+ body: {
73
+ error: 'That version holds no design. Open it in the besigner and save ' + 'once before publishing it.'
74
+ }
75
+ };
76
+ }
77
+ // Unwrap the synthetic canvas root: a placed form grafts from `rootId`, so
78
+ // publishing the wrapper would put an always-empty container around every
79
+ // instance (AGL-680).
80
+ const definition = canvasTreeToDefinition(decoded);
81
+ if (definition.ambiguousRoot) {
82
+ return {
83
+ status: 422,
84
+ body: {
85
+ error: 'A form needs a single top-level element. Wrap what you have in ' + 'one container, save, then publish.'
86
+ }
87
+ };
88
+ }
89
+ const nodes = definition.nodes;
90
+ const formNodeId = Object.keys(nodes).find((nodeId)=>{
91
+ var _nodes_nodeId;
92
+ return ((_nodes_nodeId = nodes[nodeId]) == null ? void 0 : _nodes_nodeId.componentId) === FORM_COMPONENT_ID;
93
+ });
94
+ const violations = checkFormContract({
95
+ form: form,
96
+ formId,
97
+ nodes: nodes,
98
+ formNodeId: formNodeId
99
+ });
100
+ if (!formContractIsSatisfied(violations)) {
101
+ return {
102
+ status: 422,
103
+ body: {
104
+ // The headline names the CONSEQUENCE; the violations name what to fix.
105
+ error: 'This version would stop the submissions arriving',
106
+ violations
107
+ }
108
+ };
109
+ }
110
+ return {
111
+ rootId: definition.rootId,
112
+ nodes,
113
+ /*
114
+ * The DECLARATION publishes with the DESIGN.
115
+ *
116
+ * `fields` is what the submit route validates against and what the form's
117
+ * consent-field picker offers; `nodes` is what the author drew. Writing
118
+ * one without the other is how they drift, and the drift is exactly what
119
+ * the check above just refused to ship — so the two go out in a single
120
+ * write, derived from the same tree.
121
+ *
122
+ * `checkFormContract` reports `form-node-missing` when there is no form
123
+ * node, so reaching here means this id resolved.
124
+ *
125
+ * Where each field SAVES TO is not drawn on the canvas — it is edited on
126
+ * the form's page — so it is carried from the stored declaration by
127
+ * field name, or every publish would unmap every field (AGL-2601).
128
+ */ fields: carryContactFieldMappings(form == null ? void 0 : form['fields'], formFieldDeclsFromNodes(nodes, formNodeId))
129
+ };
130
+ }
131
+ export default resolveFormPromotion;
132
+
133
+ //# sourceMappingURL=form-promotion.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/server/form-promotion.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n canvasTreeToDefinition,\n carryContactFieldMappings,\n checkFormContract,\n decodeStoredNodes,\n formContractIsSatisfied,\n formFieldDeclsFromNodes,\n FORMS_OFF_FOR_SITE_VIOLATION,\n type FormContractViolation,\n type FormFieldDecl,\n type NodeId,\n} from '@aglyn/aglyn/server'\n\n/** The canvas id a form's own node carries. */\nconst FORM_COMPONENT_ID = 'form'\n\n/** A promotion that must not happen, and what to tell the author. */\nexport interface FormPromotionRefusal {\n status: number\n body: {\n error: string\n /**\n * The contract violations, when that is what refused it.\n *\n * The pure module's own shape, so a caller renders the same sentences the\n * besigner does without either side parsing prose. Absent on the two\n * refusals that are about the CANVAS rather than the contract.\n */\n violations?: FormContractViolation[]\n }\n}\n\n/** Exactly the fields a promotion writes onto the form document. */\nexport interface FormPromotionWrite {\n rootId?: string\n nodes: Record<string, any>\n fields: FormFieldDecl[]\n}\n\n/** Whether this answer is the refusal. */\nexport function isFormPromotionRefusal(\n result: FormPromotionWrite | FormPromotionRefusal,\n): result is FormPromotionRefusal {\n return (result as FormPromotionRefusal).status !== undefined\n}\n\n/**\n * WHAT A PROMOTION WOULD WRITE, OR WHY IT MUST NOT HAPPEN.\n *\n * Promoting a form is not \"copy the drawing onto the parent\". A reusable\n * component's promotion is exactly that, because a component is only ever\n * DRAWN — the worst a bad one does is make a page ugly, and the page still\n * renders. A form is drawn AND it is a contract: `/api/forms/submit` keys\n * submissions on the id the form node carries, reads marketing consent out of\n * a field the document NAMES, and creates a lead from an address it expects to\n * find. Every one of those couplings is resolved by NAME at submit time, so\n * the design can break them, and every break is silent — the form renders, the\n * visitor submits, the row lands, and the thing the merchant believed they\n * were collecting is not there.\n *\n * So this decides, before any write exists to undo, and it decides in one\n * place because there are two ways to reach a promotion: the besigner's Save &\n * publish, and the version history on the form's own page. A second answer\n * would be a second chance to promote a design the check never saw.\n *\n * Pure, and separate from the route, so the refusal can be exercised on real\n * node maps rather than through a mocked Admin SDK — the property under test\n * is which trees it says no to, not how a 422 is serialized.\n *\n * @param options.form the STORED form document. `routing` and\n * `consentFieldName` are what the check reads; the\n * stored `fields` are the previous design's output, not\n * a contract the new one owes anything to — except the\n * contact-field mappings drawn on them, which are\n * carried across by field name (AGL-2601).\n * @param options.storedNodes the version document's `nodes` in any stored\n * form — a besigner save writes them compressed.\n * @param options.formsOnForSite whether Forms runs on the site (AGL-3029):\n * `false` refuses the promotion with the contract's own\n * sentence, whatever the design.\n */\nexport function resolveFormPromotion(options: {\n formId: string\n form: Record<string, unknown> | null | undefined\n storedNodes: unknown\n formsOnForSite?: boolean\n}): FormPromotionWrite | FormPromotionRefusal {\n const { formId, form, storedNodes } = options\n if (options.formsOnForSite === false) {\n return {\n status: 422,\n body: {\n error: FORMS_OFF_FOR_SITE_VIOLATION.message,\n violations: [FORMS_OFF_FOR_SITE_VIOLATION],\n },\n }\n }\n const decoded = decodeStoredNodes<Record<string, any>>(storedNodes)\n if (!decoded || !Object.keys(decoded).length) {\n return {\n status: 422,\n body: {\n error:\n 'That version holds no design. Open it in the besigner and save ' +\n 'once before publishing it.',\n },\n }\n }\n // Unwrap the synthetic canvas root: a placed form grafts from `rootId`, so\n // publishing the wrapper would put an always-empty container around every\n // instance (AGL-680).\n const definition = canvasTreeToDefinition(decoded)\n if (definition.ambiguousRoot) {\n return {\n status: 422,\n body: {\n error:\n 'A form needs a single top-level element. Wrap what you have in ' +\n 'one container, save, then publish.',\n },\n }\n }\n const nodes = definition.nodes as Record<string, any>\n const formNodeId = Object.keys(nodes).find(\n (nodeId) => nodes[nodeId]?.componentId === FORM_COMPONENT_ID,\n )\n const violations = checkFormContract({\n form: form as never,\n formId,\n nodes: nodes as never,\n formNodeId: formNodeId as NodeId | undefined,\n })\n if (!formContractIsSatisfied(violations)) {\n return {\n status: 422,\n body: {\n // The headline names the CONSEQUENCE; the violations name what to fix.\n error: 'This version would stop the submissions arriving',\n violations,\n },\n }\n }\n return {\n rootId: definition.rootId,\n nodes,\n /*\n * The DECLARATION publishes with the DESIGN.\n *\n * `fields` is what the submit route validates against and what the form's\n * consent-field picker offers; `nodes` is what the author drew. Writing\n * one without the other is how they drift, and the drift is exactly what\n * the check above just refused to ship — so the two go out in a single\n * write, derived from the same tree.\n *\n * `checkFormContract` reports `form-node-missing` when there is no form\n * node, so reaching here means this id resolved.\n *\n * Where each field SAVES TO is not drawn on the canvas — it is edited on\n * the form's page — so it is carried from the stored declaration by\n * field name, or every publish would unmap every field (AGL-2601).\n */\n fields: carryContactFieldMappings(\n form?.['fields'] as FormFieldDecl[] | undefined,\n formFieldDeclsFromNodes(nodes as never, formNodeId as NodeId),\n ),\n }\n}\n\nexport default resolveFormPromotion\n"],"names":["canvasTreeToDefinition","carryContactFieldMappings","checkFormContract","decodeStoredNodes","formContractIsSatisfied","formFieldDeclsFromNodes","FORMS_OFF_FOR_SITE_VIOLATION","FORM_COMPONENT_ID","isFormPromotionRefusal","result","status","undefined","resolveFormPromotion","options","formId","form","storedNodes","formsOnForSite","body","error","message","violations","decoded","Object","keys","length","definition","ambiguousRoot","nodes","formNodeId","find","nodeId","componentId","rootId","fields"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,sBAAsB,EACtBC,yBAAyB,EACzBC,iBAAiB,EACjBC,iBAAiB,EACjBC,uBAAuB,EACvBC,uBAAuB,EACvBC,4BAA4B,QAIvB,sBAAqB;AAE5B,6CAA6C,GAC7C,MAAMC,oBAAoB;AAyB1B,wCAAwC,GACxC,OAAO,SAASC,uBACdC,MAAiD;IAEjD,OAAO,AAACA,OAAgCC,MAAM,KAAKC;AACrD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GACD,OAAO,SAASC,qBAAqBC,OAKpC;IACC,MAAM,EAAEC,MAAM,EAAEC,IAAI,EAAEC,WAAW,EAAE,GAAGH;IACtC,IAAIA,QAAQI,cAAc,KAAK,OAAO;QACpC,OAAO;YACLP,QAAQ;YACRQ,MAAM;gBACJC,OAAOb,6BAA6Bc,OAAO;gBAC3CC,YAAY;oBAACf;iBAA6B;YAC5C;QACF;IACF;IACA,MAAMgB,UAAUnB,kBAAuCa;IACvD,IAAI,CAACM,WAAW,CAACC,OAAOC,IAAI,CAACF,SAASG,MAAM,EAAE;QAC5C,OAAO;YACLf,QAAQ;YACRQ,MAAM;gBACJC,OACE,oEACA;YACJ;QACF;IACF;IACA,2EAA2E;IAC3E,0EAA0E;IAC1E,sBAAsB;IACtB,MAAMO,aAAa1B,uBAAuBsB;IAC1C,IAAII,WAAWC,aAAa,EAAE;QAC5B,OAAO;YACLjB,QAAQ;YACRQ,MAAM;gBACJC,OACE,oEACA;YACJ;QACF;IACF;IACA,MAAMS,QAAQF,WAAWE,KAAK;IAC9B,MAAMC,aAAaN,OAAOC,IAAI,CAACI,OAAOE,IAAI,CACxC,CAACC;YAAWH;eAAAA,EAAAA,gBAAAA,KAAK,CAACG,OAAO,qBAAbH,cAAeI,WAAW,MAAKzB;;IAE7C,MAAMc,aAAanB,kBAAkB;QACnCa,MAAMA;QACND;QACAc,OAAOA;QACPC,YAAYA;IACd;IACA,IAAI,CAACzB,wBAAwBiB,aAAa;QACxC,OAAO;YACLX,QAAQ;YACRQ,MAAM;gBACJ,uEAAuE;gBACvEC,OAAO;gBACPE;YACF;QACF;IACF;IACA,OAAO;QACLY,QAAQP,WAAWO,MAAM;QACzBL;QACA;;;;;;;;;;;;;;;KAeC,GACDM,QAAQjC,0BACNc,wBAAAA,IAAM,CAAC,SAAS,EAChBV,wBAAwBuB,OAAgBC;IAE5C;AACF;AAEA,eAAejB,qBAAoB"}
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type UsageSources } from '@aglyn/tenant-data-admin/server/live-page-usage';
18
+ /**
19
+ * Every live screen whose rendered output contains the form `formId`, however
20
+ * indirectly.
21
+ *
22
+ * The form twin of {@link screenIdsUsingComponentDeep}, and the same walk for
23
+ * the same reason: a placed form is found by SEARCHING node trees, not by
24
+ * matching a pointer, and it can sit on a screen, inside page chrome, or
25
+ * inside a reusable component that a third component nests. Publishing a form
26
+ * changes every page that renders it, so anything this misses keeps serving
27
+ * the old fields for the whole revalidate window while the editor reports that
28
+ * the live sites now serve the new design.
29
+ *
30
+ * The first level is the only thing that differs from the component walk: a
31
+ * document uses a form by PLACING it. After that the fan-out is identical, so
32
+ * it is delegated rather than restated — a component that holds the form is
33
+ * reached by whatever reaches that component, and a layout by whatever renders
34
+ * inside it.
35
+ *
36
+ * Pure, and separate from the Firestore read, so the closure is testable
37
+ * without a database. Cycle-safe by delegation: both walks it defers to bound
38
+ * themselves.
39
+ */
40
+ export declare function screenIdsUsingFormDeep(formId: string, sources: UsageSources): string[];
41
+ /**
42
+ * Drop the cached pages that place `formId`, after its design was published.
43
+ *
44
+ * The plugin knows WHICH pages — the ones whose node trees place the form,
45
+ * found by {@link screenIdsUsingFormDeep} over the site's screens, layouts and
46
+ * components — and the app knows how to drop one on every address the site
47
+ * answers at, so the addresses go through the platform's site cache
48
+ * (`dropPluginSiteCache`, narrowed by `paths`).
49
+ *
50
+ * BEST EFFORT, ALWAYS. The publish has already succeeded by the time this
51
+ * runs, so a failed cache hint must never make it look failed: every failure
52
+ * resolves, and the render cache's TTL stays underneath as the backstop. A
53
+ * scan the bound truncated drops the whole site instead, because a prefix of
54
+ * the site would report a publish while leaving placed pages stale.
55
+ */
56
+ export declare function announceFormPublish(options: {
57
+ firestore: FirebaseFirestore.Firestore;
58
+ hostId: string;
59
+ formId: string;
60
+ }): Promise<{
61
+ revalidated: number;
62
+ screenIds: string[];
63
+ }>;