@pithy-sh/testers 0.1.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/LICENSE +21 -0
- package/README.md +17 -0
- package/docs/store-apis.md +107 -0
- package/package.json +62 -0
- package/pithy.manifest.json +52 -0
- package/src/activity/resolve.ts +273 -0
- package/src/audit/actions.ts +56 -0
- package/src/capability.ts +128 -0
- package/src/clock/days.ts +70 -0
- package/src/clock/replay.ts +190 -0
- package/src/cloudflare-test.d.ts +13 -0
- package/src/config/config.ts +518 -0
- package/src/crypto/token.ts +60 -0
- package/src/data/cohort.ts +83 -0
- package/src/data/enums.ts +134 -0
- package/src/data/event.ts +81 -0
- package/src/data/member.ts +79 -0
- package/src/data/snapshot.ts +280 -0
- package/src/data/tables.ts +49 -0
- package/src/error/errors.ts +229 -0
- package/src/health/score.ts +225 -0
- package/src/http/guards.ts +37 -0
- package/src/http/pages.ts +66 -0
- package/src/http/responses.ts +634 -0
- package/src/http/routes.ts +933 -0
- package/src/http/schemas.ts +210 -0
- package/src/http/scopes.ts +79 -0
- package/src/http/view.ts +304 -0
- package/src/index.ts +80 -0
- package/src/migrations/0001_cohorts.ts +202 -0
- package/src/nudge/cooldown.ts +104 -0
- package/src/nudge/copy.ts +179 -0
- package/src/nudge/enqueueSeam.ts +95 -0
- package/src/nudge/send.ts +89 -0
- package/src/projection/build.ts +285 -0
- package/src/projection/forecast.ts +348 -0
- package/src/projection/inputs.ts +63 -0
- package/src/projection/poissonBinomial.ts +91 -0
- package/src/projection/trend.ts +185 -0
- package/src/provision/provisionTesters.ts +109 -0
- package/src/provision/resolveTestersConfig.ts +155 -0
- package/src/roster/read.ts +227 -0
- package/src/roster/write.ts +511 -0
- package/src/seeds/example.ts +219 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/daily.ts +513 -0
- package/src/workflows/pass.ts +100 -0
- package/src/workflows/report.ts +52 -0
- package/src/workflows/retryPolicy.ts +48 -0
- package/src/workflows/specs.ts +73 -0
- package/src/workflows/worker.ts +132 -0
- package/src/workflows/wrangler.jsonc +66 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **What the daily pass retries, and what it refuses to.**
|
|
8
|
+
*
|
|
9
|
+
* The pass reads activity, advances state, writes one snapshot per cohort, and enqueues nudges. All of
|
|
10
|
+
* that is D1: a nudge is a row in `pithy_email_jobs`, and the send Workflow it dispatches afterwards is
|
|
11
|
+
* the email capability's problem rather than this one's. So there is no `testers/*` code a second
|
|
12
|
+
* attempt can answer differently, and the record is empty on purpose (pithy-sh/pithy#348).
|
|
13
|
+
*
|
|
14
|
+
* **An empty record is a statement, not an omission.** Core answers for D1's transient vocabulary
|
|
15
|
+
* through `withD1Retry` — busy, timed out, connection lost, storage reset, internal — so a database
|
|
16
|
+
* under contention is still re-driven, and nothing about that is restated here. What the empty record
|
|
17
|
+
* adds is that testers retries none of its own codes.
|
|
18
|
+
*
|
|
19
|
+
* ## Terminal, and why
|
|
20
|
+
*
|
|
21
|
+
* - **`testers/cohort_closed`** — the pass refusing to send from a finished program. That is the
|
|
22
|
+
* refusal *working*: a closed cohort keeps its history and sends nothing further, and it does not
|
|
23
|
+
* reopen because the step asked again.
|
|
24
|
+
* - **`testers/cohort_not_found`, `testers/member_not_found`** — a cohort or member deleted between the
|
|
25
|
+
* enumeration step and the cohort's own step. Deletion is not undone by a backoff, and this is exactly
|
|
26
|
+
* the case one-step-per-cohort exists to contain: the missing cohort loses its snapshot, the other
|
|
27
|
+
* cohorts keep theirs.
|
|
28
|
+
* - **`testers/not_configured`** — no sending identity, no base URL. Config, and identical next time.
|
|
29
|
+
* - **`testers/roster_full`, `testers/already_on_roster`, `testers/withdrawn`, `testers/invalid_token`,
|
|
30
|
+
* `testers/nudge_cooldown`, `testers/copy_not_allowed`** — every one belongs to a control-plane
|
|
31
|
+
* request or a tester's own click. The pass raises none of them.
|
|
32
|
+
* - **`validation/invalid_input`** — a `TestersDailyParams` an operator dispatched by hand.
|
|
33
|
+
*
|
|
34
|
+
* **The cron is the outer retry, and the pass is contained per cohort.** A cohort whose step fails loses
|
|
35
|
+
* its own day rather than everyone's, and the day after that runs again. Five platform attempts against
|
|
36
|
+
* a closed cohort would spend the pass's budget re-asking a question whose answer is a person's or an
|
|
37
|
+
* adopter's decision.
|
|
38
|
+
*
|
|
39
|
+
* **What this cannot say.** A `EMAIL_SENDER.create` that fails throws whatever the Workflows binding
|
|
40
|
+
* threw — not a `PithyError`, so `unclassified`, so terminal. The job row is already written and
|
|
41
|
+
* `pending`, and the email scheduler's grace re-drive claims a `pending` job whose dispatch died, so the
|
|
42
|
+
* nudge is not lost; it is late. That is the correct outcome, but it is a default rather than a decision
|
|
43
|
+
* and no policy record can turn it into one.
|
|
44
|
+
*/
|
|
45
|
+
export const testersWorkflowRetry: WorkflowRetryPolicy = {
|
|
46
|
+
capability: "testers",
|
|
47
|
+
retryable: {},
|
|
48
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
|
|
5
|
+
import type { WorkflowRegistry, WorkflowSpecMap } from "@pithy-sh/core/src/workflow/spec";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/** This capability's name, and the first segment of every workflow dispatch key. */
|
|
9
|
+
export const TESTERS_CAPABILITY = "testers";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The daily pass's parameters.
|
|
13
|
+
*
|
|
14
|
+
* **Every field is optional, which is a requirement rather than a convenience.** A cron supplies no
|
|
15
|
+
* input, and `createEntrypoint` dispatches a scheduled job with `{}` — so a schema demanding a field
|
|
16
|
+
* could never run on its own schedule. The fields exist for the on-demand case: running one cohort in
|
|
17
|
+
* staging, or checking what a pass would do before letting it mail twelve people.
|
|
18
|
+
*/
|
|
19
|
+
export const TestersDailyParams = z
|
|
20
|
+
.object({
|
|
21
|
+
cohortId: z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Run the pass for one cohort only. Omit for every open cohort, which is what the cron does."),
|
|
26
|
+
skipNudges: z
|
|
27
|
+
.boolean()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe(
|
|
30
|
+
"Advance state and write the snapshot, but send nothing. The safe way to exercise the pass in staging against real testers.",
|
|
31
|
+
),
|
|
32
|
+
})
|
|
33
|
+
.describe("What one daily pass should do. Every field optional, because a cron passes none of them.");
|
|
34
|
+
export type TestersDailyParams = z.infer<typeof TestersDailyParams>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The one durable job this capability owns.
|
|
38
|
+
*
|
|
39
|
+
* **Daily at 05:00 UTC**, offset from storage's 03:00 sweep, the secrets rotation at 03:00, and
|
|
40
|
+
* payments' 04:00 reconciliation, so four hosts in one account do not contend for the same minute.
|
|
41
|
+
* Daily is the right cadence because the thing being measured is a day counter: an hourly pass would
|
|
42
|
+
* write the same row twenty-four times and change the answer on none of them.
|
|
43
|
+
*
|
|
44
|
+
* It is a cron **and** a dispatch target. A pass nobody can run on demand cannot be tested in staging,
|
|
45
|
+
* which is precisely when a developer wants to know what it will send.
|
|
46
|
+
*
|
|
47
|
+
* `optional: true` because the binding exists only once `pithy testers provision` has deployed the
|
|
48
|
+
* host, and a project that has not provisioned it must still be able to invite testers, accept
|
|
49
|
+
* confirmations, and read its cohorts.
|
|
50
|
+
*/
|
|
51
|
+
export const testersWorkflows = {
|
|
52
|
+
daily: {
|
|
53
|
+
binding: "TESTERS_DAILY",
|
|
54
|
+
className: "TestersDailyWorkflow",
|
|
55
|
+
params: TestersDailyParams,
|
|
56
|
+
schedule: "0 5 * * *",
|
|
57
|
+
optional: true,
|
|
58
|
+
},
|
|
59
|
+
} as const satisfies WorkflowSpecMap;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The jobs as a dispatch registry, keyed `testers/<job>`.
|
|
63
|
+
*
|
|
64
|
+
* Built here rather than through `composeWorkflows` because the host worker dispatches its own job
|
|
65
|
+
* before any project-wide registry exists — and the key format comes from core's `workflowKey` either
|
|
66
|
+
* way, so the two cannot drift.
|
|
67
|
+
*/
|
|
68
|
+
export const testersWorkflowRegistry: WorkflowRegistry = Object.fromEntries(
|
|
69
|
+
Object.entries(testersWorkflows).map(([job, spec]) => {
|
|
70
|
+
const key = workflowKey(TESTERS_CAPABILITY, job);
|
|
71
|
+
return [key, { key, capability: TESTERS_CAPABILITY, job, spec }];
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
|
|
5
|
+
import { NonRetryableError } from "cloudflare:workflows";
|
|
6
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
7
|
+
import { bindWorkflowContext, createWorkerLogger } from "@pithy-sh/core/src/logger/worker";
|
|
8
|
+
import { triggerWorkflow } from "@pithy-sh/core/src/workflow/dispatch";
|
|
9
|
+
import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
|
|
10
|
+
import { confirmUrl, optInUrl, optOutUrl, TestersConfig } from "../config/config";
|
|
11
|
+
import type { NudgeKind } from "../data/enums";
|
|
12
|
+
import type { TestersMember } from "../data/member";
|
|
13
|
+
import { testersDatabase } from "../data/tables";
|
|
14
|
+
import { buildNudgeEnqueue, type NudgeEnqueueEnv } from "../nudge/enqueueSeam";
|
|
15
|
+
import type { EnqueueNudge } from "../nudge/send";
|
|
16
|
+
import type { CohortPassResult } from "./daily";
|
|
17
|
+
import { runDurableDailyPass } from "./pass";
|
|
18
|
+
import { testersWorkflowRetry } from "./retryPolicy";
|
|
19
|
+
import { TESTERS_CAPABILITY, TestersDailyParams, testersWorkflowRegistry } from "./specs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The prebuilt worker hosting the daily pass.
|
|
23
|
+
*
|
|
24
|
+
* **Why its own worker rather than the app worker's `scheduled()`.** The pass can mail every tester on
|
|
25
|
+
* every roster. Keeping it out of the request-serving deployment means it cannot compete with a
|
|
26
|
+
* tester's confirmation click for CPU, and — more to the point — the app worker never needs a binding
|
|
27
|
+
* that can send mail to an entire roster on a timer. It is a Workflow rather than a plain scheduled
|
|
28
|
+
* handler because a Worker invocation is wall-clock bounded while a Workflow step is not: several
|
|
29
|
+
* cohorts of a hundred testers each is a job you want checkpointed rather than restarted.
|
|
30
|
+
*
|
|
31
|
+
* This is the only module in the package that imports `cloudflare:workers`, which is why the
|
|
32
|
+
* schema-description meta-test excludes it — the import is unresolvable outside the Workers runtime.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The bindings and vars this worker's env carries, filled by `pithy testers provision`.
|
|
37
|
+
*
|
|
38
|
+
* The sending identity and the send binding are carried as vars rather than guessed, because this
|
|
39
|
+
* worker is standalone: it is not assembled by `createBackend`, so the email capability's `compose`
|
|
40
|
+
* hook never runs here and there is no bound `enqueue` seam to borrow. A default would be worse than an
|
|
41
|
+
* absent value — mail from the wrong domain fails DKIM and lands the adopter's testers in spam, which is
|
|
42
|
+
* exactly the outcome this capability exists to avoid. They are declared on {@link NudgeEnqueueEnv},
|
|
43
|
+
* beside the seam that reads them.
|
|
44
|
+
*/
|
|
45
|
+
export interface TestersWorkerEnv extends NudgeEnqueueEnv {
|
|
46
|
+
DB: D1Database;
|
|
47
|
+
TESTERS_CONFIG?: string;
|
|
48
|
+
ENVIRONMENT?: string;
|
|
49
|
+
/** The global email-suppression database, for reconciling which addresses have bounced. */
|
|
50
|
+
EMAIL_SUPPRESSIONS?: D1Database;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The daily pass over every open cohort. */
|
|
54
|
+
export class TestersDailyWorkflow extends WorkflowEntrypoint<TestersWorkerEnv, TestersDailyParams> {
|
|
55
|
+
override async run(event: WorkflowEvent<TestersDailyParams>, step: WorkflowStep): Promise<CohortPassResult[]> {
|
|
56
|
+
// Parse rather than trust: an instance can be started by the cron, by `c.var.workflows.trigger`, or
|
|
57
|
+
// by an operator through the dashboard, and only the first two have already been validated.
|
|
58
|
+
const params = TestersDailyParams.parse(event.payload ?? {});
|
|
59
|
+
const config = TestersConfig.parse(this.env.TESTERS_CONFIG ? JSON.parse(this.env.TESTERS_CONFIG) : {});
|
|
60
|
+
|
|
61
|
+
// A run has no request, so there is no `c.var.log` to inherit: the Workflow builds its own and binds
|
|
62
|
+
// the instance onto it. That id is what the dashboard and `wrangler workflows` key on, so binding it
|
|
63
|
+
// once here is what lets an operator read a whole pass — the tally and every failed cohort — as one
|
|
64
|
+
// correlated set. It is handed to the pass too, so a suppression list it could not read says so
|
|
65
|
+
// against the same instance.
|
|
66
|
+
const log = bindWorkflowContext(createWorkerLogger({ name: `${TESTERS_CAPABILITY}:daily` }), {
|
|
67
|
+
workflow: event.workflowName,
|
|
68
|
+
instance: event.instanceId,
|
|
69
|
+
env: this.env.ENVIRONMENT ?? "unknown",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Built with the default clock, which the seam reads **per nudge**. This is the pass's other clock
|
|
73
|
+
// and it is the opposite of the journalled one: see `enqueueSeam.ts`.
|
|
74
|
+
const enqueue: EnqueueNudge | undefined = params.skipNudges ? undefined : await buildNudgeEnqueue(this.env);
|
|
75
|
+
const linkForKind = params.skipNudges ? undefined : linkFor(config);
|
|
76
|
+
|
|
77
|
+
return runDurableDailyPass(
|
|
78
|
+
{
|
|
79
|
+
db: testersDatabase(this.env.DB),
|
|
80
|
+
d1: this.env.DB,
|
|
81
|
+
config,
|
|
82
|
+
newId: () => crypto.randomUUID(),
|
|
83
|
+
log,
|
|
84
|
+
enqueue,
|
|
85
|
+
linkFor: linkForKind,
|
|
86
|
+
optOutLinkFor: (member: TestersMember) => optOutUrl(config, member.optInToken),
|
|
87
|
+
suppressionD1: this.env.EMAIL_SUPPRESSIONS,
|
|
88
|
+
},
|
|
89
|
+
// Under `testersWorkflowRetry`, whose record is empty and says so: the pass is D1, core answers
|
|
90
|
+
// for D1, and a closed cohort or a deleted member is a decision rather than an outage. Contained
|
|
91
|
+
// per cohort already, so a terminal fault loses one cohort's day, not everyone's.
|
|
92
|
+
classifiedSteps(step, testersWorkflowRetry, NonRetryableError),
|
|
93
|
+
params,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the link a nudge carries.
|
|
100
|
+
*
|
|
101
|
+
* No secret, no minting, no failure mode: the token lives on the member row, so this is string
|
|
102
|
+
* concatenation. That is the whole reason the token stopped being a signature.
|
|
103
|
+
*/
|
|
104
|
+
function linkFor(config: TestersConfig): (kind: NudgeKind, member: TestersMember) => string | undefined {
|
|
105
|
+
return (kind, member) => {
|
|
106
|
+
if (kind === "confirm") return confirmUrl(config, member.optInToken);
|
|
107
|
+
if (kind === "store") return optInUrl(config, member.optInToken);
|
|
108
|
+
return undefined;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default {
|
|
113
|
+
/**
|
|
114
|
+
* Cron entry: start one pass per fire, with empty parameters — the defaults are the scheduled
|
|
115
|
+
* behavior.
|
|
116
|
+
*
|
|
117
|
+
* Through the same dispatcher an on-demand pass uses, rather than `env.TESTERS_DAILY.create()`: the
|
|
118
|
+
* parameters are validated against the job's own schema before the binding is touched, and a host
|
|
119
|
+
* deployed with the binding absent or renamed logs a skip instead of throwing inside a cron nobody is
|
|
120
|
+
* watching. A daily job that fires, does nothing, and says nothing is the one failure mode this pass
|
|
121
|
+
* cannot afford — because silence is also what success looks like.
|
|
122
|
+
*/
|
|
123
|
+
async scheduled(_controller: unknown, env: TestersWorkerEnv): Promise<void> {
|
|
124
|
+
await triggerWorkflow(
|
|
125
|
+
env as unknown as Record<string, unknown>,
|
|
126
|
+
testersWorkflowRegistry,
|
|
127
|
+
"testers/daily",
|
|
128
|
+
{},
|
|
129
|
+
createWorkerLogger(),
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The prebuilt testers daily-pass worker. Like the email, media, storage and payments workers, this is
|
|
3
|
+
// a TEMPLATE rather than a wrangler env-stanza file: staging and prod are genuinely separate
|
|
4
|
+
// workers. `pithy testers provision` resolves it into one complete config per environment — filling
|
|
5
|
+
// the `<...>` placeholders and deriving the `workflows` array and the cron from the capability's
|
|
6
|
+
// specs — and deploys each with `wrangler deploy --config <resolved>`. The adopter authors none of it.
|
|
7
|
+
// Resolved per project and env → <project>-staging-testers / <project>-prod-testers. Worker
|
|
8
|
+
// script names are account-scoped, so the project segment is what stops a second Pithy project's
|
|
9
|
+
// deploy overwriting this one's running worker instead of colliding with it.
|
|
10
|
+
"name": "pithy-testers",
|
|
11
|
+
"main": "./worker.ts",
|
|
12
|
+
// The compatibility date every Worker in this repository runs on. Stated once in the repository
|
|
13
|
+
// root's `compatibility.ts` and copied here because JSONC cannot import it —
|
|
14
|
+
// `cli/src/ci/compatibilityDates.test.ts` fails on any Worker older than it.
|
|
15
|
+
"compatibility_date": "2026-06-01",
|
|
16
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
17
|
+
|
|
18
|
+
// No public URL. Every testers route lives in the app worker; this one is reached only by its cron and
|
|
19
|
+
// by Workflow dispatch. It can mail every tester on every roster, so it stays off workers.dev.
|
|
20
|
+
"workers_dev": false,
|
|
21
|
+
|
|
22
|
+
// Two databases and no more. The app database holds the pithy_testers_* tables, the pithy_auth_*
|
|
23
|
+
// tables the activity reader joins against, and the pithy_email_jobs rows a nudge becomes. The
|
|
24
|
+
// suppression database is shared across the project's environments rather than per environment — an
|
|
25
|
+
// unsubscribe in prod must stop staging too — and the daily pass reads it to reconcile which
|
|
26
|
+
// addresses have bounced. Its name resolves to <project>-global-email-suppressions at provision.
|
|
27
|
+
//
|
|
28
|
+
// No SECRETS binding and no master key: the confirmation token is a random value on the tester's own
|
|
29
|
+
// row rather than a signature, so this worker reads no secret at all. That is the whole reason
|
|
30
|
+
// `pithy testers invite` works against any environment.
|
|
31
|
+
"d1_databases": [
|
|
32
|
+
{ "binding": "DB", "database_name": "pithy-app", "database_id": "<filled-at-provision>" },
|
|
33
|
+
{
|
|
34
|
+
"binding": "EMAIL_SUPPRESSIONS",
|
|
35
|
+
"database_name": "pithy-email-suppressions",
|
|
36
|
+
"database_id": "<filled-at-provision>"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
|
|
40
|
+
// The one Workflow this worker hosts. Rewritten at provision from `workflows/specs.ts`, so the binding,
|
|
41
|
+
// the class name, and the per-environment deployed name come from the spec rather than from this block —
|
|
42
|
+
// it is here so the template reads as a complete config.
|
|
43
|
+
"workflows": [{ "binding": "TESTERS_DAILY", "name": "pithy-testers-daily", "class_name": "TestersDailyWorkflow" }],
|
|
44
|
+
|
|
45
|
+
// The daily pass, at 05:00 UTC. Also rewritten at provision from the spec's `schedule`. Offset from
|
|
46
|
+
// storage's 03:00 sweep, the secrets rotation at 03:00, and payments' 04:00 reconciliation, so four
|
|
47
|
+
// hosts in one account do not contend for the same minute.
|
|
48
|
+
"triggers": { "crons": ["0 5 * * *"] },
|
|
49
|
+
|
|
50
|
+
"vars": {
|
|
51
|
+
// The resolved TestersConfig as one JSON blob, filled at provision from the app's testers() config.
|
|
52
|
+
// The pass reads the cooldown, the survival priors, the health weights, and the base URL from it.
|
|
53
|
+
"TESTERS_CONFIG": "<filled-at-provision>",
|
|
54
|
+
|
|
55
|
+
// The sending identity, copied from the email capability's resolved config. Carried as vars because
|
|
56
|
+
// this worker is standalone — it is not assembled by `createBackend`, so email's `compose` hook
|
|
57
|
+
// never runs here and there is no bound `enqueue` seam to borrow. Absent means the pass advances
|
|
58
|
+
// state and records the day but sends nothing, which is better than mailing an adopter's testers
|
|
59
|
+
// from a domain their DKIM does not cover.
|
|
60
|
+
"EMAIL_FROM_ADDRESS": "<filled-at-provision>",
|
|
61
|
+
"EMAIL_FROM_NAME": "<filled-at-provision>",
|
|
62
|
+
"EMAIL_THEME": "<filled-at-provision>",
|
|
63
|
+
|
|
64
|
+
"ENVIRONMENT": "<filled-at-provision>"
|
|
65
|
+
}
|
|
66
|
+
}
|