@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.
@@ -9,16 +9,24 @@ const DOCS_URL = "https://docs.cowliss.com";
9
9
  */
10
10
  const CLERK_ORG_SUBJECT_PREFIX = "org_";
11
11
  /**
12
- * Fixed consent purposes for the prototype. Consent is a per-purpose map on
13
- * the profile, checked at send-step execution time.
14
- *
15
- * The two names describe what the recipient agreed to, not the pipe it
16
- * arrives on: "emails I did not ask for individually" and "my data leaving
17
- * for somewhere else". Naming them after the channel (`email`, `webhook`)
18
- * said nothing a recipient could consent to, and transactional mail already
19
- * bypasses the email purpose, so it was only ever marketing consent.
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 CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
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 fixed `emailMarketing` and
89
- * `dataProcessing`. Purposes are keys in the `consent` map a customer reads
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 `emailMarketing` umbrella,
105
- * so `denied` is the only default it may carry: the purpose is absent on
106
- * every profile that already exists, and a granted default would answer for
107
- * all of them at once.
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, and the column
111
- * behind it still holds `granted` for the seeded `dataProcessing` row: this
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
- * The two fixed purposes cannot be declared. They are seeded for every org
115
- * and owned by no project, so a project redeclaring one would be renaming
116
- * the master switch every other project's journeys hang off.
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) => !CONSENT_PURPOSES.includes(key), `${CONSENT_PURPOSES.join(" and ")} are fixed purposes and cannot be declared`),
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 a segment entry. The registry DTO in `../journeys`
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 compile instead of silently triggering on every app.
146
- * There is deliberately no pipe filter: a trigger narrows by the app the
147
- * write is attributed to, the same token a segment definition names.
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: z.string().min(1) })]);
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
- * A destination's name: the token a journey addresses it by, in a
155
- * `send.webhook` call or a journey's `senderIdentity`.
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
- * Defined here rather than beside the destinations contract, and imported
158
- * from here by it, because a journey manifest names one and the guest layer
159
- * is bundled into every tenant module: importing it the other way round
160
- * would pull the Drizzle destinations table into all of them.
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 destinationNameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
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 carries
217
- * `environments`, and every stored manifest has to keep parsing for good
218
- * (ADR 0011). Zod strips the key, and the journey runs wherever the
219
- * per-environment enabled flag says it runs, which is the one gate there is.
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
- * The sender identity every `send.email` in this journey goes out as,
234
- * unless the call names its own. A name, never a `dst_` id: an org has one
235
- * row per environment, so an id would send in production and fail in
236
- * development, which is the one thing a journey must not do.
237
- *
238
- * Optional here and required at `defineJourney`, exactly like `purposes`: a
239
- * version pushed before the field existed carries none and its stored
240
- * manifest still parses. The author's build is where the error is useful.
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
- senderIdentity: destinationNameSchema.optional(),
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. The environment is always a flag, and auth never lives in
323
- * the project. Strict, so a typo'd key is a build error rather than a
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`, an `environment`, or any other field
380
- * beside the ones a capability takes fails the parse instead of having it
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
- * Which sender identity this mail leaves as, by name. Required, and
403
- * the guest SDK fills in the journey's own when the call does not name
404
- * one, so the host has one resolution path and never has to read the
405
- * manifest to find a sender.
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
- senderIdentity: destinationNameSchema
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(2),
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(2),
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
- senderIdentity: true,
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 environment, and so the docs site can
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 destination, invalid
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 or a malformed purpose key
660
- * fails at build time rather than at the first execution. Whether the org
661
- * declares that purpose is a question only the deploy can answer.
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
- senderIdentity: true,
859
+ enrollment: true,
860
+ description: true,
861
+ from: true,
667
862
  tags: true
668
- }).extend({ senderIdentity: destinationNameSchema });
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: a journey runs wherever it is enabled,
673
- * and nowhere else (ADR 0011). A source that still passes `environments` is
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
- ...journeyConfigSchema.parse({
679
- trigger: input.trigger,
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
  }