@cowliss/cli 0.7.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
  */
@@ -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
@@ -3392,6 +3424,15 @@ const deliveries$1 = pgTable("deliveries", {
3392
3424
  * neither of which has one.
3393
3425
  */
3394
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"),
3395
3436
  kind: deliveryKindEnum("kind").notNull().default("journey"),
3396
3437
  /**
3397
3438
  * Email template name or webhook name. An api send that named no
@@ -3449,6 +3490,7 @@ const deliveries$1 = pgTable("deliveries", {
3449
3490
  }, (table) => [
3450
3491
  index("deliveries_org_id_created_at_idx").on(table.orgId, table.createdAt),
3451
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),
3452
3494
  uniqueIndex("deliveries_org_id_attempt_key_unique").on(table.orgId, table.attemptKey),
3453
3495
  uniqueIndex("deliveries_provider_id_unique").on(table.providerId),
3454
3496
  index("deliveries_status_sent_at_idx").on(table.status, table.sentAt)
@@ -3688,10 +3730,10 @@ const identifierKindEnum = pgEnum("identifier_kind", [
3688
3730
  ]);
3689
3731
  /**
3690
3732
  * The identifiers map: every named identifier a call has ever carried,
3691
- * pointing at the profile it resolved to. Unique per (org, kind, value),
3692
- * which is what makes "a shared identifier means the same person"
3693
- * enforceable at the row level: the same clerkId twice in one organization
3694
- * is one person.
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).
3695
3737
  *
3696
3738
  * Rows follow their profile: erasure deletes the profile and these cascade,
3697
3739
  * and a merge re-points them at the survivor.
@@ -3699,11 +3741,21 @@ const identifierKindEnum = pgEnum("identifier_kind", [
3699
3741
  const identifiers = pgTable("identifiers", {
3700
3742
  id: text("id").primaryKey(),
3701
3743
  orgId: text("org_id").notNull(),
3744
+ /** The app this identifier resolves in; always its profile's app. */
3745
+ appId: text("app_id").notNull(),
3702
3746
  kind: identifierKindEnum("kind").notNull(),
3703
3747
  value: text("value").notNull(),
3704
3748
  profileId: text("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
3705
3749
  createdAt: createdAt()
3706
- }, (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
+ ]);
3707
3759
  const selectIdentifierSchema = createSelectSchema(identifiers);
3708
3760
  const insertIdentifierSchema = createInsertSchema(identifiers);
3709
3761
 
@@ -3767,10 +3819,11 @@ const journeyStatusEnum = pgEnum("journey_status", [
3767
3819
  *
3768
3820
  * A push only ever adds and updates: it owns the keys its manifest carries
3769
3821
  * and leaves every other row alone, because an org's journeys can come from
3770
- * several projects and a developer may push a project holding only some of
3771
- * the files. `projectId` records which project put the row here, which is
3772
- * what makes a key another project owns a refused push rather than a silent
3773
- * 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.
3774
3827
  *
3775
3828
  * Whether a journey fires is one field and one only: `status`, which a
3776
3829
  * push never writes, so deploying code never turns anything on or off. The
@@ -3784,14 +3837,14 @@ const journeyStatusEnum = pgEnum("journey_status", [
3784
3837
  const journeys$1 = pgTable("journeys", {
3785
3838
  orgId: text("org_id").notNull(),
3786
3839
  key: text("key").notNull(),
3787
- /** The project whose push owns this key. */
3788
- 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(),
3789
3842
  /** The author's labels, from the manifest; the dashboard's only grouping. */
3790
3843
  tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
3791
3844
  trigger: jsonb("trigger").$type().notNull(),
3792
3845
  /**
3793
3846
  * The consent purpose this journey's sends are gated on: one of the two
3794
- * 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
3795
3848
  * enum, because the set is the org's `consent_purposes` rows, which a
3796
3849
  * deploy checks the key against; a database enum could only ever hold
3797
3850
  * the fixed pair.
@@ -3811,7 +3864,11 @@ const journeys$1 = pgTable("journeys", {
3811
3864
  status: journeyStatusEnum("status").notNull().default("off"),
3812
3865
  createdAt: createdAt(),
3813
3866
  updatedAt: updatedAt()
3814
- }, (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
+ })]);
3815
3872
  const selectJourneySchema = createSelectSchema(journeys$1);
3816
3873
  const insertJourneySchema = createInsertSchema(journeys$1);
3817
3874
 
@@ -3911,33 +3968,15 @@ const orgSettings = pgTable("org_settings", {
3911
3968
  const selectOrgSettingsSchema = createSelectSchema(orgSettings);
3912
3969
  const insertOrgSettingsSchema = createInsertSchema(orgSettings);
3913
3970
 
3914
- //#endregion
3915
- //#region ../../packages/db/src/schema/projects.ts
3916
- /**
3917
- * An org's cow project: the folder a developer runs `cow init` in, and the
3918
- * thing pushes belong to. An org may hold several, one per repo, each with
3919
- * its own release sequence and its own slice of the deployed journey rows. The name is how `cow.json` picks
3920
- * one, so it is unique within the org.
3921
- */
3922
- const projects = pgTable("projects", {
3923
- id: text("id").primaryKey(),
3924
- orgId: text("org_id").notNull(),
3925
- name: text("name").notNull(),
3926
- createdAt: createdAt(),
3927
- updatedAt: updatedAt()
3928
- }, (table) => [uniqueIndex("projects_org_id_name_unique").on(table.orgId, table.name)]);
3929
- const selectProjectSchema = createSelectSchema(projects);
3930
- const insertProjectSchema = createInsertSchema(projects);
3931
-
3932
3971
  //#endregion
3933
3972
  //#region ../../packages/db/src/schema/pushes.ts
3934
3973
  /**
3935
- * 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
3936
3975
  * source archive and creates a version of every key whose bundle changed
3937
3976
  * (ADR 0011); it settles nothing and changes no flag, so it has no status
3938
3977
  * of its own. What is compiling is read off its versions.
3939
3978
  *
3940
- * `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
3941
3980
  * transaction; the unique index leads with the org id so it doubles as the
3942
3981
  * tenancy key.
3943
3982
  *
@@ -3948,7 +3987,8 @@ const insertProjectSchema = createInsertSchema(projects);
3948
3987
  const pushes$1 = pgTable("pushes", {
3949
3988
  id: text("id").primaryKey(),
3950
3989
  orgId: text("org_id").notNull(),
3951
- 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(),
3952
3992
  seq: integer("seq").notNull(),
3953
3993
  manifest: jsonb("manifest").$type().notNull(),
3954
3994
  /** sha256 of the pushed manifest; the CLI compares it to skip a no-op push. */
@@ -3957,7 +3997,15 @@ const pushes$1 = pgTable("pushes", {
3957
3997
  sourceDigest: text("source_digest").notNull(),
3958
3998
  createdBy: text("created_by").notNull(),
3959
3999
  createdAt: createdAt()
3960
- }, (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
+ ]);
3961
4009
  const selectPushSchema = createSelectSchema(pushes$1);
3962
4010
  const insertPushSchema = createInsertSchema(pushes$1);
3963
4011
  /**
@@ -4062,6 +4110,9 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4062
4110
  * hand-maintained: the engine in packages/segments recomputes on every write
4063
4111
  * and materializes the result into segment_members.
4064
4112
  *
4113
+ * A segment belongs to one app (ADR 0022) and sees only that app's profiles
4114
+ * and that app's events.
4115
+ *
4065
4116
  * A row is either standalone (built in the dashboard, `journey_key` null) or
4066
4117
  * owned by one journey whose trigger inlined its definition (ADR 0016). An
4067
4118
  * owned row is named after its journey's key, is written only by the push,
@@ -4076,6 +4127,8 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4076
4127
  const segments$1 = pgTable("segments", {
4077
4128
  id: text("id").primaryKey(),
4078
4129
  orgId: text("org_id").notNull(),
4130
+ /** The one app this segment evaluates. */
4131
+ appId: text("app_id").notNull(),
4079
4132
  name: text("name").notNull(),
4080
4133
  description: text("description"),
4081
4134
  definition: jsonb("definition").$type().notNull(),
@@ -4090,7 +4143,12 @@ const segments$1 = pgTable("segments", {
4090
4143
  columns: [table.orgId, table.journeyKey],
4091
4144
  foreignColumns: [journeys$1.orgId, journeys$1.key],
4092
4145
  name: "segments_journey_fk"
4093
- }).onDelete("cascade")
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
+ })
4094
4152
  ]);
4095
4153
  /**
4096
4154
  * Materialized membership: one row per (segment, profile) currently in the
@@ -4353,8 +4411,8 @@ const versions$1 = pgTable("versions", {
4353
4411
  /** The file basename its author wrote; unique per org within a kind. */
4354
4412
  key: text("key").notNull(),
4355
4413
  kind: versionKindEnum("kind").notNull(),
4356
- /** The project that pushed this key; ADR 0009's ownership. */
4357
- 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(),
4358
4416
  /** The push that compiled this version's module. */
4359
4417
  pushId: text("push_id").notNull().references(() => pushes$1.id),
4360
4418
  moduleDigest: text("module_digest"),
@@ -4374,7 +4432,12 @@ const versions$1 = pgTable("versions", {
4374
4432
  }, (table) => [
4375
4433
  uniqueIndex("versions_org_id_kind_key_module_digest_unique").on(table.orgId, table.kind, table.key, table.moduleDigest),
4376
4434
  index("versions_org_id_kind_key_pushed_at_idx").on(table.orgId, table.kind, table.key, table.pushedAt),
4377
- 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
+ })
4378
4441
  ]);
4379
4442
  const selectVersionSchema = createSelectSchema(versions$1);
4380
4443
  const insertVersionSchema = createInsertSchema(versions$1);
@@ -4557,8 +4620,6 @@ const ERROR_CODE_STATUS = {
4557
4620
  journey_nondeterministic: 500,
4558
4621
  push_invalid: 422,
4559
4622
  version_not_ready: 409,
4560
- project_exists: 409,
4561
- project_missing: 404,
4562
4623
  artifact_too_large: 413
4563
4624
  };
4564
4625
  /** First zod issue's message: enough signal for a 422 without a novel format. */
@@ -4612,6 +4673,21 @@ const identifierValueSchema = z.string().trim().min(1, "an identifier value is r
4612
4673
  * and a call names only the kinds it has.
4613
4674
  */
4614
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
+ });
4615
4691
  /** One identifier as the profile DTOs carry it. */
