@cowliss/cli 0.5.0 → 0.6.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;
@@ -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
  *
@@ -3400,7 +3359,6 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3400
3359
  "skipped_quota",
3401
3360
  "skipped_frequency_cap",
3402
3361
  "skipped_consent",
3403
- "skipped_sender",
3404
3362
  "skipped_domain",
3405
3363
  "skipped_ssrf",
3406
3364
  "would_send",
@@ -3410,34 +3368,47 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3410
3368
  "would_skip_quota",
3411
3369
  "would_skip_frequency_cap",
3412
3370
  "would_skip_consent",
3413
- "would_skip_sender",
3414
3371
  "would_skip_domain",
3415
3372
  "would_skip_ssrf"
3416
3373
  ]);
3417
3374
  const deliveries$1 = pgTable("deliveries", {
3418
3375
  id: text("id").primaryKey(),
3419
3376
  orgId: text("org_id").notNull(),
3420
- /** The instance's environment, stamped from the workflow run input. */
3421
- environment: environmentEnum("environment").notNull(),
3422
3377
  /**
3423
3378
  * The recipient profile (`usr_`); a text column, since a delivery is a
3424
3379
  * 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.
3380
+ * goes to the member who asked for it rather than to anyone's profile,
3381
+ * and on an api send, which names addresses and knows no profile.
3426
3382
  */
3427
3383
  profileId: text("profile_id"),
3428
- /** The journey that sent this; null on a test send, which has none. */
3384
+ /**
3385
+ * The journey that sent this; null on a test send and on an api send,
3386
+ * neither of which has one.
3387
+ */
3429
3388
  journey: text("journey"),
3430
3389
  kind: deliveryKindEnum("kind").notNull().default("journey"),
3431
- /** Email template name or webhook destination name. */
3390
+ /**
3391
+ * Email template name or webhook name. An api send that named no
3392
+ * template says `email`: there is a message but no key to point at.
3393
+ */
3432
3394
  step: text("step").notNull(),
3433
3395
  channel: deliveryChannelEnum("channel").notNull(),
3434
3396
  status: deliveryStatusEnum("status").notNull(),
3435
3397
  dryRun: boolean("dry_run").notNull().default(false),
3436
3398
  /**
3437
- * Recipient email address or destination URL. PII: the logger redacts
3438
- * this path, so it is stored here and nowhere else.
3399
+ * Recipient email address or webhook URL. An api send to several
3400
+ * addresses records the first `to`; the rest are in the payload. PII:
3401
+ * the logger redacts this path, so it is stored here and nowhere else.
3439
3402
  */
3440
3403
  recipient: text("recipient"),
3404
+ /**
3405
+ * What the send was made of, per kind: a journey or test email holds the
3406
+ * template props (plus `rendered` on a dry run or a test), a webhook
3407
+ * holds its body, and an `api` send holds the caller's envelope as they
3408
+ * spelled it, `{ from, to, cc, bcc, reply_to, subject, headers,
3409
+ * template, variables }`. Never the rendered bodies of a real send: SES
3410
+ * has the message and the log does not keep a second copy.
3411
+ */
3441
3412
  payload: jsonb("payload").$type().notNull().default({}),
3442
3413
  /**
3443
3414
  * The provider's message id (SES's MessageId), stored so a feedback
@@ -3470,7 +3441,7 @@ const deliveries$1 = pgTable("deliveries", {
3470
3441
  precision: 3
3471
3442
  })
3472
3443
  }, (table) => [
3473
- index("deliveries_org_id_environment_created_at_idx").on(table.orgId, table.environment, table.createdAt),
3444
+ index("deliveries_org_id_created_at_idx").on(table.orgId, table.createdAt),
3474
3445
  index("deliveries_org_id_profile_id_created_at_idx").on(table.orgId, table.profileId, table.createdAt),
3475
3446
  uniqueIndex("deliveries_org_id_attempt_key_unique").on(table.orgId, table.attemptKey),
3476
3447
  uniqueIndex("deliveries_provider_id_unique").on(table.providerId),
@@ -3479,62 +3450,14 @@ const deliveries$1 = pgTable("deliveries", {
3479
3450
  const selectDeliverySchema = createSelectSchema(deliveries$1);
3480
3451
  const insertDeliverySchema = createInsertSchema(deliveries$1);
3481
3452
 
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
3453
  //#endregion
3529
3454
  //#region ../../packages/db/src/schema/email-addresses.ts
3530
3455
  /**
3531
3456
  * 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.
3457
+ * address, scoped to the org and keyed by the address rather than the
3458
+ * profile. Verification is address-scoped by nature: a profile that changes
3459
+ * its email has not verified the new one, and an address verified once
3460
+ * stays verified when the same profile comes back to it.
3538
3461
  *
3539
3462
  * The profile keeps its canonical `email` trait as the address Cowliss sends
3540
3463
  * to, plus the system-maintained `emailVerifiedAt` / `emailVerifiedAddress`
@@ -3552,7 +3475,6 @@ const verificationMethodEnum = pgEnum("verification_method", [
3552
3475
  const emailAddresses = pgTable("email_addresses", {
3553
3476
  id: text("id").primaryKey(),
3554
3477
  orgId: text("org_id").notNull(),
3555
- environment: environmentEnum("environment").notNull(),
3556
3478
  /** Stored lowercased and trimmed: the mailbox, not the way it was typed. */
3557
3479
  address: text("address").notNull(),
3558
3480
  /** Null until something verified the address. */
@@ -3564,45 +3486,10 @@ const emailAddresses = pgTable("email_addresses", {
3564
3486
  verificationMethod: verificationMethodEnum("verification_method"),
3565
3487
  createdAt: createdAt(),
3566
3488
  updatedAt: updatedAt()
3567
- }, (table) => [uniqueIndex("email_addresses_org_env_address_unique").on(table.orgId, table.environment, table.address)]);
3489
+ }, (table) => [uniqueIndex("email_addresses_org_address_unique").on(table.orgId, table.address)]);
3568
3490
  const selectEmailAddressSchema = createSelectSchema(emailAddresses);
3569
3491
  const insertEmailAddressSchema = createInsertSchema(emailAddresses);
3570
3492
 
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
3493
  //#endregion
3607
3494
  //#region ../../packages/db/src/schema/executions.ts
