@cowliss/cli 0.5.0 → 0.7.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 +4 -5
- package/dist/guest/{driver-14FnzM-h.js → driver-C1bsCjZT.js} +7 -3
- package/dist/guest/driver.d.ts +1 -1
- package/dist/guest/driver.js +1 -1
- package/dist/guest/emails.d.ts +6 -0
- package/dist/guest/{index-BDj02EsQ.d.ts → index-3PMqJKmc.d.ts} +111 -16
- package/dist/guest/{journeys-C8j8Sqvl.js → journeys-Dpsp225V.js} +286 -87
- package/dist/guest/journeys.d.ts +52 -23
- package/dist/guest/journeys.js +1 -1
- package/dist/guest/wasi.js +1 -1
- package/dist/index.js +1504 -987
- package/examples/abandoned-checkout/journeys/abandoned-checkout.ts +9 -2
- package/examples/abandoned-checkout/scenarios/abandoned-checkout.timeout.json +1 -2
- package/examples/activity-decay/journeys/activity-decay.ts +13 -3
- package/examples/cross-app-pitch/journeys/cross-app-pitch.ts +10 -5
- package/examples/cross-app-pitch/scenarios/cross-app-pitch.json +1 -1
- package/examples/winback/journeys/winback.ts +10 -3
- package/examples/winback/scenarios/winback.json +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -38,24 +38,6 @@ const REQUEST_ID_HEADER = "X-Request-Id";
|
|
|
38
38
|
*/
|
|
39
39
|
const CLERK_ORG_SUBJECT_PREFIX = "org_";
|
|
40
40
|
/**
|
|
41
|
-
* The two fixed environments every org has (spec: Environments). An app
|
|
42
|
-
* belongs to one, so the environment of every write is the app's;
|
|
43
|
-
* profiles, identifiers, events, memberships, journey instances,
|
|
44
|
-
* deliveries, violations, quarantine, the address ledger, and idempotency
|
|
45
|
-
* keys are per environment, while the catalog, sending domains, the
|
|
46
|
-
* suppression mirror, billing, and journey code are shared. A third
|
|
47
|
-
* environment is a one-line change here plus the mirrored db enum.
|
|
48
|
-
*/
|
|
49
|
-
const ENVIRONMENTS = ["development", "production"];
|
|
50
|
-
/** What an admin call gets when it names no environment. */
|
|
51
|
-
const DEFAULT_ENVIRONMENT = "production";
|
|
52
|
-
/**
|
|
53
|
-
* The request header admin endpoints read the environment from; absent
|
|
54
|
-
* means production. Ingestion endpoints ignore it: the environment of a
|
|
55
|
-
* write comes from the app.
|
|
56
|
-
*/
|
|
57
|
-
const ENVIRONMENT_HEADER = "X-Cow-Environment";
|
|
58
|
-
/**
|
|
59
41
|
* The header every Cowliss client names itself in, as
|
|
60
42
|
* `<package>/<version>` (`@cowliss/cli/0.1.0`). The API logs it and reads
|
|
61
43
|
* nothing from it: no behaviour depends on the value, so a caller that
|
|
@@ -74,16 +56,6 @@ const ENVIRONMENT_HEADER = "X-Cow-Environment";
|
|
|
74
56
|
*/
|
|
75
57
|
const CLIENT_HEADER = "X-Cow-Client";
|
|
76
58
|
/**
|
|
77
|
-
* Event retention per environment, in days: both the default (what an org
|
|
78
|
-
* gets without setting anything) and the cap (the API refuses more). There
|
|
79
|
-
* is no "forever". Production keeps 18 months; development keeps 30 days,
|
|
80
|
-
* because test traffic is disposable by definition.
|
|
81
|
-
*/
|
|
82
|
-
const EVENT_RETENTION_DAYS = {
|
|
83
|
-
production: 548,
|
|
84
|
-
development: 30
|
|
85
|
-
};
|
|
86
|
-
/**
|
|
87
59
|
* The named identifiers a call may carry, a fixed enum grown by code (spec:
|
|
88
60
|
* Identity). `anonymousId` is reserved only in that a profile whose
|
|
89
61
|
* identifiers are all anonymous is labelled anonymous in the dashboard;
|
|
@@ -110,30 +82,30 @@ const TOPUP_PRESETS_MICROS = [
|
|
|
110
82
|
2e8
|
|
111
83
|
];
|
|
112
84
|
/**
|
|
113
|
-
* Fixed consent purposes for the prototype. Consent is a per-purpose map on
|
|
114
|
-
* the profile, checked at send-step execution time.
|
|
115
|
-
*
|
|
116
|
-
* The two names describe what the recipient agreed to, not the pipe it
|
|
117
|
-
* arrives on: "emails I did not ask for individually" and "my data leaving
|
|
118
|
-
* for somewhere else". Naming them after the channel (`email`, `webhook`)
|
|
119
|
-
* said nothing a recipient could consent to, and transactional mail already
|
|
120
|
-
* bypasses the email purpose, so it was only ever marketing consent.
|
|
121
|
-
*/
|
|
122
|
-
const CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
|
|
123
|
-
/**
|
|
124
85
|
* The marketing purpose by name, since it is the one every gate, the
|
|
125
86
|
* unsubscribe route, and the developer's own toggle all reach for.
|
|
126
87
|
*/
|
|
127
|
-
const
|
|
88
|
+
const MARKETING = "marketing";
|
|
89
|
+
/**
|
|
90
|
+
* A legal journey `purpose` that is not a consent purpose: the send gates
|
|
91
|
+
* pass it unconditionally, no profile's map stores it and no editor renders
|
|
92
|
+
* it. What protects the channel itself (suppression, the SES gates, the
|
|
93
|
+
* quota) still applies.
|
|
94
|
+
*/
|
|
95
|
+
const TRANSACTIONAL = "transactional";
|
|
96
|
+
/**
|
|
97
|
+
* The purposes a project may not declare, which are the same two that sit
|
|
98
|
+
* outside the `marketing` umbrella: `marketing` is the umbrella itself and
|
|
99
|
+
* `transactional` is the case consent does not govern. Everything a project
|
|
100
|
+
* declares is a marketing-mail category and so sits under it.
|
|
101
|
+
*/
|
|
102
|
+
const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
|
|
128
103
|
/**
|
|
129
104
|
* What a purpose means when the profile's map does not answer it, matching
|
|
130
|
-
* the `profiles.consent` column default: marketing is asked for,
|
|
131
|
-
*
|
|
105
|
+
* the `profiles.consent` column default: marketing is asked for, never
|
|
106
|
+
* assumed.
|
|
132
107
|
*/
|
|
133
|
-
const CONSENT_PURPOSE_DEFAULTS = {
|
|
134
|
-
emailMarketing: false,
|
|
135
|
-
dataProcessing: true
|
|
136
|
-
};
|
|
108
|
+
const CONSENT_PURPOSE_DEFAULTS = { marketing: false };
|
|
137
109
|
/**
|
|
138
110
|
* The `defaults` argument `consentGranted` takes, built from an org's
|
|
139
111
|
* purpose rows. Every surface that renders or gates a purpose reads those
|
|
@@ -3084,21 +3056,6 @@ const abuseEvents = pgTable("abuse_events", {
|
|
|
3084
3056
|
const selectAbuseEventSchema = createSelectSchema(abuseEvents);
|
|
3085
3057
|
const insertAbuseEventSchema = createInsertSchema(abuseEvents);
|
|
3086
3058
|
|
|
3087
|
-
//#endregion
|
|
3088
|
-
//#region ../../packages/db/src/schema/environments.ts
|
|
3089
|
-
/**
|
|
3090
|
-
* The two fixed environments every org has. A partition inside the org, not
|
|
3091
|
-
* a resource: no table, no ids, no create surface. The environment of a
|
|
3092
|
-
* write comes from the app it names (apps carry one), and everything
|
|
3093
|
-
* downstream of a write (profiles, identifiers, events, memberships,
|
|
3094
|
-
* deliveries, runs, violations, quarantine, the address ledger) is stamped
|
|
3095
|
-
* with it.
|
|
3096
|
-
*
|
|
3097
|
-
* The literal list mirrors ENVIRONMENTS in packages/shared/constants; db
|
|
3098
|
-
* cannot import it without closing a package cycle (shared -> db).
|
|
3099
|
-
*/
|
|
3100
|
-
const environmentEnum = pgEnum("environment", ["development", "production"]);
|
|
3101
|
-
|
|
3102
3059
|
//#endregion
|
|
3103
3060
|
//#region ../../packages/db/src/schema/timestamps.ts
|
|
3104
3061
|
/**
|
|
@@ -3134,12 +3091,7 @@ const updatedAt = () => stamp("updated_at").notNull().defaultNow().$onUpdate(()
|
|
|
3134
3091
|
/**
|
|
3135
3092
|
* Apps: the registry of an org's apps/products, and the attribution unit.
|
|
3136
3093
|
* Everything downstream (ingestion, via the source a payload names;
|
|
3137
|
-
* segments, journeys,
|
|
3138
|
-
*
|
|
3139
|
-
* An app belongs to exactly one environment, chosen at creation and
|
|
3140
|
-
* immutable: the environment of every write is the environment of the
|
|
3141
|
-
* app behind the source the payload names, and nothing else carries it.
|
|
3142
|
-
* The dev and prod rows of one app are two rows with two names and two ids.
|
|
3094
|
+
* segments, journeys, webhooks) validates appIds against this table.
|
|
3143
3095
|
*
|
|
3144
3096
|
* The id is readable, `app_<slug>` derived from the name at creation, and
|
|
3145
3097
|
* unique per org rather than globally: the primary key is (orgId, id), and
|
|
@@ -3162,7 +3114,6 @@ const appStatusEnum = pgEnum("app_status", ["active", "archived"]);
|
|
|
3162
3114
|
const apps$1 = pgTable("apps", {
|
|
3163
3115
|
id: text("id").notNull(),
|
|
3164
3116
|
orgId: text("org_id").notNull(),
|
|
3165
|
-
environment: environmentEnum("environment").notNull(),
|
|
3166
3117
|
name: text("name").notNull(),
|
|
3167
3118
|
status: appStatusEnum("status").notNull().default("active"),
|
|
3168
3119
|
createdAt: createdAt(),
|
|
@@ -3317,7 +3268,7 @@ const insertCatalogTraitSchema = createInsertSchema(catalogTraits);
|
|
|
3317
3268
|
* in their `cow.json`. One read here returns the whole set, so nothing
|
|
3318
3269
|
* downstream unions a table with a constant.
|
|
3319
3270
|
*
|
|
3320
|
-
* Org-wide rather than per-project
|
|
3271
|
+
* Org-wide rather than per-project: `profiles.consent` is
|
|
3321
3272
|
* one jsonb map per profile, so an answer given under one project is the
|
|
3322
3273
|
* same answer under the next, and two projects declaring one key must agree
|
|
3323
3274
|
* on its label and default or the deploy is refused.
|
|
@@ -3367,8 +3318,16 @@ const deliveryChannelEnum = pgEnum("delivery_channel", ["email", "webhook"]);
|
|
|
3367
3318
|
* nullable. The meter and the quota gate exclude it (packages/delivery's
|
|
3368
3319
|
* repository), and every per-profile read excludes it by construction,
|
|
3369
3320
|
* because it has no profile to be found under.
|
|
3321
|
+
*
|
|
3322
|
+
* `api` is a direct send through the Resend-compatible facade (ADR 0014):
|
|
3323
|
+
* no journey and no profile either, addressed to whatever the caller named,
|
|
3324
|
+
* and metered exactly like a journey's email.
|
|
3370
3325
|
*/
|
|
3371
|
-
const deliveryKindEnum = pgEnum("delivery_kind", [
|
|
3326
|
+
const deliveryKindEnum = pgEnum("delivery_kind", [
|
|
3327
|
+
"journey",
|
|
3328
|
+
"test",
|
|
3329
|
+
"api"
|
|
3330
|
+
]);
|
|
3372
3331
|
/**
|
|
3373
3332
|
* The delivery lifecycle, frozen with the API version.
|
|
3374
3333
|
*
|
|
@@ -3382,6 +3341,10 @@ const deliveryKindEnum = pgEnum("delivery_kind", ["journey", "test"]);
|
|
|
3382
3341
|
* `skipped_suppressed` is the recipient's address sitting in the
|
|
3383
3342
|
* suppression mirror, which SES would have bounced off its own
|
|
3384
3343
|
* suppression list anyway.
|
|
3344
|
+
* `skipped_unknown_purpose` is the version naming a purpose the
|
|
3345
|
+
* organization does not have, which is a broken manifest rather than a
|
|
3346
|
+
* consent decision: the `error` column names the purpose, and the fix is
|
|
3347
|
+
* another push (ADR 0017).
|
|
3385
3348
|
* - `would_*` are dry-run outcomes: terminal, never reach SES, and
|
|
3386
3349
|
* excluded from feedback, quotas, and reconciliation.
|
|
3387
3350
|
*
|
|
@@ -3398,9 +3361,9 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
|
|
|
3398
3361
|
"skipped_paused",
|
|
3399
3362
|
"skipped_suppressed",
|
|
3400
3363
|
"skipped_quota",
|
|
3364
|
+
"skipped_unknown_purpose",
|
|
3401
3365
|
"skipped_frequency_cap",
|
|
3402
3366
|
"skipped_consent",
|
|
3403
|
-
"skipped_sender",
|
|
3404
3367
|
"skipped_domain",
|
|
3405
3368
|
"skipped_ssrf",
|
|
3406
3369
|
"would_send",
|
|
@@ -3408,36 +3371,50 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
|
|
|
3408
3371
|
"would_skip_paused",
|
|
3409
3372
|
"would_skip_suppressed",
|
|
3410
3373
|
"would_skip_quota",
|
|
3374
|
+
"would_skip_unknown_purpose",
|
|
3411
3375
|
"would_skip_frequency_cap",
|
|
3412
3376
|
"would_skip_consent",
|
|
3413
|
-
"would_skip_sender",
|
|
3414
3377
|
"would_skip_domain",
|
|
3415
3378
|
"would_skip_ssrf"
|
|
3416
3379
|
]);
|
|
3417
3380
|
const deliveries$1 = pgTable("deliveries", {
|
|
3418
3381
|
id: text("id").primaryKey(),
|
|
3419
3382
|
orgId: text("org_id").notNull(),
|
|
3420
|
-
/** The instance's environment, stamped from the workflow run input. */
|
|
3421
|
-
environment: environmentEnum("environment").notNull(),
|
|
3422
3383
|
/**
|
|
3423
3384
|
* The recipient profile (`usr_`); a text column, since a delivery is a
|
|
3424
3385
|
* log row and outlives nothing but erasure. Null on a test send, which
|
|
3425
|
-
* goes to the member who asked for it rather than to anyone's profile
|
|
3386
|
+
* goes to the member who asked for it rather than to anyone's profile,
|
|
3387
|
+
* and on an api send, which names addresses and knows no profile.
|
|
3426
3388
|
*/
|
|
3427
3389
|
profileId: text("profile_id"),
|
|
3428
|
-
/**
|
|
3390
|
+
/**
|
|
3391
|
+
* The journey that sent this; null on a test send and on an api send,
|
|
3392
|
+
* neither of which has one.
|
|
3393
|
+
*/
|
|
3429
3394
|
journey: text("journey"),
|
|
3430
3395
|
kind: deliveryKindEnum("kind").notNull().default("journey"),
|
|
3431
|
-
/**
|
|
3396
|
+
/**
|
|
3397
|
+
* Email template name or webhook name. An api send that named no
|
|
3398
|
+
* template says `email`: there is a message but no key to point at.
|
|
3399
|
+
*/
|
|
3432
3400
|
step: text("step").notNull(),
|
|
3433
3401
|
channel: deliveryChannelEnum("channel").notNull(),
|
|
3434
3402
|
status: deliveryStatusEnum("status").notNull(),
|
|
3435
3403
|
dryRun: boolean("dry_run").notNull().default(false),
|
|
3436
3404
|
/**
|
|
3437
|
-
* Recipient email address or
|
|
3438
|
-
*
|
|
3405
|
+
* Recipient email address or webhook URL. An api send to several
|
|
3406
|
+
* addresses records the first `to`; the rest are in the payload. PII:
|
|
3407
|
+
* the logger redacts this path, so it is stored here and nowhere else.
|
|
3439
3408
|
*/
|
|
3440
3409
|
recipient: text("recipient"),
|
|
3410
|
+
/**
|
|
3411
|
+
* What the send was made of, per kind: a journey or test email holds the
|
|
3412
|
+
* template props (plus `rendered` on a dry run or a test), a webhook
|
|
3413
|
+
* holds its body, and an `api` send holds the caller's envelope as they
|
|
3414
|
+
* spelled it, `{ from, to, cc, bcc, reply_to, subject, headers,
|
|
3415
|
+
* template, variables }`. Never the rendered bodies of a real send: SES
|
|
3416
|
+
* has the message and the log does not keep a second copy.
|
|
3417
|
+
*/
|
|
3441
3418
|
payload: jsonb("payload").$type().notNull().default({}),
|
|
3442
3419
|
/**
|
|
3443
3420
|
* The provider's message id (SES's MessageId), stored so a feedback
|
|
@@ -3470,7 +3447,7 @@ const deliveries$1 = pgTable("deliveries", {
|
|
|
3470
3447
|
precision: 3
|
|
3471
3448
|
})
|
|
3472
3449
|
}, (table) => [
|
|
3473
|
-
index("
|
|
3450
|
+
index("deliveries_org_id_created_at_idx").on(table.orgId, table.createdAt),
|
|
3474
3451
|
index("deliveries_org_id_profile_id_created_at_idx").on(table.orgId, table.profileId, table.createdAt),
|
|
3475
3452
|
uniqueIndex("deliveries_org_id_attempt_key_unique").on(table.orgId, table.attemptKey),
|
|
3476
3453
|
uniqueIndex("deliveries_provider_id_unique").on(table.providerId),
|
|
@@ -3479,62 +3456,14 @@ const deliveries$1 = pgTable("deliveries", {
|
|
|
3479
3456
|
const selectDeliverySchema = createSelectSchema(deliveries$1);
|
|
3480
3457
|
const insertDeliverySchema = createInsertSchema(deliveries$1);
|
|
3481
3458
|
|
|
3482
|
-
//#endregion
|
|
3483
|
-
//#region ../../packages/db/src/schema/destinations.ts
|
|
3484
|
-
/**
|
|
3485
|
-
* Destinations: named send targets (webhook URLs, sender identities) that
|
|
3486
|
-
* journeys reference by name. Mirrors the apps table conventions: ms
|
|
3487
|
-
* precision timestamps for cursor round-trips, one environment chosen at
|
|
3488
|
-
* creation and immutable, and name uniqueness per org AND environment, so
|
|
3489
|
-
* `crm` can point at the staging receiver in development and at the real one
|
|
3490
|
-
* in production. A journey step naming `webhook("crm")` resolves to the row
|
|
3491
|
-
* in the instance's environment; the same rule picks the sender identity, of
|
|
3492
|
-
* which an org has one per environment.
|
|
3493
|
-
*
|
|
3494
|
-
* WEBHOOK_PAYLOAD_VERSION (packages/shared) pins the webhook payload shape;
|
|
3495
|
-
* the column records the version each destination was created against.
|
|
3496
|
-
* signingSecret is the Standard Webhooks signing secret, generated at
|
|
3497
|
-
* creation and shown once — the API DTO omits it after the create response.
|
|
3498
|
-
*/
|
|
3499
|
-
const destinationTypeEnum = pgEnum("destination_type", ["webhook", "sender_identity"]);
|
|
3500
|
-
const destinations$1 = pgTable("destinations", {
|
|
3501
|
-
id: text("id").primaryKey(),
|
|
3502
|
-
orgId: text("org_id").notNull(),
|
|
3503
|
-
environment: environmentEnum("environment").notNull(),
|
|
3504
|
-
name: text("name").notNull(),
|
|
3505
|
-
type: destinationTypeEnum("type").notNull(),
|
|
3506
|
-
config: jsonb("config").$type().notNull(),
|
|
3507
|
-
webhookPayloadVersion: integer("webhook_payload_version").notNull().default(1),
|
|
3508
|
-
signingSecret: text("signing_secret"),
|
|
3509
|
-
/**
|
|
3510
|
-
* Auto-disable state, mirroring how receivers treat us: the webhook send
|
|
3511
|
-
* activity bumps the counter once per failed delivery attempt (not once
|
|
3512
|
-
* per Temporal retry) and stamps disabledAt at the threshold. A disabled
|
|
3513
|
-
* destination records `skipped_disabled` until an admin clears it; a
|
|
3514
|
-
* successful delivery resets the counter.
|
|
3515
|
-
*/
|
|
3516
|
-
consecutiveFailures: integer("consecutive_failures").notNull().default(0),
|
|
3517
|
-
disabledAt: timestamp("disabled_at", {
|
|
3518
|
-
withTimezone: true,
|
|
3519
|
-
mode: "date",
|
|
3520
|
-
precision: 3
|
|
3521
|
-
}),
|
|
3522
|
-
createdAt: createdAt(),
|
|
3523
|
-
updatedAt: updatedAt()
|
|
3524
|
-
}, (table) => [index("destinations_org_id_idx").on(table.orgId), uniqueIndex("destinations_org_id_environment_name_unique").on(table.orgId, table.environment, table.name)]);
|
|
3525
|
-
const selectDestinationSchema = createSelectSchema(destinations$1);
|
|
3526
|
-
const insertDestinationSchema = createInsertSchema(destinations$1);
|
|
3527
|
-
|
|
3528
3459
|
//#endregion
|
|
3529
3460
|
//#region ../../packages/db/src/schema/email-addresses.ts
|
|
3530
3461
|
/**
|
|
3531
3462
|
* The email address ledger (ticket 35): everything Cowliss knows about an
|
|
3532
|
-
* address, scoped to the org and
|
|
3533
|
-
*
|
|
3534
|
-
*
|
|
3535
|
-
*
|
|
3536
|
-
* it. Per environment because a verification proven against a development
|
|
3537
|
-
* profile says nothing about production.
|
|
3463
|
+
* address, scoped to the org and keyed by the address rather than the
|
|
3464
|
+
* profile. Verification is address-scoped by nature: a profile that changes
|
|
3465
|
+
* its email has not verified the new one, and an address verified once
|
|
3466
|
+
* stays verified when the same profile comes back to it.
|
|
3538
3467
|
*
|
|
3539
3468
|
* The profile keeps its canonical `email` trait as the address Cowliss sends
|
|
3540
3469
|
* to, plus the system-maintained `emailVerifiedAt` / `emailVerifiedAddress`
|
|
@@ -3552,7 +3481,6 @@ const verificationMethodEnum = pgEnum("verification_method", [
|
|
|
3552
3481
|
const emailAddresses = pgTable("email_addresses", {
|
|
3553
3482
|
id: text("id").primaryKey(),
|
|
3554
3483
|
orgId: text("org_id").notNull(),
|
|
3555
|
-
environment: environmentEnum("environment").notNull(),
|
|
3556
3484
|
/** Stored lowercased and trimmed: the mailbox, not the way it was typed. */
|
|
3557
3485
|
address: text("address").notNull(),
|
|
3558
3486
|
/** Null until something verified the address. */
|
|
@@ -3564,45 +3492,10 @@ const emailAddresses = pgTable("email_addresses", {
|
|
|
3564
3492
|
verificationMethod: verificationMethodEnum("verification_method"),
|
|
3565
3493
|
createdAt: createdAt(),
|
|
3566
3494
|
updatedAt: updatedAt()
|
|
3567
|
-
}, (table) => [uniqueIndex("
|
|
3495
|
+
}, (table) => [uniqueIndex("email_addresses_org_address_unique").on(table.orgId, table.address)]);
|
|
3568
3496
|
const selectEmailAddressSchema = createSelectSchema(emailAddresses);
|
|
3569
3497
|
const insertEmailAddressSchema = createInsertSchema(emailAddresses);
|
|
3570
3498
|
|
|
3571
|
-
//#endregion
|
|
3572
|
-
//#region ../../packages/db/src/schema/environment-settings.ts
|
|
3573
|
-
/**
|
|
3574
|
-
* The ingestion policy for catalog governance. Single source of truth:
|
|
3575
|
-
* @cowliss/shared/governance derives INGESTION_POLICIES from
|
|
3576
|
-
* ingestionPolicyEnum.enumValues, so the enum and the settings contract
|
|
3577
|
-
* cannot drift.
|
|
3578
|
-
*/
|
|
3579
|
-
const ingestionPolicyEnum = pgEnum("ingestion_policy", ["permissive", "strict"]);
|
|
3580
|
-
/**
|
|
3581
|
-
* Per-environment settings, keyed by org and environment. "No row means
|
|
3582
|
-
* every default applies", the same convention as org_settings: a fresh org
|
|
3583
|
-
* is zero-config in both environments, and the write path never seeds rows
|
|
3584
|
-
* it does not own.
|
|
3585
|
-
*
|
|
3586
|
-
* `ingestionPolicy`: permissive accepts quarantined (unclassified) names and
|
|
3587
|
-
* observes them; strict routes them to the review queue instead of applying
|
|
3588
|
-
* them. Development can be strict while production stays lenient.
|
|
3589
|
-
*
|
|
3590
|
-
* `eventRetentionDays`: the ClickHouse TTL window for the environment's
|
|
3591
|
-
* events. Null means the environment's default (18 months in production,
|
|
3592
|
-
* 30 days in development, from packages/shared constants); there is no
|
|
3593
|
-
* "forever", and the API refuses a value above the environment's cap.
|
|
3594
|
-
*/
|
|
3595
|
-
const environmentSettings = pgTable("environment_settings", {
|
|
3596
|
-
orgId: text("org_id").notNull(),
|
|
3597
|
-
environment: environmentEnum("environment").notNull(),
|
|
3598
|
-
ingestionPolicy: ingestionPolicyEnum("ingestion_policy").notNull().default("permissive"),
|
|
3599
|
-
eventRetentionDays: integer("event_retention_days"),
|
|
3600
|
-
createdAt: createdAt(),
|
|
3601
|
-
updatedAt: updatedAt()
|
|
3602
|
-
}, (table) => [primaryKey({ columns: [table.orgId, table.environment] })]);
|
|
3603
|
-
const selectEnvironmentSettingsSchema = createSelectSchema(environmentSettings);
|
|
3604
|
-
const insertEnvironmentSettingsSchema = createInsertSchema(environmentSettings);
|
|
3605
|
-
|
|
3606
3499
|
//#endregion
|
|
3607
3500
|
//#region ../../packages/db/src/schema/executions.ts
|
|
3608
3501
|
const executionStatusEnum = pgEnum("execution_status", [
|
|
@@ -3615,7 +3508,6 @@ const executionStatusEnum = pgEnum("execution_status", [
|
|
|
3615
3508
|
const executions$1 = pgTable("executions", {
|
|
3616
3509
|
id: text("id").primaryKey(),
|
|
3617
3510
|
orgId: text("org_id").notNull(),
|
|
3618
|
-
environment: environmentEnum("environment").notNull(),
|
|
3619
3511
|
/** The journey's key, not a foreign key: derived journey rows come and go with pushes. */
|
|
3620
3512
|
journeyKey: text("journey_key").notNull(),
|
|
3621
3513
|
profileId: text("profile_id").notNull(),
|
|
@@ -3664,9 +3556,9 @@ const executions$1 = pgTable("executions", {
|
|
|
3664
3556
|
precision: 3
|
|
3665
3557
|
})
|
|
3666
3558
|
}, (table) => [
|
|
3667
|
-
index("
|
|
3668
|
-
|
|
3669
|
-
index("
|
|
3559
|
+
index("executions_org_id_version_id_status_idx").on(table.orgId, table.versionId, table.status),
|
|
3560
|
+
index("executions_org_id_workflow_id_status_idx").on(table.orgId, table.workflowId, table.status),
|
|
3561
|
+
index("executions_org_id_started_at_idx").on(table.orgId, table.startedAt)
|
|
3670
3562
|
]);
|
|
3671
3563
|
const selectExecutionSchema = createSelectSchema(executions$1);
|
|
3672
3564
|
const insertExecutionSchema = createInsertSchema(executions$1);
|
|
@@ -3725,7 +3617,7 @@ const idempotencyKeys = pgTable("idempotency_keys", {
|
|
|
3725
3617
|
//#endregion
|
|
3726
3618
|
//#region ../../packages/db/src/schema/profiles.ts
|
|
3727
3619
|
/**
|
|
3728
|
-
* Profiles: one row per person
|
|
3620
|
+
* Profiles: one row per person. Cowliss generates the id
|
|
3729
3621
|
* (`usr_`) and that id is the person key everywhere: workflow ids,
|
|
3730
3622
|
* ClickHouse rows, segment membership, deliveries, journey runs, erasure.
|
|
3731
3623
|
* Callers never send it on the write path; they send named identifiers
|
|
@@ -3733,15 +3625,11 @@ const idempotencyKeys = pgTable("idempotency_keys", {
|
|
|
3733
3625
|
* creating one when none is known.
|
|
3734
3626
|
*
|
|
3735
3627
|
* traits is the merged trait bag (RFC 7386 key-level merge on write).
|
|
3736
|
-
* consent is the
|
|
3737
|
-
*
|
|
3738
|
-
*
|
|
3739
|
-
*
|
|
3740
|
-
*
|
|
3741
|
-
*
|
|
3742
|
-
* `environment` is the app's, stamped at creation and never changed: a
|
|
3743
|
-
* person in development and a person in production are two rows even when
|
|
3744
|
-
* their identifiers match.
|
|
3628
|
+
* consent is the per-purpose consent map. Marketing starts denied: nobody
|
|
3629
|
+
* is subscribed by the act of being ingested. `transactional` is absent by
|
|
3630
|
+
* design (ADR 0017): it is not a purpose anyone may withhold, so it is not
|
|
3631
|
+
* stored. The map is written by the identify path (a caller passing
|
|
3632
|
+
* `consent`), the consent editor, and the automatic revocations.
|
|
3745
3633
|
*
|
|
3746
3634
|
* `mergedInto` is the merge pointer (spec: Identity). Null on a live
|
|
3747
3635
|
* profile; on a profile a merge folded away it names the survivor, whose
|
|
@@ -3756,19 +3644,15 @@ const idempotencyKeys = pgTable("idempotency_keys", {
|
|
|
3756
3644
|
const profiles = pgTable("profiles", {
|
|
3757
3645
|
id: text("id").primaryKey(),
|
|
3758
3646
|
orgId: text("org_id").notNull(),
|
|
3759
|
-
environment: environmentEnum("environment").notNull(),
|
|
3760
3647
|
appId: text("app_id").notNull(),
|
|
3761
3648
|
sourceId: text("source_id").notNull(),
|
|
3762
3649
|
traits: jsonb("traits").$type().notNull().default({}),
|
|
3763
|
-
consent: jsonb("consent").$type().notNull().default({
|
|
3764
|
-
emailMarketing: false,
|
|
3765
|
-
dataProcessing: true
|
|
3766
|
-
}),
|
|
3650
|
+
consent: jsonb("consent").$type().notNull().default({ marketing: false }),
|
|
3767
3651
|
mergedInto: text("merged_into"),
|
|
3768
3652
|
createdAt: createdAt(),
|
|
3769
3653
|
updatedAt: updatedAt()
|
|
3770
3654
|
}, (table) => [
|
|
3771
|
-
index("
|
|
3655
|
+
index("profiles_org_id_idx").on(table.orgId),
|
|
3772
3656
|
index("profiles_merged_into_idx").on(table.mergedInto),
|
|
3773
3657
|
foreignKey({
|
|
3774
3658
|
columns: [table.mergedInto],
|
|
@@ -3804,10 +3688,10 @@ const identifierKindEnum = pgEnum("identifier_kind", [
|
|
|
3804
3688
|
]);
|
|
3805
3689
|
/**
|
|
3806
3690
|
* The identifiers map: every named identifier a call has ever carried,
|
|
3807
|
-
* pointing at the profile it resolved to. Unique per (org,
|
|
3808
|
-
*
|
|
3809
|
-
*
|
|
3810
|
-
*
|
|
3691
|
+
* pointing at the profile it resolved to. Unique per (org, kind, value),
|
|
3692
|
+
* which is what makes "a shared identifier means the same person"
|
|
3693
|
+
* enforceable at the row level: the same clerkId twice in one organization
|
|
3694
|
+
* is one person.
|
|
3811
3695
|
*
|
|
3812
3696
|
* Rows follow their profile: erasure deletes the profile and these cascade,
|
|
3813
3697
|
* and a merge re-points them at the survivor.
|
|
@@ -3815,12 +3699,11 @@ const identifierKindEnum = pgEnum("identifier_kind", [
|
|
|
3815
3699
|
const identifiers = pgTable("identifiers", {
|
|
3816
3700
|
id: text("id").primaryKey(),
|
|
3817
3701
|
orgId: text("org_id").notNull(),
|
|
3818
|
-
environment: environmentEnum("environment").notNull(),
|
|
3819
3702
|
kind: identifierKindEnum("kind").notNull(),
|
|
3820
3703
|
value: text("value").notNull(),
|
|
3821
3704
|
profileId: text("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
|
|
3822
3705
|
createdAt: createdAt()
|
|
3823
|
-
}, (table) => [uniqueIndex("
|
|
3706
|
+
}, (table) => [uniqueIndex("identifiers_org_kind_value_unique").on(table.orgId, table.kind, table.value), index("identifiers_profile_id_idx").on(table.profileId)]);
|
|
3824
3707
|
const selectIdentifierSchema = createSelectSchema(identifiers);
|
|
3825
3708
|
const insertIdentifierSchema = createInsertSchema(identifiers);
|
|
3826
3709
|
|
|
@@ -3846,8 +3729,6 @@ const insertIdentifierSchema = createInsertSchema(identifiers);
|
|
|
3846
3729
|
const journeyRuns = pgTable("journey_runs", {
|
|
3847
3730
|
id: text("id").primaryKey(),
|
|
3848
3731
|
orgId: text("org_id").notNull(),
|
|
3849
|
-
/** The instance's environment; development instances never land here (they bill nothing). */
|
|
3850
|
-
environment: environmentEnum("environment").notNull(),
|
|
3851
3732
|
/** The journey's name, not its registry id: the run is about the definition that ran. */
|
|
3852
3733
|
journey: text("journey").notNull(),
|
|
3853
3734
|
profileId: text("profile_id").notNull(),
|
|
@@ -3867,15 +3748,23 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
|
|
|
3867
3748
|
//#endregion
|
|
3868
3749
|
//#region ../../packages/db/src/schema/journeys.ts
|
|
3869
3750
|
/**
|
|
3751
|
+
* The one operational state a manager sets (ADR 0018): `on` (recipients
|
|
3752
|
+
* enroll and executions run), `off` (nothing enrolls, executions in flight
|
|
3753
|
+
* finish) or `paused` (nothing enrolls, executions in flight hold before
|
|
3754
|
+
* their next step). Three values rather than two booleans: a second flag
|
|
3755
|
+
* beside the first would spell two combinations that mean nothing.
|
|
3756
|
+
*/
|
|
3757
|
+
const journeyStatusEnum = pgEnum("journey_status", [
|
|
3758
|
+
"on",
|
|
3759
|
+
"off",
|
|
3760
|
+
"paused"
|
|
3761
|
+
]);
|
|
3762
|
+
/**
|
|
3870
3763
|
* Derived journey rows: one per (org, key), upserted from the manifest a
|
|
3871
3764
|
* push carried. Nothing here is authored through the API, which is why
|
|
3872
3765
|
* there is no id of its own: the key is the name the author gave the file,
|
|
3873
3766
|
* and the (org, key) pair is the only identity a journey has.
|
|
3874
3767
|
*
|
|
3875
|
-
* One row for both environments (ADR 0011): the journey's code is its
|
|
3876
|
-
* latest ready version, and what differs per environment is the enabled
|
|
3877
|
-
* flag alone. A trigger that should fire in both names both of an app's ids.
|
|
3878
|
-
*
|
|
3879
3768
|
* A push only ever adds and updates: it owns the keys its manifest carries
|
|
3880
3769
|
* and leaves every other row alone, because an org's journeys can come from
|
|
3881
3770
|
* several projects and a developer may push a project holding only some of
|
|
@@ -3883,9 +3772,9 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
|
|
|
3883
3772
|
* what makes a key another project owns a refused push rather than a silent
|
|
3884
3773
|
* overwrite. Removing a journey is an explicit delete (ADR 0009).
|
|
3885
3774
|
*
|
|
3886
|
-
* Whether a journey fires is one
|
|
3887
|
-
*
|
|
3888
|
-
*
|
|
3775
|
+
* Whether a journey fires is one field and one only: `status`, which a
|
|
3776
|
+
* push never writes, so deploying code never turns anything on or off. The
|
|
3777
|
+
* author has no second gate of their own (ADR 0011, ADR 0018).
|
|
3889
3778
|
*
|
|
3890
3779
|
* The descriptive columns are what its latest ready version reported, so a
|
|
3891
3780
|
* failed compile leaves both the code and its description as they were.
|
|
@@ -3908,65 +3797,73 @@ const journeys$1 = pgTable("journeys", {
|
|
|
3908
3797
|
* the fixed pair.
|
|
3909
3798
|
*/
|
|
3910
3799
|
purpose: text("purpose").notNull(),
|
|
3800
|
+
/**
|
|
3801
|
+
* The author's own sentence about what the journey does, from the
|
|
3802
|
+
* manifest. Null when they wrote none.
|
|
3803
|
+
*/
|
|
3804
|
+
description: text("description"),
|
|
3911
3805
|
spine: jsonb("spine").$type().notNull(),
|
|
3806
|
+
/**
|
|
3807
|
+
* The one gate on the journey: off until `cow enable` or a manager's
|
|
3808
|
+
* control, and never touched by a push (ADR 0011, amended by ADR 0013
|
|
3809
|
+
* and ADR 0018).
|
|
3810
|
+
*/
|
|
3811
|
+
status: journeyStatusEnum("status").notNull().default("off"),
|
|
3912
3812
|
createdAt: createdAt(),
|
|
3913
3813
|
updatedAt: updatedAt()
|
|
3914
3814
|
}, (table) => [primaryKey({ columns: [table.orgId, table.key] })]);
|
|
3915
3815
|
const selectJourneySchema = createSelectSchema(journeys$1);
|
|
3916
3816
|
const insertJourneySchema = createInsertSchema(journeys$1);
|
|
3917
|
-
/**
|
|
3918
|
-
* Per-org, per-environment enable state for a journey key; the only
|
|
3919
|
-
* operator write on a journey, and the one gate on it. A row exists only
|
|
3920
|
-
* once someone turned the journey on or off in that environment: NO ROW
|
|
3921
|
-
* MEANS DISABLED (ADR 0011). A push writes none, so a key is off in both
|
|
3922
|
-
* environments until `cow enable` or a manager's toggle.
|
|
3923
|
-
*
|
|
3924
|
-
* Keyed by key rather than by a foreign key into `journeys` on purpose: the
|
|
3925
|
-
* flag outlives every push, so it survives a key that temporarily leaves a
|
|
3926
|
-
* project and comes back.
|
|
3927
|
-
*/
|
|
3928
|
-
const journeyStates = pgTable("journey_states", {
|
|
3929
|
-
orgId: text("org_id").notNull(),
|
|
3930
|
-
environment: environmentEnum("environment").notNull(),
|
|
3931
|
-
key: text("key").notNull(),
|
|
3932
|
-
enabled: boolean("enabled").notNull(),
|
|
3933
|
-
createdAt: createdAt(),
|
|
3934
|
-
updatedAt: updatedAt()
|
|
3935
|
-
}, (table) => [primaryKey({ columns: [
|
|
3936
|
-
table.orgId,
|
|
3937
|
-
table.environment,
|
|
3938
|
-
table.key
|
|
3939
|
-
] })]);
|
|
3940
3817
|
|
|
3941
3818
|
//#endregion
|
|
3942
3819
|
//#region ../../packages/db/src/schema/org-settings.ts
|
|
3943
3820
|
/**
|
|
3944
|
-
*
|
|
3945
|
-
*
|
|
3946
|
-
*
|
|
3947
|
-
*
|
|
3948
|
-
|
|
3821
|
+
* The ingestion policy for catalog governance. Single source of truth:
|
|
3822
|
+
* @cowliss/shared/governance derives INGESTION_POLICIES from
|
|
3823
|
+
* ingestionPolicyEnum.enumValues, so the enum and the settings contract
|
|
3824
|
+
* cannot drift.
|
|
3825
|
+
*/
|
|
3826
|
+
const ingestionPolicyEnum = pgEnum("ingestion_policy", ["permissive", "strict"]);
|
|
3827
|
+
/**
|
|
3828
|
+
* Per-org settings. A row exists only once something has been set: no row
|
|
3829
|
+
* means every default applies, which readers fill in (same convention as
|
|
3830
|
+
* journey_states). That keeps a fresh org zero-config and keeps the write
|
|
3831
|
+
* path from having to seed rows it does not own.
|
|
3949
3832
|
*
|
|
3950
|
-
*
|
|
3951
|
-
*
|
|
3952
|
-
*
|
|
3833
|
+
* Every value here is the organization's:
|
|
3834
|
+
* the ingestion policy, the event retention window, the sending kill
|
|
3835
|
+
* switch, the allowance override, and Clerk's cached profile.
|
|
3953
3836
|
*
|
|
3954
3837
|
* `slug` and `name` are the exception to "only what Cowliss owns": they are
|
|
3955
|
-
* Clerk's, cached here by the API's org-sync middleware
|
|
3956
|
-
*
|
|
3957
|
-
* `<org-slug>@<shared domain>` with the org's name as the from-name.
|
|
3838
|
+
* Clerk's, cached here by the API's org-sync middleware, and they are what a
|
|
3839
|
+
* template test-send composes its from-address out of (`orgFromAddress`).
|
|
3958
3840
|
*/
|
|
3959
3841
|
const orgSettings = pgTable("org_settings", {
|
|
3960
3842
|
orgId: text("org_id").primaryKey(),
|
|
3961
3843
|
/**
|
|
3962
3844
|
* The Clerk org's slug and display name, synced by the API on request.
|
|
3963
|
-
* Null until a member of the org has hit the dashboard API once
|
|
3964
|
-
*
|
|
3965
|
-
*
|
|
3845
|
+
* Null until a member of the org has hit the dashboard API once, when
|
|
3846
|
+
* `orgFromAddress` falls back to an orgId-derived local part and the tool
|
|
3847
|
+
* name.
|
|
3966
3848
|
*/
|
|
3967
3849
|
slug: text("slug"),
|
|
3968
3850
|
name: text("name"),
|
|
3969
3851
|
/**
|
|
3852
|
+
* Catalog governance: permissive accepts quarantined (unclassified) names
|
|
3853
|
+
* and observes them; strict routes them to the review queue instead of
|
|
3854
|
+
* applying them. One policy for the organization, applied to every write.
|
|
3855
|
+
*/
|
|
3856
|
+
ingestionPolicy: ingestionPolicyEnum("ingestion_policy").notNull().default("permissive"),
|
|
3857
|
+
/**
|
|
3858
|
+
* The ClickHouse TTL window for the org's events, in days. Null means the
|
|
3859
|
+
* default (18 months, EVENT_RETENTION_DAYS.production in
|
|
3860
|
+
* packages/shared); there is no "forever", and the API refuses a value
|
|
3861
|
+
* above that cap. Development events are trimmed at the shorter
|
|
3862
|
+
* development cap whatever this says: the rule is
|
|
3863
|
+
* `effectiveRetentionDays` in packages/shared.
|
|
3864
|
+
*/
|
|
3865
|
+
eventRetentionDays: integer("event_retention_days"),
|
|
3866
|
+
/**
|
|
3970
3867
|
* The operator-set monthly free allowance override, in micro-dollars,
|
|
3971
3868
|
* synced from the Clerk org's `publicMetadata.freeAllowanceMicros` by the
|
|
3972
3869
|
* same org-sync read that caches slug and name. Null (the default) means
|
|
@@ -4019,8 +3916,7 @@ const insertOrgSettingsSchema = createInsertSchema(orgSettings);
|
|
|
4019
3916
|
/**
|
|
4020
3917
|
* An org's cow project: the folder a developer runs `cow init` in, and the
|
|
4021
3918
|
* thing pushes belong to. An org may hold several, one per repo, each with
|
|
4022
|
-
* its own release sequence
|
|
4023
|
-
* own slice of the deployed journey rows. The name is how `cow.json` picks
|
|
3919
|
+
* its own release sequence and its own slice of the deployed journey rows. The name is how `cow.json` picks
|
|
4024
3920
|
* one, so it is unique within the org.
|
|
4025
3921
|
*/
|
|
4026
3922
|
const projects = pgTable("projects", {
|
|
@@ -4086,14 +3982,12 @@ const pushArtifacts = pgTable("push_artifacts", {
|
|
|
4086
3982
|
//#endregion
|
|
4087
3983
|
//#region ../../packages/db/src/schema/violations.ts
|
|
4088
3984
|
/**
|
|
4089
|
-
* Violations: one row per (orgId,
|
|
4090
|
-
*
|
|
4091
|
-
*
|
|
4092
|
-
*
|
|
4093
|
-
* per environment, so a name a dev box invented never shows up as a
|
|
4094
|
-
* production violation.
|
|
3985
|
+
* Violations: one row per (orgId, kind, name) observed in ingestion that
|
|
3986
|
+
* the tracking plan catalog does not define. Observe and flag, never
|
|
3987
|
+
* reject: the payload is always written, the violation only records the
|
|
3988
|
+
* governance gap.
|
|
4095
3989
|
*
|
|
4096
|
-
* Uniqueness on (orgId,
|
|
3990
|
+
* Uniqueness on (orgId, kind, name) dedupes repeat sightings:
|
|
4097
3991
|
* an unknown event fired a thousand times is one open violation whose
|
|
4098
3992
|
* lastSeenAt keeps bumping, not a thousand rows. The payload captures the first observed shape
|
|
4099
3993
|
* (inferred property types for events, inferred type for traits) so resolve
|
|
@@ -4111,7 +4005,6 @@ const violationStatusEnum = pgEnum("violation_status", [
|
|
|
4111
4005
|
const violations$1 = pgTable("violations", {
|
|
4112
4006
|
id: text("id").primaryKey(),
|
|
4113
4007
|
orgId: text("org_id").notNull(),
|
|
4114
|
-
environment: environmentEnum("environment").notNull(),
|
|
4115
4008
|
kind: violationKindEnum("kind").notNull(),
|
|
4116
4009
|
name: text("name").notNull(),
|
|
4117
4010
|
payload: jsonb("payload").$type().notNull(),
|
|
@@ -4136,7 +4029,7 @@ const violations$1 = pgTable("violations", {
|
|
|
4136
4029
|
mode: "date",
|
|
4137
4030
|
precision: 3
|
|
4138
4031
|
})
|
|
4139
|
-
}, (table) => [index("
|
|
4032
|
+
}, (table) => [index("violations_org_id_idx").on(table.orgId), uniqueIndex("violations_org_kind_name_unique").on(table.orgId, table.kind, table.name)]);
|
|
4140
4033
|
const selectViolationSchema = createSelectSchema(violations$1);
|
|
4141
4034
|
const insertViolationSchema = createInsertSchema(violations$1);
|
|
4142
4035
|
|
|
@@ -4145,7 +4038,6 @@ const insertViolationSchema = createInsertSchema(violations$1);
|
|
|
4145
4038
|
const quarantineEntries = pgTable("quarantine_entries", {
|
|
4146
4039
|
id: text("id").primaryKey(),
|
|
4147
4040
|
orgId: text("org_id").notNull(),
|
|
4148
|
-
environment: environmentEnum("environment").notNull(),
|
|
4149
4041
|
kind: violationKindEnum("kind").notNull(),
|
|
4150
4042
|
name: text("name").notNull(),
|
|
4151
4043
|
appId: text("app_id").notNull(),
|
|
@@ -4159,20 +4051,24 @@ const quarantineEntries = pgTable("quarantine_entries", {
|
|
|
4159
4051
|
}).notNull(),
|
|
4160
4052
|
createdAt: createdAt(),
|
|
4161
4053
|
updatedAt: updatedAt()
|
|
4162
|
-
}, (table) => [index("quarantine_entries_org_kind_name_idx").on(table.orgId, table.kind, table.name), index("
|
|
4054
|
+
}, (table) => [index("quarantine_entries_org_kind_name_idx").on(table.orgId, table.kind, table.name), index("quarantine_entries_org_created_idx").on(table.orgId, table.createdAt)]);
|
|
4163
4055
|
const selectQuarantineEntrySchema = createSelectSchema(quarantineEntries);
|
|
4164
4056
|
const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
|
|
4165
4057
|
|
|
4166
4058
|
//#endregion
|
|
4167
4059
|
//#region ../../packages/db/src/schema/segments.ts
|
|
4168
4060
|
/**
|
|
4169
|
-
* Segments:
|
|
4170
|
-
*
|
|
4171
|
-
*
|
|
4061
|
+
* Segments: membership rules over profiles. Membership is computed, never
|
|
4062
|
+
* hand-maintained: the engine in packages/segments recomputes on every write
|
|
4063
|
+
* and materializes the result into segment_members.
|
|
4172
4064
|
*
|
|
4173
|
-
*
|
|
4174
|
-
*
|
|
4175
|
-
*
|
|
4065
|
+
* A row is either standalone (built in the dashboard, `journey_key` null) or
|
|
4066
|
+
* owned by one journey whose trigger inlined its definition (ADR 0016). An
|
|
4067
|
+
* owned row is named after its journey's key, is written only by the push,
|
|
4068
|
+
* and goes when the journey does — which is what the composite foreign key
|
|
4069
|
+
* says: it points at `journeys(org_id, key)` and cascades. A null
|
|
4070
|
+
* `journey_key` satisfies it vacuously (MATCH SIMPLE), so standalone rows
|
|
4071
|
+
* are unconstrained.
|
|
4176
4072
|
*
|
|
4177
4073
|
* Timestamps use millisecond precision, same rationale as apps: JS Dates
|
|
4178
4074
|
* carry ms only and cursor pagination compares createdAt for equality.
|
|
@@ -4183,18 +4079,24 @@ const segments$1 = pgTable("segments", {
|
|
|
4183
4079
|
name: text("name").notNull(),
|
|
4184
4080
|
description: text("description"),
|
|
4185
4081
|
definition: jsonb("definition").$type().notNull(),
|
|
4186
|
-
/** The
|
|
4187
|
-
|
|
4082
|
+
/** The journey that owns this row, or null for a standalone segment. */
|
|
4083
|
+
journeyKey: text("journey_key"),
|
|
4188
4084
|
createdAt: createdAt(),
|
|
4189
4085
|
updatedAt: updatedAt()
|
|
4190
|
-
}, (table) => [
|
|
4086
|
+
}, (table) => [
|
|
4087
|
+
index("segments_org_id_idx").on(table.orgId),
|
|
4088
|
+
uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name),
|
|
4089
|
+
foreignKey({
|
|
4090
|
+
columns: [table.orgId, table.journeyKey],
|
|
4091
|
+
foreignColumns: [journeys$1.orgId, journeys$1.key],
|
|
4092
|
+
name: "segments_journey_fk"
|
|
4093
|
+
}).onDelete("cascade")
|
|
4094
|
+
]);
|
|
4191
4095
|
/**
|
|
4192
|
-
* Materialized membership: one row per (segment,
|
|
4193
|
-
*
|
|
4194
|
-
*
|
|
4195
|
-
*
|
|
4196
|
-
* so the environment is part of the key: dropping an environment from a
|
|
4197
|
-
* definition drops that environment's rows and leaves the other's standing.
|
|
4096
|
+
* Materialized membership: one row per (segment, profile) currently in the
|
|
4097
|
+
* segment. Deleting a segment drops its members with it, and erasing a
|
|
4098
|
+
* profile drops its memberships, so neither can leave orphaned membership
|
|
4099
|
+
* behind.
|
|
4198
4100
|
*
|
|
4199
4101
|
* The (orgId, profileId) index is the recompute lookup: every write asks
|
|
4200
4102
|
* "which of this org's segments does this profile currently belong to?"
|
|
@@ -4203,19 +4105,13 @@ const segments$1 = pgTable("segments", {
|
|
|
4203
4105
|
const segmentMembers = pgTable("segment_members", {
|
|
4204
4106
|
segmentId: text("segment_id").notNull().references(() => segments$1.id, { onDelete: "cascade" }),
|
|
4205
4107
|
orgId: text("org_id").notNull(),
|
|
4206
|
-
/** The member profile's environment; always the profile's own. */
|
|
4207
|
-
environment: environmentEnum("environment").notNull(),
|
|
4208
4108
|
profileId: text("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
|
|
4209
4109
|
enteredAt: timestamp("entered_at", {
|
|
4210
4110
|
withTimezone: true,
|
|
4211
4111
|
mode: "date",
|
|
4212
4112
|
precision: 3
|
|
4213
4113
|
}).notNull().defaultNow()
|
|
4214
|
-
}, (table) => [primaryKey({ columns: [
|
|
4215
|
-
table.segmentId,
|
|
4216
|
-
table.environment,
|
|
4217
|
-
table.profileId
|
|
4218
|
-
] }), index("segment_members_org_id_profile_id_idx").on(table.orgId, table.profileId)]);
|
|
4114
|
+
}, (table) => [primaryKey({ columns: [table.segmentId, table.profileId] }), index("segment_members_org_id_profile_id_idx").on(table.orgId, table.profileId)]);
|
|
4219
4115
|
const selectSegmentSchema = createSelectSchema(segments$1);
|
|
4220
4116
|
const insertSegmentSchema = createInsertSchema(segments$1);
|
|
4221
4117
|
|
|
@@ -4231,10 +4127,16 @@ const insertSegmentSchema = createInsertSchema(segments$1);
|
|
|
4231
4127
|
* emits no domain events, so the mirror refreshes by pulling
|
|
4232
4128
|
* `GetEmailIdentity` on a schedule and on the manual re-check button.
|
|
4233
4129
|
*
|
|
4234
|
-
*
|
|
4235
|
-
*
|
|
4236
|
-
*
|
|
4237
|
-
*
|
|
4130
|
+
* A domain is claimed when it VERIFIES, not when it is added (ADR 0021).
|
|
4131
|
+
* Any number of orgs may hold the same unverified string; the partial unique
|
|
4132
|
+
* below is what makes at most one of them verified, and so the owner. The
|
|
4133
|
+
* cross-org parent/child guard (a query, not a constraint) refuses a claim
|
|
4134
|
+
* under or over a name another org already verified.
|
|
4135
|
+
*
|
|
4136
|
+
* SES keys ONE identity by the domain name, so its DKIM records, and the
|
|
4137
|
+
* SUCCESS it reports for them, are shared by every org holding the name.
|
|
4138
|
+
* They therefore prove that SOMEBODY controls the DNS, never which org, and
|
|
4139
|
+
* `challenge_token` is the part that is per org: the claim needs both.
|
|
4238
4140
|
*/
|
|
4239
4141
|
/**
|
|
4240
4142
|
* SES's identity status vocabulary, lower-cased like every other enum here.
|
|
@@ -4242,13 +4144,19 @@ const insertSegmentSchema = createInsertSchema(segments$1);
|
|
|
4242
4144
|
* all report the same five words. `temporary_failure` is SES's retryable
|
|
4243
4145
|
* state, which is neither verified nor a dead end, so it is kept distinct
|
|
4244
4146
|
* from `failed`.
|
|
4147
|
+
*
|
|
4148
|
+
* `blocked` is the one value SES never says: the DNS checks out, but another
|
|
4149
|
+
* organization has already verified this name or a parent or child of it, so
|
|
4150
|
+
* the claim cannot be granted here (ADR 0021). It is a terminal-looking
|
|
4151
|
+
* state that clears itself, because every refresh re-asks the question.
|
|
4245
4152
|
*/
|
|
4246
4153
|
const senderDomainStatusEnum = pgEnum("sender_domain_status", [
|
|
4247
4154
|
"not_started",
|
|
4248
4155
|
"pending",
|
|
4249
4156
|
"success",
|
|
4250
4157
|
"failed",
|
|
4251
|
-
"temporary_failure"
|
|
4158
|
+
"temporary_failure",
|
|
4159
|
+
"blocked"
|
|
4252
4160
|
]);
|
|
4253
4161
|
const senderDomains = pgTable("sender_domains", {
|
|
4254
4162
|
id: text("id").primaryKey(),
|
|
@@ -4262,12 +4170,25 @@ const senderDomains = pgTable("sender_domains", {
|
|
|
4262
4170
|
spfStatus: text("spf_status"),
|
|
4263
4171
|
/**
|
|
4264
4172
|
* The per-domain click-tracking toggle, Cowliss's own setting: the send
|
|
4265
|
-
* picks SES's tracked configuration set when it is on. Off by default
|
|
4266
|
-
*
|
|
4267
|
-
* at all): one setting cannot serve every org sharing it.
|
|
4173
|
+
* picks SES's tracked configuration set when it is on. Off by default:
|
|
4174
|
+
* link rewriting is a thing an org opts into.
|
|
4268
4175
|
*/
|
|
4269
4176
|
clickTracking: boolean("click_tracking").notNull().default(false),
|
|
4270
4177
|
dnsRecords: jsonb("dns_records").$type().notNull().default([]),
|
|
4178
|
+
/**
|
|
4179
|
+
* This org's own proof of control, published as a TXT record under the
|
|
4180
|
+
* domain. Random per row and never re-issued, so two orgs holding the
|
|
4181
|
+
* same name publish different values and only the one that controls the
|
|
4182
|
+
* DNS can publish its own. Verified once, at the moment the claim is
|
|
4183
|
+
* granted; a live sender never loses its domain to one failed lookup.
|
|
4184
|
+
*/
|
|
4185
|
+
challengeToken: text("challenge_token").notNull().default(sql`replace(gen_random_uuid()::text, '-', '')`),
|
|
4186
|
+
/** When this org's own TXT record was last seen. Null until it is. */
|
|
4187
|
+
challengeVerifiedAt: timestamp("challenge_verified_at", {
|
|
4188
|
+
withTimezone: true,
|
|
4189
|
+
mode: "date",
|
|
4190
|
+
precision: 3
|
|
4191
|
+
}),
|
|
4271
4192
|
/** When the mirror last asked SES; drives the refresh sweep. */
|
|
4272
4193
|
lastCheckedAt: timestamp("last_checked_at", {
|
|
4273
4194
|
withTimezone: true,
|
|
@@ -4276,7 +4197,11 @@ const senderDomains = pgTable("sender_domains", {
|
|
|
4276
4197
|
}),
|
|
4277
4198
|
createdAt: createdAt(),
|
|
4278
4199
|
updatedAt: updatedAt()
|
|
4279
|
-
}, (table) => [
|
|
4200
|
+
}, (table) => [
|
|
4201
|
+
uniqueIndex("sender_domains_org_id_domain_unique").on(table.orgId, table.domain),
|
|
4202
|
+
uniqueIndex("sender_domains_verified_domain_unique").on(table.domain).where(sql`${table.status} = 'success'`),
|
|
4203
|
+
index("sender_domains_org_id_created_at_idx").on(table.orgId, table.createdAt)
|
|
4204
|
+
]);
|
|
4280
4205
|
const selectSenderDomainSchema = createSelectSchema(senderDomains);
|
|
4281
4206
|
const insertSenderDomainSchema = createInsertSchema(senderDomains);
|
|
4282
4207
|
|
|
@@ -4287,14 +4212,14 @@ const insertSenderDomainSchema = createInsertSchema(senderDomains);
|
|
|
4287
4212
|
* source is a way data reaches it, so "App A via Clerk" and "App A via the
|
|
4288
4213
|
* SDK" land on the same app and therefore on the same profiles.
|
|
4289
4214
|
*
|
|
4290
|
-
* Every app has
|
|
4291
|
-
*
|
|
4292
|
-
*
|
|
4293
|
-
* beside it.
|
|
4215
|
+
* Every app has one source of kind `api`, the first-party SDK and HTTP
|
|
4216
|
+
* path, created with the app. It has an empty config and no credential, and
|
|
4217
|
+
* archiving it is that app's ingestion kill switch. Provider kinds sit
|
|
4218
|
+
* beside it, singleton per app.
|
|
4294
4219
|
*
|
|
4295
4220
|
* `config` holds the per-kind source configuration and `signingSecret` the
|
|
4296
4221
|
* credential of the one kind that has one today (Clerk's webhook signing
|
|
4297
|
-
* secret), stored write-only (the
|
|
4222
|
+
* secret), stored write-only (the webhooks show-once pattern: never on a
|
|
4298
4223
|
* DTO). `kind` is immutable after creation; the webhook route dispatches on
|
|
4299
4224
|
* it. `lastReceivedAt` is stamped per accepted delivery so the dashboard can
|
|
4300
4225
|
* tell a wired-up source from a silent one.
|
|
@@ -4529,20 +4454,44 @@ const selectWalletTopupSchema = createSelectSchema(walletTopups);
|
|
|
4529
4454
|
const insertWalletTopupSchema = createInsertSchema(walletTopups);
|
|
4530
4455
|
|
|
4531
4456
|
//#endregion
|
|
4532
|
-
//#region ../../packages/
|
|
4457
|
+
//#region ../../packages/db/src/schema/webhooks.ts
|
|
4533
4458
|
/**
|
|
4534
|
-
*
|
|
4535
|
-
*
|
|
4536
|
-
* `
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
*
|
|
4541
|
-
*
|
|
4542
|
-
*
|
|
4543
|
-
*
|
|
4459
|
+
* Webhooks: named URLs that journeys POST to by name. A webhook is the
|
|
4460
|
+
* organization's: the name is unique per org, and a journey step naming
|
|
4461
|
+
* `webhook("crm")` resolves to the same row for every execution. A developer
|
|
4462
|
+
* who wants a local receiver creates a second webhook under another name.
|
|
4463
|
+
* Ms-precision timestamps, as everywhere, so a cursor round-trips.
|
|
4464
|
+
*
|
|
4465
|
+
* WEBHOOK_PAYLOAD_VERSION (packages/shared) pins the webhook payload shape;
|
|
4466
|
+
* the column records the version each webhook was created against.
|
|
4467
|
+
* signingSecret is the Standard Webhooks signing secret, generated at
|
|
4468
|
+
* creation and shown once — the API DTO omits it after the create response.
|
|
4544
4469
|
*/
|
|
4545
|
-
const
|
|
4470
|
+
const webhooks$1 = pgTable("webhooks", {
|
|
4471
|
+
id: text("id").primaryKey(),
|
|
4472
|
+
orgId: text("org_id").notNull(),
|
|
4473
|
+
name: text("name").notNull(),
|
|
4474
|
+
config: jsonb("config").$type().notNull(),
|
|
4475
|
+
webhookPayloadVersion: integer("webhook_payload_version").notNull().default(1),
|
|
4476
|
+
signingSecret: text("signing_secret"),
|
|
4477
|
+
/**
|
|
4478
|
+
* Auto-disable state, mirroring how receivers treat us: the webhook send
|
|
4479
|
+
* activity bumps the counter once per failed delivery attempt (not once
|
|
4480
|
+
* per Temporal retry) and stamps disabledAt at the threshold. A disabled
|
|
4481
|
+
* webhook records `skipped_disabled` until an admin clears it; a
|
|
4482
|
+
* successful delivery resets the counter.
|
|
4483
|
+
*/
|
|
4484
|
+
consecutiveFailures: integer("consecutive_failures").notNull().default(0),
|
|
4485
|
+
disabledAt: timestamp("disabled_at", {
|
|
4486
|
+
withTimezone: true,
|
|
4487
|
+
mode: "date",
|
|
4488
|
+
precision: 3
|
|
4489
|
+
}),
|
|
4490
|
+
createdAt: createdAt(),
|
|
4491
|
+
updatedAt: updatedAt()
|
|
4492
|
+
}, (table) => [index("webhooks_org_id_idx").on(table.orgId), uniqueIndex("webhooks_org_id_name_unique").on(table.orgId, table.name)]);
|
|
4493
|
+
const selectWebhookSchema = createSelectSchema(webhooks$1);
|
|
4494
|
+
const insertWebhookSchema = createInsertSchema(webhooks$1);
|
|
4546
4495
|
|
|
4547
4496
|
//#endregion
|
|
4548
4497
|
//#region ../../packages/shared/src/apps.ts
|
|
@@ -4553,10 +4502,10 @@ const environmentsSchema = z.array(environmentSchema).min(1, "at least one envir
|
|
|
4553
4502
|
* JSON.stringify already emits it for Date values.
|
|
4554
4503
|
*
|
|
4555
4504
|
* An app is the attribution unit and nothing else: it carries no kind, no
|
|
4556
|
-
* config
|
|
4557
|
-
*
|
|
4558
|
-
*
|
|
4559
|
-
*
|
|
4505
|
+
* config and no credential (its sources carry those, see
|
|
4506
|
+
* sources.ts). The two extra fields are read-model facts about those
|
|
4507
|
+
* sources, which is what the dashboard's list needs to say whether an app
|
|
4508
|
+
* has a pipe wired up without a request per card.
|
|
4560
4509
|
*/
|
|
4561
4510
|
const appSchema = selectAppSchema.extend({
|
|
4562
4511
|
createdAt: z.iso.datetime(),
|
|
@@ -4568,11 +4517,7 @@ const appSchema = selectAppSchema.extend({
|
|
|
4568
4517
|
lastReceivedAt: z.iso.datetime().nullable()
|
|
4569
4518
|
});
|
|
4570
4519
|
const nameSchema$2 = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
|
|
4571
|
-
const createAppBodySchema = z.object({ data: z.object({
|
|
4572
|
-
name: nameSchema$2,
|
|
4573
|
-
/** Immutable after creation; production when omitted. */
|
|
4574
|
-
environment: environmentSchema.default("production")
|
|
4575
|
-
}) });
|
|
4520
|
+
const createAppBodySchema = z.object({ data: z.object({ name: nameSchema$2 }) });
|
|
4576
4521
|
const updateAppBodySchema = z.object({ data: z.object({
|
|
4577
4522
|
name: nameSchema$2.optional(),
|
|
4578
4523
|
status: z.literal("archived").optional()
|
|
@@ -4674,6 +4619,92 @@ const identifierDtoSchema = z.object({
|
|
|
4674
4619
|
createdAt: z.iso.datetime()
|
|
4675
4620
|
});
|
|
4676
4621
|
|
|
4622
|
+
//#endregion
|
|
4623
|
+
//#region ../../packages/shared/src/segment-definition.ts
|
|
4624
|
+
/**
|
|
4625
|
+
* A segment definition: a flat list of predicates over traits and event
|
|
4626
|
+
* history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
|
|
4627
|
+
* prototype, and a flat list keeps the pure evaluator a fold.
|
|
4628
|
+
*
|
|
4629
|
+
* `appId` scopes the EVENT side of a definition only. Traits are
|
|
4630
|
+
* per-profile and profiles merge across apps, so there is nothing
|
|
4631
|
+
* app-shaped to filter on the trait side.
|
|
4632
|
+
*
|
|
4633
|
+
* A definition and the membership it produces are both the
|
|
4634
|
+
* organization's.
|
|
4635
|
+
*
|
|
4636
|
+
* Apart from `./segments` because a journey's trigger carries a definition
|
|
4637
|
+
* and the trigger schema is bundled into the wasm guest, where an edge to
|
|
4638
|
+
* `@cowliss/db` (which `./segments` has, for the row schema) is a hard
|
|
4639
|
+
* bundler failure. Nothing here imports anything but zod.
|
|
4640
|
+
*
|
|
4641
|
+
* The object is strict: a definition holding the retired `sourceId` key
|
|
4642
|
+
* (which meant the app) fails loudly instead of parsing as an unfiltered
|
|
4643
|
+
* definition that evaluates over every app. There is deliberately no pipe
|
|
4644
|
+
* filter here — filtering by source is a later feature, and accepting one
|
|
4645
|
+
* now would make a stale `sourceId` parse as a filter on a pipe that does
|
|
4646
|
+
* not exist and silently match nothing.
|
|
4647
|
+
*/
|
|
4648
|
+
const SEGMENT_TRAIT_OPS = [
|
|
4649
|
+
"eq",
|
|
4650
|
+
"neq",
|
|
4651
|
+
"gt",
|
|
4652
|
+
"gte",
|
|
4653
|
+
"lt",
|
|
4654
|
+
"lte",
|
|
4655
|
+
"exists",
|
|
4656
|
+
"notExists",
|
|
4657
|
+
"contains"
|
|
4658
|
+
];
|
|
4659
|
+
/** Operators that read no comparison value: presence of the key is the test. */
|
|
4660
|
+
const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
|
|
4661
|
+
const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
|
|
4662
|
+
const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
|
|
4663
|
+
kind: z.literal("trait"),
|
|
4664
|
+
name: predicateNameSchema,
|
|
4665
|
+
op: z.enum(SEGMENT_TRAIT_OPS),
|
|
4666
|
+
/**
|
|
4667
|
+
* Compared against the stored trait, which is `unknown` because
|
|
4668
|
+
* identify accepts arbitrary JSON. The evaluator coerces both sides
|
|
4669
|
+
* before comparing, so authors here (CLI, MCP, dashboard) get the
|
|
4670
|
+
* form-field-friendly reading rather than strict JSON equality:
|
|
4671
|
+
* numeric-looking strings are compared as numbers for eq/neq and for
|
|
4672
|
+
* ordering ("150" matches 150, and orders like it), and "true"/"false"
|
|
4673
|
+
* are compared as booleans for eq/neq, trimmed and case-insensitively
|
|
4674
|
+
* (" TRUE " reads as true). Coercion needs both sides to agree on a
|
|
4675
|
+
* type: "0" never equals false. The numeric net is as wide as
|
|
4676
|
+
* `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
|
|
4677
|
+
* matters most for opaque ids: "007" is authored as the number 7.
|
|
4678
|
+
*
|
|
4679
|
+
* contains reads three ways. Against an array trait it is membership
|
|
4680
|
+
* under that same equality, so "true" matches `[true]` and "1" matches
|
|
4681
|
+
* `[1, 2]`. Against a string trait it is a plain substring search with
|
|
4682
|
+
* no coercion, since substrings only mean something between strings.
|
|
4683
|
+
* Against anything else it never matches. Ordering never coerces
|
|
4684
|
+
* booleans.
|
|
4685
|
+
*/
|
|
4686
|
+
value: z.unknown().optional()
|
|
4687
|
+
}), z.object({
|
|
4688
|
+
kind: z.literal("event"),
|
|
4689
|
+
name: predicateNameSchema,
|
|
4690
|
+
op: z.enum(["performed", "notPerformed"]),
|
|
4691
|
+
/** How many matching events the predicate counts as "performed". */
|
|
4692
|
+
atLeast: z.number().int().min(1).default(1),
|
|
4693
|
+
/** Rolling window, relative to evaluation time; absent means all history. */
|
|
4694
|
+
withinDays: z.number().int().min(1).optional()
|
|
4695
|
+
})]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
|
|
4696
|
+
const appIdSchema = z.string().trim().min(1);
|
|
4697
|
+
const segmentDefinitionSchema = z.strictObject({
|
|
4698
|
+
match: z.enum(["all", "any"]).default("all"),
|
|
4699
|
+
/**
|
|
4700
|
+
* Optional app filter over the event side; null/absent spans apps.
|
|
4701
|
+
* A list matches any of the named apps, which is how one definition
|
|
4702
|
+
* names the development and the production id of the same app.
|
|
4703
|
+
*/
|
|
4704
|
+
appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
|
|
4705
|
+
predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
|
|
4706
|
+
});
|
|
4707
|
+
|
|
4677
4708
|
//#endregion
|
|
4678
4709
|
//#region ../../packages/shared/src/journeys-v2/manifest.ts
|
|
4679
4710
|
/**
|
|
@@ -4683,6 +4714,34 @@ const identifierDtoSchema = z.object({
|
|
|
4683
4714
|
* changed, and stores that entry on the version it creates.
|
|
4684
4715
|
*/
|
|
4685
4716
|
/**
|
|
4717
|
+
* A duration as journey code writes it: an ms-style string ("2d") or
|
|
4718
|
+
* milliseconds. It lives here rather than with the guest protocol because a
|
|
4719
|
+
* manifest carries one too (a journey's enrollment cooldown) and `./guest`
|
|
4720
|
+
* already imports this module, so the other direction would be a cycle.
|
|
4721
|
+
*/
|
|
4722
|
+
const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
|
|
4723
|
+
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
|
|
4724
|
+
const DURATION_UNIT_MS = {
|
|
4725
|
+
ms: 1,
|
|
4726
|
+
s: 1e3,
|
|
4727
|
+
m: 6e4,
|
|
4728
|
+
h: 36e5,
|
|
4729
|
+
d: 864e5,
|
|
4730
|
+
w: 6048e5
|
|
4731
|
+
};
|
|
4732
|
+
/**
|
|
4733
|
+
* A duration in milliseconds. The simulator's virtual clock and the runner's
|
|
4734
|
+
* timers both need it, and neither may pull in Temporal's `msToNumber` (one
|
|
4735
|
+
* runs in the CLI, the other inside workflow code).
|
|
4736
|
+
*/
|
|
4737
|
+
function parseDuration(duration) {
|
|
4738
|
+
if (typeof duration === "number") return duration;
|
|
4739
|
+
const match = DURATION_PATTERN.exec(duration.trim());
|
|
4740
|
+
if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
|
|
4741
|
+
const unit = DURATION_UNIT_MS[match[2]];
|
|
4742
|
+
return Math.round(Number(match[1]) * unit);
|
|
4743
|
+
}
|
|
4744
|
+
/**
|
|
4686
4745
|
* A journey or template key: the file basename under `journeys/` or
|
|
4687
4746
|
* `emails/`, kebab-case and unique across the project. Becomes part of the
|
|
4688
4747
|
* Temporal workflow id and travels in the journey chain, so it stays short.
|
|
@@ -4703,8 +4762,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
|
|
|
4703
4762
|
*/
|
|
4704
4763
|
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)]);
|
|
4705
4764
|
/**
|
|
4706
|
-
* A consent purpose key: camelCase, matching the
|
|
4707
|
-
* `
|
|
4765
|
+
* A consent purpose key: camelCase, matching the reserved `marketing` and
|
|
4766
|
+
* `transactional`. Purposes are keys in the `consent` map a customer reads
|
|
4708
4767
|
* on their own profile, which is why they are not the kebab-case of a
|
|
4709
4768
|
* journey key.
|
|
4710
4769
|
*/
|
|
@@ -4719,22 +4778,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
|
|
|
4719
4778
|
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)");
|
|
4720
4779
|
/**
|
|
4721
4780
|
* One purpose a project declares in `cow.json` (spec: Decisions). A declared
|
|
4722
|
-
* purpose is marketing-class and sits under the `
|
|
4723
|
-
*
|
|
4724
|
-
*
|
|
4725
|
-
*
|
|
4781
|
+
* purpose is marketing-class and sits under the `marketing` umbrella, so
|
|
4782
|
+
* `denied` is the only default it may carry: the purpose is absent on every
|
|
4783
|
+
* profile that already exists, and a granted default would answer for all of
|
|
4784
|
+
* them at once.
|
|
4726
4785
|
*
|
|
4727
4786
|
* The field stays required rather than disappearing, so every `cow.json` and
|
|
4728
|
-
* every stored manifest written before this still parses
|
|
4729
|
-
*
|
|
4730
|
-
* is a refusal at declaration time, not a narrower storage shape.
|
|
4787
|
+
* every stored manifest written before this still parses: this is a refusal
|
|
4788
|
+
* at declaration time, not a narrower storage shape.
|
|
4731
4789
|
*
|
|
4732
|
-
*
|
|
4733
|
-
*
|
|
4734
|
-
*
|
|
4790
|
+
* Neither reserved purpose can be declared: `marketing` is the master switch
|
|
4791
|
+
* every other project's journeys hang off, and `transactional` is not a
|
|
4792
|
+
* consent purpose at all, so declaring it would promise a switch that no
|
|
4793
|
+
* send ever reads.
|
|
4735
4794
|
*/
|
|
4736
4795
|
const declaredPurposeSchema = z.strictObject({
|
|
4737
|
-
key: consentPurposeKeySchema.refine((key) => !
|
|
4796
|
+
key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
|
|
4738
4797
|
/** What the dashboard and the account modal render beside the switch. */
|
|
4739
4798
|
label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
|
|
4740
4799
|
/** What the purpose means for a profile whose map does not answer it. */
|
|
@@ -4756,28 +4815,65 @@ const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 2
|
|
|
4756
4815
|
const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
|
|
4757
4816
|
/**
|
|
4758
4817
|
* What starts a journey: an event (optionally narrowed to an app id or a
|
|
4759
|
-
* list of them) or
|
|
4760
|
-
* reuses it.
|
|
4761
|
-
*
|
|
4762
|
-
*
|
|
4763
|
-
*
|
|
4764
|
-
*
|
|
4765
|
-
*
|
|
4818
|
+
* list of them), or entry into the segment the trigger itself describes.
|
|
4819
|
+
* The registry DTO in `../journeys` reuses it.
|
|
4820
|
+
*
|
|
4821
|
+
* A segment trigger carries the predicate list, not a name: the push
|
|
4822
|
+
* materializes one segment row per journey that inlines a definition, owned
|
|
4823
|
+
* by the journey and named after its key, so the segment exists because the
|
|
4824
|
+
* journey exists and there is no order to get wrong (ADR 0016). The
|
|
4825
|
+
* definition is data — validated here, carried on the version, never
|
|
4826
|
+
* compiled and never executed.
|
|
4827
|
+
*
|
|
4828
|
+
* Both members are strict, so a journey holding the retired `source` key, or
|
|
4829
|
+
* the retired `{ segment: "name" }` form, fails to build instead of silently
|
|
4830
|
+
* triggering on every app or on nothing. There is deliberately no pipe
|
|
4831
|
+
* filter: a trigger narrows by the app the write is attributed to, the same
|
|
4832
|
+
* token a segment definition names.
|
|
4766
4833
|
*/
|
|
4767
4834
|
const triggerSchema = z.union([z.strictObject({
|
|
4768
4835
|
event: patternSchema,
|
|
4769
4836
|
appId: patternSchema.optional()
|
|
4770
|
-
}), z.strictObject({ segment:
|
|
4837
|
+
}), z.strictObject({ segment: segmentDefinitionSchema })]);
|
|
4838
|
+
/**
|
|
4839
|
+
* The address half of a from-header: a local part, an `@`, and a dotted
|
|
4840
|
+
* domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
|
|
4841
|
+
* address literals): every character it refuses would have to be quoted or
|
|
4842
|
+
* escaped to survive a header, and none of them belong in an address a
|
|
4843
|
+
* journey sends from.
|
|
4844
|
+
*/
|
|
4845
|
+
const FROM_ADDRESS = /^[^\s@<>",;]+@([^\s@<>",;.]+(?:\.[^\s@<>",;.]+)+)$/;
|
|
4846
|
+
/**
|
|
4847
|
+
* Parse a journey's or a send's `from`: `addr@domain`, or
|
|
4848
|
+
* `Name <addr@domain>` with the name optionally quoted. Null when it is
|
|
4849
|
+
* neither, which is what `cow build` refuses on and what the send-time
|
|
4850
|
+
* domain gate turns into a skip.
|
|
4851
|
+
*
|
|
4852
|
+
* One parser, and the send facade reuses it. A control character anywhere is
|
|
4853
|
+
* a refusal rather than something to strip: it survives quoting and would
|
|
4854
|
+
* inject a second header.
|
|
4855
|
+
*/
|
|
4856
|
+
function parseFromAddress(from) {
|
|
4857
|
+
const text = from.trim();
|
|
4858
|
+
if (/\p{Cc}/u.test(text)) return null;
|
|
4859
|
+
const angled = /^(.*?)\s*<([^<>]*)>$/.exec(text);
|
|
4860
|
+
const address = (angled?.[2] ?? text).trim();
|
|
4861
|
+
const domain = FROM_ADDRESS.exec(address)?.[1];
|
|
4862
|
+
if (!domain) return null;
|
|
4863
|
+
const name = (angled?.[1] ?? "").trim().replace(/^"(.*)"$/s, "$1").replace(/\\(.)/g, "$1").trim();
|
|
4864
|
+
return {
|
|
4865
|
+
...name ? { name } : {},
|
|
4866
|
+
address,
|
|
4867
|
+
domain: domain.toLowerCase()
|
|
4868
|
+
};
|
|
4869
|
+
}
|
|
4771
4870
|
/**
|
|
4772
|
-
*
|
|
4773
|
-
*
|
|
4774
|
-
*
|
|
4775
|
-
*
|
|
4776
|
-
* from here by it, because a journey manifest names one and the guest layer
|
|
4777
|
-
* is bundled into every tenant module: importing it the other way round
|
|
4778
|
-
* would pull the Drizzle destinations table into all of them.
|
|
4871
|
+
* The address a journey or one send leaves as: a string, not the name of a
|
|
4872
|
+
* configured row (ADR 0014). The domain is checked against the
|
|
4873
|
+
* organization's verified ones at push time and again at send time; the
|
|
4874
|
+
* shape is all that is checked here, because it is all a build can know.
|
|
4779
4875
|
*/
|
|
4780
|
-
const
|
|
4876
|
+
const fromSchema = z.string().trim().max(200, "from must be at most 200 characters").refine((value) => parseFromAddress(value) !== null, { message: "from must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")" });
|
|
4781
4877
|
/** A content address: `sha256:` plus the lowercase hex digest. */
|
|
4782
4878
|
const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "digest must be sha256:<64 hex>");
|
|
4783
4879
|
/**
|
|
@@ -4813,13 +4909,6 @@ const SPINE_ENTRY_NAMES = [
|
|
|
4813
4909
|
const spineEntrySchema = z.object({
|
|
4814
4910
|
name: z.enum(SPINE_ENTRY_NAMES),
|
|
4815
4911
|
detail: z.string().max(200).optional(),
|
|
4816
|
-
/**
|
|
4817
|
-
* The sender identity a `send.email` call named for itself, overriding
|
|
4818
|
-
* the journey's own. Present only when the author wrote one on the call,
|
|
4819
|
-
* which is what lets the deploy warning and the journey detail page name
|
|
4820
|
-
* the override without re-reading the code.
|
|
4821
|
-
*/
|
|
4822
|
-
senderIdentity: z.string().max(100).optional(),
|
|
4823
4912
|
/** A `waitForEvent` timeout, as the author wrote it. */
|
|
4824
4913
|
timeout: z.string().max(50).optional(),
|
|
4825
4914
|
get steps() {
|
|
@@ -4831,10 +4920,10 @@ const spineEntrySchema = z.object({
|
|
|
4831
4920
|
}).meta({ id: "JourneySpineEntry" });
|
|
4832
4921
|
/**
|
|
4833
4922
|
* One journey in a stored manifest. A plain (non-strict) object on purpose:
|
|
4834
|
-
* a manifest pushed before the author's rollout gate went away still
|
|
4835
|
-
*
|
|
4836
|
-
* (ADR 0011). Zod strips
|
|
4837
|
-
*
|
|
4923
|
+
* a manifest pushed before the author's rollout gate went away still
|
|
4924
|
+
* carries keys that no longer exist, and every stored manifest has to keep
|
|
4925
|
+
* parsing for good (ADR 0011). Zod strips them, and the journey runs when
|
|
4926
|
+
* its `enabled` flag says so, which is the one gate there is.
|
|
4838
4927
|
*/
|
|
4839
4928
|
const manifestJourneySchema = z.object({
|
|
4840
4929
|
key: journeyKeySchema,
|
|
@@ -4848,16 +4937,37 @@ const manifestJourneySchema = z.object({
|
|
|
4848
4937
|
*/
|
|
4849
4938
|
purpose: consentPurposeKeySchema,
|
|
4850
4939
|
/**
|
|
4851
|
-
*
|
|
4852
|
-
*
|
|
4853
|
-
*
|
|
4854
|
-
*
|
|
4855
|
-
*
|
|
4856
|
-
*
|
|
4857
|
-
*
|
|
4858
|
-
* manifest still parses. The author's build is where the error is useful.
|
|
4940
|
+
* How often one recipient may enter. Enrollment derives from the purpose
|
|
4941
|
+
* (ADR 0015), so the only thing an author writes is how long after a
|
|
4942
|
+
* completed run the journey re-opens: absent means once, ever. Refused on
|
|
4943
|
+
* a transactional journey, which enrolls on every trigger — see
|
|
4944
|
+
* `manifestSchema` below, where the cross-field check lives (a refinement
|
|
4945
|
+
* on this object would break the `.pick()` the guest SDK and the guest
|
|
4946
|
+
* protocol both take of it).
|
|
4859
4947
|
*/
|
|
4860
|
-
|
|
4948
|
+
enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
|
|
4949
|
+
try {
|
|
4950
|
+
parseDuration(value);
|
|
4951
|
+
return true;
|
|
4952
|
+
} catch {
|
|
4953
|
+
return false;
|
|
4954
|
+
}
|
|
4955
|
+
}, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
|
|
4956
|
+
/**
|
|
4957
|
+
* The author's own sentence about what this journey does, shown wherever
|
|
4958
|
+
* the journey is read. Capped like a segment's description; absent when
|
|
4959
|
+
* the author wrote none.
|
|
4960
|
+
*/
|
|
4961
|
+
description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
|
|
4962
|
+
/**
|
|
4963
|
+
* The address every `send.email` in this journey goes out as, unless the
|
|
4964
|
+
* call names its own. Required of the author (`defineJourney` types it so,
|
|
4965
|
+
* and `cow build` refuses a journey without it), and still optional here:
|
|
4966
|
+
* releases pushed before ADR 0014 was amended carry none, and this schema
|
|
4967
|
+
* parses stored manifests as well as new ones. A push with no `from` is
|
|
4968
|
+
* refused by the domain gate, which says what to do about it.
|
|
4969
|
+
*/
|
|
4970
|
+
from: fromSchema.optional(),
|
|
4861
4971
|
spine: z.array(spineEntrySchema),
|
|
4862
4972
|
bundle: digestSchema
|
|
4863
4973
|
});
|
|
@@ -4868,6 +4978,13 @@ const manifestTemplateSchema = z.object({
|
|
|
4868
4978
|
sendClass: z.enum(SEND_CLASSES),
|
|
4869
4979
|
/** True asks the host to mint a signed `verifyUrl` prop at send time. */
|
|
4870
4980
|
verifyLink: z.boolean(),
|
|
4981
|
+
/**
|
|
4982
|
+
* True asks the host to mint a signed `unsubscribeUrl` prop at send time,
|
|
4983
|
+
* for the author's own footer link. Default false: a marketing send whose
|
|
4984
|
+
* HTML carries no unsubscribe link at all still gets one, appended as a
|
|
4985
|
+
* platform footer, so the link is never the author's to forget.
|
|
4986
|
+
*/
|
|
4987
|
+
unsubscribeLink: z.boolean().default(false),
|
|
4871
4988
|
/** JSON Schema of the template's `props`, converted by `cow build`. */
|
|
4872
4989
|
propsSchema: z.record(z.string(), z.unknown()),
|
|
4873
4990
|
bundle: digestSchema
|
|
@@ -4918,6 +5035,15 @@ const manifestSchema = z.object({
|
|
|
4918
5035
|
uniqueKeys(manifest.journeys, ctx, "journeys");
|
|
4919
5036
|
uniqueKeys(manifest.templates, ctx, "templates");
|
|
4920
5037
|
uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
|
|
5038
|
+
for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
|
|
5039
|
+
code: "custom",
|
|
5040
|
+
message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
|
|
5041
|
+
path: [
|
|
5042
|
+
"journeys",
|
|
5043
|
+
index,
|
|
5044
|
+
"enrollment"
|
|
5045
|
+
]
|
|
5046
|
+
});
|
|
4921
5047
|
});
|
|
4922
5048
|
/**
|
|
4923
5049
|
* One entry of a stored manifest: what a version is a snapshot of. Journeys
|
|
@@ -4934,7 +5060,7 @@ const versionManifestSchema = z.union([manifestJourneySchema, manifestTemplateSc
|
|
|
4934
5060
|
* further in the future than the clock-skew tolerance (the spec's "future
|
|
4935
5061
|
* timestamps are rejected with 422" rule). Absent stays valid. Used by the
|
|
4936
5062
|
* track and identify payload schemas; older-than-retention is a service
|
|
4937
|
-
* check (withinRetention) because the window is
|
|
5063
|
+
* check (withinRetention) because the window is the org's setting.
|
|
4938
5064
|
*/
|
|
4939
5065
|
function notFutureTimestamp(value) {
|
|
4940
5066
|
if (value === void 0) return true;
|
|
@@ -4981,7 +5107,7 @@ const traitBagSchema = z.custom((value) => value !== null && typeof value === "o
|
|
|
4981
5107
|
* `identify` carries it too: the grant has to have a route in through
|
|
4982
5108
|
* ingestion, or the only thing anyone can express is the revocation.
|
|
4983
5109
|
*/
|
|
4984
|
-
const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [
|
|
5110
|
+
const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
|
|
4985
5111
|
/**
|
|
4986
5112
|
* The identify payload fields, shared between the single-call body schema
|
|
4987
5113
|
* and the batch item schema (which drops `sourceId`: a batch names one
|
|
@@ -5015,7 +5141,6 @@ const identifyBodySchema = z.object({ data: identifyDataSchema });
|
|
|
5015
5141
|
const eventDtoSchema = z.object({
|
|
5016
5142
|
id: z.string(),
|
|
5017
5143
|
orgId: z.string(),
|
|
5018
|
-
environment: environmentSchema,
|
|
5019
5144
|
/** The app the event is attributed to: derived from the source. */
|
|
5020
5145
|
appId: z.string(),
|
|
5021
5146
|
/** The pipe it arrived through: what the caller named on the wire. */
|
|
@@ -5052,11 +5177,10 @@ const trackDataSchema = trackFieldsSchema.refine((data) => notFutureTimestamp(da
|
|
|
5052
5177
|
});
|
|
5053
5178
|
const trackBodySchema = z.object({ data: trackDataSchema });
|
|
5054
5179
|
/**
|
|
5055
|
-
*
|
|
5180
|
+
* The org-wide event feed query (GET /v1/events). Same opaque cursor as
|
|
5056
5181
|
* every other list; the sort key is the event timestamp, newest first, so
|
|
5057
5182
|
* the cursor's sortAt slot carries an event timestamp here. appId, sourceId
|
|
5058
|
-
* and event are exact-match filters, all optional.
|
|
5059
|
-
* from the selection header, never from the query.
|
|
5183
|
+
* and event are exact-match filters, all optional.
|
|
5060
5184
|
*
|
|
5061
5185
|
* This is the one place a pipe id is a filter: unlike a stored segment
|
|
5062
5186
|
* definition, a query param is read-time state, so a stale `?sourceId=` is a
|
|
@@ -5076,8 +5200,8 @@ const listEventsQuerySchema = paginationQuerySchema.extend({
|
|
|
5076
5200
|
profileId: z.string().trim().min(1).max(200).optional()
|
|
5077
5201
|
});
|
|
5078
5202
|
/**
|
|
5079
|
-
* Event stats (GET /v1/events/stats)
|
|
5080
|
-
*
|
|
5203
|
+
* Event stats (GET /v1/events/stats): the overview page's volume chart and
|
|
5204
|
+
* per-app breakdown. `volume` is
|
|
5081
5205
|
* zero-filled per UTC day over the whole `days` window ending today, so the
|
|
5082
5206
|
* chart gets a continuous time axis; `byApp` is sorted busiest-first with
|
|
5083
5207
|
* app names joined in.
|
|
@@ -5097,8 +5221,8 @@ const eventStatsDtoSchema = z.object({
|
|
|
5097
5221
|
}))
|
|
5098
5222
|
});
|
|
5099
5223
|
/**
|
|
5100
|
-
* Per-name activity (GET /v1/events/stats/names)
|
|
5101
|
-
*
|
|
5224
|
+
* Per-name activity (GET /v1/events/stats/names): what the event catalog
|
|
5225
|
+
* shows beside each entry. `count` is
|
|
5102
5226
|
* windowed over `days`; `lastSeenAt` is not windowed and is null only for a
|
|
5103
5227
|
* name that has never been seen, so "quiet for two months" reads as a zero
|
|
5104
5228
|
* count next to a real date rather than as no history.
|
|
@@ -5124,7 +5248,7 @@ const eventNameStatsDtoSchema = z.object({
|
|
|
5124
5248
|
* API in one request instead of thousands of round trips.
|
|
5125
5249
|
*
|
|
5126
5250
|
* A batch names one source for every item, `{ sourceId, items }`, so it
|
|
5127
|
-
* never spans apps
|
|
5251
|
+
* never spans apps. Each item is one
|
|
5128
5252
|
* flattened identify or track call tagged by `type`; the field schemas are
|
|
5129
5253
|
* the single-call ones minus `sourceId`, so an item valid here is valid
|
|
5130
5254
|
* there. Batch writes are QUIET in one sense: they skip raw-event journey
|
|
@@ -5427,8 +5551,11 @@ const listConsentPurposesQuerySchema = paginationQuerySchema;
|
|
|
5427
5551
|
*/
|
|
5428
5552
|
const DELIVERY_CHANNELS = deliveryChannelEnum.enumValues;
|
|
5429
5553
|
const DELIVERY_STATUSES = deliveryStatusEnum.enumValues;
|
|
5554
|
+
/** Why a send happened: a journey ran, a member tested, or a caller sent. */
|
|
5555
|
+
const DELIVERY_KINDS = deliveryKindEnum.enumValues;
|
|
5430
5556
|
const deliveryStatusSchema = z.enum(DELIVERY_STATUSES);
|
|
5431
5557
|
const deliveryChannelSchema = z.enum(DELIVERY_CHANNELS);
|
|
5558
|
+
const deliveryKindSchema = z.enum(DELIVERY_KINDS);
|
|
5432
5559
|
/**
|
|
5433
5560
|
* The SNS HTTPS envelope, common to every message SNS POSTs to the feedback
|
|
5434
5561
|
* endpoint. `Message` is a JSON STRING (the SES event for a `Notification`,
|
|
@@ -5528,18 +5655,45 @@ const deliverySchema = selectDeliverySchema.omit({
|
|
|
5528
5655
|
settledAt: z.iso.datetime().nullable()
|
|
5529
5656
|
});
|
|
5530
5657
|
/**
|
|
5531
|
-
* The message a dry run
|
|
5532
|
-
*
|
|
5533
|
-
* rendering: SES got it, and the log does not keep a second
|
|
5658
|
+
* The message a dry run rendered, stored under `payload.rendered` beside the
|
|
5659
|
+
* template data, so a developer can read what would have gone out. A real
|
|
5660
|
+
* send carries no rendering: SES got it, and the log does not keep a second
|
|
5661
|
+
* copy.
|
|
5534
5662
|
*/
|
|
5535
5663
|
const renderedEmailSchema = z.object({
|
|
5536
5664
|
subject: z.string(),
|
|
5537
5665
|
html: z.string(),
|
|
5538
|
-
text: z.string()
|
|
5666
|
+
text: z.string(),
|
|
5667
|
+
/**
|
|
5668
|
+
* Where a reply would have gone, when the send named one. Absent on a
|
|
5669
|
+
* test send and wherever none was named, which is replies going to the
|
|
5670
|
+
* from-address.
|
|
5671
|
+
*/
|
|
5672
|
+
replyTo: z.email().optional()
|
|
5539
5673
|
});
|
|
5540
5674
|
/** One delivery with the payload the send rendered (GET /v1/deliveries/:id). */
|
|
5541
5675
|
const deliveryDetailSchema = deliverySchema.extend({ payload: z.object({ rendered: renderedEmailSchema.optional() }).catchall(z.unknown()) });
|
|
5542
5676
|
/**
|
|
5677
|
+
* The `payload` a delivery of kind `api` carries: the envelope the caller of
|
|
5678
|
+
* the send facade wrote, and never the bodies (ADR 0014). Every field is
|
|
5679
|
+
* optional because the caller named only some of them, and the row is read
|
|
5680
|
+
* back through this rather than cast, so a row written by an older shape
|
|
5681
|
+
* reads as a message with fields missing instead of one with a type nobody
|
|
5682
|
+
* checked.
|
|
5683
|
+
*/
|
|
5684
|
+
const apiDeliveryPayloadSchema = z.object({
|
|
5685
|
+
from: z.string().optional(),
|
|
5686
|
+
to: z.array(z.string()).optional(),
|
|
5687
|
+
cc: z.array(z.string()).optional(),
|
|
5688
|
+
bcc: z.array(z.string()).optional(),
|
|
5689
|
+
reply_to: z.array(z.string()).optional(),
|
|
5690
|
+
subject: z.string().optional(),
|
|
5691
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
5692
|
+
/** The template key that wrote the message, and the props it took. */
|
|
5693
|
+
template: z.string().optional(),
|
|
5694
|
+
variables: z.record(z.string(), z.unknown()).optional()
|
|
5695
|
+
});
|
|
5696
|
+
/**
|
|
5543
5697
|
* List query for the deliveries log. Every filter is exact-match and
|
|
5544
5698
|
* optional; combining them ANDs them.
|
|
5545
5699
|
*
|
|
@@ -5553,113 +5707,29 @@ const listDeliveriesQuerySchema = paginationQuerySchema.extend({
|
|
|
5553
5707
|
direction: sortDirectionSchema.default("desc"),
|
|
5554
5708
|
status: deliveryStatusSchema.optional(),
|
|
5555
5709
|
journey: z.string().trim().min(1).max(200).optional(),
|
|
5556
|
-
/** The template or
|
|
5710
|
+
/** The template or webhook this attempt was for; a template page's own log. */
|
|
5557
5711
|
step: z.string().trim().min(1).max(200).optional(),
|
|
5558
5712
|
channel: deliveryChannelSchema.optional(),
|
|
5713
|
+
/** Why the send happened: a journey, a test send, or a direct API send. */
|
|
5714
|
+
kind: deliveryKindSchema.optional(),
|
|
5559
5715
|
appId: z.string().trim().min(1).max(200).optional(),
|
|
5560
5716
|
/** One person's deliveries, merged ids included, same as the event feed. */
|
|
5561
5717
|
profileId: z.string().trim().min(1).max(200).optional(),
|
|
5562
5718
|
/**
|
|
5563
5719
|
* Only deliveries touched at or after this instant, by `updatedAt`: the
|
|
5564
|
-
* cursor a tail (`cow dev`) polls with, so a
|
|
5565
|
-
*
|
|
5566
|
-
*
|
|
5567
|
-
*
|
|
5720
|
+
* cursor a tail (`cow dev`) polls with, so a send and its later feedback
|
|
5721
|
+
* transition both arrive. Inclusive for the same reason as the execution
|
|
5722
|
+
* filter, and paged the same way: `createdAt` still orders the page, this
|
|
5723
|
+
* only narrows it.
|
|
5568
5724
|
*/
|
|
5569
5725
|
since: z.iso.datetime().optional()
|
|
5570
5726
|
});
|
|
5571
5727
|
|
|
5572
|
-
//#endregion
|
|
5573
|
-
//#region ../../packages/shared/src/destinations.ts
|
|
5574
|
-
/**
|
|
5575
|
-
* Destinations API contracts. Derived from the Drizzle table via drizzle-zod.
|
|
5576
|
-
* signingSecret is deliberately absent from the DTO: it is shown exactly
|
|
5577
|
-
* once, in the create response (destinationCreatedSchema). consecutiveFailures
|
|
5578
|
-
* is absent too: it is the auto-disable counter's internal state, and
|
|
5579
|
-
* disabledAt is the part the dashboard banner needs.
|
|
5580
|
-
*
|
|
5581
|
-
* A destination belongs to one environment, chosen at creation and
|
|
5582
|
-
* immutable like a source's, and names are unique per org and environment,
|
|
5583
|
-
* so the same name points at a different receiver in each.
|
|
5584
|
-
*/
|
|
5585
|
-
const destinationSchema = selectDestinationSchema.extend({
|
|
5586
|
-
createdAt: z.iso.datetime(),
|
|
5587
|
-
updatedAt: z.iso.datetime(),
|
|
5588
|
-
disabledAt: z.iso.datetime().nullable()
|
|
5589
|
-
}).omit({
|
|
5590
|
-
signingSecret: true,
|
|
5591
|
-
consecutiveFailures: true
|
|
5592
|
-
});
|
|
5593
|
-
/** The two kinds of destination, from the table's own enum. */
|
|
5594
|
-
const destinationTypeSchema = destinationSchema.shape.type;
|
|
5595
|
-
const destinationCreatedSchema = destinationSchema.extend({ signingSecret: z.string().optional() });
|
|
5596
|
-
const webhookConfigSchema = z.object({ url: z.url("config.url must be a valid URL").refine((url) => {
|
|
5597
|
-
try {
|
|
5598
|
-
return ["http:", "https:"].includes(new URL(url).protocol);
|
|
5599
|
-
} catch {
|
|
5600
|
-
return false;
|
|
5601
|
-
}
|
|
5602
|
-
}, { message: "config.url must use http or https" }) });
|
|
5603
|
-
/**
|
|
5604
|
-
* The address shape only. WHICH domain is allowed is not a static fact: it
|
|
5605
|
-
* is the shared fallback domain or one this org has verified, which needs a
|
|
5606
|
-
* database read, so the rule lives in the destinations service (write time)
|
|
5607
|
-
* and the send gate (send time) instead of here.
|
|
5608
|
-
*/
|
|
5609
|
-
const senderIdentityConfigSchema = z.object({
|
|
5610
|
-
fromName: z.string().trim().min(1, "config.fromName is required").max(100).refine((name) => !/\p{Cc}/u.test(name), { message: "config.fromName must not contain control characters" }),
|
|
5611
|
-
fromEmail: z.email("config.fromEmail must be a valid email address")
|
|
5612
|
-
});
|
|
5613
|
-
/**
|
|
5614
|
-
* The two destination shapes, named individually so the dashboard's create
|
|
5615
|
-
* and edit forms can resolve against exactly one of them. A form knows its
|
|
5616
|
-
* type before the user types anything, and zodResolver over a member is
|
|
5617
|
-
* what gives react-hook-form a non-union field path to register.
|
|
5618
|
-
*/
|
|
5619
|
-
const webhookDestinationInputSchema = z.object({
|
|
5620
|
-
name: destinationNameSchema,
|
|
5621
|
-
/** The environment this destination lives in; immutable after creation. */
|
|
5622
|
-
environment: environmentSchema,
|
|
5623
|
-
type: z.literal("webhook"),
|
|
5624
|
-
config: webhookConfigSchema
|
|
5625
|
-
});
|
|
5626
|
-
const senderIdentityDestinationInputSchema = z.object({
|
|
5627
|
-
name: destinationNameSchema,
|
|
5628
|
-
/** The environment this destination lives in; immutable after creation. */
|
|
5629
|
-
environment: environmentSchema,
|
|
5630
|
-
type: z.literal("sender_identity"),
|
|
5631
|
-
config: senderIdentityConfigSchema
|
|
5632
|
-
});
|
|
5633
|
-
const createDestinationBodySchema = z.object({ data: z.discriminatedUnion("type", [webhookDestinationInputSchema, senderIdentityDestinationInputSchema]) });
|
|
5634
|
-
/**
|
|
5635
|
-
* Destination update. `enabled` is the admin's half of the auto-disable
|
|
5636
|
-
* loop: the webhook send activity stamps `disabledAt` after enough
|
|
5637
|
-
* consecutive failures and every send after that records `skipped_disabled`,
|
|
5638
|
-
* so without a way to clear it the only exit would be deleting the
|
|
5639
|
-
* destination, which throws away the signing secret the receiver is
|
|
5640
|
-
* configured with. `enabled: true` clears the stamp and the failure counter;
|
|
5641
|
-
* `enabled: false` is the same switch operated by hand.
|
|
5642
|
-
*/
|
|
5643
|
-
const updateDestinationBodySchema = z.object({ data: z.object({
|
|
5644
|
-
name: destinationNameSchema.optional(),
|
|
5645
|
-
config: z.unknown().optional(),
|
|
5646
|
-
enabled: z.boolean().optional()
|
|
5647
|
-
}).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" }) });
|
|
5648
|
-
/**
|
|
5649
|
-
* `q` is a substring search over the destination's name; `type` narrows to
|
|
5650
|
-
* one kind, because the webhooks page and the sender identities section on
|
|
5651
|
-
* the domains page each want one and the table holds both.
|
|
5652
|
-
*/
|
|
5653
|
-
const listDestinationsQuerySchema = paginationQuerySchema.extend({
|
|
5654
|
-
q: searchQuerySchema,
|
|
5655
|
-
type: destinationTypeSchema.optional()
|
|
5656
|
-
});
|
|
5657
|
-
|
|
5658
5728
|
//#endregion
|
|
5659
5729
|
//#region ../../packages/shared/src/domains.ts
|
|
5660
5730
|
/**
|
|
5661
5731
|
* Sending-domain contracts. Derived from the Drizzle table so the row and
|
|
5662
|
-
* the DTO cannot drift, same as deliveries,
|
|
5732
|
+
* the DTO cannot drift, same as deliveries, webhooks, and suppressions.
|
|
5663
5733
|
*
|
|
5664
5734
|
* Verification itself is SES's; everything here describes the claim and the
|
|
5665
5735
|
* mirrored answer.
|
|
@@ -5683,6 +5753,7 @@ const dnsRecordSchema = z.object({
|
|
|
5683
5753
|
/** One sending domain on the wire: dates become ISO 8601 strings. */
|
|
5684
5754
|
const senderDomainSchema = selectSenderDomainSchema.extend({
|
|
5685
5755
|
dnsRecords: z.array(dnsRecordSchema),
|
|
5756
|
+
challengeVerifiedAt: z.iso.datetime().nullable(),
|
|
5686
5757
|
lastCheckedAt: z.iso.datetime().nullable(),
|
|
5687
5758
|
createdAt: z.iso.datetime(),
|
|
5688
5759
|
updatedAt: z.iso.datetime()
|
|
@@ -5715,20 +5786,6 @@ const domainDnsSetupSchema = z.object({
|
|
|
5715
5786
|
provider: z.string().nullable()
|
|
5716
5787
|
});
|
|
5717
5788
|
|
|
5718
|
-
//#endregion
|
|
5719
|
-
//#region ../../packages/shared/src/enabled.ts
|
|
5720
|
-
/**
|
|
5721
|
-
* The enable flag per environment; absent rows read as off (ADR 0011).
|
|
5722
|
-
*
|
|
5723
|
-
* Its own module because both `journeys.ts` and `pushes.ts` need it and
|
|
5724
|
-
* neither may import the other: a journey carries the flag, and so does the
|
|
5725
|
-
* project status a push is read back through.
|
|
5726
|
-
*/
|
|
5727
|
-
const journeyEnabledSchema = z.object({
|
|
5728
|
-
development: z.boolean(),
|
|
5729
|
-
production: z.boolean()
|
|
5730
|
-
});
|
|
5731
|
-
|
|
5732
5789
|
//#endregion
|
|
5733
5790
|
//#region ../../packages/shared/src/patterns.ts
|
|
5734
5791
|
/**
|
|
@@ -5819,29 +5876,6 @@ function patternPlaceholder(pattern) {
|
|
|
5819
5876
|
* a guest returns with these schemas before anything acts on it; the guest
|
|
5820
5877
|
* SDK and the Node simulator produce and consume the same shapes.
|
|
5821
5878
|
*/
|
|
5822
|
-
/** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
|
|
5823
|
-
const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
|
|
5824
|
-
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
|
|
5825
|
-
const DURATION_UNIT_MS = {
|
|
5826
|
-
ms: 1,
|
|
5827
|
-
s: 1e3,
|
|
5828
|
-
m: 6e4,
|
|
5829
|
-
h: 36e5,
|
|
5830
|
-
d: 864e5,
|
|
5831
|
-
w: 6048e5
|
|
5832
|
-
};
|
|
5833
|
-
/**
|
|
5834
|
-
* A duration in milliseconds. The simulator's virtual clock and the runner's
|
|
5835
|
-
* timers both need it, and neither may pull in Temporal's `msToNumber` (one
|
|
5836
|
-
* runs in the CLI, the other inside workflow code).
|
|
5837
|
-
*/
|
|
5838
|
-
function parseDuration(duration) {
|
|
5839
|
-
if (typeof duration === "number") return duration;
|
|
5840
|
-
const match = DURATION_PATTERN.exec(duration.trim());
|
|
5841
|
-
if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
|
|
5842
|
-
const unit = DURATION_UNIT_MS[match[2]];
|
|
5843
|
-
return Math.round(Number(match[1]) * unit);
|
|
5844
|
-
}
|
|
5845
5879
|
/** The event a journey runs for, or waits on: name, properties, and when. */
|
|
5846
5880
|
const guestEventSchema = z.object({
|
|
5847
5881
|
name: z.string().min(1),
|
|
@@ -5856,8 +5890,8 @@ const properties = z.record(z.string(), z.unknown());
|
|
|
5856
5890
|
* them. A name outside the union is rejected, never dispatched.
|
|
5857
5891
|
*
|
|
5858
5892
|
* Every `args` is strict, and that is the tenancy boundary made mechanical:
|
|
5859
|
-
* a module that returns an `orgId
|
|
5860
|
-
*
|
|
5893
|
+
* a module that returns an `orgId` or any other field beside the ones a
|
|
5894
|
+
* capability takes fails the parse instead of having it
|
|
5861
5895
|
* quietly dropped. Tenant context comes from the workflow input, never from
|
|
5862
5896
|
* guest output, and this is where saying so becomes checkable.
|
|
5863
5897
|
*/
|
|
@@ -5879,12 +5913,15 @@ const commandSchema = z.discriminatedUnion("name", [
|
|
|
5879
5913
|
template: journeyKeySchema,
|
|
5880
5914
|
props: properties,
|
|
5881
5915
|
/**
|
|
5882
|
-
*
|
|
5883
|
-
*
|
|
5884
|
-
* one
|
|
5885
|
-
*
|
|
5916
|
+
* The address this mail leaves as. The guest SDK fills in the
|
|
5917
|
+
* journey's own whenever the call does not name one, so the host has
|
|
5918
|
+
* one resolution path and never reads the manifest to find it. A
|
|
5919
|
+
* journey must name one, so absent on both is a release pushed before
|
|
5920
|
+
* that was true, and the `domain` gate refuses it.
|
|
5886
5921
|
*/
|
|
5887
|
-
|
|
5922
|
+
from: fromSchema.optional(),
|
|
5923
|
+
/** Where a reply to this one mail goes, instead of the from-address. */
|
|
5924
|
+
replyTo: z.email().optional()
|
|
5888
5925
|
})
|
|
5889
5926
|
}),
|
|
5890
5927
|
z.object({
|
|
@@ -5965,7 +6002,7 @@ const executionLimitsSchema = z.object({
|
|
|
5965
6002
|
logLineBytes: z.number().int().positive()
|
|
5966
6003
|
});
|
|
5967
6004
|
const journeyStepInputSchema = z.object({
|
|
5968
|
-
protocol: z.literal(
|
|
6005
|
+
protocol: z.literal(3),
|
|
5969
6006
|
kind: z.literal("journey"),
|
|
5970
6007
|
key: journeyKeySchema,
|
|
5971
6008
|
event: guestEventSchema,
|
|
@@ -6005,7 +6042,7 @@ const journeyStepOutputSchema = z.discriminatedUnion("status", [
|
|
|
6005
6042
|
})
|
|
6006
6043
|
]);
|
|
6007
6044
|
const templateRenderInputSchema = z.object({
|
|
6008
|
-
protocol: z.literal(
|
|
6045
|
+
protocol: z.literal(3),
|
|
6009
6046
|
kind: z.literal("template"),
|
|
6010
6047
|
key: journeyKeySchema,
|
|
6011
6048
|
props: properties
|
|
@@ -6024,11 +6061,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
|
|
|
6024
6061
|
const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
|
|
6025
6062
|
trigger: true,
|
|
6026
6063
|
purpose: true,
|
|
6027
|
-
|
|
6064
|
+
enrollment: true,
|
|
6065
|
+
description: true,
|
|
6066
|
+
from: true,
|
|
6028
6067
|
tags: true
|
|
6029
6068
|
}).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
|
|
6030
6069
|
sendClass: true,
|
|
6031
6070
|
verifyLink: true,
|
|
6071
|
+
unsubscribeLink: true,
|
|
6032
6072
|
propsSchema: true,
|
|
6033
6073
|
tags: true
|
|
6034
6074
|
}).extend({ kind: z.literal("template") })]);
|
|
@@ -6088,7 +6128,7 @@ const executionDetailSchema = executionSchema.extend({
|
|
|
6088
6128
|
logs: z.array(executionLogSchema)
|
|
6089
6129
|
});
|
|
6090
6130
|
const listExecutionsQuerySchema = paginationQuerySchema.extend({
|
|
6091
|
-
/** A journey key
|
|
6131
|
+
/** A journey key. */
|
|
6092
6132
|
journey: z.string().trim().min(1).max(200).optional(),
|
|
6093
6133
|
/** One version's own runs, for the history rows on a journey. */
|
|
6094
6134
|
version: z.string().trim().min(1).max(200).optional(),
|
|
@@ -6104,12 +6144,15 @@ const listExecutionsQuerySchema = paginationQuerySchema.extend({
|
|
|
6104
6144
|
since: z.iso.datetime().optional()
|
|
6105
6145
|
});
|
|
6106
6146
|
/**
|
|
6107
|
-
* What to stop: every live execution of one journey
|
|
6108
|
-
*
|
|
6109
|
-
*
|
|
6110
|
-
*
|
|
6147
|
+
* What to stop: every live execution of one journey, or the ones still
|
|
6148
|
+
* pinned to one version. Cancelling is explicit (ADR 0011): disabling a
|
|
6149
|
+
* journey stops new entries and lets what is running finish, and this is
|
|
6150
|
+
* the other verb.
|
|
6111
6151
|
*/
|
|
6112
|
-
const cancelExecutionsBodySchema = z.object({ data: z.
|
|
6152
|
+
const cancelExecutionsBodySchema = z.object({ data: z.object({
|
|
6153
|
+
journey: z.string().trim().min(1).max(200).optional(),
|
|
6154
|
+
version: z.string().trim().min(1).max(200).optional()
|
|
6155
|
+
}).refine((data) => data.journey === void 0 !== (data.version === void 0), { message: "name a journey or a version, not both" }) });
|
|
6113
6156
|
/**
|
|
6114
6157
|
* Cancelling is a request, not an edit: each execution stops when it reaches
|
|
6115
6158
|
* its next step, so the answer is that the sweep is under way rather than a
|
|
@@ -6127,9 +6170,8 @@ const cancellingSchema = z.object({ cancelling: z.literal(true) });
|
|
|
6127
6170
|
const projectNameSchema = z.string().trim().min(1).max(200);
|
|
6128
6171
|
/**
|
|
6129
6172
|
* `cow.json` (spec: Project layout): the org, and which of its projects this
|
|
6130
|
-
* directory is.
|
|
6131
|
-
*
|
|
6132
|
-
* silently ignored setting.
|
|
6173
|
+
* directory is. Auth never lives in the project. Strict, so a typo'd key
|
|
6174
|
+
* is a build error rather than a silently ignored setting.
|
|
6133
6175
|
*/
|
|
6134
6176
|
const cowConfigSchema = z.strictObject({
|
|
6135
6177
|
$schema: z.url().optional(),
|
|
@@ -6238,7 +6280,7 @@ const createPushBodySchema = z.object({ data: z.object({
|
|
|
6238
6280
|
* is a live CLI talking to a live platform, so a mismatch here is a 422
|
|
6239
6281
|
* the developer fixes by updating `@cowliss/cli`, and nothing is stored.
|
|
6240
6282
|
*/
|
|
6241
|
-
manifest: manifestSchema.safeExtend({ protocol: z.literal(
|
|
6283
|
+
manifest: manifestSchema.safeExtend({ protocol: z.literal(3) }),
|
|
6242
6284
|
/** The `cow.json` project this push belongs to. */
|
|
6243
6285
|
project: projectNameSchema
|
|
6244
6286
|
}) });
|
|
@@ -6256,7 +6298,7 @@ const listVersionsQuerySchema = paginationQuerySchema.extend({
|
|
|
6256
6298
|
});
|
|
6257
6299
|
/**
|
|
6258
6300
|
* One key of a project as `cow status` and the MCP `status` tool report it:
|
|
6259
|
-
* the code the server holds for it,
|
|
6301
|
+
* the code the server holds for it, what state it is in, and what is still
|
|
6260
6302
|
* running. Journeys and templates share the shape, because a developer asks
|
|
6261
6303
|
* the same question of both; the two fields only a journey has are null on a
|
|
6262
6304
|
* template.
|
|
@@ -6270,13 +6312,14 @@ const projectStatusKeySchema = z.object({
|
|
|
6270
6312
|
* server holds. Null only for a journey whose versions are all pruned.
|
|
6271
6313
|
*/
|
|
6272
6314
|
latestVersion: versionSchema.nullable(),
|
|
6273
|
-
/**
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6315
|
+
/**
|
|
6316
|
+
* The journey's one state; null on a template, which has none. Built from
|
|
6317
|
+
* the column here rather than imported from `journeys.ts`, which reads
|
|
6318
|
+
* this module for its version summary.
|
|
6319
|
+
*/
|
|
6320
|
+
status: z.enum(journeyStatusEnum.enumValues).nullable(),
|
|
6321
|
+
/** Executions still running or waiting. */
|
|
6322
|
+
liveExecutions: z.number().int()
|
|
6280
6323
|
});
|
|
6281
6324
|
/**
|
|
6282
6325
|
* Everything the server knows about one project's keys, in one read: the
|
|
@@ -6285,33 +6328,76 @@ const projectStatusKeySchema = z.object({
|
|
|
6285
6328
|
* is my project doing" is useless split across cursors.
|
|
6286
6329
|
*
|
|
6287
6330
|
* `warnings` is what a deploy used to answer with (ADR 0011 retired the
|
|
6288
|
-
* deploy): a name a journey references that the
|
|
6289
|
-
* yet. They never block, because a segment or a
|
|
6331
|
+
* deploy): a name a journey references that the organization does not
|
|
6332
|
+
* define yet. They never block, because a segment or a webhook may be created
|
|
6290
6333
|
* right after a push.
|
|
6291
6334
|
*/
|
|
6292
6335
|
const projectStatusSchema = z.object({
|
|
6293
6336
|
project: projectNameSchema,
|
|
6294
6337
|
keys: z.array(projectStatusKeySchema),
|
|
6295
|
-
warnings: z.
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6338
|
+
warnings: z.array(z.string())
|
|
6339
|
+
});
|
|
6340
|
+
|
|
6341
|
+
//#endregion
|
|
6342
|
+
//#region ../../packages/shared/src/segments.ts
|
|
6343
|
+
const segmentSchema = selectSegmentSchema.extend({
|
|
6344
|
+
definition: segmentDefinitionSchema,
|
|
6345
|
+
createdAt: z.iso.datetime(),
|
|
6346
|
+
updatedAt: z.iso.datetime()
|
|
6347
|
+
});
|
|
6348
|
+
/** The detail view adds the materialized member count. */
|
|
6349
|
+
const segmentDetailSchema = segmentSchema.extend({ memberCount: z.number().int().min(0) });
|
|
6350
|
+
const segmentMemberSchema = z.object({
|
|
6351
|
+
segmentId: z.string(),
|
|
6352
|
+
orgId: z.string(),
|
|
6353
|
+
profileId: z.string(),
|
|
6354
|
+
enteredAt: z.iso.datetime()
|
|
6355
|
+
});
|
|
6356
|
+
/**
|
|
6357
|
+
* The builder's design-time sanity check: run one not-yet-persisted
|
|
6358
|
+
* definition over the org's existing profiles. Members is a small sample of
|
|
6359
|
+
* matching profile ids, not the full member list: there is no segment row
|
|
6360
|
+
* and no membership entry, so there is no segmentId or enteredAt to report.
|
|
6361
|
+
*
|
|
6362
|
+
* The scan is bounded, so `memberCount` is the exact org-wide count only
|
|
6363
|
+
* when `truncated` is false. When it is true the scan stopped at the cap,
|
|
6364
|
+
* and `memberCount` is the count over the first `scanned` profiles only.
|
|
6365
|
+
*/
|
|
6366
|
+
const previewSegmentBodySchema = z.object({ data: segmentDefinitionSchema });
|
|
6367
|
+
const segmentPreviewSchema = z.object({
|
|
6368
|
+
memberCount: z.number().int().min(0),
|
|
6369
|
+
members: z.array(z.string()),
|
|
6370
|
+
/** Profiles the preview actually evaluated. */
|
|
6371
|
+
scanned: z.number().int().min(0),
|
|
6372
|
+
/** True when the scan hit its cap, so memberCount is a floor. */
|
|
6373
|
+
truncated: z.boolean()
|
|
6299
6374
|
});
|
|
6375
|
+
const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
|
|
6376
|
+
const descriptionSchema = z.string().trim().max(500, `description must be at most ${500} characters`);
|
|
6377
|
+
const createSegmentBodySchema = z.object({ data: z.object({
|
|
6378
|
+
name: nameSchema,
|
|
6379
|
+
description: descriptionSchema.nullish(),
|
|
6380
|
+
definition: segmentDefinitionSchema
|
|
6381
|
+
}) });
|
|
6382
|
+
const updateSegmentBodySchema = z.object({ data: z.object({
|
|
6383
|
+
name: nameSchema.optional(),
|
|
6384
|
+
description: descriptionSchema.nullish(),
|
|
6385
|
+
definition: segmentDefinitionSchema.optional()
|
|
6386
|
+
}).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0, { message: "at least one field (name, description or definition) is required" }) });
|
|
6387
|
+
/** `q` is a substring search over the segment's name. */
|
|
6388
|
+
const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
|
|
6389
|
+
const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
|
|
6300
6390
|
|
|
6301
6391
|
//#endregion
|
|
6302
6392
|
//#region ../../packages/shared/src/journeys.ts
|
|
6303
6393
|
/**
|
|
6304
6394
|
* Journeys as the API reports them: the rows a push derives from its
|
|
6305
6395
|
* manifest, one per (org, key). Nothing here is authored through the API, so
|
|
6306
|
-
* the only write is the
|
|
6396
|
+
* the only write is the status.
|
|
6307
6397
|
*
|
|
6308
6398
|
* Derived from the Drizzle table via drizzle-zod so the wire DTO and the row
|
|
6309
6399
|
* share one source of truth; the jsonb columns get explicit wire schemas
|
|
6310
|
-
* because the row types are opaque to drizzle-zod.
|
|
6311
|
-
* column: it is the journey_states value per environment, absent meaning
|
|
6312
|
-
* off, and both environments are reported whichever one the caller selected,
|
|
6313
|
-
* because "is this on in production?" is the question the list exists to
|
|
6314
|
-
* answer.
|
|
6400
|
+
* because the row types are opaque to drizzle-zod.
|
|
6315
6401
|
*/
|
|
6316
6402
|
const journeyTriggerSchema = triggerSchema;
|
|
6317
6403
|
/**
|
|
@@ -6320,11 +6406,19 @@ const journeyTriggerSchema = triggerSchema;
|
|
|
6320
6406
|
* control flow is whatever its code does at run time.
|
|
6321
6407
|
*/
|
|
6322
6408
|
const journeySpineEntrySchema = spineEntrySchema;
|
|
6409
|
+
/**
|
|
6410
|
+
* The one operational state a manager sets (ADR 0018). `on` lets recipients
|
|
6411
|
+
* enroll and executions run; `off` enrolls nobody and lets what is in flight
|
|
6412
|
+
* finish; `paused` enrolls nobody and holds what is in flight before its next
|
|
6413
|
+
* step. Turning a paused journey on resumes it.
|
|
6414
|
+
*/
|
|
6415
|
+
const JOURNEY_STATUSES = journeyStatusEnum.enumValues;
|
|
6416
|
+
const journeyStatusSchema = z.enum(JOURNEY_STATUSES);
|
|
6323
6417
|
const journeySchema = selectJourneySchema.extend({
|
|
6324
6418
|
trigger: journeyTriggerSchema,
|
|
6325
6419
|
purpose: consentPurposeKeySchema,
|
|
6326
6420
|
spine: z.array(journeySpineEntrySchema),
|
|
6327
|
-
|
|
6421
|
+
status: journeyStatusSchema,
|
|
6328
6422
|
/**
|
|
6329
6423
|
* The newest version of this key, whatever it compiled to, so a list can
|
|
6330
6424
|
* say "pushed 2 hours ago" and "compiling" without a second read. What the
|
|
@@ -6337,14 +6431,24 @@ const journeySchema = selectJourneySchema.extend({
|
|
|
6337
6431
|
updatedAt: z.iso.datetime()
|
|
6338
6432
|
});
|
|
6339
6433
|
/**
|
|
6434
|
+
* One journey, with the segment it owns when its trigger inlines a
|
|
6435
|
+
* definition (ADR 0016): the same row and member count the segments API
|
|
6436
|
+
* reports, so the journey's page can show who is in its segment without a
|
|
6437
|
+
* second concept. Null for an event trigger.
|
|
6438
|
+
*
|
|
6439
|
+
* On the read of one journey only. A list would be one member count per row,
|
|
6440
|
+
* and a list already shows the trigger.
|
|
6441
|
+
*/
|
|
6442
|
+
const journeyDetailSchema = journeySchema.extend({ segment: segmentDetailSchema.nullable() });
|
|
6443
|
+
/**
|
|
6340
6444
|
* Journey list query. `q` is a substring search over the key and the tags,
|
|
6341
6445
|
* the two things an author names a journey by; `tag` is "has this tag",
|
|
6342
6446
|
* exact and case-sensitive, so a badge in the table is the way into it.
|
|
6343
6447
|
* Both are server-side, like every other list filter.
|
|
6344
6448
|
*/
|
|
6345
6449
|
const listJourneysQuerySchema = paginationQuerySchema.extend({
|
|
6346
|
-
/** On or
|
|
6347
|
-
|
|
6450
|
+
/** On, off or paused; the journey's one state. */
|
|
6451
|
+
status: journeyStatusSchema.optional(),
|
|
6348
6452
|
q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0),
|
|
6349
6453
|
tag: z.string().trim().max(50, "tag must be at most 50 characters").optional()
|
|
6350
6454
|
});
|
|
@@ -6372,41 +6476,24 @@ const journeyStatsSchema = z.object({
|
|
|
6372
6476
|
deliveries: z.object({ byStatus: z.record(deliveryStatusSchema, z.number().int()) })
|
|
6373
6477
|
});
|
|
6374
6478
|
/**
|
|
6375
|
-
*
|
|
6376
|
-
*
|
|
6377
|
-
*
|
|
6378
|
-
*
|
|
6379
|
-
*/
|
|
6380
|
-
const journeyDeliverySchema = selectDeliverySchema.pick({
|
|
6381
|
-
id: true,
|
|
6382
|
-
journey: true,
|
|
6383
|
-
step: true,
|
|
6384
|
-
channel: true,
|
|
6385
|
-
status: true,
|
|
6386
|
-
dryRun: true,
|
|
6387
|
-
error: true
|
|
6388
|
-
}).extend({ createdAt: z.iso.datetime() });
|
|
6389
|
-
/** One journey on a user's timeline: the journey, their run, their sends. */
|
|
6390
|
-
const userJourneySchema = z.object({
|
|
6391
|
-
journey: journeySchema,
|
|
6392
|
-
/** Null when this user never entered the journey. */
|
|
6393
|
-
execution: executionSchema.nullable(),
|
|
6394
|
-
deliveries: z.array(journeyDeliverySchema)
|
|
6395
|
-
});
|
|
6396
|
-
const listUserJourneysQuerySchema = paginationQuerySchema;
|
|
6397
|
-
/**
|
|
6398
|
-
* Which journeys the flag acts on: the keys the caller named, or every
|
|
6399
|
-
* journey of one project. Exactly one of the two, because a call carrying
|
|
6400
|
-
* both would have to decide which it meant.
|
|
6479
|
+
* Which journeys the status acts on, and which state to put them in: the
|
|
6480
|
+
* keys the caller named, or every journey of one project. Exactly one of the
|
|
6481
|
+
* two selectors, because a call carrying both would have to decide which it
|
|
6482
|
+
* meant.
|
|
6401
6483
|
*
|
|
6402
6484
|
* A list rather than one key per call so every surface can do what `cow
|
|
6403
6485
|
* enable` does: name several keys, or `--all` (which is `project`, resolved
|
|
6404
6486
|
* server-side, so an agent turning a project on is one call too).
|
|
6487
|
+
*
|
|
6488
|
+
* One body for three states rather than a route per verb (ADR 0018): the
|
|
6489
|
+
* transitions differ in what they do to what is in flight, not in who may
|
|
6490
|
+
* ask or what they name.
|
|
6405
6491
|
*/
|
|
6406
|
-
const
|
|
6492
|
+
const setJourneysStatusBodySchema = z.object({ data: z.object({
|
|
6407
6493
|
keys: z.array(journeyKeySchema).min(1).max(PUSH_LIMITS.journeys).optional(),
|
|
6408
6494
|
/** Every journey of this project, by the name `cow.json` carries. */
|
|
6409
|
-
project: projectNameSchema.optional()
|
|
6495
|
+
project: projectNameSchema.optional(),
|
|
6496
|
+
status: journeyStatusSchema
|
|
6410
6497
|
}).refine((data) => data.keys === void 0 !== (data.project === void 0), { message: "name keys or a project, not both" }) });
|
|
6411
6498
|
/**
|
|
6412
6499
|
* Dry run: one real profile, the journey's latest ready version, and every
|
|
@@ -6415,6 +6502,106 @@ const setJourneysEnabledBodySchema = z.object({ data: z.object({
|
|
|
6415
6502
|
* now and a v1 instance was a Temporal read.
|
|
6416
6503
|
*/
|
|
6417
6504
|
const dryRunJourneyBodySchema = z.object({ data: z.object({ profileId: z.string().min(1) }) });
|
|
6505
|
+
/**
|
|
6506
|
+
* Enrollment (ADR 0015): what turning a journey on would do, or is doing,
|
|
6507
|
+
* to the people already in its audience.
|
|
6508
|
+
*
|
|
6509
|
+
* The same three numbers answer both questions, so they are one schema: a
|
|
6510
|
+
* preview walks the whole segment counting, and the running job counts the
|
|
6511
|
+
* same way as it goes. `scanned` is the segment's current membership,
|
|
6512
|
+
* `enrolled` the ones that entered (or in a preview would), and the skips
|
|
6513
|
+
* say why the rest did not.
|
|
6514
|
+
*/
|
|
6515
|
+
const enrollmentCountsSchema = z.object({
|
|
6516
|
+
scanned: z.number().int(),
|
|
6517
|
+
enrolled: z.number().int(),
|
|
6518
|
+
skipped: z.object({
|
|
6519
|
+
/** They have been through this journey, and it enrolls once. */
|
|
6520
|
+
completed: z.number().int(),
|
|
6521
|
+
/** They finished recently and the journey's cooldown has not elapsed. */
|
|
6522
|
+
cooldown: z.number().int(),
|
|
6523
|
+
/** They are in the journey right now. */
|
|
6524
|
+
running: z.number().int()
|
|
6525
|
+
})
|
|
6526
|
+
});
|
|
6527
|
+
/**
|
|
6528
|
+
* How each skip is said to a person, in the order the surfaces list them.
|
|
6529
|
+
* The confirm screen, the progress card and `cow enable` all report the same
|
|
6530
|
+
* three numbers, and the words for them live here so the three cannot drift:
|
|
6531
|
+
* `tile` names the count on its own, `reason` completes "N ...".
|
|
6532
|
+
*/
|
|
6533
|
+
const ENROLLMENT_SKIP_WORDS = {
|
|
6534
|
+
completed: {
|
|
6535
|
+
tile: "Already been through",
|
|
6536
|
+
reason: "have already been through it"
|
|
6537
|
+
},
|
|
6538
|
+
cooldown: {
|
|
6539
|
+
tile: "Too recently",
|
|
6540
|
+
reason: "went through it too recently"
|
|
6541
|
+
},
|
|
6542
|
+
running: {
|
|
6543
|
+
tile: "In it already",
|
|
6544
|
+
reason: "are in it right now"
|
|
6545
|
+
}
|
|
6546
|
+
};
|
|
6547
|
+
const enrollmentStatusSchema = enrollmentCountsSchema.extend({
|
|
6548
|
+
/**
|
|
6549
|
+
* `running` while the job works, `done` when it finished the segment,
|
|
6550
|
+
* `cancelled` when someone stopped it, `stopped` when the journey itself
|
|
6551
|
+
* stopped being on under it, `failed` when it could not finish. On any of
|
|
6552
|
+
* the three that end early the counts are what it reached, and the people
|
|
6553
|
+
* it already enrolled stay in the journey.
|
|
6554
|
+
*/
|
|
6555
|
+
state: z.enum([
|
|
6556
|
+
"running",
|
|
6557
|
+
"done",
|
|
6558
|
+
"cancelled",
|
|
6559
|
+
"stopped",
|
|
6560
|
+
"failed"
|
|
6561
|
+
]) });
|
|
6562
|
+
/**
|
|
6563
|
+
* What a journey turned on says about the audience it now has waiting.
|
|
6564
|
+
*
|
|
6565
|
+
* It rides on the status response rather than being fetched separately so
|
|
6566
|
+
* that every surface that moves the control sees it: `cow enable` prints it,
|
|
6567
|
+
* and the MCP tool, which is planned from the contract and has no code of
|
|
6568
|
+
* its own, returns it. An agent that cannot start the enrollment can still
|
|
6569
|
+
* tell the person who can how many people it would reach and where to go.
|
|
6570
|
+
*
|
|
6571
|
+
* Null on a journey whose trigger is an event: there is no standing audience
|
|
6572
|
+
* to reach. `counts` is null when the numbers could not be worked out just
|
|
6573
|
+
* then; the journey is on either way, because a count nobody could read is
|
|
6574
|
+
* no reason to leave the switch off.
|
|
6575
|
+
*/
|
|
6576
|
+
const enrollmentOfferSchema = z.object({
|
|
6577
|
+
counts: enrollmentCountsSchema.nullable(),
|
|
6578
|
+
/** The journey's page, where the offer can be accepted. */
|
|
6579
|
+
url: z.string()
|
|
6580
|
+
});
|
|
6581
|
+
/**
|
|
6582
|
+
* A journey as the status route answers, carrying that offer. Null on every
|
|
6583
|
+
* status but `on`: a journey turned off or held reaches nobody, so there is
|
|
6584
|
+
* no audience to offer.
|
|
6585
|
+
*/
|
|
6586
|
+
const journeyWithOfferSchema = journeySchema.extend({ enrollment: enrollmentOfferSchema.nullable() });
|
|
6587
|
+
/**
|
|
6588
|
+
* What holding a journey until a given day would end (ADR 0018). A wait
|
|
6589
|
+
* that comes due while the journey is held ends that person's run, so
|
|
6590
|
+
* before putting one on hold it is worth knowing how many runs that is.
|
|
6591
|
+
*
|
|
6592
|
+
* The day is the caller's, not a fixed horizon: "until tomorrow" and "until
|
|
6593
|
+
* next month" are different decisions, and the numbers behind them are what
|
|
6594
|
+
* tell them apart.
|
|
6595
|
+
*/
|
|
6596
|
+
const waitsDueQuerySchema = z.object({
|
|
6597
|
+
/** Count the waits that come due before this instant. */
|
|
6598
|
+
before: z.iso.datetime() });
|
|
6599
|
+
const waitsDueSchema = z.object({
|
|
6600
|
+
/** Executions in flight whose next step comes due before then. */
|
|
6601
|
+
due: z.number().int(),
|
|
6602
|
+
/** Executions in flight altogether, due or not. */
|
|
6603
|
+
inFlight: z.number().int()
|
|
6604
|
+
});
|
|
6418
6605
|
|
|
6419
6606
|
//#endregion
|
|
6420
6607
|
//#region ../../packages/shared/src/journeys-v2/sandbox.ts
|
|
@@ -6444,7 +6631,7 @@ const sandboxFailureCodeSchema = z.enum(SANDBOX_FAILURE_CODES);
|
|
|
6444
6631
|
* to be written by hand or by an agent, not generated.
|
|
6445
6632
|
*
|
|
6446
6633
|
* The schema lives here rather than in packages/journeys so the CLI can reject
|
|
6447
|
-
* a bad file before it boots a Temporal
|
|
6634
|
+
* a bad file before it boots a Temporal dev server, and so the docs site can
|
|
6448
6635
|
* render the format from one source.
|
|
6449
6636
|
*/
|
|
6450
6637
|
const journeyScenarioSchema = z.object({
|
|
@@ -6515,6 +6702,168 @@ const notificationPreferencesSchema = z.object({ purposes: z.array(notificationP
|
|
|
6515
6702
|
*/
|
|
6516
6703
|
const updateNotificationPreferencesBodySchema = z.object({ data: z.object({ purposes: consentPatchSchema }) });
|
|
6517
6704
|
|
|
6705
|
+
//#endregion
|
|
6706
|
+
//#region ../../packages/shared/src/resend.ts
|
|
6707
|
+
/** Resend's own cap on one message's recipients, per list. */
|
|
6708
|
+
const RESEND_MAX_RECIPIENTS = 50;
|
|
6709
|
+
/** Resend's own cap on one batch. */
|
|
6710
|
+
const RESEND_MAX_BATCH = 100;
|
|
6711
|
+
/** Resend's own cap on a from-header, a subject, and a message's tags. */
|
|
6712
|
+
const RESEND_MAX_FROM = 200;
|
|
6713
|
+
const RESEND_MAX_SUBJECT = 1e3;
|
|
6714
|
+
const RESEND_MAX_TAGS = 50;
|
|
6715
|
+
/**
|
|
6716
|
+
* One recipient: a bare address or `Name <addr@domain>`, both of which SES
|
|
6717
|
+
* takes in a Destination. The same parser the journey path uses, so an
|
|
6718
|
+
* address the facade accepts is one a journey could have sent to.
|
|
6719
|
+
*/
|
|
6720
|
+
const recipientSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "Every recipient must be an address (\"ada@acme.com\") or a name and address (\"Ada <ada@acme.com>\")." });
|
|
6721
|
+
const recipientArraySchema = z.array(recipientSchema).min(1).max(50);
|
|
6722
|
+
/** Resend takes one address or a list wherever it takes recipients. */
|
|
6723
|
+
const recipientsSchema$1 = z.union([recipientSchema, recipientArraySchema]);
|
|
6724
|
+
/**
|
|
6725
|
+
* Resend's wording for an absent required field. Three fields can be
|
|
6726
|
+
* missing: `to`, `subject`, and a body. Which answer each one gets is read
|
|
6727
|
+
* off the issue itself, never off this copy, so rewording any of them is
|
|
6728
|
+
* safe (see `isMissingField`).
|
|
6729
|
+
*/
|
|
6730
|
+
const MISSING = (field) => `Missing \`${field}\` field.`;
|
|
6731
|
+
/**
|
|
6732
|
+
* A field Cowliss does not serve. Declared rather than left to the strict
|
|
6733
|
+
* object's "unrecognized key" so the 422 names the field and says what to
|
|
6734
|
+
* do instead; the Resend SDK omits an unset one, so only a caller that
|
|
6735
|
+
* really passed it is refused.
|
|
6736
|
+
*
|
|
6737
|
+
* `any` and a refusing check rather than `never`, which the OpenAPI
|
|
6738
|
+
* generator has no rendering for.
|
|
6739
|
+
*/
|
|
6740
|
+
const unsupported = (message) => z.any().refine(() => false, { error: message }).optional();
|
|
6741
|
+
/**
|
|
6742
|
+
* Resend's template slot, pointing at a Cowliss template key (ADR 0014).
|
|
6743
|
+
* `variables` are the template's own props: they are checked by the
|
|
6744
|
+
* template, in the sandbox, the same way a test send's are, so anything
|
|
6745
|
+
* JSON can carry is accepted here and the template decides.
|
|
6746
|
+
*/
|
|
6747
|
+
const templateSlotSchema = z.strictObject({
|
|
6748
|
+
id: journeyKeySchema,
|
|
6749
|
+
variables: z.record(z.string(), z.unknown()).optional()
|
|
6750
|
+
});
|
|
6751
|
+
/** Send body: Resend's fields, minus the ones Cowliss does not serve. */
|
|
6752
|
+
const resendSendBodySchema = z.strictObject({
|
|
6753
|
+
/**
|
|
6754
|
+
* Required, exactly as Resend requires it. There is no address to fall
|
|
6755
|
+
* back to: a send leaves from a domain the organization verified, and
|
|
6756
|
+
* nothing else. Missing is its own issue for the same reason `to`'s is.
|
|
6757
|
+
*/
|
|
6758
|
+
from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }).optional().nonoptional({ error: MISSING("from") }),
|
|
6759
|
+
/**
|
|
6760
|
+
* Required, and the absence is its own issue rather than the union's:
|
|
6761
|
+
* `optional().nonoptional()` lets the value through to the union when
|
|
6762
|
+
* there is one and answers for undefined itself, which is how
|
|
6763
|
+
* `isMissingField` tells "you left it out" from "you sent the wrong
|
|
6764
|
+
* thing" without reading the message.
|
|
6765
|
+
*/
|
|
6766
|
+
to: z.union([recipientSchema, recipientArraySchema]).optional().nonoptional({ error: MISSING("to") }),
|
|
6767
|
+
/**
|
|
6768
|
+
* Optional in the type and required in practice: a message either
|
|
6769
|
+
* carries its own subject or names a template, which brings one. The
|
|
6770
|
+
* refinement below says which of the two is missing.
|
|
6771
|
+
*/
|
|
6772
|
+
subject: z.string().trim().min(1).max(RESEND_MAX_SUBJECT).optional(),
|
|
6773
|
+
html: z.string().optional(),
|
|
6774
|
+
text: z.string().optional(),
|
|
6775
|
+
cc: recipientsSchema$1.optional(),
|
|
6776
|
+
bcc: recipientsSchema$1.optional(),
|
|
6777
|
+
reply_to: recipientsSchema$1.optional(),
|
|
6778
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
6779
|
+
/** Accepted and dropped: Cowliss tags a delivery by its own fields. */
|
|
6780
|
+
tags: z.array(z.object({
|
|
6781
|
+
name: z.string(),
|
|
6782
|
+
value: z.string()
|
|
6783
|
+
})).max(50).optional(),
|
|
6784
|
+
attachments: unsupported("Cowliss does not accept attachments. Link to the file from the message instead."),
|
|
6785
|
+
scheduled_at: unsupported("Cowliss does not schedule sends. Send the message when it should arrive, or let a journey's wait step do the waiting."),
|
|
6786
|
+
topic_id: unsupported("Cowliss does not take topics. Consent purposes are where it records what a person agreed to."),
|
|
6787
|
+
template: templateSlotSchema.optional()
|
|
6788
|
+
}).superRefine((body, ctx) => {
|
|
6789
|
+
const refuse = (path, message) => ctx.addIssue({
|
|
6790
|
+
code: "custom",
|
|
6791
|
+
path: [path],
|
|
6792
|
+
message
|
|
6793
|
+
});
|
|
6794
|
+
const missing = (path, message) => ctx.addIssue({
|
|
6795
|
+
code: "custom",
|
|
6796
|
+
path: [path],
|
|
6797
|
+
message,
|
|
6798
|
+
params: { missing: true }
|
|
6799
|
+
});
|
|
6800
|
+
if (body.template) {
|
|
6801
|
+
if (body.subject !== void 0) refuse("subject", "The subject comes from the template. Remove `subject`, or write the message yourself with `html` or `text`.");
|
|
6802
|
+
for (const field of ["html", "text"]) if (body[field] !== void 0) refuse(field, `A template brings its own content. Remove \`${field}\`, or drop \`template\` and write the message yourself.`);
|
|
6803
|
+
return;
|
|
6804
|
+
}
|
|
6805
|
+
if (body.subject === void 0) missing("subject", MISSING("subject"));
|
|
6806
|
+
if (body.html === void 0 && body.text === void 0) missing("html", "Missing `html` or `text` field.");
|
|
6807
|
+
});
|
|
6808
|
+
/**
|
|
6809
|
+
* A batch: Resend posts the messages as a bare array, and answers with one
|
|
6810
|
+
* id per message in the order they were given. Every message is checked
|
|
6811
|
+
* before any of them is sent, so a batch with a mistake in it sends
|
|
6812
|
+
* nothing.
|
|
6813
|
+
*/
|
|
6814
|
+
const resendBatchBodySchema = z.array(resendSendBodySchema, { error: (issue) => Array.isArray(issue.input) ? void 0 : "Send a list of messages." }).min(1, "Send at least one message.").max(100, `A batch takes at most ${100} messages. Split the list and send it in parts.`);
|
|
6815
|
+
/** What a send answers: the delivery's id, and nothing else. */
|
|
6816
|
+
const resendSentSchema = z.object({ id: z.string() });
|
|
6817
|
+
/** What a batch answers: one id per message, in the order they were given. */
|
|
6818
|
+
const resendBatchSentSchema = z.object({ data: z.array(resendSentSchema) });
|
|
6819
|
+
/**
|
|
6820
|
+
* Where a message got to, in Resend's vocabulary. Cowliss emits five of
|
|
6821
|
+
* them: a gate skip and a failure are both `failed`, because to a caller
|
|
6822
|
+
* they are the same fact, the message did not go.
|
|
6823
|
+
*/
|
|
6824
|
+
const RESEND_LAST_EVENTS = [
|
|
6825
|
+
"sent",
|
|
6826
|
+
"delivered",
|
|
6827
|
+
"bounced",
|
|
6828
|
+
"complained",
|
|
6829
|
+
"failed"
|
|
6830
|
+
];
|
|
6831
|
+
/**
|
|
6832
|
+
* Resend's email object. `html` and `text` are always null: Cowliss stores
|
|
6833
|
+
* no rendered bodies (ADR 0014), and the SDK does not need them to report
|
|
6834
|
+
* on a send.
|
|
6835
|
+
*/
|
|
6836
|
+
const resendEmailSchema = z.object({
|
|
6837
|
+
object: z.literal("email"),
|
|
6838
|
+
id: z.string(),
|
|
6839
|
+
/** The provider's message id; null until the message reaches the provider. */
|
|
6840
|
+
message_id: z.string().nullable(),
|
|
6841
|
+
to: z.array(z.string()),
|
|
6842
|
+
from: z.string(),
|
|
6843
|
+
created_at: z.string(),
|
|
6844
|
+
subject: z.string(),
|
|
6845
|
+
html: z.null(),
|
|
6846
|
+
text: z.null(),
|
|
6847
|
+
bcc: z.array(z.string()),
|
|
6848
|
+
cc: z.array(z.string()),
|
|
6849
|
+
reply_to: z.array(z.string()),
|
|
6850
|
+
last_event: z.enum(RESEND_LAST_EVENTS),
|
|
6851
|
+
scheduled_at: z.null(),
|
|
6852
|
+
tags: z.array(z.object({
|
|
6853
|
+
name: z.string(),
|
|
6854
|
+
value: z.string()
|
|
6855
|
+
}))
|
|
6856
|
+
});
|
|
6857
|
+
/**
|
|
6858
|
+
* Resend's error body. `name` is the machine-readable one, which is what an
|
|
6859
|
+
* SDK caller branches on; the message is for the developer reading it.
|
|
6860
|
+
*/
|
|
6861
|
+
const resendErrorSchema = z.object({
|
|
6862
|
+
statusCode: z.number(),
|
|
6863
|
+
message: z.string(),
|
|
6864
|
+
name: z.string()
|
|
6865
|
+
});
|
|
6866
|
+
|
|
6518
6867
|
//#endregion
|
|
6519
6868
|
//#region ../../packages/shared/src/violations.ts
|
|
6520
6869
|
/**
|
|
@@ -6614,176 +6963,103 @@ const listReviewQuerySchema = paginationQuerySchema.extend({
|
|
|
6614
6963
|
});
|
|
6615
6964
|
|
|
6616
6965
|
//#endregion
|
|
6617
|
-
//#region ../../packages/shared/src/
|
|
6966
|
+
//#region ../../packages/shared/src/send-email.ts
|
|
6618
6967
|
/**
|
|
6619
|
-
*
|
|
6620
|
-
*
|
|
6621
|
-
*
|
|
6622
|
-
*
|
|
6623
|
-
*
|
|
6624
|
-
* `appId` scopes the EVENT side of a definition only. Traits are
|
|
6625
|
-
* per-profile and profiles merge across apps, so there is nothing
|
|
6626
|
-
* app-shaped to filter on the trait side.
|
|
6627
|
-
*
|
|
6628
|
-
* A definition is shared by the environments it declares. `environments`
|
|
6629
|
-
* defaults to both, and an app id lives in exactly one environment, so a
|
|
6630
|
-
* definition that runs in both names both of an app's ids.
|
|
6968
|
+
* `POST /v1/emails`: the first-party transactional send, the one a
|
|
6969
|
+
* `@cowliss/sdk` client calls. The Resend-compatible facade (ADR 0014) is
|
|
6970
|
+
* the same send in somebody else's wire format; this is ours, so it is
|
|
6971
|
+
* camelCase, enveloped, and answers with our error codes.
|
|
6631
6972
|
*
|
|
6632
|
-
* The
|
|
6633
|
-
*
|
|
6634
|
-
* definition that evaluates over every app. There is deliberately no pipe
|
|
6635
|
-
* filter here — filtering by source is a later feature, and accepting one
|
|
6636
|
-
* now would make a stale `sourceId` parse as a filter on a pipe that does
|
|
6637
|
-
* not exist and silently match nothing.
|
|
6973
|
+
* The limits are deliberately the same numbers as the facade's: two shapes
|
|
6974
|
+
* over one send path must not accept different messages.
|
|
6638
6975
|
*/
|
|
6639
|
-
const
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
6650
|
-
|
|
6651
|
-
|
|
6652
|
-
|
|
6653
|
-
const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
|
|
6654
|
-
kind: z.literal("trait"),
|
|
6655
|
-
name: predicateNameSchema,
|
|
6656
|
-
op: z.enum(SEGMENT_TRAIT_OPS),
|
|
6657
|
-
/**
|
|
6658
|
-
* Compared against the stored trait, which is `unknown` because
|
|
6659
|
-
* identify accepts arbitrary JSON. The evaluator coerces both sides
|
|
6660
|
-
* before comparing, so authors here (CLI, MCP, dashboard) get the
|
|
6661
|
-
* form-field-friendly reading rather than strict JSON equality:
|
|
6662
|
-
* numeric-looking strings are compared as numbers for eq/neq and for
|
|
6663
|
-
* ordering ("150" matches 150, and orders like it), and "true"/"false"
|
|
6664
|
-
* are compared as booleans for eq/neq, trimmed and case-insensitively
|
|
6665
|
-
* (" TRUE " reads as true). Coercion needs both sides to agree on a
|
|
6666
|
-
* type: "0" never equals false. The numeric net is as wide as
|
|
6667
|
-
* `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
|
|
6668
|
-
* matters most for opaque ids: "007" is authored as the number 7.
|
|
6669
|
-
*
|
|
6670
|
-
* contains reads three ways. Against an array trait it is membership
|
|
6671
|
-
* under that same equality, so "true" matches `[true]` and "1" matches
|
|
6672
|
-
* `[1, 2]`. Against a string trait it is a plain substring search with
|
|
6673
|
-
* no coercion, since substrings only mean something between strings.
|
|
6674
|
-
* Against anything else it never matches. Ordering never coerces
|
|
6675
|
-
* booleans.
|
|
6676
|
-
*/
|
|
6677
|
-
value: z.unknown().optional()
|
|
6678
|
-
}), z.object({
|
|
6679
|
-
kind: z.literal("event"),
|
|
6680
|
-
name: predicateNameSchema,
|
|
6681
|
-
op: z.enum(["performed", "notPerformed"]),
|
|
6682
|
-
/** How many matching events the predicate counts as "performed". */
|
|
6683
|
-
atLeast: z.number().int().min(1).default(1),
|
|
6684
|
-
/** Rolling window, relative to evaluation time; absent means all history. */
|
|
6685
|
-
withinDays: z.number().int().min(1).optional()
|
|
6686
|
-
})]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
|
|
6687
|
-
const appIdSchema = z.string().trim().min(1);
|
|
6688
|
-
const segmentDefinitionSchema = z.strictObject({
|
|
6689
|
-
match: z.enum(["all", "any"]).default("all"),
|
|
6976
|
+
const addressSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "An address is \"ada@acme.com\" or a name and address, \"Ada <ada@acme.com>\"." });
|
|
6977
|
+
/** One address or a list, wherever a message names recipients. */
|
|
6978
|
+
const recipientsSchema = z.union([addressSchema, z.array(addressSchema).min(1).max(50)]);
|
|
6979
|
+
/** A template this organization pushed, plus the props it declares. */
|
|
6980
|
+
const templateSchema$1 = z.strictObject({
|
|
6981
|
+
key: journeyKeySchema,
|
|
6982
|
+
props: z.record(z.string(), z.unknown()).optional()
|
|
6983
|
+
});
|
|
6984
|
+
const sendEmailDataSchema = z.strictObject({
|
|
6985
|
+
sourceId: z.string(),
|
|
6986
|
+
to: recipientsSchema,
|
|
6987
|
+
cc: recipientsSchema.optional(),
|
|
6988
|
+
bcc: recipientsSchema.optional(),
|
|
6989
|
+
replyTo: recipientsSchema.optional(),
|
|
6690
6990
|
/**
|
|
6691
|
-
*
|
|
6692
|
-
*
|
|
6693
|
-
*
|
|
6991
|
+
* An address on a domain this organization verified. There is no other.
|
|
6992
|
+
* Shape is checked here, the same as the Resend body checks it, so an
|
|
6993
|
+
* address that is not one is a 422 naming the field rather than a 403
|
|
6994
|
+
* telling the caller to verify a domain they never named.
|
|
6694
6995
|
*/
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
}
|
|
6712
|
-
|
|
6713
|
-
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
|
|
6725
|
-
members: z.array(z.string()),
|
|
6726
|
-
/** Profiles the preview actually evaluated. */
|
|
6727
|
-
scanned: z.number().int().min(0),
|
|
6728
|
-
/** True when the scan hit its cap, so memberCount is a floor. */
|
|
6729
|
-
truncated: z.boolean()
|
|
6996
|
+
from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }),
|
|
6997
|
+
subject: z.string().max(RESEND_MAX_SUBJECT).optional(),
|
|
6998
|
+
html: z.string().optional(),
|
|
6999
|
+
text: z.string().optional(),
|
|
7000
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
7001
|
+
template: templateSchema$1.optional()
|
|
7002
|
+
}).superRefine((body, ctx) => {
|
|
7003
|
+
const hasBody = body.html !== void 0 || body.text !== void 0;
|
|
7004
|
+
if (body.template) {
|
|
7005
|
+
for (const field of [
|
|
7006
|
+
"subject",
|
|
7007
|
+
"html",
|
|
7008
|
+
"text"
|
|
7009
|
+
]) if (body[field] !== void 0) ctx.addIssue({
|
|
7010
|
+
code: "custom",
|
|
7011
|
+
path: [field],
|
|
7012
|
+
message: `A message names a template or writes its own body, not both. Drop \`${field}\`.`
|
|
7013
|
+
});
|
|
7014
|
+
return;
|
|
7015
|
+
}
|
|
7016
|
+
if (!hasBody) ctx.addIssue({
|
|
7017
|
+
code: "custom",
|
|
7018
|
+
path: ["html"],
|
|
7019
|
+
message: "A message needs a template, or an html or text body."
|
|
7020
|
+
});
|
|
7021
|
+
if (body.subject === void 0) ctx.addIssue({
|
|
7022
|
+
code: "custom",
|
|
7023
|
+
path: ["subject"],
|
|
7024
|
+
message: "A message that writes its own body needs a subject."
|
|
7025
|
+
});
|
|
6730
7026
|
});
|
|
6731
|
-
const
|
|
6732
|
-
const
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
description: descriptionSchema.nullish(),
|
|
6736
|
-
definition: segmentDefinitionSchema,
|
|
6737
|
-
/** The environments the segment works on; both when omitted. */
|
|
6738
|
-
environments: environmentsSchema.default([...ENVIRONMENTS])
|
|
6739
|
-
}) });
|
|
6740
|
-
const updateSegmentBodySchema = z.object({ data: z.object({
|
|
6741
|
-
name: nameSchema.optional(),
|
|
6742
|
-
description: descriptionSchema.nullish(),
|
|
6743
|
-
definition: segmentDefinitionSchema.optional(),
|
|
6744
|
-
/** Changing this recomputes membership in the newly declared environments. */
|
|
6745
|
-
environments: environmentsSchema.optional()
|
|
6746
|
-
}).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0 || data.environments !== void 0, { message: "at least one field (name, description, definition or environments) is required" }) });
|
|
6747
|
-
/** `q` is a substring search over the segment's name. */
|
|
6748
|
-
const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
|
|
6749
|
-
const listSegmentMembersQuerySchema = paginationQuerySchema;
|
|
7027
|
+
const sendEmailBodySchema = z.object({ data: sendEmailDataSchema });
|
|
7028
|
+
const sendEmailResultSchema = z.object({
|
|
7029
|
+
/** The delivery this send left behind: the row in Deliveries. */
|
|
7030
|
+
deliveryId: z.string() });
|
|
6750
7031
|
|
|
6751
7032
|
//#endregion
|
|
6752
7033
|
//#region ../../packages/shared/src/settings.ts
|
|
6753
7034
|
/**
|
|
6754
7035
|
* Settings contracts. The two operator-set values, ingestionPolicy (catalog
|
|
6755
|
-
* governance) and eventRetentionDays (the ClickHouse TTL window), are
|
|
6756
|
-
*
|
|
6757
|
-
*
|
|
6758
|
-
* The org's Clerk profile rides along read-only.
|
|
7036
|
+
* governance) and eventRetentionDays (the ClickHouse TTL window), are the
|
|
7037
|
+
* organization's, one each: `GET/PATCH /v1/settings/org` read and write
|
|
7038
|
+
* them. The org's Clerk profile rides along read-only.
|
|
6759
7039
|
*/
|
|
6760
7040
|
const ingestionPolicySchema = z.enum(INGESTION_POLICIES);
|
|
6761
7041
|
/**
|
|
6762
|
-
* The
|
|
6763
|
-
*
|
|
6764
|
-
*
|
|
6765
|
-
* selected environment's own (30 days in development) with a 422.
|
|
7042
|
+
* The org's event retention window in days. Never null: an unset window is
|
|
7043
|
+
* the default, and there is no keep-forever. Both default and cap are 18
|
|
7044
|
+
* months.
|
|
6766
7045
|
*/
|
|
6767
|
-
const eventRetentionDaysSchema = z.number().int().min(1).max(
|
|
7046
|
+
const eventRetentionDaysSchema = z.number().int().min(1).max(548);
|
|
6768
7047
|
const orgSettingsDtoSchema = z.object({
|
|
6769
|
-
/** The environment these values belong to (from the selection header). */
|
|
6770
|
-
environment: environmentSchema,
|
|
6771
7048
|
ingestionPolicy: ingestionPolicySchema,
|
|
6772
7049
|
eventRetentionDays: eventRetentionDaysSchema,
|
|
6773
7050
|
/**
|
|
6774
7051
|
* Clerk's slug and display name for the org, cached by the API and
|
|
6775
7052
|
* READ-ONLY here: Clerk owns them, and the org profile is edited in
|
|
6776
|
-
* Clerk's own UI. Exposed because
|
|
6777
|
-
*
|
|
6778
|
-
*
|
|
7053
|
+
* Clerk's own UI. Exposed because a template test-send composes its
|
|
7054
|
+
* from-address out of them (`orgFromAddress`). Null until a member has hit
|
|
7055
|
+
* the dashboard API once.
|
|
6779
7056
|
*/
|
|
6780
7057
|
slug: z.string().nullable(),
|
|
6781
7058
|
name: z.string().nullable()
|
|
6782
7059
|
});
|
|
6783
7060
|
/**
|
|
6784
|
-
* Settings patch
|
|
6785
|
-
*
|
|
6786
|
-
* patch is a no-op request, not a valid one.
|
|
7061
|
+
* Settings patch. Each field is absent-means-no-change. At least one field
|
|
7062
|
+
* must be present: an empty patch is a no-op request, not a valid one.
|
|
6787
7063
|
*/
|
|
6788
7064
|
const updateOrgSettingsBodySchema = z.object({ data: z.object({
|
|
6789
7065
|
ingestionPolicy: ingestionPolicySchema.optional(),
|
|
@@ -6825,7 +7101,7 @@ const sourceSchema = selectSourceSchema.extend({
|
|
|
6825
7101
|
*/
|
|
6826
7102
|
const SOURCE_KINDS = sourceKindEnum.enumValues;
|
|
6827
7103
|
/**
|
|
6828
|
-
* The per-kind source config schemas, mirroring the
|
|
7104
|
+
* The per-kind source config schemas, mirroring the webhooks
|
|
6829
7105
|
* discriminated-union pattern. The `api` source has nothing to configure:
|
|
6830
7106
|
* ingestion authenticates with the org-level API key, so the row carries no
|
|
6831
7107
|
* credential of its own. A Clerk source carries only the webhook signing
|
|
@@ -6842,7 +7118,7 @@ const apiSourceConfigSchema = z.strictObject({}, { error: "an api source takes n
|
|
|
6842
7118
|
const clerkSourceConfigSchema = z.object({ signingSecret: z.string().min(1, "config.signingSecret is required") });
|
|
6843
7119
|
/**
|
|
6844
7120
|
* The source shapes, named individually so the dashboard's create form can
|
|
6845
|
-
* resolve against exactly one of them (the
|
|
7121
|
+
* resolve against exactly one of them (the webhooks pattern: a form knows
|
|
6846
7122
|
* its kind before the user types anything, and zodResolver over a member is
|
|
6847
7123
|
* what gives react-hook-form a non-union field path to register).
|
|
6848
7124
|
*
|
|
@@ -6874,7 +7150,7 @@ const listSourcesQuerySchema = paginationQuerySchema;
|
|
|
6874
7150
|
//#region ../../packages/shared/src/suppressions.ts
|
|
6875
7151
|
/**
|
|
6876
7152
|
* The suppression mirror's wire contracts. Derived from the Drizzle table so
|
|
6877
|
-
* the row and the DTO cannot drift, same as deliveries and
|
|
7153
|
+
* the row and the DTO cannot drift, same as deliveries and webhooks.
|
|
6878
7154
|
*/
|
|
6879
7155
|
const SUPPRESSION_TYPES = suppressionTypeEnum.enumValues;
|
|
6880
7156
|
const suppressionTypeSchema = z.enum(SUPPRESSION_TYPES);
|
|
@@ -6897,7 +7173,7 @@ const listSuppressionsQuerySchema = paginationQuerySchema.extend({ q: searchQuer
|
|
|
6897
7173
|
//#region ../../packages/shared/src/templates.ts
|
|
6898
7174
|
/**
|
|
6899
7175
|
* Templates as the API reports them. A template has versions and nothing
|
|
6900
|
-
* else (ADR 0011): no row of its own, no
|
|
7176
|
+
* else (ADR 0011): no row of its own, no flag. What is
|
|
6901
7177
|
* reported here is one key, described by its newest version, plus the
|
|
6902
7178
|
* journeys whose steps send it.
|
|
6903
7179
|
*
|
|
@@ -6915,6 +7191,12 @@ const templateSchema = z.object({
|
|
|
6915
7191
|
sendClass: z.enum(SEND_CLASSES),
|
|
6916
7192
|
/** True when the template asks for a signed `verifyUrl` prop at send time. */
|
|
6917
7193
|
verifyLink: z.boolean(),
|
|
7194
|
+
/**
|
|
7195
|
+
* True when the template asks for a signed `unsubscribeUrl` prop at send
|
|
7196
|
+
* time. A marketing template without one (or without rendering the link)
|
|
7197
|
+
* gets a platform footer with the link appended at send time instead.
|
|
7198
|
+
*/
|
|
7199
|
+
unsubscribeLink: z.boolean().default(false),
|
|
6918
7200
|
/** JSON Schema of the template's props, as `cow build` converted them. */
|
|
6919
7201
|
propsSchema: z.record(z.string(), z.unknown()),
|
|
6920
7202
|
/** The newest version of this key, whatever it compiled to. */
|
|
@@ -6932,6 +7214,8 @@ const templateSchema = z.object({
|
|
|
6932
7214
|
const listTemplatesQuerySchema = paginationQuerySchema.extend({ q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0) });
|
|
6933
7215
|
/** The props a preview or a test send renders the template with. */
|
|
6934
7216
|
const renderTemplateBodySchema = z.object({ data: z.object({ props: z.record(z.string(), z.unknown()).default({}) }) });
|
|
7217
|
+
/** A test send is a delivery like any other, so it takes the same props. */
|
|
7218
|
+
const testSendTemplateBodySchema = z.object({ data: z.object({ props: z.record(z.string(), z.unknown()).default({}) }) });
|
|
6935
7219
|
/** What the sandbox rendered: the message as it would go out. */
|
|
6936
7220
|
const templatePreviewSchema = z.object({
|
|
6937
7221
|
subject: z.string(),
|
|
@@ -6945,9 +7229,8 @@ const templatePreviewSchema = z.object({
|
|
|
6945
7229
|
//#region ../../packages/shared/src/users.ts
|
|
6946
7230
|
/**
|
|
6947
7231
|
* Users API contracts (dashboard). Profiles are addressed by their
|
|
6948
|
-
* Cowliss-generated id (`usr_`)
|
|
6949
|
-
*
|
|
6950
|
-
* profile's event history is the event feed filtered to it
|
|
7232
|
+
* Cowliss-generated id (`usr_`). A profile's event history is the event
|
|
7233
|
+
* feed filtered to it
|
|
6951
7234
|
* (GET /v1/events?profileId=), not an endpoint of its own.
|
|
6952
7235
|
*/
|
|
6953
7236
|
/**
|
|
@@ -6963,6 +7246,20 @@ const userSegmentSchema = z.object({
|
|
|
6963
7246
|
});
|
|
6964
7247
|
const listUserSegmentsQuerySchema = paginationQuerySchema;
|
|
6965
7248
|
/**
|
|
7249
|
+
* One journey the profile ran to completion
|
|
7250
|
+
* (GET /v1/users/:profileId/runs): which journey it was, when it finished,
|
|
7251
|
+
* and the execution it finished, which is null once that execution is no
|
|
7252
|
+
* longer kept. Paged on (completedAt, id) newest first, so the cursor's
|
|
7253
|
+
* sortAt slot carries the completion time.
|
|
7254
|
+
*/
|
|
7255
|
+
const userRunSchema = z.object({
|
|
7256
|
+
id: z.string(),
|
|
7257
|
+
journeyKey: z.string(),
|
|
7258
|
+
completedAt: z.iso.datetime(),
|
|
7259
|
+
executionId: z.string().nullable()
|
|
7260
|
+
});
|
|
7261
|
+
const listUserRunsQuerySchema = paginationQuerySchema;
|
|
7262
|
+
/**
|
|
6966
7263
|
* Profile list query. `q` is a free-text substring search over the
|
|
6967
7264
|
* profile's identifier values and the identity traits in
|
|
6968
7265
|
* PROFILE_SEARCH_TRAITS; absent means no filter. An empty or
|
|
@@ -7039,6 +7336,62 @@ const userExportSchema = z.object({
|
|
|
7039
7336
|
quarantineEntries: z.array(quarantineEntrySchema)
|
|
7040
7337
|
});
|
|
7041
7338
|
|
|
7339
|
+
//#endregion
|
|
7340
|
+
//#region ../../packages/shared/src/webhooks.ts
|
|
7341
|
+
/**
|
|
7342
|
+
* Webhooks API contracts. Derived from the Drizzle table via drizzle-zod.
|
|
7343
|
+
* signingSecret is deliberately absent from the DTO: it is shown exactly
|
|
7344
|
+
* once, in the create response (webhookCreatedSchema). consecutiveFailures
|
|
7345
|
+
* is absent too: it is the auto-disable counter's internal state, and
|
|
7346
|
+
* disabledAt is the part the dashboard banner needs.
|
|
7347
|
+
*
|
|
7348
|
+
* A webhook is the organization's: the name is unique per org, and every
|
|
7349
|
+
* execution posts to the same row.
|
|
7350
|
+
*/
|
|
7351
|
+
/**
|
|
7352
|
+
* A webhook's name: the token a journey addresses it by, in a `send.webhook`
|
|
7353
|
+
* call. The journey names a string and the server resolves it, so the two
|
|
7354
|
+
* ends only have to agree on the characters that fit in one.
|
|
7355
|
+
*/
|
|
7356
|
+
const webhookNameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
|
|
7357
|
+
const webhookConfigSchema = z.object({ url: z.url("config.url must be a valid URL").refine((url) => {
|
|
7358
|
+
try {
|
|
7359
|
+
return ["http:", "https:"].includes(new URL(url).protocol);
|
|
7360
|
+
} catch {
|
|
7361
|
+
return false;
|
|
7362
|
+
}
|
|
7363
|
+
}, { message: "config.url must use http or https" }) });
|
|
7364
|
+
const webhookSchema = selectWebhookSchema.extend({
|
|
7365
|
+
config: webhookConfigSchema,
|
|
7366
|
+
createdAt: z.iso.datetime(),
|
|
7367
|
+
updatedAt: z.iso.datetime(),
|
|
7368
|
+
disabledAt: z.iso.datetime().nullable()
|
|
7369
|
+
}).omit({
|
|
7370
|
+
signingSecret: true,
|
|
7371
|
+
consecutiveFailures: true
|
|
7372
|
+
});
|
|
7373
|
+
const webhookCreatedSchema = webhookSchema.extend({ signingSecret: z.string() });
|
|
7374
|
+
const createWebhookBodySchema = z.object({ data: z.object({
|
|
7375
|
+
name: webhookNameSchema,
|
|
7376
|
+
config: webhookConfigSchema
|
|
7377
|
+
}) });
|
|
7378
|
+
/**
|
|
7379
|
+
* Webhook update. `enabled` is the admin's half of the auto-disable loop:
|
|
7380
|
+
* the webhook send activity stamps `disabledAt` after enough consecutive
|
|
7381
|
+
* failures and every send after that records `skipped_disabled`, so without
|
|
7382
|
+
* a way to clear it the only exit would be deleting the webhook, which
|
|
7383
|
+
* throws away the signing secret the receiver is configured with.
|
|
7384
|
+
* `enabled: true` clears the stamp and the failure counter; `enabled: false`
|
|
7385
|
+
* is the same switch operated by hand.
|
|
7386
|
+
*/
|
|
7387
|
+
const updateWebhookBodySchema = z.object({ data: z.object({
|
|
7388
|
+
name: webhookNameSchema.optional(),
|
|
7389
|
+
config: webhookConfigSchema.optional(),
|
|
7390
|
+
enabled: z.boolean().optional()
|
|
7391
|
+
}).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" }) });
|
|
7392
|
+
/** `q` is a substring search over the webhook's name. */
|
|
7393
|
+
const listWebhooksQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
|
|
7394
|
+
|
|
7042
7395
|
//#endregion
|
|
7043
7396
|
//#region ../../packages/api-client/src/client.ts
|
|
7044
7397
|
var ApiError = class extends Error {
|
|
@@ -7222,7 +7575,7 @@ function readCliPackage() {
|
|
|
7222
7575
|
* are. Reading the code sees every branch, labelled with its condition.
|
|
7223
7576
|
*
|
|
7224
7577
|
* What it reads: `await api.*` calls (in source order, with the literal
|
|
7225
|
-
* template,
|
|
7578
|
+
* template, webhook, event, key, or duration when the author wrote one
|
|
7226
7579
|
* inline), `if`/`else`, `switch`, loops, `try`/`catch`, `return`, `throw`, and
|
|
7227
7580
|
* `api.restart()`. Calls behind a helper function in another file, and a
|
|
7228
7581
|
* `run` that is not an inline function on the `defineJourney` object, are
|
|
@@ -7433,12 +7786,9 @@ var Reader = class {
|
|
|
7433
7786
|
if (timeout) entry.timeout = timeout;
|
|
7434
7787
|
break;
|
|
7435
7788
|
}
|
|
7436
|
-
case "send.email":
|
|
7789
|
+
case "send.email":
|
|
7437
7790
|
detail(this.property(first, "template"));
|
|
7438
|
-
const sender = this.property(first, "senderIdentity");
|
|
7439
|
-
if (sender) entry.senderIdentity = sender;
|
|
7440
7791
|
break;
|
|
7441
|
-
}
|
|
7442
7792
|
case "send.webhook":
|
|
7443
7793
|
detail(this.property(first, "destination"));
|
|
7444
7794
|
break;
|
|
@@ -7990,8 +8340,13 @@ async function buildProject(projectDir) {
|
|
|
7990
8340
|
const manifestJourneys = [];
|
|
7991
8341
|
const manifestTemplates = [];
|
|
7992
8342
|
for (const built of bundles) {
|
|
7993
|
-
|
|
7994
|
-
|
|
8343
|
+
let report;
|
|
8344
|
+
try {
|
|
8345
|
+
const { module, runGuest } = await loadNodeBundle(projectDir, built.source.key, built.source.kind);
|
|
8346
|
+
report = await runGuest(module, { kind: "manifest" });
|
|
8347
|
+
} catch (error) {
|
|
8348
|
+
throw new Error(`${built.source.relPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
8349
|
+
}
|
|
7995
8350
|
const isJourney = built.source.kind === "journeys";
|
|
7996
8351
|
if (report?.kind !== (isJourney ? "journey" : "template")) throw new Error(`${built.source.relPath} is not a ${isJourney ? "journey" : "template"}: a journey default-exports defineJourney({ ... }), a template exports a component and a zod \`props\` schema.`);
|
|
7997
8352
|
if (report.kind === "journey") manifestJourneys.push({
|
|
@@ -7999,7 +8354,9 @@ async function buildProject(projectDir) {
|
|
|
7999
8354
|
tags: report.tags,
|
|
8000
8355
|
trigger: report.trigger,
|
|
8001
8356
|
purpose: report.purpose,
|
|
8002
|
-
|
|
8357
|
+
enrollment: report.enrollment,
|
|
8358
|
+
description: report.description,
|
|
8359
|
+
from: report.from,
|
|
8003
8360
|
spine: readSpine(await readFile(built.source.file, "utf8")),
|
|
8004
8361
|
bundle: built.digest
|
|
8005
8362
|
});
|
|
@@ -8008,6 +8365,7 @@ async function buildProject(projectDir) {
|
|
|
8008
8365
|
tags: report.tags,
|
|
8009
8366
|
sendClass: report.sendClass,
|
|
8010
8367
|
verifyLink: report.verifyLink,
|
|
8368
|
+
unsubscribeLink: report.unsubscribeLink,
|
|
8011
8369
|
propsSchema: report.propsSchema,
|
|
8012
8370
|
bundle: built.digest
|
|
8013
8371
|
});
|
|
@@ -8025,7 +8383,7 @@ async function buildProject(projectDir) {
|
|
|
8025
8383
|
});
|
|
8026
8384
|
if (failure) throw new Error(failure);
|
|
8027
8385
|
const manifest = manifestSchema.parse({
|
|
8028
|
-
protocol:
|
|
8386
|
+
protocol: 3,
|
|
8029
8387
|
sdk: (await readCliPackage()).version,
|
|
8030
8388
|
journeys: manifestJourneys,
|
|
8031
8389
|
templates: manifestTemplates,
|
|
@@ -8330,7 +8688,7 @@ function openBrowser(url) {
|
|
|
8330
8688
|
//#region src/commands/auth.ts
|
|
8331
8689
|
/** login/logout/whoami: session-token management, no contract route. */
|
|
8332
8690
|
function registerAuth(program, env, io) {
|
|
8333
|
-
program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a
|
|
8691
|
+
program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in the project config (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
|
|
8334
8692
|
const outcome = await login(env, {
|
|
8335
8693
|
token: opts.token,
|
|
8336
8694
|
webUrl: opts.web,
|
|
@@ -9240,106 +9598,33 @@ const deliveries = defineModule(defineRoute({
|
|
|
9240
9598
|
* The confirmation page's second button, "stop all marketing
|
|
9241
9599
|
* email": the same token, revoking the master purpose instead of
|
|
9242
9600
|
* the one the mail was sent under. A flag rather than a second
|
|
9243
|
-
* route, because the claim already names the org and the profile.
|
|
9244
|
-
* The List-Unsubscribe header never carries it, so a mail client's
|
|
9245
|
-
* one-click POST stops one purpose, as RFC 8058 intends.
|
|
9246
|
-
*/
|
|
9247
|
-
all: z.literal("1").optional() }) },
|
|
9248
|
-
responses: {
|
|
9249
|
-
200: htmlResponse("Confirmation page"),
|
|
9250
|
-
400: { description: "Invalid or incomplete unsubscribe link" }
|
|
9251
|
-
}
|
|
9252
|
-
}), defineRoute({
|
|
9253
|
-
method: "get",
|
|
9254
|
-
path: "/v1/unsubscribe",
|
|
9255
|
-
operationId: "deliveries.unsubscribePage",
|
|
9256
|
-
tags: ["deliveries"],
|
|
9257
|
-
summary: "Unsubscribe confirmation page (public)",
|
|
9258
|
-
security: NO_AUTH,
|
|
9259
|
-
surfaces: HIDDEN_FROM_TOOLS,
|
|
9260
|
-
request: { query: tokenQuery },
|
|
9261
|
-
responses: {
|
|
9262
|
-
200: htmlResponse("The page offering the unsubscribe"),
|
|
9263
|
-
400: { description: "Invalid or incomplete unsubscribe link" }
|
|
9264
|
-
}
|
|
9265
|
-
}));
|
|
9266
|
-
|
|
9267
|
-
//#endregion
|
|
9268
|
-
//#region ../../packages/shared/src/contract/destinations.ts
|
|
9269
|
-
const params$9 = z.object({ id: z.string() });
|
|
9270
|
-
const destinations = defineModule(defineRoute({
|
|
9271
|
-
method: "post",
|
|
9272
|
-
path: "/v1/destinations",
|
|
9273
|
-
operationId: "destinations.create",
|
|
9274
|
-
tags: ["destinations"],
|
|
9275
|
-
summary: "Create a destination",
|
|
9276
|
-
security: SESSION_AUTH,
|
|
9277
|
-
request: { body: jsonBody(createDestinationBodySchema) },
|
|
9278
|
-
responses: {
|
|
9279
|
-
201: envelope(destinationCreatedSchema, "The created destination; the only response carrying the signing secret"),
|
|
9280
|
-
...sessionErrors,
|
|
9281
|
-
...errors("validation_failed", "malformed_request")
|
|
9282
|
-
}
|
|
9283
|
-
}), defineRoute({
|
|
9284
|
-
method: "get",
|
|
9285
|
-
path: "/v1/destinations",
|
|
9286
|
-
operationId: "destinations.list",
|
|
9287
|
-
tags: ["destinations"],
|
|
9288
|
-
summary: "List destinations in the selected environment",
|
|
9289
|
-
security: SESSION_AUTH,
|
|
9290
|
-
request: { query: listDestinationsQuerySchema },
|
|
9601
|
+
* route, because the claim already names the org and the profile.
|
|
9602
|
+
* The List-Unsubscribe header never carries it, so a mail client's
|
|
9603
|
+
* one-click POST stops one purpose, as RFC 8058 intends.
|
|
9604
|
+
*/
|
|
9605
|
+
all: z.literal("1").optional() }) },
|
|
9291
9606
|
responses: {
|
|
9292
|
-
200:
|
|
9293
|
-
|
|
9294
|
-
...errors("validation_failed")
|
|
9607
|
+
200: htmlResponse("Confirmation page"),
|
|
9608
|
+
400: { description: "Invalid or incomplete unsubscribe link" }
|
|
9295
9609
|
}
|
|
9296
9610
|
}), defineRoute({
|
|
9297
9611
|
method: "get",
|
|
9298
|
-
path: "/v1/
|
|
9299
|
-
operationId: "
|
|
9300
|
-
tags: ["
|
|
9301
|
-
summary: "
|
|
9302
|
-
security:
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
200: envelope(destinationSchema),
|
|
9306
|
-
...sessionErrors,
|
|
9307
|
-
...errors("not_found")
|
|
9308
|
-
}
|
|
9309
|
-
}), defineRoute({
|
|
9310
|
-
method: "patch",
|
|
9311
|
-
path: "/v1/destinations/{id}",
|
|
9312
|
-
operationId: "destinations.update",
|
|
9313
|
-
tags: ["destinations"],
|
|
9314
|
-
summary: "Update a destination",
|
|
9315
|
-
security: SESSION_AUTH,
|
|
9316
|
-
request: {
|
|
9317
|
-
params: params$9,
|
|
9318
|
-
body: jsonBody(updateDestinationBodySchema)
|
|
9319
|
-
},
|
|
9320
|
-
responses: {
|
|
9321
|
-
200: envelope(destinationSchema),
|
|
9322
|
-
...sessionErrors,
|
|
9323
|
-
...errors("not_found", "validation_failed", "malformed_request")
|
|
9324
|
-
}
|
|
9325
|
-
}), defineRoute({
|
|
9326
|
-
method: "delete",
|
|
9327
|
-
path: "/v1/destinations/{id}",
|
|
9328
|
-
operationId: "destinations.delete",
|
|
9329
|
-
tags: ["destinations"],
|
|
9330
|
-
summary: "Delete a destination",
|
|
9331
|
-
security: SESSION_AUTH,
|
|
9332
|
-
request: { params: params$9 },
|
|
9612
|
+
path: "/v1/unsubscribe",
|
|
9613
|
+
operationId: "deliveries.unsubscribePage",
|
|
9614
|
+
tags: ["deliveries"],
|
|
9615
|
+
summary: "Unsubscribe confirmation page (public)",
|
|
9616
|
+
security: NO_AUTH,
|
|
9617
|
+
surfaces: HIDDEN_FROM_TOOLS,
|
|
9618
|
+
request: { query: tokenQuery },
|
|
9333
9619
|
responses: {
|
|
9334
|
-
200:
|
|
9335
|
-
|
|
9336
|
-
...errors("not_found")
|
|
9620
|
+
200: htmlResponse("The page offering the unsubscribe"),
|
|
9621
|
+
400: { description: "Invalid or incomplete unsubscribe link" }
|
|
9337
9622
|
}
|
|
9338
9623
|
}));
|
|
9339
9624
|
|
|
9340
9625
|
//#endregion
|
|
9341
9626
|
//#region ../../packages/shared/src/contract/domains.ts
|
|
9342
|
-
const params$
|
|
9627
|
+
const params$9 = z.object({ id: z.string() });
|
|
9343
9628
|
const domains = defineModule(defineRoute({
|
|
9344
9629
|
method: "get",
|
|
9345
9630
|
path: "/v1/domains",
|
|
@@ -9360,7 +9645,7 @@ const domains = defineModule(defineRoute({
|
|
|
9360
9645
|
tags: ["domains"],
|
|
9361
9646
|
summary: "Fetch one sending domain",
|
|
9362
9647
|
security: SESSION_AUTH,
|
|
9363
|
-
request: { params: params$
|
|
9648
|
+
request: { params: params$9 },
|
|
9364
9649
|
responses: {
|
|
9365
9650
|
200: envelope(senderDomainSchema),
|
|
9366
9651
|
...sessionErrors,
|
|
@@ -9371,11 +9656,11 @@ const domains = defineModule(defineRoute({
|
|
|
9371
9656
|
path: "/v1/domains",
|
|
9372
9657
|
operationId: "domains.create",
|
|
9373
9658
|
tags: ["domains"],
|
|
9374
|
-
summary: "
|
|
9659
|
+
summary: "Add a sending domain",
|
|
9375
9660
|
security: SESSION_AUTH,
|
|
9376
9661
|
request: { body: jsonBody(createSenderDomainBodySchema) },
|
|
9377
9662
|
responses: {
|
|
9378
|
-
201: envelope(senderDomainSchema, "The
|
|
9663
|
+
201: envelope(senderDomainSchema, "The domain with the DNS records to publish, which is what claims it"),
|
|
9379
9664
|
...sessionErrors,
|
|
9380
9665
|
...errors("conflict", "validation_failed", "malformed_request", "dependency_unavailable")
|
|
9381
9666
|
}
|
|
@@ -9388,7 +9673,7 @@ const domains = defineModule(defineRoute({
|
|
|
9388
9673
|
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.",
|
|
9389
9674
|
security: SESSION_AUTH,
|
|
9390
9675
|
surfaces: HIDDEN_FROM_TOOLS,
|
|
9391
|
-
request: { params: params$
|
|
9676
|
+
request: { params: params$9 },
|
|
9392
9677
|
responses: {
|
|
9393
9678
|
200: envelope(domainDnsSetupSchema),
|
|
9394
9679
|
...sessionErrors,
|
|
@@ -9401,7 +9686,7 @@ const domains = defineModule(defineRoute({
|
|
|
9401
9686
|
tags: ["domains"],
|
|
9402
9687
|
summary: "Re-check DNS now",
|
|
9403
9688
|
security: SESSION_AUTH,
|
|
9404
|
-
request: { params: params$
|
|
9689
|
+
request: { params: params$9 },
|
|
9405
9690
|
responses: {
|
|
9406
9691
|
200: envelope(senderDomainSchema),
|
|
9407
9692
|
...sessionErrors,
|
|
@@ -9415,7 +9700,7 @@ const domains = defineModule(defineRoute({
|
|
|
9415
9700
|
summary: "Toggle click tracking",
|
|
9416
9701
|
security: SESSION_AUTH,
|
|
9417
9702
|
request: {
|
|
9418
|
-
params: params$
|
|
9703
|
+
params: params$9,
|
|
9419
9704
|
body: jsonBody(updateSenderDomainBodySchema)
|
|
9420
9705
|
},
|
|
9421
9706
|
responses: {
|
|
@@ -9428,9 +9713,9 @@ const domains = defineModule(defineRoute({
|
|
|
9428
9713
|
path: "/v1/domains/{id}",
|
|
9429
9714
|
operationId: "domains.delete",
|
|
9430
9715
|
tags: ["domains"],
|
|
9431
|
-
summary: "Give up a domain
|
|
9716
|
+
summary: "Give up a sending domain",
|
|
9432
9717
|
security: SESSION_AUTH,
|
|
9433
|
-
request: { params: params$
|
|
9718
|
+
request: { params: params$9 },
|
|
9434
9719
|
responses: {
|
|
9435
9720
|
200: envelope(deletedSchema),
|
|
9436
9721
|
...sessionErrors,
|
|
@@ -9440,8 +9725,31 @@ const domains = defineModule(defineRoute({
|
|
|
9440
9725
|
|
|
9441
9726
|
//#endregion
|
|
9442
9727
|
//#region ../../packages/shared/src/contract/emails.ts
|
|
9443
|
-
/**
|
|
9728
|
+
/**
|
|
9729
|
+
* The transactional send a developer's own code makes, plus the public
|
|
9730
|
+
* email-verification pages.
|
|
9731
|
+
*
|
|
9732
|
+
* `emails.send` is the first-party shape of the same send the Resend facade
|
|
9733
|
+
* serves (ADR 0014): one service, two wire formats, and this one answers in
|
|
9734
|
+
* the envelope with our error codes. It authenticates with an org API key
|
|
9735
|
+
* like the rest of the write path, which is why it is hidden from the CLI
|
|
9736
|
+
* and MCP surfaces: those hold a session.
|
|
9737
|
+
*/
|
|
9444
9738
|
const emails = defineModule(defineRoute({
|
|
9739
|
+
method: "post",
|
|
9740
|
+
path: "/v1/emails",
|
|
9741
|
+
operationId: "emails.send",
|
|
9742
|
+
tags: ["emails"],
|
|
9743
|
+
summary: "Send one transactional email",
|
|
9744
|
+
security: API_KEY_AUTH,
|
|
9745
|
+
surfaces: HIDDEN_FROM_TOOLS,
|
|
9746
|
+
request: { body: jsonBody(sendEmailBodySchema) },
|
|
9747
|
+
responses: {
|
|
9748
|
+
201: envelope(sendEmailResultSchema, "Handed over; the delivery's id"),
|
|
9749
|
+
...ingestionErrors,
|
|
9750
|
+
...errors("forbidden", "not_found", "conflict")
|
|
9751
|
+
}
|
|
9752
|
+
}), defineRoute({
|
|
9445
9753
|
method: "get",
|
|
9446
9754
|
path: "/v1/public/verify",
|
|
9447
9755
|
operationId: "emails.verifyPage",
|
|
@@ -9528,15 +9836,15 @@ const events = defineModule(defineRoute({
|
|
|
9528
9836
|
|
|
9529
9837
|
//#endregion
|
|
9530
9838
|
//#region ../../packages/shared/src/contract/executions.ts
|
|
9531
|
-
const params$
|
|
9839
|
+
const params$8 = z.object({ id: z.string() });
|
|
9532
9840
|
/**
|
|
9533
9841
|
* Executions: one run of one journey for one profile. Replaces v1's journey
|
|
9534
9842
|
* instances, which were read live from Temporal and so could only be listed
|
|
9535
9843
|
* per journey; these are rows, so they filter by journey, version, status,
|
|
9536
|
-
* and profile in one place.
|
|
9844
|
+
* and profile in one place.
|
|
9537
9845
|
*
|
|
9538
|
-
* `cancel` is the only write: stopping is explicit and
|
|
9539
|
-
*
|
|
9846
|
+
* `cancel` is the only write: stopping is explicit (ADR 0011), and each
|
|
9847
|
+
* execution records `cancelled` when it reaches its
|
|
9540
9848
|
* next step, so the answer is that the sweep started rather than a count.
|
|
9541
9849
|
* A pipeline may push, enable, disable and READ executions; stopping runs
|
|
9542
9850
|
* that are already carrying real people is a person's call, so cancel takes
|
|
@@ -9575,7 +9883,7 @@ const executions = defineModule(defineRoute({
|
|
|
9575
9883
|
tags: ["executions"],
|
|
9576
9884
|
summary: "Inspect one execution, with its logs",
|
|
9577
9885
|
security: PIPELINE_AUTH,
|
|
9578
|
-
request: { params: params$
|
|
9886
|
+
request: { params: params$8 },
|
|
9579
9887
|
responses: {
|
|
9580
9888
|
200: envelope(executionDetailSchema),
|
|
9581
9889
|
...sessionErrors,
|
|
@@ -9688,16 +9996,11 @@ const ingestion = defineModule(defineRoute({
|
|
|
9688
9996
|
* because the rows are derived from a push rather than created through the
|
|
9689
9997
|
* API, and (org, key) is the only identity they have.
|
|
9690
9998
|
*
|
|
9691
|
-
* `list` and `get` report the enabled flag of BOTH environments whichever
|
|
9692
|
-
* one the caller selected, because "is this on in production?" is the
|
|
9693
|
-
* question they exist to answer; `enable` and `disable` act on the selected
|
|
9694
|
-
* one, like every other environment-scoped write (docs/standards/api.md).
|
|
9695
|
-
*
|
|
9696
9999
|
* `journeys.listInstances` and `journeys.getInstance` are gone: executions
|
|
9697
10000
|
* are their own resource now (`executions.list` / `executions.get`), and
|
|
9698
10001
|
* `journeys.dryRun` answers with the execution it started.
|
|
9699
10002
|
*/
|
|
9700
|
-
const params$
|
|
10003
|
+
const params$7 = z.object({ key: z.string() });
|
|
9701
10004
|
const journeys = defineModule(defineRoute({
|
|
9702
10005
|
method: "get",
|
|
9703
10006
|
path: "/v1/journeys",
|
|
@@ -9718,9 +10021,9 @@ const journeys = defineModule(defineRoute({
|
|
|
9718
10021
|
tags: ["journeys"],
|
|
9719
10022
|
summary: "Inspect one journey",
|
|
9720
10023
|
security: SESSION_AUTH,
|
|
9721
|
-
request: { params: params$
|
|
10024
|
+
request: { params: params$7 },
|
|
9722
10025
|
responses: {
|
|
9723
|
-
200: envelope(
|
|
10026
|
+
200: envelope(journeyDetailSchema),
|
|
9724
10027
|
...sessionErrors,
|
|
9725
10028
|
...errors("not_found")
|
|
9726
10029
|
}
|
|
@@ -9731,7 +10034,7 @@ const journeys = defineModule(defineRoute({
|
|
|
9731
10034
|
tags: ["journeys"],
|
|
9732
10035
|
summary: "Journey operational stats",
|
|
9733
10036
|
security: SESSION_AUTH,
|
|
9734
|
-
request: { params: params$
|
|
10037
|
+
request: { params: params$7 },
|
|
9735
10038
|
responses: {
|
|
9736
10039
|
200: envelope(journeyStatsSchema),
|
|
9737
10040
|
...sessionErrors,
|
|
@@ -9739,31 +10042,33 @@ const journeys = defineModule(defineRoute({
|
|
|
9739
10042
|
}
|
|
9740
10043
|
}), defineRoute({
|
|
9741
10044
|
method: "post",
|
|
9742
|
-
path: "/v1/journeys/
|
|
9743
|
-
operationId: "journeys.
|
|
10045
|
+
path: "/v1/journeys/status",
|
|
10046
|
+
operationId: "journeys.setStatus",
|
|
9744
10047
|
tags: ["journeys"],
|
|
9745
|
-
summary: "
|
|
10048
|
+
summary: "Put journeys on, off or on hold",
|
|
9746
10049
|
security: PIPELINE_AUTH,
|
|
9747
10050
|
surfaces: { cli: false },
|
|
9748
|
-
request: { body: jsonBody(
|
|
10051
|
+
request: { body: jsonBody(setJourneysStatusBodySchema) },
|
|
9749
10052
|
responses: {
|
|
9750
|
-
200: envelope(z.array(
|
|
10053
|
+
200: envelope(z.array(journeyWithOfferSchema), "The journeys as they now stand, each with the members turning it on has waiting"),
|
|
9751
10054
|
...sessionErrors,
|
|
9752
10055
|
...errors("not_found", "project_missing", "validation_failed", "malformed_request")
|
|
9753
10056
|
}
|
|
9754
10057
|
}), defineRoute({
|
|
9755
|
-
method: "
|
|
9756
|
-
path: "/v1/journeys/
|
|
9757
|
-
operationId: "journeys.
|
|
10058
|
+
method: "get",
|
|
10059
|
+
path: "/v1/journeys/{key}/waits",
|
|
10060
|
+
operationId: "journeys.waitsDue",
|
|
9758
10061
|
tags: ["journeys"],
|
|
9759
|
-
summary: "
|
|
9760
|
-
security:
|
|
9761
|
-
|
|
9762
|
-
|
|
10062
|
+
summary: "Count the executions a hold until a given day would end",
|
|
10063
|
+
security: SESSION_AUTH,
|
|
10064
|
+
request: {
|
|
10065
|
+
params: params$7,
|
|
10066
|
+
query: waitsDueQuerySchema
|
|
10067
|
+
},
|
|
9763
10068
|
responses: {
|
|
9764
|
-
200: envelope(
|
|
10069
|
+
200: envelope(waitsDueSchema, "Executions in flight, and how many of them come due before that instant"),
|
|
9765
10070
|
...sessionErrors,
|
|
9766
|
-
...errors("not_found", "
|
|
10071
|
+
...errors("not_found", "validation_failed")
|
|
9767
10072
|
}
|
|
9768
10073
|
}), defineRoute({
|
|
9769
10074
|
method: "delete",
|
|
@@ -9772,12 +10077,65 @@ const journeys = defineModule(defineRoute({
|
|
|
9772
10077
|
tags: ["journeys"],
|
|
9773
10078
|
summary: "Delete a journey",
|
|
9774
10079
|
security: SESSION_AUTH,
|
|
9775
|
-
request: { params: params$
|
|
10080
|
+
request: { params: params$7 },
|
|
9776
10081
|
responses: {
|
|
9777
10082
|
200: envelope(deletedSchema),
|
|
9778
10083
|
...sessionErrors,
|
|
9779
10084
|
...errors("not_found")
|
|
9780
10085
|
}
|
|
10086
|
+
}), defineRoute({
|
|
10087
|
+
method: "get",
|
|
10088
|
+
path: "/v1/journeys/{key}/enrollment/preview",
|
|
10089
|
+
operationId: "journeys.enrollmentPreview",
|
|
10090
|
+
tags: ["journeys"],
|
|
10091
|
+
summary: "Count who turning this journey on would reach",
|
|
10092
|
+
security: SESSION_AUTH,
|
|
10093
|
+
request: { params: params$7 },
|
|
10094
|
+
responses: {
|
|
10095
|
+
200: envelope(enrollmentCountsSchema, "What enrolling the journey's current members would do"),
|
|
10096
|
+
...sessionErrors,
|
|
10097
|
+
...errors("not_found", "dependency_unavailable")
|
|
10098
|
+
}
|
|
10099
|
+
}), defineRoute({
|
|
10100
|
+
method: "post",
|
|
10101
|
+
path: "/v1/journeys/{key}/enrollment",
|
|
10102
|
+
operationId: "journeys.startEnrollment",
|
|
10103
|
+
tags: ["journeys"],
|
|
10104
|
+
summary: "Enroll the journey's current members",
|
|
10105
|
+
security: SESSION_AUTH,
|
|
10106
|
+
surfaces: HIDDEN_FROM_TOOLS,
|
|
10107
|
+
request: { params: params$7 },
|
|
10108
|
+
responses: {
|
|
10109
|
+
202: envelope(enrollmentStatusSchema, "The job, as it starts"),
|
|
10110
|
+
...sessionErrors,
|
|
10111
|
+
...errors("not_found")
|
|
10112
|
+
}
|
|
10113
|
+
}), defineRoute({
|
|
10114
|
+
method: "get",
|
|
10115
|
+
path: "/v1/journeys/{key}/enrollment",
|
|
10116
|
+
operationId: "journeys.enrollmentStatus",
|
|
10117
|
+
tags: ["journeys"],
|
|
10118
|
+
summary: "How far the journey's enrollment has got",
|
|
10119
|
+
security: SESSION_AUTH,
|
|
10120
|
+
request: { params: params$7 },
|
|
10121
|
+
responses: {
|
|
10122
|
+
200: envelope(enrollmentStatusSchema),
|
|
10123
|
+
...sessionErrors,
|
|
10124
|
+
...errors("not_found")
|
|
10125
|
+
}
|
|
10126
|
+
}), defineRoute({
|
|
10127
|
+
method: "post",
|
|
10128
|
+
path: "/v1/journeys/{key}/enrollment/cancel",
|
|
10129
|
+
operationId: "journeys.cancelEnrollment",
|
|
10130
|
+
tags: ["journeys"],
|
|
10131
|
+
summary: "Stop a running enrollment",
|
|
10132
|
+
security: SESSION_AUTH,
|
|
10133
|
+
request: { params: params$7 },
|
|
10134
|
+
responses: {
|
|
10135
|
+
200: envelope(enrollmentStatusSchema, "The job as it stood when the stop was asked for"),
|
|
10136
|
+
...sessionErrors,
|
|
10137
|
+
...errors("not_found")
|
|
10138
|
+
}
|
|
9781
10139
|
}), defineRoute({
|
|
9782
10140
|
method: "post",
|
|
9783
10141
|
path: "/v1/journeys/{key}/dry-run",
|
|
@@ -9786,7 +10144,7 @@ const journeys = defineModule(defineRoute({
|
|
|
9786
10144
|
summary: "Dry-run a journey against a real user",
|
|
9787
10145
|
security: SESSION_AUTH,
|
|
9788
10146
|
request: {
|
|
9789
|
-
params: params$
|
|
10147
|
+
params: params$7,
|
|
9790
10148
|
body: jsonBody(dryRunJourneyBodySchema)
|
|
9791
10149
|
},
|
|
9792
10150
|
responses: {
|
|
@@ -9800,9 +10158,9 @@ const journeys = defineModule(defineRoute({
|
|
|
9800
10158
|
//#region ../../packages/shared/src/contract/me.ts
|
|
9801
10159
|
/**
|
|
9802
10160
|
* The signed-in developer's own settings. Session auth like the rest of the
|
|
9803
|
-
* dashboard API, but deliberately not org-scoped
|
|
9804
|
-
*
|
|
9805
|
-
*
|
|
10161
|
+
* dashboard API, but deliberately not org-scoped: the subject is the
|
|
10162
|
+
* caller's own platform-workspace profile, taken from their session, never
|
|
10163
|
+
* from the request.
|
|
9806
10164
|
*
|
|
9807
10165
|
* Hidden from the CLI and the MCP server. Both act with an org's key on that
|
|
9808
10166
|
* org's data, and neither has a signed-in human whose mail preferences this
|
|
@@ -9899,7 +10257,7 @@ const project = defineModule(defineRoute({
|
|
|
9899
10257
|
|
|
9900
10258
|
//#endregion
|
|
9901
10259
|
//#region ../../packages/shared/src/contract/pushes.ts
|
|
9902
|
-
const params$
|
|
10260
|
+
const params$6 = z.object({ id: z.string() });
|
|
9903
10261
|
/**
|
|
9904
10262
|
* Pushes: one `cow push` each, the whole project at one point in time. The
|
|
9905
10263
|
* push stores the source archive and its compile creates a version of every
|
|
@@ -9944,7 +10302,7 @@ const pushes = defineModule(defineRoute({
|
|
|
9944
10302
|
tags: ["pushes"],
|
|
9945
10303
|
summary: "Inspect one push and what it compiled",
|
|
9946
10304
|
security: SESSION_AUTH,
|
|
9947
|
-
request: { params: params$
|
|
10305
|
+
request: { params: params$6 },
|
|
9948
10306
|
responses: {
|
|
9949
10307
|
200: envelope(pushSchema),
|
|
9950
10308
|
...sessionErrors,
|
|
@@ -9958,7 +10316,7 @@ const pushes = defineModule(defineRoute({
|
|
|
9958
10316
|
summary: "Download the pushed source tarball",
|
|
9959
10317
|
security: SESSION_AUTH,
|
|
9960
10318
|
surfaces: HIDDEN_FROM_TOOLS,
|
|
9961
|
-
request: { params: params$
|
|
10319
|
+
request: { params: params$6 },
|
|
9962
10320
|
responses: {
|
|
9963
10321
|
200: {
|
|
9964
10322
|
description: "The gzipped source tarball the push stored",
|
|
@@ -9969,6 +10327,98 @@ const pushes = defineModule(defineRoute({
|
|
|
9969
10327
|
}
|
|
9970
10328
|
}));
|
|
9971
10329
|
|
|
10330
|
+
//#endregion
|
|
10331
|
+
//#region ../../packages/shared/src/contract/resend.ts
|
|
10332
|
+
/**
|
|
10333
|
+
* The Resend-compatible send facade (ADR 0014): a developer points
|
|
10334
|
+
* `RESEND_BASE_URL` at `/resend/{sourceId}` and keeps their code. The source
|
|
10335
|
+
* in the path names the app, because the Resend SDK carries no other field
|
|
10336
|
+
* and accepts no custom header; the bearer is the organization's ingestion
|
|
10337
|
+
* key.
|
|
10338
|
+
*
|
|
10339
|
+
* These routes are the one exception to the envelope standard: they answer
|
|
10340
|
+
* Resend's own shapes, and the API mounts them on a sub-app with its own
|
|
10341
|
+
* error handler. They stay in the contract so the API still registers no
|
|
10342
|
+
* route outside it, and they are hidden from the CLI, the MCP server and the
|
|
10343
|
+
* generated reference: a Cowliss surface has the `/v1` routes, and the guide
|
|
10344
|
+
* is the place the facade is documented.
|
|
10345
|
+
*/
|
|
10346
|
+
const FACADE_SURFACES = {
|
|
10347
|
+
cli: false,
|
|
10348
|
+
mcp: false,
|
|
10349
|
+
docs: false
|
|
10350
|
+
};
|
|
10351
|
+
const sourceParam = z.object({ sourceId: z.string().openapi({ example: "src_2f9a8c1b" }) });
|
|
10352
|
+
/** Resend's error body, for the answers this facade gives. */
|
|
10353
|
+
const facadeError = (description) => ({
|
|
10354
|
+
description,
|
|
10355
|
+
content: { "application/json": { schema: resendErrorSchema } }
|
|
10356
|
+
});
|
|
10357
|
+
const facadeErrors = {
|
|
10358
|
+
401: facadeError("missing_api_key | invalid_api_key"),
|
|
10359
|
+
404: facadeError("not_found: unknown source, or unknown message"),
|
|
10360
|
+
422: facadeError("validation_error | missing_required_field"),
|
|
10361
|
+
429: facadeError("monthly_quota_exceeded | rate_limit_exceeded"),
|
|
10362
|
+
500: facadeError("application_error")
|
|
10363
|
+
};
|
|
10364
|
+
const resend = defineModule(defineRoute({
|
|
10365
|
+
method: "post",
|
|
10366
|
+
path: "/resend/{sourceId}/emails",
|
|
10367
|
+
operationId: "resend.send",
|
|
10368
|
+
tags: ["resend"],
|
|
10369
|
+
summary: "Send one email (Resend-compatible)",
|
|
10370
|
+
security: API_KEY_AUTH,
|
|
10371
|
+
surfaces: FACADE_SURFACES,
|
|
10372
|
+
request: {
|
|
10373
|
+
params: sourceParam,
|
|
10374
|
+
body: jsonBody(resendSendBodySchema)
|
|
10375
|
+
},
|
|
10376
|
+
responses: {
|
|
10377
|
+
201: {
|
|
10378
|
+
description: "Accepted; the id is the delivery's",
|
|
10379
|
+
content: { "application/json": { schema: resendSentSchema } }
|
|
10380
|
+
},
|
|
10381
|
+
403: facadeError("validation_error: the from-address sits on a domain this organization has not verified"),
|
|
10382
|
+
...facadeErrors
|
|
10383
|
+
}
|
|
10384
|
+
}), defineRoute({
|
|
10385
|
+
method: "post",
|
|
10386
|
+
path: "/resend/{sourceId}/emails/batch",
|
|
10387
|
+
operationId: "resend.sendBatch",
|
|
10388
|
+
tags: ["resend"],
|
|
10389
|
+
summary: "Send up to 100 emails (Resend-compatible)",
|
|
10390
|
+
security: API_KEY_AUTH,
|
|
10391
|
+
surfaces: FACADE_SURFACES,
|
|
10392
|
+
request: {
|
|
10393
|
+
params: sourceParam,
|
|
10394
|
+
body: jsonBody(resendBatchBodySchema)
|
|
10395
|
+
},
|
|
10396
|
+
responses: {
|
|
10397
|
+
201: {
|
|
10398
|
+
description: "Accepted; one id per message, in the order they were given",
|
|
10399
|
+
content: { "application/json": { schema: resendBatchSentSchema } }
|
|
10400
|
+
},
|
|
10401
|
+
403: facadeError("validation_error: a from-address sits on a domain this organization has not verified"),
|
|
10402
|
+
...facadeErrors
|
|
10403
|
+
}
|
|
10404
|
+
}), defineRoute({
|
|
10405
|
+
method: "get",
|
|
10406
|
+
path: "/resend/{sourceId}/emails/{id}",
|
|
10407
|
+
operationId: "resend.get",
|
|
10408
|
+
tags: ["resend"],
|
|
10409
|
+
summary: "Fetch one sent email (Resend-compatible)",
|
|
10410
|
+
security: API_KEY_AUTH,
|
|
10411
|
+
surfaces: FACADE_SURFACES,
|
|
10412
|
+
request: { params: sourceParam.extend({ id: z.string().openapi({ example: "dlv_2f9a8c1b" }) }) },
|
|
10413
|
+
responses: {
|
|
10414
|
+
200: {
|
|
10415
|
+
description: "The email object",
|
|
10416
|
+
content: { "application/json": { schema: resendEmailSchema } }
|
|
10417
|
+
},
|
|
10418
|
+
...facadeErrors
|
|
10419
|
+
}
|
|
10420
|
+
}));
|
|
10421
|
+
|
|
9972
10422
|
//#endregion
|
|
9973
10423
|
//#region ../../packages/shared/src/contract/review.ts
|
|
9974
10424
|
/**
|
|
@@ -9994,7 +10444,7 @@ const review = defineModule(defineRoute({
|
|
|
9994
10444
|
|
|
9995
10445
|
//#endregion
|
|
9996
10446
|
//#region ../../packages/shared/src/contract/segments.ts
|
|
9997
|
-
const params$
|
|
10447
|
+
const params$5 = z.object({ id: z.string() });
|
|
9998
10448
|
const writeErrors = errors("validation_failed", "malformed_request");
|
|
9999
10449
|
/** `preview` precedes `get` so "preview" is never read as an id. */
|
|
10000
10450
|
const segments = defineModule(defineRoute({
|
|
@@ -10044,7 +10494,7 @@ const segments = defineModule(defineRoute({
|
|
|
10044
10494
|
tags: ["segments"],
|
|
10045
10495
|
summary: "Fetch one segment with its member count",
|
|
10046
10496
|
security: SESSION_AUTH,
|
|
10047
|
-
request: { params: params$
|
|
10497
|
+
request: { params: params$5 },
|
|
10048
10498
|
responses: {
|
|
10049
10499
|
200: envelope(segmentDetailSchema),
|
|
10050
10500
|
...sessionErrors,
|
|
@@ -10058,13 +10508,13 @@ const segments = defineModule(defineRoute({
|
|
|
10058
10508
|
summary: "Update a segment",
|
|
10059
10509
|
security: SESSION_AUTH,
|
|
10060
10510
|
request: {
|
|
10061
|
-
params: params$
|
|
10511
|
+
params: params$5,
|
|
10062
10512
|
body: jsonBody(updateSegmentBodySchema)
|
|
10063
10513
|
},
|
|
10064
10514
|
responses: {
|
|
10065
10515
|
200: envelope(segmentDetailSchema),
|
|
10066
10516
|
...sessionErrors,
|
|
10067
|
-
...errors("not_found"),
|
|
10517
|
+
...errors("not_found", "conflict"),
|
|
10068
10518
|
...writeErrors
|
|
10069
10519
|
}
|
|
10070
10520
|
}), defineRoute({
|
|
@@ -10074,11 +10524,11 @@ const segments = defineModule(defineRoute({
|
|
|
10074
10524
|
tags: ["segments"],
|
|
10075
10525
|
summary: "Delete a segment",
|
|
10076
10526
|
security: SESSION_AUTH,
|
|
10077
|
-
request: { params: params$
|
|
10527
|
+
request: { params: params$5 },
|
|
10078
10528
|
responses: {
|
|
10079
10529
|
200: envelope(deletedSchema),
|
|
10080
10530
|
...sessionErrors,
|
|
10081
|
-
...errors("not_found")
|
|
10531
|
+
...errors("not_found", "conflict")
|
|
10082
10532
|
}
|
|
10083
10533
|
}), defineRoute({
|
|
10084
10534
|
method: "get",
|
|
@@ -10088,7 +10538,7 @@ const segments = defineModule(defineRoute({
|
|
|
10088
10538
|
summary: "List a segment's members",
|
|
10089
10539
|
security: SESSION_AUTH,
|
|
10090
10540
|
request: {
|
|
10091
|
-
params: params$
|
|
10541
|
+
params: params$5,
|
|
10092
10542
|
query: listSegmentMembersQuerySchema
|
|
10093
10543
|
},
|
|
10094
10544
|
responses: {
|
|
@@ -10213,7 +10663,7 @@ const settings = defineModule(defineRoute({
|
|
|
10213
10663
|
//#endregion
|
|
10214
10664
|
//#region ../../packages/shared/src/contract/sources.ts
|
|
10215
10665
|
const appParams = z.object({ appId: z.string() });
|
|
10216
|
-
const params$
|
|
10666
|
+
const params$4 = z.object({ id: z.string() });
|
|
10217
10667
|
/**
|
|
10218
10668
|
* A source is one inbound pipe into an app. It is created and listed under
|
|
10219
10669
|
* its parent app (the app is the attribution unit) and addressed by its own
|
|
@@ -10259,7 +10709,7 @@ const sources = defineModule(defineRoute({
|
|
|
10259
10709
|
tags: ["sources"],
|
|
10260
10710
|
summary: "Fetch one source",
|
|
10261
10711
|
security: SESSION_AUTH,
|
|
10262
|
-
request: { params: params$
|
|
10712
|
+
request: { params: params$4 },
|
|
10263
10713
|
responses: {
|
|
10264
10714
|
200: envelope(sourceSchema),
|
|
10265
10715
|
...sessionErrors,
|
|
@@ -10273,7 +10723,7 @@ const sources = defineModule(defineRoute({
|
|
|
10273
10723
|
summary: "Set or rotate a source's config",
|
|
10274
10724
|
security: SESSION_AUTH,
|
|
10275
10725
|
request: {
|
|
10276
|
-
params: params$
|
|
10726
|
+
params: params$4,
|
|
10277
10727
|
body: jsonBody(updateSourceBodySchema)
|
|
10278
10728
|
},
|
|
10279
10729
|
responses: {
|
|
@@ -10288,7 +10738,7 @@ const sources = defineModule(defineRoute({
|
|
|
10288
10738
|
tags: ["sources"],
|
|
10289
10739
|
summary: "Archive a source",
|
|
10290
10740
|
security: SESSION_AUTH,
|
|
10291
|
-
request: { params: params$
|
|
10741
|
+
request: { params: params$4 },
|
|
10292
10742
|
responses: {
|
|
10293
10743
|
200: envelope(sourceSchema, "The archived source"),
|
|
10294
10744
|
...sessionErrors,
|
|
@@ -10359,7 +10809,7 @@ const suppressions = defineModule(defineRoute({
|
|
|
10359
10809
|
* organization's default sender: it reaches no recipient, so it passes no
|
|
10360
10810
|
* consent gate, counts against nothing, and is logged as a test.
|
|
10361
10811
|
*/
|
|
10362
|
-
const params$
|
|
10812
|
+
const params$3 = z.object({ key: z.string() });
|
|
10363
10813
|
const templates = defineModule(defineRoute({
|
|
10364
10814
|
method: "get",
|
|
10365
10815
|
path: "/v1/templates",
|
|
@@ -10380,7 +10830,7 @@ const templates = defineModule(defineRoute({
|
|
|
10380
10830
|
tags: ["templates"],
|
|
10381
10831
|
summary: "Inspect one email template",
|
|
10382
10832
|
security: SESSION_AUTH,
|
|
10383
|
-
request: { params: params$
|
|
10833
|
+
request: { params: params$3 },
|
|
10384
10834
|
responses: {
|
|
10385
10835
|
200: envelope(templateSchema),
|
|
10386
10836
|
...sessionErrors,
|
|
@@ -10394,7 +10844,7 @@ const templates = defineModule(defineRoute({
|
|
|
10394
10844
|
summary: "Render a template with the props you pass",
|
|
10395
10845
|
security: SESSION_AUTH,
|
|
10396
10846
|
request: {
|
|
10397
|
-
params: params$
|
|
10847
|
+
params: params$3,
|
|
10398
10848
|
body: jsonBody(renderTemplateBodySchema)
|
|
10399
10849
|
},
|
|
10400
10850
|
responses: {
|
|
@@ -10410,8 +10860,8 @@ const templates = defineModule(defineRoute({
|
|
|
10410
10860
|
summary: "Send a rendered template to your own address",
|
|
10411
10861
|
security: SESSION_AUTH,
|
|
10412
10862
|
request: {
|
|
10413
|
-
params: params$
|
|
10414
|
-
body: jsonBody(
|
|
10863
|
+
params: params$3,
|
|
10864
|
+
body: jsonBody(testSendTemplateBodySchema)
|
|
10415
10865
|
},
|
|
10416
10866
|
responses: {
|
|
10417
10867
|
201: envelope(deliverySchema, "The test send, as it was logged"),
|
|
@@ -10425,7 +10875,7 @@ const templates = defineModule(defineRoute({
|
|
|
10425
10875
|
tags: ["templates"],
|
|
10426
10876
|
summary: "Delete an email template",
|
|
10427
10877
|
security: SESSION_AUTH,
|
|
10428
|
-
request: { params: params$
|
|
10878
|
+
request: { params: params$3 },
|
|
10429
10879
|
responses: {
|
|
10430
10880
|
200: envelope(deletedSchema),
|
|
10431
10881
|
...sessionErrors,
|
|
@@ -10435,7 +10885,7 @@ const templates = defineModule(defineRoute({
|
|
|
10435
10885
|
|
|
10436
10886
|
//#endregion
|
|
10437
10887
|
//#region ../../packages/shared/src/contract/users.ts
|
|
10438
|
-
const params$
|
|
10888
|
+
const params$2 = z.object({ profileId: z.string() });
|
|
10439
10889
|
/**
|
|
10440
10890
|
* The answer a merged-away profile id gets (spec: Identity): 307 to the
|
|
10441
10891
|
* same path with the survivor's id, so the method and body of a redirected
|
|
@@ -10446,9 +10896,8 @@ const mergedRedirect = { 307: {
|
|
|
10446
10896
|
headers: z.object({ Location: z.string().meta({ description: "The survivor's URL" }) })
|
|
10447
10897
|
} };
|
|
10448
10898
|
/**
|
|
10449
|
-
* Profiles by their Cowliss-generated id
|
|
10450
|
-
*
|
|
10451
|
-
* wins.
|
|
10899
|
+
* Profiles by their Cowliss-generated id. `find` precedes `{profileId}` so
|
|
10900
|
+
* the literal path wins.
|
|
10452
10901
|
*/
|
|
10453
10902
|
const users = defineModule(defineRoute({
|
|
10454
10903
|
method: "get",
|
|
@@ -10483,7 +10932,7 @@ const users = defineModule(defineRoute({
|
|
|
10483
10932
|
tags: ["users"],
|
|
10484
10933
|
summary: "Fetch one profile with its identifiers",
|
|
10485
10934
|
security: SESSION_AUTH,
|
|
10486
|
-
request: { params: params$
|
|
10935
|
+
request: { params: params$2 },
|
|
10487
10936
|
responses: {
|
|
10488
10937
|
200: envelope(userDetailSchema),
|
|
10489
10938
|
...mergedRedirect,
|
|
@@ -10497,7 +10946,7 @@ const users = defineModule(defineRoute({
|
|
|
10497
10946
|
tags: ["users"],
|
|
10498
10947
|
summary: "Erase a user (GDPR)",
|
|
10499
10948
|
security: SESSION_AUTH,
|
|
10500
|
-
request: { params: params$
|
|
10949
|
+
request: { params: params$2 },
|
|
10501
10950
|
responses: {
|
|
10502
10951
|
200: envelope(eraseUserResponseSchema),
|
|
10503
10952
|
...mergedRedirect,
|
|
@@ -10511,7 +10960,7 @@ const users = defineModule(defineRoute({
|
|
|
10511
10960
|
tags: ["users"],
|
|
10512
10961
|
summary: "Export everything held on a user (GDPR)",
|
|
10513
10962
|
security: SESSION_AUTH,
|
|
10514
|
-
request: { params: params$
|
|
10963
|
+
request: { params: params$2 },
|
|
10515
10964
|
responses: {
|
|
10516
10965
|
200: envelope(userExportSchema),
|
|
10517
10966
|
...mergedRedirect,
|
|
@@ -10526,7 +10975,7 @@ const users = defineModule(defineRoute({
|
|
|
10526
10975
|
summary: "Update consent purposes",
|
|
10527
10976
|
security: SESSION_AUTH,
|
|
10528
10977
|
request: {
|
|
10529
|
-
params: params$
|
|
10978
|
+
params: params$2,
|
|
10530
10979
|
body: jsonBody(updateConsentBodySchema)
|
|
10531
10980
|
},
|
|
10532
10981
|
responses: {
|
|
@@ -10543,7 +10992,7 @@ const users = defineModule(defineRoute({
|
|
|
10543
10992
|
summary: "The segments a profile belongs to",
|
|
10544
10993
|
security: SESSION_AUTH,
|
|
10545
10994
|
request: {
|
|
10546
|
-
params: params$
|
|
10995
|
+
params: params$2,
|
|
10547
10996
|
query: listUserSegmentsQuerySchema
|
|
10548
10997
|
},
|
|
10549
10998
|
responses: {
|
|
@@ -10554,17 +11003,17 @@ const users = defineModule(defineRoute({
|
|
|
10554
11003
|
}
|
|
10555
11004
|
}), defineRoute({
|
|
10556
11005
|
method: "get",
|
|
10557
|
-
path: "/v1/users/{profileId}/
|
|
10558
|
-
operationId: "users.
|
|
11006
|
+
path: "/v1/users/{profileId}/runs",
|
|
11007
|
+
operationId: "users.listRuns",
|
|
10559
11008
|
tags: ["users"],
|
|
10560
|
-
summary: "
|
|
11009
|
+
summary: "The journeys a profile has completed",
|
|
10561
11010
|
security: SESSION_AUTH,
|
|
10562
11011
|
request: {
|
|
10563
|
-
params: params$
|
|
10564
|
-
query:
|
|
11012
|
+
params: params$2,
|
|
11013
|
+
query: listUserRunsQuerySchema
|
|
10565
11014
|
},
|
|
10566
11015
|
responses: {
|
|
10567
|
-
200: list(
|
|
11016
|
+
200: list(userRunSchema),
|
|
10568
11017
|
...mergedRedirect,
|
|
10569
11018
|
...sessionErrors,
|
|
10570
11019
|
...errors("not_found", "validation_failed")
|
|
@@ -10596,7 +11045,7 @@ const versions = defineModule(defineRoute({
|
|
|
10596
11045
|
|
|
10597
11046
|
//#endregion
|
|
10598
11047
|
//#region ../../packages/shared/src/contract/violations.ts
|
|
10599
|
-
const params = z.object({ id: z.string() });
|
|
11048
|
+
const params$1 = z.object({ id: z.string() });
|
|
10600
11049
|
const violations = defineModule(defineRoute({
|
|
10601
11050
|
method: "get",
|
|
10602
11051
|
path: "/v1/violations/{id}",
|
|
@@ -10604,7 +11053,7 @@ const violations = defineModule(defineRoute({
|
|
|
10604
11053
|
tags: ["violations"],
|
|
10605
11054
|
summary: "Fetch one violation",
|
|
10606
11055
|
security: SESSION_AUTH,
|
|
10607
|
-
request: { params },
|
|
11056
|
+
request: { params: params$1 },
|
|
10608
11057
|
responses: {
|
|
10609
11058
|
200: envelope(violationSchema),
|
|
10610
11059
|
...sessionErrors,
|
|
@@ -10618,7 +11067,7 @@ const violations = defineModule(defineRoute({
|
|
|
10618
11067
|
summary: "Resolve a violation into the catalog",
|
|
10619
11068
|
security: SESSION_AUTH,
|
|
10620
11069
|
request: {
|
|
10621
|
-
params,
|
|
11070
|
+
params: params$1,
|
|
10622
11071
|
body: jsonBody(resolveViolationBodySchema)
|
|
10623
11072
|
},
|
|
10624
11073
|
responses: {
|
|
@@ -10633,7 +11082,7 @@ const violations = defineModule(defineRoute({
|
|
|
10633
11082
|
tags: ["violations"],
|
|
10634
11083
|
summary: "Dismiss a violation",
|
|
10635
11084
|
security: SESSION_AUTH,
|
|
10636
|
-
request: { params },
|
|
11085
|
+
request: { params: params$1 },
|
|
10637
11086
|
responses: {
|
|
10638
11087
|
200: envelope(violationSchema),
|
|
10639
11088
|
...sessionErrors,
|
|
@@ -10641,6 +11090,79 @@ const violations = defineModule(defineRoute({
|
|
|
10641
11090
|
}
|
|
10642
11091
|
}));
|
|
10643
11092
|
|
|
11093
|
+
//#endregion
|
|
11094
|
+
//#region ../../packages/shared/src/contract/webhooks.ts
|
|
11095
|
+
const params = z.object({ id: z.string() });
|
|
11096
|
+
const webhooks = defineModule(defineRoute({
|
|
11097
|
+
method: "post",
|
|
11098
|
+
path: "/v1/webhooks",
|
|
11099
|
+
operationId: "webhooks.create",
|
|
11100
|
+
tags: ["webhooks"],
|
|
11101
|
+
summary: "Create a webhook",
|
|
11102
|
+
security: SESSION_AUTH,
|
|
11103
|
+
request: { body: jsonBody(createWebhookBodySchema) },
|
|
11104
|
+
responses: {
|
|
11105
|
+
201: envelope(webhookCreatedSchema, "The created webhook; the only response carrying the signing secret"),
|
|
11106
|
+
...sessionErrors,
|
|
11107
|
+
...errors("validation_failed", "malformed_request", "conflict")
|
|
11108
|
+
}
|
|
11109
|
+
}), defineRoute({
|
|
11110
|
+
method: "get",
|
|
11111
|
+
path: "/v1/webhooks",
|
|
11112
|
+
operationId: "webhooks.list",
|
|
11113
|
+
tags: ["webhooks"],
|
|
11114
|
+
summary: "List webhooks",
|
|
11115
|
+
security: SESSION_AUTH,
|
|
11116
|
+
request: { query: listWebhooksQuerySchema },
|
|
11117
|
+
responses: {
|
|
11118
|
+
200: list(webhookSchema),
|
|
11119
|
+
...sessionErrors,
|
|
11120
|
+
...errors("validation_failed")
|
|
11121
|
+
}
|
|
11122
|
+
}), defineRoute({
|
|
11123
|
+
method: "get",
|
|
11124
|
+
path: "/v1/webhooks/{id}",
|
|
11125
|
+
operationId: "webhooks.get",
|
|
11126
|
+
tags: ["webhooks"],
|
|
11127
|
+
summary: "Fetch one webhook",
|
|
11128
|
+
security: SESSION_AUTH,
|
|
11129
|
+
request: { params },
|
|
11130
|
+
responses: {
|
|
11131
|
+
200: envelope(webhookSchema),
|
|
11132
|
+
...sessionErrors,
|
|
11133
|
+
...errors("not_found")
|
|
11134
|
+
}
|
|
11135
|
+
}), defineRoute({
|
|
11136
|
+
method: "patch",
|
|
11137
|
+
path: "/v1/webhooks/{id}",
|
|
11138
|
+
operationId: "webhooks.update",
|
|
11139
|
+
tags: ["webhooks"],
|
|
11140
|
+
summary: "Update a webhook",
|
|
11141
|
+
security: SESSION_AUTH,
|
|
11142
|
+
request: {
|
|
11143
|
+
params,
|
|
11144
|
+
body: jsonBody(updateWebhookBodySchema)
|
|
11145
|
+
},
|
|
11146
|
+
responses: {
|
|
11147
|
+
200: envelope(webhookSchema),
|
|
11148
|
+
...sessionErrors,
|
|
11149
|
+
...errors("not_found", "validation_failed", "malformed_request", "conflict")
|
|
11150
|
+
}
|
|
11151
|
+
}), defineRoute({
|
|
11152
|
+
method: "delete",
|
|
11153
|
+
path: "/v1/webhooks/{id}",
|
|
11154
|
+
operationId: "webhooks.delete",
|
|
11155
|
+
tags: ["webhooks"],
|
|
11156
|
+
summary: "Delete a webhook",
|
|
11157
|
+
security: SESSION_AUTH,
|
|
11158
|
+
request: { params },
|
|
11159
|
+
responses: {
|
|
11160
|
+
200: envelope(deletedSchema),
|
|
11161
|
+
...sessionErrors,
|
|
11162
|
+
...errors("not_found")
|
|
11163
|
+
}
|
|
11164
|
+
}));
|
|
11165
|
+
|
|
10644
11166
|
//#endregion
|
|
10645
11167
|
//#region ../../packages/shared/src/contract/index.ts
|
|
10646
11168
|
/**
|
|
@@ -10673,10 +11195,11 @@ const contract = {
|
|
|
10673
11195
|
executions,
|
|
10674
11196
|
journeys,
|
|
10675
11197
|
templates,
|
|
10676
|
-
|
|
11198
|
+
webhooks,
|
|
10677
11199
|
domains,
|
|
10678
11200
|
deliveries,
|
|
10679
11201
|
emails,
|
|
11202
|
+
resend,
|
|
10680
11203
|
suppressions,
|
|
10681
11204
|
settings,
|
|
10682
11205
|
billing
|
|
@@ -11048,53 +11571,84 @@ function registerContractCommands(program, run) {
|
|
|
11048
11571
|
//#endregion
|
|
11049
11572
|
//#region src/commands/enable.ts
|
|
11050
11573
|
/**
|
|
11051
|
-
* `cow enable` and `cow
|
|
11052
|
-
*
|
|
11053
|
-
* `journeys.
|
|
11054
|
-
* types
|
|
11055
|
-
* same set of keys they do.
|
|
11574
|
+
* `cow enable`, `cow disable` and `cow pause`: the one gate on a journey
|
|
11575
|
+
* (ADR 0011, ADR 0013, ADR 0018). Hand-written rather than derived from
|
|
11576
|
+
* `journeys.setStatus`, because the commands are top-level names a developer
|
|
11577
|
+
* types; the route itself takes the same set of keys they do.
|
|
11056
11578
|
*
|
|
11057
|
-
*
|
|
11058
|
-
*
|
|
11059
|
-
* front of real recipients.
|
|
11579
|
+
* `cow enable` on a journey on hold is how it resumes: the server has one
|
|
11580
|
+
* transition to `on` and no second word for it.
|
|
11060
11581
|
*/
|
|
11061
11582
|
/**
|
|
11062
11583
|
* One call, whichever way the keys were named: the route takes the set, and
|
|
11063
11584
|
* `--all` is the project name, resolved on the server against the rows it
|
|
11064
11585
|
* owns (so it reaches a key whose file the tree lost). A key that is not
|
|
11065
|
-
* there refuses the whole call, and nothing
|
|
11066
|
-
*/
|
|
11067
|
-
async function
|
|
11068
|
-
|
|
11069
|
-
|
|
11586
|
+
* there refuses the whole call, and nothing changes.
|
|
11587
|
+
*/
|
|
11588
|
+
async function setStatus(client, selector, status) {
|
|
11589
|
+
return (await client.request(contract.journeys["journeys.setStatus"], { body: {
|
|
11590
|
+
...selector,
|
|
11591
|
+
status
|
|
11592
|
+
} })).data;
|
|
11593
|
+
}
|
|
11594
|
+
/**
|
|
11595
|
+
* Who is already standing in a journey that was just turned on, and where
|
|
11596
|
+
* to let them in (ADR 0015). Printed, never acted on: enrolling a cohort
|
|
11597
|
+
* spends real money and reaches real people, so it stays a person's click
|
|
11598
|
+
* in the dashboard and no command here starts one.
|
|
11599
|
+
*/
|
|
11600
|
+
function segmentLines(journey) {
|
|
11601
|
+
const offer = journey.enrollment;
|
|
11602
|
+
if (!offer) return [];
|
|
11603
|
+
if (!offer.counts) return [` Who is in its segment could not be counted just now: ${offer.url}`];
|
|
11604
|
+
const { scanned, enrolled, skipped } = offer.counts;
|
|
11605
|
+
if (scanned === 0) return [" Nobody is in its segment yet."];
|
|
11606
|
+
return [
|
|
11607
|
+
` ${scanned} in its segment now, ${enrolled} of them can enter it now.`,
|
|
11608
|
+
...Object.entries(ENROLLMENT_SKIP_WORDS).map(([key, words]) => ({
|
|
11609
|
+
value: skipped[key],
|
|
11610
|
+
words
|
|
11611
|
+
})).filter(({ value }) => value > 0).map(({ value, words }) => ` ${value} ${words.reason}.`),
|
|
11612
|
+
...enrolled > 0 ? [` To let those ${enrolled} in: ${offer.url}`] : []
|
|
11613
|
+
];
|
|
11070
11614
|
}
|
|
11071
|
-
/**
|
|
11072
|
-
|
|
11073
|
-
|
|
11074
|
-
|
|
11615
|
+
/** How the terminal names each state. */
|
|
11616
|
+
const STATE_WORD = {
|
|
11617
|
+
on: "on",
|
|
11618
|
+
off: "off",
|
|
11619
|
+
paused: "on hold"
|
|
11620
|
+
};
|
|
11621
|
+
/** What the terminal says: one line per key, naming its new state. */
|
|
11622
|
+
function flipSummary(journeys, status) {
|
|
11623
|
+
if (journeys.length === 0) return `This project has no journeys to put ${STATE_WORD[status]}.`;
|
|
11624
|
+
return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
|
|
11075
11625
|
}
|
|
11076
|
-
|
|
11077
|
-
|
|
11078
|
-
|
|
11626
|
+
const DESCRIPTIONS = {
|
|
11627
|
+
on: "turn journeys on",
|
|
11628
|
+
off: "turn journeys off",
|
|
11629
|
+
paused: "hold journeys: nobody enters, and everyone part-way through waits"
|
|
11630
|
+
};
|
|
11631
|
+
function register(program, clientFor, io, verb, status) {
|
|
11632
|
+
program.command(verb).description(`${DESCRIPTIONS[status]} (many keys, or --all for this project's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this project").action(async (keys, opts) => {
|
|
11079
11633
|
const merged = {
|
|
11080
11634
|
...program.opts(),
|
|
11081
11635
|
...opts
|
|
11082
11636
|
};
|
|
11083
|
-
|
|
11084
|
-
if (merged.all
|
|
11085
|
-
if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome --env ${environment}.`);
|
|
11637
|
+
if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
|
|
11638
|
+
if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
|
|
11086
11639
|
const selector = merged.all === true ? { project: (await assertCowConfig(process.cwd())).project } : { keys };
|
|
11087
|
-
const updated = await
|
|
11640
|
+
const updated = await setStatus(await clientFor(merged), selector, status);
|
|
11088
11641
|
if (merged.json === true) {
|
|
11089
11642
|
emit({ data: updated }, io, true);
|
|
11090
11643
|
return;
|
|
11091
11644
|
}
|
|
11092
|
-
io.stdout(`${flipSummary(updated,
|
|
11645
|
+
io.stdout(`${flipSummary(updated, status)}\n`);
|
|
11093
11646
|
});
|
|
11094
11647
|
}
|
|
11095
|
-
function registerEnable(program, clientFor,
|
|
11096
|
-
register(program, clientFor,
|
|
11097
|
-
register(program, clientFor,
|
|
11648
|
+
function registerEnable(program, clientFor, io) {
|
|
11649
|
+
register(program, clientFor, io, "enable", "on");
|
|
11650
|
+
register(program, clientFor, io, "disable", "off");
|
|
11651
|
+
register(program, clientFor, io, "pause", "paused");
|
|
11098
11652
|
}
|
|
11099
11653
|
|
|
11100
11654
|
//#endregion
|
|
@@ -11150,7 +11704,7 @@ async function packageJson(name) {
|
|
|
11150
11704
|
* ponytail: plain fetch because `POST /v1/project` is not a contract route
|
|
11151
11705
|
* yet; swap it for the typed client's `project.create` once ticket 04 lands.
|
|
11152
11706
|
*/
|
|
11153
|
-
async function createProject(apiUrl, token,
|
|
11707
|
+
async function createProject(apiUrl, token, name) {
|
|
11154
11708
|
if (!token) return {
|
|
11155
11709
|
project: "skipped",
|
|
11156
11710
|
reason: "not logged in"
|
|
@@ -11160,8 +11714,7 @@ async function createProject(apiUrl, token, environment, name) {
|
|
|
11160
11714
|
method: "POST",
|
|
11161
11715
|
headers: {
|
|
11162
11716
|
"content-type": "application/json",
|
|
11163
|
-
authorization: `Bearer ${token}
|
|
11164
|
-
[ENVIRONMENT_HEADER]: environment
|
|
11717
|
+
authorization: `Bearer ${token}`
|
|
11165
11718
|
},
|
|
11166
11719
|
body: JSON.stringify({ data: { name } })
|
|
11167
11720
|
});
|
|
@@ -11220,7 +11773,7 @@ function registerInit(program, env, io) {
|
|
|
11220
11773
|
}
|
|
11221
11774
|
const example = typeof opts.example === "string" ? opts.example : void 0;
|
|
11222
11775
|
const files = [...Object.keys(contents), ...example ? await copyExample(example, dir, false) : []].sort();
|
|
11223
|
-
const outcome = await createProject(resolveApiUrl(env, credentials, typeof merged.api === "string" ? merged.api : void 0), resolveCredential(env, credentials).token,
|
|
11776
|
+
const outcome = await createProject(resolveApiUrl(env, credentials, typeof merged.api === "string" ? merged.api : void 0), resolveCredential(env, credentials).token, name);
|
|
11224
11777
|
emit({ data: {
|
|
11225
11778
|
dir,
|
|
11226
11779
|
orgId,
|
|
@@ -11535,7 +12088,7 @@ async function pushProject({ client, projectDir }) {
|
|
|
11535
12088
|
push: (await client.request(contract.pushes["pushes.create"], { body: {
|
|
11536
12089
|
manifest: {
|
|
11537
12090
|
...manifest,
|
|
11538
|
-
protocol:
|
|
12091
|
+
protocol: 3
|
|
11539
12092
|
},
|
|
11540
12093
|
project: project.name
|
|
11541
12094
|
} })).data,
|
|
@@ -11634,13 +12187,10 @@ function versionPhrase(entry, now) {
|
|
|
11634
12187
|
return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
|
|
11635
12188
|
}
|
|
11636
12189
|
function flagsPhrase(entry) {
|
|
11637
|
-
|
|
11638
|
-
const word = (on) => on ? "on" : "off";
|
|
11639
|
-
return `${word(entry.enabled.development)} in development, ${word(entry.enabled.production)} in production`;
|
|
12190
|
+
return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
|
|
11640
12191
|
}
|
|
11641
12192
|
function livePhrase(entry) {
|
|
11642
|
-
|
|
11643
|
-
return [...running(entry.liveExecutions.development, "development"), ...running(entry.liveExecutions.production, "production")].join(", ");
|
|
12193
|
+
return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
|
|
11644
12194
|
}
|
|
11645
12195
|
function pad(text, width) {
|
|
11646
12196
|
return text.padEnd(width);
|
|
@@ -11690,11 +12240,9 @@ function statusLines(status, manifest, now) {
|
|
|
11690
12240
|
lines.push("", "On the server and not in this tree:");
|
|
11691
12241
|
for (const entry of missing) lines.push(` ${entry.key}, a ${entry.kind}. Delete it with cow ${entry.kind}s delete ${entry.key}.`);
|
|
11692
12242
|
}
|
|
11693
|
-
|
|
11694
|
-
|
|
11695
|
-
|
|
11696
|
-
lines.push("", `Names ${environment} does not define yet:`);
|
|
11697
|
-
for (const warning of warnings) lines.push(` ${warning}`);
|
|
12243
|
+
if (status.warnings.length > 0) {
|
|
12244
|
+
lines.push("", "Names this organization does not define yet:");
|
|
12245
|
+
for (const warning of status.warnings) lines.push(` ${warning}`);
|
|
11698
12246
|
}
|
|
11699
12247
|
return lines;
|
|
11700
12248
|
}
|
|
@@ -11889,7 +12437,7 @@ async function runScenario(projectDir, key, scenario) {
|
|
|
11889
12437
|
let error;
|
|
11890
12438
|
for (;;) {
|
|
11891
12439
|
const output = await runGuest(module, {
|
|
11892
|
-
protocol:
|
|
12440
|
+
protocol: 3,
|
|
11893
12441
|
kind: "journey",
|
|
11894
12442
|
key,
|
|
11895
12443
|
event: trigger,
|
|
@@ -11972,32 +12520,6 @@ function registerTest(program, io) {
|
|
|
11972
12520
|
|
|
11973
12521
|
//#endregion
|
|
11974
12522
|
//#region src/commands/index.ts
|
|
11975
|
-
/**
|
|
11976
|
-
* The environment a call acts on: the root `--env`, else `COW_ENVIRONMENT`,
|
|
11977
|
-
* else production. The client sets it as the selection header; a command
|
|
11978
|
-
* that also needs the value itself reads it from here, so the two can never
|
|
11979
|
-
* disagree.
|
|
11980
|
-
*/
|
|
11981
|
-
function environmentFor(env, opts) {
|
|
11982
|
-
const flag = environmentSchema.safeParse(opts.env);
|
|
11983
|
-
return flag.success ? flag.data : env.COW_ENVIRONMENT ?? "production";
|
|
11984
|
-
}
|
|
11985
|
-
/**
|
|
11986
|
-
* The same value, but only when the caller actually chose one. `cow enable`
|
|
11987
|
-
* and `cow disable` change what real recipients get, and the default is
|
|
11988
|
-
* production: a bare `cow enable welcome` typed while thinking about
|
|
11989
|
-
* development would turn it on in production. A pipeline is unaffected,
|
|
11990
|
-
* because `COW_ENVIRONMENT` counts as having chosen.
|
|
11991
|
-
*/
|
|
11992
|
-
function requireEnvironment(env, opts) {
|
|
11993
|
-
if (opts.env === void 0 && env.COW_ENVIRONMENT === void 0) throw new Error(`Name the environment to act on: --env ${ENVIRONMENTS.join(" or --env ")}, or set COW_ENVIRONMENT.`);
|
|
11994
|
-
return environmentFor(env, opts);
|
|
11995
|
-
}
|
|
11996
|
-
function parseEnvironment(value) {
|
|
11997
|
-
const parsed = environmentSchema.safeParse(value);
|
|
11998
|
-
if (!parsed.success) throw new InvalidArgumentError(`expected one of ${ENVIRONMENTS.join(", ")}`);
|
|
11999
|
-
return parsed.data;
|
|
12000
|
-
}
|
|
12001
12523
|
/** Every command prints the response envelope as JSON. */
|
|
12002
12524
|
function emit(result, io, json) {
|
|
12003
12525
|
const compact = json === true || !io.isTTY;
|
|
@@ -12005,7 +12527,7 @@ function emit(result, io, json) {
|
|
|
12005
12527
|
}
|
|
12006
12528
|
function buildProgram(env, io) {
|
|
12007
12529
|
const program = new Command();
|
|
12008
|
-
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("--
|
|
12530
|
+
program.name("cow").description("Admin CLI for Cowliss: a thin client of the dashboard API").option("--api <url>", "API base URL (overrides env and saved config)").option("--json", "force compact single-line JSON output").option("--config <file>", `project config to read instead of ${DEFAULT_CONFIG_FILE} (overrides COW_CONFIG)`);
|
|
12009
12531
|
program.hook("preAction", () => {
|
|
12010
12532
|
const flag = program.opts().config;
|
|
12011
12533
|
setConfigFile((typeof flag === "string" ? flag : void 0) ?? env.COW_CONFIG ?? "cow.json");
|
|
@@ -12015,10 +12537,7 @@ function buildProgram(env, io) {
|
|
|
12015
12537
|
return createClient({
|
|
12016
12538
|
baseUrl: resolveApiUrl(env, credentials, typeof opts.api === "string" ? opts.api : void 0, (await readCowConfig(process.cwd()))?.apiUrl),
|
|
12017
12539
|
auth: async () => resolveCredential(env, await readCredentials(env)).token,
|
|
12018
|
-
headers: async () => ({
|
|
12019
|
-
[ENVIRONMENT_HEADER]: environmentFor(env, opts),
|
|
12020
|
-
[CLIENT_HEADER]: `@cowliss/cli/${(await readCliPackage()).version}`
|
|
12021
|
-
})
|
|
12540
|
+
headers: async () => ({ [CLIENT_HEADER]: `@cowliss/cli/${(await readCliPackage()).version}` })
|
|
12022
12541
|
});
|
|
12023
12542
|
};
|
|
12024
12543
|
const run = async (opts, fn) => {
|
|
@@ -12035,7 +12554,7 @@ function buildProgram(env, io) {
|
|
|
12035
12554
|
registerBuild(program, io);
|
|
12036
12555
|
registerPush(program, clientFor, env, io);
|
|
12037
12556
|
registerStatus(program, clientFor, io);
|
|
12038
|
-
registerEnable(program, clientFor,
|
|
12557
|
+
registerEnable(program, clientFor, io);
|
|
12039
12558
|
registerPull(program, clientFor, io);
|
|
12040
12559
|
registerTest(program, io);
|
|
12041
12560
|
registerMcp(program, clientFor, env, io);
|
|
@@ -12056,8 +12575,6 @@ const envSchema = z.object({
|
|
|
12056
12575
|
COW_TOKEN: z.string().min(1).optional(),
|
|
12057
12576
|
/** An org pipeline key for CI; when set it wins over the cached session (ticket 05 reads it). */
|
|
12058
12577
|
COW_PIPELINE_KEY: z.string().min(1).optional(),
|
|
12059
|
-
/** The environment admin calls select; `--env` overrides it, production is the default. */
|
|
12060
|
-
COW_ENVIRONMENT: environmentSchema.optional(),
|
|
12061
12578
|
/**
|
|
12062
12579
|
* The project config to read instead of `cow.json`, so one checkout can
|
|
12063
12580
|
* hold both the committed production config and a local override.
|