4616
4692
  const identifierDtoSchema = z.object({
4617
4693
  kind: identifierKindSchema,
@@ -4626,21 +4702,18 @@ const identifierDtoSchema = z.object({
4626
4702
  * history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
4627
4703
  * prototype, and a flat list keeps the pure evaluator a fold.
4628
4704
  *
4629
- * `appId` scopes the EVENT side of a definition only. Traits are
4630
- * per-profile and profiles merge across apps, so there is nothing
4631
- * app-shaped to filter on the trait side.
4632
- *
4633
- * A definition and the membership it produces are both the
4634
- * organization's.
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.
4635
4708
  *
4636
4709
  * Apart from `./segments` because a journey's trigger carries a definition
4637
4710
  * and the trigger schema is bundled into the wasm guest, where an edge to
4638
4711
  * `@cowliss/db` (which `./segments` has, for the row schema) is a hard
4639
4712
  * bundler failure. Nothing here imports anything but zod.
4640
4713
  *
4641
- * The object is strict: a definition holding the retired `sourceId` key
4642
- * (which meant the app) fails loudly instead of parsing as an unfiltered
4643
- * definition that evaluates over every app. There is deliberately no pipe
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
4644
4717
  * filter here — filtering by source is a later feature, and accepting one
4645
4718
  * now would make a stale `sourceId` parse as a filter on a pipe that does
4646
4719
  * not exist and silently match nothing.
@@ -4693,15 +4766,8 @@ const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
4693
4766
  /** Rolling window, relative to evaluation time; absent means all history. */
4694
4767
  withinDays: z.number().int().min(1).optional()
4695
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" });
4696
- const appIdSchema = z.string().trim().min(1);
4697
4769
  const segmentDefinitionSchema = z.strictObject({
4698
4770
  match: z.enum(["all", "any"]).default("all"),
4699
- /**
4700
- * Optional app filter over the event side; null/absent spans apps.
4701
- * A list matches any of the named apps, which is how one definition
4702
- * names the development and the production id of the same app.
4703
- */
4704
- appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
4705
4771
  predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
4706
4772
  });
4707
4773
 
@@ -4814,9 +4880,12 @@ const purposesSchema = z.array(declaredPurposeSchema).max(20, "a project declare
4814
4880
  const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 200 characters");
4815
4881
  const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
4816
4882
  /**
4817
- * What starts a journey: an event (optionally narrowed to an app id or a
4818
- * list of them), or entry into the segment the trigger itself describes.
4819
- * The registry DTO in `../journeys` 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.
4820
4889
  *
4821
4890
  * A segment trigger carries the predicate list, not a name: the push
4822
4891
  * materializes one segment row per journey that inlines a definition, owned
@@ -4825,16 +4894,13 @@ const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1
4825
4894
  * definition is data — validated here, carried on the version, never
4826
4895
  * compiled and never executed.
4827
4896
  *
4828
- * Both members are strict, so a journey holding the retired `source` key, or
4829
- * the retired `{ segment: "name" }` form, fails to build instead of silently
4830
- * triggering on every app or on nothing. There is deliberately no pipe
4831
- * filter: a trigger narrows by the app the write is attributed to, the same
4832
- * token a segment definition names.
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.
4833
4902
  */
4834
- const triggerSchema = z.union([z.strictObject({
4835
- event: patternSchema,
4836
- appId: patternSchema.optional()
4837
- }), z.strictObject({ segment: segmentDefinitionSchema })]);
4903
+ const triggerSchema = z.union([z.strictObject({ event: patternSchema }), z.strictObject({ segment: segmentDefinitionSchema })]);
4838
4904
  /**
4839
4905
  * The address half of a from-header: a local part, an `@`, and a dotted
4840
4906
  * domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
@@ -5130,6 +5196,74 @@ const identifyDataSchema = identifyFieldsSchema.refine((data) => notFutureTimest
5130
5196
  });
5131
5197
  const identifyBodySchema = z.object({ data: identifyDataSchema });
5132
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
+
5133
5267
  //#endregion
5134
5268
  //#region ../../packages/shared/src/track.ts
5135
5269
  /**
@@ -5189,7 +5323,7 @@ const trackBodySchema = z.object({ data: trackDataSchema });
5189
5323
  */
5190
5324
  const listEventsQuerySchema = paginationQuerySchema.extend({
5191
5325
  direction: sortDirectionSchema.default("desc"),
5192
- appId: z.string().trim().min(1).max(200).optional(),
5326
+ appId: appIdSchema.optional(),
5193
5327
  sourceId: z.string().trim().min(1).max(200).optional(),
5194
5328
  event: z.string().trim().min(1).max(200).optional(),
5195
5329
  /**
@@ -5712,10 +5846,16 @@ const listDeliveriesQuerySchema = paginationQuerySchema.extend({
5712
5846
  channel: deliveryChannelSchema.optional(),
5713
5847
  /** Why the send happened: a journey, a test send, or a direct API send. */
5714
5848
  kind: deliveryKindSchema.optional(),
5715
- appId: z.string().trim().min(1).max(200).optional(),
5849
+ appId: appIdSchema.optional(),
5716
5850
  /** One person's deliveries, merged ids included, same as the event feed. */
5717
5851
  profileId: z.string().trim().min(1).max(200).optional(),
5718
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
+ /**
5719
5859
  * Only deliveries touched at or after this instant, by `updatedAt`: the
5720
5860
  * cursor a tail (`cow dev`) polls with, so a send and its later feedback
5721
5861
  * transition both arrive. Inclusive for the same reason as the execution
@@ -5794,10 +5934,10 @@ const domainDnsSetupSchema = z.object({
5794
5934
  * braces, no dependency. A pattern field takes one pattern or a non-empty
5795
5935
  * list of them, and a list is a disjunction.
5796
5936
  *
5797
- * It lives here rather than beside `appIdList` in ./apps because that module
5798
- * imports the Drizzle schema, which neither the wasmtime guest nor the
5799
- * Temporal workflow isolate can carry. This file imports one constant, so
5800
- * 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.
5801
5941
  *
5802
5942
  * The system rule: a name under SYSTEM_EVENT_PREFIX matches only a pattern
5803
5943
  * whose *literal prefix* (the text before its first `*`) is itself under
@@ -6135,6 +6275,13 @@ const listExecutionsQuerySchema = paginationQuerySchema.extend({
6135
6275
  status: executionStatusSchema.optional(),
6136
6276
  profileId: z.string().trim().min(1).max(200).optional(),
6137
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
+ /**
6138
6285
  * Only executions touched at or after this instant, by `updatedAt`: what a
6139
6286
  * tail (`cow dev`) asks for so it sees both new runs and status changes in
6140
6287
  * one filter. Inclusive on purpose, because two rows can share a
@@ -6160,67 +6307,17 @@ const cancelExecutionsBodySchema = z.object({ data: z.object({
6160
6307
  */
6161
6308
  const cancellingSchema = z.object({ cancelling: z.literal(true) });
6162
6309
 
6163
- //#endregion
6164
- //#region ../../packages/shared/src/journeys-v2/config.ts
6165
- /**
6166
- * A project's name: what `cow init` asked for, and what the `projects` row
6167
- * stores. Lives here rather than beside the release DTOs because `cow.json`
6168
- * is the file a developer types it into; `createProjectBodySchema` reuses it.
6169
- */
6170
- const projectNameSchema = z.string().trim().min(1).max(200);
6171
- /**
6172
- * `cow.json` (spec: Project layout): the org, and which of its projects this
6173
- * directory is. Auth never lives in the project. Strict, so a typo'd key
6174
- * is a build error rather than a silently ignored setting.
6175
- */
6176
- const cowConfigSchema = z.strictObject({
6177
- $schema: z.url().optional(),
6178
- orgId: z.string().startsWith(CLERK_ORG_SUBJECT_PREFIX, "orgId must be a Clerk org id"),
6179
- /**
6180
- * Which project of the org this directory pushes to. One org can hold
6181
- * several, one per repo, each with its own release sequence and its own
6182
- * slice of the deployed journeys, so every project says which it is.
6183
- */
6184
- project: projectNameSchema,
6185
- /**
6186
- * The consent purposes this project declares. They are org-wide, so two
6187
- * projects declaring one key must agree on its label and default or the
6188
- * deploy is refused; a deploy adds and updates them and never deletes
6189
- * one, because profiles hold answers against them.
6190
- */
6191
- purposes: purposesSchema.optional(),
6192
- /** Overrides the API the CLI talks to; the hosted product needs none. */
6193
- apiUrl: z.url().optional(),
6194
- /**
6195
- * Overrides the dashboard `cow login` opens. Paired with `apiUrl`: a
6196
- * login against one Cowliss's dashboard yields a token the other's API
6197
- * rejects, so a config that names an API names its dashboard too.
6198
- */
6199
- webUrl: z.url().optional()
6200
- }).meta({
6201
- title: "cow.json",
6202
- description: "A cow project: the organization and the project it pushes to."
6203
- });
6204
-
6205
6310
  //#endregion
6206
6311
  //#region ../../packages/shared/src/pushes.ts
6207
6312
  /**
6208
- * The push lifecycle's wire contracts (ADR 0011): the project a developer
6209
- * runs `cow init` in, the artifacts `cow push` uploads, the push itself, and
6210
- * the versions its compile produces.
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).
6211
6316
  *
6212
6317
  * Row-derived through drizzle-zod where a row is what the wire carries, so
6213
6318
  * the DTO cannot drift from the column set; the jsonb manifest gets its
6214
6319
  * explicit shared schema, because the column type is opaque to drizzle-zod.
6215
6320
  */
6216
- const projectSchema = selectProjectSchema.extend({
6217
- createdAt: z.iso.datetime(),
6218
- updatedAt: z.iso.datetime()
6219
- });
6220
- /** `cow init` names the project after the folder it created. */
6221
- const createProjectBodySchema = z.object({ data: z.object({ name: projectNameSchema }) });
6222
- /** Which project to read: the one the caller's `cow.json` names. */
6223
- const getProjectQuerySchema = z.object({ name: projectNameSchema });
6224
6321
  const artifactDigestParams = z.object({ digest: digestSchema });
6225
6322
  /**
6226
6323
  * Which kind of artifact the uploaded bytes are. Named by the client rather
@@ -6252,7 +6349,7 @@ const versionSummarySchema = selectVersionSchema.pick({
6252
6349
  status: true,
6253
6350
  moduleDigest: true,
6254
6351
  pushId: true,
6255
- projectId: true,
6352
+ appId: true,
6256
6353
  error: true
6257
6354
  }).extend({ pushedAt: z.iso.datetime() });
6258
6355
  /**
@@ -6281,12 +6378,21 @@ const createPushBodySchema = z.object({ data: z.object({
6281
6378
  * the developer fixes by updating `@cowliss/cli`, and nothing is stored.
6282
6379
  */
6283
6380
  manifest: manifestSchema.safeExtend({ protocol: z.literal(3) }),
6284
- /** The `cow.json` project this push belongs to. */
6285
- 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
6286
6392
  }) });
6287
6393
  const listPushesQuerySchema = paginationQuerySchema.extend({
6288
- /** One project's own pushes; absent lists the org's. */
6289
- project: projectNameSchema.optional() });
6394
+ /** One app's own pushes; absent lists the org's. */
6395
+ appId: appIdSchema.optional() });
6290
6396
  /**
6291
6397
  * One key's version history, newest first. Both the key and the kind are
6292
6398
  * required: journeys and templates share a key space, so `welcome` alone
@@ -6297,13 +6403,13 @@ const listVersionsQuerySchema = paginationQuerySchema.extend({
6297
6403
  kind: z.enum(["journey", "template"])
6298
6404
  });
6299
6405
  /**
6300
- * One key of a project as `cow status` and the MCP `status` tool report it:
6406
+ * One key of an app as `cow status` and the MCP `status` tool report it:
6301
6407
  * the code the server holds for it, what state it is in, and what is still
6302
6408
  * running. Journeys and templates share the shape, because a developer asks
6303
6409
  * the same question of both; the two fields only a journey has are null on a
6304
6410
  * template.
6305
6411
  */
6306
- const projectStatusKeySchema = z.object({
6412
+ const appStatusKeySchema = z.object({
6307
6413
  kind: z.enum(["journey", "template"]),
6308
6414
  key: z.string(),
6309
6415
  /**
@@ -6322,19 +6428,19 @@ const projectStatusKeySchema = z.object({
6322
6428
  liveExecutions: z.number().int()
6323
6429
  });
6324
6430
  /**
6325
- * Everything the server knows about one project's keys, in one read: the
6326
- * 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
6327
6433
  * `PUSH_LIMITS.journeys + PUSH_LIMITS.templates` keys and the answer to "what
6328
- * is my project doing" is useless split across cursors.
6434
+ * is my app doing" is useless split across cursors.
6329
6435
  *
6330
6436
  * `warnings` is what a deploy used to answer with (ADR 0011 retired the
6331
6437
  * deploy): a name a journey references that the organization does not
6332
6438
  * define yet. They never block, because a segment or a webhook may be created
6333
6439
  * right after a push.
6334
6440
  */
6335
- const projectStatusSchema = z.object({
6336
- project: projectNameSchema,
6337
- keys: z.array(projectStatusKeySchema),
6441
+ const appStatusSchema = z.object({
6442
+ appId: appIdSchema,
6443
+ keys: z.array(appStatusKeySchema),
6338
6444
  warnings: z.array(z.string())
6339
6445
  });
6340
6446
 
@@ -6355,15 +6461,20 @@ const segmentMemberSchema = z.object({
6355
6461
  });
6356
6462
  /**
6357
6463
  * The builder's design-time sanity check: run one not-yet-persisted
6358
- * definition over the org's existing profiles. Members is a small sample of
6359
- * matching profile ids, not the full member list: there is no segment row
6360
- * and no membership entry, so there is no segmentId or enteredAt to report.
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.
6361
6469
  *
6362
- * The scan is bounded, so `memberCount` is the exact org-wide count only
6470
+ * The scan is bounded, so `memberCount` is the exact count for that app only
6363
6471
  * when `truncated` is false. When it is true the scan stopped at the cap,
6364
6472
  * and `memberCount` is the count over the first `scanned` profiles only.
6365
6473
  */
6366
- const previewSegmentBodySchema = z.object({ data: segmentDefinitionSchema });
6474
+ const previewSegmentBodySchema = z.object({ data: z.object({
6475
+ appId: appIdSchema,
6476
+ definition: segmentDefinitionSchema
6477
+ }) });
6367
6478
  const segmentPreviewSchema = z.object({
6368
6479
  memberCount: z.number().int().min(0),
6369
6480
  members: z.array(z.string()),
@@ -6374,8 +6485,15 @@ const segmentPreviewSchema = z.object({
6374
6485
  });
6375
6486
  const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
6376
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
+ */
6377
6494
  const createSegmentBodySchema = z.object({ data: z.object({
6378
6495
  name: nameSchema,
6496
+ appId: appIdSchema,
6379
6497
  description: descriptionSchema.nullish(),
6380
6498
  definition: segmentDefinitionSchema
6381
6499
  }) });
@@ -6384,8 +6502,15 @@ const updateSegmentBodySchema = z.object({ data: z.object({
6384
6502
  description: descriptionSchema.nullish(),
6385
6503
  definition: segmentDefinitionSchema.optional()
6386
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" }) });
6387
- /** `q` is a substring search over the segment's name. */
6388
- const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
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
+ });
6389
6514
  const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
6390
6515
 
6391
6516
  //#endregion
@@ -6445,10 +6570,15 @@ const journeyDetailSchema = journeySchema.extend({ segment: segmentDetailSchema.
6445
6570
  * the two things an author names a journey by; `tag` is "has this tag",
6446
6571
  * exact and case-sensitive, so a badge in the table is the way into it.
6447
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.
6448
6577
  */
6449
6578
  const listJourneysQuerySchema = paginationQuerySchema.extend({
6450
6579
  /** On, off or paused; the journey's one state. */
6451
6580
  status: journeyStatusSchema.optional(),
6581
+ appId: appIdSchema.optional(),
6452
6582
  q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0),
6453
6583
  tag: z.string().trim().max(50, "tag must be at most 50 characters").optional()
6454
6584
  });
@@ -6477,13 +6607,13 @@ const journeyStatsSchema = z.object({
6477
6607
  });
6478
6608
  /**
6479
6609
  * Which journeys the status acts on, and which state to put them in: the
6480
- * keys the caller named, or every journey of one project. Exactly one of the
6610
+ * keys the caller named, or every journey of one app. Exactly one of the
6481
6611
  * two selectors, because a call carrying both would have to decide which it
6482
6612
  * meant.
6483
6613
  *
6484
6614
  * A list rather than one key per call so every surface can do what `cow
6485
- * enable` does: name several keys, or `--all` (which is `project`, resolved
6486
- * 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).
6487
6617
  *
6488
6618
  * One body for three states rather than a route per verb (ADR 0018): the
6489
6619
  * transitions differ in what they do to what is in flight, not in who may
@@ -6491,10 +6621,10 @@ const journeyStatsSchema = z.object({
6491
6621
  */
6492
6622
  const setJourneysStatusBodySchema = z.object({ data: z.object({
6493
6623
  keys: z.array(journeyKeySchema).min(1).max(PUSH_LIMITS.journeys).optional(),
6494
- /** Every journey of this project, by the name `cow.json` carries. */
6495
- project: projectNameSchema.optional(),
6624
+ /** Every journey of this app, by the id `cow.json` carries. */
6625
+ appId: appIdSchema.optional(),
6496
6626
  status: journeyStatusSchema
6497
- }).refine((data) => data.keys === void 0 !== (data.project === void 0), { message: "name keys or a project, not both" }) });
6627
+ }).refine((data) => data.keys === void 0 !== (data.appId === void 0), { message: "name keys or an app, not both" }) });
6498
6628
  /**
6499
6629
  * Dry run: one real profile, the journey's latest ready version, and every
6500
6630
  * send gated into a `would_*` row instead of a message. The body is the v1
@@ -6982,7 +7112,12 @@ const templateSchema$1 = z.strictObject({
6982
7112
  props: z.record(z.string(), z.unknown()).optional()
6983
7113
  });
6984
7114
  const sendEmailDataSchema = z.strictObject({
6985
- sourceId: z.string(),
7115
+ /**
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.
7119
+ */
7120
+ sourceId: sourceIdSchema,
6986
7121
  to: recipientsSchema,
6987
7122
  cc: recipientsSchema.optional(),
6988
7123
  bcc: recipientsSchema.optional(),
@@ -7184,8 +7319,8 @@ const listSuppressionsQuerySchema = paginationQuerySchema.extend({ q: searchQuer
7184
7319
  */
7185
7320
  const templateSchema = z.object({
7186
7321
  key: journeyKeySchema,
7187
- /** The project whose push owns this key (ADR 0009). */
7188
- projectId: z.string(),
7322
+ /** The app whose push owns this key (ADR 0009, ADR 0022). */
7323
+ appId: z.string(),
7189
7324
  /** The author's labels; the dashboard's only grouping. */
7190
7325
  tags: tagsSchema,
7191
7326
  sendClass: z.enum(SEND_CLASSES),
@@ -7271,10 +7406,17 @@ const listUserRunsQuerySchema = paginationQuerySchema;
7271
7406
  * as text (the jsonb value rendered with ->>), so "who is on the pro plan" is
7272
7407
  * one request. `traitValue` without `trait` is meaningless and is a 422: the
7273
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.
7274
7414
  */
7275
7415
  const listUsersQuerySchema = paginationQuerySchema.extend({
7276
7416
  direction: sortDirectionSchema.default("asc"),
7277
7417
  q: searchQuerySchema,
7418
+ /** One app's profiles. An unknown id matches nobody, it is not a 404. */
7419
+ appId: appIdSchema.optional(),
7278
7420
  trait: z.string().trim().min(1).max(200).optional(),
7279
7421
  traitValue: z.string().trim().min(1).max(500).optional(),
7280
7422
  /** Members of one segment. An unknown id matches nobody, it is not a 404. */
@@ -7300,13 +7442,32 @@ const findUserQuerySchema = z.object({ identifier: z.string().trim().min(3, "ide
7300
7442
  const userDetailSchema = profileDtoSchema.extend({ mergedIds: z.array(z.string()) });
7301
7443
  const updateConsentBodySchema = z.object({ data: consentPatchSchema });
7302
7444
  /**
7303
- * DELETE /v1/users/:profileId: what erasure did, so the dashboard action
7304
- * 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.
7305
7451
  */
7306
7452
  const eraseUserResponseSchema = z.object({
7307
- profileId: z.string(),
7453
+ profileIds: z.array(z.string()),
7308
7454
  terminatedInstances: z.number().int().min(0)
7309
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 });
7310
7471
  /** One address-ledger row on the export; dates are ISO 8601 strings. */
7311
7472
  const emailAddressSchema = selectEmailAddressSchema.extend({
7312
7473
  verifiedAt: z.iso.datetime().nullable(),
@@ -7319,14 +7480,20 @@ const journeyRunDtoSchema = selectJourneyRunSchema.extend({
7319
7480
  createdAt: z.iso.datetime()
7320
7481
  });
7321
7482
  /**
7322
- * GET /v1/users/:profileId/export: the identity's whole data record in one
7323
- * JSON document (GDPR portability). Each section reuses the wire DTO of
7324
- * the store it came from, so the export cannot drift from what the
7325
- * 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.
7326
7493
  */
7327
7494
  const userExportSchema = z.object({
7328
7495
  exportedAt: z.iso.datetime(),
7329
- user: userDetailSchema,
7496
+ users: z.array(userDetailSchema),
7330
7497
  events: z.array(eventDtoSchema),
7331
7498
  journeys: z.array(journeyRunDtoSchema),
7332
7499
  deliveries: z.array(deliverySchema),
@@ -8278,7 +8445,9 @@ async function assertCowConfig(projectDir) {
8278
8445
  } catch {
8279
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.`);
8280
8447
  }
8281
- 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);
8282
8451
  if (!parsed.success) throw new Error(`${name} is invalid: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`);
8283
8452
  return parsed.data;
8284
8453
  }
@@ -8291,14 +8460,14 @@ async function readCowConfig(projectDir) {
8291
8460
  }
8292
8461
  }
8293
8462
  /**
8294
- * 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
8295
8464
  * `cow.json`, or undefined when there is no `cow.json` here at all. The
8296
- * undefined case is `cow pull` into an empty directory, which has no project
8297
- * 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.
8298
8467
  */
8299
- async function configuredProject(projectDir) {
8468
+ async function configuredApp(projectDir) {
8300
8469
  try {
8301
- return (await assertCowConfig(projectDir)).project;
8470
+ return (await assertCowConfig(projectDir)).appId;
8302
8471
  } catch {
8303
8472
  return;
8304
8473
  }
@@ -8569,7 +8738,7 @@ async function copyExample(name, dir, force) {
8569
8738
  return files;
8570
8739
  }
8571
8740
  function registerAdd(program, io) {
8572
- 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) => {
8573
8742
  const dir = process.cwd();
8574
8743
  await assertCowConfig(dir);
8575
8744
  const files = await copyExample(name, dir, opts.force === true);
@@ -8598,10 +8767,13 @@ async function login(env, options) {
8598
8767
  const claims = decodeSessionToken(token);
8599
8768
  if (claims === null) throw new Error("Token is not a decodable JWT");
8600
8769
  if (claims.userId === null) throw new Error("Token has no subject claim. Is it a Clerk session token?");
8601
- const credentials = { token };
8602
- if (options.apiFlag !== void 0) credentials.apiUrl = options.apiFlag;
8770
+ const apiUrl = options.apiFlag ?? resolveApiUrl(env, null, void 0, (await readCowConfig(process.cwd()))?.apiUrl);
8603
8771
  return {
8604
- credentialsPath: await writeCredentials(env, credentials),
8772
+ credentialsPath: await writeCredentials(env, {
8773
+ token,
8774
+ apiUrl
8775
+ }),
8776
+ apiUrl,
8605
8777
  userId: claims.userId,
8606
8778
  orgId: claims.orgId,
8607
8779
  role: claims.role,
@@ -8688,7 +8860,7 @@ function openBrowser(url) {
8688
8860
  //#region src/commands/auth.ts
8689
8861
  /** login/logout/whoami: session-token management, no contract route. */
8690
8862
  function registerAuth(program, env, io) {
8691
- program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in the project config (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
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) => {
8692
8864
  const outcome = await login(env, {
8693
8865
  token: opts.token,
8694
8866
  webUrl: opts.web,
@@ -8718,8 +8890,14 @@ function registerAuth(program, env, io) {
8718
8890
  }
8719
8891
  const claims = decodeSessionToken(credentials?.token ?? "");
8720
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;
8721
8896
  emit({ data: {
8722
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.` } : {},
8723
8901
  ...claims
8724
8902
  } }, io, opts.json);
8725
8903
  });
@@ -8732,7 +8910,7 @@ function registerAuth(program, env, io) {
8732
8910
  * the current directory. Local only, no API call and no network.
8733
8911
  */
8734
8912
  function registerBuild(program, io) {
8735
- 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) => {
8736
8914
  emit({ data: await buildProject(process.cwd()) }, io, opts.json);
8737
8915
  });
8738
8916
  }
@@ -9165,7 +9343,8 @@ function htmlResponse(description) {
9165
9343
  const params$11 = z.object({ id: z.string() });
9166
9344
  /**
9167
9345
  * An app is the attribution unit: what a developer names, what ingestion
9168
- * 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
9169
9348
  * pipes live under /v1/apps/{appId}/sources, served by the sources module.
9170
9349
  */
9171
9350
  const apps = defineModule(defineRoute({
@@ -9223,6 +9402,23 @@ const apps = defineModule(defineRoute({
9223
9402
  ...sessionErrors,
9224
9403
  ...errors("not_found", "validation_failed", "malformed_request")
9225
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
+ }
9226
9422
  }));
9227
9423
 
9228
9424
  //#endregion
@@ -9514,7 +9710,7 @@ const catalog = defineModule(defineRoute({
9514
9710
  //#region ../../packages/shared/src/contract/consent.ts
9515
9711
  /**
9516
9712
  * The consent purposes an organization's profiles answer: the two fixed ones
9517
- * 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`.
9518
9714
  *
9519
9715
  * Read-only, and deliberately so. Purposes are code, like journeys and
9520
9716
  * templates: a deploy upserts them and never deletes one, because profiles
@@ -10052,7 +10248,7 @@ const journeys = defineModule(defineRoute({
10052
10248
  responses: {
10053
10249
  200: envelope(z.array(journeyWithOfferSchema), "The journeys as they now stand, each with the members turning it on has waiting"),
10054
10250
  ...sessionErrors,
10055
- ...errors("not_found", "project_missing", "validation_failed", "malformed_request")
10251
+ ...errors("not_found", "validation_failed", "malformed_request")
10056
10252
  }
10057
10253
  }), defineRoute({
10058
10254
  method: "get",
@@ -10203,63 +10399,11 @@ const me = defineModule(defineRoute({
10203
10399
  }
10204
10400
  }));
10205
10401
 
10206
- //#endregion
10207
- //#region ../../packages/shared/src/contract/project.ts
10208
- /**
10209
- * The org's project. Singular in the path on purpose: a caller always means
10210
- * one, the one its `cow.json` names, so the name is a query parameter and
10211
- * there is no id to put in a URL. An org with a single project needs neither.
10212
- */
10213
- const project = defineModule(defineRoute({
10214
- method: "get",
10215
- path: "/v1/project",
10216
- operationId: "project.get",
10217
- tags: ["project"],
10218
- summary: "Get one of the organization's projects",
10219
- security: PIPELINE_AUTH,
10220
- request: { query: getProjectQuerySchema },
10221
- responses: {
10222
- 200: envelope(projectSchema),
10223
- ...sessionErrors,
10224
- ...errors("project_missing", "validation_failed", "malformed_request")
10225
- }
10226
- }), defineRoute({
10227
- method: "get",
10228
- path: "/v1/project/status",
10229
- operationId: "project.status",
10230
- tags: ["project"],
10231
- summary: "What a project's journeys and templates are doing",
10232
- security: PIPELINE_AUTH,
10233
- surfaces: {
10234
- cli: false,
10235
- mcpName: "status"
10236
- },
10237
- request: { query: getProjectQuerySchema },
10238
- responses: {
10239
- 200: envelope(projectStatusSchema),
10240
- ...sessionErrors,
10241
- ...errors("project_missing", "validation_failed", "malformed_request")
10242
- }
10243
- }), defineRoute({
10244
- method: "post",
10245
- path: "/v1/project",
10246
- operationId: "project.create",
10247
- tags: ["project"],
10248
- summary: "Create a project in the organization",
10249
- security: PIPELINE_AUTH,
10250
- request: { body: jsonBody(createProjectBodySchema) },
10251
- responses: {
10252
- 201: envelope(projectSchema, "The created project"),
10253
- ...sessionErrors,
10254
- ...errors("project_exists", "validation_failed", "malformed_request")
10255
- }
10256
- }));
10257
-
10258
10402
  //#endregion
10259
10403
  //#region ../../packages/shared/src/contract/pushes.ts
10260
10404
  const params$6 = z.object({ id: z.string() });
10261
10405
  /**
10262
- * 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
10263
10407
  * push stores the source archive and its compile creates a version of every
10264
10408
  * key whose module changed; it turns nothing on (ADR 0011).
10265
10409
  *
@@ -10273,14 +10417,14 @@ const pushes = defineModule(defineRoute({
10273
10417
  path: "/v1/pushes",
10274
10418
  operationId: "pushes.create",
10275
10419
  tags: ["pushes"],
10276
- summary: "Push a built project",
10420
+ summary: "Push a built app",
10277
10421
  security: PIPELINE_AUTH,
10278
10422
  surfaces: HIDDEN_FROM_TOOLS,
10279
10423
  request: { body: jsonBody(createPushBodySchema) },
10280
10424
  responses: {
10281
10425
  201: envelope(pushSchema, "The stored push and the versions it is compiling"),
10282
10426
  ...sessionErrors,
10283
- ...errors("project_missing", "push_invalid", "validation_failed", "malformed_request", "dependency_unavailable")
10427
+ ...errors("push_invalid", "validation_failed", "malformed_request", "dependency_unavailable")
10284
10428
  }
10285
10429
  }), defineRoute({
10286
10430
  method: "get",
@@ -10331,8 +10475,8 @@ const pushes = defineModule(defineRoute({
10331
10475
  //#region ../../packages/shared/src/contract/resend.ts
10332
10476
  /**
10333
10477
  * The Resend-compatible send facade (ADR 0014): a developer points
10334
- * `RESEND_BASE_URL` at `/resend/{sourceId}` and keeps their code. The source
10335
- * in the path names the app, because the Resend SDK carries no other field
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
10336
10480
  * and accepts no custom header; the bearer is the organization's ingestion
10337
10481
  * key.
10338
10482
  *
@@ -10348,7 +10492,15 @@ const FACADE_SURFACES = {
10348
10492
  mcp: false,
10349
10493
  docs: false
10350
10494
  };
10351
- 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
+ }) });
10352
10504
  /** Resend's error body, for the answers this facade gives. */
10353
10505
  const facadeError = (description) => ({
10354
10506
  description,
@@ -10356,21 +10508,21 @@ const facadeError = (description) => ({
10356
10508
  });
10357
10509
  const facadeErrors = {
10358
10510
  401: facadeError("missing_api_key | invalid_api_key"),
10359
- 404: facadeError("not_found: unknown source, or unknown message"),
10511
+ 404: facadeError("not_found: unknown app or source, or unknown message"),
10360
10512
  422: facadeError("validation_error | missing_required_field"),
10361
10513
  429: facadeError("monthly_quota_exceeded | rate_limit_exceeded"),
10362
10514
  500: facadeError("application_error")
10363
10515
  };
10364
10516
  const resend = defineModule(defineRoute({
10365
10517
  method: "post",
10366
- path: "/resend/{sourceId}/emails",
10518
+ path: "/resend/{appOrSourceId}/emails",
10367
10519
  operationId: "resend.send",
10368
10520
  tags: ["resend"],
10369
10521
  summary: "Send one email (Resend-compatible)",
10370
10522
  security: API_KEY_AUTH,
10371
10523
  surfaces: FACADE_SURFACES,
10372
10524
  request: {
10373
- params: sourceParam,
10525
+ params: targetParam,
10374
10526
  body: jsonBody(resendSendBodySchema)
10375
10527
  },
10376
10528
  responses: {
@@ -10383,14 +10535,14 @@ const resend = defineModule(defineRoute({
10383
10535
  }
10384
10536
  }), defineRoute({
10385
10537
  method: "post",
10386
- path: "/resend/{sourceId}/emails/batch",
10538
+ path: "/resend/{appOrSourceId}/emails/batch",
10387
10539
  operationId: "resend.sendBatch",
10388
10540
  tags: ["resend"],
10389
10541
  summary: "Send up to 100 emails (Resend-compatible)",
10390
10542
  security: API_KEY_AUTH,
10391
10543
  surfaces: FACADE_SURFACES,
10392
10544
  request: {
10393
- params: sourceParam,
10545
+ params: targetParam,
10394
10546
  body: jsonBody(resendBatchBodySchema)
10395
10547
  },
10396
10548
  responses: {
@@ -10403,13 +10555,13 @@ const resend = defineModule(defineRoute({
10403
10555
  }
10404
10556
  }), defineRoute({
10405
10557
  method: "get",
10406
- path: "/resend/{sourceId}/emails/{id}",
10558
+ path: "/resend/{appOrSourceId}/emails/{id}",
10407
10559
  operationId: "resend.get",
10408
10560
  tags: ["resend"],
10409
10561
  summary: "Fetch one sent email (Resend-compatible)",
10410
10562
  security: API_KEY_AUTH,
10411
10563
  surfaces: FACADE_SURFACES,
10412
- request: { params: sourceParam.extend({ id: z.string().openapi({ example: "dlv_2f9a8c1b" }) }) },
10564
+ request: { params: targetParam.extend({ id: z.string().openapi({ example: "dlv_2f9a8c1b" }) }) },
10413
10565
  responses: {
10414
10566
  200: {
10415
10567
  description: "The email object",
@@ -10896,8 +11048,18 @@ const mergedRedirect = { 307: {
10896
11048
  headers: z.object({ Location: z.string().meta({ description: "The survivor's URL" }) })
10897
11049
  } };
10898
11050
  /**
10899
- * Profiles by their Cowliss-generated id. `find` precedes `{profileId}` so
10900
- * 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.
10901
11063
  */
10902
11064
  const users = defineModule(defineRoute({
10903
11065
  method: "get",
@@ -10925,6 +11087,32 @@ const users = defineModule(defineRoute({
10925
11087
  ...sessionErrors,
10926
11088
  ...errors("not_found", "validation_failed")
10927
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
+ }
10928
11116
  }), defineRoute({
10929
11117
  method: "get",
10930
11118
  path: "/v1/users/{profileId}",
@@ -11188,7 +11376,6 @@ const contract = {
11188
11376
  violations,
11189
11377
  review,
11190
11378
  segments,
11191
- project,
11192
11379
  artifacts,
11193
11380
  pushes,
11194
11381
  versions,
@@ -11581,7 +11768,7 @@ function registerContractCommands(program, run) {
11581
11768
  */
11582
11769
  /**
11583
11770
  * One call, whichever way the keys were named: the route takes the set, and
11584
- * `--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
11585
11772
  * owns (so it reaches a key whose file the tree lost). A key that is not
11586
11773
  * there refuses the whole call, and nothing changes.
11587
11774
  */
@@ -11620,7 +11807,7 @@ const STATE_WORD = {
11620
11807
  };
11621
11808
  /** What the terminal says: one line per key, naming its new state. */
11622
11809
  function flipSummary(journeys, status) {
11623
- if (journeys.length === 0) return `This project has no journeys to put ${STATE_WORD[status]}.`;
11810
+ if (journeys.length === 0) return `This app has no journeys to put ${STATE_WORD[status]}.`;
11624
11811
  return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
11625
11812
  }
11626
11813
  const DESCRIPTIONS = {
@@ -11629,14 +11816,14 @@ const DESCRIPTIONS = {
11629
11816
  paused: "hold journeys: nobody enters, and everyone part-way through waits"
11630
11817
  };
11631
11818
  function register(program, clientFor, io, verb, status) {
11632
- program.command(verb).description(`${DESCRIPTIONS[status]} (many keys, or --all for this project's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this project").action(async (keys, opts) => {
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) => {
11633
11820
  const merged = {
11634
11821
  ...program.opts(),
11635
11822
  ...opts
11636
11823
  };
11637
11824
  if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
11638
11825
  if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
11639
- const selector = merged.all === true ? { project: (await assertCowConfig(process.cwd())).project } : { keys };
11826
+ const selector = merged.all === true ? { appId: (await assertCowConfig(process.cwd())).appId } : { keys };
11640
11827
  const updated = await setStatus(await clientFor(merged), selector, status);
11641
11828
  if (merged.json === true) {
11642
11829
  emit({ data: updated }, io, true);
@@ -11696,43 +11883,45 @@ async function packageJson(name) {
11696
11883
  }
11697
11884
  });
11698
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
+ }
11699
11897
  /**
11700
- * Create the org's project row. Best effort by design: with no credentials
11701
- * there is nothing to authenticate with, and any other failure (offline, an
11702
- * older API) leaves the row for `cow push` to create.
11703
- *
11704
- * ponytail: plain fetch because `POST /v1/project` is not a contract route
11705
- * 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.
11706
11901
  */
11707
- async function createProject(apiUrl, token, name) {
11708
- if (!token) return {
11709
- project: "skipped",
11710
- 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}`
11711
11907
  };
11712
- try {
11713
- const response = await fetch(`${apiUrl}/v1/project`, {
11714
- method: "POST",
11715
- headers: {
11716
- "content-type": "application/json",
11717
- authorization: `Bearer ${token}`
11718
- },
11719
- body: JSON.stringify({ data: { name } })
11720
- });
11721
- if (response.status === 201) return { project: "created" };
11722
- if (response.status === 409) return { project: "exists" };
11723
- return {
11724
- project: "skipped",
11725
- reason: `POST /v1/project answered ${response.status}`
11726
- };
11727
- } catch (error) {
11728
- return {
11729
- project: "skipped",
11730
- reason: error instanceof Error ? error.message : String(error)
11731
- };
11732
- }
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) };
11733
11922
  }
11734
- function registerInit(program, env, io) {
11735
- 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) => {
11736
11925
  const merged = {
11737
11926
  ...program.opts(),
11738
11927
  ...opts
@@ -11740,17 +11929,27 @@ function registerInit(program, env, io) {
11740
11929
  const skipPrompts = opts.yes === true;
11741
11930
  const cwd = process.cwd();
11742
11931
  const defaultName = basename(dirArg ? resolve(cwd, dirArg) : cwd);
11743
- const name = await ask("Project name", defaultName, io, skipPrompts);
11932
+ const name = await ask("Repo name", defaultName, io, skipPrompts);
11744
11933
  const dir = dirArg ? resolve(cwd, dirArg) : name === defaultName ? cwd : resolve(cwd, name);
11745
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.`);
11746
11935
  const credentials = await readCredentials(env);
11747
11936
  const claimedOrg = credentials ? decodeSessionToken(credentials.token)?.orgId ?? null : null;
11748
11937
  const orgId = typeof opts.org === "string" && opts.org !== "" ? opts.org : await ask("Organization id", claimedOrg ?? "", io, skipPrompts);
11749
11938
  if (orgId === "") throw new Error("No organization id. Pass --org org_… or run `cow login` first.");
11750
- 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 === "" ? {
11751
11946
  $schema: COW_CONFIG_SCHEMA_URL,
11752
11947
  orgId,
11753
- project: name
11948
+ appId: ""
11949
+ } : cowConfigSchema.parse({
11950
+ $schema: COW_CONFIG_SCHEMA_URL,
11951
+ orgId,
11952
+ appId: app.appId
11754
11953
  });
11755
11954
  const contents = {
11756
11955
  "cow.json": json(config),
@@ -11773,19 +11972,18 @@ function registerInit(program, env, io) {
11773
11972
  }
11774
11973
  const example = typeof opts.example === "string" ? opts.example : void 0;
11775
11974
  const files = [...Object.keys(contents), ...example ? await copyExample(example, dir, false) : []].sort();
11776
- const outcome = await createProject(resolveApiUrl(env, credentials, typeof merged.api === "string" ? merged.api : void 0), resolveCredential(env, credentials).token, name);
11777
11975
  emit({ data: {
11778
11976
  dir,
11779
11977
  orgId,
11780
11978
  files,
11781
- ...outcome
11979
+ ...app
11782
11980
  } }, io, merged.json);
11783
11981
  const here = relative(cwd, dir) || ".";
11784
11982
  const scenario = files.find((file) => file.startsWith("scenarios/")) ?? "scenarios/abandoned-checkout.timeout.json";
11785
- 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}`;
11786
11984
  io.stderr([
11787
- `Created a cow project in ${here}`,
11788
- projectLine,
11985
+ `Created a cow repo in ${here}`,
11986
+ appLine,
11789
11987
  "",
11790
11988
  "Next steps:",
11791
11989
  ` cd ${here}`,
@@ -11959,25 +12157,25 @@ function noGitHere(error) {
11959
12157
  return failure.code === "ENOENT" || String(failure.stderr ?? "").includes("not a git repository");
11960
12158
  }
11961
12159
  /**
11962
- * The push to restore: the one named, else this project's newest. Scoped to
11963
- * this directory's project, so a sibling repo's tree is never unpacked over
11964
- * 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.
11965
12163
  *
11966
12164
  * The newest rather than the newest that compiled: a push stores the whole
11967
12165
  * tree and its source archive whatever its keys then compiled to, so it is
11968
12166
  * the last thing anyone pushed either way.
11969
12167
  */
11970
- async function resolvePush(client, pushId, project) {
12168
+ async function resolvePush(client, pushId, appId) {
11971
12169
  if (pushId) return (await client.request(contract.pushes["pushes.get"], { params: { id: pushId } })).data;
11972
12170
  const newest = (await client.request(contract.pushes["pushes.list"], { query: {
11973
12171
  limit: 1,
11974
- project
12172
+ appId
11975
12173
  } })).data[0];
11976
- 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.");
11977
12175
  return newest;
11978
12176
  }
11979
12177
  async function pullPush({ client, projectDir, pushId, force }) {
11980
- const push = await resolvePush(client, pushId, await configuredProject(projectDir));
12178
+ const push = await resolvePush(client, pushId, await configuredApp(projectDir));
11981
12179
  if (!force) {
11982
12180
  const dirty = await dirtyPaths(projectDir);
11983
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.`);
@@ -11999,7 +12197,7 @@ async function pullPush({ client, projectDir, pushId, force }) {
11999
12197
  };
12000
12198
  }
12001
12199
  function registerPull(program, clientFor, io) {
12002
- 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) => {
12003
12201
  const merged = {
12004
12202
  ...program.opts(),
12005
12203
  ...opts
@@ -12020,25 +12218,6 @@ function registerPull(program, clientFor, io) {
12020
12218
 
12021
12219
  //#endregion
12022
12220
  //#region src/commands/push.ts
12023
- /**
12024
- * This directory's project, created when the org has none by that name.
12025
- * `cow init` usually got there first; a project pushed from a machine that
12026
- * only ever cloned the repo has not, and creating it here is what makes that
12027
- * clone work. The name is `cow.json`'s `project`.
12028
- */
12029
- async function ensureProject(client, name) {
12030
- try {
12031
- return (await client.request(contract.project["project.get"], { query: { name } })).data;
12032
- } catch (error) {
12033
- if (!(error instanceof ApiError) || error.code !== "project_missing") throw error;
12034
- }
12035
- try {
12036
- return (await client.request(contract.project["project.create"], { body: { name } })).data;
12037
- } catch (error) {
12038
- const reason = error instanceof Error ? error.message : String(error);
12039
- throw new Error(`This organization has no project and one could not be created (${reason}). Run \`cow init\` first.`);
12040
- }
12041
- }
12042
12221
  /** Does the org already hold these bytes? A 404 is the answer, not a failure. */
12043
12222
  async function held(client, digest) {
12044
12223
  try {
@@ -12072,8 +12251,6 @@ function artifactFiles(projectDir, manifest) {
12072
12251
  }
12073
12252
  async function pushProject({ client, projectDir }) {
12074
12253
  const { manifest, config } = await buildProject(projectDir);
12075
- const project = await ensureProject(client, config.project);
12076
- 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).`);
12077
12254
  const uploaded = [];
12078
12255
  for (const artifact of artifactFiles(projectDir, manifest)) {
12079
12256
  if (await held(client, artifact.digest)) continue;
@@ -12090,7 +12267,8 @@ async function pushProject({ client, projectDir }) {
12090
12267
  ...manifest,
12091
12268
  protocol: 3
12092
12269
  },
12093
- project: project.name
12270
+ appId: config.appId,
12271
+ orgId: config.orgId
12094
12272
  } })).data,
12095
12273
  uploaded
12096
12274
  };
@@ -12106,10 +12284,10 @@ function pushSummary(outcome, webUrl) {
12106
12284
  const stored = `Push #${push.seq} stored (${uploaded}).`;
12107
12285
  if (push.versions.length === 0) return `${stored} Nothing changed.`;
12108
12286
  const width = Math.max(...push.versions.map((version) => version.key.length));
12109
- 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");
12110
12288
  }
12111
12289
  function registerPush(program, clientFor, env, io) {
12112
- 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) => {
12113
12291
  const merged = {
12114
12292
  ...program.opts(),
12115
12293
  ...opts
@@ -12130,10 +12308,10 @@ function registerPush(program, clientFor, env, io) {
12130
12308
  //#endregion
12131
12309
  //#region src/commands/status.ts
12132
12310
  /**
12133
- * `cow status`: what the server holds for every key of this project, and how
12134
- * far the tree in front of the developer has moved from it. Hand-written
12135
- * rather than derived from `project.status`, because the drift is the half
12136
- * 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
12137
12315
  * here and comparing the bundle digests with the versions the server has.
12138
12316
  */
12139
12317
  /** Largest first: the first unit the gap fills is the one that reads best. */
@@ -12207,7 +12385,7 @@ function statusLines(status, manifest, now) {
12207
12385
  const local = manifest ? localKeys(manifest) : [];
12208
12386
  const journeys = status.keys.filter((entry) => entry.kind === "journey");
12209
12387
  const templates = status.keys.filter((entry) => entry.kind === "template");
12210
- 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.`];
12211
12389
  const width = Math.max(0, ...status.keys.map((entry) => entry.key.length));
12212
12390
  const section = (title, entries) => {
12213
12391
  if (entries.length === 0) return;
@@ -12246,19 +12424,19 @@ function statusLines(status, manifest, now) {
12246
12424
  }
12247
12425
  return lines;
12248
12426
  }
12249
- /** The project's status from the server, for this directory's `cow.json`. */
12250
- async function readStatus(client, project) {
12251
- 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;
12252
12430
  }
12253
12431
  function registerStatus(program, clientFor, io) {
12254
- 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) => {
12255
12433
  const merged = {
12256
12434
  ...program.opts(),
12257
12435
  ...opts
12258
12436
  };
12259
12437
  const config = await assertCowConfig(process.cwd());
12260
12438
  const built = await buildProject(process.cwd()).catch(() => null);
12261
- const status = await readStatus(await clientFor(merged), config.project);
12439
+ const status = await readStatus(await clientFor(merged), config.appId);
12262
12440
  if (merged.json === true) {
12263
12441
  emit({ data: status }, io, true);
12264
12442
  return;
@@ -12527,7 +12705,7 @@ function emit(result, io, json) {
12527
12705
  }
12528
12706
  function buildProgram(env, io) {
12529
12707
  const program = new Command();
12530
- program.name("cow").description("Admin CLI for Cowliss: a thin client of the dashboard API").option("--api <url>", "API base URL (overrides env and saved config)").option("--json", "force compact single-line JSON output").option("--config <file>", `project config to read instead of ${DEFAULT_CONFIG_FILE} (overrides COW_CONFIG)`);
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)`);
12531
12709
  program.hook("preAction", () => {
12532
12710
  const flag = program.opts().config;
12533
12711
  setConfigFile((typeof flag === "string" ? flag : void 0) ?? env.COW_CONFIG ?? "cow.json");
@@ -12549,7 +12727,7 @@ function buildProgram(env, io) {
12549
12727
  };
12550
12728
  registerContractCommands(program, run);
12551
12729
  registerAuth(program, env, io);
12552
- registerInit(program, env, io);
12730
+ registerInit(program, clientFor, env, io);
12553
12731
  registerAdd(program, io);
12554
12732
  registerBuild(program, io);
12555
12733
  registerPush(program, clientFor, env, io);