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