@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/dist/index.js CHANGED
@@ -38,24 +38,6 @@ const REQUEST_ID_HEADER = "X-Request-Id";
38
38
  */
39
39
  const CLERK_ORG_SUBJECT_PREFIX = "org_";
40
40
  /**
41
- * The two fixed environments every org has (spec: Environments). An app
42
- * belongs to one, so the environment of every write is the app's;
43
- * profiles, identifiers, events, memberships, journey instances,
44
- * deliveries, violations, quarantine, the address ledger, and idempotency
45
- * keys are per environment, while the catalog, sending domains, the
46
- * suppression mirror, billing, and journey code are shared. A third
47
- * environment is a one-line change here plus the mirrored db enum.
48
- */
49
- const ENVIRONMENTS = ["development", "production"];
50
- /** What an admin call gets when it names no environment. */
51
- const DEFAULT_ENVIRONMENT = "production";
52
- /**
53
- * The request header admin endpoints read the environment from; absent
54
- * means production. Ingestion endpoints ignore it: the environment of a
55
- * write comes from the app.
56
- */
57
- const ENVIRONMENT_HEADER = "X-Cow-Environment";
58
- /**
59
41
  * The header every Cowliss client names itself in, as
60
42
  * `<package>/<version>` (`@cowliss/cli/0.1.0`). The API logs it and reads
61
43
  * nothing from it: no behaviour depends on the value, so a caller that
@@ -74,16 +56,6 @@ const ENVIRONMENT_HEADER = "X-Cow-Environment";
74
56
  */
75
57
  const CLIENT_HEADER = "X-Cow-Client";
76
58
  /**
77
- * Event retention per environment, in days: both the default (what an org
78
- * gets without setting anything) and the cap (the API refuses more). There
79
- * is no "forever". Production keeps 18 months; development keeps 30 days,
80
- * because test traffic is disposable by definition.
81
- */
82
- const EVENT_RETENTION_DAYS = {
83
- production: 548,
84
- development: 30
85
- };
86
- /**
87
59
  * The named identifiers a call may carry, a fixed enum grown by code (spec:
88
60
  * Identity). `anonymousId` is reserved only in that a profile whose
89
61
  * identifiers are all anonymous is labelled anonymous in the dashboard;
@@ -110,30 +82,30 @@ const TOPUP_PRESETS_MICROS = [
110
82
  2e8
111
83
  ];
112
84
  /**
113
- * Fixed consent purposes for the prototype. Consent is a per-purpose map on
114
- * the profile, checked at send-step execution time.
115
- *
116
- * The two names describe what the recipient agreed to, not the pipe it
117
- * arrives on: "emails I did not ask for individually" and "my data leaving
118
- * for somewhere else". Naming them after the channel (`email`, `webhook`)
119
- * said nothing a recipient could consent to, and transactional mail already
120
- * bypasses the email purpose, so it was only ever marketing consent.
121
- */
122
- const CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
123
- /**
124
85
  * The marketing purpose by name, since it is the one every gate, the
125
86
  * unsubscribe route, and the developer's own toggle all reach for.
126
87
  */
127
- const EMAIL_MARKETING = "emailMarketing";
88
+ const MARKETING = "marketing";
89
+ /**
90
+ * A legal journey `purpose` that is not a consent purpose: the send gates
91
+ * pass it unconditionally, no profile's map stores it and no editor renders
92
+ * it. What protects the channel itself (suppression, the SES gates, the
93
+ * quota) still applies.
94
+ */
95
+ const TRANSACTIONAL = "transactional";
96
+ /**
97
+ * The purposes a project may not declare, which are the same two that sit
98
+ * outside the `marketing` umbrella: `marketing` is the umbrella itself and
99
+ * `transactional` is the case consent does not govern. Everything a project
100
+ * declares is a marketing-mail category and so sits under it.
101
+ */
102
+ const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
128
103
  /**
129
104
  * What a purpose means when the profile's map does not answer it, matching
130
- * the `profiles.consent` column default: marketing is asked for, everything
131
- * else about the data the developer already sends is granted.
105
+ * the `profiles.consent` column default: marketing is asked for, never
106
+ * assumed.
132
107
  */
133
- const CONSENT_PURPOSE_DEFAULTS = {
134
- emailMarketing: false,
135
- dataProcessing: true
136
- };
108
+ const CONSENT_PURPOSE_DEFAULTS = { marketing: false };
137
109
  /**
138
110
  * The `defaults` argument `consentGranted` takes, built from an org's
139
111
  * purpose rows. Every surface that renders or gates a purpose reads those
@@ -3084,21 +3056,6 @@ const abuseEvents = pgTable("abuse_events", {
3084
3056
  const selectAbuseEventSchema = createSelectSchema(abuseEvents);
3085
3057
  const insertAbuseEventSchema = createInsertSchema(abuseEvents);
3086
3058
 
3087
- //#endregion
3088
- //#region ../../packages/db/src/schema/environments.ts
3089
- /**
3090
- * The two fixed environments every org has. A partition inside the org, not
3091
- * a resource: no table, no ids, no create surface. The environment of a
3092
- * write comes from the app it names (apps carry one), and everything
3093
- * downstream of a write (profiles, identifiers, events, memberships,
3094
- * deliveries, runs, violations, quarantine, the address ledger) is stamped
3095
- * with it.
3096
- *
3097
- * The literal list mirrors ENVIRONMENTS in packages/shared/constants; db
3098
- * cannot import it without closing a package cycle (shared -> db).
3099
- */
3100
- const environmentEnum = pgEnum("environment", ["development", "production"]);
3101
-
3102
3059
  //#endregion
3103
3060
  //#region ../../packages/db/src/schema/timestamps.ts
3104
3061
  /**
@@ -3134,12 +3091,7 @@ const updatedAt = () => stamp("updated_at").notNull().defaultNow().$onUpdate(()
3134
3091
  /**
3135
3092
  * Apps: the registry of an org's apps/products, and the attribution unit.
3136
3093
  * Everything downstream (ingestion, via the source a payload names;
3137
- * segments, journeys, destinations) validates appIds against this table.
3138
- *
3139
- * An app belongs to exactly one environment, chosen at creation and
3140
- * immutable: the environment of every write is the environment of the
3141
- * app behind the source the payload names, and nothing else carries it.
3142
- * The dev and prod rows of one app are two rows with two names and two ids.
3094
+ * segments, journeys, webhooks) validates appIds against this table.
3143
3095
  *
3144
3096
  * The id is readable, `app_<slug>` derived from the name at creation, and
3145
3097
  * unique per org rather than globally: the primary key is (orgId, id), and
@@ -3162,7 +3114,6 @@ const appStatusEnum = pgEnum("app_status", ["active", "archived"]);
3162
3114
  const apps$1 = pgTable("apps", {
3163
3115
  id: text("id").notNull(),
3164
3116
  orgId: text("org_id").notNull(),
3165
- environment: environmentEnum("environment").notNull(),
3166
3117
  name: text("name").notNull(),
3167
3118
  status: appStatusEnum("status").notNull().default("active"),
3168
3119
  createdAt: createdAt(),
@@ -3317,7 +3268,7 @@ const insertCatalogTraitSchema = createInsertSchema(catalogTraits);
3317
3268
  * in their `cow.json`. One read here returns the whole set, so nothing
3318
3269
  * downstream unions a table with a constant.
3319
3270
  *
3320
- * Org-wide rather than per-project or per-environment: `profiles.consent` is
3271
+ * Org-wide rather than per-project: `profiles.consent` is
3321
3272
  * one jsonb map per profile, so an answer given under one project is the
3322
3273
  * same answer under the next, and two projects declaring one key must agree
3323
3274
  * on its label and default or the deploy is refused.
@@ -3367,8 +3318,16 @@ const deliveryChannelEnum = pgEnum("delivery_channel", ["email", "webhook"]);
3367
3318
  * nullable. The meter and the quota gate exclude it (packages/delivery's
3368
3319
  * repository), and every per-profile read excludes it by construction,
3369
3320
  * because it has no profile to be found under.
3321
+ *
3322
+ * `api` is a direct send through the Resend-compatible facade (ADR 0014):
3323
+ * no journey and no profile either, addressed to whatever the caller named,
3324
+ * and metered exactly like a journey's email.
3370
3325
  */
3371
- const deliveryKindEnum = pgEnum("delivery_kind", ["journey", "test"]);
3326
+ const deliveryKindEnum = pgEnum("delivery_kind", [
3327
+ "journey",
3328
+ "test",
3329
+ "api"
3330
+ ]);
3372
3331
  /**
3373
3332
  * The delivery lifecycle, frozen with the API version.
3374
3333
  *
@@ -3382,6 +3341,10 @@ const deliveryKindEnum = pgEnum("delivery_kind", ["journey", "test"]);
3382
3341
  * `skipped_suppressed` is the recipient's address sitting in the
3383
3342
  * suppression mirror, which SES would have bounced off its own
3384
3343
  * suppression list anyway.
3344
+ * `skipped_unknown_purpose` is the version naming a purpose the
3345
+ * organization does not have, which is a broken manifest rather than a
3346
+ * consent decision: the `error` column names the purpose, and the fix is
3347
+ * another push (ADR 0017).
3385
3348
  * - `would_*` are dry-run outcomes: terminal, never reach SES, and
3386
3349
  * excluded from feedback, quotas, and reconciliation.
3387
3350
  *
@@ -3398,9 +3361,9 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3398
3361
  "skipped_paused",
3399
3362
  "skipped_suppressed",
3400
3363
  "skipped_quota",
3364
+ "skipped_unknown_purpose",
3401
3365
  "skipped_frequency_cap",
3402
3366
  "skipped_consent",
3403
- "skipped_sender",
3404
3367
  "skipped_domain",
3405
3368
  "skipped_ssrf",
3406
3369
  "would_send",
@@ -3408,36 +3371,50 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3408
3371
  "would_skip_paused",
3409
3372
  "would_skip_suppressed",
3410
3373
  "would_skip_quota",
3374
+ "would_skip_unknown_purpose",
3411
3375
  "would_skip_frequency_cap",
3412
3376
  "would_skip_consent",
3413
- "would_skip_sender",
3414
3377
  "would_skip_domain",
3415
3378
  "would_skip_ssrf"
3416
3379
  ]);
3417
3380
  const deliveries$1 = pgTable("deliveries", {
3418
3381
  id: text("id").primaryKey(),
3419
3382
  orgId: text("org_id").notNull(),
3420
- /** The instance's environment, stamped from the workflow run input. */
3421
- environment: environmentEnum("environment").notNull(),
3422
3383
  /**
3423
3384
  * The recipient profile (`usr_`); a text column, since a delivery is a
3424
3385
  * log row and outlives nothing but erasure. Null on a test send, which
3425
- * goes to the member who asked for it rather than to anyone's profile.
3386
+ * goes to the member who asked for it rather than to anyone's profile,
3387
+ * and on an api send, which names addresses and knows no profile.
3426
3388
  */
3427
3389
  profileId: text("profile_id"),
3428
- /** The journey that sent this; null on a test send, which has none. */
3390
+ /**
3391
+ * The journey that sent this; null on a test send and on an api send,
3392
+ * neither of which has one.
3393
+ */
3429
3394
  journey: text("journey"),
3430
3395
  kind: deliveryKindEnum("kind").notNull().default("journey"),
3431
- /** Email template name or webhook destination name. */
3396
+ /**
3397
+ * Email template name or webhook name. An api send that named no
3398
+ * template says `email`: there is a message but no key to point at.
3399
+ */
3432
3400
  step: text("step").notNull(),
3433
3401
  channel: deliveryChannelEnum("channel").notNull(),
3434
3402
  status: deliveryStatusEnum("status").notNull(),
3435
3403
  dryRun: boolean("dry_run").notNull().default(false),
3436
3404
  /**
3437
- * Recipient email address or destination URL. PII: the logger redacts
3438
- * this path, so it is stored here and nowhere else.
3405
+ * Recipient email address or webhook URL. An api send to several
3406
+ * addresses records the first `to`; the rest are in the payload. PII:
3407
+ * the logger redacts this path, so it is stored here and nowhere else.
3439
3408
  */
3440
3409
  recipient: text("recipient"),
3410
+ /**
3411
+ * What the send was made of, per kind: a journey or test email holds the
3412
+ * template props (plus `rendered` on a dry run or a test), a webhook
3413
+ * holds its body, and an `api` send holds the caller's envelope as they
3414
+ * spelled it, `{ from, to, cc, bcc, reply_to, subject, headers,
3415
+ * template, variables }`. Never the rendered bodies of a real send: SES
3416
+ * has the message and the log does not keep a second copy.
3417
+ */
3441
3418
  payload: jsonb("payload").$type().notNull().default({}),
3442
3419
  /**
3443
3420
  * The provider's message id (SES's MessageId), stored so a feedback
@@ -3470,7 +3447,7 @@ const deliveries$1 = pgTable("deliveries", {
3470
3447
  precision: 3
3471
3448
  })
3472
3449
  }, (table) => [
3473
- index("deliveries_org_id_environment_created_at_idx").on(table.orgId, table.environment, table.createdAt),
3450
+ index("deliveries_org_id_created_at_idx").on(table.orgId, table.createdAt),
3474
3451
  index("deliveries_org_id_profile_id_created_at_idx").on(table.orgId, table.profileId, table.createdAt),
3475
3452
  uniqueIndex("deliveries_org_id_attempt_key_unique").on(table.orgId, table.attemptKey),
3476
3453
  uniqueIndex("deliveries_provider_id_unique").on(table.providerId),
@@ -3479,62 +3456,14 @@ const deliveries$1 = pgTable("deliveries", {
3479
3456
  const selectDeliverySchema = createSelectSchema(deliveries$1);
3480
3457
  const insertDeliverySchema = createInsertSchema(deliveries$1);
3481
3458
 
3482
- //#endregion
3483
- //#region ../../packages/db/src/schema/destinations.ts
3484
- /**
3485
- * Destinations: named send targets (webhook URLs, sender identities) that
3486
- * journeys reference by name. Mirrors the apps table conventions: ms
3487
- * precision timestamps for cursor round-trips, one environment chosen at
3488
- * creation and immutable, and name uniqueness per org AND environment, so
3489
- * `crm` can point at the staging receiver in development and at the real one
3490
- * in production. A journey step naming `webhook("crm")` resolves to the row
3491
- * in the instance's environment; the same rule picks the sender identity, of
3492
- * which an org has one per environment.
3493
- *
3494
- * WEBHOOK_PAYLOAD_VERSION (packages/shared) pins the webhook payload shape;
3495
- * the column records the version each destination was created against.
3496
- * signingSecret is the Standard Webhooks signing secret, generated at
3497
- * creation and shown once — the API DTO omits it after the create response.
3498
- */
3499
- const destinationTypeEnum = pgEnum("destination_type", ["webhook", "sender_identity"]);
3500
- const destinations$1 = pgTable("destinations", {
3501
- id: text("id").primaryKey(),
3502
- orgId: text("org_id").notNull(),
3503
- environment: environmentEnum("environment").notNull(),
3504
- name: text("name").notNull(),
3505
- type: destinationTypeEnum("type").notNull(),
3506
- config: jsonb("config").$type().notNull(),
3507
- webhookPayloadVersion: integer("webhook_payload_version").notNull().default(1),
3508
- signingSecret: text("signing_secret"),
3509
- /**
3510
- * Auto-disable state, mirroring how receivers treat us: the webhook send
3511
- * activity bumps the counter once per failed delivery attempt (not once
3512
- * per Temporal retry) and stamps disabledAt at the threshold. A disabled
3513
- * destination records `skipped_disabled` until an admin clears it; a
3514
- * successful delivery resets the counter.
3515
- */
3516
- consecutiveFailures: integer("consecutive_failures").notNull().default(0),
3517
- disabledAt: timestamp("disabled_at", {
3518
- withTimezone: true,
3519
- mode: "date",
3520
- precision: 3
3521
- }),
3522
- createdAt: createdAt(),
3523
- updatedAt: updatedAt()
3524
- }, (table) => [index("destinations_org_id_idx").on(table.orgId), uniqueIndex("destinations_org_id_environment_name_unique").on(table.orgId, table.environment, table.name)]);
3525
- const selectDestinationSchema = createSelectSchema(destinations$1);
3526
- const insertDestinationSchema = createInsertSchema(destinations$1);
3527
-
3528
3459
  //#endregion
3529
3460
  //#region ../../packages/db/src/schema/email-addresses.ts
3530
3461
  /**
3531
3462
  * The email address ledger (ticket 35): everything Cowliss knows about an
3532
- * address, scoped to the org and environment and keyed by the address
3533
- * rather than the profile. Verification is address-scoped by nature: a
3534
- * profile that changes its email has not verified the new one, and an
3535
- * address verified once stays verified when the same profile comes back to
3536
- * it. Per environment because a verification proven against a development
3537
- * profile says nothing about production.
3463
+ * address, scoped to the org and keyed by the address rather than the
3464
+ * profile. Verification is address-scoped by nature: a profile that changes
3465
+ * its email has not verified the new one, and an address verified once
3466
+ * stays verified when the same profile comes back to it.
3538
3467
  *
3539
3468
  * The profile keeps its canonical `email` trait as the address Cowliss sends
3540
3469
  * to, plus the system-maintained `emailVerifiedAt` / `emailVerifiedAddress`
@@ -3552,7 +3481,6 @@ const verificationMethodEnum = pgEnum("verification_method", [
3552
3481
  const emailAddresses = pgTable("email_addresses", {
3553
3482
  id: text("id").primaryKey(),
3554
3483
  orgId: text("org_id").notNull(),
3555
- environment: environmentEnum("environment").notNull(),
3556
3484
  /** Stored lowercased and trimmed: the mailbox, not the way it was typed. */
3557
3485
  address: text("address").notNull(),
3558
3486
  /** Null until something verified the address. */
@@ -3564,45 +3492,10 @@ const emailAddresses = pgTable("email_addresses", {
3564
3492
  verificationMethod: verificationMethodEnum("verification_method"),
3565
3493
  createdAt: createdAt(),
3566
3494
  updatedAt: updatedAt()
3567
- }, (table) => [uniqueIndex("email_addresses_org_env_address_unique").on(table.orgId, table.environment, table.address)]);
3495
+ }, (table) => [uniqueIndex("email_addresses_org_address_unique").on(table.orgId, table.address)]);
3568
3496
  const selectEmailAddressSchema = createSelectSchema(emailAddresses);
3569
3497
  const insertEmailAddressSchema = createInsertSchema(emailAddresses);
3570
3498
 
3571
- //#endregion
3572
- //#region ../../packages/db/src/schema/environment-settings.ts
3573
- /**
3574
- * The ingestion policy for catalog governance. Single source of truth:
3575
- * @cowliss/shared/governance derives INGESTION_POLICIES from
3576
- * ingestionPolicyEnum.enumValues, so the enum and the settings contract
3577
- * cannot drift.
3578
- */
3579
- const ingestionPolicyEnum = pgEnum("ingestion_policy", ["permissive", "strict"]);
3580
- /**
3581
- * Per-environment settings, keyed by org and environment. "No row means
3582
- * every default applies", the same convention as org_settings: a fresh org
3583
- * is zero-config in both environments, and the write path never seeds rows
3584
- * it does not own.
3585
- *
3586
- * `ingestionPolicy`: permissive accepts quarantined (unclassified) names and
3587
- * observes them; strict routes them to the review queue instead of applying
3588
- * them. Development can be strict while production stays lenient.
3589
- *
3590
- * `eventRetentionDays`: the ClickHouse TTL window for the environment's
3591
- * events. Null means the environment's default (18 months in production,
3592
- * 30 days in development, from packages/shared constants); there is no
3593
- * "forever", and the API refuses a value above the environment's cap.
3594
- */
3595
- const environmentSettings = pgTable("environment_settings", {
3596
- orgId: text("org_id").notNull(),
3597
- environment: environmentEnum("environment").notNull(),
3598
- ingestionPolicy: ingestionPolicyEnum("ingestion_policy").notNull().default("permissive"),
3599
- eventRetentionDays: integer("event_retention_days"),
3600
- createdAt: createdAt(),
3601
- updatedAt: updatedAt()
3602
- }, (table) => [primaryKey({ columns: [table.orgId, table.environment] })]);
3603
- const selectEnvironmentSettingsSchema = createSelectSchema(environmentSettings);
3604
- const insertEnvironmentSettingsSchema = createInsertSchema(environmentSettings);
3605
-
3606
3499
  //#endregion
3607
3500
  //#region ../../packages/db/src/schema/executions.ts
