@cowliss/cli 0.2.0 → 0.4.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
@@ -7,13 +7,14 @@ import { copyFile, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "
7
7
  import { homedir } from "node:os";
8
8
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
9
9
  import { Command, InvalidArgumentError } from "commander";
10
- import { existsSync, readFileSync } from "node:fs";
11
- import { fileURLToPath, pathToFileURL } from "node:url";
12
10
  import { execFile, spawn } from "node:child_process";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
12
  import { promisify } from "node:util";
14
13
  import { MessagePort } from "node:worker_threads";
15
14
  import { build, formatMessagesSync } from "esbuild";
16
15
  import { create, extract } from "tar";
16
+ import { parse } from "@babel/parser";
17
+ import { existsSync, readFileSync } from "node:fs";
17
18
  import { createServer } from "node:http";
18
19
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
19
20
  import { createInterface } from "node:readline/promises";
@@ -121,6 +122,29 @@ const TOPUP_PRESETS_MICROS = [
121
122
  */
122
123
  const CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
123
124
  /**
125
+ * The marketing purpose by name, since it is the one every gate, the
126
+ * unsubscribe route, and the developer's own toggle all reach for.
127
+ */
128
+ const EMAIL_MARKETING = "emailMarketing";
129
+ /**
130
+ * What a purpose means when the profile's map does not answer it, matching
131
+ * the `profiles.consent` column default: marketing is asked for, everything
132
+ * else about the data the developer already sends is granted.
133
+ */
134
+ const CONSENT_PURPOSE_DEFAULTS = {
135
+ emailMarketing: false,
136
+ dataProcessing: true
137
+ };
138
+ /**
139
+ * The `defaults` argument `consentGranted` takes, built from an org's
140
+ * purpose rows. Every surface that renders or gates a purpose reads those
141
+ * rows and then needs this same map, so the reshaping lives here rather
142
+ * than in each of them.
143
+ */
144
+ function consentDefaultsOf(rows) {
145
+ return Object.fromEntries(rows.map((row) => [row.key, row.defaultGranted]));
146
+ }
147
+ /**
124
148
  * The two classes of send, declared on the template rather than passed per
125
149
  * call so the class cannot drift between two sends of the same message.
126
150
  *
@@ -261,9 +285,9 @@ const searchQuerySchema = z.string().trim().max(200, "q must be at most 200 char
261
285
  * key belongs to and which routes it may reach, never anything a client
262
286
  * sends or reads. So /v1/settings/deploy-keys reuses these schemas.
263
287
  */
264
- const nameSchema$4 = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
288
+ const nameSchema$3 = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
265
289
  /** Body for POST /v1/settings/api-keys. */
266
- const createApiKeyBodySchema = z.object({ data: z.object({ name: nameSchema$4 }) });
290
+ const createApiKeyBodySchema = z.object({ data: z.object({ name: nameSchema$3 }) });
267
291
  /**
268
292
  * Query for GET /v1/settings/api-keys. Cursor-paginated like every list
269
293
  * endpoint; the cursor is an opaque offset cursor (Clerk pages by
@@ -3295,6 +3319,44 @@ const insertCatalogEventSchema = createInsertSchema(catalogEvents);
3295
3319
  const selectCatalogTraitSchema = createSelectSchema(catalogTraits);
3296
3320
  const insertCatalogTraitSchema = createInsertSchema(catalogTraits);
3297
3321
 
3322
+ //#endregion
3323
+ //#region ../../packages/db/src/schema/consent-purposes.ts
3324
+ /**
3325
+ * The consent purposes an org's profiles answer: the two fixed ones, seeded
3326
+ * the first time the set is read, plus whatever the org's projects declare
3327
+ * in their `cow.json`. One read here returns the whole set, so nothing
3328
+ * downstream unions a table with a constant.
3329
+ *
3330
+ * Org-wide rather than per-project or per-environment: `profiles.consent` is
3331
+ * one jsonb map per profile, so an answer given under one project is the
3332
+ * same answer under the next, and two projects declaring one key must agree
3333
+ * on its label and default or the deploy is refused.
3334
+ *
3335
+ * A deploy adds and updates rows and never deletes one (ADR 0009): profiles
3336
+ * already hold answers against a purpose, and deleting it would orphan them.
3337
+ */
3338
+ const consentPurposes = pgTable("consent_purposes", {
3339
+ orgId: text("org_id").notNull(),
3340
+ /** camelCase, and a key in every profile's `consent` map. */
3341
+ key: text("key").notNull(),
3342
+ /** What the dashboard renders beside the switch. */
3343
+ label: text("label").notNull(),
3344
+ /**
3345
+ * What the purpose means for a profile whose map does not answer it. A
3346
+ * declared purpose is marketing-class and denied by default: it is
3347
+ * absent on every profile that already exists.
3348
+ */
3349
+ defaultGranted: boolean("default_granted").notNull(),
3350
+ /**
3351
+ * The project whose `cow.json` declared it, and the one a disagreeing
3352
+ * deploy is refused in the name of. Null for the two fixed purposes,
3353
+ * which every org has and no project owns.
3354
+ */
3355
+ projectId: text("project_id"),
3356
+ createdAt: createdAt(),
3357
+ updatedAt: updatedAt()
3358
+ }, (table) => [primaryKey({ columns: [table.orgId, table.key] })]);
3359
+
3298
3360
  //#endregion
3299
3361
  //#region ../../packages/db/src/schema/deliveries.ts
3300
3362
  /**
@@ -3338,6 +3400,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3338
3400
  "skipped_quota",
3339
3401
  "skipped_frequency_cap",
3340
3402
  "skipped_consent",
3403
+ "skipped_sender",
3341
3404
  "skipped_domain",
3342
3405
  "skipped_ssrf",
3343
3406
  "would_send",
@@ -3347,6 +3410,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3347
3410
  "would_skip_quota",
3348
3411
  "would_skip_frequency_cap",
3349
3412
  "would_skip_consent",
3413
+ "would_skip_sender",
3350
3414
  "would_skip_domain",
3351
3415
  "would_skip_ssrf"
3352
3416
  ]);
@@ -3664,9 +3728,11 @@ const idempotencyKeys = pgTable("idempotency_keys", {
3664
3728
  * creating one when none is known.
3665
3729
  *
3666
3730
  * traits is the merged trait bag (RFC 7386 key-level merge on write).
3667
- * consent is the fixed-purpose consent map from the spec ({ emailMarketing,
3668
- * dataProcessing }, both default true); writes never touch it except the
3669
- * consent editor and the automatic revocations.
3731
+ * consent is the fixed-purpose consent map from the spec. Marketing starts
3732
+ * denied and data processing granted: nobody is subscribed by the act of
3733
+ * being ingested, while the processing the product runs on is the basis the
3734
+ * profile exists under at all. It is written by the identify path (a caller
3735
+ * passing `consent`), the consent editor, and the automatic revocations.
3670
3736
  *
3671
3737
  * `environment` is the app's, stamped at creation and never changed: a
3672
3738
  * person in development and a person in production are two rows even when
@@ -3690,7 +3756,7 @@ const profiles = pgTable("profiles", {
3690
3756
  sourceId: text("source_id").notNull(),
3691
3757
  traits: jsonb("traits").$type().notNull().default({}),
3692
3758
  consent: jsonb("consent").$type().notNull().default({
3693
- emailMarketing: true,
3759
+ emailMarketing: false,
3694
3760
  dataProcessing: true
3695
3761
  }),
3696
3762
  mergedInto: text("merged_into"),
@@ -3796,12 +3862,6 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3796
3862
  //#endregion
3797
3863
  //#region ../../packages/db/src/schema/journeys.ts
3798
3864
  /**
3799
- * The fixed consent purposes from the spec (fixed enum for the prototype).
3800
- * The literal list mirrors CONSENT_PURPOSES in packages/shared; db cannot
3801
- * import it without closing a package cycle (shared -> db).
3802
- */
3803
- const consentPurposeEnum = pgEnum("consent_purpose", ["emailMarketing", "dataProcessing"]);
3804
- /**
3805
3865
  * Derived journey rows: one per (org, environment, key), upserted from the
3806
3866
  * manifest inside the deploy transaction. Nothing here is authored through
3807
3867
  * the API, which is why there is no id of its own: the key is the name the
@@ -3835,7 +3895,14 @@ const journeys$1 = pgTable("journeys", {
3835
3895
  /** The author's labels, from the manifest; the dashboard's only grouping. */
3836
3896
  tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
3837
3897
  trigger: jsonb("trigger").$type().notNull(),
3838
- purpose: consentPurposeEnum("purpose").notNull(),
3898
+ /**
3899
+ * The consent purpose this journey's sends are gated on: one of the two
3900
+ * fixed keys or one the project declares in `cow.json`. Text and not an
3901
+ * enum, because the set is the org's `consent_purposes` rows, which a
3902
+ * deploy checks the key against; a database enum could only ever hold
3903
+ * the fixed pair.
3904
+ */
3905
+ purpose: text("purpose").notNull(),
3839
3906
  /** The manifest's `environments` contains this environment. */
3840
3907
  active: boolean("active").notNull(),
3841
3908
  spine: jsonb("spine").$type().notNull(),
@@ -3929,6 +3996,19 @@ const orgSettings = pgTable("org_settings", {
3929
3996
  mode: "date",
3930
3997
  precision: 3
3931
3998
  }),
3999
+ /**
4000
+ * When this org's SES tenant was created and fully associated. Null means
4001
+ * it has none yet, and a send goes out untenanted; set means the send names
4002
+ * the tenant, so SES meters that org's reputation on its own and keeps its
4003
+ * suppressed addresses off every other org's list. The tenant's name is the
4004
+ * org id. Written last by ensureOrgTenant, never before the associations:
4005
+ * a tenant SES cannot send for must not look ready here.
4006
+ */
4007
+ sesTenantAt: timestamp("ses_tenant_at", {
4008
+ withTimezone: true,
4009
+ mode: "date",
4010
+ precision: 3
4011
+ }),
3932
4012
  createdAt: createdAt(),
3933
4013
  updatedAt: updatedAt()
3934
4014
  });
