@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/docs/store-apis.md +107 -0
  4. package/package.json +62 -0
  5. package/pithy.manifest.json +52 -0
  6. package/src/activity/resolve.ts +273 -0
  7. package/src/audit/actions.ts +56 -0
  8. package/src/capability.ts +128 -0
  9. package/src/clock/days.ts +70 -0
  10. package/src/clock/replay.ts +190 -0
  11. package/src/cloudflare-test.d.ts +13 -0
  12. package/src/config/config.ts +518 -0
  13. package/src/crypto/token.ts +60 -0
  14. package/src/data/cohort.ts +83 -0
  15. package/src/data/enums.ts +134 -0
  16. package/src/data/event.ts +81 -0
  17. package/src/data/member.ts +79 -0
  18. package/src/data/snapshot.ts +280 -0
  19. package/src/data/tables.ts +49 -0
  20. package/src/error/errors.ts +229 -0
  21. package/src/health/score.ts +225 -0
  22. package/src/http/guards.ts +37 -0
  23. package/src/http/pages.ts +66 -0
  24. package/src/http/responses.ts +634 -0
  25. package/src/http/routes.ts +933 -0
  26. package/src/http/schemas.ts +210 -0
  27. package/src/http/scopes.ts +79 -0
  28. package/src/http/view.ts +304 -0
  29. package/src/index.ts +80 -0
  30. package/src/migrations/0001_cohorts.ts +202 -0
  31. package/src/nudge/cooldown.ts +104 -0
  32. package/src/nudge/copy.ts +179 -0
  33. package/src/nudge/enqueueSeam.ts +95 -0
  34. package/src/nudge/send.ts +89 -0
  35. package/src/projection/build.ts +285 -0
  36. package/src/projection/forecast.ts +348 -0
  37. package/src/projection/inputs.ts +63 -0
  38. package/src/projection/poissonBinomial.ts +91 -0
  39. package/src/projection/trend.ts +185 -0
  40. package/src/provision/provisionTesters.ts +109 -0
  41. package/src/provision/resolveTestersConfig.ts +155 -0
  42. package/src/roster/read.ts +227 -0
  43. package/src/roster/write.ts +511 -0
  44. package/src/seeds/example.ts +219 -0
  45. package/src/version.generated.ts +16 -0
  46. package/src/workflows/daily.ts +513 -0
  47. package/src/workflows/pass.ts +100 -0
  48. package/src/workflows/report.ts +52 -0
  49. package/src/workflows/retryPolicy.ts +48 -0
  50. package/src/workflows/specs.ts +73 -0
  51. package/src/workflows/worker.ts +132 -0
  52. 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
+ }