@cowliss/cli 0.6.0 → 0.8.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
@@ -33,6 +33,34 @@ const DOCS_URL = "https://docs.cowliss.com";
33
33
  */
34
34
  const REQUEST_ID_HEADER = "X-Request-Id";
35
35
  /**
36
+ * Stripe-style ID prefixes per resource type, so ids in logs, URLs, and
37
+ * payloads are self-describing.
38
+ */
39
+ const ID_PREFIXES = {
40
+ user: "usr_",
41
+ event: "evt_",
42
+ identifier: "idn_",
43
+ segment: "seg_",
44
+ /** The attribution unit: a readable `app_<slug>`, not a TypeID. */
45
+ app: "app_",
46
+ /** One inbound pipe into an app; a TypeID like everything else. */
47
+ source: "src_",
48
+ webhook: "whk_",
49
+ push: "psh_",
50
+ version: "ver_",
51
+ execution: "exe_",
52
+ delivery: "dlv_",
53
+ violation: "vio_",
54
+ quarantineEntry: "qtn_",
55
+ idempotencyKey: "idk_",
56
+ abuseEvent: "abu_",
57
+ suppression: "sup_",
58
+ senderDomain: "dom_",
59
+ journeyRun: "run_",
60
+ topup: "top_",
61
+ emailAddress: "eml_"
62
+ };
63
+ /**
36
64
  * Clerk ID prefix marking an organization-scoped subject. Ingestion API keys
37
65
  * must resolve to an org subject; user-scoped keys are rejected.
38
66
  */
@@ -82,30 +110,30 @@ const TOPUP_PRESETS_MICROS = [
82
110
  2e8
83
111
  ];
84
112
  /**
85
- * Fixed consent purposes for the prototype. Consent is a per-purpose map on
86
- * the profile, checked at send-step execution time.
87
- *
88
- * The two names describe what the recipient agreed to, not the pipe it
89
- * arrives on: "emails I did not ask for individually" and "my data leaving
90
- * for somewhere else". Naming them after the channel (`email`, `webhook`)
91
- * said nothing a recipient could consent to, and transactional mail already
92
- * bypasses the email purpose, so it was only ever marketing consent.
93
- */
94
- const CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
95
- /**
96
113
  * The marketing purpose by name, since it is the one every gate, the
97
114
  * unsubscribe route, and the developer's own toggle all reach for.
98
115
  */
99
- const EMAIL_MARKETING = "emailMarketing";
116
+ const MARKETING = "marketing";
117
+ /**
118
+ * A legal journey `purpose` that is not a consent purpose: the send gates
119
+ * pass it unconditionally, no profile's map stores it and no editor renders
120
+ * it. What protects the channel itself (suppression, the SES gates, the
121
+ * quota) still applies.
122
+ */
123
+ const TRANSACTIONAL = "transactional";
124
+ /**
125
+ * The purposes a project may not declare, which are the same two that sit
126
+ * outside the `marketing` umbrella: `marketing` is the umbrella itself and
127
+ * `transactional` is the case consent does not govern. Everything a project
128
+ * declares is a marketing-mail category and so sits under it.
129
+ */
130
+ const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
100
131
  /**
101
132
  * What a purpose means when the profile's map does not answer it, matching
102
- * the `profiles.consent` column default: marketing is asked for, everything
103
- * else about the data the developer already sends is granted.
133
+ * the `profiles.consent` column default: marketing is asked for, never
134
+ * assumed.
104
135
  */
105
- const CONSENT_PURPOSE_DEFAULTS = {
106
- emailMarketing: false,
107
- dataProcessing: true
108
- };
136
+ const CONSENT_PURPOSE_DEFAULTS = { marketing: false };
109
137
  /**
110
138
  * The `defaults` argument `consentGranted` takes, built from an org's
111
139
  * purpose rows. Every surface that renders or gates a purpose reads those
@@ -3143,7 +3171,7 @@ const bytea = customType({ dataType: () => "bytea" });
3143
3171
  /**
3144
3172
  * What an artifact is: a journey or template bundle as `cow build` emitted
3145
3173
  * it, the wasm module the server compiled from it, or the gzipped source
3146
- * tarball of the project the release was built from.
3174
+ * tarball of the repo the push was built from.
3147
3175
  */
3148
3176
  const artifactKindEnum = pgEnum("artifact_kind", [
3149
3177
  "bundle",
@@ -3264,14 +3292,14 @@ const insertCatalogTraitSchema = createInsertSchema(catalogTraits);
3264
3292
  //#region ../../packages/db/src/schema/consent-purposes.ts
3265
3293
  /**
3266
3294
  * The consent purposes an org's profiles answer: the two fixed ones, seeded
3267
- * the first time the set is read, plus whatever the org's projects declare
3268
- * in their `cow.json`. One read here returns the whole set, so nothing
3295
+ * the first time the set is read, plus whatever the org's apps declare in
3296
+ * their `cow.json`. One read here returns the whole set, so nothing
3269
3297
  * downstream unions a table with a constant.
3270
3298
  *
3271
- * Org-wide rather than per-project: `profiles.consent` is
3272
- * one jsonb map per profile, so an answer given under one project is the
3273
- * same answer under the next, and two projects declaring one key must agree
3274
- * on its label and default or the deploy is refused.
3299
+ * Org-wide rather than per-app: `profiles.consent` is one jsonb map per
3300
+ * profile, so an answer given under one app is the same answer under the
3301
+ * next, and two apps declaring one key must agree on its label and default
3302
+ * or the deploy is refused.
3275
3303
  *
3276
3304
  * A deploy adds and updates rows and never deletes one (ADR 0009): profiles
3277
3305
  * already hold answers against a purpose, and deleting it would orphan them.
@@ -3289,14 +3317,18 @@ const consentPurposes = pgTable("consent_purposes", {
3289
3317
  */
3290
3318
  defaultGranted: boolean("default_granted").notNull(),
3291
3319
  /**
3292
- * The project whose `cow.json` declared it, and the one a disagreeing
3293
- * deploy is refused in the name of. Null for the two fixed purposes,
3294
- * which every org has and no project owns.
3320
+ * The app whose `cow.json` declared it, and the one a disagreeing deploy
3321
+ * is refused in the name of. Null for the two fixed purposes, which every
3322
+ * org has and no app owns.
3295
3323
  */
3296
- projectId: text("project_id"),
3324
+ appId: text("app_id"),
3297
3325
  createdAt: createdAt(),
3298
3326
  updatedAt: updatedAt()
3299
- }, (table) => [primaryKey({ columns: [table.orgId, table.key] })]);
3327
+ }, (table) => [primaryKey({ columns: [table.orgId, table.key] }), foreignKey({
3328
+ columns: [table.orgId, table.appId],
3329
+ foreignColumns: [apps$1.orgId, apps$1.id],
3330
+ name: "consent_purposes_app_fk"
3331
+ })]);
3300
3332
 
3301
3333
  //#endregion
3302
3334
  //#region ../../packages/db/src/schema/deliveries.ts
@@ -3341,6 +3373,10 @@ const deliveryKindEnum = pgEnum("delivery_kind", [
3341
3373
  * `skipped_suppressed` is the recipient's address sitting in the
3342
3374
  * suppression mirror, which SES would have bounced off its own
3343
3375
  * suppression list anyway.
3376
+ * `skipped_unknown_purpose` is the version naming a purpose the
3377
+ * organization does not have, which is a broken manifest rather than a
3378
+ * consent decision: the `error` column names the purpose, and the fix is
3379
+ * another push (ADR 0017).
3344
3380
  * - `would_*` are dry-run outcomes: terminal, never reach SES, and
3345
3381
  * excluded from feedback, quotas, and reconciliation.
3346
3382
  *
@@ -3357,6 +3393,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3357
3393
  "skipped_paused",
3358
3394
  "skipped_suppressed",
3359
3395
  "skipped_quota",
3396
+ "skipped_unknown_purpose",
3360
3397
  "skipped_frequency_cap",
3361
3398
  "skipped_consent",
3362
3399
  "skipped_domain",
@@ -3366,6 +3403,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3366
3403
  "would_skip_paused",
3367
3404
  "would_skip_suppressed",
3368
3405
  "would_skip_quota",
3406
+ "would_skip_unknown_purpose",
3369
3407
  "would_skip_frequency_cap",
3370
3408
  "would_skip_consent",
3371
3409
  "would_skip_domain",
@@ -3386,6 +3424,15 @@ const deliveries$1 = pgTable("deliveries", {
3386
3424
  * neither of which has one.
3387
3425
  */
3388
3426
  journey: text("journey"),
3427
+ /**
3428
+ * The journey execution (`exe_`) this send was made from, so an execution
3429
+ * page can show what it sent. Null is a real state rather than a gap: a
3430
+ * test send and an api send have no execution behind them, and rows
3431
+ * written before this column existed have none to name. No foreign key,
3432
+ * because executions are pruned on their own schedule and a delivery is a
3433
+ * log row that outlives them.
3434
+ */
3435
+ executionId: text("execution_id"),
3389
3436
  kind: deliveryKindEnum("kind").notNull().default("journey"),
3390
3437
  /**
3391
3438
  * Email template name or webhook name. An api send that named no
@@ -3443,6 +3490,7 @@ const deliveries$1 = pgTable("deliveries", {
3443
3490
  }, (table) => [
3444
3491
  index("deliveries_org_id_created_at_idx").on(table.orgId, table.createdAt),
3445
3492
  index("deliveries_org_id_profile_id_created_at_idx").on(table.orgId, table.profileId, table.createdAt),
3493
+ index("deliveries_org_id_execution_id_created_at_idx").on(table.orgId, table.executionId, table.createdAt),
3446
3494
  uniqueIndex("deliveries_org_id_attempt_key_unique").on(table.orgId, table.attemptKey),
3447
3495
  uniqueIndex("deliveries_provider_id_unique").on(table.providerId),
3448
3496
  index("deliveries_status_sent_at_idx").on(table.status, table.sentAt)
@@ -3551,7 +3599,7 @@ const executions$1 = pgTable("executions", {
3551
3599
  })
3552
3600
  }, (table) => [
3553
3601
  index("executions_org_id_version_id_status_idx").on(table.orgId, table.versionId, table.status),
3554
- uniqueIndex("executions_org_id_workflow_id_unique").on(table.orgId, table.workflowId),
3602
+ index("executions_org_id_workflow_id_status_idx").on(table.orgId, table.workflowId, table.status),
3555
3603
  index("executions_org_id_started_at_idx").on(table.orgId, table.startedAt)
3556
3604
  ]);
3557
3605
  const selectExecutionSchema = createSelectSchema(executions$1);
@@ -3619,11 +3667,11 @@ const idempotencyKeys = pgTable("idempotency_keys", {
3619
3667
  * creating one when none is known.
3620
3668
  *
3621
3669
  * traits is the merged trait bag (RFC 7386 key-level merge on write).
3622
- * consent is the fixed-purpose consent map from the spec. Marketing starts
3623
- * denied and data processing granted: nobody is subscribed by the act of
3624
- * being ingested, while the processing the product runs on is the basis the
3625
- * profile exists under at all. It is written by the identify path (a caller
3626
- * passing `consent`), the consent editor, and the automatic revocations.
3670
+ * consent is the per-purpose consent map. Marketing starts denied: nobody
3671
+ * is subscribed by the act of being ingested. `transactional` is absent by
3672
+ * design (ADR 0017): it is not a purpose anyone may withhold, so it is not
3673
+ * stored. The map is written by the identify path (a caller passing
3674
+ * `consent`), the consent editor, and the automatic revocations.
3627
3675
  *
3628
3676
  * `mergedInto` is the merge pointer (spec: Identity). Null on a live
3629
3677
  * profile; on a profile a merge folded away it names the survivor, whose
@@ -3641,10 +3689,7 @@ const profiles = pgTable("profiles", {
3641
3689
  appId: text("app_id").notNull(),
3642
3690
  sourceId: text("source_id").notNull(),
3643
3691
  traits: jsonb("traits").$type().notNull().default({}),
3644
- consent: jsonb("consent").$type().notNull().default({
3645
- emailMarketing: false,
3646
- dataProcessing: true
3647
- }),
3692
+ consent: jsonb("consent").$type().notNull().default({ marketing: false }),
3648
3693
  mergedInto: text("merged_into"),
3649
3694
  createdAt: createdAt(),
3650
3695
  updatedAt: updatedAt()
@@ -3685,10 +3730,10 @@ const identifierKindEnum = pgEnum("identifier_kind", [
3685
3730
  ]);
3686
3731
  /**
3687
3732
  * The identifiers map: every named identifier a call has ever carried,
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.
3733
+ * pointing at the profile it resolved to. Unique per (org, app, kind, value),
3734
+ * which is what makes "a shared identifier means the same person" enforceable
3735
+ * at the row level, inside one app: the same clerkId twice in one app is one
3736
+ * person, and the same email on two apps is two people (ADR 0022).
3692
3737
  *
3693
3738
  * Rows follow their profile: erasure deletes the profile and these cascade,
3694
3739
  * and a merge re-points them at the survivor.
@@ -3696,11 +3741,21 @@ const identifierKindEnum = pgEnum("identifier_kind", [
3696
3741
  const identifiers = pgTable("identifiers", {
3697
3742
  id: text("id").primaryKey(),
3698
3743
  orgId: text("org_id").notNull(),
3744
+ /** The app this identifier resolves in; always its profile's app. */
3745
+ appId: text("app_id").notNull(),
3699
3746
  kind: identifierKindEnum("kind").notNull(),
3700
3747
  value: text("value").notNull(),
3701
3748
  profileId: text("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
3702
3749
  createdAt: createdAt()
3703
- }, (table) => [uniqueIndex("identifiers_org_kind_value_unique").on(table.orgId, table.kind, table.value), index("identifiers_profile_id_idx").on(table.profileId)]);
3750
+ }, (table) => [
3751
+ uniqueIndex("identifiers_org_app_kind_value_unique").on(table.orgId, table.appId, table.kind, table.value),
3752
+ index("identifiers_profile_id_idx").on(table.profileId),
3753
+ foreignKey({
3754
+ columns: [table.orgId, table.appId],
3755
+ foreignColumns: [apps$1.orgId, apps$1.id],
3756
+ name: "identifiers_app_fk"
3757
+ })
3758
+ ]);
3704
3759
  const selectIdentifierSchema = createSelectSchema(identifiers);
3705
3760
  const insertIdentifierSchema = createInsertSchema(identifiers);
3706
3761
 
@@ -3745,6 +3800,18 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3745
3800
  //#endregion
3746
3801
  //#region ../../packages/db/src/schema/journeys.ts
3747
3802
  /**
3803
+ * The one operational state a manager sets (ADR 0018): `on` (recipients
3804
+ * enroll and executions run), `off` (nothing enrolls, executions in flight
3805
+ * finish) or `paused` (nothing enrolls, executions in flight hold before
3806
+ * their next step). Three values rather than two booleans: a second flag
3807
+ * beside the first would spell two combinations that mean nothing.
3808
+ */
3809
+ const journeyStatusEnum = pgEnum("journey_status", [
3810
+ "on",
3811
+ "off",
3812
+ "paused"
3813
+ ]);
3814
+ /**
3748
3815
  * Derived journey rows: one per (org, key), upserted from the manifest a
3749
3816
  * push carried. Nothing here is authored through the API, which is why
3750
3817
  * there is no id of its own: the key is the name the author gave the file,
@@ -3752,14 +3819,15 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3752
3819
  *
3753
3820
  * A push only ever adds and updates: it owns the keys its manifest carries
3754
3821
  * and leaves every other row alone, because an org's journeys can come from
3755
- * several projects and a developer may push a project holding only some of
3756
- * the files. `projectId` records which project put the row here, which is
3757
- * what makes a key another project owns a refused push rather than a silent
3758
- * overwrite. Removing a journey is an explicit delete (ADR 0009).
3822
+ * several apps and a developer may push a repo holding only some of the
3823
+ * files. `appId` records which app put the row here, which is what makes a
3824
+ * key another app owns a refused push rather than a silent overwrite
3825
+ * (ADR 0009, held by the app since ADR 0022). Removing a journey is an
3826
+ * explicit delete.
3759
3827
  *
3760
- * Whether a journey fires is one flag and one only: `enabled`, which a
3828
+ * Whether a journey fires is one field and one only: `status`, which a
3761
3829
  * push never writes, so deploying code never turns anything on or off. The
3762
- * author has no second gate of their own (ADR 0011).
3830
+ * author has no second gate of their own (ADR 0011, ADR 0018).
3763
3831
  *
3764
3832
  * The descriptive columns are what its latest ready version reported, so a
3765
3833
  * failed compile leaves both the code and its description as they were.
@@ -3769,28 +3837,38 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3769
3837
  const journeys$1 = pgTable("journeys", {
3770
3838
  orgId: text("org_id").notNull(),
3771
3839
  key: text("key").notNull(),
3772
- /** The project whose push owns this key. */
3773
- projectId: text("project_id").notNull(),
3840
+ /** The app whose push owns this key, and the only app it fires for. */
3841
+ appId: text("app_id").notNull(),
3774
3842
  /** The author's labels, from the manifest; the dashboard's only grouping. */
3775
3843
  tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
3776
3844
  trigger: jsonb("trigger").$type().notNull(),
3777
3845
  /**
3778
3846
  * The consent purpose this journey's sends are gated on: one of the two
3779
- * fixed keys or one the project declares in `cow.json`. Text and not an
3847
+ * fixed keys or one the app's repo declares in `cow.json`. Text and not an
3780
3848
  * enum, because the set is the org's `consent_purposes` rows, which a
3781
3849
  * deploy checks the key against; a database enum could only ever hold
3782
3850
  * the fixed pair.
3783
3851
  */
3784
3852
  purpose: text("purpose").notNull(),
3853
+ /**
3854
+ * The author's own sentence about what the journey does, from the
3855
+ * manifest. Null when they wrote none.
3856
+ */
3857
+ description: text("description"),
3785
3858
  spine: jsonb("spine").$type().notNull(),
3786
3859
  /**
3787
3860
  * 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).
3861
+ * control, and never touched by a push (ADR 0011, amended by ADR 0013
3862
+ * and ADR 0018).
3789
3863
  */
3790
- enabled: boolean("enabled").notNull().default(false),
3864
+ status: journeyStatusEnum("status").notNull().default("off"),
3791
3865
  createdAt: createdAt(),
3792
3866
  updatedAt: updatedAt()
3793
- }, (table) => [primaryKey({ columns: [table.orgId, table.key] })]);
3867
+ }, (table) => [primaryKey({ columns: [table.orgId, table.key] }), foreignKey({
3868
+ columns: [table.orgId, table.appId],
3869
+ foreignColumns: [apps$1.orgId, apps$1.id],
3870
+ name: "journeys_app_fk"
3871
+ })]);
3794
3872
  const selectJourneySchema = createSelectSchema(journeys$1);
3795
3873
  const insertJourneySchema = createInsertSchema(journeys$1);
3796
3874
 
@@ -3814,17 +3892,16 @@ const ingestionPolicyEnum = pgEnum("ingestion_policy", ["permissive", "strict"])
3814
3892
  * switch, the allowance override, and Clerk's cached profile.
3815
3893
  *
3816
3894
  * `slug` and `name` are the exception to "only what Cowliss owns": they are
3817
- * Clerk's, cached here by the API's org-sync middleware because the worker
3818
- * has no Clerk client and still has to build the shared fallback sender
3819
- * `<org-slug>@<shared domain>` with the org's name as the from-name.
3895
+ * Clerk's, cached here by the API's org-sync middleware, and they are what a
3896
+ * template test-send composes its from-address out of (`orgFromAddress`).
3820
3897
  */