3608
3501
  const executionStatusEnum = pgEnum("execution_status", [
@@ -3615,7 +3508,6 @@ const executionStatusEnum = pgEnum("execution_status", [
3615
3508
  const executions$1 = pgTable("executions", {
3616
3509
  id: text("id").primaryKey(),
3617
3510
  orgId: text("org_id").notNull(),
3618
- environment: environmentEnum("environment").notNull(),
3619
3511
  /** The journey's key, not a foreign key: derived journey rows come and go with pushes. */
3620
3512
  journeyKey: text("journey_key").notNull(),
3621
3513
  profileId: text("profile_id").notNull(),
@@ -3664,9 +3556,9 @@ const executions$1 = pgTable("executions", {
3664
3556
  precision: 3
3665
3557
  })
3666
3558
  }, (table) => [
3667
- index("executions_org_id_environment_version_id_status_idx").on(table.orgId, table.environment, table.versionId, table.status),
3668
- uniqueIndex("executions_org_id_workflow_id_unique").on(table.orgId, table.workflowId),
3669
- index("executions_org_id_environment_started_at_idx").on(table.orgId, table.environment, table.startedAt)
3559
+ index("executions_org_id_version_id_status_idx").on(table.orgId, table.versionId, table.status),
3560
+ index("executions_org_id_workflow_id_status_idx").on(table.orgId, table.workflowId, table.status),
3561
+ index("executions_org_id_started_at_idx").on(table.orgId, table.startedAt)
3670
3562
  ]);
3671
3563
  const selectExecutionSchema = createSelectSchema(executions$1);
3672
3564
  const insertExecutionSchema = createInsertSchema(executions$1);
@@ -3725,7 +3617,7 @@ const idempotencyKeys = pgTable("idempotency_keys", {
3725
3617
  //#endregion
3726
3618
  //#region ../../packages/db/src/schema/profiles.ts
3727
3619
  /**
3728
- * Profiles: one row per person per environment. Cowliss generates the id
3620
+ * Profiles: one row per person. Cowliss generates the id
3729
3621
  * (`usr_`) and that id is the person key everywhere: workflow ids,
3730
3622
  * ClickHouse rows, segment membership, deliveries, journey runs, erasure.
3731
3623
  * Callers never send it on the write path; they send named identifiers
@@ -3733,15 +3625,11 @@ const idempotencyKeys = pgTable("idempotency_keys", {
3733
3625
  * creating one when none is known.
3734
3626
  *
3735
3627
  * traits is the merged trait bag (RFC 7386 key-level merge on write).
3736
- * consent is the fixed-purpose consent map from the spec. Marketing starts
3737
- * denied and data processing granted: nobody is subscribed by the act of
3738
- * being ingested, while the processing the product runs on is the basis the
3739
- * profile exists under at all. It is written by the identify path (a caller
3740
- * passing `consent`), the consent editor, and the automatic revocations.
3741
- *
3742
- * `environment` is the app's, stamped at creation and never changed: a
3743
- * person in development and a person in production are two rows even when
3744
- * their identifiers match.
3628
+ * consent is the per-purpose consent map. Marketing starts denied: nobody
3629
+ * is subscribed by the act of being ingested. `transactional` is absent by
3630
+ * design (ADR 0017): it is not a purpose anyone may withhold, so it is not
3631
+ * stored. The map is written by the identify path (a caller passing
3632
+ * `consent`), the consent editor, and the automatic revocations.
3745
3633
  *
3746
3634
  * `mergedInto` is the merge pointer (spec: Identity). Null on a live
3747
3635
  * profile; on a profile a merge folded away it names the survivor, whose
@@ -3756,19 +3644,15 @@ const idempotencyKeys = pgTable("idempotency_keys", {
3756
3644
  const profiles = pgTable("profiles", {
3757
3645
  id: text("id").primaryKey(),
3758
3646
  orgId: text("org_id").notNull(),
3759
- environment: environmentEnum("environment").notNull(),
3760
3647
  appId: text("app_id").notNull(),
3761
3648
  sourceId: text("source_id").notNull(),
3762
3649
  traits: jsonb("traits").$type().notNull().default({}),
3763
- consent: jsonb("consent").$type().notNull().default({
3764
- emailMarketing: false,
3765
- dataProcessing: true
3766
- }),
3650
+ consent: jsonb("consent").$type().notNull().default({ marketing: false }),
3767
3651
  mergedInto: text("merged_into"),
3768
3652
  createdAt: createdAt(),
3769
3653
  updatedAt: updatedAt()
3770
3654
  }, (table) => [
3771
- index("profiles_org_id_environment_idx").on(table.orgId, table.environment),
3655
+ index("profiles_org_id_idx").on(table.orgId),
3772
3656
  index("profiles_merged_into_idx").on(table.mergedInto),
3773
3657
  foreignKey({
3774
3658
  columns: [table.mergedInto],
@@ -3804,10 +3688,10 @@ const identifierKindEnum = pgEnum("identifier_kind", [
3804
3688
  ]);
3805
3689
  /**
3806
3690
  * The identifiers map: every named identifier a call has ever carried,
3807
- * pointing at the profile it resolved to. Unique per (org, environment,
3808
- * kind, value), which is what makes "a shared identifier means the same
3809
- * person" enforceable at the row level: the same clerkId in development and
3810
- * production is two people, and the same clerkId twice in production is one.
3691
+ * pointing at the profile it resolved to. Unique per (org, kind, value),
3692
+ * which is what makes "a shared identifier means the same person"
3693
+ * enforceable at the row level: the same clerkId twice in one organization
3694
+ * is one person.
3811
3695
  *
3812
3696
  * Rows follow their profile: erasure deletes the profile and these cascade,
3813
3697
  * and a merge re-points them at the survivor.
@@ -3815,12 +3699,11 @@ const identifierKindEnum = pgEnum("identifier_kind", [
3815
3699
  const identifiers = pgTable("identifiers", {
3816
3700
  id: text("id").primaryKey(),
3817
3701
  orgId: text("org_id").notNull(),
3818
- environment: environmentEnum("environment").notNull(),
3819
3702
  kind: identifierKindEnum("kind").notNull(),
3820
3703
  value: text("value").notNull(),
3821
3704
  profileId: text("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
3822
3705
  createdAt: createdAt()
3823
- }, (table) => [uniqueIndex("identifiers_org_env_kind_value_unique").on(table.orgId, table.environment, table.kind, table.value), index("identifiers_profile_id_idx").on(table.profileId)]);
3706
+ }, (table) => [uniqueIndex("identifiers_org_kind_value_unique").on(table.orgId, table.kind, table.value), index("identifiers_profile_id_idx").on(table.profileId)]);
3824
3707
  const selectIdentifierSchema = createSelectSchema(identifiers);
3825
3708
  const insertIdentifierSchema = createInsertSchema(identifiers);
3826
3709
 
@@ -3846,8 +3729,6 @@ const insertIdentifierSchema = createInsertSchema(identifiers);
3846
3729
  const journeyRuns = pgTable("journey_runs", {
3847
3730
  id: text("id").primaryKey(),
3848
3731
  orgId: text("org_id").notNull(),
3849
- /** The instance's environment; development instances never land here (they bill nothing). */
3850
- environment: environmentEnum("environment").notNull(),
3851
3732
  /** The journey's name, not its registry id: the run is about the definition that ran. */
3852
3733
  journey: text("journey").notNull(),
3853
3734
  profileId: text("profile_id").notNull(),
@@ -3867,15 +3748,23 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3867
3748
  //#endregion
3868
3749
  //#region ../../packages/db/src/schema/journeys.ts
3869
3750
  /**
3751
+ * The one operational state a manager sets (ADR 0018): `on` (recipients
3752
+ * enroll and executions run), `off` (nothing enrolls, executions in flight
3753
+ * finish) or `paused` (nothing enrolls, executions in flight hold before
3754
+ * their next step). Three values rather than two booleans: a second flag
3755
+ * beside the first would spell two combinations that mean nothing.
3756
+ */
3757
+ const journeyStatusEnum = pgEnum("journey_status", [
3758
+ "on",
3759
+ "off",
3760
+ "paused"
3761
+ ]);
3762
+ /**
3870
3763
  * Derived journey rows: one per (org, key), upserted from the manifest a
3871
3764
  * push carried. Nothing here is authored through the API, which is why
3872
3765
  * there is no id of its own: the key is the name the author gave the file,
3873
3766
  * and the (org, key) pair is the only identity a journey has.
3874
3767
  *
3875
- * One row for both environments (ADR 0011): the journey's code is its
3876
- * latest ready version, and what differs per environment is the enabled
3877
- * flag alone. A trigger that should fire in both names both of an app's ids.
3878
- *
3879
3768
  * A push only ever adds and updates: it owns the keys its manifest carries
3880
3769
  * and leaves every other row alone, because an org's journeys can come from
3881
3770
  * several projects and a developer may push a project holding only some of
@@ -3883,9 +3772,9 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3883
3772
  * what makes a key another project owns a refused push rather than a silent
3884
3773
  * overwrite. Removing a journey is an explicit delete (ADR 0009).
3885
3774
  *
3886
- * Whether a journey fires is one flag and one only: journey_states, per
3887
- * environment, which outlives every push, so a push never turns anything on
3888
- * or off. The author has no second gate of their own (ADR 0011).
3775
+ * Whether a journey fires is one field and one only: `status`, which a
3776
+ * push never writes, so deploying code never turns anything on or off. The
3777
+ * author has no second gate of their own (ADR 0011, ADR 0018).
3889
3778
  *
3890
3779
  * The descriptive columns are what its latest ready version reported, so a
3891
3780
  * failed compile leaves both the code and its description as they were.
@@ -3908,65 +3797,73 @@ const journeys$1 = pgTable("journeys", {
3908
3797
  * the fixed pair.
3909
3798
  */
3910
3799
  purpose: text("purpose").notNull(),
3800
+ /**
3801
+ * The author's own sentence about what the journey does, from the
3802
+ * manifest. Null when they wrote none.
3803
+ */
3804
+ description: text("description"),
3911
3805
  spine: jsonb("spine").$type().notNull(),
3806
+ /**
3807
+ * The one gate on the journey: off until `cow enable` or a manager's
3808
+ * control, and never touched by a push (ADR 0011, amended by ADR 0013
3809
+ * and ADR 0018).
3810
+ */
3811
+ status: journeyStatusEnum("status").notNull().default("off"),
3912
3812
  createdAt: createdAt(),
3913
3813
  updatedAt: updatedAt()
3914
3814
  }, (table) => [primaryKey({ columns: [table.orgId, table.key] })]);
3915
3815
  const selectJourneySchema = createSelectSchema(journeys$1);
3916
3816
  const insertJourneySchema = createInsertSchema(journeys$1);
3917
- /**
3918
- * Per-org, per-environment enable state for a journey key; the only
3919
- * operator write on a journey, and the one gate on it. A row exists only
3920
- * once someone turned the journey on or off in that environment: NO ROW
3921
- * MEANS DISABLED (ADR 0011). A push writes none, so a key is off in both
3922
- * environments until `cow enable` or a manager's toggle.
3923
- *
3924
- * Keyed by key rather than by a foreign key into `journeys` on purpose: the
3925
- * flag outlives every push, so it survives a key that temporarily leaves a
3926
- * project and comes back.
3927
- */
3928
- const journeyStates = pgTable("journey_states", {
3929
- orgId: text("org_id").notNull(),
3930
- environment: environmentEnum("environment").notNull(),
3931
- key: text("key").notNull(),
3932
- enabled: boolean("enabled").notNull(),
3933
- createdAt: createdAt(),
3934
- updatedAt: updatedAt()
3935
- }, (table) => [primaryKey({ columns: [
3936
- table.orgId,
3937
- table.environment,
3938
- table.key
3939
- ] })]);
3940
3817
 
3941
3818
  //#endregion
3942
3819
  //#region ../../packages/db/src/schema/org-settings.ts
3943
3820
  /**
3944
- * Per-org settings: what is shared across both environments. A row exists
3945
- * only once something has been set: no row means every default applies,
3946
- * which readers fill in (same convention as journey_states). That keeps a
3947
- * fresh org zero-config and keeps the write path from having to seed rows
3948
- * it does not own.
3821
+ * The ingestion policy for catalog governance. Single source of truth:
3822
+ * @cowliss/shared/governance derives INGESTION_POLICIES from
3823
+ * ingestionPolicyEnum.enumValues, so the enum and the settings contract
3824
+ * cannot drift.
3825
+ */
3826
+ const ingestionPolicyEnum = pgEnum("ingestion_policy", ["permissive", "strict"]);
3827
+ /**
3828
+ * Per-org settings. A row exists only once something has been set: no row
3829
+ * means every default applies, which readers fill in (same convention as
3830
+ * journey_states). That keeps a fresh org zero-config and keeps the write
3831
+ * path from having to seed rows it does not own.
3949
3832
  *
3950
- * The per-environment settings (ingestion policy, event retention) live in
3951
- * environment_settings; this row holds the org-wide facts: the sending
3952
- * kill switch, the allowance override, and Clerk's cached profile.
3833
+ * Every value here is the organization's:
3834
+ * the ingestion policy, the event retention window, the sending kill
3835
+ * switch, the allowance override, and Clerk's cached profile.
3953
3836
  *
3954
3837
  * `slug` and `name` are the exception to "only what Cowliss owns": they are
3955
- * Clerk's, cached here by the API's org-sync middleware because the worker
3956
- * has no Clerk client and still has to build the shared fallback sender
3957
- * `<org-slug>@<shared domain>` with the org's name as the from-name.
3838
+ * Clerk's, cached here by the API's org-sync middleware, and they are what a
3839
+ * template test-send composes its from-address out of (`orgFromAddress`).
3958
3840
  */
3959
3841
  const orgSettings = pgTable("org_settings", {
3960
3842
  orgId: text("org_id").primaryKey(),
3961
3843
  /**
3962
3844
  * The Clerk org's slug and display name, synced by the API on request.
3963
- * Null until a member of the org has hit the dashboard API once; the
3964
- * shared fallback sender falls back to an orgId-derived local part and the
3965
- * tool name until then.
3845
+ * Null until a member of the org has hit the dashboard API once, when
3846
+ * `orgFromAddress` falls back to an orgId-derived local part and the tool
3847
+ * name.
3966
3848
  */
3967
3849
  slug: text("slug"),
3968
3850
  name: text("name"),
3969
3851
  /**
3852
+ * Catalog governance: permissive accepts quarantined (unclassified) names
3853
+ * and observes them; strict routes them to the review queue instead of
3854
+ * applying them. One policy for the organization, applied to every write.
3855
+ */
3856
+ ingestionPolicy: ingestionPolicyEnum("ingestion_policy").notNull().default("permissive"),
3857
+ /**
3858
+ * The ClickHouse TTL window for the org's events, in days. Null means the
3859
+ * default (18 months, EVENT_RETENTION_DAYS.production in
3860
+ * packages/shared); there is no "forever", and the API refuses a value
3861
+ * above that cap. Development events are trimmed at the shorter
3862
+ * development cap whatever this says: the rule is
3863
+ * `effectiveRetentionDays` in packages/shared.
3864
+ */
3865
+ eventRetentionDays: integer("event_retention_days"),
3866
+ /**
3970
3867
  * The operator-set monthly free allowance override, in micro-dollars,
3971
3868
  * synced from the Clerk org's `publicMetadata.freeAllowanceMicros` by the
3972
3869
  * same org-sync read that caches slug and name. Null (the default) means
@@ -4019,8 +3916,7 @@ const insertOrgSettingsSchema = createInsertSchema(orgSettings);
4019
3916
  /**
4020
3917
  * An org's cow project: the folder a developer runs `cow init` in, and the
4021
3918
  * thing pushes belong to. An org may hold several, one per repo, each with
4022
- * its own release sequence, its own current release per environment, and its
4023
- * own slice of the deployed journey rows. The name is how `cow.json` picks
3919
+ * its own release sequence and its own slice of the deployed journey rows. The name is how `cow.json` picks
4024
3920
  * one, so it is unique within the org.
4025
3921
  */
4026
3922
  const projects = pgTable("projects", {
@@ -4086,14 +3982,12 @@ const pushArtifacts = pgTable("push_artifacts", {
4086
3982
  //#endregion
4087
3983
  //#region ../../packages/db/src/schema/violations.ts
4088
3984
  /**
4089
- * Violations: one row per (orgId, environment, kind, name) observed in
4090
- * ingestion that the tracking plan catalog does not define. Observe and
4091
- * flag, never reject: the payload is always written, the violation only
4092
- * records the governance gap. The catalog is org-wide; the sightings are
4093
- * per environment, so a name a dev box invented never shows up as a
4094
- * production violation.
3985
+ * Violations: one row per (orgId, kind, name) observed in ingestion that
3986
+ * the tracking plan catalog does not define. Observe and flag, never
3987
+ * reject: the payload is always written, the violation only records the
3988
+ * governance gap.
4095
3989
  *
4096
- * Uniqueness on (orgId, environment, kind, name) dedupes repeat sightings:
3990
+ * Uniqueness on (orgId, kind, name) dedupes repeat sightings:
4097
3991
  * an unknown event fired a thousand times is one open violation whose
4098
3992
  * lastSeenAt keeps bumping, not a thousand rows. The payload captures the first observed shape
4099
3993
  * (inferred property types for events, inferred type for traits) so resolve
@@ -4111,7 +4005,6 @@ const violationStatusEnum = pgEnum("violation_status", [
4111
4005
  const violations$1 = pgTable("violations", {
4112
4006
  id: text("id").primaryKey(),
4113
4007
  orgId: text("org_id").notNull(),
4114
- environment: environmentEnum("environment").notNull(),
4115
4008
  kind: violationKindEnum("kind").notNull(),
4116
4009
  name: text("name").notNull(),
4117
4010
  payload: jsonb("payload").$type().notNull(),
@@ -4136,7 +4029,7 @@ const violations$1 = pgTable("violations", {
4136
4029
  mode: "date",
4137
4030
  precision: 3
4138
4031
  })
4139
- }, (table) => [index("violations_org_id_environment_idx").on(table.orgId, table.environment), uniqueIndex("violations_org_env_kind_name_unique").on(table.orgId, table.environment, table.kind, table.name)]);
4032
+ }, (table) => [index("violations_org_id_idx").on(table.orgId), uniqueIndex("violations_org_kind_name_unique").on(table.orgId, table.kind, table.name)]);
4140
4033
  const selectViolationSchema = createSelectSchema(violations$1);
4141
4034
  const insertViolationSchema = createInsertSchema(violations$1);
4142
4035
 
@@ -4145,7 +4038,6 @@ const insertViolationSchema = createInsertSchema(violations$1);
4145
4038
  const quarantineEntries = pgTable("quarantine_entries", {
4146
4039
  id: text("id").primaryKey(),
4147
4040
  orgId: text("org_id").notNull(),
4148
- environment: environmentEnum("environment").notNull(),
4149
4041
  kind: violationKindEnum("kind").notNull(),
4150
4042
  name: text("name").notNull(),
4151
4043
  appId: text("app_id").notNull(),
@@ -4159,20 +4051,24 @@ const quarantineEntries = pgTable("quarantine_entries", {
4159
4051
  }).notNull(),
4160
4052
  createdAt: createdAt(),
4161
4053
  updatedAt: updatedAt()
4162
- }, (table) => [index("quarantine_entries_org_kind_name_idx").on(table.orgId, table.kind, table.name), index("quarantine_entries_org_env_created_idx").on(table.orgId, table.environment, table.createdAt)]);
4054
+ }, (table) => [index("quarantine_entries_org_kind_name_idx").on(table.orgId, table.kind, table.name), index("quarantine_entries_org_created_idx").on(table.orgId, table.createdAt)]);
4163
4055
  const selectQuarantineEntrySchema = createSelectSchema(quarantineEntries);
4164
4056
  const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4165
4057
 
4166
4058
  //#endregion
4167
4059
  //#region ../../packages/db/src/schema/segments.ts
4168
4060
  /**
4169
- * Segments: dashboard-managed audience definitions. Membership is computed,
4170
- * never hand-maintained: the engine in packages/segments recomputes on every
4171
- * write and materializes the result into segment_members.
4061
+ * Segments: membership rules over profiles. Membership is computed, never
4062
+ * hand-maintained: the engine in packages/segments recomputes on every write
4063
+ * and materializes the result into segment_members.
4172
4064
  *
4173
- * The definition is shared by the org's environments and declares which of
4174
- * them it works on: `environments` defaults to both, and the recompute
4175
- * evaluates a definition only for profiles in a declared environment.
4065
+ * A row is either standalone (built in the dashboard, `journey_key` null) or
4066
+ * owned by one journey whose trigger inlined its definition (ADR 0016). An
4067
+ * owned row is named after its journey's key, is written only by the push,
4068
+ * and goes when the journey does — which is what the composite foreign key
4069
+ * says: it points at `journeys(org_id, key)` and cascades. A null
4070
+ * `journey_key` satisfies it vacuously (MATCH SIMPLE), so standalone rows
4071
+ * are unconstrained.
4176
4072
  *
4177
4073
  * Timestamps use millisecond precision, same rationale as apps: JS Dates
4178
4074
  * carry ms only and cursor pagination compares createdAt for equality.
@@ -4183,18 +4079,24 @@ const segments$1 = pgTable("segments", {
4183
4079
  name: text("name").notNull(),
4184
4080
  description: text("description"),
4185
4081
  definition: jsonb("definition").$type().notNull(),
4186
- /** The environments this definition works on; the enum is the check. */
4187
- environments: environmentEnum("environments").array().notNull().default(["development", "production"]),
4082
+ /** The journey that owns this row, or null for a standalone segment. */
4083
+ journeyKey: text("journey_key"),
4188
4084
  createdAt: createdAt(),
4189
4085
  updatedAt: updatedAt()
4190
- }, (table) => [index("segments_org_id_idx").on(table.orgId), uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name)]);
4086
+ }, (table) => [
4087
+ index("segments_org_id_idx").on(table.orgId),
4088
+ uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name),
4089
+ foreignKey({
4090
+ columns: [table.orgId, table.journeyKey],
4091
+ foreignColumns: [journeys$1.orgId, journeys$1.key],
4092
+ name: "segments_journey_fk"
4093
+ }).onDelete("cascade")
4094
+ ]);
4191
4095
  /**
4192
- * Materialized membership: one row per (segment, environment, profile)
4193
- * currently in the segment. Deleting a segment drops its members with it,
4194
- * and erasing a profile drops its memberships, so neither can leave orphaned
4195
- * membership behind. A definition is shared by the environments it declares,
4196
- * so the environment is part of the key: dropping an environment from a
4197
- * definition drops that environment's rows and leaves the other's standing.
4096
+ * Materialized membership: one row per (segment, profile) currently in the
4097
+ * segment. Deleting a segment drops its members with it, and erasing a
4098
+ * profile drops its memberships, so neither can leave orphaned membership
4099
+ * behind.
4198
4100
  *
4199
4101
  * The (orgId, profileId) index is the recompute lookup: every write asks
4200
4102
  * "which of this org's segments does this profile currently belong to?"
@@ -4203,19 +4105,13 @@ const segments$1 = pgTable("segments", {
4203
4105
  const segmentMembers = pgTable("segment_members", {
4204
4106
  segmentId: text("segment_id").notNull().references(() => segments$1.id, { onDelete: "cascade" }),
4205
4107
  orgId: text("org_id").notNull(),
4206
- /** The member profile's environment; always the profile's own. */
4207
- environment: environmentEnum("environment").notNull(),
4208
4108
  profileId: text("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
4209
4109
  enteredAt: timestamp("entered_at", {
4210
4110
  withTimezone: true,
4211
4111
  mode: "date",
4212
4112
  precision: 3
4213
4113
  }).notNull().defaultNow()
4214
- }, (table) => [primaryKey({ columns: [
4215
- table.segmentId,
4216
- table.environment,
4217
- table.profileId
4218
- ] }), index("segment_members_org_id_profile_id_idx").on(table.orgId, table.profileId)]);
4114
+ }, (table) => [primaryKey({ columns: [table.segmentId, table.profileId] }), index("segment_members_org_id_profile_id_idx").on(table.orgId, table.profileId)]);
4219
4115
  const selectSegmentSchema = createSelectSchema(segments$1);
4220
4116
  const insertSegmentSchema = createInsertSchema(segments$1);
4221
4117
 
@@ -4231,10 +4127,16 @@ const insertSegmentSchema = createInsertSchema(segments$1);
4231
4127
  * emits no domain events, so the mirror refreshes by pulling
4232
4128
  * `GetEmailIdentity` on a schedule and on the manual re-check button.
4233
4129
  *
4234
- * The unique index on `domain` is GLOBAL, not per org: it is the
4235
- * anti-squatting rule from the spec. Once one org claims a domain string, no
4236
- * other org can claim it, and the cross-org parent/child guard (a query, not
4237
- * a constraint) covers the subdomain half of the same rule.
4130
+ * A domain is claimed when it VERIFIES, not when it is added (ADR 0021).
4131
+ * Any number of orgs may hold the same unverified string; the partial unique
4132
+ * below is what makes at most one of them verified, and so the owner. The
4133
+ * cross-org parent/child guard (a query, not a constraint) refuses a claim
4134
+ * under or over a name another org already verified.
4135
+ *
4136
+ * SES keys ONE identity by the domain name, so its DKIM records, and the
4137
+ * SUCCESS it reports for them, are shared by every org holding the name.
4138
+ * They therefore prove that SOMEBODY controls the DNS, never which org, and
4139
+ * `challenge_token` is the part that is per org: the claim needs both.
4238
4140
  */
4239
4141
  /**
4240
4142
  * SES's identity status vocabulary, lower-cased like every other enum here.
@@ -4242,13 +4144,19 @@ const insertSegmentSchema = createInsertSchema(segments$1);
4242
4144
  * all report the same five words. `temporary_failure` is SES's retryable
4243
4145
  * state, which is neither verified nor a dead end, so it is kept distinct
4244
4146
  * from `failed`.
4147
+ *
4148
+ * `blocked` is the one value SES never says: the DNS checks out, but another
4149
+ * organization has already verified this name or a parent or child of it, so
4150
+ * the claim cannot be granted here (ADR 0021). It is a terminal-looking
4151
+ * state that clears itself, because every refresh re-asks the question.
4245
4152
  */
4246
4153
  const senderDomainStatusEnum = pgEnum("sender_domain_status", [
4247
4154
  "not_started",
4248
4155
  "pending",
4249
4156
  "success",
4250
4157
  "failed",
4251
- "temporary_failure"
4158
+ "temporary_failure",
4159
+ "blocked"
4252
4160
  ]);
4253
4161
  const senderDomains = pgTable("sender_domains", {
4254
4162
  id: text("id").primaryKey(),
@@ -4262,12 +4170,25 @@ const senderDomains = pgTable("sender_domains", {
4262
4170
  spfStatus: text("spf_status"),
4263
4171
  /**
4264
4172
  * The per-domain click-tracking toggle, Cowliss's own setting: the send
4265
- * picks SES's tracked configuration set when it is on. Off by default
4266
- * and always off for the shared fallback domain (which has no row here
4267
- * at all): one setting cannot serve every org sharing it.
4173
+ * picks SES's tracked configuration set when it is on. Off by default:
4174
+ * link rewriting is a thing an org opts into.
4268
4175
  */
4269
4176
  clickTracking: boolean("click_tracking").notNull().default(false),
4270
4177
  dnsRecords: jsonb("dns_records").$type().notNull().default([]),
4178
+ /**
4179
+ * This org's own proof of control, published as a TXT record under the
4180
+ * domain. Random per row and never re-issued, so two orgs holding the
4181
+ * same name publish different values and only the one that controls the
4182
+ * DNS can publish its own. Verified once, at the moment the claim is
4183
+ * granted; a live sender never loses its domain to one failed lookup.
4184
+ */
4185
+ challengeToken: text("challenge_token").notNull().default(sql`replace(gen_random_uuid()::text, '-', '')`),
4186
+ /** When this org's own TXT record was last seen. Null until it is. */
4187
+ challengeVerifiedAt: timestamp("challenge_verified_at", {
4188
+ withTimezone: true,
4189
+ mode: "date",
4190
+ precision: 3
4191
+ }),
4271
4192
  /** When the mirror last asked SES; drives the refresh sweep. */
4272
4193
  lastCheckedAt: timestamp("last_checked_at", {
4273
4194
  withTimezone: true,
@@ -4276,7 +4197,11 @@ const senderDomains = pgTable("sender_domains", {
4276
4197
  }),
4277
4198
  createdAt: createdAt(),
4278
4199
  updatedAt: updatedAt()
4279
- }, (table) => [uniqueIndex("sender_domains_domain_unique").on(table.domain), index("sender_domains_org_id_created_at_idx").on(table.orgId, table.createdAt)]);
4200
+ }, (table) => [
4201
+ uniqueIndex("sender_domains_org_id_domain_unique").on(table.orgId, table.domain),
4202
+ uniqueIndex("sender_domains_verified_domain_unique").on(table.domain).where(sql`${table.status} = 'success'`),
4203
+ index("sender_domains_org_id_created_at_idx").on(table.orgId, table.createdAt)
4204
+ ]);
4280
4205
  const selectSenderDomainSchema = createSelectSchema(senderDomains);
4281
4206
  const insertSenderDomainSchema = createInsertSchema(senderDomains);
4282
4207
 
@@ -4287,14 +4212,14 @@ const insertSenderDomainSchema = createInsertSchema(senderDomains);
4287
4212
  * source is a way data reaches it, so "App A via Clerk" and "App A via the
4288
4213
  * SDK" land on the same app and therefore on the same profiles.
4289
4214
  *
4290
- * Every app has exactly one source of kind `api`, the first-party SDK and
4291
- * HTTP path, created with the app. It has an empty config and no credential,
4292
- * and archiving it is the app's ingestion kill switch. Provider kinds sit
4293
- * beside it.
4215
+ * Every app has one source of kind `api`, the first-party SDK and HTTP
4216
+ * path, created with the app. It has an empty config and no credential, and
4217
+ * archiving it is that app's ingestion kill switch. Provider kinds sit
4218
+ * beside it, singleton per app.
4294
4219
  *
4295
4220
  * `config` holds the per-kind source configuration and `signingSecret` the
4296
4221
  * credential of the one kind that has one today (Clerk's webhook signing
4297
- * secret), stored write-only (the destinations show-once pattern: never on a
4222
+ * secret), stored write-only (the webhooks show-once pattern: never on a
4298
4223
  * DTO). `kind` is immutable after creation; the webhook route dispatches on
4299
4224
  * it. `lastReceivedAt` is stamped per accepted delivery so the dashboard can
4300
4225
  * tell a wired-up source from a silent one.
@@ -4529,20 +4454,44 @@ const selectWalletTopupSchema = createSelectSchema(walletTopups);
4529
4454
  const insertWalletTopupSchema = createInsertSchema(walletTopups);
4530
4455
 
4531
4456
  //#endregion
4532
- //#region ../../packages/shared/src/environments.ts
4457
+ //#region ../../packages/db/src/schema/webhooks.ts
4533
4458
  /**
4534
- * The environment on the wire: the source's `environment` field, the
4535
- * `X-Cow-Environment` header admin calls select with, and the
4536
- * `environment` column every per-environment DTO carries.
4537
- */
4538
- const environmentSchema = z.enum(ENVIRONMENTS);
4539
- /**
4540
- * The environments a shared definition (a segment, a journey) declares it
4541
- * works on: a non-empty list of distinct values. A definition declaring none
4542
- * would be dead code with a row behind it, and a repeated value is a typo
4543
- * rather than an intent, which is the rule `defineJourney` applies too.
4459
+ * Webhooks: named URLs that journeys POST to by name. A webhook is the
4460
+ * organization's: the name is unique per org, and a journey step naming
4461
+ * `webhook("crm")` resolves to the same row for every execution. A developer
4462
+ * who wants a local receiver creates a second webhook under another name.
4463
+ * Ms-precision timestamps, as everywhere, so a cursor round-trips.
4464
+ *
4465
+ * WEBHOOK_PAYLOAD_VERSION (packages/shared) pins the webhook payload shape;
4466
+ * the column records the version each webhook was created against.
4467
+ * signingSecret is the Standard Webhooks signing secret, generated at
4468
+ * creation and shown once — the API DTO omits it after the create response.
4544
4469
  */
4545
- const environmentsSchema = z.array(environmentSchema).min(1, "at least one environment is required").refine((values) => new Set(values).size === values.length, { message: "environments must not repeat" });
4470
+ const webhooks$1 = pgTable("webhooks", {
4471
+ id: text("id").primaryKey(),
4472
+ orgId: text("org_id").notNull(),
4473
+ name: text("name").notNull(),
4474
+ config: jsonb("config").$type().notNull(),
4475
+ webhookPayloadVersion: integer("webhook_payload_version").notNull().default(1),
4476
+ signingSecret: text("signing_secret"),
4477
+ /**
4478
+ * Auto-disable state, mirroring how receivers treat us: the webhook send
4479
+ * activity bumps the counter once per failed delivery attempt (not once
4480
+ * per Temporal retry) and stamps disabledAt at the threshold. A disabled
4481
+ * webhook records `skipped_disabled` until an admin clears it; a
4482
+ * successful delivery resets the counter.
4483
+ */
4484
+ consecutiveFailures: integer("consecutive_failures").notNull().default(0),
4485
+ disabledAt: timestamp("disabled_at", {
4486
+ withTimezone: true,
4487
+ mode: "date",
4488
+ precision: 3
4489
+ }),
4490
+ createdAt: createdAt(),
4491
+ updatedAt: updatedAt()
4492
+ }, (table) => [index("webhooks_org_id_idx").on(table.orgId), uniqueIndex("webhooks_org_id_name_unique").on(table.orgId, table.name)]);
4493
+ const selectWebhookSchema = createSelectSchema(webhooks$1);
4494
+ const insertWebhookSchema = createInsertSchema(webhooks$1);
4546
4495
 
4547
4496
  //#endregion
4548
4497
  //#region ../../packages/shared/src/apps.ts
@@ -4553,10 +4502,10 @@ const environmentsSchema = z.array(environmentSchema).min(1, "at least one envir
4553
4502
  * JSON.stringify already emits it for Date values.
4554
4503
  *
4555
4504
  * An app is the attribution unit and nothing else: it carries no kind, no
4556
- * config, and no credential. It belongs to one environment, chosen at
4557
- * creation and immutable. The two extra fields are read-model facts about
4558
- * its sources (see sources.ts), which is what the dashboard's list needs to
4559
- * say whether an app has a pipe wired up without a request per card.
4505
+ * config and no credential (its sources carry those, see
4506
+ * sources.ts). The two extra fields are read-model facts about those
4507
+ * sources, which is what the dashboard's list needs to say whether an app
4508
+ * has a pipe wired up without a request per card.
4560
4509
  */
4561
4510
  const appSchema = selectAppSchema.extend({
4562
4511
  createdAt: z.iso.datetime(),
@@ -4568,11 +4517,7 @@ const appSchema = selectAppSchema.extend({
4568
4517
  lastReceivedAt: z.iso.datetime().nullable()
4569
4518
  });
4570
4519
  const nameSchema$2 = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
4571
- const createAppBodySchema = z.object({ data: z.object({
4572
- name: nameSchema$2,
4573
- /** Immutable after creation; production when omitted. */
4574
- environment: environmentSchema.default("production")
4575
- }) });
4520
+ const createAppBodySchema = z.object({ data: z.object({ name: nameSchema$2 }) });
4576
4521
  const updateAppBodySchema = z.object({ data: z.object({
4577
4522
  name: nameSchema$2.optional(),
4578
4523
  status: z.literal("archived").optional()
@@ -4674,6 +4619,92 @@ const identifierDtoSchema = z.object({
4674
4619
  createdAt: z.iso.datetime()
4675
4620
  });
4676
4621
 
4622
+ //#endregion
4623
+ //#region ../../packages/shared/src/segment-definition.ts
4624
+ /**
4625
+ * A segment definition: a flat list of predicates over traits and event
4626
+ * history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
4627
+ * prototype, and a flat list keeps the pure evaluator a fold.
4628
+ *
4629
+ * `appId` scopes the EVENT side of a definition only. Traits are
4630
+ * per-profile and profiles merge across apps, so there is nothing
4631
+ * app-shaped to filter on the trait side.
4632
+ *
4633
+ * A definition and the membership it produces are both the
4634
+ * organization's.
4635
+ *
4636
+ * Apart from `./segments` because a journey's trigger carries a definition
4637
+ * and the trigger schema is bundled into the wasm guest, where an edge to
4638
+ * `@cowliss/db` (which `./segments` has, for the row schema) is a hard
4639
+ * bundler failure. Nothing here imports anything but zod.
4640
+ *
4641
+ * The object is strict: a definition holding the retired `sourceId` key
4642
+ * (which meant the app) fails loudly instead of parsing as an unfiltered
4643
+ * definition that evaluates over every app. There is deliberately no pipe
4644
+ * filter here — filtering by source is a later feature, and accepting one
4645
+ * now would make a stale `sourceId` parse as a filter on a pipe that does
4646
+ * not exist and silently match nothing.
4647
+ */
4648
+ const SEGMENT_TRAIT_OPS = [
4649
+ "eq",
4650
+ "neq",
4651
+ "gt",
4652
+ "gte",
4653
+ "lt",
4654
+ "lte",
4655
+ "exists",
4656
+ "notExists",
4657
+ "contains"
4658
+ ];
4659
+ /** Operators that read no comparison value: presence of the key is the test. */
4660
+ const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
4661
+ const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
4662
+ const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
4663
+ kind: z.literal("trait"),
4664
+ name: predicateNameSchema,
4665
+ op: z.enum(SEGMENT_TRAIT_OPS),
4666
+ /**
4667
+ * Compared against the stored trait, which is `unknown` because
4668
+ * identify accepts arbitrary JSON. The evaluator coerces both sides
4669
+ * before comparing, so authors here (CLI, MCP, dashboard) get the
4670
+ * form-field-friendly reading rather than strict JSON equality:
4671
+ * numeric-looking strings are compared as numbers for eq/neq and for
4672
+ * ordering ("150" matches 150, and orders like it), and "true"/"false"
4673
+ * are compared as booleans for eq/neq, trimmed and case-insensitively
4674
+ * (" TRUE " reads as true). Coercion needs both sides to agree on a
4675
+ * type: "0" never equals false. The numeric net is as wide as
4676
+ * `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
4677
+ * matters most for opaque ids: "007" is authored as the number 7.
4678
+ *
4679
+ * contains reads three ways. Against an array trait it is membership
4680
+ * under that same equality, so "true" matches `[true]` and "1" matches
4681
+ * `[1, 2]`. Against a string trait it is a plain substring search with
4682
+ * no coercion, since substrings only mean something between strings.
4683
+ * Against anything else it never matches. Ordering never coerces
4684
+ * booleans.
4685
+ */
4686
+ value: z.unknown().optional()
4687
+ }), z.object({
4688
+ kind: z.literal("event"),
4689
+ name: predicateNameSchema,
4690
+ op: z.enum(["performed", "notPerformed"]),
4691
+ /** How many matching events the predicate counts as "performed". */
4692
+ atLeast: z.number().int().min(1).default(1),
4693
+ /** Rolling window, relative to evaluation time; absent means all history. */
4694
+ withinDays: z.number().int().min(1).optional()
4695
+ })]).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" });
4696
+ const appIdSchema = z.string().trim().min(1);
4697
+ const segmentDefinitionSchema = z.strictObject({
4698
+ match: z.enum(["all", "any"]).default("all"),
4699
+ /**
4700
+ * Optional app filter over the event side; null/absent spans apps.
4701
+ * A list matches any of the named apps, which is how one definition
4702
+ * names the development and the production id of the same app.
4703
+ */
4704
+ appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
4705
+ predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
4706
+ });
4707
+
4677
4708
  //#endregion
4678
4709
  //#region ../../packages/shared/src/journeys-v2/manifest.ts
4679
4710
  /**
@@ -4683,6 +4714,34 @@ const identifierDtoSchema = z.object({
4683
4714
  * changed, and stores that entry on the version it creates.
4684
4715
  */
4685
4716
  /**
4717
+ * A duration as journey code writes it: an ms-style string ("2d") or
4718
+ * milliseconds. It lives here rather than with the guest protocol because a
4719
+ * manifest carries one too (a journey's enrollment cooldown) and `./guest`
4720
+ * already imports this module, so the other direction would be a cycle.
4721
+ */
4722
+ const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
4723
+ const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
4724
+ const DURATION_UNIT_MS = {
4725
+ ms: 1,
4726
+ s: 1e3,
4727
+ m: 6e4,
4728
+ h: 36e5,
4729
+ d: 864e5,
4730
+ w: 6048e5
4731
+ };
4732
+ /**
4733
+ * A duration in milliseconds. The simulator's virtual clock and the runner's
4734
+ * timers both need it, and neither may pull in Temporal's `msToNumber` (one
4735
+ * runs in the CLI, the other inside workflow code).
4736
+ */
4737
+ function parseDuration(duration) {
4738
+ if (typeof duration === "number") return duration;
4739
+ const match = DURATION_PATTERN.exec(duration.trim());
4740
+ if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
4741
+ const unit = DURATION_UNIT_MS[match[2]];
4742
+ return Math.round(Number(match[1]) * unit);
4743
+ }
4744
+ /**
4686
4745
  * A journey or template key: the file basename under `journeys/` or
4687
4746
  * `emails/`, kebab-case and unique across the project. Becomes part of the
4688
4747
  * Temporal workflow id and travels in the journey chain, so it stays short.
@@ -4703,8 +4762,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
4703
4762
  */
4704
4763
  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)]);
4705
4764
  /**
4706
- * A consent purpose key: camelCase, matching the fixed `emailMarketing` and
4707
- * `dataProcessing`. Purposes are keys in the `consent` map a customer reads
4765
+ * A consent purpose key: camelCase, matching the reserved `marketing` and
4766
+ * `transactional`. Purposes are keys in the `consent` map a customer reads
4708
4767
  * on their own profile, which is why they are not the kebab-case of a
4709
4768
  * journey key.
4710
4769
  */
@@ -4719,22 +4778,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
4719
4778
  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)");
4720
4779
  /**
4721
4780
  * One purpose a project declares in `cow.json` (spec: Decisions). A declared
4722
- * purpose is marketing-class and sits under the `emailMarketing` umbrella,
4723
- * so `denied` is the only default it may carry: the purpose is absent on
4724
- * every profile that already exists, and a granted default would answer for
4725
- * all of them at once.
4781
+ * purpose is marketing-class and sits under the `marketing` umbrella, so
4782
+ * `denied` is the only default it may carry: the purpose is absent on every
4783
+ * profile that already exists, and a granted default would answer for all of
4784
+ * them at once.
4726
4785
  *
4727
4786
  * The field stays required rather than disappearing, so every `cow.json` and
4728
- * every stored manifest written before this still parses, and the column
4729
- * behind it still holds `granted` for the seeded `dataProcessing` row: this
4730
- * is a refusal at declaration time, not a narrower storage shape.
4787
+ * every stored manifest written before this still parses: this is a refusal
4788
+ * at declaration time, not a narrower storage shape.
4731
4789
  *
4732
- * The two fixed purposes cannot be declared. They are seeded for every org
4733
- * and owned by no project, so a project redeclaring one would be renaming
4734
- * the master switch every other project's journeys hang off.
4790
+ * Neither reserved purpose can be declared: `marketing` is the master switch
4791
+ * every other project's journeys hang off, and `transactional` is not a
4792
+ * consent purpose at all, so declaring it would promise a switch that no
4793
+ * send ever reads.
4735
4794
  */
4736
4795
  const declaredPurposeSchema = z.strictObject({
4737
- key: consentPurposeKeySchema.refine((key) => !CONSENT_PURPOSES.includes(key), `${CONSENT_PURPOSES.join(" and ")} are fixed purposes and cannot be declared`),
4796
+ key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
4738
4797
  /** What the dashboard and the account modal render beside the switch. */
4739
4798
  label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
4740
4799
  /** What the purpose means for a profile whose map does not answer it. */
@@ -4756,28 +4815,65 @@ const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 2
4756
4815
  const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
4757
4816
  /**
4758
4817
  * What starts a journey: an event (optionally narrowed to an app id or a
4759
- * list of them) or a segment entry. The registry DTO in `../journeys`
4760
- * reuses it.
4761
- *
4762
- * Both members are strict, so a journey holding the retired `source` key
4763
- * fails to compile instead of silently triggering on every app.
4764
- * There is deliberately no pipe filter: a trigger narrows by the app the
4765
- * write is attributed to, the same token a segment definition names.
4818
+ * list of them), or entry into the segment the trigger itself describes.
4819
+ * The registry DTO in `../journeys` reuses it.
4820
+ *
4821
+ * A segment trigger carries the predicate list, not a name: the push
4822
+ * materializes one segment row per journey that inlines a definition, owned
4823
+ * by the journey and named after its key, so the segment exists because the
4824
+ * journey exists and there is no order to get wrong (ADR 0016). The
4825
+ * definition is data — validated here, carried on the version, never
4826
+ * compiled and never executed.
4827
+ *
4828
+ * Both members are strict, so a journey holding the retired `source` key, or
4829
+ * the retired `{ segment: "name" }` form, fails to build instead of silently
4830
+ * triggering on every app or on nothing. There is deliberately no pipe
4831
+ * filter: a trigger narrows by the app the write is attributed to, the same
4832
+ * token a segment definition names.
4766
4833
  */
4767
4834
  const triggerSchema = z.union([z.strictObject({
4768
4835
  event: patternSchema,
4769
4836
  appId: patternSchema.optional()
4770
- }), z.strictObject({ segment: z.string().min(1) })]);
4837
+ }), z.strictObject({ segment: segmentDefinitionSchema })]);
4838
+ /**
4839
+ * The address half of a from-header: a local part, an `@`, and a dotted
4840
+ * domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
4841
+ * address literals): every character it refuses would have to be quoted or
4842
+ * escaped to survive a header, and none of them belong in an address a
4843
+ * journey sends from.
4844
+ */
4845
+ const FROM_ADDRESS = /^[^\s@<>",;]+@([^\s@<>",;.]+(?:\.[^\s@<>",;.]+)+)$/;
4846
+ /**
4847
+ * Parse a journey's or a send's `from`: `addr@domain`, or
4848
+ * `Name <addr@domain>` with the name optionally quoted. Null when it is
4849
+ * neither, which is what `cow build` refuses on and what the send-time
4850
+ * domain gate turns into a skip.
4851
+ *
4852
+ * One parser, and the send facade reuses it. A control character anywhere is
4853
+ * a refusal rather than something to strip: it survives quoting and would
4854
+ * inject a second header.
4855
+ */
4856
+ function parseFromAddress(from) {
4857
+ const text = from.trim();
4858
+ if (/\p{Cc}/u.test(text)) return null;
4859
+ const angled = /^(.*?)\s*<([^<>]*)>$/.exec(text);
4860
+ const address = (angled?.[2] ?? text).trim();
4861
+ const domain = FROM_ADDRESS.exec(address)?.[1];
4862
+ if (!domain) return null;
4863
+ const name = (angled?.[1] ?? "").trim().replace(/^"(.*)"$/s, "$1").replace(/\\(.)/g, "$1").trim();
4864
+ return {
4865
+ ...name ? { name } : {},
4866
+ address,
4867
+ domain: domain.toLowerCase()
4868
+ };
4869
+ }
4771
4870
  /**
4772
- * A destination's name: the token a journey addresses it by, in a
4773
- * `send.webhook` call or a journey's `senderIdentity`.
4774
- *
4775
- * Defined here rather than beside the destinations contract, and imported
4776
- * from here by it, because a journey manifest names one and the guest layer
4777
- * is bundled into every tenant module: importing it the other way round
4778
- * would pull the Drizzle destinations table into all of them.
4871
+ * The address a journey or one send leaves as: a string, not the name of a
4872
+ * configured row (ADR 0014). The domain is checked against the
4873
+ * organization's verified ones at push time and again at send time; the
4874
+ * shape is all that is checked here, because it is all a build can know.
4779
4875
  */
4780
- const destinationNameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
4876
+ 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>\")" });
4781
4877
  /** A content address: `sha256:` plus the lowercase hex digest. */