3608
3495
  const executionStatusEnum = pgEnum("execution_status", [
@@ -3615,7 +3502,6 @@ const executionStatusEnum = pgEnum("execution_status", [
3615
3502
  const executions$1 = pgTable("executions", {
3616
3503
  id: text("id").primaryKey(),
3617
3504
  orgId: text("org_id").notNull(),
3618
- environment: environmentEnum("environment").notNull(),
3619
3505
  /** The journey's key, not a foreign key: derived journey rows come and go with pushes. */
3620
3506
  journeyKey: text("journey_key").notNull(),
3621
3507
  profileId: text("profile_id").notNull(),
@@ -3664,9 +3550,9 @@ const executions$1 = pgTable("executions", {
3664
3550
  precision: 3
3665
3551
  })
3666
3552
  }, (table) => [
3667
- index("executions_org_id_environment_version_id_status_idx").on(table.orgId, table.environment, table.versionId, table.status),
3553
+ index("executions_org_id_version_id_status_idx").on(table.orgId, table.versionId, table.status),
3668
3554
  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)
3555
+ index("executions_org_id_started_at_idx").on(table.orgId, table.startedAt)
3670
3556
  ]);
3671
3557
  const selectExecutionSchema = createSelectSchema(executions$1);
3672
3558
  const insertExecutionSchema = createInsertSchema(executions$1);
@@ -3725,7 +3611,7 @@ const idempotencyKeys = pgTable("idempotency_keys", {
3725
3611
  //#endregion
3726
3612
  //#region ../../packages/db/src/schema/profiles.ts
3727
3613
  /**
3728
- * Profiles: one row per person per environment. Cowliss generates the id
3614
+ * Profiles: one row per person. Cowliss generates the id
3729
3615
  * (`usr_`) and that id is the person key everywhere: workflow ids,
3730
3616
  * ClickHouse rows, segment membership, deliveries, journey runs, erasure.
3731
3617
  * Callers never send it on the write path; they send named identifiers
@@ -3739,10 +3625,6 @@ const idempotencyKeys = pgTable("idempotency_keys", {
3739
3625
  * profile exists under at all. It is written by the identify path (a caller
3740
3626
  * passing `consent`), the consent editor, and the automatic revocations.
3741
3627
  *
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.
3745
- *
3746
3628
  * `mergedInto` is the merge pointer (spec: Identity). Null on a live
3747
3629
  * profile; on a profile a merge folded away it names the survivor, whose
3748
3630
  * reads union its merged ids. The graph stays flat: a merge re-points rows
@@ -3756,7 +3638,6 @@ const idempotencyKeys = pgTable("idempotency_keys", {
3756
3638
  const profiles = pgTable("profiles", {
3757
3639
  id: text("id").primaryKey(),
3758
3640
  orgId: text("org_id").notNull(),
3759
- environment: environmentEnum("environment").notNull(),
3760
3641
  appId: text("app_id").notNull(),
3761
3642
  sourceId: text("source_id").notNull(),
3762
3643
  traits: jsonb("traits").$type().notNull().default({}),
@@ -3768,7 +3649,7 @@ const profiles = pgTable("profiles", {
3768
3649
  createdAt: createdAt(),
3769
3650
  updatedAt: updatedAt()
3770
3651
  }, (table) => [
3771
- index("profiles_org_id_environment_idx").on(table.orgId, table.environment),
3652
+ index("profiles_org_id_idx").on(table.orgId),
3772
3653
  index("profiles_merged_into_idx").on(table.mergedInto),
3773
3654
  foreignKey({
3774
3655
  columns: [table.mergedInto],
@@ -3804,10 +3685,10 @@ const identifierKindEnum = pgEnum("identifier_kind", [
3804
3685
  ]);
3805
3686
  /**
3806
3687
  * 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.
3688
+ * pointing at the profile it resolved to. Unique per (org, kind, value),
3689
+ * which is what makes "a shared identifier means the same person"
3690
+ * enforceable at the row level: the same clerkId twice in one organization
3691
+ * is one person.
3811
3692
  *
3812
3693
  * Rows follow their profile: erasure deletes the profile and these cascade,
3813
3694
  * and a merge re-points them at the survivor.
@@ -3815,12 +3696,11 @@ const identifierKindEnum = pgEnum("identifier_kind", [
3815
3696
  const identifiers = pgTable("identifiers", {
3816
3697
  id: text("id").primaryKey(),
3817
3698
  orgId: text("org_id").notNull(),
3818
- environment: environmentEnum("environment").notNull(),
3819
3699
  kind: identifierKindEnum("kind").notNull(),
3820
3700
  value: text("value").notNull(),
3821
3701
  profileId: text("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
3822
3702
  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)]);
3703
+ }, (table) => [uniqueIndex("identifiers_org_kind_value_unique").on(table.orgId, table.kind, table.value), index("identifiers_profile_id_idx").on(table.profileId)]);
3824
3704
  const selectIdentifierSchema = createSelectSchema(identifiers);
3825
3705
  const insertIdentifierSchema = createInsertSchema(identifiers);
3826
3706
 
@@ -3846,8 +3726,6 @@ const insertIdentifierSchema = createInsertSchema(identifiers);
3846
3726
  const journeyRuns = pgTable("journey_runs", {
3847
3727
  id: text("id").primaryKey(),
3848
3728
  orgId: text("org_id").notNull(),
3849
- /** The instance's environment; development instances never land here (they bill nothing). */
3850
- environment: environmentEnum("environment").notNull(),
3851
3729
  /** The journey's name, not its registry id: the run is about the definition that ran. */
3852
3730
  journey: text("journey").notNull(),
3853
3731
  profileId: text("profile_id").notNull(),
@@ -3872,10 +3750,6 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3872
3750
  * there is no id of its own: the key is the name the author gave the file,
3873
3751
  * and the (org, key) pair is the only identity a journey has.
3874
3752
  *
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
3753
  * A push only ever adds and updates: it owns the keys its manifest carries
3880
3754
  * and leaves every other row alone, because an org's journeys can come from
3881
3755
  * several projects and a developer may push a project holding only some of
@@ -3883,9 +3757,9 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3883
3757
  * what makes a key another project owns a refused push rather than a silent
3884
3758
  * overwrite. Removing a journey is an explicit delete (ADR 0009).
3885
3759
  *
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).
3760
+ * Whether a journey fires is one flag and one only: `enabled`, which a
3761
+ * push never writes, so deploying code never turns anything on or off. The
3762
+ * author has no second gate of their own (ADR 0011).
3889
3763
  *
3890
3764
  * The descriptive columns are what its latest ready version reported, so a
3891
3765
  * failed compile leaves both the code and its description as they were.
@@ -3909,47 +3783,35 @@ const journeys$1 = pgTable("journeys", {
3909
3783
  */
3910
3784
  purpose: text("purpose").notNull(),
3911
3785
  spine: jsonb("spine").$type().notNull(),
3786
+ /**
3787
+ * The one gate on the journey: off until `cow enable` or a manager's
3788
+ * toggle, and never touched by a push (ADR 0011, amended by ADR 0013).
3789
+ */
3790
+ enabled: boolean("enabled").notNull().default(false),
3912
3791
  createdAt: createdAt(),
3913
3792
  updatedAt: updatedAt()
3914
3793
  }, (table) => [primaryKey({ columns: [table.orgId, table.key] })]);
3915
3794
  const selectJourneySchema = createSelectSchema(journeys$1);
3916
3795
  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
3796
 
3941
3797
  //#endregion
3942
3798
  //#region ../../packages/db/src/schema/org-settings.ts
3943
3799
  /**
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.
3800
+ * The ingestion policy for catalog governance. Single source of truth:
3801
+ * @cowliss/shared/governance derives INGESTION_POLICIES from
3802
+ * ingestionPolicyEnum.enumValues, so the enum and the settings contract
3803
+ * cannot drift.
3804
+ */
3805
+ const ingestionPolicyEnum = pgEnum("ingestion_policy", ["permissive", "strict"]);
3806
+ /**
3807
+ * Per-org settings. A row exists only once something has been set: no row
3808
+ * means every default applies, which readers fill in (same convention as
3809
+ * journey_states). That keeps a fresh org zero-config and keeps the write
3810
+ * path from having to seed rows it does not own.
3949
3811
  *
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.
3812
+ * Every value here is the organization's:
3813
+ * the ingestion policy, the event retention window, the sending kill
3814
+ * switch, the allowance override, and Clerk's cached profile.
3953
3815
  *
3954
3816
  * `slug` and `name` are the exception to "only what Cowliss owns": they are
3955
3817
  * Clerk's, cached here by the API's org-sync middleware because the worker
@@ -3967,6 +3829,21 @@ const orgSettings = pgTable("org_settings", {
3967
3829
  slug: text("slug"),
3968
3830
  name: text("name"),
3969
3831
  /**
3832
+ * Catalog governance: permissive accepts quarantined (unclassified) names
3833
+ * and observes them; strict routes them to the review queue instead of
3834
+ * applying them. One policy for the organization, applied to every write.
3835
+ */
3836
+ ingestionPolicy: ingestionPolicyEnum("ingestion_policy").notNull().default("permissive"),
3837
+ /**
3838
+ * The ClickHouse TTL window for the org's events, in days. Null means the
3839
+ * default (18 months, EVENT_RETENTION_DAYS.production in
3840
+ * packages/shared); there is no "forever", and the API refuses a value
3841
+ * above that cap. Development events are trimmed at the shorter
3842
+ * development cap whatever this says: the rule is
3843
+ * `effectiveRetentionDays` in packages/shared.
3844
+ */
3845
+ eventRetentionDays: integer("event_retention_days"),
3846
+ /**
3970
3847
  * The operator-set monthly free allowance override, in micro-dollars,
3971
3848
  * synced from the Clerk org's `publicMetadata.freeAllowanceMicros` by the
3972
3849
  * same org-sync read that caches slug and name. Null (the default) means
@@ -4019,8 +3896,7 @@ const insertOrgSettingsSchema = createInsertSchema(orgSettings);
4019
3896
  /**
4020
3897
  * An org's cow project: the folder a developer runs `cow init` in, and the
4021
3898
  * 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
3899
+ * its own release sequence and its own slice of the deployed journey rows. The name is how `cow.json` picks
4024
3900
  * one, so it is unique within the org.
4025
3901
  */
4026
3902
  const projects = pgTable("projects", {
@@ -4086,14 +3962,12 @@ const pushArtifacts = pgTable("push_artifacts", {
4086
3962
  //#endregion
4087
3963
  //#region ../../packages/db/src/schema/violations.ts
4088
3964
  /**
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.
3965
+ * Violations: one row per (orgId, kind, name) observed in ingestion that
3966
+ * the tracking plan catalog does not define. Observe and flag, never
3967
+ * reject: the payload is always written, the violation only records the
3968
+ * governance gap.
4095
3969
  *
4096
- * Uniqueness on (orgId, environment, kind, name) dedupes repeat sightings:
3970
+ * Uniqueness on (orgId, kind, name) dedupes repeat sightings:
4097
3971
  * an unknown event fired a thousand times is one open violation whose
4098
3972
  * lastSeenAt keeps bumping, not a thousand rows. The payload captures the first observed shape
4099
3973
  * (inferred property types for events, inferred type for traits) so resolve
@@ -4111,7 +3985,6 @@ const violationStatusEnum = pgEnum("violation_status", [
4111
3985
  const violations$1 = pgTable("violations", {
4112
3986
  id: text("id").primaryKey(),
4113
3987
  orgId: text("org_id").notNull(),
4114
- environment: environmentEnum("environment").notNull(),
4115
3988
  kind: violationKindEnum("kind").notNull(),
4116
3989
  name: text("name").notNull(),
4117
3990
  payload: jsonb("payload").$type().notNull(),
@@ -4136,7 +4009,7 @@ const violations$1 = pgTable("violations", {
4136
4009
  mode: "date",
4137
4010
  precision: 3
4138
4011
  })
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)]);
4012
+ }, (table) => [index("violations_org_id_idx").on(table.orgId), uniqueIndex("violations_org_kind_name_unique").on(table.orgId, table.kind, table.name)]);
4140
4013
  const selectViolationSchema = createSelectSchema(violations$1);
4141
4014
  const insertViolationSchema = createInsertSchema(violations$1);
4142
4015
 
@@ -4145,7 +4018,6 @@ const insertViolationSchema = createInsertSchema(violations$1);
4145
4018
  const quarantineEntries = pgTable("quarantine_entries", {
4146
4019
  id: text("id").primaryKey(),
4147
4020
  orgId: text("org_id").notNull(),
4148
- environment: environmentEnum("environment").notNull(),
4149
4021
  kind: violationKindEnum("kind").notNull(),
4150
4022
  name: text("name").notNull(),
4151
4023
  appId: text("app_id").notNull(),
@@ -4159,7 +4031,7 @@ const quarantineEntries = pgTable("quarantine_entries", {
4159
4031
  }).notNull(),
4160
4032
  createdAt: createdAt(),
4161
4033
  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)]);
4034
+ }, (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
4035
  const selectQuarantineEntrySchema = createSelectSchema(quarantineEntries);
4164
4036
  const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4165
4037
 
@@ -4170,10 +4042,6 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4170
4042
  * never hand-maintained: the engine in packages/segments recomputes on every
4171
4043
  * write and materializes the result into segment_members.
4172
4044
  *
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.
4176
- *
4177
4045
  * Timestamps use millisecond precision, same rationale as apps: JS Dates
4178
4046
  * carry ms only and cursor pagination compares createdAt for equality.
4179
4047
  */
@@ -4183,18 +4051,14 @@ const segments$1 = pgTable("segments", {
4183
4051
  name: text("name").notNull(),
4184
4052
  description: text("description"),
4185
4053
  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"]),
4188
4054
  createdAt: createdAt(),
4189
4055
  updatedAt: updatedAt()
4190
4056
  }, (table) => [index("segments_org_id_idx").on(table.orgId), uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name)]);
4191
4057
  /**
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.
4058
+ * Materialized membership: one row per (segment, profile) currently in the
4059
+ * segment. Deleting a segment drops its members with it, and erasing a
4060
+ * profile drops its memberships, so neither can leave orphaned membership
4061
+ * behind.
4198
4062
  *
4199
4063
  * The (orgId, profileId) index is the recompute lookup: every write asks
4200
4064
  * "which of this org's segments does this profile currently belong to?"
@@ -4203,19 +4067,13 @@ const segments$1 = pgTable("segments", {
4203
4067
  const segmentMembers = pgTable("segment_members", {
4204
4068
  segmentId: text("segment_id").notNull().references(() => segments$1.id, { onDelete: "cascade" }),
4205
4069
  orgId: text("org_id").notNull(),
4206
- /** The member profile's environment; always the profile's own. */
4207
- environment: environmentEnum("environment").notNull(),
4208
4070
  profileId: text("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
4209
4071
  enteredAt: timestamp("entered_at", {
4210
4072
  withTimezone: true,
4211
4073
  mode: "date",
4212
4074
  precision: 3
4213
4075
  }).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)]);
4076
+ }, (table) => [primaryKey({ columns: [table.segmentId, table.profileId] }), index("segment_members_org_id_profile_id_idx").on(table.orgId, table.profileId)]);
4219
4077
  const selectSegmentSchema = createSelectSchema(segments$1);
4220
4078
  const insertSegmentSchema = createInsertSchema(segments$1);
4221
4079
 
@@ -4287,14 +4145,14 @@ const insertSenderDomainSchema = createInsertSchema(senderDomains);
4287
4145
  * source is a way data reaches it, so "App A via Clerk" and "App A via the
4288
4146
  * SDK" land on the same app and therefore on the same profiles.
4289
4147
  *
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.
4148
+ * Every app has one source of kind `api`, the first-party SDK and HTTP
4149
+ * path, created with the app. It has an empty config and no credential, and
4150
+ * archiving it is that app's ingestion kill switch. Provider kinds sit
4151
+ * beside it, singleton per app.
4294
4152
  *
4295
4153
  * `config` holds the per-kind source configuration and `signingSecret` the
4296
4154
  * 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
4155
+ * secret), stored write-only (the webhooks show-once pattern: never on a
4298
4156
  * DTO). `kind` is immutable after creation; the webhook route dispatches on
4299
4157
  * it. `lastReceivedAt` is stamped per accepted delivery so the dashboard can
4300
4158
  * tell a wired-up source from a silent one.
@@ -4529,20 +4387,44 @@ const selectWalletTopupSchema = createSelectSchema(walletTopups);
4529
4387
  const insertWalletTopupSchema = createInsertSchema(walletTopups);
4530
4388
 
4531
4389
  //#endregion
4532
- //#region ../../packages/shared/src/environments.ts
4390
+ //#region ../../packages/db/src/schema/webhooks.ts
4533
4391
  /**
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.
4392
+ * Webhooks: named URLs that journeys POST to by name. A webhook is the
4393
+ * organization's: the name is unique per org, and a journey step naming
4394
+ * `webhook("crm")` resolves to the same row for every execution. A developer
4395
+ * who wants a local receiver creates a second webhook under another name.
4396
+ * Ms-precision timestamps, as everywhere, so a cursor round-trips.
4397
+ *
4398
+ * WEBHOOK_PAYLOAD_VERSION (packages/shared) pins the webhook payload shape;
4399
+ * the column records the version each webhook was created against.
4400
+ * signingSecret is the Standard Webhooks signing secret, generated at
4401
+ * creation and shown once — the API DTO omits it after the create response.
4544
4402
  */
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" });
4403
+ const webhooks$1 = pgTable("webhooks", {
4404
+ id: text("id").primaryKey(),
4405
+ orgId: text("org_id").notNull(),
4406
+ name: text("name").notNull(),
4407
+ config: jsonb("config").$type().notNull(),
4408
+ webhookPayloadVersion: integer("webhook_payload_version").notNull().default(1),
4409
+ signingSecret: text("signing_secret"),
4410
+ /**
4411
+ * Auto-disable state, mirroring how receivers treat us: the webhook send
4412
+ * activity bumps the counter once per failed delivery attempt (not once
4413
+ * per Temporal retry) and stamps disabledAt at the threshold. A disabled
4414
+ * webhook records `skipped_disabled` until an admin clears it; a
4415
+ * successful delivery resets the counter.
4416
+ */
4417
+ consecutiveFailures: integer("consecutive_failures").notNull().default(0),
4418
+ disabledAt: timestamp("disabled_at", {
4419
+ withTimezone: true,
4420
+ mode: "date",
4421
+ precision: 3
4422
+ }),
4423
+ createdAt: createdAt(),
4424
+ updatedAt: updatedAt()
4425
+ }, (table) => [index("webhooks_org_id_idx").on(table.orgId), uniqueIndex("webhooks_org_id_name_unique").on(table.orgId, table.name)]);
4426
+ const selectWebhookSchema = createSelectSchema(webhooks$1);
4427
+ const insertWebhookSchema = createInsertSchema(webhooks$1);
4546
4428
 
4547
4429
  //#endregion
4548
4430
  //#region ../../packages/shared/src/apps.ts
@@ -4553,10 +4435,10 @@ const environmentsSchema = z.array(environmentSchema).min(1, "at least one envir
4553
4435
  * JSON.stringify already emits it for Date values.
4554
4436
  *
4555
4437
  * 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.
4438
+ * config and no credential (its sources carry those, see
4439
+ * sources.ts). The two extra fields are read-model facts about those
4440
+ * sources, which is what the dashboard's list needs to say whether an app
4441
+ * has a pipe wired up without a request per card.
4560
4442
  */
4561
4443
  const appSchema = selectAppSchema.extend({
4562
4444
  createdAt: z.iso.datetime(),
@@ -4568,11 +4450,7 @@ const appSchema = selectAppSchema.extend({
4568
4450
  lastReceivedAt: z.iso.datetime().nullable()
4569
4451
  });
4570
4452
  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
- }) });
4453
+ const createAppBodySchema = z.object({ data: z.object({ name: nameSchema$2 }) });
4576
4454
  const updateAppBodySchema = z.object({ data: z.object({
4577
4455
  name: nameSchema$2.optional(),
4578
4456
  status: z.literal("archived").optional()
@@ -4769,15 +4647,44 @@ const triggerSchema = z.union([z.strictObject({
4769
4647
  appId: patternSchema.optional()
4770
4648
  }), z.strictObject({ segment: z.string().min(1) })]);
4771
4649
  /**
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.
4650
+ * The address half of a from-header: a local part, an `@`, and a dotted
4651
+ * domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
4652
+ * address literals): every character it refuses would have to be quoted or
4653
+ * escaped to survive a header, and none of them belong in an address a
4654
+ * journey sends from.
4655
+ */
4656
+ const FROM_ADDRESS = /^[^\s@<>",;]+@([^\s@<>",;.]+(?:\.[^\s@<>",;.]+)+)$/;
4657
+ /**
4658
+ * Parse a journey's or a send's `from`: `addr@domain`, or
4659
+ * `Name <addr@domain>` with the name optionally quoted. Null when it is
4660
+ * neither, which is what `cow build` refuses on and what the send-time
4661
+ * domain gate turns into a skip.
4662
+ *
4663
+ * One parser, and the send facade reuses it. A control character anywhere is
4664
+ * a refusal rather than something to strip: it survives quoting and would
4665
+ * inject a second header.
4666
+ */
4667
+ function parseFromAddress(from) {
4668
+ const text = from.trim();
4669
+ if (/\p{Cc}/u.test(text)) return null;
4670
+ const angled = /^(.*?)\s*<([^<>]*)>$/.exec(text);
4671
+ const address = (angled?.[2] ?? text).trim();
4672
+ const domain = FROM_ADDRESS.exec(address)?.[1];
4673
+ if (!domain) return null;
4674
+ const name = (angled?.[1] ?? "").trim().replace(/^"(.*)"$/s, "$1").replace(/\\(.)/g, "$1").trim();
4675
+ return {
4676
+ ...name ? { name } : {},
4677
+ address,
4678
+ domain: domain.toLowerCase()
4679
+ };
4680
+ }
4681
+ /**
4682
+ * The address a journey or one send leaves as: a string, not the name of a
4683
+ * configured row (ADR 0014). The domain is checked against the
4684
+ * organization's verified ones at push time and again at send time; the
4685
+ * shape is all that is checked here, because it is all a build can know.
4779
4686
  */
4780
- const destinationNameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
4687
+ 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
4688
  /** A content address: `sha256:` plus the lowercase hex digest. */
4782
4689
  const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "digest must be sha256:<64 hex>");
4783
4690
  /**
@@ -4813,13 +4720,6 @@ const SPINE_ENTRY_NAMES = [
4813
4720
  const spineEntrySchema = z.object({
4814
4721
  name: z.enum(SPINE_ENTRY_NAMES),
4815
4722
  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
4723
  /** A `waitForEvent` timeout, as the author wrote it. */
4824
4724
  timeout: z.string().max(50).optional(),
4825
4725
  get steps() {
@@ -4831,10 +4731,10 @@ const spineEntrySchema = z.object({
4831
4731
  }).meta({ id: "JourneySpineEntry" });
4832
4732
  /**
4833
4733
  * 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.
4734
+ * a manifest pushed before the author's rollout gate went away still
4735
+ * carries keys that no longer exist, and every stored manifest has to keep
4736
+ * parsing for good (ADR 0011). Zod strips them, and the journey runs when
4737
+ * its `enabled` flag says so, which is the one gate there is.
4838
4738
  */
4839
4739
  const manifestJourneySchema = z.object({
4840
4740
  key: journeyKeySchema,
@@ -4848,16 +4748,12 @@ const manifestJourneySchema = z.object({
4848
4748
  */
4849
4749
  purpose: consentPurposeKeySchema,
4850
4750
  /**
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.
4751
+ * The address every `send.email` in this journey goes out as, unless the
4752
+ * call names its own. Optional everywhere: an omitted one is the
4753
+ * organization's shared fallback address, which is what makes day-one
4754
+ * sending zero-config (ADR 0014).
4859
4755
  */
4860
- senderIdentity: destinationNameSchema.optional(),
4756
+ from: fromSchema.optional(),
4861
4757
  spine: z.array(spineEntrySchema),
4862
4758
  bundle: digestSchema
4863
4759
  });
@@ -4934,7 +4830,7 @@ const versionManifestSchema = z.union([manifestJourneySchema, manifestTemplateSc
4934
4830
  * further in the future than the clock-skew tolerance (the spec's "future
4935
4831
  * timestamps are rejected with 422" rule). Absent stays valid. Used by the
4936
4832
  * track and identify payload schemas; older-than-retention is a service
4937
- * check (withinRetention) because the window is per environment.
4833
+ * check (withinRetention) because the window is the org's setting.
4938
4834
  */
4939
4835
  function notFutureTimestamp(value) {
4940
4836
  if (value === void 0) return true;
@@ -5015,7 +4911,6 @@ const identifyBodySchema = z.object({ data: identifyDataSchema });
5015
4911
  const eventDtoSchema = z.object({
5016
4912
  id: z.string(),
5017
4913
  orgId: z.string(),
5018
- environment: environmentSchema,
5019
4914
  /** The app the event is attributed to: derived from the source. */
5020
4915
  appId: z.string(),
5021
4916
  /** The pipe it arrived through: what the caller named on the wire. */
@@ -5052,11 +4947,10 @@ const trackDataSchema = trackFieldsSchema.refine((data) => notFutureTimestamp(da
5052
4947
  });
5053
4948
  const trackBodySchema = z.object({ data: trackDataSchema });
5054
4949
  /**
5055
- * Environment-wide event feed query (GET /v1/events). Same opaque cursor as
4950
+ * The org-wide event feed query (GET /v1/events). Same opaque cursor as
5056
4951
  * every other list; the sort key is the event timestamp, newest first, so
5057
4952
  * 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.
4953
+ * and event are exact-match filters, all optional.
5060
4954
  *
5061
4955
  * This is the one place a pipe id is a filter: unlike a stored segment
5062
4956
  * definition, a query param is read-time state, so a stale `?sourceId=` is a
@@ -5076,8 +4970,8 @@ const listEventsQuerySchema = paginationQuerySchema.extend({
5076
4970
  profileId: z.string().trim().min(1).max(200).optional()
5077
4971
  });
5078
4972
  /**
5079
- * Event stats (GET /v1/events/stats) for the selected environment, the
5080
- * overview page's volume chart and per-app breakdown. `volume` is
4973
+ * Event stats (GET /v1/events/stats): the overview page's volume chart and
4974
+ * per-app breakdown. `volume` is
5081
4975
  * zero-filled per UTC day over the whole `days` window ending today, so the
5082
4976
  * chart gets a continuous time axis; `byApp` is sorted busiest-first with
5083
4977
  * app names joined in.
@@ -5097,8 +4991,8 @@ const eventStatsDtoSchema = z.object({
5097
4991
  }))
5098
4992
  });
5099
4993
  /**
5100
- * Per-name activity (GET /v1/events/stats/names) for the selected
5101
- * environment: what the event catalog shows beside each entry. `count` is
4994
+ * Per-name activity (GET /v1/events/stats/names): what the event catalog
4995
+ * shows beside each entry. `count` is
5102
4996
  * windowed over `days`; `lastSeenAt` is not windowed and is null only for a
5103
4997
  * name that has never been seen, so "quiet for two months" reads as a zero
5104
4998
  * count next to a real date rather than as no history.
@@ -5124,7 +5018,7 @@ const eventNameStatsDtoSchema = z.object({
5124
5018
  * API in one request instead of thousands of round trips.
5125
5019
  *
5126
5020
  * 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
5021
+ * never spans apps. Each item is one
5128
5022
  * flattened identify or track call tagged by `type`; the field schemas are
5129
5023
  * the single-call ones minus `sourceId`, so an item valid here is valid
5130
5024
  * there. Batch writes are QUIET in one sense: they skip raw-event journey
@@ -5427,8 +5321,11 @@ const listConsentPurposesQuerySchema = paginationQuerySchema;
5427
5321
  */
5428
5322
  const DELIVERY_CHANNELS = deliveryChannelEnum.enumValues;
5429
5323
  const DELIVERY_STATUSES = deliveryStatusEnum.enumValues;
5324
+ /** Why a send happened: a journey ran, a member tested, or a caller sent. */
5325
+ const DELIVERY_KINDS = deliveryKindEnum.enumValues;
5430
5326
  const deliveryStatusSchema = z.enum(DELIVERY_STATUSES);
5431
5327
  const deliveryChannelSchema = z.enum(DELIVERY_CHANNELS);
5328
+ const deliveryKindSchema = z.enum(DELIVERY_KINDS);
5432
5329
  /**
5433
5330
  * The SNS HTTPS envelope, common to every message SNS POSTs to the feedback
5434
5331
  * endpoint. `Message` is a JSON STRING (the SES event for a `Notification`,
@@ -5528,18 +5425,45 @@ const deliverySchema = selectDeliverySchema.omit({
5528
5425
  settledAt: z.iso.datetime().nullable()
5529
5426
  });
5530
5427
  /**
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.
5428
+ * The message a dry run rendered, stored under `payload.rendered` beside the
5429
+ * template data, so a developer can read what would have gone out. A real
5430
+ * send carries no rendering: SES got it, and the log does not keep a second
5431
+ * copy.
5534
5432
  */
5535
5433
  const renderedEmailSchema = z.object({
5536
5434
  subject: z.string(),
5537
5435
  html: z.string(),
5538
- text: z.string()
5436
+ text: z.string(),
5437
+ /**
5438
+ * Where a reply would have gone, when the send named one. Absent on a
5439
+ * test send and wherever none was named, which is replies going to the
5440
+ * from-address.
5441
+ */
5442
+ replyTo: z.email().optional()
5539
5443
  });
5540
5444
  /** One delivery with the payload the send rendered (GET /v1/deliveries/:id). */
5541
5445
  const deliveryDetailSchema = deliverySchema.extend({ payload: z.object({ rendered: renderedEmailSchema.optional() }).catchall(z.unknown()) });
5542
5446
  /**
5447
+ * The `payload` a delivery of kind `api` carries: the envelope the caller of
5448
+ * the send facade wrote, and never the bodies (ADR 0014). Every field is
5449
+ * optional because the caller named only some of them, and the row is read
5450
+ * back through this rather than cast, so a row written by an older shape
5451
+ * reads as a message with fields missing instead of one with a type nobody
5452
+ * checked.
5453
+ */
5454
+ const apiDeliveryPayloadSchema = z.object({
5455
+ from: z.string().optional(),
5456
+ to: z.array(z.string()).optional(),
5457
+ cc: z.array(z.string()).optional(),
5458
+ bcc: z.array(z.string()).optional(),
5459
+ reply_to: z.array(z.string()).optional(),
5460
+ subject: z.string().optional(),
5461
+ headers: z.record(z.string(), z.string()).optional(),
5462
+ /** The template key that wrote the message, and the props it took. */
5463
+ template: z.string().optional(),
5464
+ variables: z.record(z.string(), z.unknown()).optional()
5465
+ });
5466
+ /**
5543
5467
  * List query for the deliveries log. Every filter is exact-match and
5544
5468
  * optional; combining them ANDs them.
5545
5469
  *
@@ -5553,113 +5477,29 @@ const listDeliveriesQuerySchema = paginationQuerySchema.extend({
5553
5477
  direction: sortDirectionSchema.default("desc"),
5554
5478
  status: deliveryStatusSchema.optional(),
5555
5479
  journey: z.string().trim().min(1).max(200).optional(),
5556
- /** The template or destination this attempt was for; a template page's own log. */
5480
+ /** The template or webhook this attempt was for; a template page's own log. */
5557
5481
  step: z.string().trim().min(1).max(200).optional(),
5558
5482
  channel: deliveryChannelSchema.optional(),
5483
+ /** Why the send happened: a journey, a test send, or a direct API send. */
5484
+ kind: deliveryKindSchema.optional(),
5559
5485
  appId: z.string().trim().min(1).max(200).optional(),
5560
5486
  /** One person's deliveries, merged ids included, same as the event feed. */
5561
5487
  profileId: z.string().trim().min(1).max(200).optional(),
5562
5488
  /**
5563
5489
  * 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.
5490
+ * cursor a tail (`cow dev`) polls with, so a send and its later feedback
5491
+ * transition both arrive. Inclusive for the same reason as the execution
5492
+ * filter, and paged the same way: `createdAt` still orders the page, this
5493
+ * only narrows it.
5568
5494
  */
5569
5495
  since: z.iso.datetime().optional()
5570
5496
  });
5571
5497
 
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
5498
  //#endregion
5659
5499
  //#region ../../packages/shared/src/domains.ts
5660
5500
  /**
5661
5501
  * Sending-domain contracts. Derived from the Drizzle table so the row and
5662
- * the DTO cannot drift, same as deliveries, destinations, and suppressions.
5502
+ * the DTO cannot drift, same as deliveries, webhooks, and suppressions.
5663
5503
  *
5664
5504
  * Verification itself is SES's; everything here describes the claim and the
5665
5505
  * mirrored answer.
@@ -5715,20 +5555,6 @@ const domainDnsSetupSchema = z.object({
5715
5555
  provider: z.string().nullable()
5716
5556
  });
5717
5557
 
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
5558
  //#endregion
5733
5559
  //#region ../../packages/shared/src/patterns.ts
5734
5560
  /**
@@ -5856,8 +5682,8 @@ const properties = z.record(z.string(), z.unknown());
5856
5682
  * them. A name outside the union is rejected, never dispatched.
5857
5683
  *
5858
5684
  * 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
5685
+ * a module that returns an `orgId` or any other field beside the ones a
5686
+ * capability takes fails the parse instead of having it
5861
5687
  * quietly dropped. Tenant context comes from the workflow input, never from
5862
5688
  * guest output, and this is where saying so becomes checkable.
5863
5689
  */
@@ -5879,12 +5705,14 @@ const commandSchema = z.discriminatedUnion("name", [
5879
5705
  template: journeyKeySchema,
5880
5706
  props: properties,
5881
5707
  /**
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.
5708
+ * The address this mail leaves as. The guest SDK fills in the
5709
+ * journey's own whenever the call does not name one, so the host has
5710
+ * one resolution path and never reads the manifest to find it;
5711
+ * absent on both is the organization's shared fallback address.
5886
5712
  */
5887
- senderIdentity: destinationNameSchema
5713
+ from: fromSchema.optional(),
5714
+ /** Where a reply to this one mail goes, instead of the from-address. */
5715
+ replyTo: z.email().optional()
5888
5716
  })
5889
5717
  }),
5890
5718
  z.object({
@@ -5965,7 +5793,7 @@ const executionLimitsSchema = z.object({
5965
5793
  logLineBytes: z.number().int().positive()
5966
5794
  });
5967
5795
  const journeyStepInputSchema = z.object({
5968
- protocol: z.literal(2),
5796
+ protocol: z.literal(3),
5969
5797
  kind: z.literal("journey"),
5970
5798
  key: journeyKeySchema,
5971
5799
  event: guestEventSchema,
@@ -6005,7 +5833,7 @@ const journeyStepOutputSchema = z.discriminatedUnion("status", [
6005
5833
  })
6006
5834
  ]);
6007
5835
  const templateRenderInputSchema = z.object({
6008
- protocol: z.literal(2),
5836
+ protocol: z.literal(3),
6009
5837
  kind: z.literal("template"),
6010
5838
  key: journeyKeySchema,
6011
5839
  props: properties
@@ -6024,7 +5852,7 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
6024
5852
  const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
6025
5853
  trigger: true,
6026
5854
  purpose: true,
6027
- senderIdentity: true,
5855
+ from: true,
6028
5856
  tags: true
6029
5857
  }).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
6030
5858
  sendClass: true,
@@ -6088,7 +5916,7 @@ const executionDetailSchema = executionSchema.extend({
6088
5916
  logs: z.array(executionLogSchema)
6089
5917
  });
6090
5918
  const listExecutionsQuerySchema = paginationQuerySchema.extend({
6091
- /** A journey key; the environment comes from the selection header. */
5919
+ /** A journey key. */
6092
5920
  journey: z.string().trim().min(1).max(200).optional(),
6093
5921
  /** One version's own runs, for the history rows on a journey. */
6094
5922
  version: z.string().trim().min(1).max(200).optional(),
@@ -6104,12 +5932,15 @@ const listExecutionsQuerySchema = paginationQuerySchema.extend({
6104
5932
  since: z.iso.datetime().optional()
6105
5933
  });
6106
5934
  /**
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.
5935
+ * What to stop: every live execution of one journey, or the ones still
5936
+ * pinned to one version. Cancelling is explicit (ADR 0011): disabling a
5937
+ * journey stops new entries and lets what is running finish, and this is
5938
+ * the other verb.
6111
5939
  */
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) })]) });
5940
+ const cancelExecutionsBodySchema = z.object({ data: z.object({
5941
+ journey: z.string().trim().min(1).max(200).optional(),
5942
+ version: z.string().trim().min(1).max(200).optional()
5943
+ }).refine((data) => data.journey === void 0 !== (data.version === void 0), { message: "name a journey or a version, not both" }) });
6113
5944
  /**
6114
5945
  * Cancelling is a request, not an edit: each execution stops when it reaches
6115
5946
  * its next step, so the answer is that the sweep is under way rather than a
@@ -6127,9 +5958,8 @@ const cancellingSchema = z.object({ cancelling: z.literal(true) });
6127
5958
  const projectNameSchema = z.string().trim().min(1).max(200);
6128
5959
  /**
6129
5960
  * `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.
5961
+ * directory is. Auth never lives in the project. Strict, so a typo'd key
5962
+ * is a build error rather than a silently ignored setting.
6133
5963
  */
6134
5964
  const cowConfigSchema = z.strictObject({
6135
5965
  $schema: z.url().optional(),
@@ -6238,7 +6068,7 @@ const createPushBodySchema = z.object({ data: z.object({
6238
6068
  * is a live CLI talking to a live platform, so a mismatch here is a 422
6239
6069
  * the developer fixes by updating `@cowliss/cli`, and nothing is stored.
6240
6070
  */
6241
- manifest: manifestSchema.safeExtend({ protocol: z.literal(2) }),
6071
+ manifest: manifestSchema.safeExtend({ protocol: z.literal(3) }),
6242
6072
  /** The `cow.json` project this push belongs to. */
6243
6073
  project: projectNameSchema
6244
6074
  }) });
@@ -6270,13 +6100,10 @@ const projectStatusKeySchema = z.object({
6270
6100
  * server holds. Null only for a journey whose versions are all pruned.
6271
6101
  */
6272
6102
  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
- })
6103
+ /** The journey's one flag; null on a template, which has none. */
6104
+ enabled: z.boolean().nullable(),
6105
+ /** Executions still running or waiting. */
6106
+ liveExecutions: z.number().int()
6280
6107
  });
6281
6108
  /**
6282
6109
  * Everything the server knows about one project's keys, in one read: the
@@ -6285,17 +6112,14 @@ const projectStatusKeySchema = z.object({
6285
6112
  * is my project doing" is useless split across cursors.
6286
6113
  *
6287
6114
  * `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
6115
+ * deploy): a name a journey references that the organization does not
6116
+ * define yet. They never block, because a segment or a webhook may be created
6290
6117
  * right after a push.
6291
6118
  */
6292
6119
  const projectStatusSchema = z.object({
6293
6120
  project: projectNameSchema,
6294
6121
  keys: z.array(projectStatusKeySchema),
6295
- warnings: z.object({
6296
- development: z.array(z.string()),
6297
- production: z.array(z.string())
6298
- })
6122
+ warnings: z.array(z.string())
6299
6123
  });
6300
6124
 
6301
6125
  //#endregion
@@ -6307,11 +6131,7 @@ const projectStatusSchema = z.object({
6307
6131
  *
6308
6132
  * Derived from the Drizzle table via drizzle-zod so the wire DTO and the row
6309
6133
  * 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.
6134
+ * because the row types are opaque to drizzle-zod.
6315
6135
  */
6316
6136
  const journeyTriggerSchema = triggerSchema;
6317
6137
  /**
@@ -6324,7 +6144,7 @@ const journeySchema = selectJourneySchema.extend({
6324
6144
  trigger: journeyTriggerSchema,
6325
6145
  purpose: consentPurposeKeySchema,
6326
6146
  spine: z.array(journeySpineEntrySchema),
6327
- enabled: journeyEnabledSchema,
6147
+ enabled: z.boolean(),
6328
6148
  /**
6329
6149
  * The newest version of this key, whatever it compiled to, so a list can
6330
6150
  * say "pushed 2 hours ago" and "compiling" without a second read. What the
@@ -6343,7 +6163,7 @@ const journeySchema = selectJourneySchema.extend({
6343
6163
  * Both are server-side, like every other list filter.
6344
6164
  */
6345
6165
  const listJourneysQuerySchema = paginationQuerySchema.extend({
6346
- /** On or off in the environment the call selected. */
6166
+ /** On or off; the journey's one flag. */
6347
6167
  enabled: z.enum(["true", "false"]).optional(),
6348
6168
  q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0),
6349
6169
  tag: z.string().trim().max(50, "tag must be at most 50 characters").optional()
@@ -6372,29 +6192,6 @@ const journeyStatsSchema = z.object({
6372
6192
  deliveries: z.object({ byStatus: z.record(deliveryStatusSchema, z.number().int()) })
6373
6193
  });
6374
6194
  /**
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
6195
  * Which journeys the flag acts on: the keys the caller named, or every
6399
6196
  * journey of one project. Exactly one of the two, because a call carrying
6400
6197
  * both would have to decide which it meant.
@@ -6444,7 +6241,7 @@ const sandboxFailureCodeSchema = z.enum(SANDBOX_FAILURE_CODES);
6444
6241
  * to be written by hand or by an agent, not generated.
6445
6242
  *
6446
6243
  * 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
6244
+ * a bad file before it boots a Temporal dev server, and so the docs site can
6448
6245
  * render the format from one source.
6449
6246
  */
6450
6247
  const journeyScenarioSchema = z.object({
@@ -6515,6 +6312,168 @@ const notificationPreferencesSchema = z.object({ purposes: z.array(notificationP
6515
6312
  */
6516
6313
  const updateNotificationPreferencesBodySchema = z.object({ data: z.object({ purposes: consentPatchSchema }) });
6517
6314
 
6315
+ //#endregion
6316
+ //#region ../../packages/shared/src/resend.ts
6317
+ /** Resend's own cap on one message's recipients, per list. */
6318
+ const RESEND_MAX_RECIPIENTS = 50;
6319
+ /** Resend's own cap on one batch. */
6320
+ const RESEND_MAX_BATCH = 100;
6321
+ /** Resend's own cap on a from-header, a subject, and a message's tags. */
6322
+ const RESEND_MAX_FROM = 200;
6323
+ const RESEND_MAX_SUBJECT = 1e3;
6324
+ const RESEND_MAX_TAGS = 50;
6325
+ /**
6326
+ * One recipient: a bare address or `Name <addr@domain>`, both of which SES
6327
+ * takes in a Destination. The same parser the journey path uses, so an
6328
+ * address the facade accepts is one a journey could have sent to.
6329
+ */
6330
+ 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>\")." });
6331
+ const recipientArraySchema = z.array(recipientSchema).min(1).max(50);
6332
+ /** Resend takes one address or a list wherever it takes recipients. */
6333
+ const recipientsSchema = z.union([recipientSchema, recipientArraySchema]);
6334
+ /**
6335
+ * Resend's wording for an absent required field. Three fields can be
6336
+ * missing: `to`, `subject`, and a body. Which answer each one gets is read
6337
+ * off the issue itself, never off this copy, so rewording any of them is
6338
+ * safe (see `isMissingField`).
6339
+ */
6340
+ const MISSING = (field) => `Missing \`${field}\` field.`;
6341
+ /**
6342
+ * A field Cowliss does not serve. Declared rather than left to the strict
6343
+ * object's "unrecognized key" so the 422 names the field and says what to
6344
+ * do instead; the Resend SDK omits an unset one, so only a caller that
6345
+ * really passed it is refused.
6346
+ *
6347
+ * `any` and a refusing check rather than `never`, which the OpenAPI
6348
+ * generator has no rendering for.
6349
+ */
6350
+ const unsupported = (message) => z.any().refine(() => false, { error: message }).optional();
6351
+ /**
6352
+ * Resend's template slot, pointing at a Cowliss template key (ADR 0014).
6353
+ * `variables` are the template's own props: they are checked by the
6354
+ * template, in the sandbox, the same way a test send's are, so anything
6355
+ * JSON can carry is accepted here and the template decides.
6356
+ */
6357
+ const templateSlotSchema = z.strictObject({
6358
+ id: journeyKeySchema,
6359
+ variables: z.record(z.string(), z.unknown()).optional()
6360
+ });
6361
+ /** Send body: Resend's fields, minus the ones Cowliss does not serve. */
6362
+ const resendSendBodySchema = z.strictObject({
6363
+ /**
6364
+ * Optional here where Resend requires it: an omitted `from` is the
6365
+ * organization's shared fallback address, which is what makes a first
6366
+ * send work with no configuration at all.
6367
+ */
6368
+ 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(),
6369
+ /**
6370
+ * Required, and the absence is its own issue rather than the union's:
6371
+ * `optional().nonoptional()` lets the value through to the union when
6372
+ * there is one and answers for undefined itself, which is how
6373
+ * `isMissingField` tells "you left it out" from "you sent the wrong
6374
+ * thing" without reading the message.
6375
+ */
6376
+ to: z.union([recipientSchema, recipientArraySchema]).optional().nonoptional({ error: MISSING("to") }),
6377
+ /**
6378
+ * Optional in the type and required in practice: a message either
6379
+ * carries its own subject or names a template, which brings one. The
6380
+ * refinement below says which of the two is missing.
6381
+ */
6382
+ subject: z.string().trim().min(1).max(RESEND_MAX_SUBJECT).optional(),
6383
+ html: z.string().optional(),
6384
+ text: z.string().optional(),
6385
+ cc: recipientsSchema.optional(),
6386
+ bcc: recipientsSchema.optional(),
6387
+ reply_to: recipientsSchema.optional(),
6388
+ headers: z.record(z.string(), z.string()).optional(),
6389
+ /** Accepted and dropped: Cowliss tags a delivery by its own fields. */
6390
+ tags: z.array(z.object({
6391
+ name: z.string(),
6392
+ value: z.string()
6393
+ })).max(50).optional(),
6394
+ attachments: unsupported("Cowliss does not accept attachments. Link to the file from the message instead."),
6395
+ 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."),
6396
+ topic_id: unsupported("Cowliss does not take topics. Consent purposes are where it records what a person agreed to."),
6397
+ template: templateSlotSchema.optional()
6398
+ }).superRefine((body, ctx) => {
6399
+ const refuse = (path, message) => ctx.addIssue({
6400
+ code: "custom",
6401
+ path: [path],
6402
+ message
6403
+ });
6404
+ const missing = (path, message) => ctx.addIssue({
6405
+ code: "custom",
6406
+ path: [path],
6407
+ message,
6408
+ params: { missing: true }
6409
+ });
6410
+ if (body.template) {
6411
+ if (body.subject !== void 0) refuse("subject", "The subject comes from the template. Remove `subject`, or write the message yourself with `html` or `text`.");
6412
+ 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.`);
6413
+ return;
6414
+ }
6415
+ if (body.subject === void 0) missing("subject", MISSING("subject"));
6416
+ if (body.html === void 0 && body.text === void 0) missing("html", "Missing `html` or `text` field.");
6417
+ });
6418
+ /**
6419
+ * A batch: Resend posts the messages as a bare array, and answers with one
6420
+ * id per message in the order they were given. Every message is checked
6421
+ * before any of them is sent, so a batch with a mistake in it sends
6422
+ * nothing.
6423
+ */
6424
+ 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.`);
6425
+ /** What a send answers: the delivery's id, and nothing else. */
6426
+ const resendSentSchema = z.object({ id: z.string() });
6427
+ /** What a batch answers: one id per message, in the order they were given. */
6428
+ const resendBatchSentSchema = z.object({ data: z.array(resendSentSchema) });
6429
+ /**
6430
+ * Where a message got to, in Resend's vocabulary. Cowliss emits five of
6431
+ * them: a gate skip and a failure are both `failed`, because to a caller
6432
+ * they are the same fact, the message did not go.
6433
+ */
6434
+ const RESEND_LAST_EVENTS = [
6435
+ "sent",
6436
+ "delivered",
6437
+ "bounced",
6438
+ "complained",
6439
+ "failed"
6440
+ ];
6441
+ /**
6442
+ * Resend's email object. `html` and `text` are always null: Cowliss stores
6443
+ * no rendered bodies (ADR 0014), and the SDK does not need them to report
6444
+ * on a send.
6445
+ */
6446
+ const resendEmailSchema = z.object({
6447
+ object: z.literal("email"),
6448
+ id: z.string(),
6449
+ /** The provider's message id; null until the message reaches the provider. */
6450
+ message_id: z.string().nullable(),
6451
+ to: z.array(z.string()),
6452
+ from: z.string(),
6453
+ created_at: z.string(),
6454
+ subject: z.string(),
6455
+ html: z.null(),
6456
+ text: z.null(),
6457
+ bcc: z.array(z.string()),
6458
+ cc: z.array(z.string()),
6459
+ reply_to: z.array(z.string()),
6460
+ last_event: z.enum(RESEND_LAST_EVENTS),
6461
+ scheduled_at: z.null(),
6462
+ tags: z.array(z.object({
6463
+ name: z.string(),
6464
+ value: z.string()
6465
+ }))
6466
+ });
6467
+ /**
6468
+ * Resend's error body. `name` is the machine-readable one, which is what an
6469
+ * SDK caller branches on; the message is for the developer reading it.
6470
+ */
6471
+ const resendErrorSchema = z.object({
6472
+ statusCode: z.number(),
6473
+ message: z.string(),
6474
+ name: z.string()
6475
+ });
6476
+
6518
6477
  //#endregion
6519
6478
  //#region ../../packages/shared/src/violations.ts
6520
6479
  /**
@@ -6625,9 +6584,8 @@ const listReviewQuerySchema = paginationQuerySchema.extend({
6625
6584
  * per-profile and profiles merge across apps, so there is nothing
6626
6585
  * app-shaped to filter on the trait side.
6627
6586
  *
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.
6587
+ * A definition and the membership it produces are both the
6588
+ * organization's.
6631
6589
  *
6632
6590
  * The object is strict: a definition holding the retired `sourceId` key
6633
6591
  * (which meant the app) fails loudly instead of parsing as an unfiltered
@@ -6697,7 +6655,6 @@ const segmentDefinitionSchema = z.strictObject({
6697
6655
  });
6698
6656
  const segmentSchema = selectSegmentSchema.extend({
6699
6657
  definition: segmentDefinitionSchema,
6700
- environments: environmentsSchema,
6701
6658
  createdAt: z.iso.datetime(),
6702
6659
  updatedAt: z.iso.datetime()
6703
6660
  });
@@ -6733,41 +6690,33 @@ const descriptionSchema = z.string().trim().max(500, "description must be at mos
6733
6690
  const createSegmentBodySchema = z.object({ data: z.object({
6734
6691
  name: nameSchema,
6735
6692
  description: descriptionSchema.nullish(),
6736
- definition: segmentDefinitionSchema,
6737
- /** The environments the segment works on; both when omitted. */
6738
- environments: environmentsSchema.default([...ENVIRONMENTS])
6693
+ definition: segmentDefinitionSchema
6739
6694
  }) });
6740
6695
  const updateSegmentBodySchema = z.object({ data: z.object({
6741
6696
  name: nameSchema.optional(),
6742
6697
  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" }) });
6698
+ definition: segmentDefinitionSchema.optional()
6699
+ }).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" }) });
6747
6700
  /** `q` is a substring search over the segment's name. */
6748
6701
  const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
6749
- const listSegmentMembersQuerySchema = paginationQuerySchema;
6702
+ const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
6750
6703
 
6751
6704
  //#endregion
6752
6705
  //#region ../../packages/shared/src/settings.ts
6753
6706
  /**
6754
6707
  * 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.
6708
+ * governance) and eventRetentionDays (the ClickHouse TTL window), are the
6709
+ * organization's, one each: `GET/PATCH /v1/settings/org` read and write
6710
+ * them. The org's Clerk profile rides along read-only.
6759
6711
  */
6760
6712
  const ingestionPolicySchema = z.enum(INGESTION_POLICIES);
6761
6713
  /**
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.
6714
+ * The org's event retention window in days. Never null: an unset window is
6715
+ * the default, and there is no keep-forever. Both default and cap are 18
6716
+ * months.
6766
6717
  */
6767
- const eventRetentionDaysSchema = z.number().int().min(1).max(Math.max(...Object.values(EVENT_RETENTION_DAYS)));
6718
+ const eventRetentionDaysSchema = z.number().int().min(1).max(548);
6768
6719
  const orgSettingsDtoSchema = z.object({
6769
- /** The environment these values belong to (from the selection header). */
6770
- environment: environmentSchema,
6771
6720
  ingestionPolicy: ingestionPolicySchema,
6772
6721
  eventRetentionDays: eventRetentionDaysSchema,
6773
6722
  /**
@@ -6781,9 +6730,8 @@ const orgSettingsDtoSchema = z.object({
6781
6730
  name: z.string().nullable()
6782
6731
  });
6783
6732
  /**
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.
6733
+ * Settings patch. Each field is absent-means-no-change. At least one field
6734
+ * must be present: an empty patch is a no-op request, not a valid one.
6787
6735
  */
6788
6736
  const updateOrgSettingsBodySchema = z.object({ data: z.object({
6789
6737
  ingestionPolicy: ingestionPolicySchema.optional(),
@@ -6825,7 +6773,7 @@ const sourceSchema = selectSourceSchema.extend({
6825
6773
  */
6826
6774
  const SOURCE_KINDS = sourceKindEnum.enumValues;
6827
6775
  /**
6828
- * The per-kind source config schemas, mirroring the destinations
6776
+ * The per-kind source config schemas, mirroring the webhooks
6829
6777
  * discriminated-union pattern. The `api` source has nothing to configure:
6830
6778
  * ingestion authenticates with the org-level API key, so the row carries no
6831
6779
  * credential of its own. A Clerk source carries only the webhook signing
@@ -6842,7 +6790,7 @@ const apiSourceConfigSchema = z.strictObject({}, { error: "an api source takes n
6842
6790
  const clerkSourceConfigSchema = z.object({ signingSecret: z.string().min(1, "config.signingSecret is required") });
6843
6791
  /**
6844
6792
  * 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
6793
+ * resolve against exactly one of them (the webhooks pattern: a form knows
6846
6794
  * its kind before the user types anything, and zodResolver over a member is
6847
6795
  * what gives react-hook-form a non-union field path to register).
6848
6796
  *
@@ -6874,7 +6822,7 @@ const listSourcesQuerySchema = paginationQuerySchema;
6874
6822
  //#region ../../packages/shared/src/suppressions.ts
6875
6823
  /**
6876
6824
  * 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.
6825
+ * the row and the DTO cannot drift, same as deliveries and webhooks.
6878
6826
  */
6879
6827
  const SUPPRESSION_TYPES = suppressionTypeEnum.enumValues;
6880
6828
  const suppressionTypeSchema = z.enum(SUPPRESSION_TYPES);
@@ -6897,7 +6845,7 @@ const listSuppressionsQuerySchema = paginationQuerySchema.extend({ q: searchQuer
6897
6845
  //#region ../../packages/shared/src/templates.ts
6898
6846
  /**
6899
6847
  * 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
6848
+ * else (ADR 0011): no row of its own, no flag. What is
6901
6849
  * reported here is one key, described by its newest version, plus the
6902
6850
  * journeys whose steps send it.
6903
6851
  *
@@ -6932,6 +6880,8 @@ const templateSchema = z.object({
6932
6880
  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
6881
  /** The props a preview or a test send renders the template with. */
6934
6882
  const renderTemplateBodySchema = z.object({ data: z.object({ props: z.record(z.string(), z.unknown()).default({}) }) });
6883
+ /** A test send is a delivery like any other, so it takes the same props. */
6884
+ const testSendTemplateBodySchema = z.object({ data: z.object({ props: z.record(z.string(), z.unknown()).default({}) }) });
6935
6885
  /** What the sandbox rendered: the message as it would go out. */
6936
6886
  const templatePreviewSchema = z.object({
6937
6887
  subject: z.string(),
@@ -6945,9 +6895,8 @@ const templatePreviewSchema = z.object({
6945
6895
  //#region ../../packages/shared/src/users.ts
6946
6896
  /**
6947
6897
  * 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
6898
+ * Cowliss-generated id (`usr_`). A profile's event history is the event
6899
+ * feed filtered to it
6951
6900
  * (GET /v1/events?profileId=), not an endpoint of its own.
6952
6901
  */
6953
6902
  /**
@@ -7039,6 +6988,62 @@ const userExportSchema = z.object({
7039
6988
  quarantineEntries: z.array(quarantineEntrySchema)
7040
6989
  });
7041
6990
 
6991
+ //#endregion
6992
+ //#region ../../packages/shared/src/webhooks.ts
6993
+ /**
6994
+ * Webhooks API contracts. Derived from the Drizzle table via drizzle-zod.
6995
+ * signingSecret is deliberately absent from the DTO: it is shown exactly
6996
+ * once, in the create response (webhookCreatedSchema). consecutiveFailures
6997
+ * is absent too: it is the auto-disable counter's internal state, and
6998
+ * disabledAt is the part the dashboard banner needs.
6999
+ *
7000
+ * A webhook is the organization's: the name is unique per org, and every
7001
+ * execution posts to the same row.
7002
+ */
7003
+ /**
7004
+ * A webhook's name: the token a journey addresses it by, in a `send.webhook`
7005
+ * call. The journey names a string and the server resolves it, so the two
7006
+ * ends only have to agree on the characters that fit in one.
7007
+ */
7008
+ const webhookNameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
7009
+ const webhookConfigSchema = z.object({ url: z.url("config.url must be a valid URL").refine((url) => {
7010
+ try {
7011
+ return ["http:", "https:"].includes(new URL(url).protocol);
7012
+ } catch {
7013
+ return false;
7014
+ }
7015
+ }, { message: "config.url must use http or https" }) });
7016
+ const webhookSchema = selectWebhookSchema.extend({
7017
+ config: webhookConfigSchema,
7018
+ createdAt: z.iso.datetime(),
7019
+ updatedAt: z.iso.datetime(),
7020
+ disabledAt: z.iso.datetime().nullable()
7021
+ }).omit({
7022
+ signingSecret: true,
7023
+ consecutiveFailures: true
7024
+ });
7025
+ const webhookCreatedSchema = webhookSchema.extend({ signingSecret: z.string() });
7026
+ const createWebhookBodySchema = z.object({ data: z.object({
7027
+ name: webhookNameSchema,
7028
+ config: webhookConfigSchema
7029
+ }) });
7030
+ /**
7031
+ * Webhook update. `enabled` is the admin's half of the auto-disable loop:
7032
+ * the webhook send activity stamps `disabledAt` after enough consecutive
7033
+ * failures and every send after that records `skipped_disabled`, so without
7034
+ * a way to clear it the only exit would be deleting the webhook, which
7035
+ * throws away the signing secret the receiver is configured with.
7036
+ * `enabled: true` clears the stamp and the failure counter; `enabled: false`
7037
+ * is the same switch operated by hand.
7038
+ */
7039
+ const updateWebhookBodySchema = z.object({ data: z.object({
7040
+ name: webhookNameSchema.optional(),
7041
+ config: webhookConfigSchema.optional(),
7042
+ enabled: z.boolean().optional()
7043
+ }).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" }) });
7044
+ /** `q` is a substring search over the webhook's name. */
7045
+ const listWebhooksQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
7046
+
7042
7047
  //#endregion
7043
7048
  //#region ../../packages/api-client/src/client.ts
7044
7049
  var ApiError = class extends Error {
@@ -7222,7 +7227,7 @@ function readCliPackage() {
7222
7227
  * are. Reading the code sees every branch, labelled with its condition.
7223
7228
  *
7224
7229
  * What it reads: `await api.*` calls (in source order, with the literal
7225
- * template, destination, event, key, or duration when the author wrote one
7230
+ * template, webhook, event, key, or duration when the author wrote one
7226
7231
  * inline), `if`/`else`, `switch`, loops, `try`/`catch`, `return`, `throw`, and
7227
7232
  * `api.restart()`. Calls behind a helper function in another file, and a
7228
7233
  * `run` that is not an inline function on the `defineJourney` object, are
@@ -7433,12 +7438,9 @@ var Reader = class {
7433
7438
  if (timeout) entry.timeout = timeout;
7434
7439
  break;
7435
7440
  }
7436
- case "send.email": {
7441
+ case "send.email":
7437
7442
  detail(this.property(first, "template"));
7438
- const sender = this.property(first, "senderIdentity");
7439
- if (sender) entry.senderIdentity = sender;
7440
7443
  break;
7441
- }
7442
7444
  case "send.webhook":
7443
7445
  detail(this.property(first, "destination"));
7444
7446
  break;
@@ -7999,7 +8001,7 @@ async function buildProject(projectDir) {
7999
8001
  tags: report.tags,
8000
8002
  trigger: report.trigger,
8001
8003
  purpose: report.purpose,
8002
- senderIdentity: report.senderIdentity,
8004
+ from: report.from,
8003
8005
  spine: readSpine(await readFile(built.source.file, "utf8")),
8004
8006
  bundle: built.digest
8005
8007
  });
@@ -8025,7 +8027,7 @@ async function buildProject(projectDir) {
8025
8027
  });
8026
8028
  if (failure) throw new Error(failure);
8027
8029
  const manifest = manifestSchema.parse({
8028
- protocol: 2,
8030
+ protocol: 3,
8029
8031
  sdk: (await readCliPackage()).version,
8030
8032
  journeys: manifestJourneys,
8031
8033
  templates: manifestTemplates,
@@ -9264,82 +9266,9 @@ all: z.literal("1").optional() }) },
9264
9266
  }
9265
9267
  }));
9266
9268
 
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 },
9291
- responses: {
9292
- 200: list(destinationSchema),
9293
- ...sessionErrors,
9294
- ...errors("validation_failed")
9295
- }
9296
- }), defineRoute({
9297
- 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 },
9333
- responses: {
9334
- 200: envelope(deletedSchema),
9335
- ...sessionErrors,
9336
- ...errors("not_found")
9337
- }
9338
- }));
9339
-
9340
9269
  //#endregion
9341
9270
  //#region ../../packages/shared/src/contract/domains.ts
9342
- const params$8 = z.object({ id: z.string() });
9271
+ const params$9 = z.object({ id: z.string() });
9343
9272
  const domains = defineModule(defineRoute({
9344
9273
  method: "get",
9345
9274
  path: "/v1/domains",
@@ -9360,7 +9289,7 @@ const domains = defineModule(defineRoute({
9360
9289
  tags: ["domains"],
9361
9290
  summary: "Fetch one sending domain",
9362
9291
  security: SESSION_AUTH,
9363
- request: { params: params$8 },
9292
+ request: { params: params$9 },
9364
9293
  responses: {
9365
9294
  200: envelope(senderDomainSchema),
9366
9295
  ...sessionErrors,
@@ -9388,7 +9317,7 @@ const domains = defineModule(defineRoute({
9388
9317
  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
9318
  security: SESSION_AUTH,
9390
9319
  surfaces: HIDDEN_FROM_TOOLS,
9391
- request: { params: params$8 },
9320
+ request: { params: params$9 },
9392
9321
  responses: {
9393
9322
  200: envelope(domainDnsSetupSchema),
9394
9323
  ...sessionErrors,
@@ -9401,7 +9330,7 @@ const domains = defineModule(defineRoute({
9401
9330
  tags: ["domains"],
9402
9331
  summary: "Re-check DNS now",
9403
9332
  security: SESSION_AUTH,
9404
- request: { params: params$8 },
9333
+ request: { params: params$9 },
9405
9334
  responses: {
9406
9335
  200: envelope(senderDomainSchema),
9407
9336
  ...sessionErrors,
@@ -9415,7 +9344,7 @@ const domains = defineModule(defineRoute({
9415
9344
  summary: "Toggle click tracking",
9416
9345
  security: SESSION_AUTH,
9417
9346
  request: {
9418
- params: params$8,
9347
+ params: params$9,
9419
9348
  body: jsonBody(updateSenderDomainBodySchema)
9420
9349
  },
9421
9350
  responses: {
@@ -9430,7 +9359,7 @@ const domains = defineModule(defineRoute({
9430
9359
  tags: ["domains"],
9431
9360
  summary: "Give up a domain claim",
9432
9361
  security: SESSION_AUTH,
9433
- request: { params: params$8 },
9362
+ request: { params: params$9 },
9434
9363
  responses: {
9435
9364
  200: envelope(deletedSchema),
9436
9365
  ...sessionErrors,
@@ -9528,15 +9457,15 @@ const events = defineModule(defineRoute({
9528
9457
 
9529
9458
  //#endregion
9530
9459
  //#region ../../packages/shared/src/contract/executions.ts
9531
- const params$7 = z.object({ id: z.string() });
9460
+ const params$8 = z.object({ id: z.string() });
9532
9461
  /**
9533
9462
  * Executions: one run of one journey for one profile. Replaces v1's journey
9534
9463
  * instances, which were read live from Temporal and so could only be listed
9535
9464
  * 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.
9465
+ * and profile in one place.
9537
9466
  *
9538
- * `cancel` is the only write: stopping is explicit and per environment
9539
- * (ADR 0011), and each execution records `cancelled` when it reaches its
9467
+ * `cancel` is the only write: stopping is explicit (ADR 0011), and each
9468
+ * execution records `cancelled` when it reaches its
9540
9469
  * next step, so the answer is that the sweep started rather than a count.
9541
9470
  * A pipeline may push, enable, disable and READ executions; stopping runs
9542
9471
  * that are already carrying real people is a person's call, so cancel takes
@@ -9575,7 +9504,7 @@ const executions = defineModule(defineRoute({
9575
9504
  tags: ["executions"],
9576
9505
  summary: "Inspect one execution, with its logs",
9577
9506
  security: PIPELINE_AUTH,
9578
- request: { params: params$7 },
9507
+ request: { params: params$8 },
9579
9508
  responses: {
9580
9509
  200: envelope(executionDetailSchema),
9581
9510
  ...sessionErrors,
@@ -9688,16 +9617,11 @@ const ingestion = defineModule(defineRoute({
9688
9617
  * because the rows are derived from a push rather than created through the
9689
9618
  * API, and (org, key) is the only identity they have.
9690
9619
  *
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
9620
  * `journeys.listInstances` and `journeys.getInstance` are gone: executions
9697
9621
  * are their own resource now (`executions.list` / `executions.get`), and
9698
9622
  * `journeys.dryRun` answers with the execution it started.
9699
9623
  */
9700
- const params$6 = z.object({ key: z.string() });
9624
+ const params$7 = z.object({ key: z.string() });
9701
9625
  const journeys = defineModule(defineRoute({
9702
9626
  method: "get",
9703
9627
  path: "/v1/journeys",
@@ -9718,7 +9642,7 @@ const journeys = defineModule(defineRoute({
9718
9642
  tags: ["journeys"],
9719
9643
  summary: "Inspect one journey",
9720
9644
  security: SESSION_AUTH,
9721
- request: { params: params$6 },
9645
+ request: { params: params$7 },
9722
9646
  responses: {
9723
9647
  200: envelope(journeySchema),
9724
9648
  ...sessionErrors,
@@ -9731,7 +9655,7 @@ const journeys = defineModule(defineRoute({
9731
9655
  tags: ["journeys"],
9732
9656
  summary: "Journey operational stats",
9733
9657
  security: SESSION_AUTH,
9734
- request: { params: params$6 },
9658
+ request: { params: params$7 },
9735
9659
  responses: {
9736
9660
  200: envelope(journeyStatsSchema),
9737
9661
  ...sessionErrors,
@@ -9742,7 +9666,7 @@ const journeys = defineModule(defineRoute({
9742
9666
  path: "/v1/journeys/enable",
9743
9667
  operationId: "journeys.enable",
9744
9668
  tags: ["journeys"],
9745
- summary: "Turn journeys on in the selected environment",
9669
+ summary: "Turn journeys on",
9746
9670
  security: PIPELINE_AUTH,
9747
9671
  surfaces: { cli: false },
9748
9672
  request: { body: jsonBody(setJourneysEnabledBodySchema) },
@@ -9756,7 +9680,7 @@ const journeys = defineModule(defineRoute({
9756
9680
  path: "/v1/journeys/disable",
9757
9681
  operationId: "journeys.disable",
9758
9682
  tags: ["journeys"],
9759
- summary: "Turn journeys off in the selected environment",
9683
+ summary: "Turn journeys off",
9760
9684
  security: PIPELINE_AUTH,
9761
9685
  surfaces: { cli: false },
9762
9686
  request: { body: jsonBody(setJourneysEnabledBodySchema) },
@@ -9772,7 +9696,7 @@ const journeys = defineModule(defineRoute({
9772
9696
  tags: ["journeys"],
9773
9697
  summary: "Delete a journey",
9774
9698
  security: SESSION_AUTH,
9775
- request: { params: params$6 },
9699
+ request: { params: params$7 },
9776
9700
  responses: {
9777
9701
  200: envelope(deletedSchema),
9778
9702
  ...sessionErrors,
@@ -9786,7 +9710,7 @@ const journeys = defineModule(defineRoute({
9786
9710
  summary: "Dry-run a journey against a real user",
9787
9711
  security: SESSION_AUTH,
9788
9712
  request: {
9789
- params: params$6,
9713
+ params: params$7,
9790
9714
  body: jsonBody(dryRunJourneyBodySchema)
9791
9715
  },
9792
9716
  responses: {
@@ -9800,9 +9724,9 @@ const journeys = defineModule(defineRoute({
9800
9724
  //#region ../../packages/shared/src/contract/me.ts
9801
9725
  /**
9802
9726
  * 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.
9727
+ * dashboard API, but deliberately not org-scoped: the subject is the
9728
+ * caller's own platform-workspace profile, taken from their session, never
9729
+ * from the request.
9806
9730
  *
9807
9731
  * Hidden from the CLI and the MCP server. Both act with an org's key on that
9808
9732
  * org's data, and neither has a signed-in human whose mail preferences this
@@ -9899,7 +9823,7 @@ const project = defineModule(defineRoute({
9899
9823
 
9900
9824
  //#endregion
9901
9825
  //#region ../../packages/shared/src/contract/pushes.ts
9902
- const params$5 = z.object({ id: z.string() });
9826
+ const params$6 = z.object({ id: z.string() });
9903
9827
  /**
9904
9828
  * Pushes: one `cow push` each, the whole project at one point in time. The
9905
9829
  * push stores the source archive and its compile creates a version of every
@@ -9944,7 +9868,7 @@ const pushes = defineModule(defineRoute({
9944
9868
  tags: ["pushes"],
9945
9869
  summary: "Inspect one push and what it compiled",
9946
9870
  security: SESSION_AUTH,
9947
- request: { params: params$5 },
9871
+ request: { params: params$6 },
9948
9872
  responses: {
9949
9873
  200: envelope(pushSchema),
9950
9874
  ...sessionErrors,
@@ -9958,7 +9882,7 @@ const pushes = defineModule(defineRoute({
9958
9882
  summary: "Download the pushed source tarball",
9959
9883
  security: SESSION_AUTH,
9960
9884
  surfaces: HIDDEN_FROM_TOOLS,
9961
- request: { params: params$5 },
9885
+ request: { params: params$6 },
9962
9886
  responses: {
9963
9887
  200: {
9964
9888
  description: "The gzipped source tarball the push stored",
@@ -9969,6 +9893,98 @@ const pushes = defineModule(defineRoute({
9969
9893
  }
9970
9894
  }));
9971
9895
 
9896
+ //#endregion
9897
+ //#region ../../packages/shared/src/contract/resend.ts
9898
+ /**
9899
+ * The Resend-compatible send facade (ADR 0014): a developer points
9900
+ * `RESEND_BASE_URL` at `/resend/{sourceId}` and keeps their code. The source
9901
+ * in the path names the app, because the Resend SDK carries no other field
9902
+ * and accepts no custom header; the bearer is the organization's ingestion
9903
+ * key.
9904
+ *
9905
+ * These routes are the one exception to the envelope standard: they answer
9906
+ * Resend's own shapes, and the API mounts them on a sub-app with its own
9907
+ * error handler. They stay in the contract so the API still registers no
9908
+ * route outside it, and they are hidden from the CLI, the MCP server and the
9909
+ * generated reference: a Cowliss surface has the `/v1` routes, and the guide
9910
+ * is the place the facade is documented.
9911
+ */
9912
+ const FACADE_SURFACES = {
9913
+ cli: false,
9914
+ mcp: false,
9915
+ docs: false
9916
+ };
9917
+ const sourceParam = z.object({ sourceId: z.string().openapi({ example: "src_2f9a8c1b" }) });
9918
+ /** Resend's error body, for the answers this facade gives. */
9919
+ const facadeError = (description) => ({
9920
+ description,
9921
+ content: { "application/json": { schema: resendErrorSchema } }
9922
+ });
9923
+ const facadeErrors = {
9924
+ 401: facadeError("missing_api_key | invalid_api_key"),
9925
+ 404: facadeError("not_found: unknown source, or unknown message"),
9926
+ 422: facadeError("validation_error | missing_required_field"),
9927
+ 429: facadeError("monthly_quota_exceeded | rate_limit_exceeded"),
9928
+ 500: facadeError("application_error")
9929
+ };
9930
+ const resend = defineModule(defineRoute({
9931
+ method: "post",
9932
+ path: "/resend/{sourceId}/emails",
9933
+ operationId: "resend.send",
9934
+ tags: ["resend"],
9935
+ summary: "Send one email (Resend-compatible)",
9936
+ security: API_KEY_AUTH,
9937
+ surfaces: FACADE_SURFACES,
9938
+ request: {
9939
+ params: sourceParam,
9940
+ body: jsonBody(resendSendBodySchema)
9941
+ },
9942
+ responses: {
9943
+ 201: {
9944
+ description: "Accepted; the id is the delivery's",
9945
+ content: { "application/json": { schema: resendSentSchema } }
9946
+ },
9947
+ 403: facadeError("validation_error: the from-address sits on a domain this organization has not verified"),
9948
+ ...facadeErrors
9949
+ }
9950
+ }), defineRoute({
9951
+ method: "post",
9952
+ path: "/resend/{sourceId}/emails/batch",
9953
+ operationId: "resend.sendBatch",
9954
+ tags: ["resend"],
9955
+ summary: "Send up to 100 emails (Resend-compatible)",
9956
+ security: API_KEY_AUTH,
9957
+ surfaces: FACADE_SURFACES,
9958
+ request: {
9959
+ params: sourceParam,
9960
+ body: jsonBody(resendBatchBodySchema)
9961
+ },
9962
+ responses: {
9963
+ 201: {
9964
+ description: "Accepted; one id per message, in the order they were given",
9965
+ content: { "application/json": { schema: resendBatchSentSchema } }
9966
+ },
9967
+ 403: facadeError("validation_error: a from-address sits on a domain this organization has not verified"),
9968
+ ...facadeErrors
9969
+ }
9970
+ }), defineRoute({
9971
+ method: "get",
9972
+ path: "/resend/{sourceId}/emails/{id}",
9973
+ operationId: "resend.get",
9974
+ tags: ["resend"],
9975
+ summary: "Fetch one sent email (Resend-compatible)",
9976
+ security: API_KEY_AUTH,
9977
+ surfaces: FACADE_SURFACES,
9978
+ request: { params: sourceParam.extend({ id: z.string().openapi({ example: "dlv_2f9a8c1b" }) }) },
9979
+ responses: {
9980
+ 200: {
9981
+ description: "The email object",
9982
+ content: { "application/json": { schema: resendEmailSchema } }
9983
+ },
9984
+ ...facadeErrors
9985
+ }
9986
+ }));
9987
+
9972
9988
  //#endregion
9973
9989
  //#region ../../packages/shared/src/contract/review.ts
9974
9990
  /**
@@ -9994,7 +10010,7 @@ const review = defineModule(defineRoute({
9994
10010
 
9995
10011
  //#endregion
9996
10012
  //#region ../../packages/shared/src/contract/segments.ts
9997
- const params$4 = z.object({ id: z.string() });
10013
+ const params$5 = z.object({ id: z.string() });
9998
10014
  const writeErrors = errors("validation_failed", "malformed_request");
9999
10015
  /** `preview` precedes `get` so "preview" is never read as an id. */
10000
10016
  const segments = defineModule(defineRoute({
@@ -10044,7 +10060,7 @@ const segments = defineModule(defineRoute({
10044
10060
  tags: ["segments"],
10045
10061
  summary: "Fetch one segment with its member count",
10046
10062
  security: SESSION_AUTH,
10047
- request: { params: params$4 },
10063
+ request: { params: params$5 },
10048
10064
  responses: {
10049
10065
  200: envelope(segmentDetailSchema),
10050
10066
  ...sessionErrors,
@@ -10058,7 +10074,7 @@ const segments = defineModule(defineRoute({
10058
10074
  summary: "Update a segment",
10059
10075
  security: SESSION_AUTH,
10060
10076
  request: {
10061
- params: params$4,
10077
+ params: params$5,
10062
10078
  body: jsonBody(updateSegmentBodySchema)
10063
10079
  },
10064
10080
  responses: {
@@ -10074,7 +10090,7 @@ const segments = defineModule(defineRoute({
10074
10090
  tags: ["segments"],
10075
10091
  summary: "Delete a segment",
10076
10092
  security: SESSION_AUTH,
10077
- request: { params: params$4 },
10093
+ request: { params: params$5 },
10078
10094
  responses: {
10079
10095
  200: envelope(deletedSchema),
10080
10096
  ...sessionErrors,
@@ -10088,7 +10104,7 @@ const segments = defineModule(defineRoute({
10088
10104
  summary: "List a segment's members",
10089
10105
  security: SESSION_AUTH,
10090
10106
  request: {
10091
- params: params$4,
10107
+ params: params$5,
10092
10108
  query: listSegmentMembersQuerySchema
10093
10109
  },
10094
10110
  responses: {
@@ -10213,7 +10229,7 @@ const settings = defineModule(defineRoute({
10213
10229
  //#endregion
10214
10230
  //#region ../../packages/shared/src/contract/sources.ts
10215
10231
  const appParams = z.object({ appId: z.string() });
10216
- const params$3 = z.object({ id: z.string() });
10232
+ const params$4 = z.object({ id: z.string() });
10217
10233
  /**
10218
10234
  * A source is one inbound pipe into an app. It is created and listed under
10219
10235
  * its parent app (the app is the attribution unit) and addressed by its own
@@ -10259,7 +10275,7 @@ const sources = defineModule(defineRoute({
10259
10275
  tags: ["sources"],
10260
10276
  summary: "Fetch one source",
10261
10277
  security: SESSION_AUTH,
10262
- request: { params: params$3 },
10278
+ request: { params: params$4 },
10263
10279
  responses: {
10264
10280
  200: envelope(sourceSchema),
10265
10281
  ...sessionErrors,
@@ -10273,7 +10289,7 @@ const sources = defineModule(defineRoute({
10273
10289
  summary: "Set or rotate a source's config",
10274
10290
  security: SESSION_AUTH,
10275
10291
  request: {
10276
- params: params$3,
10292
+ params: params$4,
10277
10293
  body: jsonBody(updateSourceBodySchema)
10278
10294
  },
10279
10295
  responses: {
@@ -10288,7 +10304,7 @@ const sources = defineModule(defineRoute({
10288
10304
  tags: ["sources"],
10289
10305
  summary: "Archive a source",
10290
10306
  security: SESSION_AUTH,
10291
- request: { params: params$3 },
10307
+ request: { params: params$4 },
10292
10308
  responses: {
10293
10309
  200: envelope(sourceSchema, "The archived source"),
10294
10310
  ...sessionErrors,
@@ -10359,7 +10375,7 @@ const suppressions = defineModule(defineRoute({
10359
10375
  * organization's default sender: it reaches no recipient, so it passes no
10360
10376
  * consent gate, counts against nothing, and is logged as a test.
10361
10377
  */
10362
- const params$2 = z.object({ key: z.string() });
10378
+ const params$3 = z.object({ key: z.string() });
10363
10379
  const templates = defineModule(defineRoute({
10364
10380
  method: "get",
10365
10381
  path: "/v1/templates",
@@ -10380,7 +10396,7 @@ const templates = defineModule(defineRoute({
10380
10396
  tags: ["templates"],
10381
10397
  summary: "Inspect one email template",
10382
10398
  security: SESSION_AUTH,
10383
- request: { params: params$2 },
10399
+ request: { params: params$3 },
10384
10400
  responses: {
10385
10401
  200: envelope(templateSchema),
10386
10402
  ...sessionErrors,
@@ -10394,7 +10410,7 @@ const templates = defineModule(defineRoute({
10394
10410
  summary: "Render a template with the props you pass",
10395
10411
  security: SESSION_AUTH,
10396
10412
  request: {
10397
- params: params$2,
10413
+ params: params$3,
10398
10414
  body: jsonBody(renderTemplateBodySchema)
10399
10415
  },
10400
10416
  responses: {
@@ -10410,8 +10426,8 @@ const templates = defineModule(defineRoute({
10410
10426
  summary: "Send a rendered template to your own address",
10411
10427
  security: SESSION_AUTH,
10412
10428
  request: {
10413
- params: params$2,
10414
- body: jsonBody(renderTemplateBodySchema)
10429
+ params: params$3,
10430
+ body: jsonBody(testSendTemplateBodySchema)
10415
10431
  },
10416
10432
  responses: {
10417
10433
  201: envelope(deliverySchema, "The test send, as it was logged"),
@@ -10425,7 +10441,7 @@ const templates = defineModule(defineRoute({
10425
10441
  tags: ["templates"],
10426
10442
  summary: "Delete an email template",
10427
10443
  security: SESSION_AUTH,
10428
- request: { params: params$2 },
10444
+ request: { params: params$3 },
10429
10445
  responses: {
10430
10446
  200: envelope(deletedSchema),
10431
10447
  ...sessionErrors,
@@ -10435,7 +10451,7 @@ const templates = defineModule(defineRoute({
10435
10451
 
10436
10452
  //#endregion
10437
10453
  //#region ../../packages/shared/src/contract/users.ts
10438
- const params$1 = z.object({ profileId: z.string() });
10454
+ const params$2 = z.object({ profileId: z.string() });
10439
10455
  /**
10440
10456
  * The answer a merged-away profile id gets (spec: Identity): 307 to the
10441
10457
  * same path with the survivor's id, so the method and body of a redirected
@@ -10446,9 +10462,8 @@ const mergedRedirect = { 307: {
10446
10462
  headers: z.object({ Location: z.string().meta({ description: "The survivor's URL" }) })
10447
10463
  } };
10448
10464
  /**
10449
- * Profiles by their Cowliss-generated id, within the environment the
10450
- * selection header names. `find` precedes `{profileId}` so the literal path
10451
- * wins.
10465
+ * Profiles by their Cowliss-generated id. `find` precedes `{profileId}` so
10466
+ * the literal path wins.
10452
10467
  */
10453
10468
  const users = defineModule(defineRoute({
10454
10469
  method: "get",
@@ -10483,7 +10498,7 @@ const users = defineModule(defineRoute({
10483
10498
  tags: ["users"],
10484
10499
  summary: "Fetch one profile with its identifiers",
10485
10500
  security: SESSION_AUTH,
10486
- request: { params: params$1 },
10501
+ request: { params: params$2 },
10487
10502
  responses: {
10488
10503
  200: envelope(userDetailSchema),
10489
10504
  ...mergedRedirect,
@@ -10497,7 +10512,7 @@ const users = defineModule(defineRoute({
10497
10512
  tags: ["users"],
10498
10513
  summary: "Erase a user (GDPR)",
10499
10514
  security: SESSION_AUTH,
10500
- request: { params: params$1 },
10515
+ request: { params: params$2 },
10501
10516
  responses: {
10502
10517
  200: envelope(eraseUserResponseSchema),
10503
10518
  ...mergedRedirect,
@@ -10511,7 +10526,7 @@ const users = defineModule(defineRoute({
10511
10526
  tags: ["users"],
10512
10527
  summary: "Export everything held on a user (GDPR)",
10513
10528
  security: SESSION_AUTH,
10514
- request: { params: params$1 },
10529
+ request: { params: params$2 },
10515
10530
  responses: {
10516
10531
  200: envelope(userExportSchema),
10517
10532
  ...mergedRedirect,
@@ -10526,7 +10541,7 @@ const users = defineModule(defineRoute({
10526
10541
  summary: "Update consent purposes",
10527
10542
  security: SESSION_AUTH,
10528
10543
  request: {
10529
- params: params$1,
10544
+ params: params$2,
10530
10545
  body: jsonBody(updateConsentBodySchema)
10531
10546
  },
10532
10547
  responses: {
@@ -10543,7 +10558,7 @@ const users = defineModule(defineRoute({
10543
10558
  summary: "The segments a profile belongs to",
10544
10559
  security: SESSION_AUTH,
10545
10560
  request: {
10546
- params: params$1,
10561
+ params: params$2,
10547
10562
  query: listUserSegmentsQuerySchema
10548
10563
  },
10549
10564
  responses: {
@@ -10552,23 +10567,6 @@ const users = defineModule(defineRoute({
10552
10567
  ...sessionErrors,
10553
10568
  ...errors("not_found", "validation_failed")
10554
10569
  }
10555
- }), defineRoute({
10556
- method: "get",
10557
- path: "/v1/users/{profileId}/journeys",
10558
- operationId: "users.listJourneys",
10559
- tags: ["users"],
10560
- summary: "A profile's journey timeline",
10561
- security: SESSION_AUTH,
10562
- request: {
10563
- params: params$1,
10564
- query: listUserJourneysQuerySchema
10565
- },
10566
- responses: {
10567
- 200: list(userJourneySchema),
10568
- ...mergedRedirect,
10569
- ...sessionErrors,
10570
- ...errors("not_found", "validation_failed")
10571
- }
10572
10570
  }));
10573
10571
 
10574
10572
  //#endregion
@@ -10596,7 +10594,7 @@ const versions = defineModule(defineRoute({
10596
10594
 
10597
10595
  //#endregion
10598
10596
  //#region ../../packages/shared/src/contract/violations.ts
10599
- const params = z.object({ id: z.string() });
10597
+ const params$1 = z.object({ id: z.string() });
10600
10598
  const violations = defineModule(defineRoute({
10601
10599
  method: "get",
10602
10600
  path: "/v1/violations/{id}",
@@ -10604,7 +10602,7 @@ const violations = defineModule(defineRoute({
10604
10602
  tags: ["violations"],
10605
10603
  summary: "Fetch one violation",
10606
10604
  security: SESSION_AUTH,
10607
- request: { params },
10605
+ request: { params: params$1 },
10608
10606
  responses: {
10609
10607
  200: envelope(violationSchema),
10610
10608
  ...sessionErrors,
@@ -10618,7 +10616,7 @@ const violations = defineModule(defineRoute({
10618
10616
  summary: "Resolve a violation into the catalog",
10619
10617
  security: SESSION_AUTH,
10620
10618
  request: {
10621
- params,
10619
+ params: params$1,
10622
10620
  body: jsonBody(resolveViolationBodySchema)
10623
10621
  },
10624
10622
  responses: {
@@ -10633,7 +10631,7 @@ const violations = defineModule(defineRoute({
10633
10631
  tags: ["violations"],
10634
10632
  summary: "Dismiss a violation",
10635
10633
  security: SESSION_AUTH,
10636
- request: { params },
10634
+ request: { params: params$1 },
10637
10635
  responses: {
10638
10636
  200: envelope(violationSchema),
10639
10637
  ...sessionErrors,
@@ -10641,6 +10639,79 @@ const violations = defineModule(defineRoute({
10641
10639
  }
10642
10640
  }));
10643
10641
 
10642
+ //#endregion
10643
+ //#region ../../packages/shared/src/contract/webhooks.ts
10644
+ const params = z.object({ id: z.string() });
10645
+ const webhooks = defineModule(defineRoute({
10646
+ method: "post",
10647
+ path: "/v1/webhooks",
10648
+ operationId: "webhooks.create",
10649
+ tags: ["webhooks"],
10650
+ summary: "Create a webhook",
10651
+ security: SESSION_AUTH,
10652
+ request: { body: jsonBody(createWebhookBodySchema) },
10653
+ responses: {
10654
+ 201: envelope(webhookCreatedSchema, "The created webhook; the only response carrying the signing secret"),
10655
+ ...sessionErrors,
10656
+ ...errors("validation_failed", "malformed_request", "conflict")
10657
+ }
10658
+ }), defineRoute({
10659
+ method: "get",
10660
+ path: "/v1/webhooks",
10661
+ operationId: "webhooks.list",
10662
+ tags: ["webhooks"],
10663
+ summary: "List webhooks",
10664
+ security: SESSION_AUTH,
10665
+ request: { query: listWebhooksQuerySchema },
10666
+ responses: {
10667
+ 200: list(webhookSchema),
10668
+ ...sessionErrors,
10669
+ ...errors("validation_failed")
10670
+ }
10671
+ }), defineRoute({
10672
+ method: "get",
10673
+ path: "/v1/webhooks/{id}",
10674
+ operationId: "webhooks.get",
10675
+ tags: ["webhooks"],
10676
+ summary: "Fetch one webhook",
10677
+ security: SESSION_AUTH,
10678
+ request: { params },
10679
+ responses: {
10680
+ 200: envelope(webhookSchema),
10681
+ ...sessionErrors,
10682
+ ...errors("not_found")
10683
+ }
10684
+ }), defineRoute({
10685
+ method: "patch",
10686
+ path: "/v1/webhooks/{id}",
10687
+ operationId: "webhooks.update",
10688
+ tags: ["webhooks"],
10689
+ summary: "Update a webhook",
10690
+ security: SESSION_AUTH,
10691
+ request: {
10692
+ params,
10693
+ body: jsonBody(updateWebhookBodySchema)
10694
+ },
10695
+ responses: {
10696
+ 200: envelope(webhookSchema),
10697
+ ...sessionErrors,
10698
+ ...errors("not_found", "validation_failed", "malformed_request", "conflict")
10699
+ }
10700
+ }), defineRoute({
10701
+ method: "delete",
10702
+ path: "/v1/webhooks/{id}",
10703
+ operationId: "webhooks.delete",
10704
+ tags: ["webhooks"],
10705
+ summary: "Delete a webhook",
10706
+ security: SESSION_AUTH,
10707
+ request: { params },
10708
+ responses: {
10709
+ 200: envelope(deletedSchema),
10710
+ ...sessionErrors,
10711
+ ...errors("not_found")
10712
+ }
10713
+ }));
10714
+
10644
10715
  //#endregion
10645
10716
  //#region ../../packages/shared/src/contract/index.ts
10646
10717
  /**
@@ -10673,10 +10744,11 @@ const contract = {
10673
10744
  executions,
10674
10745
  journeys,
10675
10746
  templates,
10676
- destinations,
10747
+ webhooks,
10677
10748
  domains,
10678
10749
  deliveries,
10679
10750
  emails,
10751
+ resend,
10680
10752
  suppressions,
10681
10753
  settings,
10682
10754
  billing
@@ -11048,15 +11120,10 @@ function registerContractCommands(program, run) {
11048
11120
  //#endregion
11049
11121
  //#region src/commands/enable.ts
11050
11122
  /**
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.
11056
- *
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.
11123
+ * `cow enable` and `cow disable`: the one gate on a journey (ADR 0011, ADR
11124
+ * 0013). Hand-written rather than derived from `journeys.enable`, because
11125
+ * the commands are top-level names a developer types; the route itself
11126
+ * takes the same set of keys they do.
11060
11127
  */
11061
11128
  /**
11062
11129
  * One call, whichever way the keys were named: the route takes the set, and
@@ -11068,33 +11135,32 @@ async function setEnabled(client, selector, enabled) {
11068
11135
  const route = enabled ? contract.journeys["journeys.enable"] : contract.journeys["journeys.disable"];
11069
11136
  return (await client.request(route, { body: selector })).data;
11070
11137
  }
11071
- /** What the terminal says: one line per key, naming where it now stands. */
11072
- function flipSummary(journeys, enabled, environment) {
11138
+ /** What the terminal says: one line per key, naming its new state. */
11139
+ function flipSummary(journeys, enabled) {
11073
11140
  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");
11141
+ return journeys.map((journey) => `${journey.key} is ${enabled ? "on" : "off"}.`).join("\n");
11075
11142
  }
11076
- function register(program, clientFor, env, io, enabled) {
11143
+ function register(program, clientFor, io, enabled) {
11077
11144
  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) => {
11145
+ program.command(verb).description(`turn journeys ${enabled ? "on" : "off"} (many keys, or --all for this project's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this project").action(async (keys, opts) => {
11079
11146
  const merged = {
11080
11147
  ...program.opts(),
11081
11148
  ...opts
11082
11149
  };
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}.`);
11150
+ if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
11151
+ if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
11086
11152
  const selector = merged.all === true ? { project: (await assertCowConfig(process.cwd())).project } : { keys };
11087
11153
  const updated = await setEnabled(await clientFor(merged), selector, enabled);
11088
11154
  if (merged.json === true) {
11089
11155
  emit({ data: updated }, io, true);
11090
11156
  return;
11091
11157
  }
11092
- io.stdout(`${flipSummary(updated, enabled, environment)}\n`);
11158
+ io.stdout(`${flipSummary(updated, enabled)}\n`);
11093
11159
  });
11094
11160
  }
11095
- function registerEnable(program, clientFor, env, io) {
11096
- register(program, clientFor, env, io, true);
11097
- register(program, clientFor, env, io, false);
11161
+ function registerEnable(program, clientFor, io) {
11162
+ register(program, clientFor, io, true);
11163
+ register(program, clientFor, io, false);
11098
11164
  }
11099
11165
 
11100
11166
  //#endregion
@@ -11150,7 +11216,7 @@ async function packageJson(name) {
11150
11216
  * ponytail: plain fetch because `POST /v1/project` is not a contract route
11151
11217
  * yet; swap it for the typed client's `project.create` once ticket 04 lands.
11152
11218
  */
11153
- async function createProject(apiUrl, token, environment, name) {
11219
+ async function createProject(apiUrl, token, name) {
11154
11220
  if (!token) return {
11155
11221
  project: "skipped",
11156
11222
  reason: "not logged in"
@@ -11160,8 +11226,7 @@ async function createProject(apiUrl, token, environment, name) {
11160
11226
  method: "POST",
11161
11227
  headers: {
11162
11228
  "content-type": "application/json",
11163
- authorization: `Bearer ${token}`,
11164
- [ENVIRONMENT_HEADER]: environment
11229
+ authorization: `Bearer ${token}`
11165
11230
  },
11166
11231
  body: JSON.stringify({ data: { name } })
11167
11232
  });
@@ -11220,7 +11285,7 @@ function registerInit(program, env, io) {
11220
11285
  }
11221
11286
  const example = typeof opts.example === "string" ? opts.example : void 0;
11222
11287
  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);
11288
+ const outcome = await createProject(resolveApiUrl(env, credentials, typeof merged.api === "string" ? merged.api : void 0), resolveCredential(env, credentials).token, name);
11224
11289
  emit({ data: {
11225
11290
  dir,
11226
11291
  orgId,
@@ -11535,7 +11600,7 @@ async function pushProject({ client, projectDir }) {
11535
11600
  push: (await client.request(contract.pushes["pushes.create"], { body: {
11536
11601
  manifest: {
11537
11602
  ...manifest,
11538
- protocol: 2
11603
+ protocol: 3
11539
11604
  },
11540
11605
  project: project.name
11541
11606
  } })).data,
@@ -11634,13 +11699,11 @@ function versionPhrase(entry, now) {
11634
11699
  return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
11635
11700
  }
11636
11701
  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`;
11702
+ if (entry.enabled === null) return "";
11703
+ return entry.enabled ? "on" : "off";
11640
11704
  }
11641
11705
  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(", ");
11706
+ return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
11644
11707
  }
11645
11708
  function pad(text, width) {
11646
11709
  return text.padEnd(width);
@@ -11690,11 +11753,9 @@ function statusLines(status, manifest, now) {
11690
11753
  lines.push("", "On the server and not in this tree:");
11691
11754
  for (const entry of missing) lines.push(` ${entry.key}, a ${entry.kind}. Delete it with cow ${entry.kind}s delete ${entry.key}.`);
11692
11755
  }
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}`);
11756
+ if (status.warnings.length > 0) {
11757
+ lines.push("", "Names this organization does not define yet:");
11758
+ for (const warning of status.warnings) lines.push(` ${warning}`);
11698
11759
  }
11699
11760
  return lines;
11700
11761
  }
@@ -11889,7 +11950,7 @@ async function runScenario(projectDir, key, scenario) {
11889
11950
  let error;
11890
11951
  for (;;) {
11891
11952
  const output = await runGuest(module, {
11892
- protocol: 2,
11953
+ protocol: 3,
11893
11954
  kind: "journey",
11894
11955
  key,
11895
11956
  event: trigger,
@@ -11972,32 +12033,6 @@ function registerTest(program, io) {
11972
12033
 
11973
12034
  //#endregion
11974
12035
  //#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
12036
  /** Every command prints the response envelope as JSON. */
12002
12037
  function emit(result, io, json) {
12003
12038
  const compact = json === true || !io.isTTY;
@@ -12005,7 +12040,7 @@ function emit(result, io, json) {
12005
12040
  }
12006
12041
  function buildProgram(env, io) {
12007
12042
  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)`);
12043
+ 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
12044
  program.hook("preAction", () => {
12010
12045
  const flag = program.opts().config;
12011
12046
  setConfigFile((typeof flag === "string" ? flag : void 0) ?? env.COW_CONFIG ?? "cow.json");
@@ -12015,10 +12050,7 @@ function buildProgram(env, io) {
12015
12050
  return createClient({
12016
12051
  baseUrl: resolveApiUrl(env, credentials, typeof opts.api === "string" ? opts.api : void 0, (await readCowConfig(process.cwd()))?.apiUrl),
12017
12052
  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
- })
12053
+ headers: async () => ({ [CLIENT_HEADER]: `@cowliss/cli/${(await readCliPackage()).version}` })
12022
12054
  });
12023
12055
  };
12024
12056
  const run = async (opts, fn) => {
@@ -12035,7 +12067,7 @@ function buildProgram(env, io) {
12035
12067
  registerBuild(program, io);
12036
12068
  registerPush(program, clientFor, env, io);
12037
12069
  registerStatus(program, clientFor, io);
12038
- registerEnable(program, clientFor, env, io);
12070
+ registerEnable(program, clientFor, io);
12039
12071
  registerPull(program, clientFor, io);
12040
12072
  registerTest(program, io);
12041
12073
  registerMcp(program, clientFor, env, io);
@@ -12056,8 +12088,6 @@ const envSchema = z.object({
12056
12088
  COW_TOKEN: z.string().min(1).optional(),
12057
12089
  /** An org pipeline key for CI; when set it wins over the cached session (ticket 05 reads it). */
12058
12090
  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
12091
  /**
12062
12092
  * The project config to read instead of `cow.json`, so one checkout can
12063
12093
  * hold both the committed production config and a local override.