3821
3898
  const orgSettings = pgTable("org_settings", {
3822
3899
  orgId: text("org_id").primaryKey(),
3823
3900
  /**
3824
3901
  * The Clerk org's slug and display name, synced by the API on request.
3825
- * Null until a member of the org has hit the dashboard API once; the
3826
- * shared fallback sender falls back to an orgId-derived local part and the
3827
- * tool name until then.
3902
+ * Null until a member of the org has hit the dashboard API once, when
3903
+ * `orgFromAddress` falls back to an orgId-derived local part and the tool
3904
+ * name.
3828
3905
  */
3829
3906
  slug: text("slug"),
3830
3907
  name: text("name"),
@@ -3891,33 +3968,15 @@ const orgSettings = pgTable("org_settings", {
3891
3968
  const selectOrgSettingsSchema = createSelectSchema(orgSettings);
3892
3969
  const insertOrgSettingsSchema = createInsertSchema(orgSettings);
3893
3970
 
3894
- //#endregion
3895
- //#region ../../packages/db/src/schema/projects.ts
3896
- /**
3897
- * An org's cow project: the folder a developer runs `cow init` in, and the
3898
- * thing pushes belong to. An org may hold several, one per repo, each with
3899
- * its own release sequence and its own slice of the deployed journey rows. The name is how `cow.json` picks
3900
- * one, so it is unique within the org.
3901
- */
3902
- const projects = pgTable("projects", {
3903
- id: text("id").primaryKey(),
3904
- orgId: text("org_id").notNull(),
3905
- name: text("name").notNull(),
3906
- createdAt: createdAt(),
3907
- updatedAt: updatedAt()
3908
- }, (table) => [uniqueIndex("projects_org_id_name_unique").on(table.orgId, table.name)]);
3909
- const selectProjectSchema = createSelectSchema(projects);
3910
- const insertProjectSchema = createInsertSchema(projects);
3911
-
3912
3971
  //#endregion
3913
3972
  //#region ../../packages/db/src/schema/pushes.ts
3914
3973
  /**
3915
- * One `cow push`: the whole project at one point in time. A push stores the
3974
+ * One `cow push`: the whole repo at one point in time. A push stores the
3916
3975
  * source archive and creates a version of every key whose bundle changed
3917
3976
  * (ADR 0011); it settles nothing and changes no flag, so it has no status
3918
3977
  * of its own. What is compiling is read off its versions.
3919
3978
  *
3920
- * `seq` is the human handle and is per project, allocated inside the create
3979
+ * `seq` is the human handle and is per app, allocated inside the create
3921
3980
  * transaction; the unique index leads with the org id so it doubles as the
3922
3981
  * tenancy key.
3923
3982
  *
@@ -3928,7 +3987,8 @@ const insertProjectSchema = createInsertSchema(projects);
3928
3987
  const pushes$1 = pgTable("pushes", {
3929
3988
  id: text("id").primaryKey(),
3930
3989
  orgId: text("org_id").notNull(),
3931
- projectId: text("project_id").notNull().references(() => projects.id),
3990
+ /** The app this push deployed; the push unit since ADR 0022. */
3991
+ appId: text("app_id").notNull(),
3932
3992
  seq: integer("seq").notNull(),
3933
3993
  manifest: jsonb("manifest").$type().notNull(),
3934
3994
  /** sha256 of the pushed manifest; the CLI compares it to skip a no-op push. */
@@ -3937,7 +3997,15 @@ const pushes$1 = pgTable("pushes", {
3937
3997
  sourceDigest: text("source_digest").notNull(),
3938
3998
  createdBy: text("created_by").notNull(),
3939
3999
  createdAt: createdAt()
3940
- }, (table) => [uniqueIndex("pushes_org_id_project_id_seq_unique").on(table.orgId, table.projectId, table.seq), index("pushes_org_id_created_at_idx").on(table.orgId, table.createdAt)]);
4000
+ }, (table) => [
4001
+ uniqueIndex("pushes_org_id_app_id_seq_unique").on(table.orgId, table.appId, table.seq),
4002
+ index("pushes_org_id_created_at_idx").on(table.orgId, table.createdAt),
4003
+ foreignKey({
4004
+ columns: [table.orgId, table.appId],
4005
+ foreignColumns: [apps$1.orgId, apps$1.id],
4006
+ name: "pushes_app_fk"
4007
+ })
4008
+ ]);
3941
4009
  const selectPushSchema = createSelectSchema(pushes$1);
3942
4010
  const insertPushSchema = createInsertSchema(pushes$1);
3943
4011
  /**
@@ -4038,9 +4106,20 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4038
4106
  //#endregion
4039
4107
  //#region ../../packages/db/src/schema/segments.ts
4040
4108
  /**
4041
- * Segments: dashboard-managed audience definitions. Membership is computed,
4042
- * never hand-maintained: the engine in packages/segments recomputes on every
4043
- * write and materializes the result into segment_members.
4109
+ * Segments: membership rules over profiles. Membership is computed, never
4110
+ * hand-maintained: the engine in packages/segments recomputes on every write
4111
+ * and materializes the result into segment_members.
4112
+ *
4113
+ * A segment belongs to one app (ADR 0022) and sees only that app's profiles
4114
+ * and that app's events.
4115
+ *
4116
+ * A row is either standalone (built in the dashboard, `journey_key` null) or
4117
+ * owned by one journey whose trigger inlined its definition (ADR 0016). An
4118
+ * owned row is named after its journey's key, is written only by the push,
4119
+ * and goes when the journey does — which is what the composite foreign key
4120
+ * says: it points at `journeys(org_id, key)` and cascades. A null
4121
+ * `journey_key` satisfies it vacuously (MATCH SIMPLE), so standalone rows
4122
+ * are unconstrained.
4044
4123
  *
4045
4124
  * Timestamps use millisecond precision, same rationale as apps: JS Dates
4046
4125
  * carry ms only and cursor pagination compares createdAt for equality.
@@ -4048,12 +4127,29 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4048
4127
  const segments$1 = pgTable("segments", {
4049
4128
  id: text("id").primaryKey(),
4050
4129
  orgId: text("org_id").notNull(),
4130
+ /** The one app this segment evaluates. */
4131
+ appId: text("app_id").notNull(),
4051
4132
  name: text("name").notNull(),
4052
4133
  description: text("description"),
4053
4134
  definition: jsonb("definition").$type().notNull(),
4135
+ /** The journey that owns this row, or null for a standalone segment. */
4136
+ journeyKey: text("journey_key"),
4054
4137
  createdAt: createdAt(),
4055
4138
  updatedAt: updatedAt()
4056
- }, (table) => [index("segments_org_id_idx").on(table.orgId), uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name)]);
4139
+ }, (table) => [
4140
+ index("segments_org_id_idx").on(table.orgId),
4141
+ uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name),
4142
+ foreignKey({
4143
+ columns: [table.orgId, table.journeyKey],
4144
+ foreignColumns: [journeys$1.orgId, journeys$1.key],
4145
+ name: "segments_journey_fk"
4146
+ }).onDelete("cascade"),
4147
+ foreignKey({
4148
+ columns: [table.orgId, table.appId],
4149
+ foreignColumns: [apps$1.orgId, apps$1.id],
4150
+ name: "segments_app_fk"
4151
+ })
4152
+ ]);
4057
4153
  /**
4058
4154
  * Materialized membership: one row per (segment, profile) currently in the
4059
4155
  * segment. Deleting a segment drops its members with it, and erasing a
@@ -4089,10 +4185,16 @@ const insertSegmentSchema = createInsertSchema(segments$1);
4089
4185
  * emits no domain events, so the mirror refreshes by pulling
4090
4186
  * `GetEmailIdentity` on a schedule and on the manual re-check button.
4091
4187
  *
4092
- * The unique index on `domain` is GLOBAL, not per org: it is the
4093
- * anti-squatting rule from the spec. Once one org claims a domain string, no
4094
- * other org can claim it, and the cross-org parent/child guard (a query, not
4095
- * a constraint) covers the subdomain half of the same rule.
4188
+ * A domain is claimed when it VERIFIES, not when it is added (ADR 0021).
4189
+ * Any number of orgs may hold the same unverified string; the partial unique
4190
+ * below is what makes at most one of them verified, and so the owner. The
4191
+ * cross-org parent/child guard (a query, not a constraint) refuses a claim
4192
+ * under or over a name another org already verified.
4193
+ *
4194
+ * SES keys ONE identity by the domain name, so its DKIM records, and the
4195
+ * SUCCESS it reports for them, are shared by every org holding the name.
4196
+ * They therefore prove that SOMEBODY controls the DNS, never which org, and
4197
+ * `challenge_token` is the part that is per org: the claim needs both.
4096
4198
  */
4097
4199
  /**
4098
4200
  * SES's identity status vocabulary, lower-cased like every other enum here.
@@ -4100,13 +4202,19 @@ const insertSegmentSchema = createInsertSchema(segments$1);
4100
4202
  * all report the same five words. `temporary_failure` is SES's retryable
4101
4203
  * state, which is neither verified nor a dead end, so it is kept distinct
4102
4204
  * from `failed`.
4205
+ *
4206
+ * `blocked` is the one value SES never says: the DNS checks out, but another
4207
+ * organization has already verified this name or a parent or child of it, so
4208
+ * the claim cannot be granted here (ADR 0021). It is a terminal-looking
4209
+ * state that clears itself, because every refresh re-asks the question.
4103
4210
  */
4104
4211
  const senderDomainStatusEnum = pgEnum("sender_domain_status", [
4105
4212
  "not_started",
4106
4213
  "pending",
4107
4214
  "success",
4108
4215
  "failed",
4109
- "temporary_failure"
4216
+ "temporary_failure",
4217
+ "blocked"
4110
4218
  ]);