4782
4878
  const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "digest must be sha256:<64 hex>");
4783
4879
  /**
@@ -4813,13 +4909,6 @@ const SPINE_ENTRY_NAMES = [
4813
4909
  const spineEntrySchema = z.object({
4814
4910
  name: z.enum(SPINE_ENTRY_NAMES),
4815
4911
  detail: z.string().max(200).optional(),
4816
- /**
4817
- * The sender identity a `send.email` call named for itself, overriding
4818
- * the journey's own. Present only when the author wrote one on the call,
4819
- * which is what lets the deploy warning and the journey detail page name
4820
- * the override without re-reading the code.
4821
- */
4822
- senderIdentity: z.string().max(100).optional(),
4823
4912
  /** A `waitForEvent` timeout, as the author wrote it. */
4824
4913
  timeout: z.string().max(50).optional(),
4825
4914
  get steps() {
@@ -4831,10 +4920,10 @@ const spineEntrySchema = z.object({
4831
4920
  }).meta({ id: "JourneySpineEntry" });
4832
4921
  /**
4833
4922
  * One journey in a stored manifest. A plain (non-strict) object on purpose:
4834
- * a manifest pushed before the author's rollout gate went away still carries
4835
- * `environments`, and every stored manifest has to keep parsing for good
4836
- * (ADR 0011). Zod strips the key, and the journey runs wherever the
4837
- * per-environment enabled flag says it runs, which is the one gate there is.
4923
+ * a manifest pushed before the author's rollout gate went away still
4924
+ * carries keys that no longer exist, and every stored manifest has to keep
4925
+ * parsing for good (ADR 0011). Zod strips them, and the journey runs when
4926
+ * its `enabled` flag says so, which is the one gate there is.
4838
4927
  */
4839
4928
  const manifestJourneySchema = z.object({
4840
4929
  key: journeyKeySchema,
@@ -4848,16 +4937,37 @@ const manifestJourneySchema = z.object({
4848
4937
  */
4849
4938
  purpose: consentPurposeKeySchema,
4850
4939
  /**
4851
- * The sender identity every `send.email` in this journey goes out as,
4852
- * unless the call names its own. A name, never a `dst_` id: an org has one
4853
- * row per environment, so an id would send in production and fail in
4854
- * development, which is the one thing a journey must not do.
4855
- *
4856
- * Optional here and required at `defineJourney`, exactly like `purposes`: a
4857
- * version pushed before the field existed carries none and its stored
4858
- * manifest still parses. The author's build is where the error is useful.
4940
+ * How often one recipient may enter. Enrollment derives from the purpose
4941
+ * (ADR 0015), so the only thing an author writes is how long after a
4942
+ * completed run the journey re-opens: absent means once, ever. Refused on
4943
+ * a transactional journey, which enrolls on every trigger — see
4944
+ * `manifestSchema` below, where the cross-field check lives (a refinement
4945
+ * on this object would break the `.pick()` the guest SDK and the guest
4946
+ * protocol both take of it).
4859
4947
  */
4860
- senderIdentity: destinationNameSchema.optional(),
4948
+ enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
4949
+ try {
4950
+ parseDuration(value);
4951
+ return true;
4952
+ } catch {
4953
+ return false;
4954
+ }
4955
+ }, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
4956
+ /**
4957
+ * The author's own sentence about what this journey does, shown wherever
4958
+ * the journey is read. Capped like a segment's description; absent when
4959
+ * the author wrote none.
4960
+ */
4961
+ description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
4962
+ /**
4963
+ * The address every `send.email` in this journey goes out as, unless the
4964
+ * call names its own. Required of the author (`defineJourney` types it so,
4965
+ * and `cow build` refuses a journey without it), and still optional here:
4966
+ * releases pushed before ADR 0014 was amended carry none, and this schema
4967
+ * parses stored manifests as well as new ones. A push with no `from` is
4968
+ * refused by the domain gate, which says what to do about it.
4969
+ */
4970
+ from: fromSchema.optional(),
4861
4971
  spine: z.array(spineEntrySchema),
4862
4972
  bundle: digestSchema
4863
4973
  });
@@ -4868,6 +4978,13 @@ const manifestTemplateSchema = z.object({
4868
4978
  sendClass: z.enum(SEND_CLASSES),
4869
4979
  /** True asks the host to mint a signed `verifyUrl` prop at send time. */
4870
4980
  verifyLink: z.boolean(),
4981
+ /**
4982
+ * True asks the host to mint a signed `unsubscribeUrl` prop at send time,
4983
+ * for the author's own footer link. Default false: a marketing send whose
4984
+ * HTML carries no unsubscribe link at all still gets one, appended as a
4985
+ * platform footer, so the link is never the author's to forget.
4986
+ */
4987
+ unsubscribeLink: z.boolean().default(false),
4871
4988
  /** JSON Schema of the template's `props`, converted by `cow build`. */
4872
4989
  propsSchema: z.record(z.string(), z.unknown()),
4873
4990
  bundle: digestSchema
@@ -4918,6 +5035,15 @@ const manifestSchema = z.object({
4918
5035
  uniqueKeys(manifest.journeys, ctx, "journeys");
4919
5036
  uniqueKeys(manifest.templates, ctx, "templates");
4920
5037
  uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
5038
+ for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
5039
+ code: "custom",
5040
+ message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
5041
+ path: [
5042
+ "journeys",
5043
+ index,
5044
+ "enrollment"
5045
+ ]
5046
+ });
4921
5047
  });
4922
5048
  /**
4923
5049
  * One entry of a stored manifest: what a version is a snapshot of. Journeys
@@ -4934,7 +5060,7 @@ const versionManifestSchema = z.union([manifestJourneySchema, manifestTemplateSc
4934
5060
  * further in the future than the clock-skew tolerance (the spec's "future
4935
5061
  * timestamps are rejected with 422" rule). Absent stays valid. Used by the
4936
5062
  * track and identify payload schemas; older-than-retention is a service
4937
- * check (withinRetention) because the window is per environment.
5063
+ * check (withinRetention) because the window is the org's setting.
4938
5064
  */
4939
5065
  function notFutureTimestamp(value) {
4940
5066
  if (value === void 0) return true;
@@ -4981,7 +5107,7 @@ const traitBagSchema = z.custom((value) => value !== null && typeof value === "o
4981
5107
  * `identify` carries it too: the grant has to have a route in through
4982
5108
  * ingestion, or the only thing anyone can express is the revocation.
4983
5109
  */
4984
- const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [EMAIL_MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
5110
+ const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
4985
5111
  /**
4986
5112
  * The identify payload fields, shared between the single-call body schema
4987
5113
  * and the batch item schema (which drops `sourceId`: a batch names one
@@ -5015,7 +5141,6 @@ const identifyBodySchema = z.object({ data: identifyDataSchema });
5015
5141
  const eventDtoSchema = z.object({
5016
5142
  id: z.string(),
5017
5143
  orgId: z.string(),
5018
- environment: environmentSchema,
5019
5144
  /** The app the event is attributed to: derived from the source. */
5020
5145
  appId: z.string(),
5021
5146
  /** The pipe it arrived through: what the caller named on the wire. */
@@ -5052,11 +5177,10 @@ const trackDataSchema = trackFieldsSchema.refine((data) => notFutureTimestamp(da
5052
5177
  });
5053
5178
  const trackBodySchema = z.object({ data: trackDataSchema });
5054
5179
  /**
5055
- * Environment-wide event feed query (GET /v1/events). Same opaque cursor as
5180
+ * The org-wide event feed query (GET /v1/events). Same opaque cursor as
5056
5181
  * every other list; the sort key is the event timestamp, newest first, so
5057
5182
  * the cursor's sortAt slot carries an event timestamp here. appId, sourceId
5058
- * and event are exact-match filters, all optional. The environment comes
5059
- * from the selection header, never from the query.
5183
+ * and event are exact-match filters, all optional.
5060
5184
  *
5061
5185
  * This is the one place a pipe id is a filter: unlike a stored segment
5062
5186
  * definition, a query param is read-time state, so a stale `?sourceId=` is a
@@ -5076,8 +5200,8 @@ const listEventsQuerySchema = paginationQuerySchema.extend({
5076
5200
  profileId: z.string().trim().min(1).max(200).optional()
5077
5201
  });
5078
5202
  /**
5079
- * Event stats (GET /v1/events/stats) for the selected environment, the
5080
- * overview page's volume chart and per-app breakdown. `volume` is
5203
+ * Event stats (GET /v1/events/stats): the overview page's volume chart and
5204
+ * per-app breakdown. `volume` is
5081
5205
  * zero-filled per UTC day over the whole `days` window ending today, so the
5082
5206
  * chart gets a continuous time axis; `byApp` is sorted busiest-first with
5083
5207
  * app names joined in.
@@ -5097,8 +5221,8 @@ const eventStatsDtoSchema = z.object({
5097
5221
  }))
5098
5222
  });
5099
5223
  /**
5100
- * Per-name activity (GET /v1/events/stats/names) for the selected
5101
- * environment: what the event catalog shows beside each entry. `count` is
5224
+ * Per-name activity (GET /v1/events/stats/names): what the event catalog
5225
+ * shows beside each entry. `count` is
5102
5226
  * windowed over `days`; `lastSeenAt` is not windowed and is null only for a
5103
5227
  * name that has never been seen, so "quiet for two months" reads as a zero
5104
5228
  * count next to a real date rather than as no history.
@@ -5124,7 +5248,7 @@ const eventNameStatsDtoSchema = z.object({
5124
5248
  * API in one request instead of thousands of round trips.
5125
5249
  *
5126
5250
  * A batch names one source for every item, `{ sourceId, items }`, so it
5127
- * never spans apps and therefore never spans environments. Each item is one
5251
+ * never spans apps. Each item is one
5128
5252
  * flattened identify or track call tagged by `type`; the field schemas are
5129
5253
  * the single-call ones minus `sourceId`, so an item valid here is valid
5130
5254
  * there. Batch writes are QUIET in one sense: they skip raw-event journey
@@ -5427,8 +5551,11 @@ const listConsentPurposesQuerySchema = paginationQuerySchema;
5427
5551
  */
5428
5552
  const DELIVERY_CHANNELS = deliveryChannelEnum.enumValues;
5429
5553
  const DELIVERY_STATUSES = deliveryStatusEnum.enumValues;
5554
+ /** Why a send happened: a journey ran, a member tested, or a caller sent. */
5555
+ const DELIVERY_KINDS = deliveryKindEnum.enumValues;
5430
5556
  const deliveryStatusSchema = z.enum(DELIVERY_STATUSES);
5431
5557
  const deliveryChannelSchema = z.enum(DELIVERY_CHANNELS);
5558
+ const deliveryKindSchema = z.enum(DELIVERY_KINDS);
5432
5559
  /**
5433
5560
  * The SNS HTTPS envelope, common to every message SNS POSTs to the feedback
5434
5561
  * endpoint. `Message` is a JSON STRING (the SES event for a `Notification`,
@@ -5528,18 +5655,45 @@ const deliverySchema = selectDeliverySchema.omit({
5528
5655
  settledAt: z.iso.datetime().nullable()
5529
5656
  });
5530
5657
  /**
5531
- * The message a dry run or a development capture rendered, stored under
5532
- * `payload.rendered` beside the template data. A real send carries no
5533
- * rendering: SES got it, and the log does not keep a second copy.
5658
+ * The message a dry run rendered, stored under `payload.rendered` beside the
5659
+ * template data, so a developer can read what would have gone out. A real
5660
+ * send carries no rendering: SES got it, and the log does not keep a second
5661
+ * copy.
5534
5662
  */
5535
5663
  const renderedEmailSchema = z.object({
5536
5664
  subject: z.string(),
5537
5665
  html: z.string(),
5538
- text: z.string()
5666
+ text: z.string(),
5667
+ /**
5668
+ * Where a reply would have gone, when the send named one. Absent on a
5669
+ * test send and wherever none was named, which is replies going to the
5670
+ * from-address.
5671
+ */
5672
+ replyTo: z.email().optional()
5539
5673
  });
5540
5674
  /** One delivery with the payload the send rendered (GET /v1/deliveries/:id). */
5541
5675
  const deliveryDetailSchema = deliverySchema.extend({ payload: z.object({ rendered: renderedEmailSchema.optional() }).catchall(z.unknown()) });
5542
5676
  /**
5677
+ * The `payload` a delivery of kind `api` carries: the envelope the caller of
5678
+ * the send facade wrote, and never the bodies (ADR 0014). Every field is
5679
+ * optional because the caller named only some of them, and the row is read
5680
+ * back through this rather than cast, so a row written by an older shape
5681
+ * reads as a message with fields missing instead of one with a type nobody
5682
+ * checked.
5683
+ */
5684
+ const apiDeliveryPayloadSchema = z.object({
5685
+ from: z.string().optional(),
5686
+ to: z.array(z.string()).optional(),
5687
+ cc: z.array(z.string()).optional(),
5688
+ bcc: z.array(z.string()).optional(),
5689
+ reply_to: z.array(z.string()).optional(),
5690
+ subject: z.string().optional(),
5691
+ headers: z.record(z.string(), z.string()).optional(),
5692
+ /** The template key that wrote the message, and the props it took. */
5693
+ template: z.string().optional(),
5694
+ variables: z.record(z.string(), z.unknown()).optional()
5695
+ });
5696
+ /**
5543
5697
  * List query for the deliveries log. Every filter is exact-match and
5544
5698
  * optional; combining them ANDs them.
5545
5699
  *
@@ -5553,113 +5707,29 @@ const listDeliveriesQuerySchema = paginationQuerySchema.extend({
5553
5707
  direction: sortDirectionSchema.default("desc"),
5554
5708
  status: deliveryStatusSchema.optional(),
5555
5709
  journey: z.string().trim().min(1).max(200).optional(),
5556
- /** The template or destination this attempt was for; a template page's own log. */
5710
+ /** The template or webhook this attempt was for; a template page's own log. */
5557
5711
  step: z.string().trim().min(1).max(200).optional(),
5558
5712
  channel: deliveryChannelSchema.optional(),
5713
+ /** Why the send happened: a journey, a test send, or a direct API send. */
5714
+ kind: deliveryKindSchema.optional(),
5559
5715
  appId: z.string().trim().min(1).max(200).optional(),
5560
5716
  /** One person's deliveries, merged ids included, same as the event feed. */
5561
5717
  profileId: z.string().trim().min(1).max(200).optional(),
5562
5718
  /**
5563
5719
  * Only deliveries touched at or after this instant, by `updatedAt`: the
5564
- * cursor a tail (`cow dev`) polls with, so a captured send and its later
5565
- * feedback transition both arrive. Inclusive for the same reason as the
5566
- * execution filter, and paged the same way: `createdAt` still orders the
5567
- * page, this only narrows it.
5720
+ * cursor a tail (`cow dev`) polls with, so a send and its later feedback
5721
+ * transition both arrive. Inclusive for the same reason as the execution
5722
+ * filter, and paged the same way: `createdAt` still orders the page, this
5723
+ * only narrows it.
5568
5724
  */
5569
5725
  since: z.iso.datetime().optional()
5570
5726
  });
5571
5727
 
5572
- //#endregion
5573
- //#region ../../packages/shared/src/destinations.ts
5574
- /**
5575
- * Destinations API contracts. Derived from the Drizzle table via drizzle-zod.
5576
- * signingSecret is deliberately absent from the DTO: it is shown exactly
5577
- * once, in the create response (destinationCreatedSchema). consecutiveFailures
5578
- * is absent too: it is the auto-disable counter's internal state, and
5579
- * disabledAt is the part the dashboard banner needs.
5580
- *
5581
- * A destination belongs to one environment, chosen at creation and
5582
- * immutable like a source's, and names are unique per org and environment,
5583
- * so the same name points at a different receiver in each.
5584
- */
5585
- const destinationSchema = selectDestinationSchema.extend({
5586
- createdAt: z.iso.datetime(),
5587
- updatedAt: z.iso.datetime(),
5588
- disabledAt: z.iso.datetime().nullable()
5589
- }).omit({
5590
- signingSecret: true,
5591
- consecutiveFailures: true
5592
- });
5593
- /** The two kinds of destination, from the table's own enum. */
5594
- const destinationTypeSchema = destinationSchema.shape.type;
5595
- const destinationCreatedSchema = destinationSchema.extend({ signingSecret: z.string().optional() });
5596
- const webhookConfigSchema = z.object({ url: z.url("config.url must be a valid URL").refine((url) => {
5597
- try {
5598
- return ["http:", "https:"].includes(new URL(url).protocol);
5599
- } catch {
5600
- return false;
5601
- }
5602
- }, { message: "config.url must use http or https" }) });
5603
- /**
5604
- * The address shape only. WHICH domain is allowed is not a static fact: it
5605
- * is the shared fallback domain or one this org has verified, which needs a
5606
- * database read, so the rule lives in the destinations service (write time)
5607
- * and the send gate (send time) instead of here.
5608
- */
5609
- const senderIdentityConfigSchema = z.object({
5610
- fromName: z.string().trim().min(1, "config.fromName is required").max(100).refine((name) => !/\p{Cc}/u.test(name), { message: "config.fromName must not contain control characters" }),
5611
- fromEmail: z.email("config.fromEmail must be a valid email address")
5612
- });
5613
- /**
5614
- * The two destination shapes, named individually so the dashboard's create
5615
- * and edit forms can resolve against exactly one of them. A form knows its
5616
- * type before the user types anything, and zodResolver over a member is
5617
- * what gives react-hook-form a non-union field path to register.
5618
- */
5619
- const webhookDestinationInputSchema = z.object({
5620
- name: destinationNameSchema,
5621
- /** The environment this destination lives in; immutable after creation. */
5622
- environment: environmentSchema,
5623
- type: z.literal("webhook"),
5624
- config: webhookConfigSchema
5625
- });
5626
- const senderIdentityDestinationInputSchema = z.object({
5627
- name: destinationNameSchema,
5628
- /** The environment this destination lives in; immutable after creation. */
5629
- environment: environmentSchema,
5630
- type: z.literal("sender_identity"),
5631
- config: senderIdentityConfigSchema
5632
- });
5633
- const createDestinationBodySchema = z.object({ data: z.discriminatedUnion("type", [webhookDestinationInputSchema, senderIdentityDestinationInputSchema]) });
5634
- /**
5635
- * Destination update. `enabled` is the admin's half of the auto-disable
5636
- * loop: the webhook send activity stamps `disabledAt` after enough
5637
- * consecutive failures and every send after that records `skipped_disabled`,
5638
- * so without a way to clear it the only exit would be deleting the
5639
- * destination, which throws away the signing secret the receiver is
5640
- * configured with. `enabled: true` clears the stamp and the failure counter;
5641
- * `enabled: false` is the same switch operated by hand.
5642
- */
5643
- const updateDestinationBodySchema = z.object({ data: z.object({
5644
- name: destinationNameSchema.optional(),
5645
- config: z.unknown().optional(),
5646
- enabled: z.boolean().optional()
5647
- }).refine((data) => data.name !== void 0 || data.config !== void 0 || data.enabled !== void 0, { message: "at least one field (name, config, or enabled) is required" }) });
5648
- /**
5649
- * `q` is a substring search over the destination's name; `type` narrows to
5650
- * one kind, because the webhooks page and the sender identities section on
5651
- * the domains page each want one and the table holds both.
5652
- */
5653
- const listDestinationsQuerySchema = paginationQuerySchema.extend({
5654
- q: searchQuerySchema,
5655
- type: destinationTypeSchema.optional()
5656
- });
5657
-
5658
5728
  //#endregion
5659
5729
  //#region ../../packages/shared/src/domains.ts
5660
5730
  /**
5661
5731
  * Sending-domain contracts. Derived from the Drizzle table so the row and
5662
- * the DTO cannot drift, same as deliveries, destinations, and suppressions.
5732
+ * the DTO cannot drift, same as deliveries, webhooks, and suppressions.
5663
5733
  *
5664
5734
  * Verification itself is SES's; everything here describes the claim and the
5665
5735
  * mirrored answer.
@@ -5683,6 +5753,7 @@ const dnsRecordSchema = z.object({
5683
5753
  /** One sending domain on the wire: dates become ISO 8601 strings. */
5684
5754
  const senderDomainSchema = selectSenderDomainSchema.extend({
5685
5755
  dnsRecords: z.array(dnsRecordSchema),
5756
+ challengeVerifiedAt: z.iso.datetime().nullable(),
5686
5757
  lastCheckedAt: z.iso.datetime().nullable(),
5687
5758
  createdAt: z.iso.datetime(),
5688
5759
  updatedAt: z.iso.datetime()
@@ -5715,20 +5786,6 @@ const domainDnsSetupSchema = z.object({
5715
5786
  provider: z.string().nullable()
5716
5787
  });
5717
5788
 
5718
- //#endregion
5719
- //#region ../../packages/shared/src/enabled.ts
5720
- /**
5721
- * The enable flag per environment; absent rows read as off (ADR 0011).
5722
- *
5723
- * Its own module because both `journeys.ts` and `pushes.ts` need it and
5724
- * neither may import the other: a journey carries the flag, and so does the
5725
- * project status a push is read back through.
5726
- */
5727
- const journeyEnabledSchema = z.object({
5728
- development: z.boolean(),
5729
- production: z.boolean()
5730
- });
5731
-
5732
5789
  //#endregion
5733
5790
  //#region ../../packages/shared/src/patterns.ts
5734
5791
  /**
@@ -5819,29 +5876,6 @@ function patternPlaceholder(pattern) {
5819
5876
  * a guest returns with these schemas before anything acts on it; the guest
5820
5877
  * SDK and the Node simulator produce and consume the same shapes.
5821
5878
  */
5822
- /** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
5823
- const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
5824
- const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
5825
- const DURATION_UNIT_MS = {
5826
- ms: 1,
5827
- s: 1e3,
5828
- m: 6e4,
5829
- h: 36e5,
5830
- d: 864e5,
5831
- w: 6048e5
5832
- };
5833
- /**
5834
- * A duration in milliseconds. The simulator's virtual clock and the runner's
5835
- * timers both need it, and neither may pull in Temporal's `msToNumber` (one
5836
- * runs in the CLI, the other inside workflow code).
5837
- */
5838
- function parseDuration(duration) {
5839
- if (typeof duration === "number") return duration;
5840
- const match = DURATION_PATTERN.exec(duration.trim());
5841
- if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
5842
- const unit = DURATION_UNIT_MS[match[2]];
5843
- return Math.round(Number(match[1]) * unit);
5844
- }
5845
5879
  /** The event a journey runs for, or waits on: name, properties, and when. */
5846
5880
  const guestEventSchema = z.object({
5847
5881
  name: z.string().min(1),
@@ -5856,8 +5890,8 @@ const properties = z.record(z.string(), z.unknown());
5856
5890
  * them. A name outside the union is rejected, never dispatched.
5857
5891
  *
5858
5892
  * Every `args` is strict, and that is the tenancy boundary made mechanical:
5859
- * a module that returns an `orgId`, an `environment`, or any other field
5860
- * beside the ones a capability takes fails the parse instead of having it
5893
+ * a module that returns an `orgId` or any other field beside the ones a
5894
+ * capability takes fails the parse instead of having it
5861
5895
  * quietly dropped. Tenant context comes from the workflow input, never from
5862
5896
  * guest output, and this is where saying so becomes checkable.
5863
5897
  */
@@ -5879,12 +5913,15 @@ const commandSchema = z.discriminatedUnion("name", [
5879
5913
  template: journeyKeySchema,
5880
5914
  props: properties,
5881
5915
  /**
5882
- * Which sender identity this mail leaves as, by name. Required, and
5883
- * the guest SDK fills in the journey's own when the call does not name
5884
- * one, so the host has one resolution path and never has to read the
5885
- * manifest to find a sender.
5916
+ * The address this mail leaves as. The guest SDK fills in the
5917
+ * journey's own whenever the call does not name one, so the host has
5918
+ * one resolution path and never reads the manifest to find it. A
5919
+ * journey must name one, so absent on both is a release pushed before
5920
+ * that was true, and the `domain` gate refuses it.
5886
5921
  */
5887
- senderIdentity: destinationNameSchema
5922
+ from: fromSchema.optional(),
5923
+ /** Where a reply to this one mail goes, instead of the from-address. */
5924
+ replyTo: z.email().optional()
5888
5925
  })
5889
5926
  }),
5890
5927
  z.object({
@@ -5965,7 +6002,7 @@ const executionLimitsSchema = z.object({
5965
6002
  logLineBytes: z.number().int().positive()
5966
6003
  });
5967
6004
  const journeyStepInputSchema = z.object({
5968
- protocol: z.literal(2),
6005
+ protocol: z.literal(3),
5969
6006
  kind: z.literal("journey"),
5970
6007
  key: journeyKeySchema,
5971
6008
  event: guestEventSchema,
@@ -6005,7 +6042,7 @@ const journeyStepOutputSchema = z.discriminatedUnion("status", [
6005
6042
  })
6006
6043
  ]);
6007
6044
  const templateRenderInputSchema = z.object({
6008
- protocol: z.literal(2),
6045
+ protocol: z.literal(3),
6009
6046
  kind: z.literal("template"),
6010
6047
  key: journeyKeySchema,
6011
6048
  props: properties
@@ -6024,11 +6061,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
6024
6061
  const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
6025
6062
  trigger: true,
6026
6063
  purpose: true,
6027
- senderIdentity: true,
6064
+ enrollment: true,
6065
+ description: true,
6066
+ from: true,
6028
6067
  tags: true
6029
6068
  }).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
6030
6069
  sendClass: true,
6031
6070
  verifyLink: true,
6071
+ unsubscribeLink: true,
6032
6072
  propsSchema: true,
6033
6073
  tags: true
6034
6074
  }).extend({ kind: z.literal("template") })]);
@@ -6088,7 +6128,7 @@ const executionDetailSchema = executionSchema.extend({
6088
6128
  logs: z.array(executionLogSchema)
6089
6129
  });
6090
6130
  const listExecutionsQuerySchema = paginationQuerySchema.extend({
6091
- /** A journey key; the environment comes from the selection header. */
6131
+ /** A journey key. */
6092
6132
  journey: z.string().trim().min(1).max(200).optional(),
6093
6133
  /** One version's own runs, for the history rows on a journey. */
6094
6134
  version: z.string().trim().min(1).max(200).optional(),
@@ -6104,12 +6144,15 @@ const listExecutionsQuerySchema = paginationQuerySchema.extend({
6104
6144
  since: z.iso.datetime().optional()
6105
6145
  });
6106
6146
  /**
6107
- * What to stop: every live execution of one journey in the selected
6108
- * environment, or the ones still pinned to one version. Cancelling is
6109
- * explicit and per environment (ADR 0011): disabling a journey stops new
6110
- * entries and lets what is running finish, and this is the other verb.
6147
+ * What to stop: every live execution of one journey, or the ones still
6148
+ * pinned to one version. Cancelling is explicit (ADR 0011): disabling a
6149
+ * journey stops new entries and lets what is running finish, and this is
6150
+ * the other verb.
6111
6151
  */
6112
- const cancelExecutionsBodySchema = z.object({ data: z.union([z.object({ journey: z.string().trim().min(1).max(200) }), z.object({ version: z.string().trim().min(1).max(200) })]) });
6152
+ const cancelExecutionsBodySchema = z.object({ data: z.object({
6153
+ journey: z.string().trim().min(1).max(200).optional(),
6154
+ version: z.string().trim().min(1).max(200).optional()
6155
+ }).refine((data) => data.journey === void 0 !== (data.version === void 0), { message: "name a journey or a version, not both" }) });
6113
6156
  /**
6114
6157
  * Cancelling is a request, not an edit: each execution stops when it reaches
6115
6158
  * its next step, so the answer is that the sweep is under way rather than a
@@ -6127,9 +6170,8 @@ const cancellingSchema = z.object({ cancelling: z.literal(true) });
6127
6170
  const projectNameSchema = z.string().trim().min(1).max(200);
6128
6171
  /**
6129
6172
  * `cow.json` (spec: Project layout): the org, and which of its projects this
6130
- * directory is. The environment is always a flag, and auth never lives in
6131
- * the project. Strict, so a typo'd key is a build error rather than a
6132
- * silently ignored setting.
6173
+ * directory is. Auth never lives in the project. Strict, so a typo'd key
6174
+ * is a build error rather than a silently ignored setting.
6133
6175
  */
6134
6176
  const cowConfigSchema = z.strictObject({
6135
6177
  $schema: z.url().optional(),
@@ -6238,7 +6280,7 @@ const createPushBodySchema = z.object({ data: z.object({
6238
6280
  * is a live CLI talking to a live platform, so a mismatch here is a 422
6239
6281
  * the developer fixes by updating `@cowliss/cli`, and nothing is stored.
6240
6282
  */
6241
- manifest: manifestSchema.safeExtend({ protocol: z.literal(2) }),
6283
+ manifest: manifestSchema.safeExtend({ protocol: z.literal(3) }),
6242
6284
  /** The `cow.json` project this push belongs to. */
6243
6285
  project: projectNameSchema
6244
6286
  }) });
@@ -6256,7 +6298,7 @@ const listVersionsQuerySchema = paginationQuerySchema.extend({
6256
6298
  });
6257
6299
  /**
6258
6300
  * One key of a project as `cow status` and the MCP `status` tool report it:
6259
- * the code the server holds for it, whether it is on, and what is still
6301
+ * the code the server holds for it, what state it is in, and what is still
6260
6302
  * running. Journeys and templates share the shape, because a developer asks
6261
6303
  * the same question of both; the two fields only a journey has are null on a
6262
6304
  * template.
@@ -6270,13 +6312,14 @@ const projectStatusKeySchema = z.object({
6270
6312
  * server holds. Null only for a journey whose versions are all pruned.
6271
6313
  */
6272
6314
  latestVersion: versionSchema.nullable(),
6273
- /** The flag per environment; null on a template, which has none. */
6274
- enabled: journeyEnabledSchema.nullable(),
6275
- /** Executions still running or waiting, per environment. */
6276
- liveExecutions: z.object({
6277
- development: z.number().int(),
6278
- production: z.number().int()
6279
- })
6315
+ /**
6316
+ * The journey's one state; null on a template, which has none. Built from
6317
+ * the column here rather than imported from `journeys.ts`, which reads
6318
+ * this module for its version summary.
6319
+ */
6320
+ status: z.enum(journeyStatusEnum.enumValues).nullable(),
6321
+ /** Executions still running or waiting. */
6322
+ liveExecutions: z.number().int()
6280
6323
  });
6281
6324
  /**
6282
6325
  * Everything the server knows about one project's keys, in one read: the
@@ -6285,33 +6328,76 @@ const projectStatusKeySchema = z.object({
6285
6328
  * is my project doing" is useless split across cursors.
6286
6329
  *
6287
6330
  * `warnings` is what a deploy used to answer with (ADR 0011 retired the
6288
- * deploy): a name a journey references that the environment does not define
6289
- * yet. They never block, because a segment or a destination may be created
6331
+ * deploy): a name a journey references that the organization does not
6332
+ * define yet. They never block, because a segment or a webhook may be created
6290
6333
  * right after a push.
6291
6334
  */
6292
6335
  const projectStatusSchema = z.object({
6293
6336
  project: projectNameSchema,
6294
6337
  keys: z.array(projectStatusKeySchema),
6295
- warnings: z.object({
6296
- development: z.array(z.string()),
6297
- production: z.array(z.string())
6298
- })
6338
+ warnings: z.array(z.string())
6339
+ });
6340
+
6341
+ //#endregion
6342
+ //#region ../../packages/shared/src/segments.ts
6343
+ const segmentSchema = selectSegmentSchema.extend({
6344
+ definition: segmentDefinitionSchema,
6345
+ createdAt: z.iso.datetime(),
6346
+ updatedAt: z.iso.datetime()
6347
+ });
6348
+ /** The detail view adds the materialized member count. */
6349
+ const segmentDetailSchema = segmentSchema.extend({ memberCount: z.number().int().min(0) });
6350
+ const segmentMemberSchema = z.object({
6351
+ segmentId: z.string(),
6352
+ orgId: z.string(),
6353
+ profileId: z.string(),
6354
+ enteredAt: z.iso.datetime()
6355
+ });
6356
+ /**
6357
+ * The builder's design-time sanity check: run one not-yet-persisted
6358
+ * definition over the org's existing profiles. Members is a small sample of
6359
+ * matching profile ids, not the full member list: there is no segment row
6360
+ * and no membership entry, so there is no segmentId or enteredAt to report.
6361
+ *
6362
+ * The scan is bounded, so `memberCount` is the exact org-wide count only
6363
+ * when `truncated` is false. When it is true the scan stopped at the cap,
6364
+ * and `memberCount` is the count over the first `scanned` profiles only.
6365
+ */
6366
+ const previewSegmentBodySchema = z.object({ data: segmentDefinitionSchema });
6367
+ const segmentPreviewSchema = z.object({
6368
+ memberCount: z.number().int().min(0),
6369
+ members: z.array(z.string()),
6370
+ /** Profiles the preview actually evaluated. */
6371
+ scanned: z.number().int().min(0),
6372
+ /** True when the scan hit its cap, so memberCount is a floor. */
6373
+ truncated: z.boolean()
6299
6374
  });
6375
+ const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
6376
+ const descriptionSchema = z.string().trim().max(500, `description must be at most ${500} characters`);
6377
+ const createSegmentBodySchema = z.object({ data: z.object({
6378
+ name: nameSchema,
6379
+ description: descriptionSchema.nullish(),
6380
+ definition: segmentDefinitionSchema
6381
+ }) });
6382
+ const updateSegmentBodySchema = z.object({ data: z.object({
6383
+ name: nameSchema.optional(),
6384
+ description: descriptionSchema.nullish(),
6385
+ definition: segmentDefinitionSchema.optional()
6386
+ }).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0, { message: "at least one field (name, description or definition) is required" }) });
6387
+ /** `q` is a substring search over the segment's name. */
6388
+ const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
6389
+ const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
6300
6390
 
6301
6391
  //#endregion
6302
6392
  //#region ../../packages/shared/src/journeys.ts
6303
6393
  /**
6304
6394
  * Journeys as the API reports them: the rows a push derives from its
6305
6395
  * manifest, one per (org, key). Nothing here is authored through the API, so
6306
- * the only write is the enable flag.
6396
+ * the only write is the status.
6307
6397
  *
6308
6398
  * Derived from the Drizzle table via drizzle-zod so the wire DTO and the row
6309
6399
  * share one source of truth; the jsonb columns get explicit wire schemas
6310
- * because the row types are opaque to drizzle-zod. `enabled` is not a
6311
- * column: it is the journey_states value per environment, absent meaning
6312
- * off, and both environments are reported whichever one the caller selected,
6313
- * because "is this on in production?" is the question the list exists to
6314
- * answer.
6400
+ * because the row types are opaque to drizzle-zod.
6315
6401
  */
6316
6402
  const journeyTriggerSchema = triggerSchema;
6317
6403
  /**
@@ -6320,11 +6406,19 @@ const journeyTriggerSchema = triggerSchema;
6320
6406
  * control flow is whatever its code does at run time.
6321
6407
  */
6322
6408
  const journeySpineEntrySchema = spineEntrySchema;
6409
+ /**
6410
+ * The one operational state a manager sets (ADR 0018). `on` lets recipients
6411
+ * enroll and executions run; `off` enrolls nobody and lets what is in flight
6412
+ * finish; `paused` enrolls nobody and holds what is in flight before its next
6413
+ * step. Turning a paused journey on resumes it.
6414
+ */
6415
+ const JOURNEY_STATUSES = journeyStatusEnum.enumValues;
6416
+ const journeyStatusSchema = z.enum(JOURNEY_STATUSES);
6323
6417
  const journeySchema = selectJourneySchema.extend({
6324
6418
  trigger: journeyTriggerSchema,
6325
6419
  purpose: consentPurposeKeySchema,
6326
6420
  spine: z.array(journeySpineEntrySchema),
6327
- enabled: journeyEnabledSchema,
6421
+ status: journeyStatusSchema,
6328
6422
  /**
6329
6423
  * The newest version of this key, whatever it compiled to, so a list can
6330
6424
  * say "pushed 2 hours ago" and "compiling" without a second read. What the
@@ -6337,14 +6431,24 @@ const journeySchema = selectJourneySchema.extend({
6337
6431
  updatedAt: z.iso.datetime()
6338
6432
  });
6339
6433
  /**
6434
+ * One journey, with the segment it owns when its trigger inlines a
6435
+ * definition (ADR 0016): the same row and member count the segments API
6436
+ * reports, so the journey's page can show who is in its segment without a
6437
+ * second concept. Null for an event trigger.
6438
+ *
6439
+ * On the read of one journey only. A list would be one member count per row,
6440
+ * and a list already shows the trigger.
6441
+ */
6442
+ const journeyDetailSchema = journeySchema.extend({ segment: segmentDetailSchema.nullable() });
6443
+ /**
6340
6444
  * Journey list query. `q` is a substring search over the key and the tags,
6341
6445
  * the two things an author names a journey by; `tag` is "has this tag",
6342
6446
  * exact and case-sensitive, so a badge in the table is the way into it.
6343
6447
  * Both are server-side, like every other list filter.
6344
6448
  */
6345
6449
  const listJourneysQuerySchema = paginationQuerySchema.extend({
6346
- /** On or off in the environment the call selected. */
6347
- enabled: z.enum(["true", "false"]).optional(),
6450
+ /** On, off or paused; the journey's one state. */
6451
+ status: journeyStatusSchema.optional(),
6348
6452
  q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0),
6349
6453
  tag: z.string().trim().max(50, "tag must be at most 50 characters").optional()
6350
6454
  });
@@ -6372,41 +6476,24 @@ const journeyStatsSchema = z.object({
6372
6476
  deliveries: z.object({ byStatus: z.record(deliveryStatusSchema, z.number().int()) })
6373
6477
  });
6374
6478
  /**
6375
- * A delivery row as the per-user journey timeline reports it. Deliberately
6376
- * narrow: the full delivery DTO and its list contract live with the
6377
- * deliveries log, and this timeline needs only what happened, not the
6378
- * recipient address or the rendered payload.
6379
- */
6380
- const journeyDeliverySchema = selectDeliverySchema.pick({
6381
- id: true,
6382
- journey: true,
6383
- step: true,
6384
- channel: true,
6385
- status: true,
6386
- dryRun: true,
6387
- error: true
6388
- }).extend({ createdAt: z.iso.datetime() });
6389
- /** One journey on a user's timeline: the journey, their run, their sends. */
6390
- const userJourneySchema = z.object({
6391
- journey: journeySchema,
6392
- /** Null when this user never entered the journey. */
6393
- execution: executionSchema.nullable(),
6394
- deliveries: z.array(journeyDeliverySchema)
6395
- });
6396
- const listUserJourneysQuerySchema = paginationQuerySchema;
6397
- /**
6398
- * Which journeys the flag acts on: the keys the caller named, or every
6399
- * journey of one project. Exactly one of the two, because a call carrying
6400
- * both would have to decide which it meant.
6479
+ * Which journeys the status acts on, and which state to put them in: the
6480
+ * keys the caller named, or every journey of one project. Exactly one of the
6481
+ * two selectors, because a call carrying both would have to decide which it
6482
+ * meant.
6401
6483
  *
6402
6484
  * A list rather than one key per call so every surface can do what `cow
6403
6485
  * enable` does: name several keys, or `--all` (which is `project`, resolved
6404
6486
  * server-side, so an agent turning a project on is one call too).
6487
+ *
6488
+ * One body for three states rather than a route per verb (ADR 0018): the
6489
+ * transitions differ in what they do to what is in flight, not in who may
6490
+ * ask or what they name.
6405
6491
  */
6406
- const setJourneysEnabledBodySchema = z.object({ data: z.object({
6492
+ const setJourneysStatusBodySchema = z.object({ data: z.object({
6407
6493
  keys: z.array(journeyKeySchema).min(1).max(PUSH_LIMITS.journeys).optional(),
6408
6494
  /** Every journey of this project, by the name `cow.json` carries. */
6409
- project: projectNameSchema.optional()
6495
+ project: projectNameSchema.optional(),
6496
+ status: journeyStatusSchema
6410
6497
  }).refine((data) => data.keys === void 0 !== (data.project === void 0), { message: "name keys or a project, not both" }) });
6411
6498
  /**
6412
6499
  * Dry run: one real profile, the journey's latest ready version, and every
@@ -6415,6 +6502,106 @@ const setJourneysEnabledBodySchema = z.object({ data: z.object({
6415
6502
  * now and a v1 instance was a Temporal read.
6416
6503
  */
6417
6504
  const dryRunJourneyBodySchema = z.object({ data: z.object({ profileId: z.string().min(1) }) });
6505
+ /**
6506
+ * Enrollment (ADR 0015): what turning a journey on would do, or is doing,
6507
+ * to the people already in its audience.
6508
+ *
6509
+ * The same three numbers answer both questions, so they are one schema: a
6510
+ * preview walks the whole segment counting, and the running job counts the
6511
+ * same way as it goes. `scanned` is the segment's current membership,
6512
+ * `enrolled` the ones that entered (or in a preview would), and the skips
6513
+ * say why the rest did not.
6514
+ */
6515
+ const enrollmentCountsSchema = z.object({
6516
+ scanned: z.number().int(),
6517
+ enrolled: z.number().int(),
6518
+ skipped: z.object({
6519
+ /** They have been through this journey, and it enrolls once. */
6520
+ completed: z.number().int(),
6521
+ /** They finished recently and the journey's cooldown has not elapsed. */
6522
+ cooldown: z.number().int(),
6523
+ /** They are in the journey right now. */
6524
+ running: z.number().int()
6525
+ })
6526
+ });
6527
+ /**
6528
+ * How each skip is said to a person, in the order the surfaces list them.
6529
+ * The confirm screen, the progress card and `cow enable` all report the same
6530
+ * three numbers, and the words for them live here so the three cannot drift:
6531
+ * `tile` names the count on its own, `reason` completes "N ...".
6532
+ */
6533
+ const ENROLLMENT_SKIP_WORDS = {
6534
+ completed: {
6535
+ tile: "Already been through",
6536
+ reason: "have already been through it"
6537
+ },
6538
+ cooldown: {
6539
+ tile: "Too recently",
6540
+ reason: "went through it too recently"
6541
+ },
6542
+ running: {
6543
+ tile: "In it already",
6544
+ reason: "are in it right now"
6545
+ }
6546
+ };
6547
+ const enrollmentStatusSchema = enrollmentCountsSchema.extend({
6548
+ /**
6549
+ * `running` while the job works, `done` when it finished the segment,
6550
+ * `cancelled` when someone stopped it, `stopped` when the journey itself
6551
+ * stopped being on under it, `failed` when it could not finish. On any of
6552
+ * the three that end early the counts are what it reached, and the people
6553
+ * it already enrolled stay in the journey.
6554
+ */
6555
+ state: z.enum([
6556
+ "running",
6557
+ "done",
6558
+ "cancelled",
6559
+ "stopped",
6560
+ "failed"
6561
+ ]) });
6562
+ /**
6563
+ * What a journey turned on says about the audience it now has waiting.
6564
+ *
6565
+ * It rides on the status response rather than being fetched separately so
6566
+ * that every surface that moves the control sees it: `cow enable` prints it,
6567
+ * and the MCP tool, which is planned from the contract and has no code of
6568
+ * its own, returns it. An agent that cannot start the enrollment can still
6569
+ * tell the person who can how many people it would reach and where to go.
6570
+ *
6571
+ * Null on a journey whose trigger is an event: there is no standing audience
6572
+ * to reach. `counts` is null when the numbers could not be worked out just
6573
+ * then; the journey is on either way, because a count nobody could read is
6574
+ * no reason to leave the switch off.
6575
+ */
6576
+ const enrollmentOfferSchema = z.object({
6577
+ counts: enrollmentCountsSchema.nullable(),
6578
+ /** The journey's page, where the offer can be accepted. */
6579
+ url: z.string()
6580
+ });
6581
+ /**
6582
+ * A journey as the status route answers, carrying that offer. Null on every
6583
+ * status but `on`: a journey turned off or held reaches nobody, so there is
6584
+ * no audience to offer.
6585
+ */
6586
+ const journeyWithOfferSchema = journeySchema.extend({ enrollment: enrollmentOfferSchema.nullable() });
6587
+ /**
6588
+ * What holding a journey until a given day would end (ADR 0018). A wait
6589
+ * that comes due while the journey is held ends that person's run, so
6590
+ * before putting one on hold it is worth knowing how many runs that is.
6591
+ *
6592
+ * The day is the caller's, not a fixed horizon: "until tomorrow" and "until
6593
+ * next month" are different decisions, and the numbers behind them are what
6594
+ * tell them apart.
6595
+ */
6596
+ const waitsDueQuerySchema = z.object({
6597
+ /** Count the waits that come due before this instant. */
6598
+ before: z.iso.datetime() });
6599
+ const waitsDueSchema = z.object({
6600
+ /** Executions in flight whose next step comes due before then. */
6601
+ due: z.number().int(),
6602
+ /** Executions in flight altogether, due or not. */
6603
+ inFlight: z.number().int()
6604
+ });
6418
6605
 
6419
6606
  //#endregion
6420
6607
  //#region ../../packages/shared/src/journeys-v2/sandbox.ts
@@ -6444,7 +6631,7 @@ const sandboxFailureCodeSchema = z.enum(SANDBOX_FAILURE_CODES);
6444
6631
  * to be written by hand or by an agent, not generated.
6445
6632
  *
6446
6633
  * The schema lives here rather than in packages/journeys so the CLI can reject
6447
- * a bad file before it boots a Temporal environment, and so the docs site can
6634
+ * a bad file before it boots a Temporal dev server, and so the docs site can
6448
6635
  * render the format from one source.
6449
6636
  */
6450
6637
  const journeyScenarioSchema = z.object({
@@ -6515,6 +6702,168 @@ const notificationPreferencesSchema = z.object({ purposes: z.array(notificationP
6515
6702
  */
6516
6703
  const updateNotificationPreferencesBodySchema = z.object({ data: z.object({ purposes: consentPatchSchema }) });
6517
6704
 
6705
+ //#endregion
6706
+ //#region ../../packages/shared/src/resend.ts
6707
+ /** Resend's own cap on one message's recipients, per list. */
6708
+ const RESEND_MAX_RECIPIENTS = 50;
6709
+ /** Resend's own cap on one batch. */
6710
+ const RESEND_MAX_BATCH = 100;
6711
+ /** Resend's own cap on a from-header, a subject, and a message's tags. */
6712
+ const RESEND_MAX_FROM = 200;
6713
+ const RESEND_MAX_SUBJECT = 1e3;
6714
+ const RESEND_MAX_TAGS = 50;
6715
+ /**
6716
+ * One recipient: a bare address or `Name <addr@domain>`, both of which SES
6717
+ * takes in a Destination. The same parser the journey path uses, so an
6718
+ * address the facade accepts is one a journey could have sent to.
6719
+ */
6720
+ const recipientSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "Every recipient must be an address (\"ada@acme.com\") or a name and address (\"Ada <ada@acme.com>\")." });
6721
+ const recipientArraySchema = z.array(recipientSchema).min(1).max(50);
6722
+ /** Resend takes one address or a list wherever it takes recipients. */
6723
+ const recipientsSchema$1 = z.union([recipientSchema, recipientArraySchema]);
6724
+ /**
6725
+ * Resend's wording for an absent required field. Three fields can be
6726
+ * missing: `to`, `subject`, and a body. Which answer each one gets is read
6727
+ * off the issue itself, never off this copy, so rewording any of them is
6728
+ * safe (see `isMissingField`).
6729
+ */
6730
+ const MISSING = (field) => `Missing \`${field}\` field.`;
6731
+ /**
6732
+ * A field Cowliss does not serve. Declared rather than left to the strict
6733
+ * object's "unrecognized key" so the 422 names the field and says what to
6734
+ * do instead; the Resend SDK omits an unset one, so only a caller that
6735
+ * really passed it is refused.
6736
+ *
6737
+ * `any` and a refusing check rather than `never`, which the OpenAPI
6738
+ * generator has no rendering for.
6739
+ */
6740
+ const unsupported = (message) => z.any().refine(() => false, { error: message }).optional();
6741
+ /**
6742
+ * Resend's template slot, pointing at a Cowliss template key (ADR 0014).
6743
+ * `variables` are the template's own props: they are checked by the
6744
+ * template, in the sandbox, the same way a test send's are, so anything
6745
+ * JSON can carry is accepted here and the template decides.
6746
+ */
6747
+ const templateSlotSchema = z.strictObject({
6748
+ id: journeyKeySchema,
6749
+ variables: z.record(z.string(), z.unknown()).optional()
6750
+ });
6751
+ /** Send body: Resend's fields, minus the ones Cowliss does not serve. */
6752
+ const resendSendBodySchema = z.strictObject({
6753
+ /**
6754
+ * Required, exactly as Resend requires it. There is no address to fall
6755
+ * back to: a send leaves from a domain the organization verified, and
6756
+ * nothing else. Missing is its own issue for the same reason `to`'s is.
6757
+ */
6758
+ from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }).optional().nonoptional({ error: MISSING("from") }),
6759
+ /**
6760
+ * Required, and the absence is its own issue rather than the union's:
6761
+ * `optional().nonoptional()` lets the value through to the union when
6762
+ * there is one and answers for undefined itself, which is how
6763
+ * `isMissingField` tells "you left it out" from "you sent the wrong
6764
+ * thing" without reading the message.
6765
+ */
6766
+ to: z.union([recipientSchema, recipientArraySchema]).optional().nonoptional({ error: MISSING("to") }),
6767
+ /**
6768
+ * Optional in the type and required in practice: a message either
6769
+ * carries its own subject or names a template, which brings one. The
6770
+ * refinement below says which of the two is missing.
6771
+ */
6772
+ subject: z.string().trim().min(1).max(RESEND_MAX_SUBJECT).optional(),
6773
+ html: z.string().optional(),
6774
+ text: z.string().optional(),
6775
+ cc: recipientsSchema$1.optional(),
6776
+ bcc: recipientsSchema$1.optional(),
6777
+ reply_to: recipientsSchema$1.optional(),
6778
+ headers: z.record(z.string(), z.string()).optional(),
6779
+ /** Accepted and dropped: Cowliss tags a delivery by its own fields. */
6780
+ tags: z.array(z.object({
6781
+ name: z.string(),
6782
+ value: z.string()
6783
+ })).max(50).optional(),
6784
+ attachments: unsupported("Cowliss does not accept attachments. Link to the file from the message instead."),
6785
+ scheduled_at: unsupported("Cowliss does not schedule sends. Send the message when it should arrive, or let a journey's wait step do the waiting."),
6786
+ topic_id: unsupported("Cowliss does not take topics. Consent purposes are where it records what a person agreed to."),
6787
+ template: templateSlotSchema.optional()
6788
+ }).superRefine((body, ctx) => {
6789
+ const refuse = (path, message) => ctx.addIssue({
6790
+ code: "custom",
6791
+ path: [path],
6792
+ message
6793
+ });
6794
+ const missing = (path, message) => ctx.addIssue({
6795
+ code: "custom",
6796
+ path: [path],
6797
+ message,
6798
+ params: { missing: true }
6799
+ });
6800
+ if (body.template) {
6801
+ if (body.subject !== void 0) refuse("subject", "The subject comes from the template. Remove `subject`, or write the message yourself with `html` or `text`.");
6802
+ for (const field of ["html", "text"]) if (body[field] !== void 0) refuse(field, `A template brings its own content. Remove \`${field}\`, or drop \`template\` and write the message yourself.`);
6803
+ return;
6804
+ }
6805
+ if (body.subject === void 0) missing("subject", MISSING("subject"));
6806
+ if (body.html === void 0 && body.text === void 0) missing("html", "Missing `html` or `text` field.");
6807
+ });
6808
+ /**
6809
+ * A batch: Resend posts the messages as a bare array, and answers with one
6810
+ * id per message in the order they were given. Every message is checked
6811
+ * before any of them is sent, so a batch with a mistake in it sends
6812
+ * nothing.
6813
+ */
6814
+ const resendBatchBodySchema = z.array(resendSendBodySchema, { error: (issue) => Array.isArray(issue.input) ? void 0 : "Send a list of messages." }).min(1, "Send at least one message.").max(100, `A batch takes at most ${100} messages. Split the list and send it in parts.`);
6815
+ /** What a send answers: the delivery's id, and nothing else. */
6816
+ const resendSentSchema = z.object({ id: z.string() });
6817
+ /** What a batch answers: one id per message, in the order they were given. */
6818
+ const resendBatchSentSchema = z.object({ data: z.array(resendSentSchema) });
6819
+ /**
6820
+ * Where a message got to, in Resend's vocabulary. Cowliss emits five of
6821
+ * them: a gate skip and a failure are both `failed`, because to a caller
6822
+ * they are the same fact, the message did not go.
6823
+ */
6824
+ const RESEND_LAST_EVENTS = [
6825
+ "sent",
6826
+ "delivered",
6827
+ "bounced",
6828
+ "complained",
6829
+ "failed"
6830
+ ];
6831
+ /**
6832
+ * Resend's email object. `html` and `text` are always null: Cowliss stores
6833
+ * no rendered bodies (ADR 0014), and the SDK does not need them to report
6834
+ * on a send.
6835
+ */
6836
+ const resendEmailSchema = z.object({
6837
+ object: z.literal("email"),
6838
+ id: z.string(),
6839
+ /** The provider's message id; null until the message reaches the provider. */
6840
+ message_id: z.string().nullable(),
6841
+ to: z.array(z.string()),
6842
+ from: z.string(),
6843
+ created_at: z.string(),
6844
+ subject: z.string(),
6845
+ html: z.null(),
6846
+ text: z.null(),
6847
+ bcc: z.array(z.string()),
6848
+ cc: z.array(z.string()),
6849
+ reply_to: z.array(z.string()),
6850
+ last_event: z.enum(RESEND_LAST_EVENTS),
6851
+ scheduled_at: z.null(),
6852
+ tags: z.array(z.object({
6853
+ name: z.string(),
6854
+ value: z.string()
6855
+ }))
6856
+ });
6857
+ /**
6858
+ * Resend's error body. `name` is the machine-readable one, which is what an
6859
+ * SDK caller branches on; the message is for the developer reading it.
6860
+ */
6861
+ const resendErrorSchema = z.object({
6862
+ statusCode: z.number(),
6863
+ message: z.string(),
6864
+ name: z.string()
6865
+ });
6866
+
6518
6867
  //#endregion
6519
6868
  //#region ../../packages/shared/src/violations.ts
6520
6869
  /**
@@ -6614,176 +6963,103 @@ const listReviewQuerySchema = paginationQuerySchema.extend({
6614
6963
  });
6615
6964
 
6616
6965
  //#endregion
6617
- //#region ../../packages/shared/src/segments.ts
6966
+ //#region ../../packages/shared/src/send-email.ts
6618
6967
  /**
6619
- * Segments API contracts. The definition is the interesting part: a flat
6620
- * list of predicates over traits and event history, combined with `all` or
6621
- * `any`. Deliberately flat — nested predicate groups are YAGNI for the
6622
- * prototype, and a flat list keeps the pure evaluator a fold.
6623
- *
6624
- * `appId` scopes the EVENT side of a definition only. Traits are
6625
- * per-profile and profiles merge across apps, so there is nothing
6626
- * app-shaped to filter on the trait side.
6627
- *
6628
- * A definition is shared by the environments it declares. `environments`
6629
- * defaults to both, and an app id lives in exactly one environment, so a
6630
- * definition that runs in both names both of an app's ids.
6968
+ * `POST /v1/emails`: the first-party transactional send, the one a
6969
+ * `@cowliss/sdk` client calls. The Resend-compatible facade (ADR 0014) is
6970
+ * the same send in somebody else's wire format; this is ours, so it is
6971
+ * camelCase, enveloped, and answers with our error codes.
6631
6972
  *
6632
- * The object is strict: a definition holding the retired `sourceId` key
6633
- * (which meant the app) fails loudly instead of parsing as an unfiltered
6634
- * definition that evaluates over every app. There is deliberately no pipe
6635
- * filter here — filtering by source is a later feature, and accepting one
6636
- * now would make a stale `sourceId` parse as a filter on a pipe that does
6637
- * not exist and silently match nothing.
6973
+ * The limits are deliberately the same numbers as the facade's: two shapes
6974
+ * over one send path must not accept different messages.
6638
6975
  */
6639
- const SEGMENT_TRAIT_OPS = [
6640
- "eq",
6641
- "neq",
6642
- "gt",
6643
- "gte",
6644
- "lt",
6645
- "lte",
6646
- "exists",
6647
- "notExists",
6648
- "contains"
6649
- ];
6650
- /** Operators that read no comparison value: presence of the key is the test. */
6651
- const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
6652
- const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
6653
- const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
6654
- kind: z.literal("trait"),
6655
- name: predicateNameSchema,
6656
- op: z.enum(SEGMENT_TRAIT_OPS),
6657
- /**
6658
- * Compared against the stored trait, which is `unknown` because
6659
- * identify accepts arbitrary JSON. The evaluator coerces both sides
6660
- * before comparing, so authors here (CLI, MCP, dashboard) get the
6661
- * form-field-friendly reading rather than strict JSON equality:
6662
- * numeric-looking strings are compared as numbers for eq/neq and for
6663
- * ordering ("150" matches 150, and orders like it), and "true"/"false"
6664
- * are compared as booleans for eq/neq, trimmed and case-insensitively
6665
- * (" TRUE " reads as true). Coercion needs both sides to agree on a
6666
- * type: "0" never equals false. The numeric net is as wide as
6667
- * `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
6668
- * matters most for opaque ids: "007" is authored as the number 7.
6669
- *
6670
- * contains reads three ways. Against an array trait it is membership
6671
- * under that same equality, so "true" matches `[true]` and "1" matches
6672
- * `[1, 2]`. Against a string trait it is a plain substring search with
6673
- * no coercion, since substrings only mean something between strings.
6674
- * Against anything else it never matches. Ordering never coerces
6675
- * booleans.
6676
- */
6677
- value: z.unknown().optional()
6678
- }), z.object({
6679
- kind: z.literal("event"),
6680
- name: predicateNameSchema,
6681
- op: z.enum(["performed", "notPerformed"]),
6682
- /** How many matching events the predicate counts as "performed". */
6683
- atLeast: z.number().int().min(1).default(1),
6684
- /** Rolling window, relative to evaluation time; absent means all history. */
6685
- withinDays: z.number().int().min(1).optional()
6686
- })]).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" });
6687
- const appIdSchema = z.string().trim().min(1);
6688
- const segmentDefinitionSchema = z.strictObject({
6689
- match: z.enum(["all", "any"]).default("all"),
6976
+ const addressSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "An address is \"ada@acme.com\" or a name and address, \"Ada <ada@acme.com>\"." });
6977
+ /** One address or a list, wherever a message names recipients. */
6978
+ const recipientsSchema = z.union([addressSchema, z.array(addressSchema).min(1).max(50)]);
6979
+ /** A template this organization pushed, plus the props it declares. */
6980
+ const templateSchema$1 = z.strictObject({
6981
+ key: journeyKeySchema,
6982
+ props: z.record(z.string(), z.unknown()).optional()
6983
+ });
6984
+ const sendEmailDataSchema = z.strictObject({
6985
+ sourceId: z.string(),
6986
+ to: recipientsSchema,
6987
+ cc: recipientsSchema.optional(),
6988
+ bcc: recipientsSchema.optional(),
6989
+ replyTo: recipientsSchema.optional(),
6690
6990
  /**
6691
- * Optional app filter over the event side; null/absent spans apps.
6692
- * A list matches any of the named apps, which is how one definition
6693
- * names the development and the production id of the same app.
6991
+ * An address on a domain this organization verified. There is no other.
6992
+ * Shape is checked here, the same as the Resend body checks it, so an
6993
+ * address that is not one is a 422 naming the field rather than a 403
6994
+ * telling the caller to verify a domain they never named.
6694
6995
  */
6695
- appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
6696
- predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
6697
- });
6698
- const segmentSchema = selectSegmentSchema.extend({
6699
- definition: segmentDefinitionSchema,
6700
- environments: environmentsSchema,
6701
- createdAt: z.iso.datetime(),
6702
- updatedAt: z.iso.datetime()
6703
- });
6704
- /** The detail view adds the materialized member count. */
6705
- const segmentDetailSchema = segmentSchema.extend({ memberCount: z.number().int().min(0) });
6706
- const segmentMemberSchema = z.object({
6707
- segmentId: z.string(),
6708
- orgId: z.string(),
6709
- profileId: z.string(),
6710
- enteredAt: z.iso.datetime()
6711
- });
6712
- /**
6713
- * The builder's design-time sanity check: run one not-yet-persisted
6714
- * definition over the org's existing profiles. Members is a small sample of
6715
- * matching profile ids, not the full member list: there is no segment row
6716
- * and no membership entry, so there is no segmentId or enteredAt to report.
6717
- *
6718
- * The scan is bounded, so `memberCount` is the exact org-wide count only
6719
- * when `truncated` is false. When it is true the scan stopped at the cap,
6720
- * and `memberCount` is the count over the first `scanned` profiles only.
6721
- */
6722
- const previewSegmentBodySchema = z.object({ data: segmentDefinitionSchema });
6723
- const segmentPreviewSchema = z.object({
6724
- memberCount: z.number().int().min(0),
6725
- members: z.array(z.string()),
6726
- /** Profiles the preview actually evaluated. */
6727
- scanned: z.number().int().min(0),
6728
- /** True when the scan hit its cap, so memberCount is a floor. */
6729
- truncated: z.boolean()
6996
+ from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }),
6997
+ subject: z.string().max(RESEND_MAX_SUBJECT).optional(),
6998
+ html: z.string().optional(),
6999
+ text: z.string().optional(),
7000
+ headers: z.record(z.string(), z.string()).optional(),
7001
+ template: templateSchema$1.optional()
7002
+ }).superRefine((body, ctx) => {
7003
+ const hasBody = body.html !== void 0 || body.text !== void 0;
7004
+ if (body.template) {
7005
+ for (const field of [
7006
+ "subject",
7007
+ "html",
7008
+ "text"
7009
+ ]) if (body[field] !== void 0) ctx.addIssue({
7010
+ code: "custom",
7011
+ path: [field],
7012
+ message: `A message names a template or writes its own body, not both. Drop \`${field}\`.`
7013
+ });
7014
+ return;
7015
+ }
7016
+ if (!hasBody) ctx.addIssue({
7017
+ code: "custom",
7018
+ path: ["html"],
7019
+ message: "A message needs a template, or an html or text body."
7020
+ });
7021
+ if (body.subject === void 0) ctx.addIssue({
7022
+ code: "custom",
7023
+ path: ["subject"],
7024
+ message: "A message that writes its own body needs a subject."
7025
+ });
6730
7026
  });
6731
- const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
6732
- const descriptionSchema = z.string().trim().max(500, "description must be at most 500 characters");
6733
- const createSegmentBodySchema = z.object({ data: z.object({
6734
- name: nameSchema,
6735
- description: descriptionSchema.nullish(),
6736
- definition: segmentDefinitionSchema,
6737
- /** The environments the segment works on; both when omitted. */
6738
- environments: environmentsSchema.default([...ENVIRONMENTS])
6739
- }) });
6740
- const updateSegmentBodySchema = z.object({ data: z.object({
6741
- name: nameSchema.optional(),
6742
- description: descriptionSchema.nullish(),
6743
- definition: segmentDefinitionSchema.optional(),
6744
- /** Changing this recomputes membership in the newly declared environments. */
6745
- environments: environmentsSchema.optional()
6746
- }).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0 || data.environments !== void 0, { message: "at least one field (name, description, definition or environments) is required" }) });
6747
- /** `q` is a substring search over the segment's name. */
6748
- const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
6749
- const listSegmentMembersQuerySchema = paginationQuerySchema;
7027
+ const sendEmailBodySchema = z.object({ data: sendEmailDataSchema });
7028
+ const sendEmailResultSchema = z.object({
7029
+ /** The delivery this send left behind: the row in Deliveries. */
7030
+ deliveryId: z.string() });
6750
7031
 
6751
7032
  //#endregion
6752
7033
  //#region ../../packages/shared/src/settings.ts
6753
7034
  /**
6754
7035
  * Settings contracts. The two operator-set values, ingestionPolicy (catalog
6755
- * governance) and eventRetentionDays (the ClickHouse TTL window), are per
6756
- * environment: `GET/PATCH /v1/settings/org` read and write the environment
6757
- * the selection header names, and the DTO says which one it is describing.
6758
- * The org's Clerk profile rides along read-only.
7036
+ * governance) and eventRetentionDays (the ClickHouse TTL window), are the
7037
+ * organization's, one each: `GET/PATCH /v1/settings/org` read and write
7038
+ * them. The org's Clerk profile rides along read-only.
6759
7039
  */
6760
7040
  const ingestionPolicySchema = z.enum(INGESTION_POLICIES);
6761
7041
  /**
6762
- * The environment's event retention window in days. Never null: an unset
6763
- * window is the environment's default, and there is no keep-forever. The
6764
- * schema caps at the largest environment's cap; the service enforces the
6765
- * selected environment's own (30 days in development) with a 422.
7042
+ * The org's event retention window in days. Never null: an unset window is
7043
+ * the default, and there is no keep-forever. Both default and cap are 18
7044
+ * months.
6766
7045
  */
6767
- const eventRetentionDaysSchema = z.number().int().min(1).max(Math.max(...Object.values(EVENT_RETENTION_DAYS)));
7046
+ const eventRetentionDaysSchema = z.number().int().min(1).max(548);
6768
7047
  const orgSettingsDtoSchema = z.object({
6769
- /** The environment these values belong to (from the selection header). */
6770
- environment: environmentSchema,
6771
7048
  ingestionPolicy: ingestionPolicySchema,
6772
7049
  eventRetentionDays: eventRetentionDaysSchema,
6773
7050
  /**
6774
7051
  * Clerk's slug and display name for the org, cached by the API and
6775
7052
  * READ-ONLY here: Clerk owns them, and the org profile is edited in
6776
- * Clerk's own UI. Exposed because they are the shared fallback sender the
6777
- * domains page shows (`<slug>@<shared domain>` as `<name>`). Null until a
6778
- * member has hit the dashboard API once.
7053
+ * Clerk's own UI. Exposed because a template test-send composes its
7054
+ * from-address out of them (`orgFromAddress`). Null until a member has hit
7055
+ * the dashboard API once.
6779
7056
  */
6780
7057
  slug: z.string().nullable(),
6781
7058
  name: z.string().nullable()
6782
7059
  });
6783
7060
  /**
6784
- * Settings patch for the selected environment. Each field is
6785
- * absent-means-no-change. At least one field must be present: an empty
6786
- * patch is a no-op request, not a valid one.
7061
+ * Settings patch. Each field is absent-means-no-change. At least one field
7062
+ * must be present: an empty patch is a no-op request, not a valid one.
6787
7063
  */
6788
7064
  const updateOrgSettingsBodySchema = z.object({ data: z.object({
6789
7065
  ingestionPolicy: ingestionPolicySchema.optional(),
@@ -6825,7 +7101,7 @@ const sourceSchema = selectSourceSchema.extend({
6825
7101
  */
6826
7102
  const SOURCE_KINDS = sourceKindEnum.enumValues;
6827
7103
  /**
6828
- * The per-kind source config schemas, mirroring the destinations
7104
+ * The per-kind source config schemas, mirroring the webhooks
6829
7105
  * discriminated-union pattern. The `api` source has nothing to configure:
6830
7106
  * ingestion authenticates with the org-level API key, so the row carries no
6831
7107
  * credential of its own. A Clerk source carries only the webhook signing
@@ -6842,7 +7118,7 @@ const apiSourceConfigSchema = z.strictObject({}, { error: "an api source takes n
6842
7118
  const clerkSourceConfigSchema = z.object({ signingSecret: z.string().min(1, "config.signingSecret is required") });
6843
7119
  /**
6844
7120
  * The source shapes, named individually so the dashboard's create form can
6845
- * resolve against exactly one of them (the destinations pattern: a form knows
7121
+ * resolve against exactly one of them (the webhooks pattern: a form knows
6846
7122
  * its kind before the user types anything, and zodResolver over a member is
6847
7123
  * what gives react-hook-form a non-union field path to register).
6848
7124
  *
@@ -6874,7 +7150,7 @@ const listSourcesQuerySchema = paginationQuerySchema;
6874
7150
  //#region ../../packages/shared/src/suppressions.ts
6875
7151
  /**
6876
7152
  * The suppression mirror's wire contracts. Derived from the Drizzle table so
6877
- * the row and the DTO cannot drift, same as deliveries and destinations.
7153
+ * the row and the DTO cannot drift, same as deliveries and webhooks.
6878
7154
  */
6879
7155
  const SUPPRESSION_TYPES = suppressionTypeEnum.enumValues;
6880
7156
  const suppressionTypeSchema = z.enum(SUPPRESSION_TYPES);
@@ -6897,7 +7173,7 @@ const listSuppressionsQuerySchema = paginationQuerySchema.extend({ q: searchQuer
6897
7173
  //#region ../../packages/shared/src/templates.ts
6898
7174
  /**
6899
7175
  * Templates as the API reports them. A template has versions and nothing
6900
- * else (ADR 0011): no row of its own, no environment, no flag. What is
7176
+ * else (ADR 0011): no row of its own, no flag. What is
6901
7177
  * reported here is one key, described by its newest version, plus the
6902
7178
  * journeys whose steps send it.
6903
7179
  *
@@ -6915,6 +7191,12 @@ const templateSchema = z.object({
6915
7191
  sendClass: z.enum(SEND_CLASSES),
6916
7192
  /** True when the template asks for a signed `verifyUrl` prop at send time. */
6917
7193
  verifyLink: z.boolean(),
7194
+ /**
7195
+ * True when the template asks for a signed `unsubscribeUrl` prop at send
7196
+ * time. A marketing template without one (or without rendering the link)
7197
+ * gets a platform footer with the link appended at send time instead.
7198
+ */
7199
+ unsubscribeLink: z.boolean().default(false),
6918
7200
  /** JSON Schema of the template's props, as `cow build` converted them. */
6919
7201
  propsSchema: z.record(z.string(), z.unknown()),
6920
7202
  /** The newest version of this key, whatever it compiled to. */
@@ -6932,6 +7214,8 @@ const templateSchema = z.object({
6932
7214
  const listTemplatesQuerySchema = paginationQuerySchema.extend({ q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0) });
6933
7215
  /** The props a preview or a test send renders the template with. */
6934
7216
  const renderTemplateBodySchema = z.object({ data: z.object({ props: z.record(z.string(), z.unknown()).default({}) }) });
7217
+ /** A test send is a delivery like any other, so it takes the same props. */
7218
+ const testSendTemplateBodySchema = z.object({ data: z.object({ props: z.record(z.string(), z.unknown()).default({}) }) });
6935
7219
  /** What the sandbox rendered: the message as it would go out. */
6936
7220
  const templatePreviewSchema = z.object({
6937
7221
  subject: z.string(),
@@ -6945,9 +7229,8 @@ const templatePreviewSchema = z.object({
6945
7229
  //#region ../../packages/shared/src/users.ts
6946
7230
  /**
6947
7231
  * Users API contracts (dashboard). Profiles are addressed by their
6948
- * Cowliss-generated id (`usr_`), within the environment the selection header
6949
- * names: a profile from the other environment reads as not found. A
6950
- * profile's event history is the event feed filtered to it
7232
+ * Cowliss-generated id (`usr_`). A profile's event history is the event
7233
+ * feed filtered to it
6951
7234
  * (GET /v1/events?profileId=), not an endpoint of its own.
6952
7235
  */
6953
7236
  /**
@@ -6963,6 +7246,20 @@ const userSegmentSchema = z.object({
6963
7246
  });
6964
7247
  const listUserSegmentsQuerySchema = paginationQuerySchema;
6965
7248
  /**
7249
+ * One journey the profile ran to completion
7250
+ * (GET /v1/users/:profileId/runs): which journey it was, when it finished,
7251
+ * and the execution it finished, which is null once that execution is no
7252
+ * longer kept. Paged on (completedAt, id) newest first, so the cursor's
7253
+ * sortAt slot carries the completion time.
7254
+ */
7255
+ const userRunSchema = z.object({
7256
+ id: z.string(),
7257
+ journeyKey: z.string(),
7258
+ completedAt: z.iso.datetime(),
7259
+ executionId: z.string().nullable()
7260
+ });
7261
+ const listUserRunsQuerySchema = paginationQuerySchema;
7262
+ /**
6966
7263
  * Profile list query. `q` is a free-text substring search over the
6967
7264
  * profile's identifier values and the identity traits in
6968
7265
  * PROFILE_SEARCH_TRAITS; absent means no filter. An empty or
@@ -7039,6 +7336,62 @@ const userExportSchema = z.object({
7039
7336
  quarantineEntries: z.array(quarantineEntrySchema)
7040
7337
  });
7041
7338
 
7339
+ //#endregion
7340
+ //#region ../../packages/shared/src/webhooks.ts
7341
+ /**
7342
+ * Webhooks API contracts. Derived from the Drizzle table via drizzle-zod.
7343
+ * signingSecret is deliberately absent from the DTO: it is shown exactly
7344
+ * once, in the create response (webhookCreatedSchema). consecutiveFailures
7345
+ * is absent too: it is the auto-disable counter's internal state, and
7346
+ * disabledAt is the part the dashboard banner needs.
7347
+ *
7348
+ * A webhook is the organization's: the name is unique per org, and every
7349
+ * execution posts to the same row.
7350
+ */
7351
+ /**
7352
+ * A webhook's name: the token a journey addresses it by, in a `send.webhook`
7353
+ * call. The journey names a string and the server resolves it, so the two
7354
+ * ends only have to agree on the characters that fit in one.
7355
+ */
7356
+ const webhookNameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
7357
+ const webhookConfigSchema = z.object({ url: z.url("config.url must be a valid URL").refine((url) => {
7358
+ try {
7359
+ return ["http:", "https:"].includes(new URL(url).protocol);
7360
+ } catch {
7361
+ return false;
7362
+ }
7363
+ }, { message: "config.url must use http or https" }) });
7364
+ const webhookSchema = selectWebhookSchema.extend({
7365
+ config: webhookConfigSchema,
7366
+ createdAt: z.iso.datetime(),
7367
+ updatedAt: z.iso.datetime(),
7368
+ disabledAt: z.iso.datetime().nullable()
7369
+ }).omit({
7370
+ signingSecret: true,
7371
+ consecutiveFailures: true
7372
+ });
7373
+ const webhookCreatedSchema = webhookSchema.extend({ signingSecret: z.string() });
7374
+ const createWebhookBodySchema = z.object({ data: z.object({
7375
+ name: webhookNameSchema,
7376
+ config: webhookConfigSchema
7377
+ }) });
7378
+ /**
7379
+ * Webhook update. `enabled` is the admin's half of the auto-disable loop:
7380
+ * the webhook send activity stamps `disabledAt` after enough consecutive
7381
+ * failures and every send after that records `skipped_disabled`, so without
7382
+ * a way to clear it the only exit would be deleting the webhook, which
7383
+ * throws away the signing secret the receiver is configured with.
7384
+ * `enabled: true` clears the stamp and the failure counter; `enabled: false`
7385
+ * is the same switch operated by hand.
7386
+ */
7387
+ const updateWebhookBodySchema = z.object({ data: z.object({
7388
+ name: webhookNameSchema.optional(),
7389
+ config: webhookConfigSchema.optional(),
7390
+ enabled: z.boolean().optional()
7391
+ }).refine((data) => data.name !== void 0 || data.config !== void 0 || data.enabled !== void 0, { message: "at least one field (name, config, or enabled) is required" }) });
7392
+ /** `q` is a substring search over the webhook's name. */
7393
+ const listWebhooksQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
7394
+
7042
7395
  //#endregion
7043
7396
  //#region ../../packages/api-client/src/client.ts
7044
7397
  var ApiError = class extends Error {
@@ -7222,7 +7575,7 @@ function readCliPackage() {
7222
7575
  * are. Reading the code sees every branch, labelled with its condition.
7223
7576
  *
7224
7577
  * What it reads: `await api.*` calls (in source order, with the literal
7225
- * template, destination, event, key, or duration when the author wrote one
7578
+ * template, webhook, event, key, or duration when the author wrote one
7226
7579
  * inline), `if`/`else`, `switch`, loops, `try`/`catch`, `return`, `throw`, and
7227
7580
  * `api.restart()`. Calls behind a helper function in another file, and a
7228
7581
  * `run` that is not an inline function on the `defineJourney` object, are
@@ -7433,12 +7786,9 @@ var Reader = class {
7433
7786
  if (timeout) entry.timeout = timeout;
7434
7787
  break;
7435
7788
  }
7436
- case "send.email": {
7789
+ case "send.email":
7437
7790
  detail(this.property(first, "template"));
7438
- const sender = this.property(first, "senderIdentity");
7439
- if (sender) entry.senderIdentity = sender;
7440
7791
  break;
7441
- }
7442
7792
  case "send.webhook":
7443
7793
  detail(this.property(first, "destination"));
7444
7794
  break;
@@ -7990,8 +8340,13 @@ async function buildProject(projectDir) {
7990
8340
  const manifestJourneys = [];
7991
8341
  const manifestTemplates = [];
7992
8342
  for (const built of bundles) {
7993
- const { module, runGuest } = await loadNodeBundle(projectDir, built.source.key, built.source.kind);
7994
- const report = await runGuest(module, { kind: "manifest" });
8343
+ let report;
8344
+ try {
8345
+ const { module, runGuest } = await loadNodeBundle(projectDir, built.source.key, built.source.kind);
8346
+ report = await runGuest(module, { kind: "manifest" });
8347
+ } catch (error) {
8348
+ throw new Error(`${built.source.relPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
8349
+ }
7995
8350
  const isJourney = built.source.kind === "journeys";
7996
8351
  if (report?.kind !== (isJourney ? "journey" : "template")) throw new Error(`${built.source.relPath} is not a ${isJourney ? "journey" : "template"}: a journey default-exports defineJourney({ ... }), a template exports a component and a zod \`props\` schema.`);
7997
8352
  if (report.kind === "journey") manifestJourneys.push({
@@ -7999,7 +8354,9 @@ async function buildProject(projectDir) {
7999
8354
  tags: report.tags,
8000
8355
  trigger: report.trigger,
8001
8356
  purpose: report.purpose,
8002
- senderIdentity: report.senderIdentity,
8357
+ enrollment: report.enrollment,
8358
+ description: report.description,
8359
+ from: report.from,
8003
8360
  spine: readSpine(await readFile(built.source.file, "utf8")),
8004
8361
  bundle: built.digest
8005
8362
  });
@@ -8008,6 +8365,7 @@ async function buildProject(projectDir) {
8008
8365
  tags: report.tags,
8009
8366
  sendClass: report.sendClass,
8010
8367
  verifyLink: report.verifyLink,
8368
+ unsubscribeLink: report.unsubscribeLink,
8011
8369
  propsSchema: report.propsSchema,
8012
8370
  bundle: built.digest
8013
8371
  });
@@ -8025,7 +8383,7 @@ async function buildProject(projectDir) {
8025
8383
  });
8026
8384
  if (failure) throw new Error(failure);
8027
8385
  const manifest = manifestSchema.parse({
8028
- protocol: 2,
8386
+ protocol: 3,
8029
8387
  sdk: (await readCliPackage()).version,
8030
8388
  journeys: manifestJourneys,
8031
8389
  templates: manifestTemplates,
@@ -8330,7 +8688,7 @@ function openBrowser(url) {
8330
8688
  //#region src/commands/auth.ts
8331
8689
  /** login/logout/whoami: session-token management, no contract route. */
8332
8690
  function registerAuth(program, env, io) {
8333
- program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a Clerk session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in the project config (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
8691
+ program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in the project config (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
8334
8692
  const outcome = await login(env, {
8335
8693
  token: opts.token,
8336
8694
  webUrl: opts.web,
@@ -9240,106 +9598,33 @@ const deliveries = defineModule(defineRoute({
9240
9598
  * The confirmation page's second button, "stop all marketing
9241
9599
  * email": the same token, revoking the master purpose instead of
9242
9600
  * the one the mail was sent under. A flag rather than a second
9243
- * route, because the claim already names the org and the profile.
9244
- * The List-Unsubscribe header never carries it, so a mail client's
9245
- * one-click POST stops one purpose, as RFC 8058 intends.
9246
- */
9247
- all: z.literal("1").optional() }) },
9248
- responses: {
9249
- 200: htmlResponse("Confirmation page"),
9250
- 400: { description: "Invalid or incomplete unsubscribe link" }
9251
- }
9252
- }), defineRoute({
9253
- method: "get",
9254
- path: "/v1/unsubscribe",
9255
- operationId: "deliveries.unsubscribePage",
9256
- tags: ["deliveries"],
9257
- summary: "Unsubscribe confirmation page (public)",
9258
- security: NO_AUTH,
9259
- surfaces: HIDDEN_FROM_TOOLS,
9260
- request: { query: tokenQuery },
9261
- responses: {
9262
- 200: htmlResponse("The page offering the unsubscribe"),
9263
- 400: { description: "Invalid or incomplete unsubscribe link" }
9264
- }
9265
- }));
9266
-
9267
- //#endregion
9268
- //#region ../../packages/shared/src/contract/destinations.ts
9269
- const params$9 = z.object({ id: z.string() });
9270
- const destinations = defineModule(defineRoute({
9271
- method: "post",
9272
- path: "/v1/destinations",
9273
- operationId: "destinations.create",
9274
- tags: ["destinations"],
9275
- summary: "Create a destination",
9276
- security: SESSION_AUTH,
9277
- request: { body: jsonBody(createDestinationBodySchema) },
9278
- responses: {
9279
- 201: envelope(destinationCreatedSchema, "The created destination; the only response carrying the signing secret"),
9280
- ...sessionErrors,
9281
- ...errors("validation_failed", "malformed_request")
9282
- }
9283
- }), defineRoute({
9284
- method: "get",
9285
- path: "/v1/destinations",
9286
- operationId: "destinations.list",
9287
- tags: ["destinations"],
9288
- summary: "List destinations in the selected environment",
9289
- security: SESSION_AUTH,
9290
- request: { query: listDestinationsQuerySchema },
9601
+ * route, because the claim already names the org and the profile.
9602
+ * The List-Unsubscribe header never carries it, so a mail client's
9603
+ * one-click POST stops one purpose, as RFC 8058 intends.
9604
+ */
9605
+ all: z.literal("1").optional() }) },
9291
9606
  responses: {
9292
- 200: list(destinationSchema),
9293
- ...sessionErrors,
9294
- ...errors("validation_failed")
9607
+ 200: htmlResponse("Confirmation page"),
9608
+ 400: { description: "Invalid or incomplete unsubscribe link" }
9295
9609
  }
9296
9610
  }), defineRoute({
9297
9611
  method: "get",
9298
- path: "/v1/destinations/{id}",
9299
- operationId: "destinations.get",
9300
- tags: ["destinations"],
9301
- summary: "Fetch one destination",
9302
- security: SESSION_AUTH,
9303
- request: { params: params$9 },
9304
- responses: {
9305
- 200: envelope(destinationSchema),
9306
- ...sessionErrors,
9307
- ...errors("not_found")
9308
- }
9309
- }), defineRoute({
9310
- method: "patch",
9311
- path: "/v1/destinations/{id}",
9312
- operationId: "destinations.update",
9313
- tags: ["destinations"],
9314
- summary: "Update a destination",
9315
- security: SESSION_AUTH,
9316
- request: {
9317
- params: params$9,
9318
- body: jsonBody(updateDestinationBodySchema)
9319
- },
9320
- responses: {
9321
- 200: envelope(destinationSchema),
9322
- ...sessionErrors,
9323
- ...errors("not_found", "validation_failed", "malformed_request")
9324
- }
9325
- }), defineRoute({
9326
- method: "delete",
9327
- path: "/v1/destinations/{id}",
9328
- operationId: "destinations.delete",
9329
- tags: ["destinations"],
9330
- summary: "Delete a destination",
9331
- security: SESSION_AUTH,
9332
- request: { params: params$9 },
9612
+ path: "/v1/unsubscribe",
9613
+ operationId: "deliveries.unsubscribePage",
9614
+ tags: ["deliveries"],
9615
+ summary: "Unsubscribe confirmation page (public)",
9616
+ security: NO_AUTH,
9617
+ surfaces: HIDDEN_FROM_TOOLS,
9618
+ request: { query: tokenQuery },
9333
9619
  responses: {
9334
- 200: envelope(deletedSchema),
9335
- ...sessionErrors,
9336
- ...errors("not_found")
9620
+ 200: htmlResponse("The page offering the unsubscribe"),
9621
+ 400: { description: "Invalid or incomplete unsubscribe link" }
9337
9622
  }
9338
9623
  }));
9339
9624
 
9340
9625
  //#endregion
9341
9626
  //#region ../../packages/shared/src/contract/domains.ts
9342
- const params$8 = z.object({ id: z.string() });
9627
+ const params$9 = z.object({ id: z.string() });
9343
9628
  const domains = defineModule(defineRoute({
9344
9629
  method: "get",
9345
9630
  path: "/v1/domains",
@@ -9360,7 +9645,7 @@ const domains = defineModule(defineRoute({
9360
9645
  tags: ["domains"],
9361
9646
  summary: "Fetch one sending domain",
9362
9647
  security: SESSION_AUTH,
9363
- request: { params: params$8 },
9648
+ request: { params: params$9 },
9364
9649
  responses: {
9365
9650
  200: envelope(senderDomainSchema),
9366
9651
  ...sessionErrors,
@@ -9371,11 +9656,11 @@ const domains = defineModule(defineRoute({
9371
9656
  path: "/v1/domains",
9372
9657
  operationId: "domains.create",
9373
9658
  tags: ["domains"],
9374
- summary: "Claim a sending domain",
9659
+ summary: "Add a sending domain",
9375
9660
  security: SESSION_AUTH,
9376
9661
  request: { body: jsonBody(createSenderDomainBodySchema) },
9377
9662
  responses: {
9378
- 201: envelope(senderDomainSchema, "The claimed domain with the DNS records to publish"),
9663
+ 201: envelope(senderDomainSchema, "The domain with the DNS records to publish, which is what claims it"),
9379
9664
  ...sessionErrors,
9380
9665
  ...errors("conflict", "validation_failed", "malformed_request", "dependency_unavailable")
9381
9666
  }
@@ -9388,7 +9673,7 @@ const domains = defineModule(defineRoute({
9388
9673
  description: "The Domain Connect link for this domain's DNS provider, which shows the admin the records and asks them to confirm. `url` is null when the provider does not support it.",
9389
9674
  security: SESSION_AUTH,
9390
9675
  surfaces: HIDDEN_FROM_TOOLS,
9391
- request: { params: params$8 },
9676
+ request: { params: params$9 },
9392
9677
  responses: {
9393
9678
  200: envelope(domainDnsSetupSchema),
9394
9679
  ...sessionErrors,
@@ -9401,7 +9686,7 @@ const domains = defineModule(defineRoute({
9401
9686
  tags: ["domains"],
9402
9687
  summary: "Re-check DNS now",
9403
9688
  security: SESSION_AUTH,
9404
- request: { params: params$8 },
9689
+ request: { params: params$9 },
9405
9690
  responses: {
9406
9691
  200: envelope(senderDomainSchema),
9407
9692
  ...sessionErrors,
@@ -9415,7 +9700,7 @@ const domains = defineModule(defineRoute({
9415
9700
  summary: "Toggle click tracking",
9416
9701
  security: SESSION_AUTH,
9417
9702
  request: {
9418
- params: params$8,
9703
+ params: params$9,
9419
9704
  body: jsonBody(updateSenderDomainBodySchema)
9420
9705
  },
9421
9706
  responses: {
@@ -9428,9 +9713,9 @@ const domains = defineModule(defineRoute({
9428
9713
  path: "/v1/domains/{id}",
9429
9714
  operationId: "domains.delete",
9430
9715
  tags: ["domains"],
9431
- summary: "Give up a domain claim",
9716
+ summary: "Give up a sending domain",
9432
9717
  security: SESSION_AUTH,
9433
- request: { params: params$8 },
9718
+ request: { params: params$9 },
9434
9719
  responses: {
9435
9720
  200: envelope(deletedSchema),
9436
9721
  ...sessionErrors,
@@ -9440,8 +9725,31 @@ const domains = defineModule(defineRoute({
9440
9725
 
9441
9726
  //#endregion
9442
9727
  //#region ../../packages/shared/src/contract/emails.ts
9443
- /** The public email-verification pages: the signed token is the authorization. */
9728
+ /**
9729
+ * The transactional send a developer's own code makes, plus the public
9730
+ * email-verification pages.
9731
+ *
9732
+ * `emails.send` is the first-party shape of the same send the Resend facade
9733
+ * serves (ADR 0014): one service, two wire formats, and this one answers in
9734
+ * the envelope with our error codes. It authenticates with an org API key
9735
+ * like the rest of the write path, which is why it is hidden from the CLI
9736
+ * and MCP surfaces: those hold a session.
9737
+ */
9444
9738
  const emails = defineModule(defineRoute({
9739
+ method: "post",
9740
+ path: "/v1/emails",
9741
+ operationId: "emails.send",
9742
+ tags: ["emails"],
9743
+ summary: "Send one transactional email",
9744
+ security: API_KEY_AUTH,
9745
+ surfaces: HIDDEN_FROM_TOOLS,
9746
+ request: { body: jsonBody(sendEmailBodySchema) },
9747
+ responses: {
9748
+ 201: envelope(sendEmailResultSchema, "Handed over; the delivery's id"),
9749
+ ...ingestionErrors,
9750
+ ...errors("forbidden", "not_found", "conflict")
9751
+ }
9752
+ }), defineRoute({
9445
9753
  method: "get",
9446
9754
  path: "/v1/public/verify",
9447
9755
  operationId: "emails.verifyPage",
@@ -9528,15 +9836,15 @@ const events = defineModule(defineRoute({
9528
9836
 
9529
9837
  //#endregion
9530
9838
  //#region ../../packages/shared/src/contract/executions.ts
9531
- const params$7 = z.object({ id: z.string() });
9839
+ const params$8 = z.object({ id: z.string() });
9532
9840
  /**
9533
9841
  * Executions: one run of one journey for one profile. Replaces v1's journey
9534
9842
  * instances, which were read live from Temporal and so could only be listed
9535
9843
  * per journey; these are rows, so they filter by journey, version, status,
9536
- * and profile in one place. The environment comes from the selection header.
9844
+ * and profile in one place.
9537
9845
  *
9538
- * `cancel` is the only write: stopping is explicit and per environment
9539
- * (ADR 0011), and each execution records `cancelled` when it reaches its
9846
+ * `cancel` is the only write: stopping is explicit (ADR 0011), and each
9847
+ * execution records `cancelled` when it reaches its
9540
9848
  * next step, so the answer is that the sweep started rather than a count.
9541
9849
  * A pipeline may push, enable, disable and READ executions; stopping runs
9542
9850
  * that are already carrying real people is a person's call, so cancel takes
@@ -9575,7 +9883,7 @@ const executions = defineModule(defineRoute({
9575
9883
  tags: ["executions"],
9576
9884
  summary: "Inspect one execution, with its logs",
9577
9885
  security: PIPELINE_AUTH,
9578
- request: { params: params$7 },
9886
+ request: { params: params$8 },
9579
9887
  responses: {
9580
9888
  200: envelope(executionDetailSchema),
9581
9889
  ...sessionErrors,
@@ -9688,16 +9996,11 @@ const ingestion = defineModule(defineRoute({
9688
9996
  * because the rows are derived from a push rather than created through the
9689
9997
  * API, and (org, key) is the only identity they have.
9690
9998
  *
9691
- * `list` and `get` report the enabled flag of BOTH environments whichever
9692
- * one the caller selected, because "is this on in production?" is the
9693
- * question they exist to answer; `enable` and `disable` act on the selected
9694
- * one, like every other environment-scoped write (docs/standards/api.md).
9695
- *
9696
9999
  * `journeys.listInstances` and `journeys.getInstance` are gone: executions
9697
10000
  * are their own resource now (`executions.list` / `executions.get`), and
9698
10001
  * `journeys.dryRun` answers with the execution it started.
9699
10002
  */
9700
- const params$6 = z.object({ key: z.string() });
10003
+ const params$7 = z.object({ key: z.string() });
9701
10004
  const journeys = defineModule(defineRoute({
9702
10005
  method: "get",
9703
10006
  path: "/v1/journeys",
@@ -9718,9 +10021,9 @@ const journeys = defineModule(defineRoute({
9718
10021
  tags: ["journeys"],
9719
10022
  summary: "Inspect one journey",
9720
10023
  security: SESSION_AUTH,
9721
- request: { params: params$6 },
10024
+ request: { params: params$7 },
9722
10025
  responses: {
9723
- 200: envelope(journeySchema),
10026
+ 200: envelope(journeyDetailSchema),
9724
10027
  ...sessionErrors,
9725
10028
  ...errors("not_found")
9726
10029
  }
@@ -9731,7 +10034,7 @@ const journeys = defineModule(defineRoute({
9731
10034
  tags: ["journeys"],
9732
10035
  summary: "Journey operational stats",
9733
10036
  security: SESSION_AUTH,
9734
- request: { params: params$6 },
10037
+ request: { params: params$7 },
9735
10038
  responses: {
9736
10039
  200: envelope(journeyStatsSchema),
9737
10040
  ...sessionErrors,
@@ -9739,31 +10042,33 @@ const journeys = defineModule(defineRoute({
9739
10042
  }
9740
10043
  }), defineRoute({
9741
10044
  method: "post",
9742
- path: "/v1/journeys/enable",
9743
- operationId: "journeys.enable",
10045
+ path: "/v1/journeys/status",
10046
+ operationId: "journeys.setStatus",
9744
10047
  tags: ["journeys"],
9745
- summary: "Turn journeys on in the selected environment",
10048
+ summary: "Put journeys on, off or on hold",
9746
10049
  security: PIPELINE_AUTH,
9747
10050
  surfaces: { cli: false },
9748
- request: { body: jsonBody(setJourneysEnabledBodySchema) },
10051
+ request: { body: jsonBody(setJourneysStatusBodySchema) },
9749
10052
  responses: {
9750
- 200: envelope(z.array(journeySchema), "The journeys as they now stand"),
10053
+ 200: envelope(z.array(journeyWithOfferSchema), "The journeys as they now stand, each with the members turning it on has waiting"),
9751
10054
  ...sessionErrors,
9752
10055
  ...errors("not_found", "project_missing", "validation_failed", "malformed_request")
9753
10056
  }
9754
10057
  }), defineRoute({
9755
- method: "post",
9756
- path: "/v1/journeys/disable",
9757
- operationId: "journeys.disable",
10058
+ method: "get",
10059
+ path: "/v1/journeys/{key}/waits",
10060
+ operationId: "journeys.waitsDue",
9758
10061
  tags: ["journeys"],
9759
- summary: "Turn journeys off in the selected environment",
9760
- security: PIPELINE_AUTH,
9761
- surfaces: { cli: false },
9762
- request: { body: jsonBody(setJourneysEnabledBodySchema) },
10062
+ summary: "Count the executions a hold until a given day would end",
10063
+ security: SESSION_AUTH,
10064
+ request: {
10065
+ params: params$7,
10066
+ query: waitsDueQuerySchema
10067
+ },
9763
10068
  responses: {
9764
- 200: envelope(z.array(journeySchema), "The journeys as they now stand"),
10069
+ 200: envelope(waitsDueSchema, "Executions in flight, and how many of them come due before that instant"),
9765
10070
  ...sessionErrors,
9766
- ...errors("not_found", "project_missing", "validation_failed", "malformed_request")
10071
+ ...errors("not_found", "validation_failed")
9767
10072
  }
9768
10073
  }), defineRoute({
9769
10074
  method: "delete",
@@ -9772,12 +10077,65 @@ const journeys = defineModule(defineRoute({
9772
10077
  tags: ["journeys"],
9773
10078
  summary: "Delete a journey",
9774
10079
  security: SESSION_AUTH,
9775
- request: { params: params$6 },
10080
+ request: { params: params$7 },
9776
10081
  responses: {
9777
10082
  200: envelope(deletedSchema),
9778
10083
  ...sessionErrors,
9779
10084
  ...errors("not_found")
9780
10085
  }
10086
+ }), defineRoute({
10087
+ method: "get",
10088
+ path: "/v1/journeys/{key}/enrollment/preview",
10089
+ operationId: "journeys.enrollmentPreview",
10090
+ tags: ["journeys"],
10091
+ summary: "Count who turning this journey on would reach",
10092
+ security: SESSION_AUTH,
10093
+ request: { params: params$7 },
10094
+ responses: {
10095
+ 200: envelope(enrollmentCountsSchema, "What enrolling the journey's current members would do"),
10096
+ ...sessionErrors,
10097
+ ...errors("not_found", "dependency_unavailable")
10098
+ }
10099
+ }), defineRoute({
10100
+ method: "post",
10101
+ path: "/v1/journeys/{key}/enrollment",
10102
+ operationId: "journeys.startEnrollment",
10103
+ tags: ["journeys"],
10104
+ summary: "Enroll the journey's current members",
10105
+ security: SESSION_AUTH,
10106
+ surfaces: HIDDEN_FROM_TOOLS,
10107
+ request: { params: params$7 },
10108
+ responses: {
10109
+ 202: envelope(enrollmentStatusSchema, "The job, as it starts"),
10110
+ ...sessionErrors,
10111
+ ...errors("not_found")
10112
+ }
10113
+ }), defineRoute({
10114
+ method: "get",
10115
+ path: "/v1/journeys/{key}/enrollment",
10116
+ operationId: "journeys.enrollmentStatus",
10117
+ tags: ["journeys"],
10118
+ summary: "How far the journey's enrollment has got",
10119
+ security: SESSION_AUTH,
10120
+ request: { params: params$7 },
10121
+ responses: {
10122
+ 200: envelope(enrollmentStatusSchema),
10123
+ ...sessionErrors,
10124
+ ...errors("not_found")
10125
+ }
10126
+ }), defineRoute({
10127
+ method: "post",
10128
+ path: "/v1/journeys/{key}/enrollment/cancel",
10129
+ operationId: "journeys.cancelEnrollment",
10130
+ tags: ["journeys"],
10131
+ summary: "Stop a running enrollment",
10132
+ security: SESSION_AUTH,
10133
+ request: { params: params$7 },
10134
+ responses: {
10135
+ 200: envelope(enrollmentStatusSchema, "The job as it stood when the stop was asked for"),
10136
+ ...sessionErrors,
10137
+ ...errors("not_found")
10138
+ }
9781
10139
  }), defineRoute({
9782
10140
  method: "post",
9783
10141
  path: "/v1/journeys/{key}/dry-run",
@@ -9786,7 +10144,7 @@ const journeys = defineModule(defineRoute({
9786
10144
  summary: "Dry-run a journey against a real user",
9787
10145
  security: SESSION_AUTH,
9788
10146
  request: {
9789
- params: params$6,
10147
+ params: params$7,
9790
10148
  body: jsonBody(dryRunJourneyBodySchema)
9791
10149
  },
9792
10150
  responses: {
@@ -9800,9 +10158,9 @@ const journeys = defineModule(defineRoute({
9800
10158
  //#region ../../packages/shared/src/contract/me.ts
9801
10159
  /**
9802
10160
  * The signed-in developer's own settings. Session auth like the rest of the
9803
- * dashboard API, but deliberately not org-scoped and indifferent to the
9804
- * environment header: the subject is the caller's own platform-workspace
9805
- * profile, taken from their session, never from the request.
10161
+ * dashboard API, but deliberately not org-scoped: the subject is the
10162
+ * caller's own platform-workspace profile, taken from their session, never
10163
+ * from the request.
9806
10164
  *
9807
10165
  * Hidden from the CLI and the MCP server. Both act with an org's key on that
9808
10166
  * org's data, and neither has a signed-in human whose mail preferences this
@@ -9899,7 +10257,7 @@ const project = defineModule(defineRoute({
9899
10257
 
9900
10258
  //#endregion
9901
10259
  //#region ../../packages/shared/src/contract/pushes.ts
9902
- const params$5 = z.object({ id: z.string() });
10260
+ const params$6 = z.object({ id: z.string() });
9903
10261
  /**
9904
10262
  * Pushes: one `cow push` each, the whole project at one point in time. The
9905
10263
  * push stores the source archive and its compile creates a version of every
@@ -9944,7 +10302,7 @@ const pushes = defineModule(defineRoute({
9944
10302
  tags: ["pushes"],
9945
10303
  summary: "Inspect one push and what it compiled",
9946
10304
  security: SESSION_AUTH,
9947
- request: { params: params$5 },
10305
+ request: { params: params$6 },
9948
10306
  responses: {
9949
10307
  200: envelope(pushSchema),
9950
10308
  ...sessionErrors,
@@ -9958,7 +10316,7 @@ const pushes = defineModule(defineRoute({
9958
10316
  summary: "Download the pushed source tarball",
9959
10317
  security: SESSION_AUTH,
9960
10318
  surfaces: HIDDEN_FROM_TOOLS,
9961
- request: { params: params$5 },
10319
+ request: { params: params$6 },
9962
10320
  responses: {
9963
10321
  200: {
9964
10322
  description: "The gzipped source tarball the push stored",
@@ -9969,6 +10327,98 @@ const pushes = defineModule(defineRoute({
9969
10327
  }
9970
10328
  }));
9971
10329
 
10330
+ //#endregion
10331
+ //#region ../../packages/shared/src/contract/resend.ts
10332
+ /**
10333
+ * The Resend-compatible send facade (ADR 0014): a developer points
10334
+ * `RESEND_BASE_URL` at `/resend/{sourceId}` and keeps their code. The source
10335
+ * in the path names the app, because the Resend SDK carries no other field
10336
+ * and accepts no custom header; the bearer is the organization's ingestion
10337
+ * key.
10338
+ *
10339
+ * These routes are the one exception to the envelope standard: they answer
10340
+ * Resend's own shapes, and the API mounts them on a sub-app with its own
10341
+ * error handler. They stay in the contract so the API still registers no
10342
+ * route outside it, and they are hidden from the CLI, the MCP server and the
10343
+ * generated reference: a Cowliss surface has the `/v1` routes, and the guide
10344
+ * is the place the facade is documented.
10345
+ */
10346
+ const FACADE_SURFACES = {
10347
+ cli: false,
10348
+ mcp: false,
10349
+ docs: false
10350
+ };
10351
+ const sourceParam = z.object({ sourceId: z.string().openapi({ example: "src_2f9a8c1b" }) });
10352
+ /** Resend's error body, for the answers this facade gives. */
10353
+ const facadeError = (description) => ({
10354
+ description,
10355
+ content: { "application/json": { schema: resendErrorSchema } }
10356
+ });
10357
+ const facadeErrors = {
10358
+ 401: facadeError("missing_api_key | invalid_api_key"),
10359
+ 404: facadeError("not_found: unknown source, or unknown message"),
10360
+ 422: facadeError("validation_error | missing_required_field"),
10361
+ 429: facadeError("monthly_quota_exceeded | rate_limit_exceeded"),
10362
+ 500: facadeError("application_error")
10363
+ };
10364
+ const resend = defineModule(defineRoute({
10365
+ method: "post",
10366
+ path: "/resend/{sourceId}/emails",
10367
+ operationId: "resend.send",
10368
+ tags: ["resend"],
10369
+ summary: "Send one email (Resend-compatible)",
10370
+ security: API_KEY_AUTH,
10371
+ surfaces: FACADE_SURFACES,
10372
+ request: {
10373
+ params: sourceParam,
10374
+ body: jsonBody(resendSendBodySchema)
10375
+ },
10376
+ responses: {
10377
+ 201: {
10378
+ description: "Accepted; the id is the delivery's",
10379
+ content: { "application/json": { schema: resendSentSchema } }
10380
+ },
10381
+ 403: facadeError("validation_error: the from-address sits on a domain this organization has not verified"),
10382
+ ...facadeErrors
10383
+ }
10384
+ }), defineRoute({
10385
+ method: "post",
10386
+ path: "/resend/{sourceId}/emails/batch",
10387
+ operationId: "resend.sendBatch",
10388
+ tags: ["resend"],
10389
+ summary: "Send up to 100 emails (Resend-compatible)",
10390
+ security: API_KEY_AUTH,
10391
+ surfaces: FACADE_SURFACES,
10392
+ request: {
10393
+ params: sourceParam,
10394
+ body: jsonBody(resendBatchBodySchema)
10395
+ },
10396
+ responses: {
10397
+ 201: {
10398
+ description: "Accepted; one id per message, in the order they were given",
10399
+ content: { "application/json": { schema: resendBatchSentSchema } }
10400
+ },
10401
+ 403: facadeError("validation_error: a from-address sits on a domain this organization has not verified"),
10402
+ ...facadeErrors
10403
+ }
10404
+ }), defineRoute({
10405
+ method: "get",
10406
+ path: "/resend/{sourceId}/emails/{id}",
10407
+ operationId: "resend.get",
10408
+ tags: ["resend"],
10409
+ summary: "Fetch one sent email (Resend-compatible)",
10410
+ security: API_KEY_AUTH,
10411
+ surfaces: FACADE_SURFACES,
10412
+ request: { params: sourceParam.extend({ id: z.string().openapi({ example: "dlv_2f9a8c1b" }) }) },
10413
+ responses: {
10414
+ 200: {
10415
+ description: "The email object",
10416
+ content: { "application/json": { schema: resendEmailSchema } }
10417
+ },
10418
+ ...facadeErrors
10419
+ }
10420
+ }));
10421
+
9972
10422
  //#endregion
9973
10423
  //#region ../../packages/shared/src/contract/review.ts
9974
10424
  /**
@@ -9994,7 +10444,7 @@ const review = defineModule(defineRoute({
9994
10444
 
9995
10445
  //#endregion
9996
10446
  //#region ../../packages/shared/src/contract/segments.ts
9997
- const params$4 = z.object({ id: z.string() });
10447
+ const params$5 = z.object({ id: z.string() });
9998
10448
  const writeErrors = errors("validation_failed", "malformed_request");
9999
10449
  /** `preview` precedes `get` so "preview" is never read as an id. */
10000
10450
  const segments = defineModule(defineRoute({
@@ -10044,7 +10494,7 @@ const segments = defineModule(defineRoute({
10044
10494
  tags: ["segments"],
10045
10495
  summary: "Fetch one segment with its member count",
10046
10496
  security: SESSION_AUTH,
10047
- request: { params: params$4 },
10497
+ request: { params: params$5 },
10048
10498
  responses: {
10049
10499
  200: envelope(segmentDetailSchema),
10050
10500
  ...sessionErrors,
@@ -10058,13 +10508,13 @@ const segments = defineModule(defineRoute({
10058
10508
  summary: "Update a segment",
10059
10509
  security: SESSION_AUTH,
10060
10510
  request: {
10061
- params: params$4,
10511
+ params: params$5,
10062
10512
  body: jsonBody(updateSegmentBodySchema)
10063
10513
  },
10064
10514
  responses: {
10065
10515
  200: envelope(segmentDetailSchema),
10066
10516
  ...sessionErrors,
10067
- ...errors("not_found"),
10517
+ ...errors("not_found", "conflict"),
10068
10518
  ...writeErrors
10069
10519
  }
10070
10520
  }), defineRoute({
@@ -10074,11 +10524,11 @@ const segments = defineModule(defineRoute({
10074
10524
  tags: ["segments"],
10075
10525
  summary: "Delete a segment",
10076
10526
  security: SESSION_AUTH,
10077
- request: { params: params$4 },
10527
+ request: { params: params$5 },
10078
10528
  responses: {
10079
10529
  200: envelope(deletedSchema),
10080
10530
  ...sessionErrors,
10081
- ...errors("not_found")
10531
+ ...errors("not_found", "conflict")
10082
10532
  }
10083
10533
  }), defineRoute({
10084
10534
  method: "get",
@@ -10088,7 +10538,7 @@ const segments = defineModule(defineRoute({
10088
10538
  summary: "List a segment's members",
10089
10539
  security: SESSION_AUTH,
10090
10540
  request: {
10091
- params: params$4,
10541
+ params: params$5,
10092
10542
  query: listSegmentMembersQuerySchema
10093
10543
  },
10094
10544
  responses: {
@@ -10213,7 +10663,7 @@ const settings = defineModule(defineRoute({
10213
10663
  //#endregion
10214
10664
  //#region ../../packages/shared/src/contract/sources.ts
10215
10665
  const appParams = z.object({ appId: z.string() });
10216
- const params$3 = z.object({ id: z.string() });
10666
+ const params$4 = z.object({ id: z.string() });
10217
10667
  /**
10218
10668
  * A source is one inbound pipe into an app. It is created and listed under
10219
10669
  * its parent app (the app is the attribution unit) and addressed by its own
@@ -10259,7 +10709,7 @@ const sources = defineModule(defineRoute({
10259
10709
  tags: ["sources"],
10260
10710
  summary: "Fetch one source",
10261
10711
  security: SESSION_AUTH,
10262
- request: { params: params$3 },
10712
+ request: { params: params$4 },
10263
10713
  responses: {
10264
10714
  200: envelope(sourceSchema),
10265
10715
  ...sessionErrors,
@@ -10273,7 +10723,7 @@ const sources = defineModule(defineRoute({
10273
10723
  summary: "Set or rotate a source's config",
10274
10724
  security: SESSION_AUTH,
10275
10725
  request: {
10276
- params: params$3,
10726
+ params: params$4,
10277
10727
  body: jsonBody(updateSourceBodySchema)
10278
10728
  },
10279
10729
  responses: {
@@ -10288,7 +10738,7 @@ const sources = defineModule(defineRoute({
10288
10738
  tags: ["sources"],
10289
10739
  summary: "Archive a source",
10290
10740
  security: SESSION_AUTH,
10291
- request: { params: params$3 },
10741
+ request: { params: params$4 },
10292
10742
  responses: {
10293
10743
  200: envelope(sourceSchema, "The archived source"),
10294
10744
  ...sessionErrors,
@@ -10359,7 +10809,7 @@ const suppressions = defineModule(defineRoute({
10359
10809
  * organization's default sender: it reaches no recipient, so it passes no
10360
10810
  * consent gate, counts against nothing, and is logged as a test.
10361
10811
  */
10362
- const params$2 = z.object({ key: z.string() });
10812
+ const params$3 = z.object({ key: z.string() });
10363
10813
  const templates = defineModule(defineRoute({
10364
10814
  method: "get",
10365
10815
  path: "/v1/templates",
@@ -10380,7 +10830,7 @@ const templates = defineModule(defineRoute({
10380
10830
  tags: ["templates"],
10381
10831
  summary: "Inspect one email template",
10382
10832
  security: SESSION_AUTH,
10383
- request: { params: params$2 },
10833
+ request: { params: params$3 },
10384
10834
  responses: {
10385
10835
  200: envelope(templateSchema),
10386
10836
  ...sessionErrors,
@@ -10394,7 +10844,7 @@ const templates = defineModule(defineRoute({
10394
10844
  summary: "Render a template with the props you pass",
10395
10845
  security: SESSION_AUTH,
10396
10846
  request: {
10397
- params: params$2,
10847
+ params: params$3,
10398
10848
  body: jsonBody(renderTemplateBodySchema)
10399
10849
  },
10400
10850
  responses: {
@@ -10410,8 +10860,8 @@ const templates = defineModule(defineRoute({
10410
10860
  summary: "Send a rendered template to your own address",
10411
10861
  security: SESSION_AUTH,
10412
10862
  request: {
10413
- params: params$2,
10414
- body: jsonBody(renderTemplateBodySchema)
10863
+ params: params$3,
10864
+ body: jsonBody(testSendTemplateBodySchema)
10415
10865
  },
10416
10866
  responses: {
10417
10867
  201: envelope(deliverySchema, "The test send, as it was logged"),
@@ -10425,7 +10875,7 @@ const templates = defineModule(defineRoute({
10425
10875
  tags: ["templates"],
10426
10876
  summary: "Delete an email template",
10427
10877
  security: SESSION_AUTH,
10428
- request: { params: params$2 },
10878
+ request: { params: params$3 },
10429
10879
  responses: {
10430
10880
  200: envelope(deletedSchema),
10431
10881
  ...sessionErrors,
@@ -10435,7 +10885,7 @@ const templates = defineModule(defineRoute({
10435
10885
 
10436
10886
  //#endregion
10437
10887
  //#region ../../packages/shared/src/contract/users.ts
10438
- const params$1 = z.object({ profileId: z.string() });
10888
+ const params$2 = z.object({ profileId: z.string() });
10439
10889
  /**
10440
10890
  * The answer a merged-away profile id gets (spec: Identity): 307 to the
10441
10891
  * same path with the survivor's id, so the method and body of a redirected
@@ -10446,9 +10896,8 @@ const mergedRedirect = { 307: {
10446
10896
  headers: z.object({ Location: z.string().meta({ description: "The survivor's URL" }) })
10447
10897
  } };
10448
10898
  /**
10449
- * Profiles by their Cowliss-generated id, within the environment the
10450
- * selection header names. `find` precedes `{profileId}` so the literal path
10451
- * wins.
10899
+ * Profiles by their Cowliss-generated id. `find` precedes `{profileId}` so
10900
+ * the literal path wins.
10452
10901
  */
10453
10902
  const users = defineModule(defineRoute({
10454
10903
  method: "get",
@@ -10483,7 +10932,7 @@ const users = defineModule(defineRoute({
10483
10932
  tags: ["users"],
10484
10933
  summary: "Fetch one profile with its identifiers",
10485
10934
  security: SESSION_AUTH,
10486
- request: { params: params$1 },
10935
+ request: { params: params$2 },
10487
10936
  responses: {
10488
10937
  200: envelope(userDetailSchema),
10489
10938
  ...mergedRedirect,
@@ -10497,7 +10946,7 @@ const users = defineModule(defineRoute({
10497
10946
  tags: ["users"],
10498
10947
  summary: "Erase a user (GDPR)",
10499
10948
  security: SESSION_AUTH,
10500
- request: { params: params$1 },
10949
+ request: { params: params$2 },
10501
10950
  responses: {
10502
10951
  200: envelope(eraseUserResponseSchema),
10503
10952
  ...mergedRedirect,
@@ -10511,7 +10960,7 @@ const users = defineModule(defineRoute({
10511
10960
  tags: ["users"],
10512
10961
  summary: "Export everything held on a user (GDPR)",
10513
10962
  security: SESSION_AUTH,
10514
- request: { params: params$1 },
10963
+ request: { params: params$2 },
10515
10964
  responses: {
10516
10965
  200: envelope(userExportSchema),
10517
10966
  ...mergedRedirect,
@@ -10526,7 +10975,7 @@ const users = defineModule(defineRoute({
10526
10975
  summary: "Update consent purposes",
10527
10976
  security: SESSION_AUTH,
10528
10977
  request: {
10529
- params: params$1,
10978
+ params: params$2,
10530
10979
  body: jsonBody(updateConsentBodySchema)
10531
10980
  },
10532
10981
  responses: {
@@ -10543,7 +10992,7 @@ const users = defineModule(defineRoute({
10543
10992
  summary: "The segments a profile belongs to",
10544
10993
  security: SESSION_AUTH,
10545
10994
  request: {
10546
- params: params$1,
10995
+ params: params$2,
10547
10996
  query: listUserSegmentsQuerySchema
10548
10997
  },
10549
10998
  responses: {
@@ -10554,17 +11003,17 @@ const users = defineModule(defineRoute({
10554
11003
  }
10555
11004
  }), defineRoute({
10556
11005
  method: "get",
10557
- path: "/v1/users/{profileId}/journeys",
10558
- operationId: "users.listJourneys",
11006
+ path: "/v1/users/{profileId}/runs",
11007
+ operationId: "users.listRuns",
10559
11008
  tags: ["users"],
10560
- summary: "A profile's journey timeline",
11009
+ summary: "The journeys a profile has completed",
10561
11010
  security: SESSION_AUTH,
10562
11011
  request: {
10563
- params: params$1,
10564
- query: listUserJourneysQuerySchema
11012
+ params: params$2,
11013
+ query: listUserRunsQuerySchema
10565
11014
  },
10566
11015
  responses: {
10567
- 200: list(userJourneySchema),
11016
+ 200: list(userRunSchema),
10568
11017
  ...mergedRedirect,
10569
11018
  ...sessionErrors,
10570
11019
  ...errors("not_found", "validation_failed")
@@ -10596,7 +11045,7 @@ const versions = defineModule(defineRoute({
10596
11045
 
10597
11046
  //#endregion
10598
11047
  //#region ../../packages/shared/src/contract/violations.ts
10599
- const params = z.object({ id: z.string() });
11048
+ const params$1 = z.object({ id: z.string() });
10600
11049
  const violations = defineModule(defineRoute({
10601
11050
  method: "get",
10602
11051
  path: "/v1/violations/{id}",
@@ -10604,7 +11053,7 @@ const violations = defineModule(defineRoute({
10604
11053
  tags: ["violations"],
10605
11054
  summary: "Fetch one violation",
10606
11055
  security: SESSION_AUTH,
10607
- request: { params },
11056
+ request: { params: params$1 },
10608
11057
  responses: {
10609
11058
  200: envelope(violationSchema),
10610
11059
  ...sessionErrors,
@@ -10618,7 +11067,7 @@ const violations = defineModule(defineRoute({
10618
11067
  summary: "Resolve a violation into the catalog",
10619
11068
  security: SESSION_AUTH,
10620
11069
  request: {
10621
- params,
11070
+ params: params$1,
10622
11071
  body: jsonBody(resolveViolationBodySchema)
10623
11072
  },
10624
11073
  responses: {
@@ -10633,7 +11082,7 @@ const violations = defineModule(defineRoute({
10633
11082
  tags: ["violations"],
10634
11083
  summary: "Dismiss a violation",
10635
11084
  security: SESSION_AUTH,
10636
- request: { params },
11085
+ request: { params: params$1 },
10637
11086
  responses: {
10638
11087
  200: envelope(violationSchema),
10639
11088
  ...sessionErrors,
@@ -10641,6 +11090,79 @@ const violations = defineModule(defineRoute({
10641
11090
  }
10642
11091
  }));
10643
11092
 
11093
+ //#endregion
11094
+ //#region ../../packages/shared/src/contract/webhooks.ts
11095
+ const params = z.object({ id: z.string() });
11096
+ const webhooks = defineModule(defineRoute({
11097
+ method: "post",
11098
+ path: "/v1/webhooks",
11099
+ operationId: "webhooks.create",
11100
+ tags: ["webhooks"],
11101
+ summary: "Create a webhook",
11102
+ security: SESSION_AUTH,
11103
+ request: { body: jsonBody(createWebhookBodySchema) },
11104
+ responses: {
11105
+ 201: envelope(webhookCreatedSchema, "The created webhook; the only response carrying the signing secret"),
11106
+ ...sessionErrors,
11107
+ ...errors("validation_failed", "malformed_request", "conflict")
11108
+ }
11109
+ }), defineRoute({
11110
+ method: "get",
11111
+ path: "/v1/webhooks",
11112
+ operationId: "webhooks.list",
11113
+ tags: ["webhooks"],
11114
+ summary: "List webhooks",
11115
+ security: SESSION_AUTH,
11116
+ request: { query: listWebhooksQuerySchema },
11117
+ responses: {
11118
+ 200: list(webhookSchema),
11119
+ ...sessionErrors,
11120
+ ...errors("validation_failed")
11121
+ }
11122
+ }), defineRoute({
11123
+ method: "get",
11124
+ path: "/v1/webhooks/{id}",
11125
+ operationId: "webhooks.get",
11126
+ tags: ["webhooks"],
11127
+ summary: "Fetch one webhook",
11128
+ security: SESSION_AUTH,
11129
+ request: { params },
11130
+ responses: {
11131
+ 200: envelope(webhookSchema),
11132
+ ...sessionErrors,
11133
+ ...errors("not_found")
11134
+ }
11135
+ }), defineRoute({
11136
+ method: "patch",
11137
+ path: "/v1/webhooks/{id}",
11138
+ operationId: "webhooks.update",
11139
+ tags: ["webhooks"],
11140
+ summary: "Update a webhook",
11141
+ security: SESSION_AUTH,
11142
+ request: {
11143
+ params,
11144
+ body: jsonBody(updateWebhookBodySchema)
11145
+ },
11146
+ responses: {
11147
+ 200: envelope(webhookSchema),
11148
+ ...sessionErrors,
11149
+ ...errors("not_found", "validation_failed", "malformed_request", "conflict")
11150
+ }
11151
+ }), defineRoute({
11152
+ method: "delete",
11153
+ path: "/v1/webhooks/{id}",
11154
+ operationId: "webhooks.delete",
11155
+ tags: ["webhooks"],
11156
+ summary: "Delete a webhook",
11157
+ security: SESSION_AUTH,
11158
+ request: { params },
11159
+ responses: {
11160
+ 200: envelope(deletedSchema),
11161
+ ...sessionErrors,
11162
+ ...errors("not_found")
11163
+ }
11164
+ }));
11165
+
10644
11166
  //#endregion
10645
11167
  //#region ../../packages/shared/src/contract/index.ts
10646
11168
  /**
@@ -10673,10 +11195,11 @@ const contract = {
10673
11195
  executions,
10674
11196
  journeys,
10675
11197
  templates,
10676
- destinations,
11198
+ webhooks,
10677
11199
  domains,
10678
11200
  deliveries,
10679
11201
  emails,
11202
+ resend,
10680
11203
  suppressions,
10681
11204
  settings,
10682
11205
  billing
@@ -11048,53 +11571,84 @@ function registerContractCommands(program, run) {
11048
11571
  //#endregion
11049
11572
  //#region src/commands/enable.ts
11050
11573
  /**
11051
- * `cow enable` and `cow disable`: the one gate on a journey, per
11052
- * environment (ADR 0011). Hand-written rather than derived from
11053
- * `journeys.enable`, because the commands are top-level names a developer
11054
- * types, and because of the `--env` rule below; the route itself takes the
11055
- * same set of keys they do.
11574
+ * `cow enable`, `cow disable` and `cow pause`: the one gate on a journey
11575
+ * (ADR 0011, ADR 0013, ADR 0018). Hand-written rather than derived from
11576
+ * `journeys.setStatus`, because the commands are top-level names a developer
11577
+ * types; the route itself takes the same set of keys they do.
11056
11578
  *
11057
- * `--env` is never defaulted here (`requireEnvironment`): the default
11058
- * environment is production, and a flag nobody typed must not put code in
11059
- * front of real recipients.
11579
+ * `cow enable` on a journey on hold is how it resumes: the server has one
11580
+ * transition to `on` and no second word for it.
11060
11581
  */
11061
11582
  /**
11062
11583
  * One call, whichever way the keys were named: the route takes the set, and
11063
11584
  * `--all` is the project name, resolved on the server against the rows it
11064
11585
  * owns (so it reaches a key whose file the tree lost). A key that is not
11065
- * there refuses the whole call, and nothing flips.
11066
- */
11067
- async function setEnabled(client, selector, enabled) {
11068
- const route = enabled ? contract.journeys["journeys.enable"] : contract.journeys["journeys.disable"];
11069
- return (await client.request(route, { body: selector })).data;
11586
+ * there refuses the whole call, and nothing changes.
11587
+ */
11588
+ async function setStatus(client, selector, status) {
11589
+ return (await client.request(contract.journeys["journeys.setStatus"], { body: {
11590
+ ...selector,
11591
+ status
11592
+ } })).data;
11593
+ }
11594
+ /**
11595
+ * Who is already standing in a journey that was just turned on, and where
11596
+ * to let them in (ADR 0015). Printed, never acted on: enrolling a cohort
11597
+ * spends real money and reaches real people, so it stays a person's click
11598
+ * in the dashboard and no command here starts one.
11599
+ */
11600
+ function segmentLines(journey) {
11601
+ const offer = journey.enrollment;
11602
+ if (!offer) return [];
11603
+ if (!offer.counts) return [` Who is in its segment could not be counted just now: ${offer.url}`];
11604
+ const { scanned, enrolled, skipped } = offer.counts;
11605
+ if (scanned === 0) return [" Nobody is in its segment yet."];
11606
+ return [
11607
+ ` ${scanned} in its segment now, ${enrolled} of them can enter it now.`,
11608
+ ...Object.entries(ENROLLMENT_SKIP_WORDS).map(([key, words]) => ({
11609
+ value: skipped[key],
11610
+ words
11611
+ })).filter(({ value }) => value > 0).map(({ value, words }) => ` ${value} ${words.reason}.`),
11612
+ ...enrolled > 0 ? [` To let those ${enrolled} in: ${offer.url}`] : []
11613
+ ];
11070
11614
  }
11071
- /** What the terminal says: one line per key, naming where it now stands. */
11072
- function flipSummary(journeys, enabled, environment) {
11073
- if (journeys.length === 0) return `This project has no journeys to turn ${enabled ? "on" : "off"}.`;
11074
- return journeys.map((journey) => `${journey.key} is ${enabled ? "on" : "off"} in ${environment}.`).join("\n");
11615
+ /** How the terminal names each state. */
11616
+ const STATE_WORD = {
11617
+ on: "on",
11618
+ off: "off",
11619
+ paused: "on hold"
11620
+ };
11621
+ /** What the terminal says: one line per key, naming its new state. */
11622
+ function flipSummary(journeys, status) {
11623
+ if (journeys.length === 0) return `This project has no journeys to put ${STATE_WORD[status]}.`;
11624
+ return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
11075
11625
  }
11076
- function register(program, clientFor, env, io, enabled) {
11077
- const verb = enabled ? "enable" : "disable";
11078
- program.command(verb).description(`turn journeys ${enabled ? "on" : "off"} in one environment (many keys, or --all for this project's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this project").action(async (keys, opts) => {
11626
+ const DESCRIPTIONS = {
11627
+ on: "turn journeys on",
11628
+ off: "turn journeys off",
11629
+ paused: "hold journeys: nobody enters, and everyone part-way through waits"
11630
+ };
11631
+ function register(program, clientFor, io, verb, status) {
11632
+ program.command(verb).description(`${DESCRIPTIONS[status]} (many keys, or --all for this project's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this project").action(async (keys, opts) => {
11079
11633
  const merged = {
11080
11634
  ...program.opts(),
11081
11635
  ...opts
11082
11636
  };
11083
- const environment = requireEnvironment(env, merged);
11084
- if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome --env ${environment}.`);
11085
- if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome --env ${environment}.`);
11637
+ if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
11638
+ if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
11086
11639
  const selector = merged.all === true ? { project: (await assertCowConfig(process.cwd())).project } : { keys };
11087
- const updated = await setEnabled(await clientFor(merged), selector, enabled);
11640
+ const updated = await setStatus(await clientFor(merged), selector, status);
11088
11641
  if (merged.json === true) {
11089
11642
  emit({ data: updated }, io, true);
11090
11643
  return;
11091
11644
  }
11092
- io.stdout(`${flipSummary(updated, enabled, environment)}\n`);
11645
+ io.stdout(`${flipSummary(updated, status)}\n`);
11093
11646
  });
11094
11647
  }
11095
- function registerEnable(program, clientFor, env, io) {
11096
- register(program, clientFor, env, io, true);
11097
- register(program, clientFor, env, io, false);
11648
+ function registerEnable(program, clientFor, io) {
11649
+ register(program, clientFor, io, "enable", "on");
11650
+ register(program, clientFor, io, "disable", "off");
11651
+ register(program, clientFor, io, "pause", "paused");
11098
11652
  }
11099
11653
 
11100
11654
  //#endregion
@@ -11150,7 +11704,7 @@ async function packageJson(name) {
11150
11704
  * ponytail: plain fetch because `POST /v1/project` is not a contract route
11151
11705
  * yet; swap it for the typed client's `project.create` once ticket 04 lands.
11152
11706
  */
11153
- async function createProject(apiUrl, token, environment, name) {
11707
+ async function createProject(apiUrl, token, name) {
11154
11708
  if (!token) return {
11155
11709
  project: "skipped",
11156
11710
  reason: "not logged in"
@@ -11160,8 +11714,7 @@ async function createProject(apiUrl, token, environment, name) {
11160
11714
  method: "POST",
11161
11715
  headers: {
11162
11716
  "content-type": "application/json",
11163
- authorization: `Bearer ${token}`,
11164
- [ENVIRONMENT_HEADER]: environment
11717
+ authorization: `Bearer ${token}`
11165
11718
  },
11166
11719
  body: JSON.stringify({ data: { name } })
11167
11720
  });
@@ -11220,7 +11773,7 @@ function registerInit(program, env, io) {
11220
11773
  }
11221
11774
  const example = typeof opts.example === "string" ? opts.example : void 0;
11222
11775
  const files = [...Object.keys(contents), ...example ? await copyExample(example, dir, false) : []].sort();
11223
- const outcome = await createProject(resolveApiUrl(env, credentials, typeof merged.api === "string" ? merged.api : void 0), resolveCredential(env, credentials).token, (typeof merged.env === "string" ? merged.env : void 0) ?? env.COW_ENVIRONMENT ?? "production", name);
11776
+ const outcome = await createProject(resolveApiUrl(env, credentials, typeof merged.api === "string" ? merged.api : void 0), resolveCredential(env, credentials).token, name);
11224
11777
  emit({ data: {
11225
11778
  dir,
11226
11779
  orgId,
@@ -11535,7 +12088,7 @@ async function pushProject({ client, projectDir }) {
11535
12088
  push: (await client.request(contract.pushes["pushes.create"], { body: {
11536
12089
  manifest: {
11537
12090
  ...manifest,
11538
- protocol: 2
12091
+ protocol: 3
11539
12092
  },
11540
12093
  project: project.name
11541
12094
  } })).data,
@@ -11634,13 +12187,10 @@ function versionPhrase(entry, now) {
11634
12187
  return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
11635
12188
  }
11636
12189
  function flagsPhrase(entry) {
11637
- if (!entry.enabled) return "";
11638
- const word = (on) => on ? "on" : "off";
11639
- return `${word(entry.enabled.development)} in development, ${word(entry.enabled.production)} in production`;
12190
+ return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
11640
12191
  }
11641
12192
  function livePhrase(entry) {
11642
- const running = (count, environment) => count === 0 ? [] : [`${count} running in ${environment}`];
11643
- return [...running(entry.liveExecutions.development, "development"), ...running(entry.liveExecutions.production, "production")].join(", ");
12193
+ return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
11644
12194
  }
11645
12195
  function pad(text, width) {
11646
12196
  return text.padEnd(width);
@@ -11690,11 +12240,9 @@ function statusLines(status, manifest, now) {
11690
12240
  lines.push("", "On the server and not in this tree:");
11691
12241
  for (const entry of missing) lines.push(` ${entry.key}, a ${entry.kind}. Delete it with cow ${entry.kind}s delete ${entry.key}.`);
11692
12242
  }
11693
- for (const environment of ["development", "production"]) {
11694
- const warnings = status.warnings[environment];
11695
- if (warnings.length === 0) continue;
11696
- lines.push("", `Names ${environment} does not define yet:`);
11697
- for (const warning of warnings) lines.push(` ${warning}`);
12243
+ if (status.warnings.length > 0) {
12244
+ lines.push("", "Names this organization does not define yet:");
12245
+ for (const warning of status.warnings) lines.push(` ${warning}`);
11698
12246
  }
11699
12247
  return lines;
11700
12248
  }
@@ -11889,7 +12437,7 @@ async function runScenario(projectDir, key, scenario) {
11889
12437
  let error;
11890
12438
  for (;;) {
11891
12439
  const output = await runGuest(module, {
11892
- protocol: 2,
12440
+ protocol: 3,
11893
12441
  kind: "journey",
11894
12442
  key,
11895
12443
  event: trigger,
@@ -11972,32 +12520,6 @@ function registerTest(program, io) {
11972
12520
 
11973
12521
  //#endregion
11974
12522
  //#region src/commands/index.ts
11975
- /**
11976
- * The environment a call acts on: the root `--env`, else `COW_ENVIRONMENT`,
11977
- * else production. The client sets it as the selection header; a command
11978
- * that also needs the value itself reads it from here, so the two can never
11979
- * disagree.
11980
- */
11981
- function environmentFor(env, opts) {
11982
- const flag = environmentSchema.safeParse(opts.env);
11983
- return flag.success ? flag.data : env.COW_ENVIRONMENT ?? "production";
11984
- }
11985
- /**
11986
- * The same value, but only when the caller actually chose one. `cow enable`
11987
- * and `cow disable` change what real recipients get, and the default is
11988
- * production: a bare `cow enable welcome` typed while thinking about
11989
- * development would turn it on in production. A pipeline is unaffected,
11990
- * because `COW_ENVIRONMENT` counts as having chosen.
11991
- */
11992
- function requireEnvironment(env, opts) {
11993
- if (opts.env === void 0 && env.COW_ENVIRONMENT === void 0) throw new Error(`Name the environment to act on: --env ${ENVIRONMENTS.join(" or --env ")}, or set COW_ENVIRONMENT.`);
11994
- return environmentFor(env, opts);
11995
- }
11996
- function parseEnvironment(value) {
11997
- const parsed = environmentSchema.safeParse(value);
11998
- if (!parsed.success) throw new InvalidArgumentError(`expected one of ${ENVIRONMENTS.join(", ")}`);
11999
- return parsed.data;
12000
- }
12001
12523
  /** Every command prints the response envelope as JSON. */
12002
12524
  function emit(result, io, json) {
12003
12525
  const compact = json === true || !io.isTTY;
@@ -12005,7 +12527,7 @@ function emit(result, io, json) {
12005
12527
  }
12006
12528
  function buildProgram(env, io) {
12007
12529
  const program = new Command();
12008
- program.name("cow").description("Admin CLI for Cowliss: a thin client of the dashboard API").option("--api <url>", "API base URL (overrides env and saved config)").option("--env <environment>", `environment to act on: ${ENVIRONMENTS.join(" | ")} (overrides COW_ENVIRONMENT; default ${DEFAULT_ENVIRONMENT})`, parseEnvironment).option("--json", "force compact single-line JSON output").option("--config <file>", `project config to read instead of ${DEFAULT_CONFIG_FILE} (overrides COW_CONFIG)`);
12530
+ program.name("cow").description("Admin CLI for Cowliss: a thin client of the dashboard API").option("--api <url>", "API base URL (overrides env and saved config)").option("--json", "force compact single-line JSON output").option("--config <file>", `project config to read instead of ${DEFAULT_CONFIG_FILE} (overrides COW_CONFIG)`);
12009
12531
  program.hook("preAction", () => {
12010
12532
  const flag = program.opts().config;
12011
12533
  setConfigFile((typeof flag === "string" ? flag : void 0) ?? env.COW_CONFIG ?? "cow.json");
@@ -12015,10 +12537,7 @@ function buildProgram(env, io) {
12015
12537
  return createClient({
12016
12538
  baseUrl: resolveApiUrl(env, credentials, typeof opts.api === "string" ? opts.api : void 0, (await readCowConfig(process.cwd()))?.apiUrl),
12017
12539
  auth: async () => resolveCredential(env, await readCredentials(env)).token,
12018
- headers: async () => ({
12019
- [ENVIRONMENT_HEADER]: environmentFor(env, opts),
12020
- [CLIENT_HEADER]: `@cowliss/cli/${(await readCliPackage()).version}`
12021
- })
12540
+ headers: async () => ({ [CLIENT_HEADER]: `@cowliss/cli/${(await readCliPackage()).version}` })
12022
12541
  });
12023
12542
  };
12024
12543
  const run = async (opts, fn) => {
@@ -12035,7 +12554,7 @@ function buildProgram(env, io) {
12035
12554
  registerBuild(program, io);
12036
12555
  registerPush(program, clientFor, env, io);
12037
12556
  registerStatus(program, clientFor, io);
12038
- registerEnable(program, clientFor, env, io);
12557
+ registerEnable(program, clientFor, io);
12039
12558
  registerPull(program, clientFor, io);
12040
12559
  registerTest(program, io);
12041
12560
  registerMcp(program, clientFor, env, io);
@@ -12056,8 +12575,6 @@ const envSchema = z.object({
12056
12575
  COW_TOKEN: z.string().min(1).optional(),
12057
12576
  /** An org pipeline key for CI; when set it wins over the cached session (ticket 05 reads it). */
12058
12577
  COW_PIPELINE_KEY: z.string().min(1).optional(),
12059
- /** The environment admin calls select; `--env` overrides it, production is the default. */
12060
- COW_ENVIRONMENT: environmentSchema.optional(),
12061
12578
  /**
12062
12579
  * The project config to read instead of `cow.json`, so one checkout can
12063
12580
  * hold both the committed production config and a local override.