@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.
- package/package.json +14 -13
- package/src/index.d.ts +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/components/form-design-preview.component.d.ts +1 -1
- package/src/lib/components/form-design-preview.component.js +1 -1
- package/src/lib/components/form-design-preview.component.js.map +1 -1
- package/src/lib/components/form-detail-card.d.ts +1 -1
- package/src/lib/components/form-detail-card.js +5 -3
- package/src/lib/components/form-detail-card.js.map +1 -1
- package/src/lib/components/form.js +3 -3
- package/src/lib/components/form.js.map +1 -1
- package/src/lib/components/host-forms-card.component.js +7 -5
- package/src/lib/components/host-forms-card.component.js.map +1 -1
- package/src/lib/components/use-form-promote-api.d.ts +1 -1
- package/src/lib/components/use-form-promote-api.js +2 -2
- package/src/lib/components/use-form-promote-api.js.map +1 -1
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +7 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/model/forms-record-routes.d.ts +22 -0
- package/src/lib/model/forms-record-routes.js +53 -0
- package/src/lib/model/forms-record-routes.js.map +1 -0
- package/src/lib/plan-entitlements.d.ts +41 -0
- package/src/lib/plan-entitlements.js +53 -0
- package/src/lib/plan-entitlements.js.map +1 -0
- package/src/lib/plugin.js +2 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/form-promote-route.d.ts +74 -0
- package/src/lib/server/form-promote-route.js +239 -0
- package/src/lib/server/form-promote-route.js.map +1 -0
- package/src/lib/server/form-promotion.d.ts +82 -0
- package/src/lib/server/form-promotion.js +133 -0
- package/src/lib/server/form-promotion.js.map +1 -0
- package/src/lib/server/form-publish-announce.d.ts +63 -0
- package/src/lib/server/form-publish-announce.js +142 -0
- package/src/lib/server/form-publish-announce.js.map +1 -0
- package/src/lib/server.d.ts +1 -0
- package/src/lib/server.js +6 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/usage-axes.d.ts +28 -0
- package/src/lib/usage-axes.js +52 -0
- 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
|
+
}>;
|