4111
4219
  const senderDomains = pgTable("sender_domains", {
4112
4220
  id: text("id").primaryKey(),
@@ -4120,12 +4228,25 @@ const senderDomains = pgTable("sender_domains", {
4120
4228
  spfStatus: text("spf_status"),
4121
4229
  /**
4122
4230
  * The per-domain click-tracking toggle, Cowliss's own setting: the send
4123
- * picks SES's tracked configuration set when it is on. Off by default
4124
- * and always off for the shared fallback domain (which has no row here
4125
- * at all): one setting cannot serve every org sharing it.
4231
+ * picks SES's tracked configuration set when it is on. Off by default:
4232
+ * link rewriting is a thing an org opts into.
4126
4233
  */
4127
4234
  clickTracking: boolean("click_tracking").notNull().default(false),
4128
4235
  dnsRecords: jsonb("dns_records").$type().notNull().default([]),
4236
+ /**
4237
+ * This org's own proof of control, published as a TXT record under the
4238
+ * domain. Random per row and never re-issued, so two orgs holding the
4239
+ * same name publish different values and only the one that controls the
4240
+ * DNS can publish its own. Verified once, at the moment the claim is
4241
+ * granted; a live sender never loses its domain to one failed lookup.
4242
+ */
4243
+ challengeToken: text("challenge_token").notNull().default(sql`replace(gen_random_uuid()::text, '-', '')`),
4244
+ /** When this org's own TXT record was last seen. Null until it is. */
4245
+ challengeVerifiedAt: timestamp("challenge_verified_at", {
4246
+ withTimezone: true,
4247
+ mode: "date",
4248
+ precision: 3
4249
+ }),
4129
4250
  /** When the mirror last asked SES; drives the refresh sweep. */
4130
4251
  lastCheckedAt: timestamp("last_checked_at", {
4131
4252
  withTimezone: true,
@@ -4134,7 +4255,11 @@ const senderDomains = pgTable("sender_domains", {
4134
4255
  }),
4135
4256
  createdAt: createdAt(),
4136
4257
  updatedAt: updatedAt()
4137
- }, (table) => [uniqueIndex("sender_domains_domain_unique").on(table.domain), index("sender_domains_org_id_created_at_idx").on(table.orgId, table.createdAt)]);
4258
+ }, (table) => [
4259
+ uniqueIndex("sender_domains_org_id_domain_unique").on(table.orgId, table.domain),
4260
+ uniqueIndex("sender_domains_verified_domain_unique").on(table.domain).where(sql`${table.status} = 'success'`),
4261
+ index("sender_domains_org_id_created_at_idx").on(table.orgId, table.createdAt)
4262
+ ]);
4138
4263
  const selectSenderDomainSchema = createSelectSchema(senderDomains);
4139
4264
  const insertSenderDomainSchema = createInsertSchema(senderDomains);
4140
4265
 
@@ -4286,8 +4411,8 @@ const versions$1 = pgTable("versions", {
4286
4411
  /** The file basename its author wrote; unique per org within a kind. */
4287
4412
  key: text("key").notNull(),
4288
4413
  kind: versionKindEnum("kind").notNull(),
4289
- /** The project that pushed this key; ADR 0009's ownership. */
4290
- projectId: text("project_id").notNull().references(() => projects.id),
4414
+ /** The app that pushed this key; ADR 0009's ownership, ADR 0022's app. */
4415
+ appId: text("app_id").notNull(),
4291
4416
  /** The push that compiled this version's module. */
4292
4417
  pushId: text("push_id").notNull().references(() => pushes$1.id),
4293
4418
  moduleDigest: text("module_digest"),
@@ -4307,7 +4432,12 @@ const versions$1 = pgTable("versions", {
4307
4432
  }, (table) => [
4308
4433
  uniqueIndex("versions_org_id_kind_key_module_digest_unique").on(table.orgId, table.kind, table.key, table.moduleDigest),
4309
4434
  index("versions_org_id_kind_key_pushed_at_idx").on(table.orgId, table.kind, table.key, table.pushedAt),
4310
- index("versions_org_id_push_id_idx").on(table.orgId, table.pushId)
4435
+ index("versions_org_id_push_id_idx").on(table.orgId, table.pushId),
4436
+ foreignKey({
4437
+ columns: [table.orgId, table.appId],
4438
+ foreignColumns: [apps$1.orgId, apps$1.id],
4439
+ name: "versions_app_fk"
4440
+ })
4311
4441
  ]);
4312
4442
  const selectVersionSchema = createSelectSchema(versions$1);
4313
4443
  const insertVersionSchema = createInsertSchema(versions$1);
@@ -4490,8 +4620,6 @@ const ERROR_CODE_STATUS = {
4490
4620
  journey_nondeterministic: 500,
4491
4621
  push_invalid: 422,
4492
4622
  version_not_ready: 409,
4493
- project_exists: 409,
4494
- project_missing: 404,
4495
4623
  artifact_too_large: 413
4496
4624
  };
4497
4625
  /** First zod issue's message: enough signal for a 422 without a novel format. */
@@ -4545,6 +4673,21 @@ const identifierValueSchema = z.string().trim().min(1, "an identifier value is r
4545
4673
  * and a call names only the kinds it has.
4546
4674
  */
4547
4675
  const identifiersSchema = z.partialRecord(identifierKindSchema, identifierValueSchema).refine((value) => Object.keys(value).length > 0, { message: "identifiers must carry at least one entry" });
4676
+ /**
4677
+ * One identifier named as an object: how compliance addresses a person
4678
+ * when no `usr_` id is at hand (ADR 0022). A value two apps know is two
4679
+ * people, so the routes taking this act on every one of them.
4680
+ *
4681
+ * An object rather than the lookup route's `<kind>:<value>` string, because
4682
+ * the kind is then an enum at the boundary (a typo'd kind is a 422 for
4683
+ * free) and an address needs no escaping inside it. The value is
4684
+ * normalized by `normalizeIdentifierValue` where it is read, like every
4685
+ * other identifier.
4686
+ */
4687
+ const identifierRefSchema = z.object({
4688
+ kind: identifierKindSchema,
4689
+ value: identifierValueSchema
4690
+ });
4548
4691
  /** One identifier as the profile DTOs carry it. */
4549
4692
  const identifierDtoSchema = z.object({
4550
4693
  kind: identifierKindSchema,
@@ -4552,6 +4695,82 @@ const identifierDtoSchema = z.object({
4552
4695
  createdAt: z.iso.datetime()
4553
4696
  });
4554
4697
 
4698
+ //#endregion
4699
+ //#region ../../packages/shared/src/segment-definition.ts
4700
+ /**
4701
+ * A segment definition: a flat list of predicates over traits and event
4702
+ * history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
4703
+ * prototype, and a flat list keeps the pure evaluator a fold.
4704
+ *
4705
+ * A definition names no app (ADR 0022): the segment row's own `app_id` is
4706
+ * the one app it evaluates, so the events and the profiles it reads are
4707
+ * that app's and there is nothing to filter by here.
4708
+ *
4709
+ * Apart from `./segments` because a journey's trigger carries a definition
4710
+ * and the trigger schema is bundled into the wasm guest, where an edge to
4711
+ * `@cowliss/db` (which `./segments` has, for the row schema) is a hard
4712
+ * bundler failure. Nothing here imports anything but zod.
4713
+ *
4714
+ * The object is strict: a definition holding the retired `sourceId` or
4715
+ * `appId` key (both of which meant the app) fails loudly instead of parsing
4716
+ * as an unfiltered definition that evaluates over every app. There is deliberately no pipe
4717
+ * filter here — filtering by source is a later feature, and accepting one
4718
+ * now would make a stale `sourceId` parse as a filter on a pipe that does
4719
+ * not exist and silently match nothing.
4720
+ */
4721
+ const SEGMENT_TRAIT_OPS = [
4722
+ "eq",
4723
+ "neq",
4724
+ "gt",
4725
+ "gte",
4726
+ "lt",
4727
+ "lte",
4728
+ "exists",
4729
+ "notExists",
4730
+ "contains"
4731
+ ];
4732
+ /** Operators that read no comparison value: presence of the key is the test. */
4733
+ const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
4734
+ const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
4735
+ const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
4736
+ kind: z.literal("trait"),
4737
+ name: predicateNameSchema,
4738
+ op: z.enum(SEGMENT_TRAIT_OPS),
4739
+ /**
4740
+ * Compared against the stored trait, which is `unknown` because
4741
+ * identify accepts arbitrary JSON. The evaluator coerces both sides
4742
+ * before comparing, so authors here (CLI, MCP, dashboard) get the
4743
+ * form-field-friendly reading rather than strict JSON equality:
4744
+ * numeric-looking strings are compared as numbers for eq/neq and for
4745
+ * ordering ("150" matches 150, and orders like it), and "true"/"false"
4746
+ * are compared as booleans for eq/neq, trimmed and case-insensitively
4747
+ * (" TRUE " reads as true). Coercion needs both sides to agree on a
4748
+ * type: "0" never equals false. The numeric net is as wide as
4749
+ * `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
4750
+ * matters most for opaque ids: "007" is authored as the number 7.
4751
+ *
4752
+ * contains reads three ways. Against an array trait it is membership
4753
+ * under that same equality, so "true" matches `[true]` and "1" matches
4754
+ * `[1, 2]`. Against a string trait it is a plain substring search with
4755
+ * no coercion, since substrings only mean something between strings.
4756
+ * Against anything else it never matches. Ordering never coerces
4757
+ * booleans.
4758
+ */
4759
+ value: z.unknown().optional()
4760
+ }), z.object({
4761
+ kind: z.literal("event"),
4762
+ name: predicateNameSchema,
4763
+ op: z.enum(["performed", "notPerformed"]),
4764
+ /** How many matching events the predicate counts as "performed". */
4765
+ atLeast: z.number().int().min(1).default(1),
4766
+ /** Rolling window, relative to evaluation time; absent means all history. */
4767
+ withinDays: z.number().int().min(1).optional()
4768
+ })]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
4769
+ const segmentDefinitionSchema = z.strictObject({
4770
+ match: z.enum(["all", "any"]).default("all"),
4771
+ predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
4772
+ });
4773
+
4555
4774
  //#endregion
4556
4775
  //#region ../../packages/shared/src/journeys-v2/manifest.ts
4557
4776
  /**
@@ -4561,6 +4780,34 @@ const identifierDtoSchema = z.object({
4561
4780
  * changed, and stores that entry on the version it creates.
4562
4781
  */
4563
4782
  /**
4783
+ * A duration as journey code writes it: an ms-style string ("2d") or
4784
+ * milliseconds. It lives here rather than with the guest protocol because a
4785
+ * manifest carries one too (a journey's enrollment cooldown) and `./guest`
4786
+ * already imports this module, so the other direction would be a cycle.
4787
+ */
4788
+ const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
4789
+ const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
4790
+ const DURATION_UNIT_MS = {
4791
+ ms: 1,
4792
+ s: 1e3,
4793
+ m: 6e4,
4794
+ h: 36e5,
4795
+ d: 864e5,
4796
+ w: 6048e5
4797
+ };
4798
+ /**
4799
+ * A duration in milliseconds. The simulator's virtual clock and the runner's
4800
+ * timers both need it, and neither may pull in Temporal's `msToNumber` (one
4801
+ * runs in the CLI, the other inside workflow code).
4802
+ */
4803
+ function parseDuration(duration) {
4804
+ if (typeof duration === "number") return duration;
4805
+ const match = DURATION_PATTERN.exec(duration.trim());
4806
+ if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
4807
+ const unit = DURATION_UNIT_MS[match[2]];
4808
+ return Math.round(Number(match[1]) * unit);
4809
+ }
4810
+ /**
4564
4811
  * A journey or template key: the file basename under `journeys/` or
4565
4812
  * `emails/`, kebab-case and unique across the project. Becomes part of the
4566
4813
  * Temporal workflow id and travels in the journey chain, so it stays short.
@@ -4581,8 +4828,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
4581
4828
  */
4582
4829
  const tagsSchema = z.array(z.string().trim().min(1).max(50, "a tag must be at most 50 characters")).max(20, "a journey or template takes at most 20 tags").default([]).transform((tags) => [...new Set(tags)]);
4583
4830
  /**
4584
- * A consent purpose key: camelCase, matching the fixed `emailMarketing` and
4585
- * `dataProcessing`. Purposes are keys in the `consent` map a customer reads
4831
+ * A consent purpose key: camelCase, matching the reserved `marketing` and
4832
+ * `transactional`. Purposes are keys in the `consent` map a customer reads
4586
4833
  * on their own profile, which is why they are not the kebab-case of a
4587
4834
  * journey key.
4588
4835
  */
@@ -4597,22 +4844,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
4597
4844
  const consentPurposeKeySchema = z.string().max(50, "a purpose key must be at most 50 characters").regex(CONSENT_PURPOSE_KEY_PATTERN, "a consent purpose key must be camelCase (a letter first, then letters and digits)");
4598
4845
  /**
4599
4846
  * One purpose a project declares in `cow.json` (spec: Decisions). A declared
4600
- * purpose is marketing-class and sits under the `emailMarketing` umbrella,
4601
- * so `denied` is the only default it may carry: the purpose is absent on
4602
- * every profile that already exists, and a granted default would answer for
4603
- * all of them at once.
4847
+ * purpose is marketing-class and sits under the `marketing` umbrella, so
4848
+ * `denied` is the only default it may carry: the purpose is absent on every
4849
+ * profile that already exists, and a granted default would answer for all of
4850
+ * them at once.
4604
4851
  *
4605
4852
  * The field stays required rather than disappearing, so every `cow.json` and
4606
- * every stored manifest written before this still parses, and the column
4607
- * behind it still holds `granted` for the seeded `dataProcessing` row: this
4608
- * is a refusal at declaration time, not a narrower storage shape.
4853
+ * every stored manifest written before this still parses: this is a refusal
4854
+ * at declaration time, not a narrower storage shape.
4609
4855
  *
4610
- * The two fixed purposes cannot be declared. They are seeded for every org
4611
- * and owned by no project, so a project redeclaring one would be renaming
4612
- * the master switch every other project's journeys hang off.
4856
+ * Neither reserved purpose can be declared: `marketing` is the master switch
4857
+ * every other project's journeys hang off, and `transactional` is not a
4858
+ * consent purpose at all, so declaring it would promise a switch that no
4859
+ * send ever reads.
4613
4860
  */
4614
4861
  const declaredPurposeSchema = z.strictObject({
4615
- key: consentPurposeKeySchema.refine((key) => !CONSENT_PURPOSES.includes(key), `${CONSENT_PURPOSES.join(" and ")} are fixed purposes and cannot be declared`),
4862
+ key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
4616
4863
  /** What the dashboard and the account modal render beside the switch. */
4617
4864
  label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
4618
4865
  /** What the purpose means for a profile whose map does not answer it. */
@@ -4633,19 +4880,27 @@ const purposesSchema = z.array(declaredPurposeSchema).max(20, "a project declare
4633
4880
  const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 200 characters");
4634
4881
  const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
4635
4882
  /**
4636
- * What starts a journey: an event (optionally narrowed to an app id or a
4637
- * list of them) or a segment entry. The registry DTO in `../journeys`
4638
- * reuses it.
4883
+ * What starts a journey: an event, or entry into the segment the trigger
4884
+ * itself describes. The registry DTO in `../journeys` reuses it.
4885
+ *
4886
+ * No trigger names an app (ADR 0022): a repository is one app, the journey
4887
+ * row carries its `app_id`, and the runtime compares that with the event's.
4888
+ * An `appId` here would be a second way to say it, and the way to leak.
4639
4889
  *
4640
- * Both members are strict, so a journey holding the retired `source` key
4641
- * fails to compile instead of silently triggering on every app.
4642
- * There is deliberately no pipe filter: a trigger narrows by the app the
4643
- * write is attributed to, the same token a segment definition names.
4890
+ * A segment trigger carries the predicate list, not a name: the push
4891
+ * materializes one segment row per journey that inlines a definition, owned
4892
+ * by the journey and named after its key, so the segment exists because the
4893
+ * journey exists and there is no order to get wrong (ADR 0016). The
4894
+ * definition is data — validated here, carried on the version, never
4895
+ * compiled and never executed.
4896
+ *
4897
+ * Both members are strict, so a journey holding the retired `source` or
4898
+ * `appId` key, or the retired `{ segment: "name" }` form, fails to build
4899
+ * instead of silently triggering on every app or on nothing. There is
4900
+ * deliberately no pipe filter and no app filter: the journey's own app is
4901
+ * the only narrowing there is.
4644
4902
  */
4645
- const triggerSchema = z.union([z.strictObject({
4646
- event: patternSchema,
4647
- appId: patternSchema.optional()
4648
- }), z.strictObject({ segment: z.string().min(1) })]);
4903
+ const triggerSchema = z.union([z.strictObject({ event: patternSchema }), z.strictObject({ segment: segmentDefinitionSchema })]);
4649
4904
  /**
4650
4905
  * The address half of a from-header: a local part, an `@`, and a dotted
4651
4906
  * domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
@@ -4748,10 +5003,35 @@ const manifestJourneySchema = z.object({
4748
5003
  */
4749
5004
  purpose: consentPurposeKeySchema,
4750
5005
  /**
5006
+ * How often one recipient may enter. Enrollment derives from the purpose
5007
+ * (ADR 0015), so the only thing an author writes is how long after a
5008
+ * completed run the journey re-opens: absent means once, ever. Refused on
5009
+ * a transactional journey, which enrolls on every trigger — see
5010
+ * `manifestSchema` below, where the cross-field check lives (a refinement
5011
+ * on this object would break the `.pick()` the guest SDK and the guest
5012
+ * protocol both take of it).
5013
+ */
5014
+ enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
5015
+ try {
5016
+ parseDuration(value);
5017
+ return true;
5018
+ } catch {
5019
+ return false;
5020
+ }
5021
+ }, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
5022
+ /**
5023
+ * The author's own sentence about what this journey does, shown wherever
5024
+ * the journey is read. Capped like a segment's description; absent when
5025
+ * the author wrote none.
5026
+ */
5027
+ description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
5028
+ /**
4751
5029
  * 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).
5030
+ * call names its own. Required of the author (`defineJourney` types it so,
5031
+ * and `cow build` refuses a journey without it), and still optional here:
5032
+ * releases pushed before ADR 0014 was amended carry none, and this schema
5033
+ * parses stored manifests as well as new ones. A push with no `from` is
5034
+ * refused by the domain gate, which says what to do about it.
4755
5035
  */
4756
5036
  from: fromSchema.optional(),
4757
5037
  spine: z.array(spineEntrySchema),
@@ -4764,6 +5044,13 @@ const manifestTemplateSchema = z.object({
4764
5044
  sendClass: z.enum(SEND_CLASSES),
4765
5045
  /** True asks the host to mint a signed `verifyUrl` prop at send time. */
4766
5046
  verifyLink: z.boolean(),
5047
+ /**
5048
+ * True asks the host to mint a signed `unsubscribeUrl` prop at send time,
5049
+ * for the author's own footer link. Default false: a marketing send whose
5050
+ * HTML carries no unsubscribe link at all still gets one, appended as a
5051
+ * platform footer, so the link is never the author's to forget.
5052
+ */
5053
+ unsubscribeLink: z.boolean().default(false),
4767
5054
  /** JSON Schema of the template's `props`, converted by `cow build`. */
4768
5055
  propsSchema: z.record(z.string(), z.unknown()),
4769
5056
  bundle: digestSchema
@@ -4814,6 +5101,15 @@ const manifestSchema = z.object({
4814
5101
  uniqueKeys(manifest.journeys, ctx, "journeys");
4815
5102
  uniqueKeys(manifest.templates, ctx, "templates");
4816
5103
  uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
5104
+ for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
5105
+ code: "custom",
5106
+ message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
5107
+ path: [
5108
+ "journeys",
5109
+ index,
5110
+ "enrollment"
5111
+ ]
5112
+ });
4817
5113
  });
4818
5114
  /**
4819
5115
  * One entry of a stored manifest: what a version is a snapshot of. Journeys
@@ -4877,7 +5173,7 @@ const traitBagSchema = z.custom((value) => value !== null && typeof value === "o
4877
5173
  * `identify` carries it too: the grant has to have a route in through
4878
5174
  * ingestion, or the only thing anyone can express is the revocation.
4879
5175
  */
4880
- const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [EMAIL_MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
5176
+ const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
4881
5177
  /**
4882
5178
  * The identify payload fields, shared between the single-call body schema
4883
5179
  * and the batch item schema (which drops `sourceId`: a batch names one
@@ -4900,6 +5196,74 @@ const identifyDataSchema = identifyFieldsSchema.refine((data) => notFutureTimest
4900
5196
  });
4901
5197
  const identifyBodySchema = z.object({ data: identifyDataSchema });
4902
5198
 
5199
+ //#endregion
5200
+ //#region ../../packages/shared/src/journeys-v2/config.ts
5201
+ /**
5202
+ * An app id as `cow.json` carries it: the readable `app_<slug>`
5203
+ * `appIdFromName` derives, which is the one id a developer types by hand.
5204
+ *
5205
+ * It lives here rather than beside the app DTOs in ../apps because that
5206
+ * module imports the Drizzle schema, which neither the wasmtime guest nor
5207
+ * the Temporal workflow isolate can carry, and because `cow.json` is the
5208
+ * file a developer types it into. Everything that names an app on the wire
5209
+ * reuses it.
5210
+ */
5211
+ const appIdSchema = z.string().trim().max(200).regex(new RegExp(`^${ID_PREFIXES.app}[a-z0-9]+(-[a-z0-9]+)*$`), "an app id looks like \"app_website\"");
5212
+ /**
5213
+ * A source id: the one inbound pipe an ingestion or send call names. It
5214
+ * lives beside `appIdSchema` for the same two reasons — the sources module
5215
+ * imports the Drizzle schema, and both are ids that travel on the wire — and
5216
+ * it has the same shape, so a bare `src_` is refused here rather than at the
5217
+ * database.
5218
+ *
5219
+ * The prefixes above and here are interpolated raw; `journeys-v2.test.ts`
5220
+ * asserts they are plain `<word>_` literals, so none of them can quietly
5221
+ * change what these patterns match.
5222
+ */
5223
+ const sourceIdSchema = z.string().trim().max(200).regex(new RegExp(`^${ID_PREFIXES.source}[a-z0-9_]+$`), "a source id looks like \"src_01j8x…\"");
5224
+ /**
5225
+ * A Clerk organization id, as `cow.json` spells it. Exported beside
5226
+ * `appIdSchema` because the push body carries the org the file names so the
5227
+ * server can refuse a push aimed at another one (ADR 0022).
5228
+ */
5229
+ const orgIdSchema = z.string().trim().max(200).startsWith(CLERK_ORG_SUBJECT_PREFIX, "orgId must be a Clerk org id");
5230
+ /**
5231
+ * `cow.json` (spec: Project layout): the org, and which of its apps this
5232
+ * repository pushes to. Auth never lives in the file. Strict, so a typo'd
5233
+ * key is a build error rather than a silently ignored setting — which is
5234
+ * also what turns the retired `project` key into a refusal (`cow build`
5235
+ * says what to rename it to).
5236
+ */
5237
+ const cowConfigSchema = z.strictObject({
5238
+ $schema: z.url().optional(),
5239
+ orgId: orgIdSchema,
5240
+ /**
5241
+ * Which app of the org this repository pushes to (ADR 0022). The app is
5242
+ * the push unit: it owns the journey and template keys, and `cow
5243
+ * status`, `cow enable --all` and `cow pull` all resolve against it. One
5244
+ * repository is one app, so no journey file names one.
5245
+ */
5246
+ appId: appIdSchema,
5247
+ /**
5248
+ * The consent purposes this repository declares. They are org-wide, so
5249
+ * two apps declaring one key must agree on its label and default or the
5250
+ * push is refused; a push adds and updates them and never deletes one,
5251
+ * because profiles hold answers against them.
5252
+ */
5253
+ purposes: purposesSchema.optional(),
5254
+ /** Overrides the API the CLI talks to; the hosted product needs none. */
5255
+ apiUrl: z.url().optional(),
5256
+ /**
5257
+ * Overrides the dashboard `cow login` opens. Paired with `apiUrl`: a
5258
+ * login against one Cowliss's dashboard yields a token the other's API
5259
+ * rejects, so a config that names an API names its dashboard too.
5260
+ */
5261
+ webUrl: z.url().optional()
5262
+ }).meta({
5263
+ title: "cow.json",
5264
+ description: "A cow project: the organization and the app it pushes to."
5265
+ });
5266
+
4903
5267
  //#endregion
4904
5268
  //#region ../../packages/shared/src/track.ts
4905
5269
  /**
@@ -4959,7 +5323,7 @@ const trackBodySchema = z.object({ data: trackDataSchema });
4959
5323
  */
4960
5324
  const listEventsQuerySchema = paginationQuerySchema.extend({
4961
5325
  direction: sortDirectionSchema.default("desc"),
4962
- appId: z.string().trim().min(1).max(200).optional(),
5326
+ appId: appIdSchema.optional(),
4963
5327
  sourceId: z.string().trim().min(1).max(200).optional(),
4964
5328
  event: z.string().trim().min(1).max(200).optional(),
4965
5329
  /**
@@ -5482,10 +5846,16 @@ const listDeliveriesQuerySchema = paginationQuerySchema.extend({
5482
5846
  channel: deliveryChannelSchema.optional(),
5483
5847
  /** Why the send happened: a journey, a test send, or a direct API send. */
5484
5848
  kind: deliveryKindSchema.optional(),
5485
- appId: z.string().trim().min(1).max(200).optional(),
5849
+ appId: appIdSchema.optional(),
5486
5850
  /** One person's deliveries, merged ids included, same as the event feed. */
5487
5851
  profileId: z.string().trim().min(1).max(200).optional(),
5488
5852
  /**
5853
+ * What one journey execution sent. Exact, not approximated from journey
5854
+ * and profile: a profile that enrolls twice has one execution per
5855
+ * enrollment, and the older one must not show the newer one's sends.
5856
+ */
5857
+ executionId: z.string().trim().min(1).max(200).optional(),
5858
+ /**
5489
5859
  * Only deliveries touched at or after this instant, by `updatedAt`: the
5490
5860
  * cursor a tail (`cow dev`) polls with, so a send and its later feedback
5491
5861
  * transition both arrive. Inclusive for the same reason as the execution
@@ -5523,6 +5893,7 @@ const dnsRecordSchema = z.object({
5523
5893
  /** One sending domain on the wire: dates become ISO 8601 strings. */
5524
5894
  const senderDomainSchema = selectSenderDomainSchema.extend({
5525
5895
  dnsRecords: z.array(dnsRecordSchema),
5896
+ challengeVerifiedAt: z.iso.datetime().nullable(),
5526
5897
  lastCheckedAt: z.iso.datetime().nullable(),
5527
5898
  createdAt: z.iso.datetime(),
5528
5899
  updatedAt: z.iso.datetime()
@@ -5563,10 +5934,10 @@ const domainDnsSetupSchema = z.object({
5563
5934
  * braces, no dependency. A pattern field takes one pattern or a non-empty
5564
5935
  * list of them, and a list is a disjunction.
5565
5936
  *
5566
- * It lives here rather than beside `appIdList` in ./apps because that module
5567
- * imports the Drizzle schema, which neither the wasmtime guest nor the
5568
- * Temporal workflow isolate can carry. This file imports one constant, so
5569
- * both can.
5937
+ * It lives here rather than beside `appIdFromName` in ./apps because that
5938
+ * module imports the Drizzle schema, which neither the wasmtime guest nor
5939
+ * the Temporal workflow isolate can carry. This file imports one constant,
5940
+ * so both can.
5570
5941
  *
5571
5942
  * The system rule: a name under SYSTEM_EVENT_PREFIX matches only a pattern
5572
5943
  * whose *literal prefix* (the text before its first `*`) is itself under
@@ -5645,29 +6016,6 @@ function patternPlaceholder(pattern) {
5645
6016
  * a guest returns with these schemas before anything acts on it; the guest
5646
6017
  * SDK and the Node simulator produce and consume the same shapes.
5647
6018
  */
5648
- /** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
5649
- const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
5650
- const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
5651
- const DURATION_UNIT_MS = {
5652
- ms: 1,
5653
- s: 1e3,
5654
- m: 6e4,
5655
- h: 36e5,
5656
- d: 864e5,
5657
- w: 6048e5
5658
- };
5659
- /**
5660
- * A duration in milliseconds. The simulator's virtual clock and the runner's
5661
- * timers both need it, and neither may pull in Temporal's `msToNumber` (one
5662
- * runs in the CLI, the other inside workflow code).
5663
- */
5664
- function parseDuration(duration) {
5665
- if (typeof duration === "number") return duration;
5666
- const match = DURATION_PATTERN.exec(duration.trim());
5667
- if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
5668
- const unit = DURATION_UNIT_MS[match[2]];
5669
- return Math.round(Number(match[1]) * unit);
5670
- }
5671
6019
  /** The event a journey runs for, or waits on: name, properties, and when. */
5672
6020
  const guestEventSchema = z.object({
5673
6021
  name: z.string().min(1),
@@ -5707,8 +6055,9 @@ const commandSchema = z.discriminatedUnion("name", [
5707
6055
  /**
5708
6056
  * The address this mail leaves as. The guest SDK fills in the
5709
6057
  * 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.
6058
+ * one resolution path and never reads the manifest to find it. A
6059
+ * journey must name one, so absent on both is a release pushed before
6060
+ * that was true, and the `domain` gate refuses it.
5712
6061
  */
5713
6062
  from: fromSchema.optional(),
5714
6063
  /** Where a reply to this one mail goes, instead of the from-address. */
@@ -5852,11 +6201,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
5852
6201
  const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
5853
6202
  trigger: true,
5854
6203
  purpose: true,
6204
+ enrollment: true,
6205
+ description: true,
5855
6206
  from: true,
5856
6207
  tags: true
5857
6208
  }).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
5858
6209
  sendClass: true,
5859
6210
  verifyLink: true,
6211
+ unsubscribeLink: true,
5860
6212
  propsSchema: true,
5861
6213
  tags: true
5862
6214
  }).extend({ kind: z.literal("template") })]);
@@ -5923,6 +6275,13 @@ const listExecutionsQuerySchema = paginationQuerySchema.extend({
5923
6275
  status: executionStatusSchema.optional(),
5924
6276
  profileId: z.string().trim().min(1).max(200).optional(),
5925
6277
  /**
6278
+ * One app's runs. An execution carries no app of its own: it is a run of
6279
+ * a journey, and the journey is what belongs to an app (ADR 0022), so the
6280
+ * filter resolves through `journeys.app_id`. Omitted means every app, and
6281
+ * an id the org does not have matches nobody.
6282
+ */
6283
+ appId: appIdSchema.optional(),
6284
+ /**
5926
6285
  * Only executions touched at or after this instant, by `updatedAt`: what a
5927
6286
  * tail (`cow dev`) asks for so it sees both new runs and status changes in
5928
6287
  * one filter. Inclusive on purpose, because two rows can share a
@@ -5949,74 +6308,24 @@ const cancelExecutionsBodySchema = z.object({ data: z.object({
5949
6308
  const cancellingSchema = z.object({ cancelling: z.literal(true) });
5950
6309
 
5951
6310
  //#endregion
5952
- //#region ../../packages/shared/src/journeys-v2/config.ts
6311
+ //#region ../../packages/shared/src/pushes.ts
5953
6312
  /**
5954
- * A project's name: what `cow init` asked for, and what the `projects` row
5955
- * stores. Lives here rather than beside the release DTOs because `cow.json`
5956
- * is the file a developer types it into; `createProjectBodySchema` reuses it.
6313
+ * The push lifecycle's wire contracts (ADR 0011): the artifacts `cow push`
6314
+ * uploads, the push itself, and the versions its compile produces. The app
6315
+ * `cow.json` names is the push unit (ADR 0022).
6316
+ *
6317
+ * Row-derived through drizzle-zod where a row is what the wire carries, so
6318
+ * the DTO cannot drift from the column set; the jsonb manifest gets its
6319
+ * explicit shared schema, because the column type is opaque to drizzle-zod.
5957
6320
  */
5958
- const projectNameSchema = z.string().trim().min(1).max(200);
6321
+ const artifactDigestParams = z.object({ digest: digestSchema });
5959
6322
  /**
5960
- * `cow.json` (spec: Project layout): the org, and which of its projects this
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.
5963
- */
5964
- const cowConfigSchema = z.strictObject({
5965
- $schema: z.url().optional(),
5966
- orgId: z.string().startsWith(CLERK_ORG_SUBJECT_PREFIX, "orgId must be a Clerk org id"),
5967
- /**
5968
- * Which project of the org this directory pushes to. One org can hold
5969
- * several, one per repo, each with its own release sequence and its own
5970
- * slice of the deployed journeys, so every project says which it is.
5971
- */
5972
- project: projectNameSchema,
5973
- /**
5974
- * The consent purposes this project declares. They are org-wide, so two
5975
- * projects declaring one key must agree on its label and default or the
5976
- * deploy is refused; a deploy adds and updates them and never deletes
5977
- * one, because profiles hold answers against them.
5978
- */
5979
- purposes: purposesSchema.optional(),
5980
- /** Overrides the API the CLI talks to; the hosted product needs none. */
5981
- apiUrl: z.url().optional(),
5982
- /**
5983
- * Overrides the dashboard `cow login` opens. Paired with `apiUrl`: a
5984
- * login against one Cowliss's dashboard yields a token the other's API
5985
- * rejects, so a config that names an API names its dashboard too.
5986
- */
5987
- webUrl: z.url().optional()
5988
- }).meta({
5989
- title: "cow.json",
5990
- description: "A cow project: the organization and the project it pushes to."
5991
- });
5992
-
5993
- //#endregion
5994
- //#region ../../packages/shared/src/pushes.ts
5995
- /**
5996
- * The push lifecycle's wire contracts (ADR 0011): the project a developer
5997
- * runs `cow init` in, the artifacts `cow push` uploads, the push itself, and
5998
- * the versions its compile produces.
5999
- *
6000
- * Row-derived through drizzle-zod where a row is what the wire carries, so
6001
- * the DTO cannot drift from the column set; the jsonb manifest gets its
6002
- * explicit shared schema, because the column type is opaque to drizzle-zod.
6003
- */
6004
- const projectSchema = selectProjectSchema.extend({
6005
- createdAt: z.iso.datetime(),
6006
- updatedAt: z.iso.datetime()
6007
- });
6008
- /** `cow init` names the project after the folder it created. */
6009
- const createProjectBodySchema = z.object({ data: z.object({ name: projectNameSchema }) });
6010
- /** Which project to read: the one the caller's `cow.json` names. */
6011
- const getProjectQuerySchema = z.object({ name: projectNameSchema });
6012
- const artifactDigestParams = z.object({ digest: digestSchema });
6013
- /**
6014
- * Which kind of artifact the uploaded bytes are. Named by the client rather
6015
- * than sniffed, because only the build knows whether a blob is a journey
6016
- * bundle or the source tarball, and the value is metadata for the prune
6017
- * sweep and the sandbox's cache rather than a trust boundary: what a module
6018
- * is, is decided by the server-side compile step. `module` is absent on
6019
- * purpose, since only that compile step ever writes one.
6323
+ * Which kind of artifact the uploaded bytes are. Named by the client rather
6324
+ * than sniffed, because only the build knows whether a blob is a journey
6325
+ * bundle or the source tarball, and the value is metadata for the prune
6326
+ * sweep and the sandbox's cache rather than a trust boundary: what a module
6327
+ * is, is decided by the server-side compile step. `module` is absent on
6328
+ * purpose, since only that compile step ever writes one.
6020
6329
  */
6021
6330
  const putArtifactQuerySchema = z.object({ kind: z.enum(["bundle", "source"]) });
6022
6331
  /**
@@ -6040,7 +6349,7 @@ const versionSummarySchema = selectVersionSchema.pick({
6040
6349
  status: true,
6041
6350
  moduleDigest: true,
6042
6351
  pushId: true,
6043
- projectId: true,
6352
+ appId: true,
6044
6353
  error: true
6045
6354
  }).extend({ pushedAt: z.iso.datetime() });
6046
6355
  /**
@@ -6069,12 +6378,21 @@ const createPushBodySchema = z.object({ data: z.object({
6069
6378
  * the developer fixes by updating `@cowliss/cli`, and nothing is stored.
6070
6379
  */
6071
6380
  manifest: manifestSchema.safeExtend({ protocol: z.literal(3) }),
6072
- /** The `cow.json` project this push belongs to. */
6073
- project: projectNameSchema
6381
+ /** The `cow.json` app this push belongs to (ADR 0022). */
6382
+ appId: appIdSchema,
6383
+ /**
6384
+ * The `cow.json` organization. The credential already decides which org
6385
+ * is written to, so this is not how the server finds the org: it is the
6386
+ * developer saying which one they meant, and a disagreement is refused.
6387
+ * Without it, a push aimed at production but run with local credentials
6388
+ * lands silently whenever both orgs happen to hold an app of the same
6389
+ * slug, which is exactly the shape `apps/platform-workspace` has.
6390
+ */
6391
+ orgId: orgIdSchema
6074
6392
  }) });
6075
6393
  const listPushesQuerySchema = paginationQuerySchema.extend({
6076
- /** One project's own pushes; absent lists the org's. */
6077
- project: projectNameSchema.optional() });
6394
+ /** One app's own pushes; absent lists the org's. */
6395
+ appId: appIdSchema.optional() });
6078
6396
  /**
6079
6397
  * One key's version history, newest first. Both the key and the kind are
6080
6398
  * required: journeys and templates share a key space, so `welcome` alone
@@ -6085,13 +6403,13 @@ const listVersionsQuerySchema = paginationQuerySchema.extend({
6085
6403
  kind: z.enum(["journey", "template"])
6086
6404
  });
6087
6405
  /**
6088
- * One key of a project as `cow status` and the MCP `status` tool report it:
6089
- * the code the server holds for it, whether it is on, and what is still
6406
+ * One key of an app as `cow status` and the MCP `status` tool report it:
6407
+ * the code the server holds for it, what state it is in, and what is still
6090
6408
  * running. Journeys and templates share the shape, because a developer asks
6091
6409
  * the same question of both; the two fields only a journey has are null on a
6092
6410
  * template.
6093
6411
  */
6094
- const projectStatusKeySchema = z.object({
6412
+ const appStatusKeySchema = z.object({
6095
6413
  kind: z.enum(["journey", "template"]),
6096
6414
  key: z.string(),
6097
6415
  /**
@@ -6100,34 +6418,107 @@ const projectStatusKeySchema = z.object({
6100
6418
  * server holds. Null only for a journey whose versions are all pruned.
6101
6419
  */
6102
6420
  latestVersion: versionSchema.nullable(),
6103
- /** The journey's one flag; null on a template, which has none. */
6104
- enabled: z.boolean().nullable(),
6421
+ /**
6422
+ * The journey's one state; null on a template, which has none. Built from
6423
+ * the column here rather than imported from `journeys.ts`, which reads
6424
+ * this module for its version summary.
6425
+ */
6426
+ status: z.enum(journeyStatusEnum.enumValues).nullable(),
6105
6427
  /** Executions still running or waiting. */
6106
6428
  liveExecutions: z.number().int()
6107
6429
  });
6108
6430
  /**
6109
- * Everything the server knows about one project's keys, in one read: the
6110
- * whole project rather than a page of it, because a manifest holds at most
6431
+ * Everything the server knows about one app's keys, in one read: the whole
6432
+ * app rather than a page of it, because a manifest holds at most
6111
6433
  * `PUSH_LIMITS.journeys + PUSH_LIMITS.templates` keys and the answer to "what
6112
- * is my project doing" is useless split across cursors.
6434
+ * is my app doing" is useless split across cursors.
6113
6435
  *
6114
6436
  * `warnings` is what a deploy used to answer with (ADR 0011 retired the
6115
6437
  * deploy): a name a journey references that the organization does not
6116
6438
  * define yet. They never block, because a segment or a webhook may be created
6117
6439
  * right after a push.
6118
6440
  */
6119
- const projectStatusSchema = z.object({
6120
- project: projectNameSchema,
6121
- keys: z.array(projectStatusKeySchema),
6441
+ const appStatusSchema = z.object({
6442
+ appId: appIdSchema,
6443
+ keys: z.array(appStatusKeySchema),
6122
6444
  warnings: z.array(z.string())
6123
6445
  });
6124
6446
 
6447
+ //#endregion
6448
+ //#region ../../packages/shared/src/segments.ts
6449
+ const segmentSchema = selectSegmentSchema.extend({
6450
+ definition: segmentDefinitionSchema,
6451
+ createdAt: z.iso.datetime(),
6452
+ updatedAt: z.iso.datetime()
6453
+ });
6454
+ /** The detail view adds the materialized member count. */
6455
+ const segmentDetailSchema = segmentSchema.extend({ memberCount: z.number().int().min(0) });
6456
+ const segmentMemberSchema = z.object({
6457
+ segmentId: z.string(),
6458
+ orgId: z.string(),
6459
+ profileId: z.string(),
6460
+ enteredAt: z.iso.datetime()
6461
+ });
6462
+ /**
6463
+ * The builder's design-time sanity check: run one not-yet-persisted
6464
+ * definition over one app's existing profiles. The app is the same one the
6465
+ * segment would be created in (ADR 0022), so the count the builder shows is
6466
+ * the count saving it would produce. Members is a small sample of matching
6467
+ * profile ids, not the full member list: there is no segment row and no
6468
+ * membership entry, so there is no segmentId or enteredAt to report.
6469
+ *
6470
+ * The scan is bounded, so `memberCount` is the exact count for that app only
6471
+ * when `truncated` is false. When it is true the scan stopped at the cap,
6472
+ * and `memberCount` is the count over the first `scanned` profiles only.
6473
+ */
6474
+ const previewSegmentBodySchema = z.object({ data: z.object({
6475
+ appId: appIdSchema,
6476
+ definition: segmentDefinitionSchema
6477
+ }) });
6478
+ const segmentPreviewSchema = z.object({
6479
+ memberCount: z.number().int().min(0),
6480
+ members: z.array(z.string()),
6481
+ /** Profiles the preview actually evaluated. */
6482
+ scanned: z.number().int().min(0),
6483
+ /** True when the scan hit its cap, so memberCount is a floor. */
6484
+ truncated: z.boolean()
6485
+ });
6486
+ const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
6487
+ const descriptionSchema = z.string().trim().max(500, `description must be at most ${500} characters`);
6488
+ /**
6489
+ * A segment belongs to exactly one app (ADR 0022), named here and never
6490
+ * again: it evaluates that app's profiles and that app's events, and an
6491
+ * update cannot move it, because the members it already has belong to the
6492
+ * app it was built for.
6493
+ */
6494
+ const createSegmentBodySchema = z.object({ data: z.object({
6495
+ name: nameSchema,
6496
+ appId: appIdSchema,
6497
+ description: descriptionSchema.nullish(),
6498
+ definition: segmentDefinitionSchema
6499
+ }) });
6500
+ const updateSegmentBodySchema = z.object({ data: z.object({
6501
+ name: nameSchema.optional(),
6502
+ description: descriptionSchema.nullish(),
6503
+ definition: segmentDefinitionSchema.optional()
6504
+ }).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" }) });
6505
+ /**
6506
+ * `q` is a substring search over the segment's name; `appId` narrows to one
6507
+ * app's segments, and omitted means every app. An id the org does not have
6508
+ * matches nobody rather than 404ing, like every other list filter.
6509
+ */
6510
+ const listSegmentsQuerySchema = paginationQuerySchema.extend({
6511
+ q: searchQuerySchema,
6512
+ appId: appIdSchema.optional()
6513
+ });
6514
+ const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
6515
+
6125
6516
  //#endregion
6126
6517
  //#region ../../packages/shared/src/journeys.ts
6127
6518
  /**
6128
6519
  * Journeys as the API reports them: the rows a push derives from its
6129
6520
  * manifest, one per (org, key). Nothing here is authored through the API, so
6130
- * the only write is the enable flag.
6521
+ * the only write is the status.
6131
6522
  *
6132
6523
  * Derived from the Drizzle table via drizzle-zod so the wire DTO and the row
6133
6524
  * share one source of truth; the jsonb columns get explicit wire schemas
@@ -6140,11 +6531,19 @@ const journeyTriggerSchema = triggerSchema;
6140
6531
  * control flow is whatever its code does at run time.
6141
6532
  */
6142
6533
  const journeySpineEntrySchema = spineEntrySchema;
6534
+ /**
6535
+ * The one operational state a manager sets (ADR 0018). `on` lets recipients
6536
+ * enroll and executions run; `off` enrolls nobody and lets what is in flight
6537
+ * finish; `paused` enrolls nobody and holds what is in flight before its next
6538
+ * step. Turning a paused journey on resumes it.
6539
+ */
6540
+ const JOURNEY_STATUSES = journeyStatusEnum.enumValues;
6541
+ const journeyStatusSchema = z.enum(JOURNEY_STATUSES);
6143
6542
  const journeySchema = selectJourneySchema.extend({
6144
6543
  trigger: journeyTriggerSchema,
6145
6544
  purpose: consentPurposeKeySchema,
6146
6545
  spine: z.array(journeySpineEntrySchema),
6147
- enabled: z.boolean(),
6546
+ status: journeyStatusSchema,
6148
6547
  /**
6149
6548
  * The newest version of this key, whatever it compiled to, so a list can
6150
6549
  * say "pushed 2 hours ago" and "compiling" without a second read. What the
@@ -6157,14 +6556,29 @@ const journeySchema = selectJourneySchema.extend({
6157
6556
  updatedAt: z.iso.datetime()
6158
6557
  });
6159
6558
  /**
6559
+ * One journey, with the segment it owns when its trigger inlines a
6560
+ * definition (ADR 0016): the same row and member count the segments API
6561
+ * reports, so the journey's page can show who is in its segment without a
6562
+ * second concept. Null for an event trigger.
6563
+ *
6564
+ * On the read of one journey only. A list would be one member count per row,
6565
+ * and a list already shows the trigger.
6566
+ */
6567
+ const journeyDetailSchema = journeySchema.extend({ segment: segmentDetailSchema.nullable() });
6568
+ /**
6160
6569
  * Journey list query. `q` is a substring search over the key and the tags,
6161
6570
  * the two things an author names a journey by; `tag` is "has this tag",
6162
6571
  * exact and case-sensitive, so a badge in the table is the way into it.
6163
6572
  * Both are server-side, like every other list filter.
6573
+ *
6574
+ * `appId` narrows to one app's journeys (ADR 0022); omitted means every
6575
+ * app. An id the org does not have matches nobody, the same empty answer
6576
+ * `journeys.setStatus` gives it.
6164
6577
  */
6165
6578
  const listJourneysQuerySchema = paginationQuerySchema.extend({
6166
- /** On or off; the journey's one flag. */
6167
- enabled: z.enum(["true", "false"]).optional(),
6579
+ /** On, off or paused; the journey's one state. */
6580
+ status: journeyStatusSchema.optional(),
6581
+ appId: appIdSchema.optional(),
6168
6582
  q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0),
6169
6583
  tag: z.string().trim().max(50, "tag must be at most 50 characters").optional()
6170
6584
  });
@@ -6192,19 +6606,25 @@ const journeyStatsSchema = z.object({
6192
6606
  deliveries: z.object({ byStatus: z.record(deliveryStatusSchema, z.number().int()) })
6193
6607
  });
6194
6608
  /**
6195
- * Which journeys the flag acts on: the keys the caller named, or every
6196
- * journey of one project. Exactly one of the two, because a call carrying
6197
- * both would have to decide which it meant.
6609
+ * Which journeys the status acts on, and which state to put them in: the
6610
+ * keys the caller named, or every journey of one app. Exactly one of the
6611
+ * two selectors, because a call carrying both would have to decide which it
6612
+ * meant.
6198
6613
  *
6199
6614
  * A list rather than one key per call so every surface can do what `cow
6200
- * enable` does: name several keys, or `--all` (which is `project`, resolved
6201
- * server-side, so an agent turning a project on is one call too).
6615
+ * enable` does: name several keys, or `--all` (which is `appId`, resolved
6616
+ * server-side, so an agent turning an app on is one call too).
6617
+ *
6618
+ * One body for three states rather than a route per verb (ADR 0018): the
6619
+ * transitions differ in what they do to what is in flight, not in who may
6620
+ * ask or what they name.
6202
6621
  */
6203
- const setJourneysEnabledBodySchema = z.object({ data: z.object({
6622
+ const setJourneysStatusBodySchema = z.object({ data: z.object({
6204
6623
  keys: z.array(journeyKeySchema).min(1).max(PUSH_LIMITS.journeys).optional(),
6205
- /** Every journey of this project, by the name `cow.json` carries. */
6206
- project: projectNameSchema.optional()
6207
- }).refine((data) => data.keys === void 0 !== (data.project === void 0), { message: "name keys or a project, not both" }) });
6624
+ /** Every journey of this app, by the id `cow.json` carries. */
6625
+ appId: appIdSchema.optional(),
6626
+ status: journeyStatusSchema
6627
+ }).refine((data) => data.keys === void 0 !== (data.appId === void 0), { message: "name keys or an app, not both" }) });
6208
6628
  /**
6209
6629
  * Dry run: one real profile, the journey's latest ready version, and every
6210
6630
  * send gated into a `would_*` row instead of a message. The body is the v1
@@ -6212,6 +6632,106 @@ const setJourneysEnabledBodySchema = z.object({ data: z.object({
6212
6632
  * now and a v1 instance was a Temporal read.
6213
6633
  */
6214
6634
  const dryRunJourneyBodySchema = z.object({ data: z.object({ profileId: z.string().min(1) }) });
6635
+ /**
6636
+ * Enrollment (ADR 0015): what turning a journey on would do, or is doing,
6637
+ * to the people already in its audience.
6638
+ *
6639
+ * The same three numbers answer both questions, so they are one schema: a
6640
+ * preview walks the whole segment counting, and the running job counts the
6641
+ * same way as it goes. `scanned` is the segment's current membership,
6642
+ * `enrolled` the ones that entered (or in a preview would), and the skips
6643
+ * say why the rest did not.
6644
+ */
6645
+ const enrollmentCountsSchema = z.object({
6646
+ scanned: z.number().int(),
6647
+ enrolled: z.number().int(),
6648
+ skipped: z.object({
6649
+ /** They have been through this journey, and it enrolls once. */
6650
+ completed: z.number().int(),
6651
+ /** They finished recently and the journey's cooldown has not elapsed. */
6652
+ cooldown: z.number().int(),
6653
+ /** They are in the journey right now. */
6654
+ running: z.number().int()
6655
+ })
6656
+ });
6657
+ /**
6658
+ * How each skip is said to a person, in the order the surfaces list them.
6659
+ * The confirm screen, the progress card and `cow enable` all report the same
6660
+ * three numbers, and the words for them live here so the three cannot drift:
6661
+ * `tile` names the count on its own, `reason` completes "N ...".
6662
+ */
6663
+ const ENROLLMENT_SKIP_WORDS = {
6664
+ completed: {
6665
+ tile: "Already been through",
6666
+ reason: "have already been through it"
6667
+ },
6668
+ cooldown: {
6669
+ tile: "Too recently",
6670
+ reason: "went through it too recently"
6671
+ },
6672
+ running: {
6673
+ tile: "In it already",
6674
+ reason: "are in it right now"
6675
+ }
6676
+ };
6677
+ const enrollmentStatusSchema = enrollmentCountsSchema.extend({
6678
+ /**
6679
+ * `running` while the job works, `done` when it finished the segment,
6680
+ * `cancelled` when someone stopped it, `stopped` when the journey itself
6681
+ * stopped being on under it, `failed` when it could not finish. On any of
6682
+ * the three that end early the counts are what it reached, and the people
6683
+ * it already enrolled stay in the journey.
6684
+ */
6685
+ state: z.enum([
6686
+ "running",
6687
+ "done",
6688
+ "cancelled",
6689
+ "stopped",
6690
+ "failed"
6691
+ ]) });
6692
+ /**
6693
+ * What a journey turned on says about the audience it now has waiting.
6694
+ *
6695
+ * It rides on the status response rather than being fetched separately so
6696
+ * that every surface that moves the control sees it: `cow enable` prints it,
6697
+ * and the MCP tool, which is planned from the contract and has no code of
6698
+ * its own, returns it. An agent that cannot start the enrollment can still
6699
+ * tell the person who can how many people it would reach and where to go.
6700
+ *
6701
+ * Null on a journey whose trigger is an event: there is no standing audience
6702
+ * to reach. `counts` is null when the numbers could not be worked out just
6703
+ * then; the journey is on either way, because a count nobody could read is
6704
+ * no reason to leave the switch off.
6705
+ */
6706
+ const enrollmentOfferSchema = z.object({
6707
+ counts: enrollmentCountsSchema.nullable(),
6708
+ /** The journey's page, where the offer can be accepted. */
6709
+ url: z.string()
6710
+ });
6711
+ /**
6712
+ * A journey as the status route answers, carrying that offer. Null on every
6713
+ * status but `on`: a journey turned off or held reaches nobody, so there is
6714
+ * no audience to offer.
6715
+ */
6716
+ const journeyWithOfferSchema = journeySchema.extend({ enrollment: enrollmentOfferSchema.nullable() });
6717
+ /**
6718
+ * What holding a journey until a given day would end (ADR 0018). A wait
6719
+ * that comes due while the journey is held ends that person's run, so
6720
+ * before putting one on hold it is worth knowing how many runs that is.
6721
+ *
6722
+ * The day is the caller's, not a fixed horizon: "until tomorrow" and "until
6723
+ * next month" are different decisions, and the numbers behind them are what
6724
+ * tell them apart.
6725
+ */
6726
+ const waitsDueQuerySchema = z.object({
6727
+ /** Count the waits that come due before this instant. */
6728
+ before: z.iso.datetime() });
6729
+ const waitsDueSchema = z.object({
6730
+ /** Executions in flight whose next step comes due before then. */
6731
+ due: z.number().int(),
6732
+ /** Executions in flight altogether, due or not. */
6733
+ inFlight: z.number().int()
6734
+ });
6215
6735
 
6216
6736
  //#endregion
6217
6737
  //#region ../../packages/shared/src/journeys-v2/sandbox.ts
@@ -6330,7 +6850,7 @@ const RESEND_MAX_TAGS = 50;
6330
6850
  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
6851
  const recipientArraySchema = z.array(recipientSchema).min(1).max(50);
6332
6852
  /** Resend takes one address or a list wherever it takes recipients. */
6333
- const recipientsSchema = z.union([recipientSchema, recipientArraySchema]);
6853
+ const recipientsSchema$1 = z.union([recipientSchema, recipientArraySchema]);
6334
6854
  /**
6335
6855
  * Resend's wording for an absent required field. Three fields can be
6336
6856
  * missing: `to`, `subject`, and a body. Which answer each one gets is read
@@ -6361,11 +6881,11 @@ const templateSlotSchema = z.strictObject({
6361
6881
  /** Send body: Resend's fields, minus the ones Cowliss does not serve. */
6362
6882
  const resendSendBodySchema = z.strictObject({
6363
6883
  /**
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.
6884
+ * Required, exactly as Resend requires it. There is no address to fall
6885
+ * back to: a send leaves from a domain the organization verified, and
6886
+ * nothing else. Missing is its own issue for the same reason `to`'s is.
6367
6887
  */
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(),
6888
+ from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }).optional().nonoptional({ error: MISSING("from") }),
6369
6889
  /**
6370
6890
  * Required, and the absence is its own issue rather than the union's:
6371
6891
  * `optional().nonoptional()` lets the value through to the union when
@@ -6382,9 +6902,9 @@ const resendSendBodySchema = z.strictObject({
6382
6902
  subject: z.string().trim().min(1).max(RESEND_MAX_SUBJECT).optional(),
6383
6903
  html: z.string().optional(),
6384
6904
  text: z.string().optional(),
6385
- cc: recipientsSchema.optional(),
6386
- bcc: recipientsSchema.optional(),
6387
- reply_to: recipientsSchema.optional(),
6905
+ cc: recipientsSchema$1.optional(),
6906
+ bcc: recipientsSchema$1.optional(),
6907
+ reply_to: recipientsSchema$1.optional(),
6388
6908
  headers: z.record(z.string(), z.string()).optional(),
6389
6909
  /** Accepted and dropped: Cowliss tags a delivery by its own fields. */
6390
6910
  tags: z.array(z.object({
@@ -6573,133 +7093,76 @@ const listReviewQuerySchema = paginationQuerySchema.extend({
6573
7093
  });
6574
7094
 
6575
7095
  //#endregion
6576
- //#region ../../packages/shared/src/segments.ts
7096
+ //#region ../../packages/shared/src/send-email.ts
6577
7097
  /**
6578
- * Segments API contracts. The definition is the interesting part: a flat
6579
- * list of predicates over traits and event history, combined with `all` or
6580
- * `any`. Deliberately flat — nested predicate groups are YAGNI for the
6581
- * prototype, and a flat list keeps the pure evaluator a fold.
6582
- *
6583
- * `appId` scopes the EVENT side of a definition only. Traits are
6584
- * per-profile and profiles merge across apps, so there is nothing
6585
- * app-shaped to filter on the trait side.
7098
+ * `POST /v1/emails`: the first-party transactional send, the one a
7099
+ * `@cowliss/sdk` client calls. The Resend-compatible facade (ADR 0014) is
7100
+ * the same send in somebody else's wire format; this is ours, so it is
7101
+ * camelCase, enveloped, and answers with our error codes.
6586
7102
  *
6587
- * A definition and the membership it produces are both the
6588
- * organization's.
6589
- *
6590
- * The object is strict: a definition holding the retired `sourceId` key
6591
- * (which meant the app) fails loudly instead of parsing as an unfiltered
6592
- * definition that evaluates over every app. There is deliberately no pipe
6593
- * filter here — filtering by source is a later feature, and accepting one
6594
- * now would make a stale `sourceId` parse as a filter on a pipe that does
6595
- * not exist and silently match nothing.
7103
+ * The limits are deliberately the same numbers as the facade's: two shapes
7104
+ * over one send path must not accept different messages.
6596
7105
  */
6597
- const SEGMENT_TRAIT_OPS = [
6598
- "eq",
6599
- "neq",
6600
- "gt",
6601
- "gte",
6602
- "lt",
6603
- "lte",
6604
- "exists",
6605
- "notExists",
6606
- "contains"
6607
- ];
6608
- /** Operators that read no comparison value: presence of the key is the test. */
6609
- const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
6610
- const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
6611
- const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
6612
- kind: z.literal("trait"),
6613
- name: predicateNameSchema,
6614
- op: z.enum(SEGMENT_TRAIT_OPS),
7106
+ const addressSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "An address is \"ada@acme.com\" or a name and address, \"Ada <ada@acme.com>\"." });
7107
+ /** One address or a list, wherever a message names recipients. */
7108
+ const recipientsSchema = z.union([addressSchema, z.array(addressSchema).min(1).max(50)]);
7109
+ /** A template this organization pushed, plus the props it declares. */
7110
+ const templateSchema$1 = z.strictObject({
7111
+ key: journeyKeySchema,
7112
+ props: z.record(z.string(), z.unknown()).optional()
7113
+ });
7114
+ const sendEmailDataSchema = z.strictObject({
6615
7115
  /**
6616
- * Compared against the stored trait, which is `unknown` because
6617
- * identify accepts arbitrary JSON. The evaluator coerces both sides
6618
- * before comparing, so authors here (CLI, MCP, dashboard) get the
6619
- * form-field-friendly reading rather than strict JSON equality:
6620
- * numeric-looking strings are compared as numbers for eq/neq and for
6621
- * ordering ("150" matches 150, and orders like it), and "true"/"false"
6622
- * are compared as booleans for eq/neq, trimmed and case-insensitively
6623
- * (" TRUE " reads as true). Coercion needs both sides to agree on a
6624
- * type: "0" never equals false. The numeric net is as wide as
6625
- * `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
6626
- * matters most for opaque ids: "007" is authored as the number 7.
6627
- *
6628
- * contains reads three ways. Against an array trait it is membership
6629
- * under that same equality, so "true" matches `[true]` and "1" matches
6630
- * `[1, 2]`. Against a string trait it is a plain substring search with
6631
- * no coercion, since substrings only mean something between strings.
6632
- * Against anything else it never matches. Ordering never coerces
6633
- * booleans.
7116
+ * The pipe the message belongs to. A source id and only a source id: the
7117
+ * Resend facade takes an app id in its base URL because the Resend SDK
7118
+ * has nowhere else to put one, and nothing else on the wire does.
6634
7119
  */
6635
- value: z.unknown().optional()
6636
- }), z.object({
6637
- kind: z.literal("event"),
6638
- name: predicateNameSchema,
6639
- op: z.enum(["performed", "notPerformed"]),
6640
- /** How many matching events the predicate counts as "performed". */
6641
- atLeast: z.number().int().min(1).default(1),
6642
- /** Rolling window, relative to evaluation time; absent means all history. */
6643
- withinDays: z.number().int().min(1).optional()
6644
- })]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
6645
- const appIdSchema = z.string().trim().min(1);
6646
- const segmentDefinitionSchema = z.strictObject({
6647
- match: z.enum(["all", "any"]).default("all"),
7120
+ sourceId: sourceIdSchema,
7121
+ to: recipientsSchema,
7122
+ cc: recipientsSchema.optional(),
7123
+ bcc: recipientsSchema.optional(),
7124
+ replyTo: recipientsSchema.optional(),
6648
7125
  /**
6649
- * Optional app filter over the event side; null/absent spans apps.
6650
- * A list matches any of the named apps, which is how one definition
6651
- * names the development and the production id of the same app.
7126
+ * An address on a domain this organization verified. There is no other.
7127
+ * Shape is checked here, the same as the Resend body checks it, so an
7128
+ * address that is not one is a 422 naming the field rather than a 403
7129
+ * telling the caller to verify a domain they never named.
6652
7130
  */
6653
- appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
6654
- predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
6655
- });
6656
- const segmentSchema = selectSegmentSchema.extend({
6657
- definition: segmentDefinitionSchema,
6658
- createdAt: z.iso.datetime(),
6659
- updatedAt: z.iso.datetime()
6660
- });
6661
- /** The detail view adds the materialized member count. */
6662
- const segmentDetailSchema = segmentSchema.extend({ memberCount: z.number().int().min(0) });
6663
- const segmentMemberSchema = z.object({
6664
- segmentId: z.string(),
6665
- orgId: z.string(),
6666
- profileId: z.string(),
6667
- enteredAt: z.iso.datetime()
6668
- });
6669
- /**
6670
- * The builder's design-time sanity check: run one not-yet-persisted
6671
- * definition over the org's existing profiles. Members is a small sample of
6672
- * matching profile ids, not the full member list: there is no segment row
6673
- * and no membership entry, so there is no segmentId or enteredAt to report.
6674
- *
6675
- * The scan is bounded, so `memberCount` is the exact org-wide count only
6676
- * when `truncated` is false. When it is true the scan stopped at the cap,
6677
- * and `memberCount` is the count over the first `scanned` profiles only.
6678
- */
6679
- const previewSegmentBodySchema = z.object({ data: segmentDefinitionSchema });
6680
- const segmentPreviewSchema = z.object({
6681
- memberCount: z.number().int().min(0),
6682
- members: z.array(z.string()),
6683
- /** Profiles the preview actually evaluated. */
6684
- scanned: z.number().int().min(0),
6685
- /** True when the scan hit its cap, so memberCount is a floor. */
6686
- truncated: z.boolean()
7131
+ 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>\")." }),
7132
+ subject: z.string().max(RESEND_MAX_SUBJECT).optional(),
7133
+ html: z.string().optional(),
7134
+ text: z.string().optional(),
7135
+ headers: z.record(z.string(), z.string()).optional(),
7136
+ template: templateSchema$1.optional()
7137
+ }).superRefine((body, ctx) => {
7138
+ const hasBody = body.html !== void 0 || body.text !== void 0;
7139
+ if (body.template) {
7140
+ for (const field of [
7141
+ "subject",
7142
+ "html",
7143
+ "text"
7144
+ ]) if (body[field] !== void 0) ctx.addIssue({
7145
+ code: "custom",
7146
+ path: [field],
7147
+ message: `A message names a template or writes its own body, not both. Drop \`${field}\`.`
7148
+ });
7149
+ return;
7150
+ }
7151
+ if (!hasBody) ctx.addIssue({
7152
+ code: "custom",
7153
+ path: ["html"],
7154
+ message: "A message needs a template, or an html or text body."
7155
+ });
7156
+ if (body.subject === void 0) ctx.addIssue({
7157
+ code: "custom",
7158
+ path: ["subject"],
7159
+ message: "A message that writes its own body needs a subject."
7160
+ });
6687
7161
  });
6688
- const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
6689
- const descriptionSchema = z.string().trim().max(500, "description must be at most 500 characters");
6690
- const createSegmentBodySchema = z.object({ data: z.object({
6691
- name: nameSchema,
6692
- description: descriptionSchema.nullish(),
6693
- definition: segmentDefinitionSchema
6694
- }) });
6695
- const updateSegmentBodySchema = z.object({ data: z.object({
6696
- name: nameSchema.optional(),
6697
- description: descriptionSchema.nullish(),
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" }) });
6700
- /** `q` is a substring search over the segment's name. */
6701
- const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
6702
- const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
7162
+ const sendEmailBodySchema = z.object({ data: sendEmailDataSchema });
7163
+ const sendEmailResultSchema = z.object({
7164
+ /** The delivery this send left behind: the row in Deliveries. */
7165
+ deliveryId: z.string() });
6703
7166
 
6704
7167
  //#endregion
6705
7168
  //#region ../../packages/shared/src/settings.ts
@@ -6722,9 +7185,9 @@ const orgSettingsDtoSchema = z.object({
6722
7185
  /**
6723
7186
  * Clerk's slug and display name for the org, cached by the API and
6724
7187
  * READ-ONLY here: Clerk owns them, and the org profile is edited in
6725
- * Clerk's own UI. Exposed because they are the shared fallback sender the
6726
- * domains page shows (`<slug>@<shared domain>` as `<name>`). Null until a
6727
- * member has hit the dashboard API once.
7188
+ * Clerk's own UI. Exposed because a template test-send composes its
7189
+ * from-address out of them (`orgFromAddress`). Null until a member has hit
7190
+ * the dashboard API once.
6728
7191
  */
6729
7192
  slug: z.string().nullable(),
6730
7193
  name: z.string().nullable()
@@ -6856,13 +7319,19 @@ const listSuppressionsQuerySchema = paginationQuerySchema.extend({ q: searchQuer
6856
7319
  */
6857
7320
  const templateSchema = z.object({
6858
7321
  key: journeyKeySchema,
6859
- /** The project whose push owns this key (ADR 0009). */
6860
- projectId: z.string(),
7322
+ /** The app whose push owns this key (ADR 0009, ADR 0022). */
7323
+ appId: z.string(),
6861
7324
  /** The author's labels; the dashboard's only grouping. */
6862
7325
  tags: tagsSchema,
6863
7326
  sendClass: z.enum(SEND_CLASSES),
6864
7327
  /** True when the template asks for a signed `verifyUrl` prop at send time. */
6865
7328
  verifyLink: z.boolean(),
7329
+ /**
7330
+ * True when the template asks for a signed `unsubscribeUrl` prop at send
7331
+ * time. A marketing template without one (or without rendering the link)
7332
+ * gets a platform footer with the link appended at send time instead.
7333
+ */
7334
+ unsubscribeLink: z.boolean().default(false),
6866
7335
  /** JSON Schema of the template's props, as `cow build` converted them. */
6867
7336
  propsSchema: z.record(z.string(), z.unknown()),
6868
7337
  /** The newest version of this key, whatever it compiled to. */
@@ -6912,6 +7381,20 @@ const userSegmentSchema = z.object({
6912
7381
  });
6913
7382
  const listUserSegmentsQuerySchema = paginationQuerySchema;
6914
7383
  /**
7384
+ * One journey the profile ran to completion
7385
+ * (GET /v1/users/:profileId/runs): which journey it was, when it finished,
7386
+ * and the execution it finished, which is null once that execution is no
7387
+ * longer kept. Paged on (completedAt, id) newest first, so the cursor's
7388
+ * sortAt slot carries the completion time.
7389
+ */
7390
+ const userRunSchema = z.object({
7391
+ id: z.string(),
7392
+ journeyKey: z.string(),
7393
+ completedAt: z.iso.datetime(),
7394
+ executionId: z.string().nullable()
7395
+ });
7396
+ const listUserRunsQuerySchema = paginationQuerySchema;
7397
+ /**
6915
7398
  * Profile list query. `q` is a free-text substring search over the
6916
7399
  * profile's identifier values and the identity traits in
6917
7400
  * PROFILE_SEARCH_TRAITS; absent means no filter. An empty or
@@ -6923,10 +7406,17 @@ const listUserSegmentsQuerySchema = paginationQuerySchema;
6923
7406
  * as text (the jsonb value rendered with ->>), so "who is on the pro plan" is
6924
7407
  * one request. `traitValue` without `trait` is meaningless and is a 422: the
6925
7408
  * value names no key to look in.
7409
+ *
7410
+ * `appId` narrows to one app's profiles (ADR 0022); omitted means every app.
7411
+ * An id the org does not have simply matches nobody, like `segmentId`. `q`
7412
+ * still searches identifier values across the org, so an address two apps
7413
+ * know answers with one profile per app.
6926
7414
  */
6927
7415
  const listUsersQuerySchema = paginationQuerySchema.extend({
6928
7416
  direction: sortDirectionSchema.default("asc"),
6929
7417
  q: searchQuerySchema,
7418
+ /** One app's profiles. An unknown id matches nobody, it is not a 404. */
7419
+ appId: appIdSchema.optional(),
6930
7420
  trait: z.string().trim().min(1).max(200).optional(),
6931
7421
  traitValue: z.string().trim().min(1).max(500).optional(),
6932
7422
  /** Members of one segment. An unknown id matches nobody, it is not a 404. */
@@ -6952,13 +7442,32 @@ const findUserQuerySchema = z.object({ identifier: z.string().trim().min(3, "ide
6952
7442
  const userDetailSchema = profileDtoSchema.extend({ mergedIds: z.array(z.string()) });
6953
7443
  const updateConsentBodySchema = z.object({ data: consentPatchSchema });
6954
7444
  /**
6955
- * DELETE /v1/users/:profileId: what erasure did, so the dashboard action
6956
- * can say "n journey instances terminated".
7445
+ * What erasure did, so the dashboard action can say "n journey instances
7446
+ * terminated". `profileIds` is the people that were erased: one id for
7447
+ * `DELETE /v1/users/:profileId`, one per app for an erasure by identifier
7448
+ * (ADR 0022). They are the survivor ids, the ones anyone could have
7449
+ * addressed; the merged-away ids that went with them are not listed,
7450
+ * because nothing ever showed them as a person.
6957
7451
  */
6958
7452
  const eraseUserResponseSchema = z.object({
6959
- profileId: z.string(),
7453
+ profileIds: z.array(z.string()),
6960
7454
  terminatedInstances: z.number().int().min(0)
6961
7455
  });
7456
+ /**
7457
+ * Erase everyone one identifier names (POST /v1/users/erase), across the
7458
+ * organization's apps. Same erasure as by id, addressed by
7459
+ * `{ kind, value }` because that is what a person asking to be forgotten
7460
+ * gives you; a value nobody in the org carries is a 404.
7461
+ */
7462
+ const eraseUserByIdentifierBodySchema = z.object({ data: identifierRefSchema });
7463
+ /**
7464
+ * The same addressing for the export (POST /v1/users/export). A body rather
7465
+ * than a query string for the same reason erasure uses one: a query string
7466
+ * is part of the URL, and an access log, a proxy log and a browser history
7467
+ * all keep it, so an address must not travel there. The method reads
7468
+ * nothing but says where the identifier goes.
7469
+ */
7470
+ const exportUserByIdentifierBodySchema = z.object({ data: identifierRefSchema });
6962
7471
  /** One address-ledger row on the export; dates are ISO 8601 strings. */
6963
7472
  const emailAddressSchema = selectEmailAddressSchema.extend({
6964
7473
  verifiedAt: z.iso.datetime().nullable(),
@@ -6971,14 +7480,20 @@ const journeyRunDtoSchema = selectJourneyRunSchema.extend({
6971
7480
  createdAt: z.iso.datetime()
6972
7481
  });
6973
7482
  /**
6974
- * GET /v1/users/:profileId/export: the identity's whole data record in one
6975
- * JSON document (GDPR portability). Each section reuses the wire DTO of
6976
- * the store it came from, so the export cannot drift from what the
6977
- * dashboard already shows.
7483
+ * The identity's whole data record in one JSON document (GDPR
7484
+ * portability). Each section reuses the wire DTO of the store it came
7485
+ * from, so the export cannot drift from what the dashboard already shows.
7486
+ *
7487
+ * `users` is who the document is about: one profile for
7488
+ * `GET /v1/users/:profileId/export`, one per app when the export is
7489
+ * addressed by identifier (ADR 0022). The other sections are the union over
7490
+ * all of them and over everything merged into them, because that is the
7491
+ * data the request asked for, and because a person's export is one file
7492
+ * whether they used one product or three.
6978
7493
  */
6979
7494
  const userExportSchema = z.object({
6980
7495
  exportedAt: z.iso.datetime(),
6981
- user: userDetailSchema,
7496
+ users: z.array(userDetailSchema),
6982
7497
  events: z.array(eventDtoSchema),
6983
7498
  journeys: z.array(journeyRunDtoSchema),
6984
7499
  deliveries: z.array(deliverySchema),
@@ -7930,7 +8445,9 @@ async function assertCowConfig(projectDir) {
7930
8445
  } catch {
7931
8446
  throw new Error(name === "cow.json" ? `No ${name} in "${projectDir}". Run \`cow init\` to create a project.` : `No ${name} in "${projectDir}". That file was selected with --config or COW_CONFIG.`);
7932
8447
  }
7933
- const parsed = cowConfigSchema.safeParse(JSON.parse(text));
8448
+ const raw = JSON.parse(text);
8449
+ if (typeof raw === "object" && raw !== null && "project" in raw) throw new Error(`${name} names a "project", which Cowliss no longer has: a repository pushes to one app. Rename that field to "appId" and give it the app's id, which looks like "app_website" and is on the app's page in the dashboard.`);
8450
+ const parsed = cowConfigSchema.safeParse(raw);
7934
8451
  if (!parsed.success) throw new Error(`${name} is invalid: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`);
7935
8452
  return parsed.data;
7936
8453
  }
@@ -7943,14 +8460,14 @@ async function readCowConfig(projectDir) {
7943
8460
  }
7944
8461
  }
7945
8462
  /**
7946
- * Which project of the org this directory belongs to: the `project` in its
8463
+ * Which app of the org this directory pushes to: the `appId` in its
7947
8464
  * `cow.json`, or undefined when there is no `cow.json` here at all. The
7948
- * undefined case is `cow pull` into an empty directory, which has no project
7949
- * to scope by yet and reads the org's releases.
8465
+ * undefined case is `cow pull` into an empty directory, which has no app to
8466
+ * scope by yet and reads the org's pushes.
7950
8467
  */
7951
- async function configuredProject(projectDir) {
8468
+ async function configuredApp(projectDir) {
7952
8469
  try {
7953
- return (await assertCowConfig(projectDir)).project;
8470
+ return (await assertCowConfig(projectDir)).appId;
7954
8471
  } catch {
7955
8472
  return;
7956
8473
  }
@@ -7992,8 +8509,13 @@ async function buildProject(projectDir) {
7992
8509
  const manifestJourneys = [];
7993
8510
  const manifestTemplates = [];
7994
8511
  for (const built of bundles) {
7995
- const { module, runGuest } = await loadNodeBundle(projectDir, built.source.key, built.source.kind);
7996
- const report = await runGuest(module, { kind: "manifest" });
8512
+ let report;
8513
+ try {
8514
+ const { module, runGuest } = await loadNodeBundle(projectDir, built.source.key, built.source.kind);
8515
+ report = await runGuest(module, { kind: "manifest" });
8516
+ } catch (error) {
8517
+ throw new Error(`${built.source.relPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
8518
+ }
7997
8519
  const isJourney = built.source.kind === "journeys";
7998
8520
  if (report?.kind !== (isJourney ? "journey" : "template")) throw new Error(`${built.source.relPath} is not a ${isJourney ? "journey" : "template"}: a journey default-exports defineJourney({ ... }), a template exports a component and a zod \`props\` schema.`);
7999
8521
  if (report.kind === "journey") manifestJourneys.push({
@@ -8001,6 +8523,8 @@ async function buildProject(projectDir) {
8001
8523
  tags: report.tags,
8002
8524
  trigger: report.trigger,
8003
8525
  purpose: report.purpose,
8526
+ enrollment: report.enrollment,
8527
+ description: report.description,
8004
8528
  from: report.from,
8005
8529
  spine: readSpine(await readFile(built.source.file, "utf8")),
8006
8530
  bundle: built.digest
@@ -8010,6 +8534,7 @@ async function buildProject(projectDir) {
8010
8534
  tags: report.tags,
8011
8535
  sendClass: report.sendClass,
8012
8536
  verifyLink: report.verifyLink,
8537
+ unsubscribeLink: report.unsubscribeLink,
8013
8538
  propsSchema: report.propsSchema,
8014
8539
  bundle: built.digest
8015
8540
  });
@@ -8213,7 +8738,7 @@ async function copyExample(name, dir, force) {
8213
8738
  return files;
8214
8739
  }
8215
8740
  function registerAdd(program, io) {
8216
- program.command("add").description("add gallery example code to a cow project").command("example <name>").description("copy an example's journeys, emails, and scenarios into this project (local: no API call)").option("--force", "overwrite files that already exist").action(async (name, opts) => {
8741
+ program.command("add").description("add gallery example code to a cow repo").command("example <name>").description("copy an example's journeys, emails, and scenarios into this repo (local: no API call)").option("--force", "overwrite files that already exist").action(async (name, opts) => {
8217
8742
  const dir = process.cwd();
8218
8743
  await assertCowConfig(dir);
8219
8744
  const files = await copyExample(name, dir, opts.force === true);
@@ -8242,10 +8767,13 @@ async function login(env, options) {
8242
8767
  const claims = decodeSessionToken(token);
8243
8768
  if (claims === null) throw new Error("Token is not a decodable JWT");
8244
8769
  if (claims.userId === null) throw new Error("Token has no subject claim. Is it a Clerk session token?");
8245
- const credentials = { token };
8246
- if (options.apiFlag !== void 0) credentials.apiUrl = options.apiFlag;
8770
+ const apiUrl = options.apiFlag ?? resolveApiUrl(env, null, void 0, (await readCowConfig(process.cwd()))?.apiUrl);
8247
8771
  return {
8248
- credentialsPath: await writeCredentials(env, credentials),
8772
+ credentialsPath: await writeCredentials(env, {
8773
+ token,
8774
+ apiUrl
8775
+ }),
8776
+ apiUrl,
8249
8777
  userId: claims.userId,
8250
8778
  orgId: claims.orgId,
8251
8779
  role: claims.role,
@@ -8332,7 +8860,7 @@ function openBrowser(url) {
8332
8860
  //#region src/commands/auth.ts
8333
8861
  /** login/logout/whoami: session-token management, no contract route. */
8334
8862
  function registerAuth(program, env, io) {
8335
- program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a Clerk session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in the project config (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
8863
+ program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in cow.json (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
8336
8864
  const outcome = await login(env, {
8337
8865
  token: opts.token,
8338
8866
  webUrl: opts.web,
@@ -8362,8 +8890,14 @@ function registerAuth(program, env, io) {
8362
8890
  }
8363
8891
  const claims = decodeSessionToken(credentials?.token ?? "");
8364
8892
  if (!claims) throw new Error("Cached token is not decodable. Run `cow login` again.");
8893
+ const projectApiUrl = (await readCowConfig(process.cwd()))?.apiUrl;
8894
+ const apiUrl = resolveApiUrl(env, credentials, opts.api, projectApiUrl);
8895
+ const mintedFor = credentials?.apiUrl;
8365
8896
  emit({ data: {
8366
8897
  credential: kind,
8898
+ apiUrl,
8899
+ mintedFor: mintedFor ?? null,
8900
+ ...mintedFor !== void 0 && mintedFor !== apiUrl ? { warning: `This token was minted for ${mintedFor}, but commands here talk to ${apiUrl}. Run \`cow login\` again from this directory.` } : {},
8367
8901
  ...claims
8368
8902
  } }, io, opts.json);
8369
8903
  });
@@ -8376,7 +8910,7 @@ function registerAuth(program, env, io) {
8376
8910
  * the current directory. Local only, no API call and no network.
8377
8911
  */
8378
8912
  function registerBuild(program, io) {
8379
- program.command("build").description("typecheck and bundle the project in the current directory, writing .cow/build (local: no API call)").action(async (opts) => {
8913
+ program.command("build").description("typecheck and bundle the repo in the current directory, writing .cow/build (local: no API call)").action(async (opts) => {
8380
8914
  emit({ data: await buildProject(process.cwd()) }, io, opts.json);
8381
8915
  });
8382
8916
  }
@@ -8809,7 +9343,8 @@ function htmlResponse(description) {
8809
9343
  const params$11 = z.object({ id: z.string() });
8810
9344
  /**
8811
9345
  * An app is the attribution unit: what a developer names, what ingestion
8812
- * stamps, and what segments and journey triggers filter on. Its inbound
9346
+ * stamps, and what a profile, a segment and a journey belong to (ADR 0022).
9347
+ * It is also the push unit, which is what `status` reports on. Its inbound
8813
9348
  * pipes live under /v1/apps/{appId}/sources, served by the sources module.
8814
9349
  */
8815
9350
  const apps = defineModule(defineRoute({
@@ -8867,6 +9402,23 @@ const apps = defineModule(defineRoute({
8867
9402
  ...sessionErrors,
8868
9403
  ...errors("not_found", "validation_failed", "malformed_request")
8869
9404
  }
9405
+ }), defineRoute({
9406
+ method: "get",
9407
+ path: "/v1/apps/{id}/status",
9408
+ operationId: "apps.status",
9409
+ tags: ["apps"],
9410
+ summary: "What an app's journeys and templates are doing",
9411
+ security: PIPELINE_AUTH,
9412
+ surfaces: {
9413
+ cli: false,
9414
+ mcpName: "status"
9415
+ },
9416
+ request: { params: params$11 },
9417
+ responses: {
9418
+ 200: envelope(appStatusSchema),
9419
+ ...sessionErrors,
9420
+ ...errors("not_found", "validation_failed")
9421
+ }
8870
9422
  }));
8871
9423
 
8872
9424
  //#endregion
@@ -9158,7 +9710,7 @@ const catalog = defineModule(defineRoute({
9158
9710
  //#region ../../packages/shared/src/contract/consent.ts
9159
9711
  /**
9160
9712
  * The consent purposes an organization's profiles answer: the two fixed ones
9161
- * every org has, plus whatever its projects declare in their `cow.json`.
9713
+ * every org has, plus whatever its apps declare in their `cow.json`.
9162
9714
  *
9163
9715
  * Read-only, and deliberately so. Purposes are code, like journeys and
9164
9716
  * templates: a deploy upserts them and never deletes one, because profiles
@@ -9300,11 +9852,11 @@ const domains = defineModule(defineRoute({
9300
9852
  path: "/v1/domains",
9301
9853
  operationId: "domains.create",
9302
9854
  tags: ["domains"],
9303
- summary: "Claim a sending domain",
9855
+ summary: "Add a sending domain",
9304
9856
  security: SESSION_AUTH,
9305
9857
  request: { body: jsonBody(createSenderDomainBodySchema) },
9306
9858
  responses: {
9307
- 201: envelope(senderDomainSchema, "The claimed domain with the DNS records to publish"),
9859
+ 201: envelope(senderDomainSchema, "The domain with the DNS records to publish, which is what claims it"),
9308
9860
  ...sessionErrors,
9309
9861
  ...errors("conflict", "validation_failed", "malformed_request", "dependency_unavailable")
9310
9862
  }
@@ -9357,7 +9909,7 @@ const domains = defineModule(defineRoute({
9357
9909
  path: "/v1/domains/{id}",
9358
9910
  operationId: "domains.delete",
9359
9911
  tags: ["domains"],
9360
- summary: "Give up a domain claim",
9912
+ summary: "Give up a sending domain",
9361
9913
  security: SESSION_AUTH,
9362
9914
  request: { params: params$9 },
9363
9915
  responses: {
@@ -9369,8 +9921,31 @@ const domains = defineModule(defineRoute({
9369
9921
 
9370
9922
  //#endregion
9371
9923
  //#region ../../packages/shared/src/contract/emails.ts
9372
- /** The public email-verification pages: the signed token is the authorization. */
9924
+ /**
9925
+ * The transactional send a developer's own code makes, plus the public
9926
+ * email-verification pages.
9927
+ *
9928
+ * `emails.send` is the first-party shape of the same send the Resend facade
9929
+ * serves (ADR 0014): one service, two wire formats, and this one answers in
9930
+ * the envelope with our error codes. It authenticates with an org API key
9931
+ * like the rest of the write path, which is why it is hidden from the CLI
9932
+ * and MCP surfaces: those hold a session.
9933
+ */
9373
9934
  const emails = defineModule(defineRoute({
9935
+ method: "post",
9936
+ path: "/v1/emails",
9937
+ operationId: "emails.send",
9938
+ tags: ["emails"],
9939
+ summary: "Send one transactional email",
9940
+ security: API_KEY_AUTH,
9941
+ surfaces: HIDDEN_FROM_TOOLS,
9942
+ request: { body: jsonBody(sendEmailBodySchema) },
9943
+ responses: {
9944
+ 201: envelope(sendEmailResultSchema, "Handed over; the delivery's id"),
9945
+ ...ingestionErrors,
9946
+ ...errors("forbidden", "not_found", "conflict")
9947
+ }
9948
+ }), defineRoute({
9374
9949
  method: "get",
9375
9950
  path: "/v1/public/verify",
9376
9951
  operationId: "emails.verifyPage",
@@ -9644,7 +10219,7 @@ const journeys = defineModule(defineRoute({
9644
10219
  security: SESSION_AUTH,
9645
10220
  request: { params: params$7 },
9646
10221
  responses: {
9647
- 200: envelope(journeySchema),
10222
+ 200: envelope(journeyDetailSchema),
9648
10223
  ...sessionErrors,
9649
10224
  ...errors("not_found")
9650
10225
  }
@@ -9663,31 +10238,33 @@ const journeys = defineModule(defineRoute({
9663
10238
  }
9664
10239
  }), defineRoute({
9665
10240
  method: "post",
9666
- path: "/v1/journeys/enable",
9667
- operationId: "journeys.enable",
10241
+ path: "/v1/journeys/status",
10242
+ operationId: "journeys.setStatus",
9668
10243
  tags: ["journeys"],
9669
- summary: "Turn journeys on",
10244
+ summary: "Put journeys on, off or on hold",
9670
10245
  security: PIPELINE_AUTH,
9671
10246
  surfaces: { cli: false },
9672
- request: { body: jsonBody(setJourneysEnabledBodySchema) },
10247
+ request: { body: jsonBody(setJourneysStatusBodySchema) },
9673
10248
  responses: {
9674
- 200: envelope(z.array(journeySchema), "The journeys as they now stand"),
10249
+ 200: envelope(z.array(journeyWithOfferSchema), "The journeys as they now stand, each with the members turning it on has waiting"),
9675
10250
  ...sessionErrors,
9676
- ...errors("not_found", "project_missing", "validation_failed", "malformed_request")
10251
+ ...errors("not_found", "validation_failed", "malformed_request")
9677
10252
  }
9678
10253
  }), defineRoute({
9679
- method: "post",
9680
- path: "/v1/journeys/disable",
9681
- operationId: "journeys.disable",
10254
+ method: "get",
10255
+ path: "/v1/journeys/{key}/waits",
10256
+ operationId: "journeys.waitsDue",
9682
10257
  tags: ["journeys"],
9683
- summary: "Turn journeys off",
9684
- security: PIPELINE_AUTH,
9685
- surfaces: { cli: false },
9686
- request: { body: jsonBody(setJourneysEnabledBodySchema) },
10258
+ summary: "Count the executions a hold until a given day would end",
10259
+ security: SESSION_AUTH,
10260
+ request: {
10261
+ params: params$7,
10262
+ query: waitsDueQuerySchema
10263
+ },
9687
10264
  responses: {
9688
- 200: envelope(z.array(journeySchema), "The journeys as they now stand"),
10265
+ 200: envelope(waitsDueSchema, "Executions in flight, and how many of them come due before that instant"),
9689
10266
  ...sessionErrors,
9690
- ...errors("not_found", "project_missing", "validation_failed", "malformed_request")
10267
+ ...errors("not_found", "validation_failed")
9691
10268
  }
9692
10269
  }), defineRoute({
9693
10270
  method: "delete",
@@ -9702,6 +10279,59 @@ const journeys = defineModule(defineRoute({
9702
10279
  ...sessionErrors,
9703
10280
  ...errors("not_found")
9704
10281
  }
10282
+ }), defineRoute({
10283
+ method: "get",
10284
+ path: "/v1/journeys/{key}/enrollment/preview",
10285
+ operationId: "journeys.enrollmentPreview",
10286
+ tags: ["journeys"],
10287
+ summary: "Count who turning this journey on would reach",
10288
+ security: SESSION_AUTH,
10289
+ request: { params: params$7 },
10290
+ responses: {
10291
+ 200: envelope(enrollmentCountsSchema, "What enrolling the journey's current members would do"),
10292
+ ...sessionErrors,
10293
+ ...errors("not_found", "dependency_unavailable")
10294
+ }
10295
+ }), defineRoute({
10296
+ method: "post",
10297
+ path: "/v1/journeys/{key}/enrollment",
10298
+ operationId: "journeys.startEnrollment",
10299
+ tags: ["journeys"],
10300
+ summary: "Enroll the journey's current members",
10301
+ security: SESSION_AUTH,
10302
+ surfaces: HIDDEN_FROM_TOOLS,
10303
+ request: { params: params$7 },
10304
+ responses: {
10305
+ 202: envelope(enrollmentStatusSchema, "The job, as it starts"),
10306
+ ...sessionErrors,
10307
+ ...errors("not_found")
10308
+ }
10309
+ }), defineRoute({
10310
+ method: "get",
10311
+ path: "/v1/journeys/{key}/enrollment",
10312
+ operationId: "journeys.enrollmentStatus",
10313
+ tags: ["journeys"],
10314
+ summary: "How far the journey's enrollment has got",
10315
+ security: SESSION_AUTH,
10316
+ request: { params: params$7 },
10317
+ responses: {
10318
+ 200: envelope(enrollmentStatusSchema),
10319
+ ...sessionErrors,
10320
+ ...errors("not_found")
10321
+ }
10322
+ }), defineRoute({
10323
+ method: "post",
10324
+ path: "/v1/journeys/{key}/enrollment/cancel",
10325
+ operationId: "journeys.cancelEnrollment",
10326
+ tags: ["journeys"],
10327
+ summary: "Stop a running enrollment",
10328
+ security: SESSION_AUTH,
10329
+ request: { params: params$7 },
10330
+ responses: {
10331
+ 200: envelope(enrollmentStatusSchema, "The job as it stood when the stop was asked for"),
10332
+ ...sessionErrors,
10333
+ ...errors("not_found")
10334
+ }
9705
10335
  }), defineRoute({
9706
10336
  method: "post",
9707
10337
  path: "/v1/journeys/{key}/dry-run",
@@ -9769,63 +10399,11 @@ const me = defineModule(defineRoute({
9769
10399
  }
9770
10400
  }));
9771
10401
 
9772
- //#endregion
9773
- //#region ../../packages/shared/src/contract/project.ts
9774
- /**
9775
- * The org's project. Singular in the path on purpose: a caller always means
9776
- * one, the one its `cow.json` names, so the name is a query parameter and
9777
- * there is no id to put in a URL. An org with a single project needs neither.
9778
- */
9779
- const project = defineModule(defineRoute({
9780
- method: "get",
9781
- path: "/v1/project",
9782
- operationId: "project.get",
9783
- tags: ["project"],
9784
- summary: "Get one of the organization's projects",
9785
- security: PIPELINE_AUTH,
9786
- request: { query: getProjectQuerySchema },
9787
- responses: {
9788
- 200: envelope(projectSchema),
9789
- ...sessionErrors,
9790
- ...errors("project_missing", "validation_failed", "malformed_request")
9791
- }
9792
- }), defineRoute({
9793
- method: "get",
9794
- path: "/v1/project/status",
9795
- operationId: "project.status",
9796
- tags: ["project"],
9797
- summary: "What a project's journeys and templates are doing",
9798
- security: PIPELINE_AUTH,
9799
- surfaces: {
9800
- cli: false,
9801
- mcpName: "status"
9802
- },
9803
- request: { query: getProjectQuerySchema },
9804
- responses: {
9805
- 200: envelope(projectStatusSchema),
9806
- ...sessionErrors,
9807
- ...errors("project_missing", "validation_failed", "malformed_request")
9808
- }
9809
- }), defineRoute({
9810
- method: "post",
9811
- path: "/v1/project",
9812
- operationId: "project.create",
9813
- tags: ["project"],
9814
- summary: "Create a project in the organization",
9815
- security: PIPELINE_AUTH,
9816
- request: { body: jsonBody(createProjectBodySchema) },
9817
- responses: {
9818
- 201: envelope(projectSchema, "The created project"),
9819
- ...sessionErrors,
9820
- ...errors("project_exists", "validation_failed", "malformed_request")
9821
- }
9822
- }));
9823
-
9824
10402
  //#endregion
9825
10403
  //#region ../../packages/shared/src/contract/pushes.ts
9826
10404
  const params$6 = z.object({ id: z.string() });
9827
10405
  /**
9828
- * Pushes: one `cow push` each, the whole project at one point in time. The
10406
+ * Pushes: one `cow push` each, the whole app at one point in time. The
9829
10407
  * push stores the source archive and its compile creates a version of every
9830
10408
  * key whose module changed; it turns nothing on (ADR 0011).
9831
10409
  *
@@ -9839,14 +10417,14 @@ const pushes = defineModule(defineRoute({
9839
10417
  path: "/v1/pushes",
9840
10418
  operationId: "pushes.create",
9841
10419
  tags: ["pushes"],
9842
- summary: "Push a built project",
10420
+ summary: "Push a built app",
9843
10421
  security: PIPELINE_AUTH,
9844
10422
  surfaces: HIDDEN_FROM_TOOLS,
9845
10423
  request: { body: jsonBody(createPushBodySchema) },
9846
10424
  responses: {
9847
10425
  201: envelope(pushSchema, "The stored push and the versions it is compiling"),
9848
10426
  ...sessionErrors,
9849
- ...errors("project_missing", "push_invalid", "validation_failed", "malformed_request", "dependency_unavailable")
10427
+ ...errors("push_invalid", "validation_failed", "malformed_request", "dependency_unavailable")
9850
10428
  }
9851
10429
  }), defineRoute({
9852
10430
  method: "get",
@@ -9897,8 +10475,8 @@ const pushes = defineModule(defineRoute({
9897
10475
  //#region ../../packages/shared/src/contract/resend.ts
9898
10476
  /**
9899
10477
  * 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
10478
+ * `RESEND_BASE_URL` at `/resend/{appOrSourceId}` and keeps their code. The
10479
+ * path segment names the app, because the Resend SDK carries no other field
9902
10480
  * and accepts no custom header; the bearer is the organization's ingestion
9903
10481
  * key.
9904
10482
  *
@@ -9914,7 +10492,15 @@ const FACADE_SURFACES = {
9914
10492
  mcp: false,
9915
10493
  docs: false
9916
10494
  };
9917
- const sourceParam = z.object({ sourceId: z.string().openapi({ example: "src_2f9a8c1b" }) });
10495
+ /**
10496
+ * The app the message belongs to: its own id, or the id of its one `api`
10497
+ * source, which is what every base URL held before app ids were accepted
10498
+ * here. Both resolve to that source, so attribution is the same either way.
10499
+ */
10500
+ const targetParam = z.object({ appOrSourceId: z.union([appIdSchema, sourceIdSchema], { error: "Use an app id (\"app_website\") or the id of its API source." }).openapi({
10501
+ example: "app_website",
10502
+ description: "The app to send for, or the id of its API source. Both name the same pipe."
10503
+ }) });
9918
10504
  /** Resend's error body, for the answers this facade gives. */
9919
10505
  const facadeError = (description) => ({
9920
10506
  description,
@@ -9922,21 +10508,21 @@ const facadeError = (description) => ({
9922
10508
  });
9923
10509
  const facadeErrors = {
9924
10510
  401: facadeError("missing_api_key | invalid_api_key"),
9925
- 404: facadeError("not_found: unknown source, or unknown message"),
10511
+ 404: facadeError("not_found: unknown app or source, or unknown message"),
9926
10512
  422: facadeError("validation_error | missing_required_field"),
9927
10513
  429: facadeError("monthly_quota_exceeded | rate_limit_exceeded"),
9928
10514
  500: facadeError("application_error")
9929
10515
  };
9930
10516
  const resend = defineModule(defineRoute({
9931
10517
  method: "post",
9932
- path: "/resend/{sourceId}/emails",
10518
+ path: "/resend/{appOrSourceId}/emails",
9933
10519
  operationId: "resend.send",
9934
10520
  tags: ["resend"],
9935
10521
  summary: "Send one email (Resend-compatible)",
9936
10522
  security: API_KEY_AUTH,
9937
10523
  surfaces: FACADE_SURFACES,
9938
10524
  request: {
9939
- params: sourceParam,
10525
+ params: targetParam,
9940
10526
  body: jsonBody(resendSendBodySchema)
9941
10527
  },
9942
10528
  responses: {
@@ -9949,14 +10535,14 @@ const resend = defineModule(defineRoute({
9949
10535
  }
9950
10536
  }), defineRoute({
9951
10537
  method: "post",
9952
- path: "/resend/{sourceId}/emails/batch",
10538
+ path: "/resend/{appOrSourceId}/emails/batch",
9953
10539
  operationId: "resend.sendBatch",
9954
10540
  tags: ["resend"],
9955
10541
  summary: "Send up to 100 emails (Resend-compatible)",
9956
10542
  security: API_KEY_AUTH,
9957
10543
  surfaces: FACADE_SURFACES,
9958
10544
  request: {
9959
- params: sourceParam,
10545
+ params: targetParam,
9960
10546
  body: jsonBody(resendBatchBodySchema)
9961
10547
  },
9962
10548
  responses: {
@@ -9969,13 +10555,13 @@ const resend = defineModule(defineRoute({
9969
10555
  }
9970
10556
  }), defineRoute({
9971
10557
  method: "get",
9972
- path: "/resend/{sourceId}/emails/{id}",
10558
+ path: "/resend/{appOrSourceId}/emails/{id}",
9973
10559
  operationId: "resend.get",
9974
10560
  tags: ["resend"],
9975
10561
  summary: "Fetch one sent email (Resend-compatible)",
9976
10562
  security: API_KEY_AUTH,
9977
10563
  surfaces: FACADE_SURFACES,
9978
- request: { params: sourceParam.extend({ id: z.string().openapi({ example: "dlv_2f9a8c1b" }) }) },
10564
+ request: { params: targetParam.extend({ id: z.string().openapi({ example: "dlv_2f9a8c1b" }) }) },
9979
10565
  responses: {
9980
10566
  200: {
9981
10567
  description: "The email object",
@@ -10080,7 +10666,7 @@ const segments = defineModule(defineRoute({
10080
10666
  responses: {
10081
10667
  200: envelope(segmentDetailSchema),
10082
10668
  ...sessionErrors,
10083
- ...errors("not_found"),
10669
+ ...errors("not_found", "conflict"),
10084
10670
  ...writeErrors
10085
10671
  }
10086
10672
  }), defineRoute({
@@ -10094,7 +10680,7 @@ const segments = defineModule(defineRoute({
10094
10680
  responses: {
10095
10681
  200: envelope(deletedSchema),
10096
10682
  ...sessionErrors,
10097
- ...errors("not_found")
10683
+ ...errors("not_found", "conflict")
10098
10684
  }
10099
10685
  }), defineRoute({
10100
10686
  method: "get",
@@ -10462,8 +11048,18 @@ const mergedRedirect = { 307: {
10462
11048
  headers: z.object({ Location: z.string().meta({ description: "The survivor's URL" }) })
10463
11049
  } };
10464
11050
  /**
10465
- * Profiles by their Cowliss-generated id. `find` precedes `{profileId}` so
10466
- * the literal path wins.
11051
+ * Profiles by their Cowliss-generated id. `find`, `export` and `erase`
11052
+ * precede `{profileId}` so the literal paths win.
11053
+ *
11054
+ * Compliance has two ways in (ADR 0022). A `usr_` id names one profile, and
11055
+ * erasure or export takes it with everything merged into it. A
11056
+ * `{ kind, value }` identifier names a person, which is one profile per app
11057
+ * that knows the value, and the same erasure or export runs over all of
11058
+ * them at once: the addressing differs, nothing below it does. An identifier
11059
+ * never travels in the URL, neither in the path nor in a query string,
11060
+ * because a GDPR request carries an address and a URL is logged whole. That
11061
+ * is why both identifier routes are a POST carrying a body, including the
11062
+ * export, which reads.
10467
11063
  */
10468
11064
  const users = defineModule(defineRoute({
10469
11065
  method: "get",
@@ -10491,6 +11087,32 @@ const users = defineModule(defineRoute({
10491
11087
  ...sessionErrors,
10492
11088
  ...errors("not_found", "validation_failed")
10493
11089
  }
11090
+ }), defineRoute({
11091
+ method: "post",
11092
+ path: "/v1/users/export",
11093
+ operationId: "users.exportByIdentifier",
11094
+ tags: ["users"],
11095
+ summary: "Export everything held on the people one identifier names",
11096
+ security: SESSION_AUTH,
11097
+ request: { body: jsonBody(exportUserByIdentifierBodySchema) },
11098
+ responses: {
11099
+ 200: envelope(userExportSchema),
11100
+ ...sessionErrors,
11101
+ ...errors("not_found", "validation_failed", "malformed_request")
11102
+ }
11103
+ }), defineRoute({
11104
+ method: "post",
11105
+ path: "/v1/users/erase",
11106
+ operationId: "users.eraseByIdentifier",
11107
+ tags: ["users"],
11108
+ summary: "Erase every person one identifier names (GDPR)",
11109
+ security: SESSION_AUTH,
11110
+ request: { body: jsonBody(eraseUserByIdentifierBodySchema) },
11111
+ responses: {
11112
+ 200: envelope(eraseUserResponseSchema),
11113
+ ...sessionErrors,
11114
+ ...errors("not_found", "validation_failed", "malformed_request")
11115
+ }
10494
11116
  }), defineRoute({
10495
11117
  method: "get",
10496
11118
  path: "/v1/users/{profileId}",
@@ -10567,6 +11189,23 @@ const users = defineModule(defineRoute({
10567
11189
  ...sessionErrors,
10568
11190
  ...errors("not_found", "validation_failed")
10569
11191
  }
11192
+ }), defineRoute({
11193
+ method: "get",
11194
+ path: "/v1/users/{profileId}/runs",
11195
+ operationId: "users.listRuns",
11196
+ tags: ["users"],
11197
+ summary: "The journeys a profile has completed",
11198
+ security: SESSION_AUTH,
11199
+ request: {
11200
+ params: params$2,
11201
+ query: listUserRunsQuerySchema
11202
+ },
11203
+ responses: {
11204
+ 200: list(userRunSchema),
11205
+ ...mergedRedirect,
11206
+ ...sessionErrors,
11207
+ ...errors("not_found", "validation_failed")
11208
+ }
10570
11209
  }));
10571
11210
 
10572
11211
  //#endregion
@@ -10737,7 +11376,6 @@ const contract = {
10737
11376
  violations,
10738
11377
  review,
10739
11378
  segments,
10740
- project,
10741
11379
  artifacts,
10742
11380
  pushes,
10743
11381
  versions,
@@ -11120,47 +11758,84 @@ function registerContractCommands(program, run) {
11120
11758
  //#endregion
11121
11759
  //#region src/commands/enable.ts
11122
11760
  /**
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.
11761
+ * `cow enable`, `cow disable` and `cow pause`: the one gate on a journey
11762
+ * (ADR 0011, ADR 0013, ADR 0018). Hand-written rather than derived from
11763
+ * `journeys.setStatus`, because the commands are top-level names a developer
11764
+ * types; the route itself takes the same set of keys they do.
11765
+ *
11766
+ * `cow enable` on a journey on hold is how it resumes: the server has one
11767
+ * transition to `on` and no second word for it.
11127
11768
  */
11128
11769
  /**
11129
11770
  * One call, whichever way the keys were named: the route takes the set, and
11130
- * `--all` is the project name, resolved on the server against the rows it
11771
+ * `--all` is the app id, resolved on the server against the rows the app
11131
11772
  * owns (so it reaches a key whose file the tree lost). A key that is not
11132
- * there refuses the whole call, and nothing flips.
11133
- */
11134
- async function setEnabled(client, selector, enabled) {
11135
- const route = enabled ? contract.journeys["journeys.enable"] : contract.journeys["journeys.disable"];
11136
- return (await client.request(route, { body: selector })).data;
11773
+ * there refuses the whole call, and nothing changes.
11774
+ */
11775
+ async function setStatus(client, selector, status) {
11776
+ return (await client.request(contract.journeys["journeys.setStatus"], { body: {
11777
+ ...selector,
11778
+ status
11779
+ } })).data;
11780
+ }
11781
+ /**
11782
+ * Who is already standing in a journey that was just turned on, and where
11783
+ * to let them in (ADR 0015). Printed, never acted on: enrolling a cohort
11784
+ * spends real money and reaches real people, so it stays a person's click
11785
+ * in the dashboard and no command here starts one.
11786
+ */
11787
+ function segmentLines(journey) {
11788
+ const offer = journey.enrollment;
11789
+ if (!offer) return [];
11790
+ if (!offer.counts) return [` Who is in its segment could not be counted just now: ${offer.url}`];
11791
+ const { scanned, enrolled, skipped } = offer.counts;
11792
+ if (scanned === 0) return [" Nobody is in its segment yet."];
11793
+ return [
11794
+ ` ${scanned} in its segment now, ${enrolled} of them can enter it now.`,
11795
+ ...Object.entries(ENROLLMENT_SKIP_WORDS).map(([key, words]) => ({
11796
+ value: skipped[key],
11797
+ words
11798
+ })).filter(({ value }) => value > 0).map(({ value, words }) => ` ${value} ${words.reason}.`),
11799
+ ...enrolled > 0 ? [` To let those ${enrolled} in: ${offer.url}`] : []
11800
+ ];
11137
11801
  }
11802
+ /** How the terminal names each state. */
11803
+ const STATE_WORD = {
11804
+ on: "on",
11805
+ off: "off",
11806
+ paused: "on hold"
11807
+ };
11138
11808
  /** What the terminal says: one line per key, naming its new state. */
11139
- function flipSummary(journeys, enabled) {
11140
- if (journeys.length === 0) return `This project has no journeys to turn ${enabled ? "on" : "off"}.`;
11141
- return journeys.map((journey) => `${journey.key} is ${enabled ? "on" : "off"}.`).join("\n");
11809
+ function flipSummary(journeys, status) {
11810
+ if (journeys.length === 0) return `This app has no journeys to put ${STATE_WORD[status]}.`;
11811
+ return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
11142
11812
  }
11143
- function register(program, clientFor, io, enabled) {
11144
- const verb = enabled ? "enable" : "disable";
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) => {
11813
+ const DESCRIPTIONS = {
11814
+ on: "turn journeys on",
11815
+ off: "turn journeys off",
11816
+ paused: "hold journeys: nobody enters, and everyone part-way through waits"
11817
+ };
11818
+ function register(program, clientFor, io, verb, status) {
11819
+ program.command(verb).description(`${DESCRIPTIONS[status]} (many keys, or --all for this app's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this app").action(async (keys, opts) => {
11146
11820
  const merged = {
11147
11821
  ...program.opts(),
11148
11822
  ...opts
11149
11823
  };
11150
11824
  if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
11151
11825
  if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
11152
- const selector = merged.all === true ? { project: (await assertCowConfig(process.cwd())).project } : { keys };
11153
- const updated = await setEnabled(await clientFor(merged), selector, enabled);
11826
+ const selector = merged.all === true ? { appId: (await assertCowConfig(process.cwd())).appId } : { keys };
11827
+ const updated = await setStatus(await clientFor(merged), selector, status);
11154
11828
  if (merged.json === true) {
11155
11829
  emit({ data: updated }, io, true);
11156
11830
  return;
11157
11831
  }
11158
- io.stdout(`${flipSummary(updated, enabled)}\n`);
11832
+ io.stdout(`${flipSummary(updated, status)}\n`);
11159
11833
  });
11160
11834
  }
11161
11835
  function registerEnable(program, clientFor, io) {
11162
- register(program, clientFor, io, true);
11163
- register(program, clientFor, io, false);
11836
+ register(program, clientFor, io, "enable", "on");
11837
+ register(program, clientFor, io, "disable", "off");
11838
+ register(program, clientFor, io, "pause", "paused");
11164
11839
  }
11165
11840
 
11166
11841
  //#endregion
@@ -11208,43 +11883,45 @@ async function packageJson(name) {
11208
11883
  }
11209
11884
  });
11210
11885
  }
11886
+ /** The org's active apps, or null when this machine cannot read them. */
11887
+ async function listApps(client) {
11888
+ try {
11889
+ return (await client.request(contract.apps["apps.list"], { query: {
11890
+ status: "active",
11891
+ limit: 100
11892
+ } })).data.map((app) => app.id);
11893
+ } catch {
11894
+ return null;
11895
+ }
11896
+ }
11211
11897
  /**
11212
- * Create the org's project row. Best effort by design: with no credentials
11213
- * there is nothing to authenticate with, and any other failure (offline, an
11214
- * older API) leaves the row for `cow push` to create.
11215
- *
11216
- * ponytail: plain fetch because `POST /v1/project` is not a contract route
11217
- * yet; swap it for the typed client's `project.create` once ticket 04 lands.
11898
+ * Pick the app this repository pushes to. One app is the answer; several
11899
+ * are a question; none is a refusal, because the answer is in the dashboard
11900
+ * and `cow init` is not allowed to invent one.
11218
11901
  */
11219
- async function createProject(apiUrl, token, name) {
11220
- if (!token) return {
11221
- project: "skipped",
11222
- reason: "not logged in"
11902
+ async function chooseApp(client, webUrl, flag, org, io, skipPrompts) {
11903
+ if (flag) return { appId: flag };
11904
+ if (org.claimed !== null && org.claimed !== org.requested) return {
11905
+ appId: "",
11906
+ reason: `this machine is logged in to ${org.claimed}, not ${org.requested}`
11223
11907
  };
11224
- try {
11225
- const response = await fetch(`${apiUrl}/v1/project`, {
11226
- method: "POST",
11227
- headers: {
11228
- "content-type": "application/json",
11229
- authorization: `Bearer ${token}`
11230
- },
11231
- body: JSON.stringify({ data: { name } })
11232
- });
11233
- if (response.status === 201) return { project: "created" };
11234
- if (response.status === 409) return { project: "exists" };
11235
- return {
11236
- project: "skipped",
11237
- reason: `POST /v1/project answered ${response.status}`
11238
- };
11239
- } catch (error) {
11240
- return {
11241
- project: "skipped",
11242
- reason: error instanceof Error ? error.message : String(error)
11243
- };
11244
- }
11908
+ const apps = await listApps(client);
11909
+ if (apps === null) return {
11910
+ appId: "",
11911
+ reason: "this machine could not read the org's apps"
11912
+ };
11913
+ if (apps.length === 0) throw new Error(`This organization has no app yet, and a repository pushes to one. Create one at ${webUrl}/apps, then run \`cow init\` again.`);
11914
+ const only = apps[0];
11915
+ if (apps.length === 1 && only) return { appId: only };
11916
+ if (skipPrompts || !io.isTTY) return {
11917
+ appId: "",
11918
+ reason: `this organization has several apps (${apps.join(", ")}) and nothing said which`
11919
+ };
11920
+ io.stderr(`Apps in this organization:\n${apps.map((id) => ` ${id}`).join("\n")}\n`);
11921
+ return { appId: await ask("App id", only ?? "", io, false) };
11245
11922
  }
11246
- function registerInit(program, env, io) {
11247
- program.command("init").argument("[dir]", "directory to create the project in (default: here)").description("create a cow project: cow.json, journeys/, emails/, and the files to build them").option("--org <id>", "organization id (default: the logged-in org)").option("--yes", "accept the defaults instead of prompting").option("--example <name>", "start from a gallery example").action(async (dirArg, opts) => {
11923
+ function registerInit(program, clientFor, env, io) {
11924
+ program.command("init").argument("[dir]", "directory to create the repo in (default: here)").description("create a cow repo: cow.json, journeys/, emails/, and the files to build them").option("--org <id>", "organization id (default: the logged-in org)").option("--app <id>", "app id this repository pushes to (default: ask, or the org's only one)").option("--yes", "accept the defaults instead of prompting").option("--example <name>", "start from a gallery example").action(async (dirArg, opts) => {
11248
11925
  const merged = {
11249
11926
  ...program.opts(),
11250
11927
  ...opts
@@ -11252,17 +11929,27 @@ function registerInit(program, env, io) {
11252
11929
  const skipPrompts = opts.yes === true;
11253
11930
  const cwd = process.cwd();
11254
11931
  const defaultName = basename(dirArg ? resolve(cwd, dirArg) : cwd);
11255
- const name = await ask("Project name", defaultName, io, skipPrompts);
11932
+ const name = await ask("Repo name", defaultName, io, skipPrompts);
11256
11933
  const dir = dirArg ? resolve(cwd, dirArg) : name === defaultName ? cwd : resolve(cwd, name);
11257
11934
  if (existsSync(join(dir, "cow.json"))) throw new Error(`"${dir}" already holds a cow.json. Use \`cow add example\` to add code to it.`);
11258
11935
  const credentials = await readCredentials(env);
11259
11936
  const claimedOrg = credentials ? decodeSessionToken(credentials.token)?.orgId ?? null : null;
11260
11937
  const orgId = typeof opts.org === "string" && opts.org !== "" ? opts.org : await ask("Organization id", claimedOrg ?? "", io, skipPrompts);
11261
11938
  if (orgId === "") throw new Error("No organization id. Pass --org org_… or run `cow login` first.");
11262
- const config = cowConfigSchema.parse({
11939
+ if (!orgIdSchema.safeParse(orgId).success) throw new Error(`"${orgId}" is not an organization id. They look like org_…; \`cow login\` writes the one you are signed in to.`);
11940
+ const webUrl = resolveWebUrl(env);
11941
+ const app = await chooseApp(await clientFor(merged), webUrl, typeof opts.app === "string" && opts.app !== "" ? opts.app : void 0, {
11942
+ requested: orgId,
11943
+ claimed: claimedOrg
11944
+ }, io, skipPrompts);
11945
+ const config = app.appId === "" ? {
11946
+ $schema: COW_CONFIG_SCHEMA_URL,
11947
+ orgId,
11948
+ appId: ""
11949
+ } : cowConfigSchema.parse({
11263
11950
  $schema: COW_CONFIG_SCHEMA_URL,
11264
11951
  orgId,
11265
- project: name
11952
+ appId: app.appId
11266
11953
  });
11267
11954
  const contents = {
11268
11955
  "cow.json": json(config),
@@ -11285,19 +11972,18 @@ function registerInit(program, env, io) {
11285
11972
  }
11286
11973
  const example = typeof opts.example === "string" ? opts.example : void 0;
11287
11974
  const files = [...Object.keys(contents), ...example ? await copyExample(example, dir, false) : []].sort();
11288
- const outcome = await createProject(resolveApiUrl(env, credentials, typeof merged.api === "string" ? merged.api : void 0), resolveCredential(env, credentials).token, name);
11289
11975
  emit({ data: {
11290
11976
  dir,
11291
11977
  orgId,
11292
11978
  files,
11293
- ...outcome
11979
+ ...app
11294
11980
  } }, io, merged.json);
11295
11981
  const here = relative(cwd, dir) || ".";
11296
11982
  const scenario = files.find((file) => file.startsWith("scenarios/")) ?? "scenarios/abandoned-checkout.timeout.json";
11297
- const projectLine = outcome.project === "created" ? `Project created for ${orgId}` : outcome.project === "exists" ? `Project already exists for ${orgId}` : `Project not created (${outcome.reason}); cow push will create it.`;
11983
+ const appLine = app.appId === "" ? `No app named yet (${app.reason}). Put its id in cow.json's "appId"; you will find it on the app's page at ${webUrl}/apps.` : `Pushes to ${app.appId}`;
11298
11984
  io.stderr([
11299
- `Created a cow project in ${here}`,
11300
- projectLine,
11985
+ `Created a cow repo in ${here}`,
11986
+ appLine,
11301
11987
  "",
11302
11988
  "Next steps:",
11303
11989
  ` cd ${here}`,
@@ -11471,25 +12157,25 @@ function noGitHere(error) {
11471
12157
  return failure.code === "ENOENT" || String(failure.stderr ?? "").includes("not a git repository");
11472
12158
  }
11473
12159
  /**
11474
- * The push to restore: the one named, else this project's newest. Scoped to
11475
- * this directory's project, so a sibling repo's tree is never unpacked over
11476
- * this one's files.
12160
+ * The push to restore: the one named, else this app's newest. Scoped to this
12161
+ * directory's app, so a sibling repo's tree is never unpacked over this
12162
+ * one's files.
11477
12163
  *
11478
12164
  * The newest rather than the newest that compiled: a push stores the whole
11479
12165
  * tree and its source archive whatever its keys then compiled to, so it is
11480
12166
  * the last thing anyone pushed either way.
11481
12167
  */
11482
- async function resolvePush(client, pushId, project) {
12168
+ async function resolvePush(client, pushId, appId) {
11483
12169
  if (pushId) return (await client.request(contract.pushes["pushes.get"], { params: { id: pushId } })).data;
11484
12170
  const newest = (await client.request(contract.pushes["pushes.list"], { query: {
11485
12171
  limit: 1,
11486
- project
12172
+ appId
11487
12173
  } })).data[0];
11488
- if (!newest) throw new Error("This project has never been pushed, so there is nothing to restore. Run `cow push` first, or name a push with --push.");
12174
+ if (!newest) throw new Error("This app has never been pushed, so there is nothing to restore. Run `cow push` first, or name a push with --push.");
11489
12175
  return newest;
11490
12176
  }
11491
12177
  async function pullPush({ client, projectDir, pushId, force }) {
11492
- const push = await resolvePush(client, pushId, await configuredProject(projectDir));
12178
+ const push = await resolvePush(client, pushId, await configuredApp(projectDir));
11493
12179
  if (!force) {
11494
12180
  const dirty = await dirtyPaths(projectDir);
11495
12181
  if (dirty.length > 0) throw new Error(`This working tree has uncommitted changes and \`cow pull\` would write over them:\n ${dirty.join("\n ")}\nCommit or stash them, or pass --force.`);
@@ -11511,7 +12197,7 @@ async function pullPush({ client, projectDir, pushId, force }) {
11511
12197
  };
11512
12198
  }
11513
12199
  function registerPull(program, clientFor, io) {
11514
- program.command("pull").description("restore the project's source files from a push into the current directory").option("--push <id>", "the push to restore (default: the newest one)").option("--force", "extract even when the git working tree is dirty").action(async (opts) => {
12200
+ program.command("pull").description("restore the repo's source files from a push into the current directory").option("--push <id>", "the push to restore (default: the newest one)").option("--force", "extract even when the git working tree is dirty").action(async (opts) => {
11515
12201
  const merged = {
11516
12202
  ...program.opts(),
11517
12203
  ...opts
@@ -11532,25 +12218,6 @@ function registerPull(program, clientFor, io) {
11532
12218
 
11533
12219
  //#endregion
11534
12220
  //#region src/commands/push.ts
11535
- /**
11536
- * This directory's project, created when the org has none by that name.
11537
- * `cow init` usually got there first; a project pushed from a machine that
11538
- * only ever cloned the repo has not, and creating it here is what makes that
11539
- * clone work. The name is `cow.json`'s `project`.
11540
- */
11541
- async function ensureProject(client, name) {
11542
- try {
11543
- return (await client.request(contract.project["project.get"], { query: { name } })).data;
11544
- } catch (error) {
11545
- if (!(error instanceof ApiError) || error.code !== "project_missing") throw error;
11546
- }
11547
- try {
11548
- return (await client.request(contract.project["project.create"], { body: { name } })).data;
11549
- } catch (error) {
11550
- const reason = error instanceof Error ? error.message : String(error);
11551
- throw new Error(`This organization has no project and one could not be created (${reason}). Run \`cow init\` first.`);
11552
- }
11553
- }
11554
12221
  /** Does the org already hold these bytes? A 404 is the answer, not a failure. */
11555
12222
  async function held(client, digest) {
11556
12223
  try {
@@ -11584,8 +12251,6 @@ function artifactFiles(projectDir, manifest) {
11584
12251
  }
11585
12252
  async function pushProject({ client, projectDir }) {
11586
12253
  const { manifest, config } = await buildProject(projectDir);
11587
- const project = await ensureProject(client, config.project);
11588
- if (project.orgId !== config.orgId) throw new Error(`cow.json is for ${config.orgId} but the credential belongs to ${project.orgId}. Log in to that organization, use its pipeline key, or select a config for it with --config or COW_CONFIG (for example cow.local.json).`);
11589
12254
  const uploaded = [];
11590
12255
  for (const artifact of artifactFiles(projectDir, manifest)) {
11591
12256
  if (await held(client, artifact.digest)) continue;
@@ -11602,7 +12267,8 @@ async function pushProject({ client, projectDir }) {
11602
12267
  ...manifest,
11603
12268
  protocol: 3
11604
12269
  },
11605
- project: project.name
12270
+ appId: config.appId,
12271
+ orgId: config.orgId
11606
12272
  } })).data,
11607
12273
  uploaded
11608
12274
  };
@@ -11618,10 +12284,10 @@ function pushSummary(outcome, webUrl) {
11618
12284
  const stored = `Push #${push.seq} stored (${uploaded}).`;
11619
12285
  if (push.versions.length === 0) return `${stored} Nothing changed.`;
11620
12286
  const width = Math.max(...push.versions.map((version) => version.key.length));
11621
- return [`${stored} Compiling ${push.versions.length} ${push.versions.length === 1 ? "key" : "keys"}:`, ...push.versions.map((version) => ` ${version.key.padEnd(width)} ${webUrl}/${version.kind === "journey" ? "journeys" : "templates"}/${version.key}`)].join("\n");
12287
+ return [`${stored} Compiling ${push.versions.length} ${push.versions.length === 1 ? "key" : "keys"}:`, ...push.versions.map((version) => ` ${version.key.padEnd(width)} ${webUrl}/${version.kind === "journey" ? "journeys" : "deliveries/templates"}/${version.key}`)].join("\n");
11622
12288
  }
11623
12289
  function registerPush(program, clientFor, env, io) {
11624
- program.command("push").description("build the project and upload it; the server compiles what changed").action(async (opts) => {
12290
+ program.command("push").description("build this repository and upload it; the server compiles what changed").action(async (opts) => {
11625
12291
  const merged = {
11626
12292
  ...program.opts(),
11627
12293
  ...opts
@@ -11642,10 +12308,10 @@ function registerPush(program, clientFor, env, io) {
11642
12308
  //#endregion
11643
12309
  //#region src/commands/status.ts
11644
12310
  /**
11645
- * `cow status`: what the server holds for every key of this project, and how
11646
- * far the tree in front of the developer has moved from it. Hand-written
11647
- * rather than derived from `project.status`, because the drift is the half
11648
- * of the answer no server read can know: it comes from building the project
12311
+ * `cow status`: what the server holds for every key of this app, and how far
12312
+ * the tree in front of the developer has moved from it. Hand-written rather
12313
+ * than derived from `apps.status`, because the drift is the half of the
12314
+ * answer no server read can know: it comes from building the repository
11649
12315
  * here and comparing the bundle digests with the versions the server has.
11650
12316
  */
11651
12317
  /** Largest first: the first unit the gap fills is the one that reads best. */
@@ -11699,8 +12365,7 @@ function versionPhrase(entry, now) {
11699
12365
  return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
11700
12366
  }
11701
12367
  function flagsPhrase(entry) {
11702
- if (entry.enabled === null) return "";
11703
- return entry.enabled ? "on" : "off";
12368
+ return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
11704
12369
  }
11705
12370
  function livePhrase(entry) {
11706
12371
  return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
@@ -11720,7 +12385,7 @@ function statusLines(status, manifest, now) {
11720
12385
  const local = manifest ? localKeys(manifest) : [];
11721
12386
  const journeys = status.keys.filter((entry) => entry.kind === "journey");
11722
12387
  const templates = status.keys.filter((entry) => entry.kind === "template");
11723
- const lines = [`${status.project}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}, ${templates.length} ${templates.length === 1 ? "template" : "templates"} on the server.`];
12388
+ const lines = [`${status.appId}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}, ${templates.length} ${templates.length === 1 ? "template" : "templates"} on the server.`];
11724
12389
  const width = Math.max(0, ...status.keys.map((entry) => entry.key.length));
11725
12390
  const section = (title, entries) => {
11726
12391
  if (entries.length === 0) return;
@@ -11759,19 +12424,19 @@ function statusLines(status, manifest, now) {
11759
12424
  }
11760
12425
  return lines;
11761
12426
  }
11762
- /** The project's status from the server, for this directory's `cow.json`. */
11763
- async function readStatus(client, project) {
11764
- return (await client.request(contract.project["project.status"], { query: { name: project } })).data;
12427
+ /** The app's status from the server, for this directory's `cow.json`. */
12428
+ async function readStatus(client, appId) {
12429
+ return (await client.request(contract.apps["apps.status"], { params: { id: appId } })).data;
11765
12430
  }
11766
12431
  function registerStatus(program, clientFor, io) {
11767
- program.command("status").description("what this project's journeys and templates are doing, and what changed here since the last push").action(async (opts) => {
12432
+ program.command("status").description("what this app's journeys and templates are doing, and what changed here since the last push").action(async (opts) => {
11768
12433
  const merged = {
11769
12434
  ...program.opts(),
11770
12435
  ...opts
11771
12436
  };
11772
12437
  const config = await assertCowConfig(process.cwd());
11773
12438
  const built = await buildProject(process.cwd()).catch(() => null);
11774
- const status = await readStatus(await clientFor(merged), config.project);
12439
+ const status = await readStatus(await clientFor(merged), config.appId);
11775
12440
  if (merged.json === true) {
11776
12441
  emit({ data: status }, io, true);
11777
12442
  return;
@@ -12040,7 +12705,7 @@ function emit(result, io, json) {
12040
12705
  }
12041
12706
  function buildProgram(env, io) {
12042
12707
  const program = new Command();
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)`);
12708
+ 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>", `config file to read instead of ${DEFAULT_CONFIG_FILE} (overrides COW_CONFIG)`);
12044
12709
  program.hook("preAction", () => {
12045
12710
  const flag = program.opts().config;
12046
12711
  setConfigFile((typeof flag === "string" ? flag : void 0) ?? env.COW_CONFIG ?? "cow.json");
@@ -12062,7 +12727,7 @@ function buildProgram(env, io) {
12062
12727
  };
12063
12728
  registerContractCommands(program, run);
12064
12729
  registerAuth(program, env, io);
12065
- registerInit(program, env, io);
12730
+ registerInit(program, clientFor, env, io);
12066
12731
  registerAdd(program, io);
12067
12732
  registerBuild(program, io);
12068
12733
  registerPush(program, clientFor, env, io);