@cowliss/cli 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/dist/guest/{driver-14FnzM-h.js → driver-C1bsCjZT.js} +7 -3
- package/dist/guest/driver.d.ts +1 -1
- package/dist/guest/driver.js +1 -1
- package/dist/guest/emails.d.ts +6 -0
- package/dist/guest/{index-BDj02EsQ.d.ts → index-3PMqJKmc.d.ts} +111 -16
- package/dist/guest/{journeys-C8j8Sqvl.js → journeys-Dpsp225V.js} +286 -87
- package/dist/guest/journeys.d.ts +52 -23
- package/dist/guest/journeys.js +1 -1
- package/dist/guest/wasi.js +1 -1
- package/dist/index.js +1504 -987
- package/examples/abandoned-checkout/journeys/abandoned-checkout.ts +9 -2
- package/examples/abandoned-checkout/scenarios/abandoned-checkout.timeout.json +1 -2
- package/examples/activity-decay/journeys/activity-decay.ts +13 -3
- package/examples/cross-app-pitch/journeys/cross-app-pitch.ts +10 -5
- package/examples/cross-app-pitch/scenarios/cross-app-pitch.json +1 -1
- package/examples/winback/journeys/winback.ts +10 -3
- package/examples/winback/scenarios/winback.json +1 -1
- package/package.json +1 -1
|
@@ -9,16 +9,24 @@ const DOCS_URL = "https://docs.cowliss.com";
|
|
|
9
9
|
*/
|
|
10
10
|
const CLERK_ORG_SUBJECT_PREFIX = "org_";
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* The marketing purpose by name, since it is the one every gate, the
|
|
13
|
+
* unsubscribe route, and the developer's own toggle all reach for.
|
|
14
|
+
*/
|
|
15
|
+
const MARKETING = "marketing";
|
|
16
|
+
/**
|
|
17
|
+
* A legal journey `purpose` that is not a consent purpose: the send gates
|
|
18
|
+
* pass it unconditionally, no profile's map stores it and no editor renders
|
|
19
|
+
* it. What protects the channel itself (suppression, the SES gates, the
|
|
20
|
+
* quota) still applies.
|
|
20
21
|
*/
|
|
21
|
-
const
|
|
22
|
+
const TRANSACTIONAL = "transactional";
|
|
23
|
+
/**
|
|
24
|
+
* The purposes a project may not declare, which are the same two that sit
|
|
25
|
+
* outside the `marketing` umbrella: `marketing` is the umbrella itself and
|
|
26
|
+
* `transactional` is the case consent does not govern. Everything a project
|
|
27
|
+
* declares is a marketing-mail category and so sits under it.
|
|
28
|
+
*/
|
|
29
|
+
const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
|
|
22
30
|
/**
|
|
23
31
|
* The two classes of send, declared on the template rather than passed per
|
|
24
32
|
* call so the class cannot drift between two sends of the same message.
|
|
@@ -56,6 +64,92 @@ const PUSH_LIMITS = {
|
|
|
56
64
|
const COW_CONFIG_SCHEMA_PATH = "/schemas/cow.json";
|
|
57
65
|
const COW_CONFIG_SCHEMA_URL = `${DOCS_URL}${COW_CONFIG_SCHEMA_PATH}`;
|
|
58
66
|
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region ../../packages/shared/src/segment-definition.ts
|
|
69
|
+
/**
|
|
70
|
+
* A segment definition: a flat list of predicates over traits and event
|
|
71
|
+
* history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
|
|
72
|
+
* prototype, and a flat list keeps the pure evaluator a fold.
|
|
73
|
+
*
|
|
74
|
+
* `appId` scopes the EVENT side of a definition only. Traits are
|
|
75
|
+
* per-profile and profiles merge across apps, so there is nothing
|
|
76
|
+
* app-shaped to filter on the trait side.
|
|
77
|
+
*
|
|
78
|
+
* A definition and the membership it produces are both the
|
|
79
|
+
* organization's.
|
|
80
|
+
*
|
|
81
|
+
* Apart from `./segments` because a journey's trigger carries a definition
|
|
82
|
+
* and the trigger schema is bundled into the wasm guest, where an edge to
|
|
83
|
+
* `@cowliss/db` (which `./segments` has, for the row schema) is a hard
|
|
84
|
+
* bundler failure. Nothing here imports anything but zod.
|
|
85
|
+
*
|
|
86
|
+
* The object is strict: a definition holding the retired `sourceId` key
|
|
87
|
+
* (which meant the app) fails loudly instead of parsing as an unfiltered
|
|
88
|
+
* definition that evaluates over every app. There is deliberately no pipe
|
|
89
|
+
* filter here — filtering by source is a later feature, and accepting one
|
|
90
|
+
* now would make a stale `sourceId` parse as a filter on a pipe that does
|
|
91
|
+
* not exist and silently match nothing.
|
|
92
|
+
*/
|
|
93
|
+
const SEGMENT_TRAIT_OPS = [
|
|
94
|
+
"eq",
|
|
95
|
+
"neq",
|
|
96
|
+
"gt",
|
|
97
|
+
"gte",
|
|
98
|
+
"lt",
|
|
99
|
+
"lte",
|
|
100
|
+
"exists",
|
|
101
|
+
"notExists",
|
|
102
|
+
"contains"
|
|
103
|
+
];
|
|
104
|
+
/** Operators that read no comparison value: presence of the key is the test. */
|
|
105
|
+
const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
|
|
106
|
+
const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
|
|
107
|
+
const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
|
|
108
|
+
kind: z.literal("trait"),
|
|
109
|
+
name: predicateNameSchema,
|
|
110
|
+
op: z.enum(SEGMENT_TRAIT_OPS),
|
|
111
|
+
/**
|
|
112
|
+
* Compared against the stored trait, which is `unknown` because
|
|
113
|
+
* identify accepts arbitrary JSON. The evaluator coerces both sides
|
|
114
|
+
* before comparing, so authors here (CLI, MCP, dashboard) get the
|
|
115
|
+
* form-field-friendly reading rather than strict JSON equality:
|
|
116
|
+
* numeric-looking strings are compared as numbers for eq/neq and for
|
|
117
|
+
* ordering ("150" matches 150, and orders like it), and "true"/"false"
|
|
118
|
+
* are compared as booleans for eq/neq, trimmed and case-insensitively
|
|
119
|
+
* (" TRUE " reads as true). Coercion needs both sides to agree on a
|
|
120
|
+
* type: "0" never equals false. The numeric net is as wide as
|
|
121
|
+
* `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
|
|
122
|
+
* matters most for opaque ids: "007" is authored as the number 7.
|
|
123
|
+
*
|
|
124
|
+
* contains reads three ways. Against an array trait it is membership
|
|
125
|
+
* under that same equality, so "true" matches `[true]` and "1" matches
|
|
126
|
+
* `[1, 2]`. Against a string trait it is a plain substring search with
|
|
127
|
+
* no coercion, since substrings only mean something between strings.
|
|
128
|
+
* Against anything else it never matches. Ordering never coerces
|
|
129
|
+
* booleans.
|
|
130
|
+
*/
|
|
131
|
+
value: z.unknown().optional()
|
|
132
|
+
}), z.object({
|
|
133
|
+
kind: z.literal("event"),
|
|
134
|
+
name: predicateNameSchema,
|
|
135
|
+
op: z.enum(["performed", "notPerformed"]),
|
|
136
|
+
/** How many matching events the predicate counts as "performed". */
|
|
137
|
+
atLeast: z.number().int().min(1).default(1),
|
|
138
|
+
/** Rolling window, relative to evaluation time; absent means all history. */
|
|
139
|
+
withinDays: z.number().int().min(1).optional()
|
|
140
|
+
})]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
|
|
141
|
+
const appIdSchema = z.string().trim().min(1);
|
|
142
|
+
const segmentDefinitionSchema = z.strictObject({
|
|
143
|
+
match: z.enum(["all", "any"]).default("all"),
|
|
144
|
+
/**
|
|
145
|
+
* Optional app filter over the event side; null/absent spans apps.
|
|
146
|
+
* A list matches any of the named apps, which is how one definition
|
|
147
|
+
* names the development and the production id of the same app.
|
|
148
|
+
*/
|
|
149
|
+
appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
|
|
150
|
+
predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
|
|
151
|
+
});
|
|
152
|
+
|
|
59
153
|
//#endregion
|
|
60
154
|
//#region ../../packages/shared/src/journeys-v2/manifest.ts
|
|
61
155
|
/**
|
|
@@ -65,6 +159,34 @@ const COW_CONFIG_SCHEMA_URL = `${DOCS_URL}${COW_CONFIG_SCHEMA_PATH}`;
|
|
|
65
159
|
* changed, and stores that entry on the version it creates.
|
|
66
160
|
*/
|
|
67
161
|
/**
|
|
162
|
+
* A duration as journey code writes it: an ms-style string ("2d") or
|
|
163
|
+
* milliseconds. It lives here rather than with the guest protocol because a
|
|
164
|
+
* manifest carries one too (a journey's enrollment cooldown) and `./guest`
|
|
165
|
+
* already imports this module, so the other direction would be a cycle.
|
|
166
|
+
*/
|
|
167
|
+
const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
|
|
168
|
+
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
|
|
169
|
+
const DURATION_UNIT_MS = {
|
|
170
|
+
ms: 1,
|
|
171
|
+
s: 1e3,
|
|
172
|
+
m: 6e4,
|
|
173
|
+
h: 36e5,
|
|
174
|
+
d: 864e5,
|
|
175
|
+
w: 6048e5
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* A duration in milliseconds. The simulator's virtual clock and the runner's
|
|
179
|
+
* timers both need it, and neither may pull in Temporal's `msToNumber` (one
|
|
180
|
+
* runs in the CLI, the other inside workflow code).
|
|
181
|
+
*/
|
|
182
|
+
function parseDuration(duration) {
|
|
183
|
+
if (typeof duration === "number") return duration;
|
|
184
|
+
const match = DURATION_PATTERN.exec(duration.trim());
|
|
185
|
+
if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
|
|
186
|
+
const unit = DURATION_UNIT_MS[match[2]];
|
|
187
|
+
return Math.round(Number(match[1]) * unit);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
68
190
|
* A journey or template key: the file basename under `journeys/` or
|
|
69
191
|
* `emails/`, kebab-case and unique across the project. Becomes part of the
|
|
70
192
|
* Temporal workflow id and travels in the journey chain, so it stays short.
|
|
@@ -85,8 +207,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
|
|
|
85
207
|
*/
|
|
86
208
|
const tagsSchema = z.array(z.string().trim().min(1).max(50, "a tag must be at most 50 characters")).max(20, "a journey or template takes at most 20 tags").default([]).transform((tags) => [...new Set(tags)]);
|
|
87
209
|
/**
|
|
88
|
-
* A consent purpose key: camelCase, matching the
|
|
89
|
-
* `
|
|
210
|
+
* A consent purpose key: camelCase, matching the reserved `marketing` and
|
|
211
|
+
* `transactional`. Purposes are keys in the `consent` map a customer reads
|
|
90
212
|
* on their own profile, which is why they are not the kebab-case of a
|
|
91
213
|
* journey key.
|
|
92
214
|
*/
|
|
@@ -101,22 +223,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
|
|
|
101
223
|
const consentPurposeKeySchema = z.string().max(50, "a purpose key must be at most 50 characters").regex(CONSENT_PURPOSE_KEY_PATTERN, "a consent purpose key must be camelCase (a letter first, then letters and digits)");
|
|
102
224
|
/**
|
|
103
225
|
* One purpose a project declares in `cow.json` (spec: Decisions). A declared
|
|
104
|
-
* purpose is marketing-class and sits under the `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
226
|
+
* purpose is marketing-class and sits under the `marketing` umbrella, so
|
|
227
|
+
* `denied` is the only default it may carry: the purpose is absent on every
|
|
228
|
+
* profile that already exists, and a granted default would answer for all of
|
|
229
|
+
* them at once.
|
|
108
230
|
*
|
|
109
231
|
* The field stays required rather than disappearing, so every `cow.json` and
|
|
110
|
-
* every stored manifest written before this still parses
|
|
111
|
-
*
|
|
112
|
-
* is a refusal at declaration time, not a narrower storage shape.
|
|
232
|
+
* every stored manifest written before this still parses: this is a refusal
|
|
233
|
+
* at declaration time, not a narrower storage shape.
|
|
113
234
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
235
|
+
* Neither reserved purpose can be declared: `marketing` is the master switch
|
|
236
|
+
* every other project's journeys hang off, and `transactional` is not a
|
|
237
|
+
* consent purpose at all, so declaring it would promise a switch that no
|
|
238
|
+
* send ever reads.
|
|
117
239
|
*/
|
|
118
240
|
const declaredPurposeSchema = z.strictObject({
|
|
119
|
-
key: consentPurposeKeySchema.refine((key) => !
|
|
241
|
+
key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
|
|
120
242
|
/** What the dashboard and the account modal render beside the switch. */
|
|
121
243
|
label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
|
|
122
244
|
/** What the purpose means for a profile whose map does not answer it. */
|
|
@@ -138,28 +260,65 @@ const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 2
|
|
|
138
260
|
const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
|
|
139
261
|
/**
|
|
140
262
|
* What starts a journey: an event (optionally narrowed to an app id or a
|
|
141
|
-
* list of them) or
|
|
142
|
-
* reuses it.
|
|
263
|
+
* list of them), or entry into the segment the trigger itself describes.
|
|
264
|
+
* The registry DTO in `../journeys` reuses it.
|
|
265
|
+
*
|
|
266
|
+
* A segment trigger carries the predicate list, not a name: the push
|
|
267
|
+
* materializes one segment row per journey that inlines a definition, owned
|
|
268
|
+
* by the journey and named after its key, so the segment exists because the
|
|
269
|
+
* journey exists and there is no order to get wrong (ADR 0016). The
|
|
270
|
+
* definition is data — validated here, carried on the version, never
|
|
271
|
+
* compiled and never executed.
|
|
143
272
|
*
|
|
144
|
-
* Both members are strict, so a journey holding the retired `source` key
|
|
145
|
-
* fails to
|
|
146
|
-
*
|
|
147
|
-
* write is attributed to, the same
|
|
273
|
+
* Both members are strict, so a journey holding the retired `source` key, or
|
|
274
|
+
* the retired `{ segment: "name" }` form, fails to build instead of silently
|
|
275
|
+
* triggering on every app or on nothing. There is deliberately no pipe
|
|
276
|
+
* filter: a trigger narrows by the app the write is attributed to, the same
|
|
277
|
+
* token a segment definition names.
|
|
148
278
|
*/
|
|
149
279
|
const triggerSchema = z.union([z.strictObject({
|
|
150
280
|
event: patternSchema,
|
|
151
281
|
appId: patternSchema.optional()
|
|
152
|
-
}), z.strictObject({ segment:
|
|
282
|
+
}), z.strictObject({ segment: segmentDefinitionSchema })]);
|
|
283
|
+
/**
|
|
284
|
+
* The address half of a from-header: a local part, an `@`, and a dotted
|
|
285
|
+
* domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
|
|
286
|
+
* address literals): every character it refuses would have to be quoted or
|
|
287
|
+
* escaped to survive a header, and none of them belong in an address a
|
|
288
|
+
* journey sends from.
|
|
289
|
+
*/
|
|
290
|
+
const FROM_ADDRESS = /^[^\s@<>",;]+@([^\s@<>",;.]+(?:\.[^\s@<>",;.]+)+)$/;
|
|
153
291
|
/**
|
|
154
|
-
*
|
|
155
|
-
* `
|
|
292
|
+
* Parse a journey's or a send's `from`: `addr@domain`, or
|
|
293
|
+
* `Name <addr@domain>` with the name optionally quoted. Null when it is
|
|
294
|
+
* neither, which is what `cow build` refuses on and what the send-time
|
|
295
|
+
* domain gate turns into a skip.
|
|
156
296
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
|
|
297
|
+
* One parser, and the send facade reuses it. A control character anywhere is
|
|
298
|
+
* a refusal rather than something to strip: it survives quoting and would
|
|
299
|
+
* inject a second header.
|
|
300
|
+
*/
|
|
301
|
+
function parseFromAddress(from) {
|
|
302
|
+
const text = from.trim();
|
|
303
|
+
if (/\p{Cc}/u.test(text)) return null;
|
|
304
|
+
const angled = /^(.*?)\s*<([^<>]*)>$/.exec(text);
|
|
305
|
+
const address = (angled?.[2] ?? text).trim();
|
|
306
|
+
const domain = FROM_ADDRESS.exec(address)?.[1];
|
|
307
|
+
if (!domain) return null;
|
|
308
|
+
const name = (angled?.[1] ?? "").trim().replace(/^"(.*)"$/s, "$1").replace(/\\(.)/g, "$1").trim();
|
|
309
|
+
return {
|
|
310
|
+
...name ? { name } : {},
|
|
311
|
+
address,
|
|
312
|
+
domain: domain.toLowerCase()
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The address a journey or one send leaves as: a string, not the name of a
|
|
317
|
+
* configured row (ADR 0014). The domain is checked against the
|
|
318
|
+
* organization's verified ones at push time and again at send time; the
|
|
319
|
+
* shape is all that is checked here, because it is all a build can know.
|
|
161
320
|
*/
|
|
162
|
-
const
|
|
321
|
+
const fromSchema = z.string().trim().max(200, "from must be at most 200 characters").refine((value) => parseFromAddress(value) !== null, { message: "from must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")" });
|
|
163
322
|
/** A content address: `sha256:` plus the lowercase hex digest. */
|
|
164
323
|
const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "digest must be sha256:<64 hex>");
|
|
165
324
|
/**
|
|
@@ -195,13 +354,6 @@ const SPINE_ENTRY_NAMES = [
|
|
|
195
354
|
const spineEntrySchema = z.object({
|
|
196
355
|
name: z.enum(SPINE_ENTRY_NAMES),
|
|
197
356
|
detail: z.string().max(200).optional(),
|
|
198
|
-
/**
|
|
199
|
-
* The sender identity a `send.email` call named for itself, overriding
|
|
200
|
-
* the journey's own. Present only when the author wrote one on the call,
|
|
201
|
-
* which is what lets the deploy warning and the journey detail page name
|
|
202
|
-
* the override without re-reading the code.
|
|
203
|
-
*/
|
|
204
|
-
senderIdentity: z.string().max(100).optional(),
|
|
205
357
|
/** A `waitForEvent` timeout, as the author wrote it. */
|
|
206
358
|
timeout: z.string().max(50).optional(),
|
|
207
359
|
get steps() {
|
|
@@ -213,10 +365,10 @@ const spineEntrySchema = z.object({
|
|
|
213
365
|
}).meta({ id: "JourneySpineEntry" });
|
|
214
366
|
/**
|
|
215
367
|
* One journey in a stored manifest. A plain (non-strict) object on purpose:
|
|
216
|
-
* a manifest pushed before the author's rollout gate went away still
|
|
217
|
-
*
|
|
218
|
-
* (ADR 0011). Zod strips
|
|
219
|
-
*
|
|
368
|
+
* a manifest pushed before the author's rollout gate went away still
|
|
369
|
+
* carries keys that no longer exist, and every stored manifest has to keep
|
|
370
|
+
* parsing for good (ADR 0011). Zod strips them, and the journey runs when
|
|
371
|
+
* its `enabled` flag says so, which is the one gate there is.
|
|
220
372
|
*/
|
|
221
373
|
const manifestJourneySchema = z.object({
|
|
222
374
|
key: journeyKeySchema,
|
|
@@ -230,16 +382,37 @@ const manifestJourneySchema = z.object({
|
|
|
230
382
|
*/
|
|
231
383
|
purpose: consentPurposeKeySchema,
|
|
232
384
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
|
|
385
|
+
* How often one recipient may enter. Enrollment derives from the purpose
|
|
386
|
+
* (ADR 0015), so the only thing an author writes is how long after a
|
|
387
|
+
* completed run the journey re-opens: absent means once, ever. Refused on
|
|
388
|
+
* a transactional journey, which enrolls on every trigger — see
|
|
389
|
+
* `manifestSchema` below, where the cross-field check lives (a refinement
|
|
390
|
+
* on this object would break the `.pick()` the guest SDK and the guest
|
|
391
|
+
* protocol both take of it).
|
|
392
|
+
*/
|
|
393
|
+
enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
|
|
394
|
+
try {
|
|
395
|
+
parseDuration(value);
|
|
396
|
+
return true;
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
|
|
401
|
+
/**
|
|
402
|
+
* The author's own sentence about what this journey does, shown wherever
|
|
403
|
+
* the journey is read. Capped like a segment's description; absent when
|
|
404
|
+
* the author wrote none.
|
|
405
|
+
*/
|
|
406
|
+
description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
|
|
407
|
+
/**
|
|
408
|
+
* The address every `send.email` in this journey goes out as, unless the
|
|
409
|
+
* call names its own. Required of the author (`defineJourney` types it so,
|
|
410
|
+
* and `cow build` refuses a journey without it), and still optional here:
|
|
411
|
+
* releases pushed before ADR 0014 was amended carry none, and this schema
|
|
412
|
+
* parses stored manifests as well as new ones. A push with no `from` is
|
|
413
|
+
* refused by the domain gate, which says what to do about it.
|
|
241
414
|
*/
|
|
242
|
-
|
|
415
|
+
from: fromSchema.optional(),
|
|
243
416
|
spine: z.array(spineEntrySchema),
|
|
244
417
|
bundle: digestSchema
|
|
245
418
|
});
|
|
@@ -250,6 +423,13 @@ const manifestTemplateSchema = z.object({
|
|
|
250
423
|
sendClass: z.enum(SEND_CLASSES),
|
|
251
424
|
/** True asks the host to mint a signed `verifyUrl` prop at send time. */
|
|
252
425
|
verifyLink: z.boolean(),
|
|
426
|
+
/**
|
|
427
|
+
* True asks the host to mint a signed `unsubscribeUrl` prop at send time,
|
|
428
|
+
* for the author's own footer link. Default false: a marketing send whose
|
|
429
|
+
* HTML carries no unsubscribe link at all still gets one, appended as a
|
|
430
|
+
* platform footer, so the link is never the author's to forget.
|
|
431
|
+
*/
|
|
432
|
+
unsubscribeLink: z.boolean().default(false),
|
|
253
433
|
/** JSON Schema of the template's `props`, converted by `cow build`. */
|
|
254
434
|
propsSchema: z.record(z.string(), z.unknown()),
|
|
255
435
|
bundle: digestSchema
|
|
@@ -300,6 +480,15 @@ const manifestSchema = z.object({
|
|
|
300
480
|
uniqueKeys(manifest.journeys, ctx, "journeys");
|
|
301
481
|
uniqueKeys(manifest.templates, ctx, "templates");
|
|
302
482
|
uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
|
|
483
|
+
for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
|
|
484
|
+
code: "custom",
|
|
485
|
+
message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
|
|
486
|
+
path: [
|
|
487
|
+
"journeys",
|
|
488
|
+
index,
|
|
489
|
+
"enrollment"
|
|
490
|
+
]
|
|
491
|
+
});
|
|
303
492
|
});
|
|
304
493
|
/**
|
|
305
494
|
* One entry of a stored manifest: what a version is a snapshot of. Journeys
|
|
@@ -319,9 +508,8 @@ const versionManifestSchema = z.union([manifestJourneySchema, manifestTemplateSc
|
|
|
319
508
|
const projectNameSchema = z.string().trim().min(1).max(200);
|
|
320
509
|
/**
|
|
321
510
|
* `cow.json` (spec: Project layout): the org, and which of its projects this
|
|
322
|
-
* directory is.
|
|
323
|
-
*
|
|
324
|
-
* silently ignored setting.
|
|
511
|
+
* directory is. Auth never lives in the project. Strict, so a typo'd key
|
|
512
|
+
* is a build error rather than a silently ignored setting.
|
|
325
513
|
*/
|
|
326
514
|
const cowConfigSchema = z.strictObject({
|
|
327
515
|
$schema: z.url().optional(),
|
|
@@ -360,8 +548,6 @@ const cowConfigSchema = z.strictObject({
|
|
|
360
548
|
* a guest returns with these schemas before anything acts on it; the guest
|
|
361
549
|
* SDK and the Node simulator produce and consume the same shapes.
|
|
362
550
|
*/
|
|
363
|
-
/** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
|
|
364
|
-
const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
|
|
365
551
|
/** The event a journey runs for, or waits on: name, properties, and when. */
|
|
366
552
|
const guestEventSchema = z.object({
|
|
367
553
|
name: z.string().min(1),
|
|
@@ -376,8 +562,8 @@ const properties = z.record(z.string(), z.unknown());
|
|
|
376
562
|
* them. A name outside the union is rejected, never dispatched.
|
|
377
563
|
*
|
|
378
564
|
* Every `args` is strict, and that is the tenancy boundary made mechanical:
|
|
379
|
-
* a module that returns an `orgId
|
|
380
|
-
*
|
|
565
|
+
* a module that returns an `orgId` or any other field beside the ones a
|
|
566
|
+
* capability takes fails the parse instead of having it
|
|
381
567
|
* quietly dropped. Tenant context comes from the workflow input, never from
|
|
382
568
|
* guest output, and this is where saying so becomes checkable.
|
|
383
569
|
*/
|
|
@@ -399,12 +585,15 @@ const commandSchema = z.discriminatedUnion("name", [
|
|
|
399
585
|
template: journeyKeySchema,
|
|
400
586
|
props: properties,
|
|
401
587
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* one
|
|
405
|
-
*
|
|
588
|
+
* The address this mail leaves as. The guest SDK fills in the
|
|
589
|
+
* journey's own whenever the call does not name one, so the host has
|
|
590
|
+
* one resolution path and never reads the manifest to find it. A
|
|
591
|
+
* journey must name one, so absent on both is a release pushed before
|
|
592
|
+
* that was true, and the `domain` gate refuses it.
|
|
406
593
|
*/
|
|
407
|
-
|
|
594
|
+
from: fromSchema.optional(),
|
|
595
|
+
/** Where a reply to this one mail goes, instead of the from-address. */
|
|
596
|
+
replyTo: z.email().optional()
|
|
408
597
|
})
|
|
409
598
|
}),
|
|
410
599
|
z.object({
|
|
@@ -485,7 +674,7 @@ const executionLimitsSchema = z.object({
|
|
|
485
674
|
logLineBytes: z.number().int().positive()
|
|
486
675
|
});
|
|
487
676
|
const journeyStepInputSchema = z.object({
|
|
488
|
-
protocol: z.literal(
|
|
677
|
+
protocol: z.literal(3),
|
|
489
678
|
kind: z.literal("journey"),
|
|
490
679
|
key: journeyKeySchema,
|
|
491
680
|
event: guestEventSchema,
|
|
@@ -525,7 +714,7 @@ const journeyStepOutputSchema = z.discriminatedUnion("status", [
|
|
|
525
714
|
})
|
|
526
715
|
]);
|
|
527
716
|
const templateRenderInputSchema = z.object({
|
|
528
|
-
protocol: z.literal(
|
|
717
|
+
protocol: z.literal(3),
|
|
529
718
|
kind: z.literal("template"),
|
|
530
719
|
key: journeyKeySchema,
|
|
531
720
|
props: properties
|
|
@@ -544,11 +733,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
|
|
|
544
733
|
const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
|
|
545
734
|
trigger: true,
|
|
546
735
|
purpose: true,
|
|
547
|
-
|
|
736
|
+
enrollment: true,
|
|
737
|
+
description: true,
|
|
738
|
+
from: true,
|
|
548
739
|
tags: true
|
|
549
740
|
}).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
|
|
550
741
|
sendClass: true,
|
|
551
742
|
verifyLink: true,
|
|
743
|
+
unsubscribeLink: true,
|
|
552
744
|
propsSchema: true,
|
|
553
745
|
tags: true
|
|
554
746
|
}).extend({ kind: z.literal("template") })]);
|
|
@@ -600,7 +792,7 @@ const sandboxFailureCodeSchema = z.enum(SANDBOX_FAILURE_CODES);
|
|
|
600
792
|
* to be written by hand or by an agent, not generated.
|
|
601
793
|
*
|
|
602
794
|
* The schema lives here rather than in packages/journeys so the CLI can reject
|
|
603
|
-
* a bad file before it boots a Temporal
|
|
795
|
+
* a bad file before it boots a Temporal dev server, and so the docs site can
|
|
604
796
|
* render the format from one source.
|
|
605
797
|
*/
|
|
606
798
|
const journeyScenarioSchema = z.object({
|
|
@@ -642,7 +834,7 @@ const journeyScenarioSchema = z.object({
|
|
|
642
834
|
//#endregion
|
|
643
835
|
//#region src/guest/journeys.ts
|
|
644
836
|
/**
|
|
645
|
-
* A capability that failed host-side (an unknown
|
|
837
|
+
* A capability that failed host-side (an unknown webhook, invalid
|
|
646
838
|
* props, a profile that is not there). The failure is journaled, so a
|
|
647
839
|
* journey that catches it takes the same branch on every replay.
|
|
648
840
|
*/
|
|
@@ -656,31 +848,38 @@ var CapabilityError = class extends Error {
|
|
|
656
848
|
};
|
|
657
849
|
/**
|
|
658
850
|
* The config half of a journey, validated with the same schema the release
|
|
659
|
-
* manifest is validated with, so a bad trigger
|
|
660
|
-
* fails at build time rather than at the
|
|
661
|
-
*
|
|
851
|
+
* manifest is validated with, so a bad trigger, a malformed purpose key, or
|
|
852
|
+
* a `from` that is not an address fails at build time rather than at the
|
|
853
|
+
* first execution. Whether the org declares that purpose, and whether it may
|
|
854
|
+
* send from that domain, are questions only the push can answer.
|
|
662
855
|
*/
|
|
663
856
|
const journeyConfigSchema = manifestJourneySchema.pick({
|
|
664
857
|
trigger: true,
|
|
665
858
|
purpose: true,
|
|
666
|
-
|
|
859
|
+
enrollment: true,
|
|
860
|
+
description: true,
|
|
861
|
+
from: true,
|
|
667
862
|
tags: true
|
|
668
|
-
})
|
|
863
|
+
});
|
|
669
864
|
/**
|
|
670
865
|
* Author a journey. Throws at definition time on an invalid config.
|
|
671
866
|
*
|
|
672
|
-
* There is no environment gate here:
|
|
673
|
-
* and nowhere else (ADR 0011).
|
|
674
|
-
* a typecheck error in `cow build`, which is where the author can fix it.
|
|
867
|
+
* There is no environment gate here: an organization has one data space, and
|
|
868
|
+
* a journey runs wherever it is on and nowhere else (ADR 0011, ADR 0013).
|
|
675
869
|
*/
|
|
676
870
|
function defineJourney(input) {
|
|
871
|
+
const config = journeyConfigSchema.parse({
|
|
872
|
+
trigger: input.trigger,
|
|
873
|
+
purpose: input.purpose,
|
|
874
|
+
enrollment: input.enrollment,
|
|
875
|
+
description: input.description,
|
|
876
|
+
from: input.from,
|
|
877
|
+
tags: input.tags
|
|
878
|
+
});
|
|
879
|
+
if (config.from === void 0) throw new Error("defineJourney needs a \"from\": the address this journey sends as, on a domain your organization verified. Add and verify it on the Domains page (Settings, Domains). For example from: \"Billing <billing@your-domain.com>\".");
|
|
677
880
|
return {
|
|
678
|
-
...
|
|
679
|
-
|
|
680
|
-
purpose: input.purpose,
|
|
681
|
-
senderIdentity: input.senderIdentity,
|
|
682
|
-
tags: input.tags
|
|
683
|
-
}),
|
|
881
|
+
...config,
|
|
882
|
+
from: config.from,
|
|
684
883
|
run: input.run
|
|
685
884
|
};
|
|
686
885
|
}
|