@@ -4056,8 +4136,15 @@ const releaseStatusEnum = pgEnum("release_status", [
4056
4136
  * it doubles as the tenancy key.
4057
4137
  *
4058
4138
  * Immutable except for the compile result, which is why there is no
4059
- * `updatedAt`: `compiledAt` and `error` are the only fields that ever move,
4060
- * and they move exactly once.
4139
+ * `updatedAt`: `compiledAt`, `error` and `compiledEntries` are the only
4140
+ * fields that ever move. The first two move exactly once; `compiledEntries`
4141
+ * climbs while the compile runs, because a push is minutes of silence
4142
+ * otherwise and the manifest's own length is the denominator.
4143
+ *
4144
+ * A counter rather than a row per entry: the question both the CLI and the
4145
+ * dashboard ask is "how far along", which one integer answers at a fraction
4146
+ * of the write volume. Naming which entry is compiling needs per-entry rows
4147
+ * and is a different question.
4061
4148
  */
4062
4149
  const releases$1 = pgTable("releases", {
4063
4150
  id: text("id").primaryKey(),
@@ -4075,7 +4162,9 @@ const releases$1 = pgTable("releases", {
4075
4162
  mode: "date",
4076
4163
  precision: 3
4077
4164
  }),
4078
- error: text("error")
4165
+ error: text("error"),
4166
+ /** How many manifest entries have compiled and passed their checks. */
4167
+ compiledEntries: integer("compiled_entries").notNull().default(0)
4079
4168
  }, (table) => [uniqueIndex("releases_org_id_project_id_seq_unique").on(table.orgId, table.projectId, table.seq), index("releases_org_id_created_at_idx").on(table.orgId, table.createdAt)]);
4080
4169
  const selectReleaseSchema = createSelectSchema(releases$1);
4081
4170
  const insertReleaseSchema = createInsertSchema(releases$1);
@@ -4457,14 +4546,14 @@ const appSchema = selectAppSchema.extend({
4457
4546
  /** The most recent accepted delivery across those sources. */
4458
4547
  lastReceivedAt: z.iso.datetime().nullable()
4459
4548
  });
4460
- const nameSchema$3 = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
4549
+ const nameSchema$2 = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
4461
4550
  const createAppBodySchema = z.object({ data: z.object({
4462
- name: nameSchema$3,
4551
+ name: nameSchema$2,
4463
4552
  /** Immutable after creation; production when omitted. */
4464
4553
  environment: environmentSchema.default("production")
4465
4554
  }) });
4466
4555
  const updateAppBodySchema = z.object({ data: z.object({
4467
- name: nameSchema$3.optional(),
4556
+ name: nameSchema$2.optional(),
4468
4557
  status: z.literal("archived").optional()
4469
4558
  }).refine((data) => data.name !== void 0 || data.status !== void 0, { message: "at least one field (name or status) is required" }) });
4470
4559
  /** `q` is a substring search over the app's name; `status` narrows to one state. */
@@ -4565,6 +4654,269 @@ const identifierDtoSchema = z.object({
4565
4654
  createdAt: z.iso.datetime()
4566
4655
  });
4567
4656
 
4657
+ //#endregion
4658
+ //#region ../../packages/shared/src/journeys-v2/manifest.ts
4659
+ /**
4660
+ * The release manifest (spec: Build; Push and compile): what `cow build`
4661
+ * extracts from a project and `cow push` uploads with the bundles. The
4662
+ * server validates it with these schemas, compiles every bundle, and stores
4663
+ * the compiled form on the release row.
4664
+ */
4665
+ /**
4666
+ * A journey or template key: the file basename under `journeys/` or
4667
+ * `emails/`, kebab-case and unique across the project. Becomes part of the
4668
+ * Temporal workflow id and travels in the journey chain, so it stays short.
4669
+ */
4670
+ const JOURNEY_KEY_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
4671
+ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must be kebab-case (a-z, 0-9, hyphens)");
4672
+ /**
4673
+ * An author's labels on a journey or a template: how the dashboard groups
4674
+ * and filters them, and the only grouping there is. Case is kept as
4675
+ * written, each entry is trimmed and non-empty, the list is deduplicated,
4676
+ * and both ceilings are low on purpose: tags are a handful of words, not a
4677
+ * taxonomy. Absent means `[]`.
4678
+ *
4679
+ * The count is capped on the list as written, before the deduplication, so
4680
+ * a 21st entry is an error even when it is a repeat: that keeps `maxItems`
4681
+ * in the generated JSON Schema, and an author who wrote 21 tags wants to
4682
+ * hear about it.
4683
+ */
4684
+ const tagsSchema = z.array(z.string().trim().min(1).max(50, "a tag must be at most 50 characters")).max(20, "a journey or template takes at most 20 tags").default([]).transform((tags) => [...new Set(tags)]);
4685
+ /**
4686
+ * A consent purpose key: camelCase, matching the fixed `emailMarketing` and
4687
+ * `dataProcessing`. Purposes are keys in the `consent` map a customer reads
4688
+ * on their own profile, which is why they are not the kebab-case of a
4689
+ * journey key.
4690
+ */
4691
+ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
4692
+ /**
4693
+ * One purpose key wherever a key is named rather than declared: a journey's
4694
+ * `purpose`, and the keys of the consent patch an identify carries. The set
4695
+ * a key is checked against is the org's declared rows, which no schema can
4696
+ * see, so validation here is the shape only; the deploy and the ingestion
4697
+ * write refuse a key the org has not declared.
4698
+ */
4699
+ const consentPurposeKeySchema = z.string().max(50, "a purpose key must be at most 50 characters").regex(CONSENT_PURPOSE_KEY_PATTERN, "a consent purpose key must be camelCase (a letter first, then letters and digits)");
4700
+ /**
4701
+ * One purpose a project declares in `cow.json` (spec: Decisions). A declared
4702
+ * purpose is marketing-class and sits under the `emailMarketing` umbrella,
4703
+ * so `denied` is the only default it may carry: the purpose is absent on
4704
+ * every profile that already exists, and a granted default would answer for
4705
+ * all of them at once.
4706
+ *
4707
+ * The field stays required rather than disappearing, so every `cow.json` and
4708
+ * every stored manifest written before this still parses, and the column
4709
+ * behind it still holds `granted` for the seeded `dataProcessing` row: this
4710
+ * is a refusal at declaration time, not a narrower storage shape.
4711
+ *
4712
+ * The two fixed purposes cannot be declared. They are seeded for every org
4713
+ * and owned by no project, so a project redeclaring one would be renaming
4714
+ * the master switch every other project's journeys hang off.
4715
+ */
4716
+ const declaredPurposeSchema = z.strictObject({
4717
+ key: consentPurposeKeySchema.refine((key) => !CONSENT_PURPOSES.includes(key), `${CONSENT_PURPOSES.join(" and ")} are fixed purposes and cannot be declared`),
4718
+ /** What the dashboard and the account modal render beside the switch. */
4719
+ label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
4720
+ /** What the purpose means for a profile whose map does not answer it. */
4721
+ default: z.literal("denied", "a declared purpose's \"default\" must be \"denied\": nobody has answered it yet, and a granted default would opt every profile you already have into it")
4722
+ });
4723
+ /**
4724
+ * The purposes one project declares. Capped low the way tags are: a purpose
4725
+ * is a category a recipient reads on a preferences switch, not a taxonomy.
4726
+ * Absent means the project declares none, which is every project today.
4727
+ */
4728
+ const purposesSchema = z.array(declaredPurposeSchema).max(20, "a project declares at most 20 consent purposes");
4729
+ /**
4730
+ * A matcher field: one pattern or a non-empty list of them, a list being a
4731
+ * disjunction. See `matchesPattern` in ../patterns for the dialect (`*`
4732
+ * only) and for why a pattern whose literal prefix is not `system.` never
4733
+ * reaches a system event.
4734
+ */
4735
+ const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 200 characters");
4736
+ const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
4737
+ /**
4738
+ * What starts a journey: an event (optionally narrowed to an app id or a
4739
+ * list of them) or a segment entry. The registry DTO in `../journeys`
4740
+ * reuses it.
4741
+ *
4742
+ * Both members are strict, so a journey holding the retired `source` key
4743
+ * fails to compile a release instead of silently triggering on every app.
4744
+ * There is deliberately no pipe filter: a trigger narrows by the app the
4745
+ * write is attributed to, the same token a segment definition names.
4746
+ */
4747
+ const triggerSchema = z.union([z.strictObject({
4748
+ event: patternSchema,
4749
+ appId: patternSchema.optional()
4750
+ }), z.strictObject({ segment: z.string().min(1) })]);
4751
+ /**
4752
+ * A destination's name: the token a journey addresses it by, in a
4753
+ * `send.webhook` call or a journey's `senderIdentity`.
4754
+ *
4755
+ * Defined here rather than beside the destinations contract, and imported
4756
+ * from here by it, because a journey manifest names one and the guest layer
4757
+ * is bundled into every tenant module: importing it the other way round
4758
+ * would pull the Drizzle destinations table into all of them.
4759
+ */
4760
+ const destinationNameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
4761
+ /** A content address: `sha256:` plus the lowercase hex digest. */
4762
+ const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "digest must be sha256:<64 hex>");
4763
+ /**
4764
+ * The names of the capability calls a journey may make. Grouped by verb and
4765
+ * then channel (`send.email`, not `email.send`), because the verb is the
4766
+ * thing a journey author is choosing between and the name should read the
4767
+ * way the code is written.
4768
+ */
4769
+ const COMMAND_NAMES = [
4770
+ "sleep",
4771
+ "waitForEvent",
4772
+ "send.email",
4773
+ "send.webhook",
4774
+ "traits.set",
4775
+ "traits.unset",
4776
+ "profile.get",
4777
+ "profiles.get",
4778
+ "events.track",
4779
+ "restart"
4780
+ ];
4781
+ /**
4782
+ * What a spine entry can be: every capability call, plus the control flow
4783
+ * `cow build` reads off the journey's source: an `if` (a condition, with
4784
+ * `steps` and `otherwise`), a `loop` (with `steps` as its body), and the `end`
4785
+ * of a path.
4786
+ */
4787
+ const SPINE_ENTRY_NAMES = [
4788
+ ...COMMAND_NAMES,
4789
+ "if",
4790
+ "loop",
4791
+ "end"
4792
+ ];
4793
+ const spineEntrySchema = z.object({
4794
+ name: z.enum(SPINE_ENTRY_NAMES),
4795
+ detail: z.string().max(200).optional(),
4796
+ /**
4797
+ * The sender identity a `send.email` call named for itself, overriding
4798
+ * the journey's own. Present only when the author wrote one on the call,
4799
+ * which is what lets the deploy warning and the journey detail page name
4800
+ * the override without re-reading the code.
4801
+ */
4802
+ senderIdentity: z.string().max(100).optional(),
4803
+ /** A `waitForEvent` timeout, as the author wrote it. */
4804
+ timeout: z.string().max(50).optional(),
4805
+ get steps() {
4806
+ return z.array(spineEntrySchema).optional();
4807
+ },
4808
+ get otherwise() {
4809
+ return z.array(spineEntrySchema).optional();
4810
+ }
4811
+ }).meta({ id: "JourneySpineEntry" });
4812
+ const manifestJourneySchema = z.object({
4813
+ key: journeyKeySchema,
4814
+ /** The author's labels; the dashboard's only grouping. */
4815
+ tags: tagsSchema,
4816
+ trigger: triggerSchema,
4817
+ /**
4818
+ * A fixed purpose or one the project declares. Checked against the org's
4819
+ * declared set at deploy time, not here: a manifest is built and pushed
4820
+ * without ever reaching the org whose rows say what exists.
4821
+ */
4822
+ purpose: consentPurposeKeySchema,
4823
+ /**
4824
+ * The sender identity every `send.email` in this journey goes out as,
4825
+ * unless the call names its own. A name, never a `dst_` id: an org has one
4826
+ * row per environment, so an id would send in production and fail in
4827
+ * development, which is the one thing a journey must not do.
4828
+ *
4829
+ * Optional here and required at `defineJourney`, exactly like `purposes`: a
4830
+ * release pushed before the field existed carries none and its stored
4831
+ * manifest still parses. The author's build is where the error is useful.
4832
+ */
4833
+ senderIdentity: destinationNameSchema.optional(),
4834
+ /** The author's rollout gate: the journey is active only in these. */
4835
+ environments: environmentsSchema,
4836
+ spine: z.array(spineEntrySchema),
4837
+ bundle: digestSchema
4838
+ });
4839
+ const manifestTemplateSchema = z.object({
4840
+ key: journeyKeySchema,
4841
+ /** The author's labels; the dashboard's only grouping. */
4842
+ tags: tagsSchema,
4843
+ sendClass: z.enum(SEND_CLASSES),
4844
+ /** True asks the host to mint a signed `verifyUrl` prop at send time. */
4845
+ verifyLink: z.boolean(),
4846
+ /** JSON Schema of the template's `props`, converted by `cow build`. */
4847
+ propsSchema: z.record(z.string(), z.unknown()),
4848
+ bundle: digestSchema
4849
+ });
4850
+ function uniqueKeys(items, ctx, path) {
4851
+ const seen = /* @__PURE__ */ new Set();
4852
+ for (const [index, item] of items.entries()) {
4853
+ if (seen.has(item.key)) ctx.addIssue({
4854
+ code: "custom",
4855
+ message: `duplicate ${path} key "${item.key}"`,
4856
+ path: [
4857
+ path,
4858
+ index,
4859
+ "key"
4860
+ ]
4861
+ });
4862
+ seen.add(item.key);
4863
+ }
4864
+ }
4865
+ /**
4866
+ * What `cow build` writes to `.cow/build/manifest.json`, and the shape a
4867
+ * release row stores for good.
4868
+ *
4869
+ * `protocol` is any positive integer rather than the current constant on
4870
+ * purpose: a release is immutable and an execution stays pinned to the one it
4871
+ * started on, so the day the protocol is bumped every stored release must
4872
+ * still parse, or the runner, the deploy, and every API response the client
4873
+ * validates all break at once for any org with history. The literal lives at
4874
+ * push time only (`createReleaseBodySchema`), which is where a stale CLI is
4875
+ * the developer's own fixable problem, and deploy plus the runner refuse a
4876
+ * release built for another protocol with a message naming both numbers.
4877
+ */
4878
+ const manifestSchema = z.object({
4879
+ protocol: z.number().int().positive(),
4880
+ /** The `@cowliss/cli` version the project was built with. */
4881
+ sdk: z.string().min(1),
4882
+ journeys: z.array(manifestJourneySchema).max(RELEASE_LIMITS.journeys),
4883
+ templates: z.array(manifestTemplateSchema).max(RELEASE_LIMITS.templates),
4884
+ /**
4885
+ * The consent purposes this project declares, copied from `cow.json`.
4886
+ * Optional rather than defaulted: a release pushed before purposes
4887
+ * existed carries none, and its stored manifest still parses.
4888
+ */
4889
+ purposes: purposesSchema.optional(),
4890
+ /** Digest of the gzipped source tarball. */
4891
+ source: digestSchema
4892
+ }).superRefine((manifest, ctx) => {
4893
+ uniqueKeys(manifest.journeys, ctx, "journeys");
4894
+ uniqueKeys(manifest.templates, ctx, "templates");
4895
+ uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
4896
+ });
4897
+ /**
4898
+ * The manifest as the release row stores it once compilation succeeded:
4899
+ * the pushed manifest plus the compiled module digest per key, and the
4900
+ * digest of the Javy engine plugin the toolchain that compiled them was
4901
+ * built from.
4902
+ *
4903
+ * Journeys and templates are keyed separately because they share a key
4904
+ * space: `welcome.ts` and `welcome.tsx` are one journey and the email it
4905
+ * sends in every example, and a flat map would let one overwrite the other.
4906
+ *
4907
+ * `plugin` is a toolchain record, not a linked artifact: modules are
4908
+ * statically linked, so the plugin bytes are inside each module. It says
4909
+ * which engine compiled the release, which is what a later bug report or a
4910
+ * reproducible rebuild needs.
4911
+ */
4912
+ const compiledManifestSchema = manifestSchema.safeExtend({
4913
+ modules: z.object({
4914
+ journeys: z.record(journeyKeySchema, digestSchema),
4915
+ templates: z.record(journeyKeySchema, digestSchema)
4916
+ }),
4917
+ plugin: digestSchema
4918
+ });
4919
+
4568
4920
  //#endregion
4569
4921
  //#region ../../packages/shared/src/timestamp.ts
4570
4922
  /**
@@ -4606,6 +4958,21 @@ const traitBagSchema = z.custom((value) => value !== null && typeof value === "o
4606
4958
  additionalProperties: true
4607
4959
  });
4608
4960
  /**
4961
+ * A partial map over the org's consent purposes, applied as an RFC 7386
4962
+ * merge patch, so omitting a purpose leaves it as it was. At least one
4963
+ * purpose is required: an empty patch is a no-op request, not a valid one.
4964
+ *
4965
+ * The key schema is the shape only, because purposes are declared per org
4966
+ * and no schema can see which. It is still a trust boundary: a key that is
4967
+ * not a purpose key never reaches the jsonb map, and the ingestion write
4968
+ * refuses the ones this org has not declared.
4969
+ *
4970
+ * It lives here rather than beside the dashboard's consent editor because
4971
+ * `identify` carries it too: the grant has to have a route in through
4972
+ * ingestion, or the only thing anyone can express is the revocation.
4973
+ */
4974
+ const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [EMAIL_MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
4975
+ /**
4609
4976
  * The identify payload fields, shared between the single-call body schema
4610
4977
  * and the batch item schema (which drops `sourceId`: a batch names one
4611
4978
  * source for every item) so both wire shapes stay in sync.
@@ -4617,6 +4984,7 @@ const identifyFieldsSchema = z.object({
4617
4984
  sourceId: z.string().min(1, "sourceId is required"),
4618
4985
  identifiers: identifiersSchema,
4619
4986
  traits: traitBagSchema.default({}),
4987
+ consent: consentPatchSchema.optional(),
4620
4988
  timestamp: z.iso.datetime().optional(),
4621
4989
  messageId: z.string().optional()
4622
4990
  });
@@ -4936,7 +5304,7 @@ function propertyTypeLabel(type) {
4936
5304
  * in its trigger unless it is handed one.
4937
5305
  */
4938
5306
  const PROPERTY_TYPE_LABELS = Object.fromEntries(PROPERTY_TYPES.map((type) => [type, propertyTypeLabel(type)]));
4939
- const nameSchema$2 = z.string().trim().min(1, "name is required").max(200, "name must be at most 200 characters");
5307
+ const nameSchema$1 = z.string().trim().min(1, "name is required").max(200, "name must be at most 200 characters");
4940
5308
  const propertiesSchema = z.record(z.string().min(1).max(200), propertyTypeSchema);
4941
5309
  const catalogEventSchema = selectCatalogEventSchema.extend({
4942
5310
  properties: propertiesSchema,
@@ -4944,7 +5312,7 @@ const catalogEventSchema = selectCatalogEventSchema.extend({
4944
5312
  updatedAt: z.iso.datetime()
4945
5313
  });
4946
5314
  const createCatalogEventBodySchema = z.object({ data: z.object({
4947
- name: nameSchema$2,
5315
+ name: nameSchema$1,
4948
5316
  properties: propertiesSchema.default({})
4949
5317
  }) });
4950
5318
  /**
@@ -4959,7 +5327,7 @@ const catalogTraitSchema = selectCatalogTraitSchema.extend({
4959
5327
  updatedAt: z.iso.datetime()
4960
5328
  });
4961
5329
  const createCatalogTraitBodySchema = z.object({ data: z.object({
4962
- name: nameSchema$2,
5330
+ name: nameSchema$1,
4963
5331
  type: propertyTypeSchema
4964
5332
  }) });
4965
5333
  /**
@@ -5013,6 +5381,28 @@ const quarantineActionBodySchema = z.object({ data: z.object({
5013
5381
  name: z.string().min(1)
5014
5382
  }) });
5015
5383
 
5384
+ //#endregion
5385
+ //#region ../../packages/shared/src/consent.ts
5386
+ /**
5387
+ * One consent purpose as an org holds it. Every surface that renders a
5388
+ * purpose reads these rows rather than a constant: a purpose a project
5389
+ * declared in `cow.json` exists only as a row, so a label kept anywhere else
5390
+ * would answer nothing for it.
5391
+ */
5392
+ const consentPurposeSchema = z.object({
5393
+ key: consentPurposeKeySchema,
5394
+ /** What a switch is labelled with, from the project that declared it. */
5395
+ label: z.string(),
5396
+ /** What the purpose means for a profile whose map does not answer it. */
5397
+ defaultGranted: z.boolean()
5398
+ });
5399
+ /**
5400
+ * Query for GET /v1/consent-purposes. Cursor-paginated like every list
5401
+ * endpoint, even though an org holds a handful: the page shape is the
5402
+ * convention, not an estimate of how many rows there are.
5403
+ */
5404
+ const listConsentPurposesQuerySchema = paginationQuerySchema;
5405
+
5016
5406
  //#endregion
5017
5407
  //#region ../../packages/shared/src/delivery.ts
5018
5408
  /**
@@ -5188,8 +5578,9 @@ const destinationSchema = selectDestinationSchema.extend({
5188
5578
  signingSecret: true,
5189
5579
  consecutiveFailures: true
5190
5580
  });
5581
+ /** The two kinds of destination, from the table's own enum. */
5582
+ const destinationTypeSchema = destinationSchema.shape.type;
5191
5583
  const destinationCreatedSchema = destinationSchema.extend({ signingSecret: z.string().optional() });
5192
- const nameSchema$1 = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
5193
5584
  const webhookConfigSchema = z.object({ url: z.url("config.url must be a valid URL").refine((url) => {
5194
5585
  try {
5195
5586
  return ["http:", "https:"].includes(new URL(url).protocol);
@@ -5214,14 +5605,14 @@ const senderIdentityConfigSchema = z.object({
5214
5605
  * what gives react-hook-form a non-union field path to register.
5215
5606
  */
5216
5607
  const webhookDestinationInputSchema = z.object({
5217
- name: nameSchema$1,
5608
+ name: destinationNameSchema,
5218
5609
  /** The environment this destination lives in; immutable after creation. */
5219
5610
  environment: environmentSchema,
5220
5611
  type: z.literal("webhook"),
5221
5612
  config: webhookConfigSchema
5222
5613
  });
5223
5614
  const senderIdentityDestinationInputSchema = z.object({
5224
- name: nameSchema$1,
5615
+ name: destinationNameSchema,
5225
5616
  /** The environment this destination lives in; immutable after creation. */
5226
5617
  environment: environmentSchema,
5227
5618
  type: z.literal("sender_identity"),
@@ -5238,12 +5629,19 @@ const createDestinationBodySchema = z.object({ data: z.discriminatedUnion("type"
5238
5629
  * `enabled: false` is the same switch operated by hand.
5239
5630
  */
5240
5631
  const updateDestinationBodySchema = z.object({ data: z.object({
5241
- name: nameSchema$1.optional(),
5632
+ name: destinationNameSchema.optional(),
5242
5633
  config: z.unknown().optional(),
5243
5634
  enabled: z.boolean().optional()
5244
5635
  }).refine((data) => data.name !== void 0 || data.config !== void 0 || data.enabled !== void 0, { message: "at least one field (name, config, or enabled) is required" }) });
5245
- /** `q` is a substring search over the destination's name. */
5246
- const listDestinationsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
5636
+ /**
5637
+ * `q` is a substring search over the destination's name; `type` narrows to
5638
+ * one kind, because the webhooks page and the sender identities section on
5639
+ * the domains page each want one and the table holds both.
5640
+ */
5641
+ const listDestinationsQuerySchema = paginationQuerySchema.extend({
5642
+ q: searchQuerySchema,
5643
+ type: destinationTypeSchema.optional()
5644
+ });
5247
5645
 
5248
5646
  //#endregion
5249
5647
  //#region ../../packages/shared/src/domains.ts
@@ -5294,6 +5692,16 @@ const createSenderDomainBodySchema = z.object({ data: senderDomainInputSchema })
5294
5692
  const updateSenderDomainBodySchema = z.object({ data: z.object({ clickTracking: z.boolean() }) });
5295
5693
  /** `q` is a substring search over the domain string. */
5296
5694
  const listSenderDomainsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
5695
+ /**
5696
+ * Where the one-click DNS setup sends the admin, or null when their DNS
5697
+ * provider does not offer it. Computed per request (it costs a DNS lookup
5698
+ * and two calls to the provider), never stored.
5699
+ */
5700
+ const domainDnsSetupSchema = z.object({
5701
+ url: z.url().nullable(),
5702
+ /** The DNS provider's own name for itself, for the button's label. */
5703
+ provider: z.string().nullable()
5704
+ });
5297
5705
 
5298
5706
  //#endregion
5299
5707
  //#region ../../packages/shared/src/patterns.ts
@@ -5378,172 +5786,24 @@ function patternPlaceholder(pattern) {
5378
5786
  }
5379
5787
 
5380
5788
  //#endregion
5381
- //#region ../../packages/shared/src/journeys-v2/manifest.ts
5789
+ //#region ../../packages/shared/src/journeys-v2/guest.ts
5382
5790
  /**
5383
- * The release manifest (spec: Build; Push and compile): what `cow build`
5384
- * extracts from a project and `cow push` uploads with the bundles. The
5385
- * server validates it with these schemas, compiles every bundle, and stores
5386
- * the compiled form on the release row.
5791
+ * The guest protocol (spec: Guest protocol): the JSON a compiled module
5792
+ * reads on stdin and writes on stdout. The sandbox worker parses every byte
5793
+ * a guest returns with these schemas before anything acts on it; the guest
5794
+ * SDK and the Node simulator produce and consume the same shapes.
5387
5795
  */
5388
- /**
5389
- * A journey or template key: the file basename under `journeys/` or
5390
- * `emails/`, kebab-case and unique across the project. Becomes part of the
5391
- * Temporal workflow id and travels in the journey chain, so it stays short.
5392
- */
5393
- const JOURNEY_KEY_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
5394
- const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must be kebab-case (a-z, 0-9, hyphens)");
5395
- /**
5396
- * An author's labels on a journey or a template: how the dashboard groups
5397
- * and filters them, and the only grouping there is. Case is kept as
5398
- * written, each entry is trimmed and non-empty, the list is deduplicated,
5399
- * and both ceilings are low on purpose: tags are a handful of words, not a
5400
- * taxonomy. Absent means `[]`.
5401
- *
5402
- * The count is capped on the list as written, before the deduplication, so
5403
- * a 21st entry is an error even when it is a repeat: that keeps `maxItems`
5404
- * in the generated JSON Schema, and an author who wrote 21 tags wants to
5405
- * hear about it.
5406
- */
5407
- const tagsSchema = z.array(z.string().trim().min(1).max(50, "a tag must be at most 50 characters")).max(20, "a journey or template takes at most 20 tags").default([]).transform((tags) => [...new Set(tags)]);
5408
- /**
5409
- * A matcher field: one pattern or a non-empty list of them, a list being a
5410
- * disjunction. See `matchesPattern` in ../patterns for the dialect (`*`
5411
- * only) and for why a pattern whose literal prefix is not `system.` never
5412
- * reaches a system event.
5413
- */
5414
- const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 200 characters");
5415
- const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
5416
- /**
5417
- * What starts a journey: an event (optionally narrowed to an app id or a
5418
- * list of them) or a segment entry. The registry DTO in `../journeys`
5419
- * reuses it.
5420
- *
5421
- * Both members are strict, so a journey holding the retired `source` key
5422
- * fails to compile a release instead of silently triggering on every app.
5423
- * There is deliberately no pipe filter: a trigger narrows by the app the
5424
- * write is attributed to, the same token a segment definition names.
5425
- */
5426
- const triggerSchema = z.union([z.strictObject({
5427
- event: patternSchema,
5428
- appId: patternSchema.optional()
5429
- }), z.strictObject({ segment: z.string().min(1) })]);
5430
- /** A content address: `sha256:` plus the lowercase hex digest. */
5431
- const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "digest must be sha256:<64 hex>");
5432
- /** The names of the capability calls a journey may make. */
5433
- const COMMAND_NAMES = [
5434
- "sleep",
5435
- "waitForEvent",
5436
- "email.send",
5437
- "webhook.send",
5438
- "traits.set",
5439
- "traits.unset",
5440
- "profile.get",
5441
- "profiles.get",
5442
- "events.track",
5443
- "restart"
5444
- ];
5445
- /**
5446
- * One entry of the step spine `cow build` records by running `run` once
5447
- * against a recording stub. Display only, never trusted: a journey's real
5448
- * control flow is whatever its code does at runtime. `detail` names the
5449
- * template, destination, event, or trait key when the call has one.
5450
- */
5451
- const spineEntrySchema = z.object({
5452
- name: z.enum(COMMAND_NAMES),
5453
- detail: z.string().max(200).optional()
5454
- });
5455
- const manifestJourneySchema = z.object({
5456
- key: journeyKeySchema,
5457
- /** The author's labels; the dashboard's only grouping. */
5458
- tags: tagsSchema,
5459
- trigger: triggerSchema,
5460
- purpose: z.enum(CONSENT_PURPOSES),
5461
- /** The author's rollout gate: the journey is active only in these. */
5462
- environments: environmentsSchema,
5463
- spine: z.array(spineEntrySchema),
5464
- bundle: digestSchema
5465
- });
5466
- const manifestTemplateSchema = z.object({
5467
- key: journeyKeySchema,
5468
- /** The author's labels; the dashboard's only grouping. */
5469
- tags: tagsSchema,
5470
- sendClass: z.enum(SEND_CLASSES),
5471
- /** True asks the host to mint a signed `verifyUrl` prop at send time. */
5472
- verifyLink: z.boolean(),
5473
- /** JSON Schema of the template's `props`, converted by `cow build`. */
5474
- propsSchema: z.record(z.string(), z.unknown()),
5475
- bundle: digestSchema
5476
- });
5477
- function uniqueKeys(items, ctx, path) {
5478
- const seen = /* @__PURE__ */ new Set();
5479
- for (const [index, item] of items.entries()) {
5480
- if (seen.has(item.key)) ctx.addIssue({
5481
- code: "custom",
5482
- message: `duplicate ${path} key "${item.key}"`,
5483
- path: [
5484
- path,
5485
- index,
5486
- "key"
5487
- ]
5488
- });
5489
- seen.add(item.key);
5490
- }
5491
- }
5492
- /** What `cow build` writes to `.cow/build/manifest.json` and `cow push` sends. */
5493
- const manifestSchema = z.object({
5494
- protocol: z.literal(1),
5495
- /** The `@cowliss/cli` version the project was built with. */
5496
- sdk: z.string().min(1),
5497
- journeys: z.array(manifestJourneySchema).max(RELEASE_LIMITS.journeys),
5498
- templates: z.array(manifestTemplateSchema).max(RELEASE_LIMITS.templates),
5499
- /** Digest of the gzipped source tarball. */
5500
- source: digestSchema
5501
- }).superRefine((manifest, ctx) => {
5502
- uniqueKeys(manifest.journeys, ctx, "journeys");
5503
- uniqueKeys(manifest.templates, ctx, "templates");
5504
- });
5505
- /**
5506
- * The manifest as the release row stores it once compilation succeeded:
5507
- * the pushed manifest plus the compiled module digest per key, and the
5508
- * digest of the Javy engine plugin the toolchain that compiled them was
5509
- * built from.
5510
- *
5511
- * Journeys and templates are keyed separately because they share a key
5512
- * space: `welcome.ts` and `welcome.tsx` are one journey and the email it
5513
- * sends in every example, and a flat map would let one overwrite the other.
5514
- *
5515
- * `plugin` is a toolchain record, not a linked artifact: modules are
5516
- * statically linked, so the plugin bytes are inside each module. It says
5517
- * which engine compiled the release, which is what a later bug report or a
5518
- * reproducible rebuild needs.
5519
- */
5520
- const compiledManifestSchema = manifestSchema.safeExtend({
5521
- modules: z.object({
5522
- journeys: z.record(journeyKeySchema, digestSchema),
5523
- templates: z.record(journeyKeySchema, digestSchema)
5524
- }),
5525
- plugin: digestSchema
5526
- });
5527
-
5528
- //#endregion
5529
- //#region ../../packages/shared/src/journeys-v2/guest.ts
5530
- /**
5531
- * The guest protocol (spec: Guest protocol): the JSON a compiled module
5532
- * reads on stdin and writes on stdout. The sandbox worker parses every byte
5533
- * a guest returns with these schemas before anything acts on it; the guest
5534
- * SDK and the Node simulator produce and consume the same shapes.
5535
- */
5536
- /** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
5537
- const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
5538
- const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
5539
- const DURATION_UNIT_MS = {
5540
- ms: 1,
5541
- s: 1e3,
5542
- m: 6e4,
5543
- h: 36e5,
5544
- d: 864e5,
5545
- w: 6048e5
5546
- };
5796
+ /** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
5797
+ const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
5798
+ const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
5799
+ const DURATION_UNIT_MS = {
5800
+ ms: 1,
5801
+ s: 1e3,
5802
+ m: 6e4,
5803
+ h: 36e5,
5804
+ d: 864e5,
5805
+ w: 6048e5
5806
+ };
5547
5807
  /**
5548
5808
  * A duration in milliseconds. The simulator's virtual clock and the runner's
5549
5809
  * timers both need it, and neither may pull in Temporal's `msToNumber` (one
@@ -5588,14 +5848,21 @@ const commandSchema = z.discriminatedUnion("name", [
5588
5848
  })
5589
5849
  }),
5590
5850
  z.object({
5591
- name: z.literal("email.send"),
5851
+ name: z.literal("send.email"),
5592
5852
  args: z.strictObject({
5593
5853
  template: journeyKeySchema,
5594
- props: properties
5854
+ props: properties,
5855
+ /**
5856
+ * Which sender identity this mail leaves as, by name. Required, and
5857
+ * the guest SDK fills in the journey's own when the call does not name
5858
+ * one, so the host has one resolution path and never has to read the
5859
+ * manifest to find a sender.
5860
+ */
5861
+ senderIdentity: destinationNameSchema
5595
5862
  })
5596
5863
  }),
5597
5864
  z.object({
5598
- name: z.literal("webhook.send"),
5865
+ name: z.literal("send.webhook"),
5599
5866
  args: z.strictObject({
5600
5867
  destination: z.string().min(1),
5601
5868
  payload: properties
@@ -5672,7 +5939,7 @@ const executionLimitsSchema = z.object({
5672
5939
  logLineBytes: z.number().int().positive()
5673
5940
  });
5674
5941
  const journeyStepInputSchema = z.object({
5675
- protocol: z.literal(1),
5942
+ protocol: z.literal(2),
5676
5943
  kind: z.literal("journey"),
5677
5944
  key: journeyKeySchema,
5678
5945
  event: guestEventSchema,
@@ -5712,7 +5979,7 @@ const journeyStepOutputSchema = z.discriminatedUnion("status", [
5712
5979
  })
5713
5980
  ]);
5714
5981
  const templateRenderInputSchema = z.object({
5715
- protocol: z.literal(1),
5982
+ protocol: z.literal(2),
5716
5983
  kind: z.literal("template"),
5717
5984
  key: journeyKeySchema,
5718
5985
  props: properties
@@ -5731,6 +5998,7 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
5731
5998
  const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
5732
5999
  trigger: true,
5733
6000
  purpose: true,
6001
+ senderIdentity: true,
5734
6002
  environments: true,
5735
6003
  tags: true
5736
6004
  }).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
@@ -5826,14 +6094,14 @@ const listExecutionsQuerySchema = paginationQuerySchema.extend({
5826
6094
  */
5827
6095
  const journeyTriggerSchema = triggerSchema;
5828
6096
  /**
5829
- * The step spine `cow build` recorded by running the journey once against a
5830
- * recording stub. Display only, never trusted: a journey's real control flow
5831
- * is whatever its code does at run time.
6097
+ * The step spine `cow build` read from the journey's source: its calls,
6098
+ * conditions, and loops. Display only, never trusted: a journey's real
6099
+ * control flow is whatever its code does at run time.
5832
6100
  */
5833
6101
  const journeySpineEntrySchema = spineEntrySchema;
5834
6102
  const journeySchema = selectJourneySchema.extend({
5835
6103
  trigger: journeyTriggerSchema,
5836
- purpose: z.enum(CONSENT_PURPOSES),
6104
+ purpose: consentPurposeKeySchema,
5837
6105
  spine: z.array(journeySpineEntrySchema),
5838
6106
  enabled: z.boolean(),
5839
6107
  createdAt: z.iso.datetime(),
@@ -5933,8 +6201,21 @@ const cowConfigSchema = z.strictObject({
5933
6201
  * slice of the deployed journeys, so every project says which it is.
5934
6202
  */
5935
6203
  project: projectNameSchema,
6204
+ /**
6205
+ * The consent purposes this project declares. They are org-wide, so two
6206
+ * projects declaring one key must agree on its label and default or the
6207
+ * deploy is refused; a deploy adds and updates them and never deletes
6208
+ * one, because profiles hold answers against them.
6209
+ */
6210
+ purposes: purposesSchema.optional(),
5936
6211
  /** Overrides the API the CLI talks to; the hosted product needs none. */
5937
- apiUrl: z.url().optional()
6212
+ apiUrl: z.url().optional(),
6213
+ /**
6214
+ * Overrides the dashboard `cow login` opens. Paired with `apiUrl`: a
6215
+ * login against one Cowliss's dashboard yields a token the other's API
6216
+ * rejects, so a config that names an API names its dashboard too.
6217
+ */
6218
+ webUrl: z.url().optional()
5938
6219
  }).meta({
5939
6220
  title: "cow.json",
5940
6221
  description: "A cow project: the organization and the project it deploys to."
@@ -6007,6 +6288,38 @@ const journeyScenarioSchema = z.object({
6007
6288
  }))
6008
6289
  });
6009
6290
 
6291
+ //#endregion
6292
+ //#region ../../packages/shared/src/me.ts
6293
+ /**
6294
+ * The signed-in developer's own record (/v1/me), as opposed to the profiles
6295
+ * their org holds. Cowliss's developers are profiles in the platform
6296
+ * workspace, so this is the one place a person answers for themselves rather
6297
+ * than an operator answering for someone else, and it is why the routes are
6298
+ * not org-scoped: the caller's own org has nothing to do with mail Cowliss
6299
+ * sends them.
6300
+ */
6301
+ /**
6302
+ * One thing Cowliss may send, as this developer has answered it: a purpose
6303
+ * the platform workspace declares, plus their own answer to it.
6304
+ */
6305
+ const notificationPurposeSchema = consentPurposeSchema.extend({
6306
+ /** Their answer, or the purpose's default where they have not given one. */
6307
+ granted: z.boolean() });
6308
+ /**
6309
+ * What Cowliss may send this developer beyond the operational mail every
6310
+ * account gets: the marketing purposes the platform workspace declares, each
6311
+ * with the label it declared and this developer's answer. A list rather than
6312
+ * a fixed flag, because Cowliss declares its purposes in a `cow.json` like
6313
+ * any other project and may add one without touching this route.
6314
+ */
6315
+ const notificationPreferencesSchema = z.object({ purposes: z.array(notificationPurposeSchema) });
6316
+ /**
6317
+ * Changing them: a merge patch over the purposes above, so a request names
6318
+ * only what was just answered and leaves the rest alone. The same patch
6319
+ * shape `identify` takes, because that is where the write actually goes.
6320
+ */
6321
+ const updateNotificationPreferencesBodySchema = z.object({ data: z.object({ purposes: consentPatchSchema }) });
6322
+
6010
6323
  //#endregion
6011
6324
  //#region ../../packages/shared/src/releases.ts
6012
6325
  /**
@@ -6045,16 +6358,30 @@ const putArtifactQuerySchema = z.object({ kind: z.enum(["bundle", "source"]) });
6045
6358
  *
6046
6359
  * `deployedIn` is not a column: it is the environments whose latest
6047
6360
  * deployment names this release, computed on read, so it can never fall out
6048
- * of sync with the append-only deployment log.
6361
+ * of sync with the append-only deployment log. Neither is `totalEntries`:
6362
+ * the manifest is already here and its journeys plus its templates are the
6363
+ * denominator, so storing it beside `compiledEntries` would only invite the
6364
+ * two to disagree.
6049
6365
  */
6050
6366
  const releaseSchema = selectReleaseSchema.extend({
6051
6367
  manifest: z.union([compiledManifestSchema, manifestSchema]),
6052
6368
  deployedIn: z.array(environmentSchema),
6369
+ /**
6370
+ * How many journeys and templates the manifest holds: the denominator
6371
+ * `compiledEntries` climbs towards, counted on read rather than stored.
6372
+ */
6373
+ totalEntries: z.number().int(),
6053
6374
  createdAt: z.iso.datetime(),
6054
6375
  compiledAt: z.iso.datetime().nullable()
6055
6376
  });
6056
6377
  const createReleaseBodySchema = z.object({ data: z.object({
6057
- manifest: manifestSchema,
6378
+ /**
6379
+ * The one place the protocol is pinned to the constant. The stored shape
6380
+ * takes any positive integer, because a release outlives a bump; a push
6381
+ * is a live CLI talking to a live platform, so a mismatch here is a 422
6382
+ * the developer fixes by updating `@cowliss/cli`, and nothing is stored.
6383
+ */
6384
+ manifest: manifestSchema.safeExtend({ protocol: z.literal(2) }),
6058
6385
  /** The `cow.json` project this push belongs to. */
6059
6386
  project: projectNameSchema
6060
6387
  }) });
@@ -6540,13 +6867,6 @@ const findUserQuerySchema = z.object({ identifier: z.string().trim().min(3, "ide
6540
6867
  * endpoint answers with: a merged-away id redirects to its survivor.
6541
6868
  */
6542
6869
  const userDetailSchema = profileDtoSchema.extend({ mergedIds: z.array(z.string()) });
6543
- /**
6544
- * Consent editor body (PATCH /v1/users/:profileId/consent): a partial map
6545
- * over the fixed purposes, applied as a merge patch, so omitting a purpose
6546
- * leaves it as it was. At least one purpose is required: an empty patch is
6547
- * a no-op request, not a valid one.
6548
- */
6549
- const consentPatchSchema = z.partialRecord(z.enum(CONSENT_PURPOSES), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: `at least one consent purpose (${CONSENT_PURPOSES.join(", ")}) is required` });
6550
6870
  const updateConsentBodySchema = z.object({ data: consentPatchSchema });
6551
6871
  /**
6552
6872
  * DELETE /v1/users/:profileId: what erasure did, so the dashboard action
@@ -6736,134 +7056,422 @@ async function clearCredentials$1(path = defaultCredentialsPath()) {
6736
7056
  }
6737
7057
 
6738
7058
  //#endregion
6739
- //#region src/lib/config.ts
6740
- const DEFAULT_API_URL = "http://localhost:3400";
6741
- const DEFAULT_WEB_URL = "http://localhost:5273";
6742
- function credentialsPath(env) {
6743
- return env.COW_CREDENTIALS_PATH ?? defaultCredentialsPath();
6744
- }
6745
- function readCredentials(env) {
6746
- return readCredentials$1(credentialsPath(env));
6747
- }
6748
- function writeCredentials(env, credentials) {
6749
- return writeCredentials$1(credentialsPath(env), credentials).then(() => credentialsPath(env));
6750
- }
6751
- function clearCredentials(env) {
6752
- return clearCredentials$1(credentialsPath(env));
6753
- }
7059
+ //#region ../../packages/shared/src/stable-json.ts
6754
7060
  /**
6755
- * The token every command sends, and where it came from. Precedence:
6756
- * `COW_DEPLOY_KEY` > `COW_TOKEN` > the cached login. The deploy key wins
6757
- * because a machine that has one is CI (or a long-running `cow dev`), and a
6758
- * stale session left in `~/.cow` on that machine must not quietly become the
6759
- * credential a pipeline runs as. `COW_TOKEN` is a session token handed over
6760
- * explicitly, so it reports as one and outranks the cached file.
7061
+ * A key-sorted JSON rendering, for comparing or hashing two values a JSON
7062
+ * round trip may have reordered. `undefined` members are dropped the way
7063
+ * `JSON.stringify` drops them.
6761
7064
  *
6762
- * A deploy key only reaches the release lifecycle, so a `cow users list` on
6763
- * a box that exports one answers 401 rather than falling back to the
6764
- * session. That is the honest failure: the two credentials are different
6765
- * identities, and silently switching between them per command is how a
6766
- * pipeline ends up passing locally and failing in CI.
7065
+ * Its own module with no imports at all, because both users need it and
7066
+ * they sit on opposite sides of a boundary: the release digest (which pulls
7067
+ * in node:crypto) and the compile workflow (which may not pull in anything).
6767
7068
  */
6768
- function resolveCredential(env, credentials) {
6769
- if (env.COW_DEPLOY_KEY) return {
6770
- token: env.COW_DEPLOY_KEY,
6771
- kind: "deployKey"
6772
- };
6773
- if (env.COW_TOKEN) return {
6774
- token: env.COW_TOKEN,
6775
- kind: "session"
6776
- };
6777
- if (credentials?.token) return {
6778
- token: credentials.token,
6779
- kind: "session"
6780
- };
6781
- return {
6782
- token: null,
6783
- kind: "none"
6784
- };
7069
+ function stableJson(value) {
7070
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
7071
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
7072
+ return JSON.stringify(value) ?? "null";
6785
7073
  }
6786
- /** Precedence: --api flag > env > credentials (from login) > default. */
6787
- function resolveApiUrl(env, credentials, flag) {
6788
- return flag ?? env.COW_API_URL ?? credentials?.apiUrl ?? "http://localhost:3400";
7074
+
7075
+ //#endregion
7076
+ //#region ../../packages/shared/src/digest.ts
7077
+ /**
7078
+ * `sha256:` plus the lowercase hex digest of these exact bytes: how every
7079
+ * artifact in the release lifecycle is named. `cow build` computes it over
7080
+ * each bundle and the source tarball, and the API recomputes it over an
7081
+ * upload's body before storing it, so the two must agree byte for byte.
7082
+ *
7083
+ * Deliberately NOT re-exported from the package index: node:crypto, and the
7084
+ * dashboard bundles @cowliss/shared for the browser. Import from
7085
+ * `@cowliss/shared/digest`.
7086
+ */
7087
+ function digestOf(bytes) {
7088
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
6789
7089
  }
6790
7090
  /**
6791
- * Decode a JWT payload without verification. Display only: the API is the
6792
- * verifier; the CLI just shows what it is about to send.
7091
+ * The digest of a release manifest, computed over a key-sorted rendering so
7092
+ * `cow push` and the API agree on the value whatever order the JSON arrived
7093
+ * in. It is what decides "this tree is already released": a disagreement
7094
+ * would silently turn the unchanged-tree skip off, which is why neither side
7095
+ * hashes `JSON.stringify(manifest)` directly.
6793
7096
  */
6794
- function decodeTokenPayload(token) {
6795
- const parts = token.split(".");
6796
- if (parts.length !== 3) return null;
7097
+ function manifestDigestOf(manifest) {
7098
+ return digestOf(stableJson(manifest));
7099
+ }
7100
+
7101
+ //#endregion
7102
+ //#region src/lib/package.ts
7103
+ let cached;
7104
+ function readCliPackage() {
7105
+ cached ??= readFile(new URL(import.meta.resolve("@cowliss/cli/package.json")), "utf8").then((text) => JSON.parse(text));
7106
+ return cached;
7107
+ }
7108
+
7109
+ //#endregion
7110
+ //#region src/build/spine.ts
7111
+ /**
7112
+ * The step spine, read off the source of `run` rather than recorded by
7113
+ * running it: a run against stub data follows one path and never sees an
7114
+ * `if` on a profile trait, which is the shape of half the journeys there
7115
+ * are. Reading the code sees every branch, labelled with its condition.
7116
+ *
7117
+ * What it reads: `await api.*` calls (in source order, with the literal
7118
+ * template, destination, event, key, or duration when the author wrote one
7119
+ * inline), `if`/`else`, `switch`, loops, `try`/`catch`, `return`, `throw`, and
7120
+ * `api.restart()`. Calls behind a helper function in another file, and a
7121
+ * `run` that is not an inline function on the `defineJourney` object, are
7122
+ * not followed: the spine is display only, and its ceiling is the source in
7123
+ * front of it. The typecheck that ran first is TypeScript's; syntax it accepts
7124
+ * and this parser does not yields an empty spine, never a failed build.
7125
+ */
7126
+ function readSpine(source) {
7127
+ let program;
6797
7128
  try {
6798
- const json = Buffer.from(parts[1] ?? "", "base64url").toString("utf8");
6799
- const payload = JSON.parse(json);
6800
- if (typeof payload !== "object" || payload === null) return null;
6801
- return payload;
7129
+ program = parse(source, {
7130
+ sourceType: "module",
7131
+ plugins: ["typescript"],
7132
+ errorRecovery: true
7133
+ }).program;
6802
7134
  } catch {
6803
- return null;
7135
+ return [];
7136
+ }
7137
+ const run = findRun(program);
7138
+ if (!run) return [];
7139
+ const api = run.params[1];
7140
+ if (api?.type !== "Identifier") return [];
7141
+ const reader = new Reader(source, api.name);
7142
+ if (run.body.type !== "BlockStatement") return [...reader.calls(run.body), {
7143
+ name: "end",
7144
+ detail: "returned"
7145
+ }];
7146
+ const body = reader.block(run.body.body);
7147
+ return body.ended ? body.entries : [...body.entries, {
7148
+ name: "end",
7149
+ detail: "returned"
7150
+ }];
7151
+ }
7152
+ /** The `run` function on the object `export default defineJourney({...})` receives. */
7153
+ function findRun(program) {
7154
+ for (const statement of program.body) {
7155
+ if (statement.type !== "ExportDefaultDeclaration") continue;
7156
+ let expr = statement.declaration;
7157
+ while (expr.type === "TSAsExpression" || expr.type === "TSSatisfiesExpression" || expr.type === "ParenthesizedExpression") expr = expr.expression;
7158
+ if (expr.type !== "CallExpression" || expr.arguments[0]?.type !== "ObjectExpression") return;
7159
+ for (const prop of expr.arguments[0].properties) {
7160
+ if (prop.type === "ObjectMethod" && keyName(prop.key) === "run") return prop;
7161
+ if (prop.type === "ObjectProperty" && keyName(prop.key) === "run" && (prop.value.type === "ArrowFunctionExpression" || prop.value.type === "FunctionExpression")) return prop.value;
7162
+ }
7163
+ }
7164
+ }
7165
+ function keyName(key) {
7166
+ if (key.type === "Identifier") return key.name;
7167
+ if (key.type === "StringLiteral") return key.value;
7168
+ }
7169
+ var Reader = class {
7170
+ source;
7171
+ api;
7172
+ /** Variables holding a `waitForEvent` result, by name, to the event waited for. */
7173
+ waits = /* @__PURE__ */ new Map();
7174
+ constructor(source, api) {
7175
+ this.source = source;
7176
+ this.api = api;
7177
+ }
7178
+ block(statements) {
7179
+ const entries = [];
7180
+ for (const statement of statements) {
7181
+ const step = this.statement(statement);
7182
+ entries.push(...step.entries);
7183
+ if (step.ended) return {
7184
+ entries,
7185
+ ended: true
7186
+ };
7187
+ }
7188
+ return {
7189
+ entries,
7190
+ ended: false
7191
+ };
7192
+ }
7193
+ statement(node) {
7194
+ switch (node.type) {
7195
+ case "BlockStatement": return this.block(node.body);
7196
+ case "IfStatement": {
7197
+ const entries = this.calls(node.test);
7198
+ const then = this.block([node.consequent]);
7199
+ const otherwise = node.alternate ? this.block([node.alternate]) : void 0;
7200
+ entries.push({
7201
+ name: "if",
7202
+ detail: this.condition(node.test),
7203
+ steps: then.entries,
7204
+ ...otherwise ? { otherwise: otherwise.entries } : {}
7205
+ });
7206
+ return {
7207
+ entries,
7208
+ ended: then.ended && otherwise?.ended === true
7209
+ };
7210
+ }
7211
+ case "SwitchStatement": {
7212
+ const entries = this.calls(node.discriminant);
7213
+ for (const kase of node.cases) {
7214
+ const body = this.block(kase.consequent).entries;
7215
+ if (kase.test) entries.push({
7216
+ name: "if",
7217
+ detail: `${this.describe(node.discriminant)} is ${this.describe(kase.test)}`,
7218
+ steps: body
7219
+ });
7220
+ else entries.push(...body);
7221
+ }
7222
+ return {
7223
+ entries,
7224
+ ended: false
7225
+ };
7226
+ }
7227
+ case "ForStatement":
7228
+ case "ForOfStatement":
7229
+ case "ForInStatement":
7230
+ case "WhileStatement":
7231
+ case "DoWhileStatement": return {
7232
+ entries: [{
7233
+ name: "loop",
7234
+ detail: this.loopDetail(node),
7235
+ steps: this.block([node.body]).entries
7236
+ }],
7237
+ ended: false
7238
+ };
7239
+ case "TryStatement": {
7240
+ const entries = this.block(node.block.body).entries;
7241
+ if (node.handler) entries.push({
7242
+ name: "if",
7243
+ detail: "that fails",
7244
+ steps: this.block(node.handler.body.body).entries
7245
+ });
7246
+ if (node.finalizer) entries.push(...this.block(node.finalizer.body).entries);
7247
+ return {
7248
+ entries,
7249
+ ended: false
7250
+ };
7251
+ }
7252
+ case "ReturnStatement": {
7253
+ const entries = node.argument ? this.calls(node.argument) : [];
7254
+ if (entries.at(-1)?.name !== "restart") entries.push({
7255
+ name: "end",
7256
+ detail: "returned"
7257
+ });
7258
+ return {
7259
+ entries,
7260
+ ended: true
7261
+ };
7262
+ }
7263
+ case "ThrowStatement": return {
7264
+ entries: [...this.calls(node.argument), {
7265
+ name: "end",
7266
+ detail: "failed"
7267
+ }],
7268
+ ended: true
7269
+ };
7270
+ case "VariableDeclaration": {
7271
+ const entries = [];
7272
+ for (const declarator of node.declarations) if (declarator.init) {
7273
+ entries.push(...this.calls(declarator.init));
7274
+ const call = unwrap(declarator.init);
7275
+ if (declarator.id.type === "Identifier" && call?.type === "CallExpression" && this.apiPath(call.callee) === "waitForEvent") this.waits.set(declarator.id.name, this.literalPattern(call.arguments[0]) ?? "the event");
7276
+ }
7277
+ return {
7278
+ entries,
7279
+ ended: false
7280
+ };
7281
+ }
7282
+ default: {
7283
+ const entries = this.calls(node);
7284
+ return {
7285
+ entries,
7286
+ ended: entries.at(-1)?.name === "restart"
7287
+ };
7288
+ }
7289
+ }
7290
+ }
7291
+ /** Every `api.*` call inside a node, in source order. */
7292
+ calls(node) {
7293
+ const entries = [];
7294
+ const visit = (child) => {
7295
+ if (child.type === "CallExpression") {
7296
+ const path = this.apiPath(child.callee);
7297
+ if (path && COMMAND_NAMES.includes(path)) {
7298
+ for (const argument of child.arguments) visit(argument);
7299
+ entries.push(this.call(path, child.arguments));
7300
+ return;
7301
+ }
7302
+ }
7303
+ if (child.type === "ArrowFunctionExpression" || child.type === "FunctionExpression") return;
7304
+ for (const key of Object.keys(child)) {
7305
+ if (key === "loc" || key === "leadingComments" || key === "trailingComments") continue;
7306
+ const value = child[key];
7307
+ for (const item of Array.isArray(value) ? value : [value]) if (isNode(item)) visit(item);
7308
+ }
7309
+ };
7310
+ visit(node);
7311
+ return entries;
7312
+ }
7313
+ call(name, args) {
7314
+ const first = args[0];
7315
+ const entry = { name };
7316
+ const detail = (value) => {
7317
+ if (value !== void 0) entry.detail = value;
7318
+ };
7319
+ switch (name) {
7320
+ case "sleep":
7321
+ detail(first ? this.literal(first) ?? this.text(first) : void 0);
7322
+ break;
7323
+ case "waitForEvent": {
7324
+ detail(first ? this.literalPattern(first) ?? this.text(first) : void 0);
7325
+ const timeout = this.property(args[1], "timeout");
7326
+ if (timeout) entry.timeout = timeout;
7327
+ break;
7328
+ }
7329
+ case "send.email": {
7330
+ detail(this.property(first, "template"));
7331
+ const sender = this.property(first, "senderIdentity");
7332
+ if (sender) entry.senderIdentity = sender;
7333
+ break;
7334
+ }
7335
+ case "send.webhook":
7336
+ detail(this.property(first, "destination"));
7337
+ break;
7338
+ case "traits.set":
7339
+ case "traits.unset":
7340
+ case "events.track":
7341
+ case "profiles.get": detail(first ? this.literal(first) ?? this.text(first) : void 0);
7342
+ }
7343
+ return entry;
7344
+ }
7345
+ /** `api.send.email` → "send.email"; anything not rooted at the api param → undefined. */
7346
+ apiPath(callee) {
7347
+ const parts = [];
7348
+ let node = callee;
7349
+ while (node.type === "MemberExpression" && !node.computed) {
7350
+ if (node.property.type !== "Identifier") return;
7351
+ parts.unshift(node.property.name);
7352
+ node = node.object;
7353
+ }
7354
+ return node.type === "Identifier" && node.name === this.api && parts.length ? parts.join(".") : void 0;
7355
+ }
7356
+ text(node) {
7357
+ return this.source.slice(node.start ?? 0, node.end ?? 0);
7358
+ }
7359
+ literal(node) {
7360
+ switch (node.type) {
7361
+ case "StringLiteral": return node.value;
7362
+ case "NumericLiteral":
7363
+ case "BooleanLiteral": return String(node.value);
7364
+ case "TemplateLiteral": return node.expressions.length === 0 ? node.quasis.map((quasi) => quasi.value.cooked ?? "").join("") : void 0;
7365
+ case "TSAsExpression":
7366
+ case "TSSatisfiesExpression":
7367
+ case "TSNonNullExpression": return this.literal(node.expression);
7368
+ default: return;
7369
+ }
7370
+ }
7371
+ /** A literal pattern, one or a list, the way the trigger label shows it. */
7372
+ literalPattern(node) {
7373
+ if (!node) return;
7374
+ if (node.type === "ArrayExpression") {
7375
+ const items = node.elements.map((item) => item ? this.literal(item) : void 0);
7376
+ return items.every((item) => item !== void 0) ? patternLabel(items) : void 0;
7377
+ }
7378
+ return this.literal(node);
7379
+ }
7380
+ /** The literal value of `key` in an inline object argument. */
7381
+ property(node, key) {
7382
+ if (node?.type !== "ObjectExpression") return;
7383
+ for (const prop of node.properties) if (prop.type === "ObjectProperty" && keyName(prop.key) === key) return this.literal(prop.value);
7384
+ }
7385
+ loopDetail(node) {
7386
+ switch (node.type) {
7387
+ case "ForStatement": return node.test ? `while ${this.condition(node.test)}` : "forever";
7388
+ case "WhileStatement":
7389
+ case "DoWhileStatement": return `while ${this.condition(node.test)}`;
7390
+ default: return `for each of ${this.describe(node.right)}`;
7391
+ }
7392
+ }
7393
+ /**
7394
+ * A condition as a sentence: "the plan trait is \"pro\"", "org_activated
7395
+ * arrives in time", "the orgActivated trait is not true". Whatever the
7396
+ * rules do not know stays as the author wrote it.
7397
+ */
7398
+ condition(node) {
7399
+ switch (node.type) {
7400
+ case "LogicalExpression": return `${this.condition(node.left)} ${node.operator === "&&" ? "and" : node.operator === "||" ? "or" : "or else"} ${this.condition(node.right)}`;
7401
+ case "UnaryExpression":
7402
+ if (node.operator === "!") return negate(this.condition(node.argument));
7403
+ return this.text(node);
7404
+ case "BinaryExpression": {
7405
+ const { operator } = node;
7406
+ const left = node.left;
7407
+ if (operator === "===" || operator === "==" || operator === "!==" || operator === "!=") {
7408
+ const positive = operator === "===" || operator === "==";
7409
+ const right = this.rightHand(node.right);
7410
+ const sentence = `${this.describe(left)} ${right}`;
7411
+ return positive ? sentence : negate(sentence);
7412
+ }
7413
+ return `${this.describe(left)} ${operator} ${this.describe(node.right)}`;
7414
+ }
7415
+ case "ParenthesizedExpression": return this.condition(node.expression);
7416
+ default: {
7417
+ const wait = node.type === "Identifier" && this.waits.get(node.name);
7418
+ if (wait) return `${wait} arrives in time`;
7419
+ return `${this.describe(node)} is set`;
7420
+ }
7421
+ }
6804
7422
  }
7423
+ /** The predicate for a comparison's right side: "is true", "is missing", "is \"pro\"". */
7424
+ rightHand(node) {
7425
+ if (node.type === "NullLiteral") return "is missing";
7426
+ if (node.type === "Identifier" && node.name === "undefined") return "is missing";
7427
+ if (node.type === "StringLiteral") return `is "${node.value}"`;
7428
+ if (node.type === "NumericLiteral" || node.type === "BooleanLiteral") return `is ${String(node.value)}`;
7429
+ return `is ${this.describe(node)}`;
7430
+ }
7431
+ /** An operand as a noun phrase: "the plan trait", "the via property", "the event name". */
7432
+ describe(node) {
7433
+ if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") {
7434
+ const object = node.object;
7435
+ if (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier") {
7436
+ if (object.property.name === "traits") return `the ${node.property.name} trait`;
7437
+ if (object.property.name === "properties") return `the ${node.property.name} property`;
7438
+ }
7439
+ if (object.type === "Identifier" && node.property.name === "name" && object.name === "event") return "the event name";
7440
+ }
7441
+ if (node.type === "MemberExpression" && node.computed && node.object.type === "MemberExpression" && !node.object.computed && node.object.property.type === "Identifier") {
7442
+ const key = this.literal(node.property);
7443
+ if (key !== void 0 && node.object.property.name === "traits") return `the ${key} trait`;
7444
+ if (key !== void 0 && node.object.property.name === "properties") return `the ${key} property`;
7445
+ }
7446
+ if (node.type === "Identifier") {
7447
+ const wait = this.waits.get(node.name);
7448
+ if (wait) return `the ${wait} event`;
7449
+ }
7450
+ const literal = this.literal(node);
7451
+ return literal !== void 0 && node.type === "StringLiteral" ? `"${literal}"` : literal ?? this.text(node);
7452
+ }
7453
+ };
7454
+ /** Each pair is a sentence and its negation; whichever side is present flips. */
7455
+ const NEGATIONS = [
7456
+ [" arrives in time", " does not arrive in time"],
7457
+ [" is missing", " is present"],
7458
+ [" is set", " is not set"],
7459
+ [" is ", " is not "]
7460
+ ];
7461
+ function negate(sentence) {
7462
+ for (const [positive, negative] of NEGATIONS) {
7463
+ if (sentence.includes(negative)) return sentence.replace(negative, positive);
7464
+ if (sentence.includes(positive)) return sentence.replace(positive, negative);
7465
+ }
7466
+ return `not ${sentence}`;
6805
7467
  }
6806
- function decodeSessionToken(token) {
6807
- const payload = decodeTokenPayload(token);
6808
- if (!payload) return null;
6809
- const o = payload.o;
6810
- return {
6811
- userId: typeof payload.sub === "string" ? payload.sub : null,
6812
- orgId: o && typeof o === "object" && typeof o.id === "string" ? o.id : typeof payload.org_id === "string" ? payload.org_id : null,
6813
- role: o && typeof o === "object" && typeof o.rol === "string" ? `org:${o.rol}` : typeof payload.org_role === "string" ? payload.org_role : null,
6814
- expiresAt: typeof payload.exp === "number" ? (/* @__PURE__ */ new Date(payload.exp * 1e3)).toISOString() : null
6815
- };
6816
- }
6817
-
6818
- //#endregion
6819
- //#region src/lib/package.ts
6820
- let cached;
6821
- function readCliPackage() {
6822
- cached ??= readFile(new URL(import.meta.resolve("@cowliss/cli/package.json")), "utf8").then((text) => JSON.parse(text));
6823
- return cached;
6824
- }
6825
-
6826
- //#endregion
6827
- //#region ../../packages/shared/src/stable-json.ts
6828
- /**
6829
- * A key-sorted JSON rendering, for comparing or hashing two values a JSON
6830
- * round trip may have reordered. `undefined` members are dropped the way
6831
- * `JSON.stringify` drops them.
6832
- *
6833
- * Its own module with no imports at all, because both users need it and
6834
- * they sit on opposite sides of a boundary: the release digest (which pulls
6835
- * in node:crypto) and the compile workflow (which may not pull in anything).
6836
- */
6837
- function stableJson(value) {
6838
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
6839
- if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
6840
- return JSON.stringify(value) ?? "null";
6841
- }
6842
-
6843
- //#endregion
6844
- //#region ../../packages/shared/src/digest.ts
6845
- /**
6846
- * `sha256:` plus the lowercase hex digest of these exact bytes: how every
6847
- * artifact in the release lifecycle is named. `cow build` computes it over
6848
- * each bundle and the source tarball, and the API recomputes it over an
6849
- * upload's body before storing it, so the two must agree byte for byte.
6850
- *
6851
- * Deliberately NOT re-exported from the package index: node:crypto, and the
6852
- * dashboard bundles @cowliss/shared for the browser. Import from
6853
- * `@cowliss/shared/digest`.
6854
- */
6855
- function digestOf(bytes) {
6856
- return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
7468
+ function unwrap(node) {
7469
+ let current = node;
7470
+ while (current.type === "AwaitExpression" || current.type === "TSAsExpression" || current.type === "TSNonNullExpression" || current.type === "ParenthesizedExpression") current = current.type === "AwaitExpression" ? current.argument : current.expression;
7471
+ return current;
6857
7472
  }
6858
- /**
6859
- * The digest of a release manifest, computed over a key-sorted rendering so
6860
- * `cow push` and the API agree on the value whatever order the JSON arrived
6861
- * in. It is what decides "this tree is already released": a disagreement
6862
- * would silently turn the unchanged-tree skip off, which is why neither side
6863
- * hashes `JSON.stringify(manifest)` directly.
6864
- */
6865
- function manifestDigestOf(manifest) {
6866
- return digestOf(stableJson(manifest));
7473
+ function isNode(value) {
7474
+ return typeof value === "object" && value !== null && typeof value.type === "string";
6867
7475
  }
6868
7476
 
6869
7477
  //#endregion
@@ -6878,18 +7486,18 @@ const execFileAsync$1 = promisify(execFile);
6878
7486
  /** Where build output lives, relative to the project root. */
6879
7487
  const BUILD_DIR = join(".cow", "build");
6880
7488
  const TYPES_FILE = join(".cow", "types.d.ts");
6881
- /** The config files the source tarball carries besides `journeys/` and `emails/`. */
7489
+ /**
7490
+ * The config files the source tarball carries besides `journeys/` and
7491
+ * `emails/`. Always the committed `cow.json`, never a `--config` override: a
7492
+ * release records the project as the repo declares it, not the local file
7493
+ * whoever pushed happened to point at.
7494
+ */
6882
7495
  const CONFIG_FILES$1 = [
6883
7496
  "cow.json",
6884
7497
  "package.json",
6885
7498
  "tsconfig.json"
6886
7499
  ];
6887
7500
  /**
6888
- * The spine is display only, so a loop that would record forever is cut
6889
- * here rather than being allowed to grow the manifest.
6890
- */
6891
- const SPINE_LIMIT = 200;
6892
- /**
6893
7501
  * The guest SDK a bundle links against is always the CLI's own, whatever
6894
7502
  * the project has installed: the manifest records that version as `sdk`,
6895
7503
  * and the sandbox runs the driver from these files.
@@ -6965,7 +7573,7 @@ async function discover(projectDir, kind, extension) {
6965
7573
  }
6966
7574
  /**
6967
7575
  * Two files under the same directory tree cannot share a key: the key is
6968
- * what a release, a workflow id, and `api.email.send` all address. A
7576
+ * what a release, a workflow id, and `api.send.email` all address. A
6969
7577
  * journey and a template may share one (they are separate namespaces, and
6970
7578
  * a journey that sends its own email usually does).
6971
7579
  */
@@ -6979,7 +7587,7 @@ function assertUniqueKeys(files) {
6979
7587
  }
6980
7588
  /**
6981
7589
  * `.cow/types.d.ts`: one `typeof import(...)` per template, merged into the
6982
- * SDK's `CowTemplates`, which is what types `api.email.send`. Written before
7590
+ * SDK's `CowTemplates`, which is what types `api.send.email`. Written before
6983
7591
  * the typecheck, because the typecheck is what it exists for.
6984
7592
  */
6985
7593
  function templateTypes(templates) {
@@ -7148,93 +7756,29 @@ async function loadNodeBundle(projectDir, key, kind = "journeys") {
7148
7756
  runGuest: loaded.runGuest
7149
7757
  };
7150
7758
  }
7151
- /** Stops the recording stub at `restart` and at the spine cap. */
7152
- var SpineStop = class extends Error {};
7153
7759
  /**
7154
- * A profile with nothing in it but the id and traits given: what the spine
7155
- * stub's reads answer, and the base the simulator answers `profile.get` with.
7156
- * The spec says a spine read returns an empty object; a bare `{}` ends most
7157
- * recordings at the first `me.traits.x`, which is the first line of half the
7158
- * journeys there are, so the shape is kept and the contents emptied. Consent
7159
- * reads true so a gated send still shows up.
7760
+ * A profile with nothing in it but the id and traits given: the base the
7761
+ * simulator answers `profile.get` with.
7762
+ *
7763
+ * Consent covers the project's declared purposes as well as the two fixed
7764
+ * ones, each reading its own default, which is what a profile that answered
7765
+ * nothing gets in production, so a dry run and a real send agree.
7160
7766
  */
7161
- function emptyProfile(id = "", traits = {}) {
7767
+ function emptyProfile(id = "", traits = {}, purposes = []) {
7162
7768
  return {
7163
7769
  id,
7164
7770
  traits,
7165
- consent: Object.fromEntries(CONSENT_PURPOSES.map((purpose) => [purpose, true])),
7771
+ consent: {
7772
+ ...CONSENT_PURPOSE_DEFAULTS,
7773
+ ...consentDefaultsOf(purposes.map((purpose) => ({
7774
+ key: purpose.key,
7775
+ defaultGranted: false
7776
+ })))
7777
+ },
7166
7778
  identifiers: {},
7167
7779
  segments: []
7168
7780
  };
7169
7781
  }
7170
- /**
7171
- * The step spine: `run` once against a stub whose reads answer empty and
7172
- * whose waits answer null, keeping what was recorded up to the first throw.
7173
- *
7174
- * ponytail: a journey that awaits a promise nothing settles (rather than an
7175
- * api call) hangs the build here. The cap covers the loop that matters,
7176
- * `for (;;) await api.sleep(...)`; a wall-clock guard is the upgrade if a
7177
- * real project ever manages it.
7178
- */
7179
- async function recordSpine(module, trigger) {
7180
- const spine = [];
7181
- const push = (name, detail) => {
7182
- if (spine.length >= SPINE_LIMIT) throw new SpineStop();
7183
- spine.push(detail === void 0 ? { name } : {
7184
- name,
7185
- detail
7186
- });
7187
- };
7188
- const api = {
7189
- sleep: async () => {
7190
- push("sleep");
7191
- },
7192
- waitForEvent: async (pattern) => {
7193
- push("waitForEvent", patternLabel(pattern));
7194
- return null;
7195
- },
7196
- email: { send: async (args) => {
7197
- push("email.send", String(args.template));
7198
- return {};
7199
- } },
7200
- webhook: { send: async (args) => {
7201
- push("webhook.send", args.destination);
7202
- } },
7203
- traits: {
7204
- set: async (key) => {
7205
- push("traits.set", key);
7206
- },
7207
- unset: async (key) => {
7208
- push("traits.unset", key);
7209
- }
7210
- },
7211
- profile: { get: async () => {
7212
- push("profile.get");
7213
- return emptyProfile();
7214
- } },
7215
- profiles: { get: async (id) => {
7216
- push("profiles.get", id);
7217
- return emptyProfile();
7218
- } },
7219
- events: { track: async (name) => {
7220
- push("events.track", name);
7221
- } },
7222
- log: () => {},
7223
- restart: async () => {
7224
- push("restart");
7225
- throw new SpineStop();
7226
- }
7227
- };
7228
- const journey = module.default;
7229
- try {
7230
- await journey.run({
7231
- name: "event" in trigger ? patternPlaceholder(trigger.event) : SYSTEM_EVENTS.segmentEntered,
7232
- properties: {},
7233
- timestamp: 0
7234
- }, api);
7235
- } catch {}
7236
- return spine;
7237
- }
7238
7782
  /** The release limits (spec: Limits), naming the offending count or size. */
7239
7783
  function limitFailure(sizes) {
7240
7784
  if (sizes.journeys > RELEASE_LIMITS.journeys) return `This project has ${sizes.journeys} journeys; a release carries at most ${RELEASE_LIMITS.journeys}.`;
@@ -7254,18 +7798,41 @@ async function writeSourceTarball(projectDir, outFile) {
7254
7798
  }, files);
7255
7799
  return (await stat(outFile)).size;
7256
7800
  }
7257
- /** The project's cow.json; throws unless `projectDir` holds a valid one. */
7801
+ /** The project config a checkout carries by default. */
7802
+ const DEFAULT_CONFIG_FILE = "cow.json";
7803
+ /**
7804
+ * Which project config every command in this process reads. Resolved once
7805
+ * from `--config`/`COW_CONFIG` at startup rather than threaded through nine
7806
+ * call sites: the CLI is one shot, and the answer cannot change mid-run.
7807
+ */
7808
+ let configFile = DEFAULT_CONFIG_FILE;
7809
+ function setConfigFile(name) {
7810
+ configFile = name;
7811
+ }
7812
+ function projectConfigFile() {
7813
+ return configFile;
7814
+ }
7815
+ /** The project's config; throws unless `projectDir` holds a valid one. */
7258
7816
  async function assertCowConfig(projectDir) {
7817
+ const name = projectConfigFile();
7259
7818
  let text;
7260
7819
  try {
7261
- text = await readFile(join(projectDir, "cow.json"), "utf8");
7820
+ text = await readFile(join(projectDir, name), "utf8");
7262
7821
  } catch {
7263
- throw new Error(`No cow.json in "${projectDir}". Run \`cow init\` to create a project.`);
7822
+ 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.`);
7264
7823
  }
7265
7824
  const parsed = cowConfigSchema.safeParse(JSON.parse(text));
7266
- if (!parsed.success) throw new Error(`cow.json is invalid: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`);
7825
+ if (!parsed.success) throw new Error(`${name} is invalid: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`);
7267
7826
  return parsed.data;
7268
7827
  }
7828
+ /** The project's config, or null when this directory has none. */
7829
+ async function readCowConfig(projectDir) {
7830
+ try {
7831
+ return await assertCowConfig(projectDir);
7832
+ } catch {
7833
+ return null;
7834
+ }
7835
+ }
7269
7836
  /**
7270
7837
  * Which project of the org this directory belongs to: the `project` in its
7271
7838
  * `cow.json`, or undefined when there is no `cow.json` here at all. The
@@ -7325,8 +7892,9 @@ async function buildProject(projectDir) {
7325
7892
  tags: report.tags,
7326
7893
  trigger: report.trigger,
7327
7894
  purpose: report.purpose,
7895
+ senderIdentity: report.senderIdentity,
7328
7896
  environments: report.environments,
7329
- spine: await recordSpine(module, report.trigger),
7897
+ spine: readSpine(await readFile(built.source.file, "utf8")),
7330
7898
  bundle: built.digest
7331
7899
  });
7332
7900
  else manifestTemplates.push({
@@ -7351,10 +7919,11 @@ async function buildProject(projectDir) {
7351
7919
  });
7352
7920
  if (failure) throw new Error(failure);
7353
7921
  const manifest = manifestSchema.parse({
7354
- protocol: 1,
7922
+ protocol: 2,
7355
7923
  sdk: (await readCliPackage()).version,
7356
7924
  journeys: manifestJourneys,
7357
7925
  templates: manifestTemplates,
7926
+ purposes: config.purposes,
7358
7927
  source: digestOf(await readFile(sourceFile))
7359
7928
  });
7360
7929
  await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -7387,6 +7956,95 @@ async function buildIfStale(projectDir) {
7387
7956
  if ((await Promise.all(sources.map((file) => stat(join(projectDir, file)).then((stats) => stats.mtimeMs, () => 0)))).some((time) => time > builtAt)) await buildProject(projectDir);
7388
7957
  }
7389
7958
 
7959
+ //#endregion
7960
+ //#region src/lib/config.ts
7961
+ const DEFAULT_API_URL = "http://localhost:3400";
7962
+ const DEFAULT_WEB_URL = "http://localhost:5273";
7963
+ function credentialsPath(env) {
7964
+ return env.COW_CREDENTIALS_PATH ?? defaultCredentialsPath();
7965
+ }
7966
+ function readCredentials(env) {
7967
+ return readCredentials$1(credentialsPath(env));
7968
+ }
7969
+ function writeCredentials(env, credentials) {
7970
+ return writeCredentials$1(credentialsPath(env), credentials).then(() => credentialsPath(env));
7971
+ }
7972
+ function clearCredentials(env) {
7973
+ return clearCredentials$1(credentialsPath(env));
7974
+ }
7975
+ /**
7976
+ * The token every command sends, and where it came from. Precedence:
7977
+ * `COW_DEPLOY_KEY` > `COW_TOKEN` > the cached login. The deploy key wins
7978
+ * because a machine that has one is CI (or a long-running `cow dev`), and a
7979
+ * stale session left in `~/.cow` on that machine must not quietly become the
7980
+ * credential a pipeline runs as. `COW_TOKEN` is a session token handed over
7981
+ * explicitly, so it reports as one and outranks the cached file.
7982
+ *
7983
+ * A deploy key only reaches the release lifecycle, so a `cow users list` on
7984
+ * a box that exports one answers 401 rather than falling back to the
7985
+ * session. That is the honest failure: the two credentials are different
7986
+ * identities, and silently switching between them per command is how a
7987
+ * pipeline ends up passing locally and failing in CI.
7988
+ */
7989
+ function resolveCredential(env, credentials) {
7990
+ if (env.COW_DEPLOY_KEY) return {
7991
+ token: env.COW_DEPLOY_KEY,
7992
+ kind: "deployKey"
7993
+ };
7994
+ if (env.COW_TOKEN) return {
7995
+ token: env.COW_TOKEN,
7996
+ kind: "session"
7997
+ };
7998
+ if (credentials?.token) return {
7999
+ token: credentials.token,
8000
+ kind: "session"
8001
+ };
8002
+ return {
8003
+ token: null,
8004
+ kind: "none"
8005
+ };
8006
+ }
8007
+ /**
8008
+ * Precedence: `--api` flag > env > the project config > credentials (from
8009
+ * login) > default.
8010
+ *
8011
+ * The project's own `apiUrl` outranks the credentials file because it is the
8012
+ * more specific answer: `cow.json` says where this project deploys, while the
8013
+ * credentials only remember where somebody last logged in. Without it a
8014
+ * checkout whose config names production silently fell through to
8015
+ * `DEFAULT_API_URL`, which is how a production deploy reaches localhost.
8016
+ */
8017
+ function resolveApiUrl(env, credentials, flag, projectApiUrl) {
8018
+ return flag ?? env.COW_API_URL ?? projectApiUrl ?? credentials?.apiUrl ?? "http://localhost:3400";
8019
+ }
8020
+ /**
8021
+ * Decode a JWT payload without verification. Display only: the API is the
8022
+ * verifier; the CLI just shows what it is about to send.
8023
+ */
8024
+ function decodeTokenPayload(token) {
8025
+ const parts = token.split(".");
8026
+ if (parts.length !== 3) return null;
8027
+ try {
8028
+ const json = Buffer.from(parts[1] ?? "", "base64url").toString("utf8");
8029
+ const payload = JSON.parse(json);
8030
+ if (typeof payload !== "object" || payload === null) return null;
8031
+ return payload;
8032
+ } catch {
8033
+ return null;
8034
+ }
8035
+ }
8036
+ function decodeSessionToken(token) {
8037
+ const payload = decodeTokenPayload(token);
8038
+ if (!payload) return null;
8039
+ const o = payload.o;
8040
+ return {
8041
+ userId: typeof payload.sub === "string" ? payload.sub : null,
8042
+ orgId: o && typeof o === "object" && typeof o.id === "string" ? o.id : typeof payload.org_id === "string" ? payload.org_id : null,
8043
+ role: o && typeof o === "object" && typeof o.rol === "string" ? `org:${o.rol}` : typeof payload.org_role === "string" ? payload.org_role : null,
8044
+ expiresAt: typeof payload.exp === "number" ? (/* @__PURE__ */ new Date(payload.exp * 1e3)).toISOString() : null
8045
+ };
8046
+ }
8047
+
7390
8048
  //#endregion
7391
8049
  //#region src/commands/add.ts
7392
8050
  /**
@@ -7463,7 +8121,7 @@ async function login(env, options) {
7463
8121
  if (options.token !== void 0) {
7464
8122
  token = options.token.trim();
7465
8123
  if (decodeTokenPayload(token) === null) throw new Error("The provided --token value is not a JWT");
7466
- } else token = await collectTokenViaBrowser(options.webUrl ?? env.COW_WEB_URL ?? "http://localhost:5273", 3e5, options.noOpen === true);
8124
+ } else token = await collectTokenViaBrowser(options.webUrl ?? env.COW_WEB_URL ?? (await readCowConfig(process.cwd()))?.webUrl ?? "http://localhost:5273", 3e5, options.noOpen === true);
7467
8125
  const claims = decodeSessionToken(token);
7468
8126
  if (claims === null) throw new Error("Token is not a decodable JWT");
7469
8127
  if (claims.userId === null) throw new Error("Token has no subject claim. Is it a Clerk session token?");
@@ -7503,7 +8161,14 @@ function collectTokenViaBrowser(webUrl, timeoutMs = 18e4, noOpen = false) {
7503
8161
  clearTimeout(timer);
7504
8162
  try {
7505
8163
  const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
7506
- const token = typeof body === "object" && body !== null && "token" in body ? body.token : void 0;
8164
+ const field = (name) => typeof body === "object" && body !== null && name in body ? body[name] : void 0;
8165
+ if (field("denied") === true) {
8166
+ res.writeHead(200, { "Content-Type": "application/json" });
8167
+ res.end(JSON.stringify({ ok: true }));
8168
+ reject(/* @__PURE__ */ new Error("Sign-in was denied in the browser. No access was granted."));
8169
+ return;
8170
+ }
8171
+ const token = field("token");
7507
8172
  if (typeof token !== "string" || token.length === 0) {
7508
8173
  res.writeHead(400, { "Content-Type": "application/json" });
7509
8174
  res.end(JSON.stringify({ error: "missing token" }));
@@ -7550,7 +8215,7 @@ function openBrowser(url) {
7550
8215
  //#region src/commands/auth.ts
7551
8216
  /** login/logout/whoami: session-token management, no contract route. */
7552
8217
  function registerAuth(program, env, io) {
7553
- program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a Clerk session token instead of the browser flow").option("--web <url>", `dashboard URL (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
8218
+ program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a Clerk session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in the project config (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
7554
8219
  const outcome = await login(env, {
7555
8220
  token: opts.token,
7556
8221
  webUrl: opts.web,
@@ -8366,6 +9031,31 @@ const catalog = defineModule(defineRoute({
8366
9031
  }
8367
9032
  }));
8368
9033
 
9034
+ //#endregion
9035
+ //#region ../../packages/shared/src/contract/consent.ts
9036
+ /**
9037
+ * The consent purposes an organization's profiles answer: the two fixed ones
9038
+ * every org has, plus whatever its projects declare in their `cow.json`.
9039
+ *
9040
+ * Read-only, and deliberately so. Purposes are code, like journeys and
9041
+ * templates: a deploy upserts them and never deletes one, because profiles
9042
+ * already hold answers against them.
9043
+ */
9044
+ const consent = defineModule(defineRoute({
9045
+ method: "get",
9046
+ path: "/v1/consent-purposes",
9047
+ operationId: "consent.purposes.list",
9048
+ tags: ["consent"],
9049
+ summary: "List the consent purposes this organization declares",
9050
+ security: SESSION_AUTH,
9051
+ request: { query: listConsentPurposesQuerySchema },
9052
+ responses: {
9053
+ 200: list(consentPurposeSchema),
9054
+ ...sessionErrors,
9055
+ ...errors("validation_failed")
9056
+ }
9057
+ }));
9058
+
8369
9059
  //#endregion
8370
9060
  //#region ../../packages/shared/src/contract/deliveries.ts
8371
9061
  /**
@@ -8425,7 +9115,16 @@ const deliveries = defineModule(defineRoute({
8425
9115
  summary: "One-click unsubscribe (public)",
8426
9116
  security: NO_AUTH,
8427
9117
  surfaces: HIDDEN_FROM_TOOLS,
8428
- request: { query: tokenQuery },
9118
+ request: { query: tokenQuery.extend({
9119
+ /**
9120
+ * The confirmation page's second button, "stop all marketing
9121
+ * email": the same token, revoking the master purpose instead of
9122
+ * the one the mail was sent under. A flag rather than a second
9123
+ * route, because the claim already names the org and the profile.
9124
+ * The List-Unsubscribe header never carries it, so a mail client's
9125
+ * one-click POST stops one purpose, as RFC 8058 intends.
9126
+ */
9127
+ all: z.literal("1").optional() }) },
8429
9128
  responses: {
8430
9129
  200: htmlResponse("Confirmation page"),
8431
9130
  400: { description: "Invalid or incomplete unsubscribe link" }
@@ -8599,6 +9298,21 @@ const domains = defineModule(defineRoute({
8599
9298
  ...sessionErrors,
8600
9299
  ...errors("conflict", "validation_failed", "malformed_request", "dependency_unavailable")
8601
9300
  }
9301
+ }), defineRoute({
9302
+ method: "get",
9303
+ path: "/v1/domains/{id}/dns-setup",
9304
+ operationId: "domains.dnsSetup",
9305
+ tags: ["domains"],
9306
+ summary: "Where the DNS provider can publish the records",
9307
+ description: "The Domain Connect link for this domain's DNS provider, which shows the admin the records and asks them to confirm. `url` is null when the provider does not support it.",
9308
+ security: SESSION_AUTH,
9309
+ surfaces: HIDDEN_FROM_TOOLS,
9310
+ request: { params: params$7 },
9311
+ responses: {
9312
+ 200: envelope(domainDnsSetupSchema),
9313
+ ...sessionErrors,
9314
+ ...errors("not_found")
9315
+ }
8602
9316
  }), defineRoute({
8603
9317
  method: "post",
8604
9318
  path: "/v1/domains/{id}/verify",
@@ -8965,6 +9679,55 @@ const journeys = defineModule(defineRoute({
8965
9679
  }
8966
9680
  }));
8967
9681
 
9682
+ //#endregion
9683
+ //#region ../../packages/shared/src/contract/me.ts
9684
+ /**
9685
+ * The signed-in developer's own settings. Session auth like the rest of the
9686
+ * dashboard API, but deliberately not org-scoped and indifferent to the
9687
+ * environment header: the subject is the caller's own platform-workspace
9688
+ * profile, taken from their session, never from the request.
9689
+ *
9690
+ * Hidden from the CLI and the MCP server. Both act with an org's key on that
9691
+ * org's data, and neither has a signed-in human whose mail preferences this
9692
+ * could mean; an operator changing an end user's consent uses
9693
+ * `users.updateConsent`, which is the same decision made by someone else.
9694
+ */
9695
+ const me = defineModule(defineRoute({
9696
+ method: "get",
9697
+ path: "/v1/me/notifications",
9698
+ operationId: "me.notifications.get",
9699
+ tags: ["me"],
9700
+ summary: "What Cowliss may send you",
9701
+ security: SESSION_AUTH,
9702
+ surfaces: {
9703
+ cli: false,
9704
+ mcp: false,
9705
+ docs: false
9706
+ },
9707
+ responses: {
9708
+ 200: envelope(notificationPreferencesSchema),
9709
+ ...sessionErrors
9710
+ }
9711
+ }), defineRoute({
9712
+ method: "patch",
9713
+ path: "/v1/me/notifications",
9714
+ operationId: "me.notifications.update",
9715
+ tags: ["me"],
9716
+ summary: "Change what Cowliss may send you",
9717
+ security: SESSION_AUTH,
9718
+ surfaces: {
9719
+ cli: false,
9720
+ mcp: false,
9721
+ docs: false
9722
+ },
9723
+ request: { body: jsonBody(updateNotificationPreferencesBodySchema) },
9724
+ responses: {
9725
+ 200: envelope(notificationPreferencesSchema),
9726
+ ...sessionErrors,
9727
+ ...errors("validation_failed", "malformed_request", "internal")
9728
+ }
9729
+ }));
9730
+
8968
9731
  //#endregion
8969
9732
  //#region ../../packages/shared/src/contract/project.ts
8970
9733
  /**
@@ -9660,6 +10423,8 @@ const contract = {
9660
10423
  openapi,
9661
10424
  ingestion,
9662
10425
  users,
10426
+ me,
10427
+ consent,
9663
10428
  events,
9664
10429
  apps,
9665
10430
  sources,
@@ -10117,23 +10882,55 @@ async function latestRelease(client, project) {
10117
10882
  } })).data[0] ?? null;
10118
10883
  }
10119
10884
  /**
10885
+ * How far a compile has got, as `18/26`. Only meaningful while the release is
10886
+ * pending: a `ready` release can legitimately sit below its total (every row
10887
+ * from before the counter existed reads 0, and the last progress write is
10888
+ * advisory), so completion is read from the status, never from this.
10889
+ */
10890
+ function compiledFraction(release) {
10891
+ return `${release.compiledEntries}/${release.totalEntries}`;
10892
+ }
10893
+ /**
10894
+ * The progress writer for a terminal, or nothing at all when there is no one
10895
+ * watching. It rewrites one line on stderr, so what a command prints to
10896
+ * stdout is the same bytes whether or not a terminal is attached.
10897
+ *
10898
+ * The flag is stdout's: a redirected push is a script reading the summary,
10899
+ * and it gets no cursor codes in either stream. `--json` is silent for the
10900
+ * same reason, since its whole output is one envelope.
10901
+ */
10902
+ function progressLineFor(io, json) {
10903
+ if (!io.isTTY || json === true) return;
10904
+ let drawn = false;
10905
+ return (text) => {
10906
+ if (text === "" && !drawn) return;
10907
+ drawn = text !== "";
10908
+ io.stderr(`\r\x1b[2K${text}`);
10909
+ };
10910
+ }
10911
+ /**
10120
10912
  * Poll until the compile settles. Backs off from a quarter second to two,
10121
10913
  * because a small project is ready almost at once and a large one is not
10122
10914
  * worth asking about ten times a second.
10123
10915
  */
10124
- async function awaitCompile(client, releaseId, sleep) {
10916
+ async function awaitCompile(client, releaseId, sleep, progress) {
10125
10917
  let waited = 0;
10126
10918
  let interval = POLL_START_MS;
10127
- for (;;) {
10128
- const { data } = await client.request(contract.releases["releases.get"], { params: { id: releaseId } });
10129
- if (data.status !== "pending") return data;
10130
- if (waited >= POLL_BUDGET_MS) throw new Error(`Release ${releaseId} is still compiling after ${Math.round(waited / 1e3)}s. Check \`cow releases get ${releaseId}\`.`);
10131
- await sleep(interval);
10132
- waited += interval;
10133
- interval = Math.min(interval * 2, POLL_MAX_MS);
10919
+ try {
10920
+ for (;;) {
10921
+ const { data } = await client.request(contract.releases["releases.get"], { params: { id: releaseId } });
10922
+ if (data.status !== "pending") return data;
10923
+ if (waited >= POLL_BUDGET_MS) throw new Error(`Release ${releaseId} is still compiling after ${Math.round(waited / 1e3)}s (${compiledFraction(data)} compiled). Check \`cow releases get ${releaseId}\`.`);
10924
+ progress?.(`Compiling ${compiledFraction(data)}`);
10925
+ await sleep(interval);
10926
+ waited += interval;
10927
+ interval = Math.min(interval * 2, POLL_MAX_MS);
10928
+ }
10929
+ } finally {
10930
+ progress?.("");
10134
10931
  }
10135
10932
  }
10136
- async function pushProject({ client, projectDir, force, sleep = (ms) => setTimeout$1(ms) }) {
10933
+ async function pushProject({ client, projectDir, force, sleep = (ms) => setTimeout$1(ms), progress }) {
10137
10934
  const { manifest, config } = await buildProject(projectDir);
10138
10935
  const project = await ensureProject(client, config.project);
10139
10936
  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, or use its deploy key.`);
@@ -10156,9 +10953,12 @@ async function pushProject({ client, projectDir, force, sleep = (ms) => setTimeo
10156
10953
  }
10157
10954
  return {
10158
10955
  release: await awaitCompile(client, (await client.request(contract.releases["releases.create"], { body: {
10159
- manifest,
10956
+ manifest: {
10957
+ ...manifest,
10958
+ protocol: 2
10959
+ },
10160
10960
  project: project.name
10161
- } })).data.id, sleep),
10961
+ } })).data.id, sleep, progress),
10162
10962
  uploaded
10163
10963
  };
10164
10964
  }
@@ -10179,7 +10979,8 @@ function registerPush(program, clientFor, io) {
10179
10979
  const outcome = await pushProject({
10180
10980
  client: await clientFor(merged),
10181
10981
  projectDir: process.cwd(),
10182
- force: opts.force === true
10982
+ force: opts.force === true,
10983
+ progress: progressLineFor(io, merged.json === true)
10183
10984
  });
10184
10985
  if (merged.json === true) emit({ data: outcome.release }, io, true);
10185
10986
  else io.stdout(`${pushSummary(outcome)}\n`);
@@ -10190,7 +10991,7 @@ function registerPush(program, clientFor, io) {
10190
10991
  //#endregion
10191
10992
  //#region src/commands/deploy.ts
10192
10993
  /** The release to deploy, and the push message when a push produced it. */
10193
- async function releaseToDeploy({ client, projectDir, releaseId, sleep }) {
10994
+ async function releaseToDeploy({ client, projectDir, releaseId, sleep, progress }) {
10194
10995
  if (releaseId) {
10195
10996
  const { data } = await client.request(contract.releases["releases.get"], { params: { id: releaseId } });
10196
10997
  return { release: data };
@@ -10198,7 +10999,8 @@ async function releaseToDeploy({ client, projectDir, releaseId, sleep }) {
10198
10999
  const pushed = await pushProject({
10199
11000
  client,
10200
11001
  projectDir,
10201
- sleep
11002
+ sleep,
11003
+ progress
10202
11004
  });
10203
11005
  return {
10204
11006
  release: pushed.release,
@@ -10207,7 +11009,7 @@ async function releaseToDeploy({ client, projectDir, releaseId, sleep }) {
10207
11009
  }
10208
11010
  async function deployProject(options) {
10209
11011
  const { release, skipped } = await releaseToDeploy(options);
10210
- if (release.status !== "ready") throw new Error(release.status === "failed" ? `${release.id} #${release.seq} failed to compile, so there is nothing to deploy: ${release.error ?? "no reason recorded"}` : `${release.id} #${release.seq} is still compiling; wait for it to be ready.`);
11012
+ if (release.status !== "ready") throw new Error(release.status === "failed" ? `${release.id} #${release.seq} failed to compile, so there is nothing to deploy: ${release.error ?? "no reason recorded"}` : `${release.id} #${release.seq} is still compiling (${compiledFraction(release)} compiled); wait for it to be ready.`);
10211
11013
  const { data } = await options.client.request(contract.deployments["deployments.create"], { body: { releaseId: release.id } });
10212
11014
  return {
10213
11015
  deployment: data,
@@ -10234,7 +11036,8 @@ function registerDeploy(program, clientFor, env, io) {
10234
11036
  const outcome = await deployProject({
10235
11037
  client: await clientFor(merged),
10236
11038
  projectDir: process.cwd(),
10237
- releaseId: opts.release
11039
+ releaseId: opts.release,
11040
+ progress: progressLineFor(io, merged.json === true)
10238
11041
  });
10239
11042
  if (merged.json === true) {
10240
11043
  emit({ data: outcome.deployment }, io, true);
@@ -10994,7 +11797,7 @@ async function runScenario(projectDir, key, scenario) {
10994
11797
  if (!journey) throw new Error(`Unknown journey "${key}". This project builds: ${manifest?.journeys.map((one) => one.key).join(", ") || "no journeys"}.`);
10995
11798
  const wallStart = Date.now();
10996
11799
  const { module, runGuest } = await loadNodeBundle(projectDir, key);
10997
- const profile = emptyProfile(scenario.user.id, { ...scenario.user.traits });
11800
+ const profile = emptyProfile(scenario.user.id, { ...scenario.user.traits }, manifest?.purposes);
10998
11801
  const scripted = scenario.events.map((one) => ({
10999
11802
  ...one,
11000
11803
  atMs: SIM_START + parseDuration(one.at),
@@ -11043,7 +11846,7 @@ async function runScenario(projectDir, key, scenario) {
11043
11846
  now
11044
11847
  };
11045
11848
  }
11046
- case "email.send":
11849
+ case "send.email":
11047
11850
  record("sendEmail", command.args);
11048
11851
  sends += 1;
11049
11852
  return {
@@ -11053,7 +11856,7 @@ async function runScenario(projectDir, key, scenario) {
11053
11856
  }),
11054
11857
  now: clock
11055
11858
  };
11056
- case "webhook.send":
11859
+ case "send.webhook":
11057
11860
  record("sendWebhook", command.args);
11058
11861
  return {
11059
11862
  result: ok(null),
@@ -11119,7 +11922,7 @@ async function runScenario(projectDir, key, scenario) {
11119
11922
  let error;
11120
11923
  for (;;) {
11121
11924
  const output = await runGuest(module, {
11122
- protocol: 1,
11925
+ protocol: 2,
11123
11926
  kind: "journey",
11124
11927
  key,
11125
11928
  event: trigger,
@@ -11236,11 +12039,15 @@ function emit(result, io, json) {
11236
12039
  }
11237
12040
  function buildProgram(env, io) {
11238
12041
  const program = new Command();
11239
- program.name("cow").description("Admin CLI for Cowliss: a thin client of the dashboard API").option("--api <url>", "API base URL (overrides env and saved config)").option("--env <environment>", `environment to act on: ${ENVIRONMENTS.join(" | ")} (overrides COW_ENVIRONMENT; default ${DEFAULT_ENVIRONMENT})`, parseEnvironment).option("--json", "force compact single-line JSON output");
12042
+ program.name("cow").description("Admin CLI for Cowliss: a thin client of the dashboard API").option("--api <url>", "API base URL (overrides env and saved config)").option("--env <environment>", `environment to act on: ${ENVIRONMENTS.join(" | ")} (overrides COW_ENVIRONMENT; default ${DEFAULT_ENVIRONMENT})`, parseEnvironment).option("--json", "force compact single-line JSON output").option("--config <file>", `project config to read instead of ${DEFAULT_CONFIG_FILE} (overrides COW_CONFIG)`);
12043
+ program.hook("preAction", () => {
12044
+ const flag = program.opts().config;
12045
+ setConfigFile((typeof flag === "string" ? flag : void 0) ?? env.COW_CONFIG ?? "cow.json");
12046
+ });
11240
12047
  const clientFor = async (opts) => {
11241
12048
  const credentials = await readCredentials(env);
11242
12049
  return createClient({
11243
- baseUrl: resolveApiUrl(env, credentials, typeof opts.api === "string" ? opts.api : void 0),
12050
+ baseUrl: resolveApiUrl(env, credentials, typeof opts.api === "string" ? opts.api : void 0, (await readCowConfig(process.cwd()))?.apiUrl),
11244
12051
  auth: async () => resolveCredential(env, await readCredentials(env)).token,
11245
12052
  headers: async () => ({
11246
12053
  [ENVIRONMENT_HEADER]: environmentFor(env, opts),
@@ -11285,7 +12092,13 @@ const envSchema = z.object({
11285
12092
  /** An org deploy key for CI; when set it wins over the cached session (ticket 05 reads it). */
11286
12093
  COW_DEPLOY_KEY: z.string().min(1).optional(),
11287
12094
  /** The environment admin calls select; `--env` overrides it, production is the default. */
11288
- COW_ENVIRONMENT: environmentSchema.optional()
12095
+ COW_ENVIRONMENT: environmentSchema.optional(),
12096
+ /**
12097
+ * The project config to read instead of `cow.json`, so one checkout can
12098
+ * hold both the committed production config and a local override.
12099
+ * `--config` overrides it.
12100
+ */
12101
+ COW_CONFIG: z.string().min(1).optional()
11289
12102
  });
11290
12103
  function loadEnv() {
11291
12104
  const parsed = envSchema.safeParse(process.env);