@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/README.md +2 -1
- package/dist/guest/{constants-OZrYz4I2.d.ts → constants-B0wk-87t.d.ts} +1 -13
- package/dist/guest/{driver-M6LQVMr2.js → driver-DaNdNhuF.js} +18 -14
- package/dist/guest/driver.d.ts +1 -1
- package/dist/guest/driver.js +1 -1
- package/dist/guest/emails.d.ts +1 -1
- package/dist/guest/{index-heC1gFE_.d.ts → index-DAGLEHDn.d.ts} +11 -10
- package/dist/guest/{journeys-BcbIxxI-.js → journeys-B_xg_GnL.js} +190 -57
- package/dist/guest/journeys.d.ts +35 -11
- package/dist/guest/journeys.js +1 -1
- package/dist/guest/wasi.js +1 -1
- package/dist/index.js +978 -302
- package/examples/abandoned-checkout/journeys/abandoned-checkout.ts +2 -1
- package/examples/abandoned-checkout/scenarios/abandoned-checkout.timeout.json +2 -1
- package/examples/activity-decay/journeys/activity-decay.ts +1 -0
- package/examples/cross-app-pitch/journeys/cross-app-pitch.ts +5 -1
- package/examples/cross-app-pitch/scenarios/cross-app-pitch.json +2 -1
- package/examples/winback/journeys/winback.ts +2 -1
- package/examples/winback/scenarios/winback.json +4 -1
- package/package.json +3 -1
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$
|
|
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$
|
|
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
|
|
3668
|
-
*
|
|
3669
|
-
*
|
|
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:
|
|
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
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
5255
|
-
|
|
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/
|
|
5789
|
+
//#region ../../packages/shared/src/journeys-v2/guest.ts
|
|
5401
5790
|
/**
|
|
5402
|
-
* The
|
|
5403
|
-
*
|
|
5404
|
-
*
|
|
5405
|
-
* the
|
|
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
|
|
5409
|
-
*
|
|
5410
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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`
|
|
5849
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
7113
|
-
*
|
|
7114
|
-
*
|
|
7115
|
-
*
|
|
7116
|
-
*
|
|
7117
|
-
*
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
11925
|
+
protocol: 2,
|
|
11250
11926
|
kind: "journey",
|
|
11251
11927
|
key,
|
|
11252
11928
|
event: trigger,
|