@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,933 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { zValidator } from "@hono/zod-validator";
6
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
7
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
8
+ import type { ControlPlaneContext } from "@pithy-sh/core/src/controlPlane/context";
9
+ import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
10
+ import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
11
+ import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
12
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
13
+ import type { VerificationStrategy } from "@pithy-sh/core/src/http/verification";
14
+ import type { Context, Hono } from "hono";
15
+ import { TestersAuditActions } from "../audit/actions";
16
+ import { confirmUrl, isStoreOptInUrl, optInUrl, optOutUrl, type TestersConfig } from "../config/config";
17
+ import type { TestersCohort } from "../data/cohort";
18
+ import type { NudgeKind } from "../data/enums";
19
+ import type { TestersMember } from "../data/member";
20
+ import { type TestersDatabase, testersDatabase } from "../data/tables";
21
+ import {
22
+ TestersCohortClosedError,
23
+ TestersCopyNotAllowedError,
24
+ TestersInvalidTokenError,
25
+ TestersMemberNotFoundError,
26
+ TestersNotConfiguredError,
27
+ TestersNudgeCooldownError,
28
+ } from "../error/errors";
29
+ import { chasedOut, mayNudge, splitByCooldown } from "../nudge/cooldown";
30
+ import { type EnqueueNudge, sendNudge } from "../nudge/send";
31
+ import { listCohorts, listMembers, listSnapshots, readCohort, requireCohort } from "../roster/read";
32
+ import {
33
+ confirmOptIn,
34
+ findMemberByToken,
35
+ inviteMember,
36
+ LIVE_STATES,
37
+ lapseMember,
38
+ recordAccepted,
39
+ recordNudge,
40
+ removeMember,
41
+ requireMember,
42
+ resendInvite,
43
+ type WriteDeps,
44
+ } from "../roster/write";
45
+ import { requireAuth } from "./guards";
46
+ import { confirmOptOutPage, page, storePage } from "./pages";
47
+ import {
48
+ type CohortsResponse,
49
+ DISCLAIMER,
50
+ type InviteResponse,
51
+ type MembershipsResponse,
52
+ type MembershipView,
53
+ type NudgeDryRunResponse,
54
+ type NudgeResponse,
55
+ type RemoveResponse,
56
+ type ResendResponse,
57
+ } from "./responses";
58
+ import {
59
+ CohortsQuery,
60
+ InviteRequest,
61
+ NudgeRequest,
62
+ OptInTokenParam,
63
+ RemoveRequest,
64
+ ResendRequest,
65
+ StatusQuery,
66
+ } from "./schemas";
67
+ import { TESTERS_NUDGE_SEND_SCOPE, TESTERS_ROSTER_READ_SCOPE, TESTERS_ROSTER_WRITE_SCOPE } from "./scopes";
68
+ import { toCohortView } from "./view";
69
+
70
+ /**
71
+ * The testers routes, their declared verification strategies, and what each accepts.
72
+ *
73
+ * GET /testers/confirm/:token → "yes, I will test" (public) param: OptInTokenParam
74
+ * GET /testers/opt-in/:token → go join the test at the store (public) param: OptInTokenParam
75
+ * GET /testers/opt-out/:token → withdraw from a cohort (public) param: OptInTokenParam
76
+ * GET /testers/status → a tester's own position (bearer|session) query: StatusQuery
77
+ * GET /testers/cohorts → cohort state for a dashboard (control-plane) query: CohortsQuery
78
+ * POST /testers/invite → add an address to a roster (control-plane) json: InviteRequest
79
+ * POST /testers/resend → send another invitation (control-plane) json: ResendRequest
80
+ * POST /testers/remove → take a tester off a roster (control-plane) json: RemoveRequest
81
+ * POST /testers/nudge → mail selected testers (control-plane) json: NudgeRequest
82
+ *
83
+ * **The tester's journey is two links, and the order is forced by the store rather than chosen.** A
84
+ * store opt-in page only works once the developer has added that address to the tester list, and no API
85
+ * can do that — it is a manual step in the console. So `/confirm` asks whether they will help and
86
+ * records their answer; the developer then adds the confirmed addresses; and `/opt-in` follows, which
87
+ * records the strongest enrollment signal we can observe and renders the store's own link for them.
88
+ * Sending the store link first produces `App not available`, which reads to a tester as a broken app.
89
+ *
90
+ * **Three routes are `public`, and all of them are deliberate.** A tester must be able to confirm or withdraw
91
+ * from an email, on a phone, with no account — requiring a sign-in for the confirmation would mean the
92
+ * one event the whole opt-in count rests on happened only for the subset of testers willing to create
93
+ * an account first. Each is single-purpose and idempotent, and the token is the only gate — a random
94
+ * value on the tester's own row, not a signature, which is what makes removing a tester a genuine
95
+ * revocation rather than a wait for an expiry. A token nobody holds fails with the same words as an
96
+ * expired one and as a withdrawn member's, so no route
97
+ * is an oracle for which cohorts or testers exist.
98
+ *
99
+ * **Validators sit after the guards on every route line.** A validator ahead of a gate turns a 401 into
100
+ * a 400 and tells an unauthenticated caller which requests were well-formed — on the control-plane
101
+ * routes that is a live probe of the roster's shape.
102
+ */
103
+
104
+ /**
105
+ * What every route this capability mounts declares: its path, its verification strategy, and the
106
+ * control-plane scope it checks when it has one.
107
+ *
108
+ * **Exported so a test can assert against the declaration rather than against a middleware count.** The
109
+ * previous gate counted entries in `app.routes` and called anything above one "guarded" — which a bare
110
+ * `zValidator` satisfies, so a control-plane route that lost its `requireControlPlane` would still have
111
+ * passed. Counting proves that *something* runs before the handler; it cannot prove *what*.
112
+ *
113
+ * This is a declaration rather than an inference, so it can drift from the router. `routeContract.test.ts`
114
+ * is what stops it: it checks this list against the paths Hono actually registered in both directions,
115
+ * so a route added without an entry and an entry without a route both fail.
116
+ */
117
+ export interface TestersRouteDeclaration {
118
+ readonly method: "GET" | "POST";
119
+ /** The path relative to the configured `basePath`, e.g. `/cohorts`. */
120
+ readonly path: string;
121
+ readonly strategy: VerificationStrategy;
122
+ /** The control-plane scope this route checks, for a `control-plane` route. */
123
+ readonly scope?: ControlPlaneScope;
124
+ }
125
+
126
+ /**
127
+ * Every route, and how it is gated.
128
+ *
129
+ * The three `public` entries are the tester's own links. They carry no scope and no auth because the
130
+ * token in the path is the entire credential — a tester confirming from an email has no account and
131
+ * must not need one.
132
+ */
133
+ export const TESTERS_ROUTES: readonly TestersRouteDeclaration[] = [
134
+ { method: "GET", path: "/confirm/:token", strategy: "public" },
135
+ { method: "GET", path: "/opt-in/:token", strategy: "public" },
136
+ { method: "GET", path: "/opt-out/:token", strategy: "public" },
137
+ { method: "POST", path: "/opt-out/:token", strategy: "public" },
138
+ { method: "GET", path: "/status", strategy: "bearer" },
139
+ { method: "GET", path: "/cohorts", strategy: "control-plane", scope: TESTERS_ROSTER_READ_SCOPE },
140
+ { method: "POST", path: "/invite", strategy: "control-plane", scope: TESTERS_ROSTER_WRITE_SCOPE },
141
+ { method: "POST", path: "/resend", strategy: "control-plane", scope: TESTERS_ROSTER_WRITE_SCOPE },
142
+ { method: "POST", path: "/remove", strategy: "control-plane", scope: TESTERS_ROSTER_WRITE_SCOPE },
143
+ { method: "POST", path: "/nudge", strategy: "control-plane", scope: TESTERS_NUDGE_SEND_SCOPE },
144
+ ];
145
+
146
+ /** How many testers one nudge request may mail. Beyond this, the caller is running a campaign. */
147
+ const MAX_NUDGE_BATCH = 200;
148
+
149
+ /** Options `registerTestersRoutes` takes. */
150
+ export interface TestersRoutesOptions {
151
+ /** The resolved configuration. */
152
+ config: TestersConfig;
153
+ /** Where the routes mount. Defaults to the config's own `basePath`. */
154
+ basePath?: string;
155
+ /** The clock. Injected so link expiries and stored timestamps are deterministic in tests. */
156
+ now?: () => Date;
157
+ /** The id source. Injected for the same reason. */
158
+ newId?: () => string;
159
+ /**
160
+ * The email enqueue seam.
161
+ *
162
+ * Takes the request env rather than being pre-bound, because a Worker's `env` is per-request: a seam
163
+ * captured at registration would close over whichever request happened to assemble the routes.
164
+ */
165
+ enqueue?: (env: Record<string, unknown>) => EnqueueNudge | undefined;
166
+ }
167
+
168
+ /**
169
+ * Which link, if any, a nudge kind carries.
170
+ *
171
+ * `confirm` asks whether they will test; `store` sends them on once the developer has added them to the
172
+ * tester list. `inactive` and `closing` carry none — inviting a tester who already joined to join again
173
+ * reads as a mistake, and a message about engagement is not a place for a call to action.
174
+ */
175
+ function nudgeLink(config: TestersConfig, kind: NudgeKind, member: TestersMember): string | undefined {
176
+ if (kind === "confirm") return confirmUrl(config, member.optInToken);
177
+ if (kind === "store") return optInUrl(config, member.optInToken);
178
+ return undefined;
179
+ }
180
+
181
+ /** The app `DB` binding, or a stated wiring failure. */
182
+ function database(c: Context<PithyHonoEnv>): D1Database {
183
+ const binding = (c.env as Record<string, unknown>).DB as D1Database | undefined;
184
+ if (!binding) {
185
+ throw new TestersNotConfiguredError({
186
+ message: "Testers is not configured.",
187
+ action: "Bind a D1 database named DB in wrangler.jsonc, then run pithy migrate.",
188
+ detail: "the testers routes require a `DB` D1 binding; none was present on env",
189
+ });
190
+ }
191
+ return binding;
192
+ }
193
+
194
+ /**
195
+ * The caller's own id. `requireAuth()` has run on every route that calls this, so a null `auth` is a
196
+ * wiring mistake rather than an unauthenticated request — hence an internal error, not a 401.
197
+ */
198
+ function callerId(c: Context<PithyHonoEnv>): string {
199
+ const auth = c.var.auth;
200
+ if (!auth) throw new InternalError({ detail: "requireAuth() must run before a testers handler reads the caller." });
201
+ return auth.userId;
202
+ }
203
+
204
+ /** The management caller. `requireControlPlane()` has run, so a null here is likewise a wiring mistake. */
205
+ function controlPlaneCaller(c: Context<PithyHonoEnv>): ControlPlaneContext {
206
+ const caller = c.var.controlPlane;
207
+ if (!caller) {
208
+ throw new InternalError({ detail: "requireControlPlane() must run before a testers handler reads the caller." });
209
+ }
210
+ return caller;
211
+ }
212
+
213
+ /** Register the testers routes. */
214
+ export function registerTestersRoutes(options: TestersRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
215
+ const config = options.config;
216
+ const base = options.basePath ?? config.basePath;
217
+ const clock = options.now ?? (() => new Date());
218
+ const ids = options.newId ?? (() => crypto.randomUUID());
219
+ const deps = (c: Context<PithyHonoEnv>): WriteDeps => ({
220
+ db: testersDatabase(database(c)),
221
+ now: clock(),
222
+ newId: ids,
223
+ });
224
+
225
+ /**
226
+ * A cohort that may still be written to and sent from.
227
+ *
228
+ * `closedAt` was stamped by `close` and then consulted by exactly one filter, so every path that did
229
+ * not go through it carried on regardless — invite, resend and nudge all still mailed a finished
230
+ * cohort's testers. There is no reopen, either, so a member invited onto a closed cohort sat in
231
+ * permanent limbo: never chased, never advanced, and on the roster forever.
232
+ */
233
+ async function requireOpenCohort(db: TestersDatabase, cohortId: string): Promise<TestersCohort> {
234
+ const cohort = await requireCohort(db, cohortId);
235
+ if (cohort.closedAt !== null) {
236
+ throw new TestersCohortClosedError({
237
+ detail: `cohort ${cohort.id} was closed at ${cohort.closedAt.toISOString()}`,
238
+ });
239
+ }
240
+ return cohort;
241
+ }
242
+
243
+ /**
244
+ * Look a token up, or answer exactly as an unknown one is answered.
245
+ *
246
+ * The token is the whole credential, so a miss and a forgery are the same event and must produce the
247
+ * same words. Anything else turns the public route into an oracle for which testers exist.
248
+ *
249
+ * `expiring` is what separates joining from leaving, and the asymmetry is deliberate — see
250
+ * {@link memberForWithdrawal}.
251
+ */
252
+ async function memberFor(
253
+ db: TestersDatabase,
254
+ token: string,
255
+ options: { expiring: boolean } = { expiring: true },
256
+ ): Promise<TestersMember> {
257
+ const member = await findMemberByToken(db, token);
258
+ if (!member) throw new TestersInvalidTokenError({ detail: "no member holds that token" });
259
+ // Removed and lapsed are both dead ends. Both rotate the token on the way out, so an old link
260
+ // already fails the lookup; this is the second line of defense, and it is what stops a withdrawal
261
+ // being undone by a replay of whatever is still sitting in the tester's inbox. Coming back requires
262
+ // a fresh invitation, which mints a fresh token.
263
+ if (member.state === "removed" || member.state === "lapsed") {
264
+ throw new TestersInvalidTokenError({ detail: `member ${member.id} is ${member.state} on this cohort` });
265
+ }
266
+ // The configured link lifetime, enforced rather than merely declared. A token has no expiry of its
267
+ // own — it is a row — so the age of the invitation that carried it is what bounds it. Without this
268
+ // the config field was cross-validated against `windowDays` at deploy and then read by nothing.
269
+ if (options.expiring) {
270
+ const ageMs = clock().getTime() - member.lastInvitedAt.getTime();
271
+ if (ageMs > config.optInLinkTtlDays * 86_400_000) {
272
+ throw new TestersInvalidTokenError({
273
+ detail: `link is ${Math.floor(ageMs / 86_400_000)} days old, past the ${config.optInLinkTtlDays}-day limit`,
274
+ });
275
+ }
276
+ }
277
+ return member;
278
+ }
279
+
280
+ /**
281
+ * The same lookup for the one action that must never expire: withdrawing.
282
+ *
283
+ * Every other public link is an invitation to *do* something, and an invitation going stale is
284
+ * correct. The opt-out link is the opposite — it is the tester's way out of mail this capability
285
+ * keeps sending, and nothing bounds that outreach by `optInLinkTtlDays`. A member invited on day 0
286
+ * who opted in on day 5 is still nudged on day 40 by the daily pass, and `lastInvitedAt` only moves
287
+ * on a resend, so under the shared TTL their unsubscribe link would have died on day 30 while the
288
+ * mail carrying it kept arriving. A door with no handle on the inside.
289
+ *
290
+ * The rest of the gate still applies. An unknown token is still unknown, and a member who already
291
+ * left is still refused — their token was rotated on the way out, so a replayed link cannot undo the
292
+ * withdrawal.
293
+ */
294
+ async function memberForWithdrawal(db: TestersDatabase, token: string): Promise<TestersMember> {
295
+ return await memberFor(db, token, { expiring: false });
296
+ }
297
+
298
+ return (app) => {
299
+ // ── public: the tester's own three links ──────────────────────────────────
300
+ //
301
+ // Public by design and by necessity. The confirmation is the event the opt-in count rests on, so the
302
+ // path to it has to be as short as an email and a thumb. The token is the whole credential; nothing
303
+ // else about the request is trusted.
304
+
305
+ // Step one: "yes, I will test." Records consent and tells the developer to add this address to the
306
+ // store's tester list. Nothing installs yet, and no store link is shown — it would not work.
307
+ app.get(`${base}/confirm/:token`, zValidator("param", OptInTokenParam, validationHook), async (c) => {
308
+ const now = clock();
309
+ const db = testersDatabase(database(c));
310
+ const member = await memberFor(db, c.req.valid("param").token);
311
+ const result = await recordAccepted({ db, now, newId: ids }, member.id);
312
+
313
+ // Only the first answer is audited. A prefetching mail client following the link three times is
314
+ // not three events, and recording it as such would make the trail useless.
315
+ if (result.firstTime) {
316
+ await c.var.emit({
317
+ action: TestersAuditActions.memberAccepted,
318
+ outcome: "success",
319
+ actorType: "user",
320
+ actorId: null,
321
+ resourceType: "testers_member",
322
+ resourceId: member.id,
323
+ ip: c.req.header("cf-connecting-ip"),
324
+ userAgent: c.req.header("user-agent"),
325
+ metadata: { cohortId: member.cohortId },
326
+ });
327
+ }
328
+
329
+ return page(
330
+ "Thank you.",
331
+ "You are down as a tester. The developer will add you to the test, and you will get one more email with the link to join and install. Nothing to do until then.",
332
+ );
333
+ });
334
+
335
+ // Step two: through to the store's own opt-in page. This is the strongest enrollment signal Pithy can
336
+ // observe — and it is still not proof that the store accepted them, which is what `estimated` means.
337
+ app.get(`${base}/opt-in/:token`, zValidator("param", OptInTokenParam, validationHook), async (c) => {
338
+ const now = clock();
339
+ const db = testersDatabase(database(c));
340
+ const member = await memberFor(db, c.req.valid("param").token);
341
+ const cohort = await requireCohort(db, member.cohortId);
342
+ const result = await confirmOptIn({ db, now, newId: ids }, member.id);
343
+
344
+ if (result.firstTime) {
345
+ await c.var.emit({
346
+ action: TestersAuditActions.memberOptedIn,
347
+ outcome: "success",
348
+ actorType: "user",
349
+ actorId: null,
350
+ resourceType: "testers_member",
351
+ resourceId: member.id,
352
+ ip: c.req.header("cf-connecting-ip"),
353
+ userAgent: c.req.header("user-agent"),
354
+ metadata: { cohortId: member.cohortId },
355
+ });
356
+ }
357
+
358
+ // The link is rendered, never redirected to. A 302 would make this Worker a redirector, and the
359
+ // host allowlist would then be the only thing between a bad config write and a phishing hop off a
360
+ // domain the tester trusts *because* the adopter signed the email that brought them here. It also
361
+ // leaves nowhere to put the two instructions below — and those are the difference between a tester
362
+ // who joins and one who sees `App not available` and concludes the app is broken.
363
+ if (!cohort.storeOptInUrl || !isStoreOptInUrl(cohort.storeOptInUrl)) {
364
+ // Recording the opt-in is still right — they did their part — but we have nowhere to send them.
365
+ return page("You're in.", "Thank you. The developer will be in touch with the link to install.");
366
+ }
367
+ return storePage(cohort.storeOptInUrl, cohort.targetPlatform);
368
+ });
369
+
370
+ // Withdrawing takes two steps, and the reason is the same one that makes the confirmation route
371
+ // idempotent: mail clients prefetch links and security scanners follow them. A one-tap GET would let
372
+ // a scanner silently withdraw a tester — and because withdrawing rotates their token, that is
373
+ // irreversible without a fresh invitation. So the GET asks, and only the POST acts. Scanners do not
374
+ // POST. This is why the opt-out is the one tester-facing action that is deliberately not one tap.
375
+ app.get(`${base}/opt-out/:token`, zValidator("param", OptInTokenParam, validationHook), async (c) => {
376
+ const db = testersDatabase(database(c));
377
+ // Looked up so a dead link says so here rather than after the tester has confirmed an intent that
378
+ // was never going to be honored.
379
+ await memberForWithdrawal(db, c.req.valid("param").token);
380
+ return confirmOptOutPage(`${base}/opt-out/${c.req.valid("param").token}`);
381
+ });
382
+
383
+ app.post(`${base}/opt-out/:token`, zValidator("param", OptInTokenParam, validationHook), async (c) => {
384
+ const now = clock();
385
+ const db = testersDatabase(database(c));
386
+ const member = await memberForWithdrawal(db, c.req.valid("param").token);
387
+ const lapsed = await lapseMember({ db, now, newId: ids }, member.id);
388
+ // Only a real withdrawal is audited. These routes answer unauthenticated strangers holding a
389
+ // token, so auditing every request lets one link write unbounded rows into the adopter's trail.
390
+ if (lapsed.firstTime) {
391
+ await c.var.emit({
392
+ action: TestersAuditActions.memberLapsed,
393
+ outcome: "success",
394
+ actorType: "user",
395
+ actorId: null,
396
+ resourceType: "testers_member",
397
+ resourceId: lapsed.member.id,
398
+ metadata: { cohortId: lapsed.member.cohortId },
399
+ });
400
+ }
401
+ return page(
402
+ "You've been removed.",
403
+ "You will not hear from this test again. Thank you for the time you gave it.",
404
+ );
405
+ });
406
+
407
+ // ── bearer | session: a tester's own view ─────────────────────────────────
408
+ app.get(`${base}/status`, requireAuth(), zValidator("query", StatusQuery, validationHook), async (c) => {
409
+ const now = clock();
410
+ const d1 = database(c);
411
+ const db = testersDatabase(d1);
412
+ const query = c.req.valid("query");
413
+
414
+ // A tester sees their own memberships and nothing else — not the roster, not the other testers,
415
+ // and not the cohort's forecast. What they legitimately want to know is whether their own
416
+ // confirmation registered and how long is left.
417
+ const userEmail = await resolveCallerEmail(d1, callerId(c));
418
+ if (!userEmail) return c.json({ memberships: [], disclaimer: DISCLAIMER } satisfies MembershipsResponse, 200);
419
+
420
+ const cohorts = query.cohortId ? [await requireCohort(db, query.cohortId)] : await listCohorts(db);
421
+ const memberships: MembershipView[] = [];
422
+ for (const cohort of cohorts) {
423
+ const member = (await listMembers(db, cohort.id)).find((entry) => entry.email === userEmail);
424
+ if (!member) continue;
425
+ // The request's own logger, namespaced to this capability. An activity read that degrades is
426
+ // then correlated to the request that asked, rather than being an orphaned line.
427
+ const reading = await readCohort(db, d1, cohort, config, now, c.var.log.child("testers"));
428
+ memberships.push({
429
+ cohortName: cohort.name,
430
+ state: member.state,
431
+ estimatedOptedInAt: member.optedInAt?.toISOString() ?? null,
432
+ estimatedDaysRemaining: reading.clock.estimatedDaysRemaining,
433
+ windowDays: cohort.windowDays,
434
+ });
435
+ }
436
+ return c.json({ memberships, disclaimer: DISCLAIMER } satisfies MembershipsResponse, 200);
437
+ });
438
+
439
+ // ── control-plane: the dashboard's surface ────────────────────────────────
440
+ app.get(
441
+ `${base}/cohorts`,
442
+ requireControlPlane(TESTERS_ROSTER_READ_SCOPE),
443
+ zValidator("query", CohortsQuery, validationHook),
444
+ async (c) => {
445
+ const now = clock();
446
+ const d1 = database(c);
447
+ const db = testersDatabase(d1);
448
+ const query = c.req.valid("query");
449
+
450
+ const cohorts = query.cohortId ? [await requireCohort(db, query.cohortId)] : await listCohorts(db);
451
+ const views = [];
452
+ for (const cohort of cohorts) {
453
+ const reading = await readCohort(db, d1, cohort, config, now, c.var.log.child("testers"));
454
+ // The latest snapshot is read even when the series is not, because it carries the
455
+ // precomputed trend — recomputing the deltas here would let the card and the chart disagree.
456
+ const snapshots = await listSnapshots(db, cohort.id, query.trend ? query.trendDays : 1);
457
+ views.push(
458
+ toCohortView(
459
+ reading,
460
+ config,
461
+ {
462
+ includeMembers: query.members,
463
+ snapshots: query.trend ? snapshots : [],
464
+ latest: snapshots[snapshots.length - 1],
465
+ },
466
+ now,
467
+ ),
468
+ );
469
+ }
470
+
471
+ return c.json(
472
+ {
473
+ cohorts: views,
474
+ modelVersion: config.modelVersion,
475
+ generatedAt: now.toISOString(),
476
+ disclaimer: DISCLAIMER,
477
+ } satisfies CohortsResponse,
478
+ 200,
479
+ );
480
+ },
481
+ );
482
+
483
+ app.post(
484
+ `${base}/invite`,
485
+ requireControlPlane(TESTERS_ROSTER_WRITE_SCOPE),
486
+ zValidator("json", InviteRequest, validationHook),
487
+ async (c) => {
488
+ const input = c.req.valid("json");
489
+ const caller = controlPlaneCaller(c);
490
+ const write = deps(c);
491
+ const cohort = await requireOpenCohort(write.db, input.cohortId);
492
+
493
+ // The route schema bounds the name at its own ceiling; this enforces the project's, which may
494
+ // be tighter. Same rule as the nudge copy below, and for the same reason: `maxNameLength` was
495
+ // declared, bounded, defaulted and described, and read by nothing at all — so an adopter who
496
+ // set it to 40 to keep their roster renderable still had 120 accepted.
497
+ if (input.name && input.name.length > config.maxNameLength) {
498
+ throw new ValidationError({
499
+ message: "That name is longer than this deployment allows.",
500
+ action: `Keep it under ${config.maxNameLength} characters.`,
501
+ detail: `name was ${input.name.length}, limit ${config.maxNameLength}`,
502
+ });
503
+ }
504
+
505
+ const { member, created } = await inviteMember(write, {
506
+ cohortId: cohort.id,
507
+ email: input.email,
508
+ name: input.name ?? null,
509
+ maxRosterSize: cohort.maxRosterSize,
510
+ });
511
+
512
+ let jobId: string | null = null;
513
+ if (input.sendInvitation) {
514
+ const enqueue = options.enqueue?.(c.env as Record<string, unknown>);
515
+ if (!enqueue) {
516
+ throw new TestersNotConfiguredError({
517
+ message: "Invitations cannot be sent yet.",
518
+ action: "Add `email(...)` to this Worker's capabilities — invitations are enqueued through it.",
519
+ detail: "the email capability was not composed, so no enqueue seam is bound",
520
+ });
521
+ }
522
+ const sent = await sendNudge(enqueue, {
523
+ member,
524
+ kind: "confirm",
525
+ supplied: undefined,
526
+ ctaUrl: confirmUrl(config, member.optInToken),
527
+ optOutUrl: optOutUrl(config, member.optInToken),
528
+ });
529
+ await recordNudge(write, {
530
+ memberId: member.id,
531
+ nudgeKind: "confirm",
532
+ jobId: sent.jobId,
533
+ copySource: "default",
534
+ });
535
+ jobId = sent.jobId;
536
+ }
537
+
538
+ await c.var.emit({
539
+ action: TestersAuditActions.memberInvited,
540
+ outcome: "success",
541
+ severity: "warning",
542
+ actorType: "control-plane",
543
+ actorId: caller.subject,
544
+ resourceType: "testers_member",
545
+ resourceId: member.id,
546
+ metadata: { connectionId: caller.connectionId, cohortId: cohort.id, email: member.email, created, jobId },
547
+ });
548
+
549
+ return c.json(
550
+ {
551
+ member: { id: member.id, email: member.email, state: member.state },
552
+ created,
553
+ jobId,
554
+ } satisfies InviteResponse,
555
+ 200,
556
+ );
557
+ },
558
+ );
559
+
560
+ app.post(
561
+ `${base}/resend`,
562
+ requireControlPlane(TESTERS_ROSTER_WRITE_SCOPE),
563
+ zValidator("json", ResendRequest, validationHook),
564
+ async (c) => {
565
+ const input = c.req.valid("json");
566
+ const caller = controlPlaneCaller(c);
567
+ const write = deps(c);
568
+
569
+ // The cooldown is enforced here too. It is documented as applying on every path, and a resend
570
+ // that bypassed it would be the obvious way to mail one tester repeatedly — the exact thing the
571
+ // guard exists to prevent, reachable by anyone holding the write scope.
572
+ const before = await requireMember(write.db, input.memberId);
573
+ // Resend names a member rather than a cohort, so the cohort comes from the member's own row.
574
+ await requireOpenCohort(write.db, before.cohortId);
575
+ // A resend is outreach, so it obeys the same consent rule every other send does. Without this,
576
+ // the one path that skipped `selectForNudge` could still mail someone who had withdrawn.
577
+ if (!LIVE_STATES.includes(before.state)) {
578
+ throw new TestersMemberNotFoundError({
579
+ detail: `member ${before.id} is ${before.state} and cannot be re-invited`,
580
+ });
581
+ }
582
+ // The chase cap, on this path too. It was read only by the daily pass, so a dashboard holding
583
+ // the write scope could keep chasing somebody who had stopped answering — every three days,
584
+ // for the life of the cohort, which is exactly what the cap exists to prevent.
585
+ if (chasedOut(before)) {
586
+ throw new TestersNudgeCooldownError({
587
+ message: "That tester has stopped answering, so we have stopped chasing them.",
588
+ action: "They stay on the roster. Remove them and invite a replacement if you need the place.",
589
+ detail: `member ${before.id} has ${before.nudgeCount} unanswered nudges`,
590
+ });
591
+ }
592
+ if (!mayNudge(before, config.nudges.cooldownHours, clock())) {
593
+ await c.var.emit({
594
+ action: TestersAuditActions.nudgeThrottled,
595
+ outcome: "denied",
596
+ actorType: "control-plane",
597
+ actorId: caller.subject,
598
+ resourceType: "testers_member",
599
+ resourceId: before.id,
600
+ metadata: { connectionId: caller.connectionId, cohortId: before.cohortId },
601
+ });
602
+ throw new TestersNudgeCooldownError({
603
+ detail: `member ${before.id} was last nudged at ${before.lastNudgedAt?.toISOString() ?? "never"}`,
604
+ });
605
+ }
606
+
607
+ const member = await resendInvite(write, input.memberId);
608
+
609
+ const enqueue = options.enqueue?.(c.env as Record<string, unknown>);
610
+ if (!enqueue) {
611
+ throw new TestersNotConfiguredError({
612
+ message: "Invitations cannot be sent yet.",
613
+ action: "Add `email(...)` to this Worker's capabilities — invitations are enqueued through it.",
614
+ detail: "the email capability was not composed, so no enqueue seam is bound",
615
+ });
616
+ }
617
+ const sent = await sendNudge(enqueue, {
618
+ member,
619
+ kind: "confirm",
620
+ supplied: undefined,
621
+ ctaUrl: confirmUrl(config, member.optInToken),
622
+ optOutUrl: optOutUrl(config, member.optInToken),
623
+ });
624
+ await recordNudge(write, {
625
+ memberId: member.id,
626
+ nudgeKind: "confirm",
627
+ jobId: sent.jobId,
628
+ copySource: "default",
629
+ });
630
+
631
+ await c.var.emit({
632
+ action: TestersAuditActions.memberReinvited,
633
+ outcome: "success",
634
+ actorType: "control-plane",
635
+ actorId: caller.subject,
636
+ resourceType: "testers_member",
637
+ resourceId: member.id,
638
+ metadata: { connectionId: caller.connectionId, cohortId: member.cohortId, jobId: sent.jobId },
639
+ });
640
+ // Id, not address — the same rule the nudge preview below states and for the same reason. The
641
+ // three scopes are separated so a credential may mail or manage a roster it was never granted
642
+ // permission to read, and echoing the address back on every write turned a list of ids (which
643
+ // the dry-run hands out by design) into a list of real people's email addresses.
644
+ return c.json({ member: { id: member.id }, jobId: sent.jobId } satisfies ResendResponse, 200);
645
+ },
646
+ );
647
+
648
+ app.post(
649
+ `${base}/remove`,
650
+ requireControlPlane(TESTERS_ROSTER_WRITE_SCOPE),
651
+ zValidator("json", RemoveRequest, validationHook),
652
+ async (c) => {
653
+ const input = c.req.valid("json");
654
+ const caller = controlPlaneCaller(c);
655
+ const write = deps(c);
656
+ const member = await removeMember(write, input.memberId, input.reason);
657
+ await c.var.emit({
658
+ action: TestersAuditActions.memberRemoved,
659
+ outcome: "success",
660
+ severity: "warning",
661
+ actorType: "control-plane",
662
+ actorId: caller.subject,
663
+ resourceType: "testers_member",
664
+ resourceId: member.id,
665
+ metadata: { connectionId: caller.connectionId, cohortId: member.cohortId, reason: input.reason ?? null },
666
+ });
667
+ // Id and state, not the address. See the resend route above.
668
+ return c.json({ member: { id: member.id, state: member.state } } satisfies RemoveResponse, 200);
669
+ },
670
+ );
671
+
672
+ app.post(
673
+ `${base}/nudge`,
674
+ requireControlPlane(TESTERS_NUDGE_SEND_SCOPE),
675
+ zValidator("json", NudgeRequest, validationHook),
676
+ async (c) => {
677
+ const input = c.req.valid("json");
678
+ const caller = controlPlaneCaller(c);
679
+ const now = clock();
680
+ const write = deps(c);
681
+ const cohort = await requireOpenCohort(write.db, input.cohortId);
682
+
683
+ const suppliedCopy = input.subject !== undefined || input.body !== undefined;
684
+ if (suppliedCopy && !config.nudges.allowCopyOverride) {
685
+ await c.var.emit({
686
+ action: TestersAuditActions.copyRejected,
687
+ outcome: "denied",
688
+ severity: "warning",
689
+ actorType: "control-plane",
690
+ actorId: caller.subject,
691
+ resourceType: "testers_cohort",
692
+ resourceId: cohort.id,
693
+ metadata: { connectionId: caller.connectionId },
694
+ });
695
+ throw new TestersCopyNotAllowedError({
696
+ detail: "nudges.allowCopyOverride is false, and the request carried a subject or body",
697
+ });
698
+ }
699
+
700
+ // The route schema bounds copy at its own ceiling; this enforces the project's, which may be
701
+ // tighter. Declaring a limit and never reading it is worse than not having one.
702
+ if (input.subject && input.subject.length > config.nudges.maxSubjectLength) {
703
+ throw new TestersCopyNotAllowedError({
704
+ message: "That subject is longer than this deployment allows.",
705
+ action: `Keep it under ${config.nudges.maxSubjectLength} characters.`,
706
+ detail: `subject was ${input.subject.length}, limit ${config.nudges.maxSubjectLength}`,
707
+ });
708
+ }
709
+ if (input.body && input.body.length > config.nudges.maxBodyLength) {
710
+ throw new TestersCopyNotAllowedError({
711
+ message: "That message is longer than this deployment allows.",
712
+ action: `Keep it under ${config.nudges.maxBodyLength} characters.`,
713
+ detail: `body was ${input.body.length}, limit ${config.nudges.maxBodyLength}`,
714
+ });
715
+ }
716
+
717
+ // The same readiness test the daily pass and the opt-in route apply. Without it the dashboard's
718
+ // own send button was the one path that could mail the join link for a cohort with no usable
719
+ // store URL — and following that link runs `confirmOptIn`, so the opt-in estimate climbs on
720
+ // testers who were never enrolled with Google. The pass calls that the worst failure this
721
+ // capability has; the route must not be the exception to it.
722
+ if (input.kind === "store" && !(cohort.storeOptInUrl && isStoreOptInUrl(cohort.storeOptInUrl))) {
723
+ throw new TestersNotConfiguredError({
724
+ message: "This cohort has no store opt-in link, so there is nothing to send.",
725
+ action: "Set one with `pithy testers create --store-url`, then send again.",
726
+ detail: `cohort ${cohort.id} storeOptInUrl is ${cohort.storeOptInUrl === null ? "null" : "not a store URL"}`,
727
+ });
728
+ }
729
+
730
+ const roster = await listMembers(write.db, cohort.id);
731
+ const candidates = selectForNudge(roster, input.kind, input.memberIds);
732
+ // Cooled and unreachable testers are removed *before* the batch cap, not after. Slicing first
733
+ // spent the two hundred places on whoever sorted earliest — including testers who were only
734
+ // going to be dropped for cooling anyway — so on a roster over the cap the tail was never
735
+ // reached on any run, however often the send was repeated.
736
+ const split = splitByCooldown(candidates, config.nudges.cooldownHours, now);
737
+ const sendable = split.eligible.slice(0, MAX_NUDGE_BATCH);
738
+ // Reported, not silent. The request schema admits 500 ids and a roster may hold far more, so
739
+ // exceeding the batch is a designed-for case rather than an edge — and a member dropped by the
740
+ // cap lands in no bucket at all, so without this a caller could not tell "everyone eligible was
741
+ // mailed" from "the first two hundred were". That is the same partial success the empty send
742
+ // below refuses to report as success.
743
+ const truncated = split.eligible.length - sendable.length;
744
+
745
+ if (input.dryRun) {
746
+ return c.json(
747
+ {
748
+ dryRun: true,
749
+ // Ids, not addresses. A caller holding only `testers:nudge:send` can mail the roster but
750
+ // was never granted permission to read it, and a preview returning every eligible
751
+ // tester's email would hand them exactly what `testers:roster:read` exists to gate.
752
+ wouldSend: sendable.map((member) => ({ id: member.id })),
753
+ cooling: split.cooling.length,
754
+ unreachable: split.unreachable.length,
755
+ chasedOut: split.chasedOut.length,
756
+ truncated,
757
+ } satisfies NudgeDryRunResponse,
758
+ 200,
759
+ );
760
+ }
761
+
762
+ // Nothing sent is not a partial success. A response that reported an empty send as a success
763
+ // would render in a dashboard as "sent" and the developer would never chase it.
764
+ if (split.eligible.length === 0) {
765
+ await c.var.emit({
766
+ action: TestersAuditActions.nudgeThrottled,
767
+ outcome: "denied",
768
+ actorType: "control-plane",
769
+ actorId: caller.subject,
770
+ resourceType: "testers_cohort",
771
+ resourceId: cohort.id,
772
+ metadata: {
773
+ connectionId: caller.connectionId,
774
+ selected: candidates.length,
775
+ cooling: split.cooling.length,
776
+ unreachable: split.unreachable.length,
777
+ },
778
+ });
779
+ throw new TestersNudgeCooldownError({
780
+ detail: `${split.cooling.length} cooling, ${split.unreachable.length} unreachable, 0 eligible`,
781
+ });
782
+ }
783
+
784
+ const enqueue = options.enqueue?.(c.env as Record<string, unknown>);
785
+ if (!enqueue) {
786
+ throw new TestersNotConfiguredError({
787
+ message: "Nudges cannot be sent yet.",
788
+ action: "Add `email(...)` to this Worker's capabilities — nudges are enqueued through it.",
789
+ detail: "the email capability was not composed, so no enqueue seam is bound",
790
+ });
791
+ }
792
+
793
+ const sent = [];
794
+ for (const member of sendable) {
795
+ const result = await sendNudge(enqueue, {
796
+ member,
797
+ kind: input.kind,
798
+ supplied: suppliedCopy ? { subject: input.subject, body: input.body } : undefined,
799
+ // Only the confirmation nudge carries a link. Attaching one to an inactivity nudge would
800
+ // invite a tester who has already confirmed to confirm again, which reads as a mistake.
801
+ // `confirm` carries the "will you test?" link; `store` carries the one that leads to the
802
+ // store. The other two kinds carry no link at all — a nudge about inactivity that invited
803
+ // someone to confirm again would read as a mistake.
804
+ ctaUrl: nudgeLink(config, input.kind, member),
805
+ optOutUrl: optOutUrl(config, member.optInToken),
806
+ });
807
+ await recordNudge(write, {
808
+ memberId: member.id,
809
+ nudgeKind: input.kind,
810
+ jobId: result.jobId,
811
+ copySource: result.copy.source,
812
+ });
813
+ sent.push({ memberId: member.id, jobId: result.jobId });
814
+ }
815
+
816
+ await c.var.emit({
817
+ action: TestersAuditActions.nudgeSent,
818
+ outcome: "success",
819
+ severity: "warning",
820
+ actorType: "control-plane",
821
+ actorId: caller.subject,
822
+ resourceType: "testers_cohort",
823
+ resourceId: cohort.id,
824
+ metadata: {
825
+ connectionId: caller.connectionId,
826
+ kind: input.kind,
827
+ sent: sent.length,
828
+ skippedCooling: split.cooling.length,
829
+ skippedUnreachable: split.unreachable.length,
830
+ skippedChasedOut: split.chasedOut.length,
831
+ truncated,
832
+ // The provenance, never the words. A trail that quoted every nudge would be a copy of every
833
+ // email ever sent, sitting in a queryable table.
834
+ copySource: suppliedCopy ? "supplied" : "default",
835
+ },
836
+ });
837
+
838
+ return c.json(
839
+ {
840
+ sent,
841
+ skipped: {
842
+ cooling: split.cooling.length,
843
+ unreachable: split.unreachable.length,
844
+ chasedOut: split.chasedOut.length,
845
+ truncated,
846
+ },
847
+ copySource: suppliedCopy ? "supplied" : "default",
848
+ } satisfies NudgeResponse,
849
+ 200,
850
+ );
851
+ },
852
+ );
853
+ };
854
+ }
855
+
856
+ /**
857
+ * Which testers a nudge kind targets when the caller names none.
858
+ *
859
+ * Naming ids explicitly always wins; the defaults exist so `POST /testers/nudge {"kind":"confirm"}` does
860
+ * the obvious thing without asking anyone to assemble a list by hand.
861
+ */
862
+ export function selectForNudge(
863
+ roster: readonly TestersMember[],
864
+ kind: NudgeKind,
865
+ memberIds: readonly string[] | undefined,
866
+ ): TestersMember[] {
867
+ if (memberIds && memberIds.length > 0) {
868
+ const wanted = new Set(memberIds);
869
+ // Naming an id selects, it does not override. Consent and removal are server-side facts exactly as
870
+ // the cooldown is, so a caller cannot mail someone who opted out by pointing at them directly —
871
+ // which is the one message this capability must never send.
872
+ // State-appropriate for the kind, not merely live. Naming an `invited` member on a `store` send
873
+ // would mail the join link to somebody who has not agreed to test and is certainly not on the Play
874
+ // tester list — the store page only works for an address the developer has already added by hand.
875
+ const found = roster.filter(
876
+ (member) => wanted.has(member.id) && LIVE_STATES.includes(member.state) && eligibleForKind(member, kind),
877
+ );
878
+ if (found.length === 0) {
879
+ throw new TestersMemberNotFoundError({
880
+ detail: `none of the ${memberIds.length} named members are on this cohort in a live state`,
881
+ });
882
+ }
883
+ return found;
884
+ }
885
+ return roster.filter((member) => eligibleForKind(member, kind));
886
+ }
887
+
888
+ /**
889
+ * Whether this tester is in a state that makes this kind of nudge sensible.
890
+ *
891
+ * Applied to a named-id selection as well as to a kind-selects-them one. Naming an id selects; it does
892
+ * not override — the same rule the live-state filter above already enforces, and for the same reason.
893
+ * Mailing the store link to an `invited` member sends the join page to somebody who has not agreed to
894
+ * test and whom the developer has certainly not added to the Play tester list, so the page answers
895
+ * `App not available` and the tester concludes the app is broken.
896
+ */
897
+ function eligibleForKind(member: TestersMember, kind: NudgeKind): boolean {
898
+ switch (kind) {
899
+ case "confirm":
900
+ return member.state === "invited";
901
+ case "store":
902
+ // The same population the daily pass sends the store link to. Without this the kind selected
903
+ // nobody and the handler raised a cooldown error, which reads as "everyone is cooling down" when
904
+ // the truth is "this kind selects no one".
905
+ return member.state === "accepted";
906
+ case "inactive":
907
+ case "closing":
908
+ return member.state === "opted_in";
909
+ default:
910
+ return false;
911
+ }
912
+ }
913
+
914
+ /**
915
+ * The caller's own email address, from `@pithy-sh/auth`.
916
+ *
917
+ * A guarded dynamic import, because auth is an optional peer: a project with no sign-in has no
918
+ * `/status` route worth serving, and the honest answer there is an empty membership list rather than a
919
+ * failure to boot.
920
+ */
921
+ async function resolveCallerEmail(d1: D1Database, userId: string): Promise<string | null> {
922
+ try {
923
+ const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
924
+ const row = await authDatabase(d1)
925
+ .selectFrom("pithyAuthUsers")
926
+ .select(["email"])
927
+ .where("id", "=", userId)
928
+ .executeTakeFirst();
929
+ return row ? normalizeAddress(String(row.email)) : null;
930
+ } catch {
931
+ return null;
932
+ }
933
+ }