@cowliss/cli 0.3.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
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
13
13
  import { MessagePort } from "node:worker_threads";
14
14
  import { build, formatMessagesSync } from "esbuild";
15
15
  import { create, extract } from "tar";
16
+ import { parse } from "@babel/parser";
16
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";
@@ -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
  });
@@ -4466,14 +4546,14 @@ const appSchema = selectAppSchema.extend({
4466
4546
  /** The most recent accepted delivery across those sources. */
4467
4547
  lastReceivedAt: z.iso.datetime().nullable()
4468
4548
  });
4469
- 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");
4470
4550
  const createAppBodySchema = z.object({ data: z.object({
4471
- name: nameSchema$3,
4551
+ name: nameSchema$2,
4472
4552
  /** Immutable after creation; production when omitted. */
4473
4553
  environment: environmentSchema.default("production")
4474
4554
  }) });
4475
4555
  const updateAppBodySchema = z.object({ data: z.object({
4476
- name: nameSchema$3.optional(),
4556
+ name: nameSchema$2.optional(),
4477
4557
  status: z.literal("archived").optional()
4478
4558
  }).refine((data) => data.name !== void 0 || data.status !== void 0, { message: "at least one field (name or status) is required" }) });
4479
4559
  /** `q` is a substring search over the app's name; `status` narrows to one state. */
@@ -4574,6 +4654,269 @@ const identifierDtoSchema = z.object({
4574
4654
  createdAt: z.iso.datetime()
4575
4655
  });
4576
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
+
4577
4920
  //#endregion
4578
4921
  //#region ../../packages/shared/src/timestamp.ts
4579
4922
  /**
@@ -4615,6 +4958,21 @@ const traitBagSchema = z.custom((value) => value !== null && typeof value === "o
4615
4958
  additionalProperties: true
4616
4959
  });
4617
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
+ /**
4618
4976
  * The identify payload fields, shared between the single-call body schema
4619
4977
  * and the batch item schema (which drops `sourceId`: a batch names one
4620
4978
  * source for every item) so both wire shapes stay in sync.
@@ -4626,6 +4984,7 @@ const identifyFieldsSchema = z.object({
4626
4984
  sourceId: z.string().min(1, "sourceId is required"),
4627
4985
  identifiers: identifiersSchema,
4628
4986
  traits: traitBagSchema.default({}),
4987
+ consent: consentPatchSchema.optional(),
4629
4988
  timestamp: z.iso.datetime().optional(),
4630
4989
  messageId: z.string().optional()
4631
4990
  });
@@ -4945,7 +5304,7 @@ function propertyTypeLabel(type) {
4945
5304
  * in its trigger unless it is handed one.
4946
5305
  */
4947
5306
  const PROPERTY_TYPE_LABELS = Object.fromEntries(PROPERTY_TYPES.map((type) => [type, propertyTypeLabel(type)]));
4948
- 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");
4949
5308
  const propertiesSchema = z.record(z.string().min(1).max(200), propertyTypeSchema);
