@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,518 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { TestersNotConfiguredError } from "../error/errors";
6
+
7
+ /**
8
+ * Configuration for the testers capability — the roster policy, the clock's shape, and every constant
9
+ * behind the forecast.
10
+ *
11
+ * **The load-bearing decision is that all of it is configurable and none of it is hidden.** The survival
12
+ * table below turns a tester's health score into "how likely are they to still be opted in on day
13
+ * fourteen", and those numbers are priors we chose, not parameters fitted to anybody's data. A model
14
+ * that cannot be audited or adjusted by the developer relying on it is a model asking to be trusted on
15
+ * authority — and this capability's whole position is that Google owns the authoritative number and we
16
+ * do not. So every constant is a described config field, and `modelVersion` is stamped on every daily
17
+ * snapshot so a chart can annotate exactly where a developer's change took effect.
18
+ *
19
+ * Cohorts themselves are rows, not config: a cohort has a lifecycle, a roster, and an event history, and
20
+ * none of that belongs in a file that is redeployed. What lives here is the policy every new cohort
21
+ * inherits.
22
+ */
23
+
24
+ /** The longest a cohort or member display name may be. Long enough for a real name, short enough to render. */
25
+ const MAX_NAME_LENGTH = 120;
26
+
27
+ /**
28
+ * Google Play's floor: twelve testers, opted in continuously for fourteen days, before a new personal
29
+ * developer account gets production access.
30
+ */
31
+ const PLAY_TARGET_SIZE = 12;
32
+
33
+ /** Google Play's continuous window, in days. */
34
+ const PLAY_WINDOW_DAYS = 14;
35
+
36
+ /**
37
+ * The roster cap a cohort defaults to.
38
+ *
39
+ * **This is a management default, not a store limit, and the difference matters.** Play's *internal*
40
+ * testing track caps at 100 testers per app; *closed* testing — the track the 12-for-14 requirement
41
+ * actually runs on — allows up to 2,000 emails per list and 50 lists per track. So 100 is not a ceiling
42
+ * Google imposes here. It is the size of roster one person can still chase by hand, which is the real
43
+ * constraint on a solo developer, and it is why over-provisioning advice is capped near it rather than
44
+ * telling someone to invite four hundred people they will never follow up with.
45
+ */
46
+ const DEFAULT_MAX_ROSTER_SIZE = 100;
47
+
48
+ /** The hard ceiling on a roster: Play's closed-testing per-list limit. Beyond this you need a second list. */
49
+ const PLAY_CLOSED_TEST_LIST_LIMIT = 2000;
50
+
51
+ /**
52
+ * The hosts a cohort's opt-in URL may point at.
53
+ *
54
+ * The opt-in route renders a link to this URL, so an unconstrained value would be an
55
+ * open redirect from the adopter's own domain — the one place a phishing link is most likely to be
56
+ * trusted, since the tester arrived there from an email the adopter signed. An allowlist of the two
57
+ * stores' own hosts is the whole legitimate surface.
58
+ */
59
+ export const STORE_OPT_IN_HOSTS: readonly string[] = ["play.google.com", "testflight.apple.com"];
60
+
61
+ /**
62
+ * Whether a URL is a store opt-in page we are willing to put in front of a tester.
63
+ *
64
+ * Exact host match over HTTPS. A subdomain check would accept `play.google.com.evil.test`, which is the
65
+ * classic way this validation is got wrong.
66
+ */
67
+ export function isStoreOptInUrl(value: string): boolean {
68
+ try {
69
+ const url = new URL(value);
70
+ return url.protocol === "https:" && STORE_OPT_IN_HOSTS.includes(url.hostname);
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /** The platform a cohort's test targets, which decides what "has a usable device" means for its testers. */
77
+ export const TesterPlatform = z
78
+ .enum(["android", "ios"])
79
+ .describe(
80
+ "Which store's beta program this cohort serves. `android` is the default because the 12-for-14 requirement that motivates this capability is Google Play's; `ios` covers a TestFlight cohort, which has no equivalent minimum.",
81
+ );
82
+ export type TesterPlatform = z.output<typeof TesterPlatform>;
83
+
84
+ /**
85
+ * What Pithy assumes happens to the continuous-day counter when a cohort dips below its target.
86
+ *
87
+ * Google does not document this and no API exposes it, so it is genuinely an assumption — named as one,
88
+ * on the wire and in config, rather than buried in the arithmetic.
89
+ */
90
+ export const ResetPolicy = z
91
+ .enum(["reset", "pause"])
92
+ .describe(
93
+ "Pithy's ASSUMPTION about a dip below target: `reset` restarts the streak from zero, `pause` holds it and resumes. Google's actual behavior is undocumented. `reset` is the default deliberately — an estimate that errs toward 'you are not finished yet' fails safe, and being told day fourteen while actually on day three is the expensive mistake.",
94
+ );
95
+ export type ResetPolicy = z.output<typeof ResetPolicy>;
96
+
97
+ /**
98
+ * Per-day survival probabilities by health band — the priors behind the whole forecast.
99
+ *
100
+ * Read each as "the chance this tester is still opted in tomorrow". Raised to the power of the days
101
+ * remaining, that becomes their chance of lasting the window, and the exact Poisson-binomial over every
102
+ * tester becomes the cohort's. They are declared, not fitted, and `GET /testers/cohorts` says so in a
103
+ * `calibration` field so nobody mistakes 0.998 for a measurement.
104
+ */
105
+ export const SurvivalPriors = z
106
+ .object({
107
+ healthy: z
108
+ .number()
109
+ .gt(0)
110
+ .lte(1)
111
+ .default(0.998)
112
+ .describe("Daily survival for a tester scoring 80–100. About a 1-in-500 chance of dropping on any given day."),
113
+ watch: z
114
+ .number()
115
+ .gt(0)
116
+ .lte(1)
117
+ .default(0.99)
118
+ .describe(
119
+ "Daily survival for a tester scoring 60–79. Roughly 1-in-100 a day — usually fine over a week, dicey over two.",
120
+ ),
121
+ atRisk: z
122
+ .number()
123
+ .gt(0)
124
+ .lte(1)
125
+ .default(0.97)
126
+ .describe("Daily survival for a tester scoring 30–59. A coin-flip-and-a-half over a full fourteen-day window."),
127
+ critical: z
128
+ .number()
129
+ .gt(0)
130
+ .lte(1)
131
+ .default(0.93)
132
+ .describe("Daily survival for a tester scoring 0–29. More likely than not to be gone before the window closes."),
133
+ unknown: z
134
+ .number()
135
+ .gt(0)
136
+ .lte(1)
137
+ .default(0.985)
138
+ .describe(
139
+ "Daily survival for a tester we cannot observe at all — one who opted in but never signed in, so there is no activity signal. A blanket assumption, deliberately mild: absence of evidence is not evidence of risk.",
140
+ ),
141
+ unreachable: z
142
+ .number()
143
+ .gt(0)
144
+ .lte(1)
145
+ .default(0.95)
146
+ .describe(
147
+ "Daily survival for a tester whose address hard-bounced or was suppressed. Lower than `unknown` because we cannot even nudge them — this is a replace-this-person signal.",
148
+ ),
149
+ })
150
+ .describe(
151
+ "Per-day survival probability by health band. Pithy's declared priors, not values fitted to your data — change them if you disagree, and bump `modelVersion` so your charts annotate the change.",
152
+ );
153
+ export type SurvivalPriors = z.output<typeof SurvivalPriors>;
154
+
155
+ /**
156
+ * Points deducted from a tester's health, by observed condition.
157
+ *
158
+ * Every term is surfaced individually on the API as a `factors[]` entry, so a score is auditable line by
159
+ * line rather than asserted. The dark-day bands are exclusive — exactly one fires.
160
+ */
161
+ export const HealthPenalties = z
162
+ .object({
163
+ darkThreeToFour: z
164
+ .number()
165
+ .int()
166
+ .min(0)
167
+ .default(10)
168
+ .describe("Quiet for 3–4 days. Three days is noise rather than signal — a nudge-worthy shrug."),
169
+ darkFiveToSeven: z
170
+ .number()
171
+ .int()
172
+ .min(0)
173
+ .default(25)
174
+ .describe("Quiet for 5–7 days. A week without opening the app is the first real sign of drift."),
175
+ darkEightToTen: z
176
+ .number()
177
+ .int()
178
+ .min(0)
179
+ .default(45)
180
+ .describe("Quiet for 8–10 days. The strongest single predictor of a silent uninstall."),
181
+ darkElevenToThirteen: z
182
+ .number()
183
+ .int()
184
+ .min(0)
185
+ .default(60)
186
+ .describe("Quiet for 11–13 days. Nearly the whole window with no sign of life."),
187
+ darkFourteenPlus: z
188
+ .number()
189
+ .int()
190
+ .min(0)
191
+ .default(75)
192
+ .describe("Quiet for 14 days or more. Gone longer than the test you are asking them to complete."),
193
+ noTargetPlatformDevice: z
194
+ .number()
195
+ .int()
196
+ .min(0)
197
+ .default(15)
198
+ .describe(
199
+ "No registered device on the cohort's target platform. An Android test needs an Android device, and a tester with only a web session may not be testing the build at all.",
200
+ ),
201
+ unansweredNudge: z
202
+ .number()
203
+ .int()
204
+ .min(0)
205
+ .default(8)
206
+ .describe(
207
+ "Per nudge sent since they last answered. Each nudge is a probe, and the counter clears when they answer one — accepting the invitation or confirming the opt-in. Opening the app does not clear it: activity is a separate signal with its own penalties, and folding the two together would let a tester who never replies look responsive because they installed the app once.",
208
+ ),
209
+ unansweredNudgeCap: z
210
+ .number()
211
+ .int()
212
+ .min(0)
213
+ .default(24)
214
+ .describe(
215
+ "The most `unansweredNudge` may deduct in total. Three unanswered probes is already an answer; a fourth tells you nothing new.",
216
+ ),
217
+ noSessionSinceOptIn: z
218
+ .number()
219
+ .int()
220
+ .min(0)
221
+ .default(20)
222
+ .describe(
223
+ "Confirmed the opt-in link but has not opened the app since. They are counted, but they are not testing.",
224
+ ),
225
+ })
226
+ .describe(
227
+ "Health-score deductions by observed condition. Each is reported as its own factor so the score can be audited.",
228
+ );
229
+ export type HealthPenalties = z.output<typeof HealthPenalties>;
230
+
231
+ /** Points added back to a tester's health, by observed condition. */
232
+ export const HealthCredits = z
233
+ .object({
234
+ engaged: z
235
+ .number()
236
+ .int()
237
+ .min(0)
238
+ .default(10)
239
+ .describe("At least `engagedSessionThreshold` sessions inside the window. A tester who is actually testing."),
240
+ engagedSessionThreshold: z
241
+ .number()
242
+ .int()
243
+ .positive()
244
+ .default(5)
245
+ .describe("How many sessions inside the window count as engaged. Five is a tester opening the app most days."),
246
+ multiDevice: z
247
+ .number()
248
+ .int()
249
+ .min(0)
250
+ .default(5)
251
+ .describe("Two or more registered devices. Someone invested enough to install twice."),
252
+ freshOptIn: z
253
+ .number()
254
+ .int()
255
+ .min(0)
256
+ .default(5)
257
+ .describe(
258
+ "Opted in within the last `freshOptInDays` days, so decay has had no time to apply. Without this a tester who joined this morning reads as neglected.",
259
+ ),
260
+ freshOptInDays: z.number().int().positive().default(3).describe("How recently an opt-in counts as fresh, in days."),
261
+ })
262
+ .describe("Health-score credits by observed condition, and the thresholds that decide when each applies.");
263
+ export type HealthCredits = z.output<typeof HealthCredits>;
264
+
265
+ /** How and when this capability may mail a tester, and what a caller may put in the message. */
266
+ export const NudgePolicy = z
267
+ .object({
268
+ cooldownHours: z
269
+ .number()
270
+ .int()
271
+ .positive()
272
+ .default(72)
273
+ .describe(
274
+ "The minimum hours between two nudges to one tester, enforced server-side on every path. A nudge trigger with no guard is a button that mails the same twelve people repeatedly, and the fastest way to lose a cohort is to become the reason they muted you.",
275
+ ),
276
+ allowCopyOverride: z
277
+ .boolean()
278
+ .default(true)
279
+ .describe(
280
+ "Whether a control-plane caller may supply its own subject and plain-text body. Set false to pin the shipped defaults, which is the right choice if the dashboard credential is shared more widely than the sending domain's reputation can afford.",
281
+ ),
282
+ maxSubjectLength: z
283
+ .number()
284
+ .int()
285
+ .positive()
286
+ .max(200)
287
+ .default(120)
288
+ .describe(
289
+ "The longest overridden subject accepted. A subject longer than this is truncated by every mail client anyway.",
290
+ ),
291
+ maxBodyLength: z
292
+ .number()
293
+ .int()
294
+ .positive()
295
+ .max(20_000)
296
+ .default(4000)
297
+ .describe(
298
+ "The longest overridden body accepted, in characters. Generous for a nudge and small enough that a flood of them cannot be a rendering denial of service.",
299
+ ),
300
+ })
301
+ .describe(
302
+ "Nudge policy: the per-tester cooldown, and whether a caller may supply copy. A caller may author words; this capability always owns the envelope, the rendering, and the delivery.",
303
+ );
304
+ export type NudgePolicy = z.output<typeof NudgePolicy>;
305
+
306
+ /** The defaults every new cohort inherits, and the bounds a cohort may be created within. */
307
+ export const CohortDefaults = z
308
+ .object({
309
+ targetSize: z
310
+ .number()
311
+ .int()
312
+ .positive()
313
+ .default(PLAY_TARGET_SIZE)
314
+ .describe(
315
+ "How many testers must be opted in simultaneously. Twelve, because that is Google Play's requirement for a new personal developer account. TestFlight has no equivalent minimum, so an `ios` cohort may set whatever number the team actually wants.",
316
+ ),
317
+ windowDays: z
318
+ .number()
319
+ .int()
320
+ .positive()
321
+ .max(365)
322
+ .default(PLAY_WINDOW_DAYS)
323
+ .describe(
324
+ "How many continuous days the target must hold. Fourteen, matching Play's rule that the days be consecutive — a tester who opts in, tests for nine days, opts out and opts back in has not banked those nine days.",
325
+ ),
326
+ maxRosterSize: z
327
+ .number()
328
+ .int()
329
+ .positive()
330
+ .max(PLAY_CLOSED_TEST_LIST_LIMIT)
331
+ .default(DEFAULT_MAX_ROSTER_SIZE)
332
+ .describe(
333
+ "The roster cap for a new cohort. A hundred by default — not a store limit, but the size of roster one person can still chase by hand. Play's closed-testing ceiling is 2,000 per email list; the widely-repeated 100 figure is the *internal* track's cap and does not apply here.",
334
+ ),
335
+ targetPlatform: TesterPlatform.default("android").describe(
336
+ "Which store's program new cohorts serve by default. Decides which registered device counts as usable, and nothing else.",
337
+ ),
338
+ storeOptInUrl: z
339
+ .string()
340
+ .nullable()
341
+ .default(null)
342
+ .refine((value) => value === null || isStoreOptInUrl(value), {
343
+ message:
344
+ "storeOptInUrl must be an https URL on play.google.com or testflight.apple.com — the opt-in route renders it as a link on a page served from your own domain, so anything else would put your domain behind a link you did not choose.",
345
+ })
346
+ .describe(
347
+ "The store's own opt-in page every new cohort inherits — `https://play.google.com/apps/testing/<package>` for Play, or a TestFlight public link. Copy it from the console; Google documents no format for it, so Pithy will not guess one. THIS is where a tester actually enrolls: Pithy's confirmation link records that they went and then sends them here.",
348
+ ),
349
+ resetPolicy: ResetPolicy.default("reset").describe(
350
+ "What a dip below target does to the streak, for new cohorts. Pithy's assumption, not Google's documented behavior.",
351
+ ),
352
+ })
353
+ .describe(
354
+ "What a cohort inherits when it is created without explicit values. A cohort stores its own copy, so changing this never rewrites history.",
355
+ );
356
+ export type CohortDefaults = z.output<typeof CohortDefaults>;
357
+
358
+ /** The whole testers configuration. */
359
+ export const TestersConfig = z
360
+ .object({
361
+ basePath: z
362
+ .string()
363
+ .startsWith("/")
364
+ .default("/testers")
365
+ .describe(
366
+ "Where the testers routes mount, the public opt-in link included. Change it and the confirmation links in already-sent invitations break, so pick it before you invite anybody.",
367
+ ),
368
+ baseUrl: z
369
+ .url()
370
+ .optional()
371
+ .describe(
372
+ "The absolute origin the opt-in link is built from, e.g. `https://api.example.com`. Required for invitations, because an email cannot carry a relative URL. Omit it only if you never send one.",
373
+ ),
374
+ cohortDefaults: CohortDefaults.default(CohortDefaults.parse({})).describe("The policy every new cohort inherits."),
375
+ activeWithinDays: z
376
+ .number()
377
+ .int()
378
+ .positive()
379
+ .default(3)
380
+ .describe(
381
+ "How recently a tester must have authenticated to count as `active`. Three days, so a weekend of silence is not an alarm.",
382
+ ),
383
+ optInLinkTtlDays: z
384
+ .number()
385
+ .int()
386
+ .positive()
387
+ .max(365)
388
+ .default(30)
389
+ .describe(
390
+ "How long an opt-in link stays valid. Long enough to survive an inbox nobody checks for a fortnight, short enough that a forwarded link does not stay live for a year.",
391
+ ),
392
+ nudges: NudgePolicy.default(NudgePolicy.parse({})).describe("The nudge cooldown and the copy-override policy."),
393
+ survival: SurvivalPriors.default(SurvivalPriors.parse({})).describe(
394
+ "The per-day survival priors behind the forecast.",
395
+ ),
396
+ healthPenalties: HealthPenalties.default(HealthPenalties.parse({})).describe("The health-score deductions."),
397
+ healthCredits: HealthCredits.default(HealthCredits.parse({})).describe("The health-score credits."),
398
+ snapshotHourUtc: z
399
+ .number()
400
+ .int()
401
+ .min(0)
402
+ .max(23)
403
+ .default(5)
404
+ .describe(
405
+ "The UTC hour the daily pass runs. Five, offset from storage's 03:00 sweep and payments' 04:00 reconciliation so three hosts in one account do not contend for the same minute.",
406
+ ),
407
+ snapshotRetentionDays: z
408
+ .number()
409
+ .int()
410
+ .positive()
411
+ .default(400)
412
+ .describe(
413
+ "How many daily snapshots a cohort keeps. Four hundred days is a year of chart with room either side; a fourteen-day cohort writes fourteen rows and never approaches it.",
414
+ ),
415
+ modelVersion: z
416
+ .string()
417
+ .min(1)
418
+ .max(32)
419
+ .default("1")
420
+ .describe(
421
+ "The label stamped on every snapshot identifying which set of survival and health constants produced it. Bump it whenever you change one: a trend line that silently spans two models is a lie, and the dashboard uses this to annotate where your change took effect.",
422
+ ),
423
+ maxNameLength: z
424
+ .number()
425
+ .int()
426
+ .positive()
427
+ .max(MAX_NAME_LENGTH)
428
+ .default(MAX_NAME_LENGTH)
429
+ .describe("The longest cohort or tester name accepted. Bounded so a roster stays renderable."),
430
+ })
431
+ .describe(
432
+ "Configuration for the testers capability: where it mounts, what a cohort defaults to, and every constant behind the forecast. Pithy's opt-in count is an estimate from your own invite records; Google's count is authoritative and no API exposes it.",
433
+ )
434
+ .check((ctx) => {
435
+ const config = ctx.value;
436
+ // A target larger than the roster cap is a cohort that can never succeed, and the failure would
437
+ // only show up as a forecast that never reaches 1.0 — worth catching at deploy instead.
438
+ if (config.cohortDefaults.targetSize > config.cohortDefaults.maxRosterSize) {
439
+ ctx.issues.push({
440
+ code: "custom",
441
+ input: ctx.value,
442
+ path: ["cohortDefaults", "targetSize"],
443
+ message: `targetSize (${config.cohortDefaults.targetSize}) exceeds maxRosterSize (${config.cohortDefaults.maxRosterSize}). A cohort whose target is larger than its roster cap can never reach target.`,
444
+ });
445
+ }
446
+ // A link that expires before the window closes strands testers mid-cohort, and the symptom — an
447
+ // opt-in route that 400s for exactly the people you most need — is miserable to diagnose live.
448
+ if (config.optInLinkTtlDays < config.cohortDefaults.windowDays) {
449
+ ctx.issues.push({
450
+ code: "custom",
451
+ input: ctx.value,
452
+ path: ["optInLinkTtlDays"],
453
+ message: `optInLinkTtlDays (${config.optInLinkTtlDays}) is shorter than windowDays (${config.cohortDefaults.windowDays}). An invitation would expire before the test window it belongs to closes.`,
454
+ });
455
+ }
456
+ // Activity is the early-warning signal; a window wider than the test window would report a tester
457
+ // as active on the strength of a session from before the cohort existed.
458
+ if (config.activeWithinDays >= config.cohortDefaults.windowDays) {
459
+ ctx.issues.push({
460
+ code: "custom",
461
+ input: ctx.value,
462
+ path: ["activeWithinDays"],
463
+ message: `activeWithinDays (${config.activeWithinDays}) must be shorter than windowDays (${config.cohortDefaults.windowDays}), or every tester reads as active for the whole window and the early-warning signal is dead.`,
464
+ });
465
+ }
466
+ // Retention shorter than the window means the chart loses the beginning of the very cohort it is
467
+ // drawing — the only period where a reset is still explicable.
468
+ if (config.snapshotRetentionDays < config.cohortDefaults.windowDays) {
469
+ ctx.issues.push({
470
+ code: "custom",
471
+ input: ctx.value,
472
+ path: ["snapshotRetentionDays"],
473
+ message: `snapshotRetentionDays (${config.snapshotRetentionDays}) is shorter than windowDays (${config.cohortDefaults.windowDays}). The trend would be pruned out from under the window it describes.`,
474
+ });
475
+ }
476
+ });
477
+ export type TestersConfig = z.output<typeof TestersConfig>;
478
+ export type TestersConfigInput = z.input<typeof TestersConfig>;
479
+
480
+ /**
481
+ * The absolute base URL, or a stated failure. Invitations cannot be built without it, so the check
482
+ * lives here rather than at each send site.
483
+ */
484
+ export function requireBaseUrl(config: TestersConfig): string {
485
+ if (!config.baseUrl) {
486
+ throw new TestersNotConfiguredError({
487
+ message: "Invitations cannot be sent yet.",
488
+ action: "Set `baseUrl` on the testers block in pithy.config.ts — an email cannot carry a relative link.",
489
+ detail: "testers.baseUrl is unset, so an absolute opt-in URL cannot be built",
490
+ });
491
+ }
492
+ return config.baseUrl.replace(/\/+$/, "");
493
+ }
494
+
495
+ /**
496
+ * The link in the first email: "yes, I will test."
497
+ *
498
+ * Records their consent and tells the developer to add the address to the store's tester list. No store
499
+ * link is shown here, because it would not work yet.
500
+ */
501
+ export function confirmUrl(config: TestersConfig, token: string): string {
502
+ return `${requireBaseUrl(config)}${config.basePath}/confirm/${token}`;
503
+ }
504
+
505
+ /**
506
+ * The link in the second email: through to the store's own opt-in page.
507
+ *
508
+ * Sent only once the developer has added the address to the tester list. It records the strongest
509
+ * enrollment signal Pithy can observe and then hands the tester the store's link.
510
+ */
511
+ export function optInUrl(config: TestersConfig, token: string): string {
512
+ return `${requireBaseUrl(config)}${config.basePath}/opt-in/${token}`;
513
+ }
514
+
515
+ /** The link a tester follows to withdraw. */
516
+ export function optOutUrl(config: TestersConfig, token: string): string {
517
+ return `${requireBaseUrl(config)}${config.basePath}/opt-out/${token}`;
518
+ }
@@ -0,0 +1,60 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The tester's confirmation credential.
6
+ *
7
+ * **The token is a row, not a signature.** Thirty-two bytes of CSPRNG stored on the member, looked up
8
+ * on every visit — the same shape `@pithy-sh/storage` uses for share links, and chosen here for three
9
+ * reasons that all point the same way.
10
+ *
11
+ * **It can be revoked.** A signed token can only expire; nothing the developer does calls one back. A
12
+ * row means removing a tester kills their link on the next request, which is what you want when someone
13
+ * forwards an invitation to a colleague or leaves the company mid-test.
14
+ *
15
+ * **It needs no secret, so it can be created anywhere.** Signing would mean reading a key from the
16
+ * secrets store, and outside the Worker that is only possible in local dev — reaching a runtime secret
17
+ * through the provisioning client is explicitly not allowed. A random token is generated when the member
18
+ * row is created, so `pithy testers invite` builds a working invitation against any environment and
19
+ * nothing has to be minted at send time.
20
+ *
21
+ * **The statelessness it gives up was never worth anything here.** The argument for a signed token is
22
+ * that it verifies without touching the database — but the opt-in route writes to the database on the
23
+ * same request regardless, so it was never avoiding a lookup. A shape check rejects garbage before the
24
+ * query, which is the only part that mattered.
25
+ *
26
+ * What the token is *not*: it is not what enrolls a tester with Google. It records that they followed our
27
+ * link, and the route then sends them on to the store's own opt-in page, which is where enrollment
28
+ * actually happens. See `docs/store-apis.md`.
29
+ */
30
+
31
+ /** How many bytes of entropy the token carries. The token is the whole credential, so this is the gate. */
32
+ const TOKEN_BYTES = 32;
33
+
34
+ /**
35
+ * A token as it appears in a URL: base64url, unpadded.
36
+ *
37
+ * Bounded and character-restricted so a malformed segment is refused before it reaches a query. This
38
+ * checks *shape*, never authenticity — a well-formed token that matches no row still fails, with the
39
+ * same words, which is what keeps the public route from being an oracle.
40
+ */
41
+ export const OPT_IN_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
42
+
43
+ /**
44
+ * Generate a confirmation token.
45
+ *
46
+ * 256 bits, base64url-encoded and unpadded so it is safe in a path segment without escaping. The token
47
+ * is the entire credential standing between a link and anyone guessing one, which is why the entropy is
48
+ * the same as a storage share link rather than something shorter and friendlier.
49
+ */
50
+ export function generateOptInToken(): string {
51
+ const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES));
52
+ let binary = "";
53
+ for (const byte of bytes) binary += String.fromCharCode(byte);
54
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
55
+ }
56
+
57
+ /** Whether a path segment is shaped like one of our tokens. Shape only — never authenticity. */
58
+ export function isOptInTokenShape(value: string): boolean {
59
+ return OPT_IN_TOKEN_PATTERN.test(value);
60
+ }
@@ -0,0 +1,83 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { ResetPolicy, TesterPlatform } from "../config/config";
7
+
8
+ /**
9
+ * One testing cohort — the row in `pithy_testers_cohorts`.
10
+ *
11
+ * **The target, window, and reset policy are stored on the row, not read from config at query time.**
12
+ * A cohort runs for a fortnight and config is redeployed whenever anything else changes; if the clock
13
+ * read its rules live, raising `targetSize` from twelve to fifteen would retroactively rewrite whether
14
+ * last Tuesday counted, and the trend chart would silently change shape behind the developer. A cohort
15
+ * inherits the defaults when it is created, then owns its copy for life.
16
+ */
17
+ export const TestersCohort = z
18
+ .object({
19
+ id: z
20
+ .string()
21
+ .describe(
22
+ "The cohort's UUID. Text rather than an autoincrement integer because cohort ids appear in control-plane responses and CLI output, and a sequential id would leak how many test programs a project has run.",
23
+ ),
24
+ name: z
25
+ .string()
26
+ .describe("A human label for the cohort, e.g. `launch-closed-test`. Shown in the CLI and the dashboard."),
27
+ targetPlatform: TesterPlatform.describe(
28
+ "Which store's program this cohort serves. Decides which registered device counts as usable when scoring a tester's health, and nothing else.",
29
+ ),
30
+ targetSize: z
31
+ .number()
32
+ .int()
33
+ .positive()
34
+ .describe(
35
+ "How many testers must be opted in simultaneously. Google Play requires twelve; the value is frozen on the row so a later config change cannot rewrite whether a past day counted.",
36
+ ),
37
+ windowDays: z
38
+ .number()
39
+ .int()
40
+ .positive()
41
+ .describe(
42
+ "How many continuous days the target must hold. Fourteen for Play. Frozen on the row for the same reason as `targetSize`.",
43
+ ),
44
+ maxRosterSize: z
45
+ .number()
46
+ .int()
47
+ .positive()
48
+ .describe(
49
+ "The most members this cohort's roster may hold. Refusing the invitation is better than discovering the cap at the store.",
50
+ ),
51
+ storeOptInUrl: z
52
+ .string()
53
+ .nullable()
54
+ .describe(
55
+ "The store's own opt-in page — `https://play.google.com/apps/testing/<package>` for Play, a `https://testflight.apple.com/join/<code>` public link for TestFlight. THIS is where a tester actually enrolls; Pithy's confirmation link only records that they went. Pasted from the console rather than derived, because Google documents no format for it. Null until set, and the invitation says so rather than sending anyone nowhere.",
56
+ ),
57
+ resetPolicy: ResetPolicy.describe(
58
+ "Pithy's assumption about what a dip below target does to the streak. Stored per cohort so changing the project default never silently re-reads a finished cohort's history.",
59
+ ),
60
+ closedAt: SQLiteDate.nullable().describe(
61
+ "When the developer closed this cohort, or null while it is running. A closed cohort stops accruing snapshots and nudges but keeps its history.",
62
+ ),
63
+ createdAt: SQLiteDate.describe("When the cohort was created. The zero point of its `dayIndex` axis."),
64
+ updatedAt: SQLiteDate.describe("When the cohort row was last written."),
65
+ })
66
+ // The one rule that spans two fields, and therefore the one the field schemas cannot carry alone. A
67
+ // target larger than the roster cap can never be reached, however many people accept — so it is an
68
+ // author error, and the schema is where an author error about this table's shape belongs.
69
+ .check((ctx) => {
70
+ if (ctx.value.maxRosterSize < ctx.value.targetSize) {
71
+ ctx.issues.push({
72
+ code: "custom",
73
+ message: `targetSize (${ctx.value.targetSize}) exceeds maxRosterSize (${ctx.value.maxRosterSize}). A cohort whose target is larger than its roster cap can never reach target.`,
74
+ input: ctx.value,
75
+ path: ["targetSize"],
76
+ });
77
+ }
78
+ })
79
+ .describe(
80
+ "One testing cohort in `pithy_testers_cohorts` — the roster's owner, and the frozen copy of the rules its clock is measured against.",
81
+ );
82
+ export type TestersCohort = z.output<typeof TestersCohort>;
83
+ export type TestersCohortRow = z.input<typeof TestersCohort>;