@funnelsgrove/runtime 0.1.56 → 0.1.59
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/dist/components/FunnelContext.d.ts +5 -1
- package/dist/components/FunnelContext.js +2 -1
- package/dist/components/ManageSubscriptionScreen.js +4 -1
- package/dist/components/SubscriptionHandoffScreen.d.ts +2 -1
- package/dist/components/SubscriptionHandoffScreen.js +11 -6
- package/dist/config/funnel.manifest.types.d.ts +4 -2
- package/dist/generated/step-contract-hash.d.ts +1 -0
- package/dist/generated/step-contract-hash.js +2 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/migrations/step-contract-v2.d.ts +50 -0
- package/dist/migrations/step-contract-v2.js +310 -0
- package/dist/migrations/step-contract-v3.d.ts +41 -0
- package/dist/migrations/step-contract-v3.js +26 -0
- package/dist/runtime/funnel-manifest.validation.d.ts +7 -2
- package/dist/runtime/funnel-manifest.validation.js +24 -68
- package/dist/runtime/funnel-step-lifecycle.d.ts +35 -0
- package/dist/runtime/funnel-step-lifecycle.js +49 -0
- package/dist/runtime/funnel-step-metadata.validation.d.ts +7 -0
- package/dist/runtime/funnel-step-metadata.validation.js +88 -0
- package/dist/runtime/submit-email-capture.d.ts +23 -0
- package/dist/runtime/submit-email-capture.js +54 -0
- package/dist/runtime/use-funnel-flow-controller.d.ts +58 -5
- package/dist/runtime/use-funnel-flow-controller.js +804 -122
- package/dist/runtime/use-step-choices.d.ts +58 -0
- package/dist/runtime/use-step-choices.js +187 -0
- package/dist/sdk/userAnswers.d.ts +1 -0
- package/dist/services/api.service.d.ts +10 -0
- package/dist/services/api.service.js +32 -0
- package/dist/services/funnel-sdk.service.d.ts +17 -0
- package/dist/services/funnel-sdk.service.js +13 -0
- package/dist/steps/choice-contract.d.ts +33 -0
- package/dist/steps/choice-contract.js +302 -0
- package/dist/steps/step-contract.d.ts +498 -0
- package/dist/steps/step-contract.js +506 -0
- package/dist/steps/types.d.ts +7 -5
- package/dist/steps/types.js +1 -18
- package/dist/testing/funnel-contract-journey-driver.d.ts +35 -0
- package/dist/testing/funnel-contract-journey-driver.js +266 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/validation/fixtures/invalid-reserved-identities.d.ts +249 -0
- package/dist/validation/fixtures/invalid-reserved-identities.js +20 -0
- package/dist/validation/fixtures/valid-v2-project.d.ts +419 -0
- package/dist/validation/fixtures/valid-v2-project.js +228 -0
- package/dist/validation/fixtures/valid-v3-project.d.ts +419 -0
- package/dist/validation/fixtures/valid-v3-project.js +30 -0
- package/dist/validation/funnel-contract-diagnostics.d.ts +49 -0
- package/dist/validation/funnel-contract-diagnostics.js +67 -0
- package/dist/validation/funnel-contract-validator.d.ts +30 -0
- package/dist/validation/funnel-contract-validator.js +1458 -0
- package/dist/validation/funnel-manifest-input.normalization.d.ts +67 -0
- package/dist/validation/funnel-manifest-input.normalization.js +386 -0
- package/package.json +9 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createFunnelContractDiagnostic, } from '../validation/funnel-contract-diagnostics.js';
|
|
2
|
+
import { FUNNEL_STEP_CONTRACTS, } from './step-contract.js';
|
|
3
|
+
export const FUNNEL_CHOICE_STEP_TYPES = Object.freeze(Object.keys(FUNNEL_STEP_CONTRACTS).filter((type) => 'choice' in FUNNEL_STEP_CONTRACTS[type]));
|
|
4
|
+
const GUIDE = 'docs/product-specs/funnel-template-step-authoring.md';
|
|
5
|
+
const SAFE_ANSWER_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
6
|
+
const PROTOTYPE_DANGEROUS_ANSWER_KEYS = new Set([
|
|
7
|
+
...Object.getOwnPropertyNames(Object.prototype),
|
|
8
|
+
'prototype',
|
|
9
|
+
]);
|
|
10
|
+
export const MAX_FUNNEL_CHOICE_ITEMS = 4096;
|
|
11
|
+
export const MAX_FUNNEL_CHOICE_IDENTIFIER_LENGTH = 256;
|
|
12
|
+
export const MAX_FUNNEL_CHOICE_CONTENT_STRING_LENGTH = 4096;
|
|
13
|
+
const diagnostic = (code, stepId, expected, received, reason, repair) => createFunnelContractDiagnostic({
|
|
14
|
+
code,
|
|
15
|
+
file: null,
|
|
16
|
+
stepId,
|
|
17
|
+
expected,
|
|
18
|
+
received,
|
|
19
|
+
reason,
|
|
20
|
+
guide: GUIDE,
|
|
21
|
+
repair,
|
|
22
|
+
});
|
|
23
|
+
const isChoiceStepType = (value) => {
|
|
24
|
+
return typeof value === 'string'
|
|
25
|
+
&& FUNNEL_CHOICE_STEP_TYPES.some((type) => type === value);
|
|
26
|
+
};
|
|
27
|
+
const readPlainDataRecord = (value) => {
|
|
28
|
+
try {
|
|
29
|
+
const prototype = typeof value === 'object' && value !== null
|
|
30
|
+
? Object.getPrototypeOf(value)
|
|
31
|
+
: undefined;
|
|
32
|
+
if (typeof value !== 'object'
|
|
33
|
+
|| value === null
|
|
34
|
+
|| Array.isArray(value)
|
|
35
|
+
|| (prototype !== Object.prototype && prototype !== null)
|
|
36
|
+
|| Object.getOwnPropertySymbols(value).length > 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
40
|
+
const fields = Object.keys(descriptors).sort();
|
|
41
|
+
if (fields.some((field) => !('value' in descriptors[field]))) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return { descriptors, fields };
|
|
45
|
+
}
|
|
46
|
+
catch (_a) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const readOwnDataProperty = (value, key) => {
|
|
51
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
52
|
+
if (!descriptor) {
|
|
53
|
+
return { status: 'missing', value: undefined };
|
|
54
|
+
}
|
|
55
|
+
if (!('value' in descriptor)) {
|
|
56
|
+
return { status: 'accessor', value: undefined };
|
|
57
|
+
}
|
|
58
|
+
return { status: 'data', value: descriptor.value };
|
|
59
|
+
};
|
|
60
|
+
const isBoundedNonblankString = (value, maximumLength) => {
|
|
61
|
+
return typeof value === 'string'
|
|
62
|
+
&& value.length <= maximumLength
|
|
63
|
+
&& value.trim().length > 0;
|
|
64
|
+
};
|
|
65
|
+
const captureChoiceStepMeta = (value) => {
|
|
66
|
+
let stepId = null;
|
|
67
|
+
try {
|
|
68
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
69
|
+
return { ok: false, stepId, issue: 'step metadata must be a plain data record' };
|
|
70
|
+
}
|
|
71
|
+
const prototype = Object.getPrototypeOf(value);
|
|
72
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
73
|
+
return { ok: false, stepId, issue: 'step metadata must be a plain data record' };
|
|
74
|
+
}
|
|
75
|
+
const idProperty = readOwnDataProperty(value, 'id');
|
|
76
|
+
if (idProperty.status !== 'data'
|
|
77
|
+
|| !isBoundedNonblankString(idProperty.value, MAX_FUNNEL_CHOICE_IDENTIFIER_LENGTH)) {
|
|
78
|
+
return { ok: false, stepId, issue: 'step ID must be a bounded nonblank data string' };
|
|
79
|
+
}
|
|
80
|
+
stepId = idProperty.value;
|
|
81
|
+
const typeProperty = readOwnDataProperty(value, 'type');
|
|
82
|
+
const choiceProperty = readOwnDataProperty(value, 'choice');
|
|
83
|
+
if (typeProperty.status !== 'data' || choiceProperty.status === 'accessor') {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
stepId,
|
|
87
|
+
issue: 'step type and choice must be own data properties',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (typeof typeProperty.value === 'string'
|
|
91
|
+
&& typeProperty.value.length > MAX_FUNNEL_CHOICE_IDENTIFIER_LENGTH) {
|
|
92
|
+
return { ok: false, stepId, issue: 'step type exceeds the maximum identifier length' };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
id: stepId,
|
|
97
|
+
type: typeProperty.value,
|
|
98
|
+
choice: choiceProperty.value,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (_a) {
|
|
102
|
+
return { ok: false, stepId, issue: 'step metadata could not be inspected safely' };
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const readBoundedDenseArray = (value, label) => {
|
|
106
|
+
try {
|
|
107
|
+
if (!Array.isArray(value)) {
|
|
108
|
+
return { ok: false, issue: `${label} must be an array` };
|
|
109
|
+
}
|
|
110
|
+
const lengthProperty = readOwnDataProperty(value, 'length');
|
|
111
|
+
if (lengthProperty.status !== 'data'
|
|
112
|
+
|| typeof lengthProperty.value !== 'number'
|
|
113
|
+
|| !Number.isSafeInteger(lengthProperty.value)
|
|
114
|
+
|| lengthProperty.value < 0) {
|
|
115
|
+
return { ok: false, issue: `${label} has an invalid length` };
|
|
116
|
+
}
|
|
117
|
+
if (lengthProperty.value > MAX_FUNNEL_CHOICE_ITEMS) {
|
|
118
|
+
const verb = label === 'options' ? 'exceed' : 'exceeds';
|
|
119
|
+
return { ok: false, issue: `${label} ${verb} the maximum item count` };
|
|
120
|
+
}
|
|
121
|
+
const values = [];
|
|
122
|
+
for (let index = 0; index < lengthProperty.value; index += 1) {
|
|
123
|
+
const item = readOwnDataProperty(value, String(index));
|
|
124
|
+
if (item.status !== 'data') {
|
|
125
|
+
return { ok: false, issue: `${label} must be a dense data array`, index };
|
|
126
|
+
}
|
|
127
|
+
values.push(item.value);
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, values };
|
|
130
|
+
}
|
|
131
|
+
catch (_a) {
|
|
132
|
+
return { ok: false, issue: `${label} could not be inspected safely` };
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const readDescriptorValue = (record, field) => { var _a; return (_a = record.descriptors[field]) === null || _a === void 0 ? void 0 : _a.value; };
|
|
136
|
+
const hasExactFields = (fields, required, allowed) => {
|
|
137
|
+
return required.every((field) => fields.includes(field))
|
|
138
|
+
&& fields.every((field) => allowed.includes(field));
|
|
139
|
+
};
|
|
140
|
+
export const parseFunnelChoiceDescriptor = (stepType, choice) => {
|
|
141
|
+
if (!isChoiceStepType(stepType)) {
|
|
142
|
+
return { ok: false, issue: 'not-choice-step-type' };
|
|
143
|
+
}
|
|
144
|
+
const contract = FUNNEL_STEP_CONTRACTS[stepType].choice;
|
|
145
|
+
const record = readPlainDataRecord(choice);
|
|
146
|
+
if (record === null
|
|
147
|
+
|| !hasExactFields(record.fields, ['answerKey'], ['answerKey', 'allowEmpty'])) {
|
|
148
|
+
return { ok: false, issue: 'invalid-descriptor-shape' };
|
|
149
|
+
}
|
|
150
|
+
const answerKey = readDescriptorValue(record, 'answerKey');
|
|
151
|
+
const allowEmpty = readDescriptorValue(record, 'allowEmpty');
|
|
152
|
+
const definesAllowEmpty = record.fields.includes('allowEmpty');
|
|
153
|
+
if (!isBoundedNonblankString(answerKey, MAX_FUNNEL_CHOICE_IDENTIFIER_LENGTH)
|
|
154
|
+
|| !SAFE_ANSWER_KEY_PATTERN.test(answerKey)
|
|
155
|
+
|| PROTOTYPE_DANGEROUS_ANSWER_KEYS.has(answerKey)) {
|
|
156
|
+
return { ok: false, issue: 'invalid-answer-key' };
|
|
157
|
+
}
|
|
158
|
+
if ((allowEmpty !== undefined && typeof allowEmpty !== 'boolean')
|
|
159
|
+
|| (definesAllowEmpty && !contract.allowEmptyConfig)
|
|
160
|
+
|| (allowEmpty === true && contract.cardinality === 'one')) {
|
|
161
|
+
return { ok: false, issue: 'invalid-allow-empty' };
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
descriptor: allowEmpty === undefined ? { answerKey } : { answerKey, allowEmpty },
|
|
166
|
+
contract,
|
|
167
|
+
stepType,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
const validateChoiceOptions = (options, presentation) => {
|
|
171
|
+
var _a;
|
|
172
|
+
const optionsRead = readBoundedDenseArray(options, 'options');
|
|
173
|
+
if (!optionsRead.ok) {
|
|
174
|
+
return {
|
|
175
|
+
valid: false,
|
|
176
|
+
optionIndex: (_a = optionsRead.index) !== null && _a !== void 0 ? _a : null,
|
|
177
|
+
issue: optionsRead.issue,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (optionsRead.values.length === 0) {
|
|
181
|
+
return { valid: false, optionIndex: null, issue: 'options must be a non-empty array' };
|
|
182
|
+
}
|
|
183
|
+
const seenIds = new Set();
|
|
184
|
+
for (let optionIndex = 0; optionIndex < optionsRead.values.length; optionIndex += 1) {
|
|
185
|
+
const option = readPlainDataRecord(optionsRead.values[optionIndex]);
|
|
186
|
+
const required = presentation === 'emoji'
|
|
187
|
+
? ['id', 'label', 'emoji']
|
|
188
|
+
: ['id', 'label'];
|
|
189
|
+
const allowed = presentation === 'emoji'
|
|
190
|
+
? ['id', 'label', 'emoji']
|
|
191
|
+
: ['id', 'label', 'imageSrc', 'iconKey'];
|
|
192
|
+
if (option === null || !hasExactFields(option.fields, required, allowed)) {
|
|
193
|
+
return { valid: false, optionIndex, issue: 'option has invalid fields' };
|
|
194
|
+
}
|
|
195
|
+
const id = readDescriptorValue(option, 'id');
|
|
196
|
+
const label = readDescriptorValue(option, 'label');
|
|
197
|
+
const emoji = readDescriptorValue(option, 'emoji');
|
|
198
|
+
const imageSrc = readDescriptorValue(option, 'imageSrc');
|
|
199
|
+
const iconKey = readDescriptorValue(option, 'iconKey');
|
|
200
|
+
const hasValidCommonFields = isBoundedNonblankString(id, MAX_FUNNEL_CHOICE_IDENTIFIER_LENGTH) && isBoundedNonblankString(label, MAX_FUNNEL_CHOICE_CONTENT_STRING_LENGTH);
|
|
201
|
+
const hasValidPresentation = presentation === 'emoji'
|
|
202
|
+
? isBoundedNonblankString(emoji, MAX_FUNNEL_CHOICE_CONTENT_STRING_LENGTH)
|
|
203
|
+
: (imageSrc === undefined || typeof imageSrc === 'string')
|
|
204
|
+
&& (imageSrc === undefined || imageSrc.length <= MAX_FUNNEL_CHOICE_CONTENT_STRING_LENGTH)
|
|
205
|
+
&& (iconKey === undefined || typeof iconKey === 'string')
|
|
206
|
+
&& (iconKey === undefined || iconKey.length <= MAX_FUNNEL_CHOICE_CONTENT_STRING_LENGTH);
|
|
207
|
+
if (!hasValidCommonFields || !hasValidPresentation) {
|
|
208
|
+
return { valid: false, optionIndex, issue: 'option has invalid field values' };
|
|
209
|
+
}
|
|
210
|
+
if (seenIds.has(id)) {
|
|
211
|
+
return { valid: false, optionIndex, issue: 'option ID is duplicated' };
|
|
212
|
+
}
|
|
213
|
+
seenIds.add(id);
|
|
214
|
+
}
|
|
215
|
+
return { valid: true, ids: seenIds };
|
|
216
|
+
};
|
|
217
|
+
const selectionSummary = (ids) => {
|
|
218
|
+
try {
|
|
219
|
+
if (typeof ids === 'string') {
|
|
220
|
+
return { shape: 'string', length: ids.length };
|
|
221
|
+
}
|
|
222
|
+
if (Array.isArray(ids)) {
|
|
223
|
+
const lengthProperty = readOwnDataProperty(ids, 'length');
|
|
224
|
+
return lengthProperty.status === 'data' && typeof lengthProperty.value === 'number'
|
|
225
|
+
? { shape: 'array', count: Math.min(lengthProperty.value, MAX_FUNNEL_CHOICE_ITEMS + 1) }
|
|
226
|
+
: { shape: 'array', count: 'invalid' };
|
|
227
|
+
}
|
|
228
|
+
return { shape: ids === null ? 'null' : typeof ids };
|
|
229
|
+
}
|
|
230
|
+
catch (_a) {
|
|
231
|
+
return { shape: 'uninspectable' };
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
export const buildChoiceCompletion = (stepMeta, options, ids) => {
|
|
235
|
+
const capturedMeta = captureChoiceStepMeta(stepMeta);
|
|
236
|
+
if (!capturedMeta.ok) {
|
|
237
|
+
return diagnostic('FG-CHOICE-001', capturedMeta.stepId, 'plain step metadata with bounded own id, type, and choice data properties', { issue: capturedMeta.issue }, 'Choice completion requires safely inspectable step metadata.', 'Provide plain step metadata with own data properties and bounded strings.');
|
|
238
|
+
}
|
|
239
|
+
const { id: stepId, type: stepType, choice } = capturedMeta;
|
|
240
|
+
const descriptorResult = parseFunnelChoiceDescriptor(stepType, choice);
|
|
241
|
+
if (!descriptorResult.ok) {
|
|
242
|
+
return diagnostic('FG-CHOICE-001', stepId, {
|
|
243
|
+
answerKey: 'bounded safe extensible identifier',
|
|
244
|
+
allowEmpty: 'omitted for single choice; optional boolean for multi-select',
|
|
245
|
+
}, {
|
|
246
|
+
descriptor: choice === undefined ? 'missing' : 'invalid',
|
|
247
|
+
issue: descriptorResult.issue,
|
|
248
|
+
}, 'Choice completion requires an exact valid choice descriptor.', 'Declare a safe answerKey and include allowEmpty only for multi-select choice steps.');
|
|
249
|
+
}
|
|
250
|
+
const { contract, descriptor } = descriptorResult;
|
|
251
|
+
const optionValidation = validateChoiceOptions(options, contract.presentation);
|
|
252
|
+
if (!optionValidation.valid) {
|
|
253
|
+
return diagnostic('FG-CHOICE-002', stepId, contract.presentation === 'emoji'
|
|
254
|
+
? { fields: ['id', 'label', 'emoji'], values: 'nonblank strings', ids: 'unique' }
|
|
255
|
+
: {
|
|
256
|
+
requiredFields: ['id', 'label'],
|
|
257
|
+
optionalFields: ['imageSrc', 'iconKey'],
|
|
258
|
+
values: 'valid strings',
|
|
259
|
+
ids: 'unique',
|
|
260
|
+
}, { optionIndex: optionValidation.optionIndex, issue: optionValidation.issue }, 'The choice option content is invalid.', 'Provide a non-empty list of exact-schema options with stable unique IDs.');
|
|
261
|
+
}
|
|
262
|
+
if (contract.cardinality === 'one') {
|
|
263
|
+
if (typeof ids !== 'string'
|
|
264
|
+
|| ids.length === 0
|
|
265
|
+
|| ids.length > MAX_FUNNEL_CHOICE_IDENTIFIER_LENGTH
|
|
266
|
+
|| !optionValidation.ids.has(ids)) {
|
|
267
|
+
return diagnostic('FG-CHOICE-002', stepId, 'one non-empty stable option ID', selectionSummary(ids), 'The single-choice selection is invalid.', 'Pass exactly one stable option ID from this step\'s option list.');
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
answerKey: descriptor.answerKey,
|
|
271
|
+
value: ids,
|
|
272
|
+
selected: { [descriptor.answerKey]: ids },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const selectedRead = readBoundedDenseArray(ids, 'selection');
|
|
276
|
+
if (!selectedRead.ok) {
|
|
277
|
+
return diagnostic('FG-CHOICE-002', stepId, 'an array of unique stable option IDs', Object.assign(Object.assign({}, selectionSummary(ids)), { issue: selectedRead.issue }), 'The multi-choice selection has the wrong shape.', 'Pass an array containing only stable option IDs from this step\'s option list.');
|
|
278
|
+
}
|
|
279
|
+
const selectedIds = [];
|
|
280
|
+
const selectedIdSet = new Set();
|
|
281
|
+
let selectionIsValid = true;
|
|
282
|
+
for (const selectedId of selectedRead.values) {
|
|
283
|
+
if (!isBoundedNonblankString(selectedId, MAX_FUNNEL_CHOICE_IDENTIFIER_LENGTH)
|
|
284
|
+
|| !optionValidation.ids.has(selectedId)
|
|
285
|
+
|| selectedIdSet.has(selectedId)) {
|
|
286
|
+
selectionIsValid = false;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
selectedIdSet.add(selectedId);
|
|
290
|
+
selectedIds.push(selectedId);
|
|
291
|
+
}
|
|
292
|
+
if (!selectionIsValid || (selectedIds.length === 0 && descriptor.allowEmpty !== true)) {
|
|
293
|
+
return diagnostic('FG-CHOICE-002', stepId, descriptor.allowEmpty === true
|
|
294
|
+
? 'an array of unique stable option IDs, including an explicit empty array'
|
|
295
|
+
: 'a non-empty array of unique stable option IDs', selectionSummary(ids), 'The multi-choice selection is invalid.', 'Pass unique stable option IDs and use an empty array only when allowEmpty is true.');
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
answerKey: descriptor.answerKey,
|
|
299
|
+
value: selectedIds,
|
|
300
|
+
selected: { [descriptor.answerKey]: selectedIds },
|
|
301
|
+
};
|
|
302
|
+
};
|