4950
5309
  const catalogEventSchema = selectCatalogEventSchema.extend({
4951
5310
  properties: propertiesSchema,
@@ -4953,7 +5312,7 @@ const catalogEventSchema = selectCatalogEventSchema.extend({
4953
5312
  updatedAt: z.iso.datetime()
4954
5313
  });
4955
5314
  const createCatalogEventBodySchema = z.object({ data: z.object({
4956
- name: nameSchema$2,
5315
+ name: nameSchema$1,
4957
5316
  properties: propertiesSchema.default({})
4958
5317
  }) });
4959
5318
  /**
@@ -4968,7 +5327,7 @@ const catalogTraitSchema = selectCatalogTraitSchema.extend({
4968
5327
  updatedAt: z.iso.datetime()
4969
5328
  });
4970
5329
  const createCatalogTraitBodySchema = z.object({ data: z.object({
4971
- name: nameSchema$2,
5330
+ name: nameSchema$1,
4972
5331
  type: propertyTypeSchema
4973
5332
  }) });
4974
5333
  /**
@@ -5022,6 +5381,28 @@ const quarantineActionBodySchema = z.object({ data: z.object({
5022
5381
  name: z.string().min(1)
5023
5382
  }) });
5024
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
+
5025
5406
  //#endregion
5026
5407
  //#region ../../packages/shared/src/delivery.ts
5027
5408
  /**
@@ -5197,8 +5578,9 @@ const destinationSchema = selectDestinationSchema.extend({
5197
5578
  signingSecret: true,
5198
5579
  consecutiveFailures: true
5199
5580
  });
5581
+ /** The two kinds of destination, from the table's own enum. */
5582
+ const destinationTypeSchema = destinationSchema.shape.type;
5200
5583
  const destinationCreatedSchema = destinationSchema.extend({ signingSecret: z.string().optional() });
5201
- const nameSchema$1 = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
5202
5584
  const webhookConfigSchema = z.object({ url: z.url("config.url must be a valid URL").refine((url) => {
5203
5585
  try {
5204
5586
  return ["http:", "https:"].includes(new URL(url).protocol);
@@ -5223,14 +5605,14 @@ const senderIdentityConfigSchema = z.object({
5223
5605
  * what gives react-hook-form a non-union field path to register.
5224
5606
  */
5225
5607
  const webhookDestinationInputSchema = z.object({
5226
- name: nameSchema$1,
5608
+ name: destinationNameSchema,
5227
5609
  /** The environment this destination lives in; immutable after creation. */
5228
5610
  environment: environmentSchema,
5229
5611
  type: z.literal("webhook"),
5230
5612
  config: webhookConfigSchema
5231
5613
  });
5232
5614
  const senderIdentityDestinationInputSchema = z.object({
5233
- name: nameSchema$1,
5615
+ name: destinationNameSchema,
5234
5616
  /** The environment this destination lives in; immutable after creation. */
5235
5617
  environment: environmentSchema,
5236
5618
  type: z.literal("sender_identity"),
@@ -5247,12 +5629,19 @@ const createDestinationBodySchema = z.object({ data: z.discriminatedUnion("type"
5247
5629
  * `enabled: false` is the same switch operated by hand.
5248
5630
  */
5249
5631
  const updateDestinationBodySchema = z.object({ data: z.object({
5250
- name: nameSchema$1.optional(),
5632
+ name: destinationNameSchema.optional(),
5251
5633
  config: z.unknown().optional(),
5252
5634
  enabled: z.boolean().optional()
5253
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" }) });
5254
- /** `q` is a substring search over the destination's name. */
5255
- 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
+ });
5256
5645
 
5257
5646
  //#endregion
5258
5647
  //#region ../../packages/shared/src/domains.ts
@@ -5397,176 +5786,28 @@ function patternPlaceholder(pattern) {
5397
5786
  }
5398
5787
 
5399
5788
  //#endregion
5400
- //#region ../../packages/shared/src/journeys-v2/manifest.ts
5789
+ //#region ../../packages/shared/src/journeys-v2/guest.ts
5401
5790
  /**
5402
- * The release manifest (spec: Build; Push and compile): what `cow build`
5403
- * extracts from a project and `cow push` uploads with the bundles. The
5404
- * server validates it with these schemas, compiles every bundle, and stores
5405
- * 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.
5406
5795
  */
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
+ };
5407
5807
  /**
5408
- * A journey or template key: the file basename under `journeys/` or
5409
- * `emails/`, kebab-case and unique across the project. Becomes part of the
5410
- * Temporal workflow id and travels in the journey chain, so it stays short.
5411
- */
5412
- const JOURNEY_KEY_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
5413
- const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must be kebab-case (a-z, 0-9, hyphens)");
5414
- /**
5415
- * An author's labels on a journey or a template: how the dashboard groups
5416
- * and filters them, and the only grouping there is. Case is kept as
5417
- * written, each entry is trimmed and non-empty, the list is deduplicated,
5418
- * and both ceilings are low on purpose: tags are a handful of words, not a
5419
- * taxonomy. Absent means `[]`.
5420
- *
5421
- * The count is capped on the list as written, before the deduplication, so
5422
- * a 21st entry is an error even when it is a repeat: that keeps `maxItems`
5423
- * in the generated JSON Schema, and an author who wrote 21 tags wants to
5424
- * hear about it.
5425
- */
5426
- 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)]);
5427
- /**
5428
- * A matcher field: one pattern or a non-empty list of them, a list being a
5429
- * disjunction. See `matchesPattern` in ../patterns for the dialect (`*`
5430
- * only) and for why a pattern whose literal prefix is not `system.` never
5431
- * reaches a system event.
5432
- */
5433
- const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 200 characters");
5434
- const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
5435
- /**
5436
- * What starts a journey: an event (optionally narrowed to an app id or a
5437
- * list of them) or a segment entry. The registry DTO in `../journeys`
5438
- * reuses it.
5439
- *
5440
- * Both members are strict, so a journey holding the retired `source` key
5441
- * fails to compile a release instead of silently triggering on every app.
5442
- * There is deliberately no pipe filter: a trigger narrows by the app the
5443
- * write is attributed to, the same token a segment definition names.
5444
- */
5445
- const triggerSchema = z.union([z.strictObject({
5446
- event: patternSchema,
5447
- appId: patternSchema.optional()
5448
- }), z.strictObject({ segment: z.string().min(1) })]);
5449
- /** A content address: `sha256:` plus the lowercase hex digest. */
5450
- const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "digest must be sha256:<64 hex>");
5451
- /** The names of the capability calls a journey may make. */
5452
- const COMMAND_NAMES = [
5453
- "sleep",
5454
- "waitForEvent",
5455
- "email.send",
5456
- "webhook.send",
5457
- "traits.set",
5458
- "traits.unset",
5459
- "profile.get",
5460
- "profiles.get",
5461
- "events.track",
5462
- "restart"
5463
- ];
5464
- /**
5465
- * One entry of the step spine `cow build` records by running `run` once
5466
- * against a recording stub. Display only, never trusted: a journey's real
5467
- * control flow is whatever its code does at runtime. `detail` names the
5468
- * template, destination, event, or trait key when the call has one.
5469
- */
5470
- const spineEntrySchema = z.object({
5471
- name: z.enum(COMMAND_NAMES),
5472
- detail: z.string().max(200).optional()
5473
- });
5474
- const manifestJourneySchema = z.object({
5475
- key: journeyKeySchema,
5476
- /** The author's labels; the dashboard's only grouping. */
5477
- tags: tagsSchema,
5478
- trigger: triggerSchema,
5479
- purpose: z.enum(CONSENT_PURPOSES),
5480
- /** The author's rollout gate: the journey is active only in these. */
5481
- environments: environmentsSchema,
5482
- spine: z.array(spineEntrySchema),
5483
- bundle: digestSchema
5484
- });
5485
- const manifestTemplateSchema = z.object({
5486
- key: journeyKeySchema,
5487
- /** The author's labels; the dashboard's only grouping. */
5488
- tags: tagsSchema,
5489
- sendClass: z.enum(SEND_CLASSES),
5490
- /** True asks the host to mint a signed `verifyUrl` prop at send time. */
5491
- verifyLink: z.boolean(),
5492
- /** JSON Schema of the template's `props`, converted by `cow build`. */
5493
- propsSchema: z.record(z.string(), z.unknown()),
5494
- bundle: digestSchema
5495
- });
5496
- function uniqueKeys(items, ctx, path) {
5497
- const seen = /* @__PURE__ */ new Set();
5498
- for (const [index, item] of items.entries()) {
5499
- if (seen.has(item.key)) ctx.addIssue({
5500
- code: "custom",
5501
- message: `duplicate ${path} key "${item.key}"`,
5502
- path: [
5503
- path,
5504
- index,
5505
- "key"
5506
- ]
5507
- });
5508
- seen.add(item.key);
5509
- }
5510
- }
5511
- /** What `cow build` writes to `.cow/build/manifest.json` and `cow push` sends. */
5512
- const manifestSchema = z.object({
5513
- protocol: z.literal(1),
5514
- /** The `@cowliss/cli` version the project was built with. */
5515
- sdk: z.string().min(1),
5516
- journeys: z.array(manifestJourneySchema).max(RELEASE_LIMITS.journeys),
5517
- templates: z.array(manifestTemplateSchema).max(RELEASE_LIMITS.templates),
5518
- /** Digest of the gzipped source tarball. */
5519
- source: digestSchema
5520
- }).superRefine((manifest, ctx) => {
5521
- uniqueKeys(manifest.journeys, ctx, "journeys");
5522
- uniqueKeys(manifest.templates, ctx, "templates");
5523
- });
5524
- /**
5525
- * The manifest as the release row stores it once compilation succeeded:
5526
- * the pushed manifest plus the compiled module digest per key, and the
5527
- * digest of the Javy engine plugin the toolchain that compiled them was
5528
- * built from.
5529
- *
5530
- * Journeys and templates are keyed separately because they share a key
5531
- * space: `welcome.ts` and `welcome.tsx` are one journey and the email it
5532
- * sends in every example, and a flat map would let one overwrite the other.
5533
- *
5534
- * `plugin` is a toolchain record, not a linked artifact: modules are
5535
- * statically linked, so the plugin bytes are inside each module. It says
5536
- * which engine compiled the release, which is what a later bug report or a
5537
- * reproducible rebuild needs.
5538
- */
5539
- const compiledManifestSchema = manifestSchema.safeExtend({
5540
- modules: z.object({
5541
- journeys: z.record(journeyKeySchema, digestSchema),
5542
- templates: z.record(journeyKeySchema, digestSchema)
5543
- }),
5544
- plugin: digestSchema
5545
- });
5546
-
5547
- //#endregion
5548
- //#region ../../packages/shared/src/journeys-v2/guest.ts
5549
- /**
5550
- * The guest protocol (spec: Guest protocol): the JSON a compiled module
5551
- * reads on stdin and writes on stdout. The sandbox worker parses every byte
5552
- * a guest returns with these schemas before anything acts on it; the guest
5553
- * SDK and the Node simulator produce and consume the same shapes.
5554
- */
5555
- /** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
5556
- const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
5557
- const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
5558
- const DURATION_UNIT_MS = {
5559
- ms: 1,
5560
- s: 1e3,
5561
- m: 6e4,
5562
- h: 36e5,
5563
- d: 864e5,
5564
- w: 6048e5
5565
- };
5566
- /**
5567
- * A duration in milliseconds. The simulator's virtual clock and the runner's
5568
- * timers both need it, and neither may pull in Temporal's `msToNumber` (one
5569
- * runs in the CLI, the other inside workflow code).
5808
+ * A duration in milliseconds. The simulator's virtual clock and the runner's
5809
+ * timers both need it, and neither may pull in Temporal's `msToNumber` (one
5810
+ * runs in the CLI, the other inside workflow code).
5570
5811
  */
5571
5812
  function parseDuration(duration) {
5572
5813
  if (typeof duration === "number") return duration;
@@ -5607,14 +5848,21 @@ const commandSchema = z.discriminatedUnion("name", [
5607
5848
  })
5608
5849
  }),
5609
5850
  z.object({
5610
- name: z.literal("email.send"),
5851
+ name: z.literal("send.email"),
5611
5852
  args: z.strictObject({
5612
5853
  template: journeyKeySchema,
5613
- 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
5614
5862
  })
5615
5863
  }),
5616
5864
  z.object({
5617
- name: z.literal("webhook.send"),
5865
+ name: z.literal("send.webhook"),
5618
5866
  args: z.strictObject({
5619
5867
  destination: z.string().min(1),
5620
5868
  payload: properties
@@ -5691,7 +5939,7 @@ const executionLimitsSchema = z.object({
5691
5939
  logLineBytes: z.number().int().positive()
5692
5940
  });
5693
5941
  const journeyStepInputSchema = z.object({
5694
- protocol: z.literal(1),
5942
+ protocol: z.literal(2),
5695
5943
  kind: z.literal("journey"),
5696
5944
  key: journeyKeySchema,
5697
5945
  event: guestEventSchema,
@@ -5731,7 +5979,7 @@ const journeyStepOutputSchema = z.discriminatedUnion("status", [
5731
5979
  })
5732
5980
  ]);
5733
5981
  const templateRenderInputSchema = z.object({
5734
- protocol: z.literal(1),
5982
+ protocol: z.literal(2),
5735
5983
  kind: z.literal("template"),
5736
5984
  key: journeyKeySchema,
5737
5985
  props: properties
@@ -5750,6 +5998,7 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
5750
5998
  const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
5751
5999
  trigger: true,
5752
6000
  purpose: true,
6001
+ senderIdentity: true,
5753
6002
  environments: true,
5754
6003
  tags: true
5755
6004
  }).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
@@ -5845,14 +6094,14 @@ const listExecutionsQuerySchema = paginationQuerySchema.extend({
5845
6094
  */
5846
6095
  const journeyTriggerSchema = triggerSchema;
5847
6096
  /**
5848
- * The step spine `cow build` recorded by running the journey once against a
5849
- * recording stub. Display only, never trusted: a journey's real control flow
5850
- * 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.
5851
6100
  */
5852
6101
  const journeySpineEntrySchema = spineEntrySchema;
5853
6102
  const journeySchema = selectJourneySchema.extend({
5854
6103
  trigger: journeyTriggerSchema,
5855
- purpose: z.enum(CONSENT_PURPOSES),
6104
+ purpose: consentPurposeKeySchema,
5856
6105
  spine: z.array(journeySpineEntrySchema),
5857
6106
  enabled: z.boolean(),
5858
6107
  createdAt: z.iso.datetime(),
@@ -5952,6 +6201,13 @@ const cowConfigSchema = z.strictObject({
5952
6201
  * slice of the deployed journeys, so every project says which it is.
5953
6202
  */
5954
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(),
5955
6211
  /** Overrides the API the CLI talks to; the hosted product needs none. */
5956
6212
  apiUrl: z.url().optional(),
5957
6213
  /**
@@ -6032,6 +6288,38 @@ const journeyScenarioSchema = z.object({
6032
6288
  }))
6033
6289
  });
6034
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
+
6035
6323
  //#endregion
6036
6324
  //#region ../../packages/shared/src/releases.ts
6037
6325
  /**
@@ -6087,7 +6375,13 @@ const releaseSchema = selectReleaseSchema.extend({
6087
6375
  compiledAt: z.iso.datetime().nullable()
6088
6376
  });
6089
6377
  const createReleaseBodySchema = z.object({ data: z.object({
6090
- 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) }),
6091
6385
  /** The `cow.json` project this push belongs to. */
6092
6386
  project: projectNameSchema
6093
6387
  }) });
@@ -6573,13 +6867,6 @@ const findUserQuerySchema = z.object({ identifier: z.string().trim().min(3, "ide
6573
6867
  * endpoint answers with: a merged-away id redirects to its survivor.
6574
6868
  */
6575
6869
  const userDetailSchema = profileDtoSchema.extend({ mergedIds: z.array(z.string()) });
6576
- /**
6577
- * Consent editor body (PATCH /v1/users/:profileId/consent): a partial map
6578
- * over the fixed purposes, applied as a merge patch, so omitting a purpose
6579
- * leaves it as it was. At least one purpose is required: an empty patch is
6580
- * a no-op request, not a valid one.
6581
- */
6582
- 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` });
6583
6870
  const updateConsentBodySchema = z.object({ data: consentPatchSchema });
6584
6871
  /**
6585
6872
  * DELETE /v1/users/:profileId: what erasure did, so the dashboard action
@@ -6819,6 +7106,374 @@ function readCliPackage() {
6819
7106
  return cached;
6820
7107
  }
6821
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;
7128
+ try {
7129
+ program = parse(source, {
7130
+ sourceType: "module",
7131
+ plugins: ["typescript"],
7132
+ errorRecovery: true
7133
+ }).program;
7134
+ } catch {
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
+ }
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}`;
7467
+ }
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;
7472
+ }
7473
+ function isNode(value) {
7474
+ return typeof value === "object" && value !== null && typeof value.type === "string";
7475
+ }
7476
+
6822
7477
  //#endregion
6823
7478
  //#region src/build/index.ts
6824
7479
  /**
@@ -6843,11 +7498,6 @@ const CONFIG_FILES$1 = [
6843
7498
  "tsconfig.json"
6844
7499
  ];
6845
7500
  /**
6846
- * The spine is display only, so a loop that would record forever is cut
6847
- * here rather than being allowed to grow the manifest.
6848
- */
6849
- const SPINE_LIMIT = 200;
6850
- /**
6851
7501
  * The guest SDK a bundle links against is always the CLI's own, whatever
6852
7502
  * the project has installed: the manifest records that version as `sdk`,
6853
7503
  * and the sandbox runs the driver from these files.
@@ -6923,7 +7573,7 @@ async function discover(projectDir, kind, extension) {
6923
7573
  }
6924
7574
  /**
6925
7575
  * Two files under the same directory tree cannot share a key: the key is
6926
- * 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
6927
7577
  * journey and a template may share one (they are separate namespaces, and
6928
7578
  * a journey that sends its own email usually does).
6929
7579
  */
@@ -6937,7 +7587,7 @@ function assertUniqueKeys(files) {
6937
7587
  }
6938
7588
  /**
6939
7589
  * `.cow/types.d.ts`: one `typeof import(...)` per template, merged into the
6940
- * 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
6941
7591
  * the typecheck, because the typecheck is what it exists for.
6942
7592
  */
6943
7593
  function templateTypes(templates) {
@@ -7106,93 +7756,29 @@ async function loadNodeBundle(projectDir, key, kind = "journeys") {
7106
7756
  runGuest: loaded.runGuest
7107
7757
  };
7108
7758
  }
7109
- /** Stops the recording stub at `restart` and at the spine cap. */
7110
- var SpineStop = class extends Error {};
7111
7759
  /**
7112
- * A profile with nothing in it but the id and traits given: what the spine
7113
- * stub's reads answer, and the base the simulator answers `profile.get` with.
7114
- * The spec says a spine read returns an empty object; a bare `{}` ends most
7115
- * recordings at the first `me.traits.x`, which is the first line of half the
7116
- * journeys there are, so the shape is kept and the contents emptied. Consent
7117
- * 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.
7118
7766
  */
7119
- function emptyProfile(id = "", traits = {}) {
7767
+ function emptyProfile(id = "", traits = {}, purposes = []) {
7120
7768
  return {
7121
7769
  id,
7122
7770
  traits,
7123
- 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
+ },
7124
7778
  identifiers: {},
7125
7779
  segments: []
7126
7780
  };
7127
7781
  }
7128
- /**
7129
- * The step spine: `run` once against a stub whose reads answer empty and
7130
- * whose waits answer null, keeping what was recorded up to the first throw.
7131
- *
7132
- * ponytail: a journey that awaits a promise nothing settles (rather than an
7133
- * api call) hangs the build here. The cap covers the loop that matters,
7134
- * `for (;;) await api.sleep(...)`; a wall-clock guard is the upgrade if a
7135
- * real project ever manages it.
7136
- */
7137
- async function recordSpine(module, trigger) {
7138
- const spine = [];
7139
- const push = (name, detail) => {
7140
- if (spine.length >= SPINE_LIMIT) throw new SpineStop();
7141
- spine.push(detail === void 0 ? { name } : {
7142
- name,
7143
- detail
7144
- });
7145
- };
7146
- const api = {
7147
- sleep: async () => {
7148
- push("sleep");
7149
- },
7150
- waitForEvent: async (pattern) => {
7151
- push("waitForEvent", patternLabel(pattern));
7152
- return null;
7153
- },
7154
- email: { send: async (args) => {
7155
- push("email.send", String(args.template));
7156
- return {};
7157
- } },
7158
- webhook: { send: async (args) => {
7159
- push("webhook.send", args.destination);
7160
- } },
7161
- traits: {
7162
- set: async (key) => {
7163
- push("traits.set", key);
7164
- },
7165
- unset: async (key) => {
7166
- push("traits.unset", key);
7167
- }
7168
- },
7169
- profile: { get: async () => {
7170
- push("profile.get");
7171
- return emptyProfile();
7172
- } },
7173
- profiles: { get: async (id) => {
7174
- push("profiles.get", id);
7175
- return emptyProfile();
7176
- } },
7177
- events: { track: async (name) => {
7178
- push("events.track", name);
7179
- } },
7180
- log: () => {},
7181
- restart: async () => {
7182
- push("restart");
7183
- throw new SpineStop();
7184
- }
7185
- };
7186
- const journey = module.default;
7187
- try {
7188
- await journey.run({
7189
- name: "event" in trigger ? patternPlaceholder(trigger.event) : SYSTEM_EVENTS.segmentEntered,
7190
- properties: {},
7191
- timestamp: 0
7192
- }, api);
7193
- } catch {}
7194
- return spine;
7195
- }
7196
7782
  /** The release limits (spec: Limits), naming the offending count or size. */
7197
7783
  function limitFailure(sizes) {
7198
7784
  if (sizes.journeys > RELEASE_LIMITS.journeys) return `This project has ${sizes.journeys} journeys; a release carries at most ${RELEASE_LIMITS.journeys}.`;
@@ -7306,8 +7892,9 @@ async function buildProject(projectDir) {
7306
7892
  tags: report.tags,
7307
7893
  trigger: report.trigger,
7308
7894
  purpose: report.purpose,
7895
+ senderIdentity: report.senderIdentity,
7309
7896
  environments: report.environments,
7310
- spine: await recordSpine(module, report.trigger),
7897
+ spine: readSpine(await readFile(built.source.file, "utf8")),
7311
7898
  bundle: built.digest
7312
7899
  });
7313
7900
  else manifestTemplates.push({
@@ -7332,10 +7919,11 @@ async function buildProject(projectDir) {
7332
7919
  });
7333
7920
  if (failure) throw new Error(failure);
7334
7921
  const manifest = manifestSchema.parse({
7335
- protocol: 1,
7922
+ protocol: 2,
7336
7923
  sdk: (await readCliPackage()).version,
7337
7924
  journeys: manifestJourneys,
7338
7925
  templates: manifestTemplates,
7926
+ purposes: config.purposes,
7339
7927
  source: digestOf(await readFile(sourceFile))
7340
7928
  });
7341
7929
  await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -8443,6 +9031,31 @@ const catalog = defineModule(defineRoute({
8443
9031
  }
8444
9032
  }));
8445
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
+
8446
9059
  //#endregion
8447
9060
  //#region ../../packages/shared/src/contract/deliveries.ts
8448
9061
  /**
@@ -8502,7 +9115,16 @@ const deliveries = defineModule(defineRoute({
8502
9115
  summary: "One-click unsubscribe (public)",
8503
9116
  security: NO_AUTH,
8504
9117
  surfaces: HIDDEN_FROM_TOOLS,
8505
- 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() }) },
8506
9128
  responses: {
8507
9129
  200: htmlResponse("Confirmation page"),
8508
9130
  400: { description: "Invalid or incomplete unsubscribe link" }
@@ -9057,6 +9679,55 @@ const journeys = defineModule(defineRoute({
9057
9679
  }
9058
9680
  }));
9059
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
+
9060
9731
  //#endregion
9061
9732
  //#region ../../packages/shared/src/contract/project.ts
9062
9733
  /**
@@ -9752,6 +10423,8 @@ const contract = {
9752
10423
  openapi,
9753
10424
  ingestion,
9754
10425
  users,
10426
+ me,
10427
+ consent,
9755
10428
  events,
9756
10429
  apps,
9757
10430
  sources,
@@ -10280,7 +10953,10 @@ async function pushProject({ client, projectDir, force, sleep = (ms) => setTimeo
10280
10953
  }
10281
10954
  return {
10282
10955
  release: await awaitCompile(client, (await client.request(contract.releases["releases.create"], { body: {
10283
- manifest,
10956
+ manifest: {
10957
+ ...manifest,
10958
+ protocol: 2
10959
+ },
10284
10960
  project: project.name
10285
10961
  } })).data.id, sleep, progress),
10286
10962
  uploaded
@@ -11121,7 +11797,7 @@ async function runScenario(projectDir, key, scenario) {
11121
11797
  if (!journey) throw new Error(`Unknown journey "${key}". This project builds: ${manifest?.journeys.map((one) => one.key).join(", ") || "no journeys"}.`);
11122
11798
  const wallStart = Date.now();
11123
11799
  const { module, runGuest } = await loadNodeBundle(projectDir, key);
11124
- const profile = emptyProfile(scenario.user.id, { ...scenario.user.traits });
11800
+ const profile = emptyProfile(scenario.user.id, { ...scenario.user.traits }, manifest?.purposes);
11125
11801
  const scripted = scenario.events.map((one) => ({
11126
11802
  ...one,
11127
11803
  atMs: SIM_START + parseDuration(one.at),
@@ -11170,7 +11846,7 @@ async function runScenario(projectDir, key, scenario) {
11170
11846
  now
11171
11847
  };
11172
11848
  }
11173
- case "email.send":
11849
+ case "send.email":
11174
11850
  record("sendEmail", command.args);
11175
11851
  sends += 1;
11176
11852
  return {
@@ -11180,7 +11856,7 @@ async function runScenario(projectDir, key, scenario) {
11180
11856
  }),
11181
11857
  now: clock
11182
11858
  };
11183
- case "webhook.send":
11859
+ case "send.webhook":
11184
11860
  record("sendWebhook", command.args);
11185
11861
  return {
11186
11862
  result: ok(null),
@@ -11246,7 +11922,7 @@ async function runScenario(projectDir, key, scenario) {
11246
11922
  let error;
11247
11923
  for (;;) {
11248
11924
  const output = await runGuest(module, {
11249
- protocol: 1,
11925
+ protocol: 2,
11250
11926
  kind: "journey",
11251
11927
  key,
11252
11928
  event: trigger,