@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,210 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { OPT_IN_TOKEN_PATTERN } from "../crypto/token";
6
+ import { NudgeKind } from "../data/enums";
7
+
8
+ /**
9
+ * Every request contract this capability accepts. There is nowhere else input may be declared, and no
10
+ * handler parses anything itself — a handler receives typed values, so the route signature carries the
11
+ * whole contract.
12
+ *
13
+ * **What is deliberately absent is as much of the design as what is here.** No request names a sender,
14
+ * a signing key, or an environment: those come from the deployment. No request supplies a tester's
15
+ * opt-in date: that comes from the link they followed. And no request accepts HTML anywhere, for
16
+ * the reason `nudge/copy.ts` sets out at length — words go out over the adopter's own DKIM signature,
17
+ * so bounding the input to text is what bounds the blast radius of a leaked dashboard credential.
18
+ */
19
+
20
+ /** The longest a cohort or tester name may be in a request. Matches the config bound. */
21
+ const MAX_NAME_LENGTH = 120;
22
+
23
+ /** The longest an overridden subject may be. Longer is truncated by every mail client anyway. */
24
+ const MAX_SUBJECT_LENGTH = 200;
25
+
26
+ /**
27
+ * The longest an overridden body may be.
28
+ *
29
+ * Generous for a nudge and small enough that a flood of them cannot be a rendering denial of service.
30
+ * The config bound may be tighter; this is the ceiling the route will parse at all.
31
+ */
32
+ const MAX_BODY_LENGTH = 20_000;
33
+
34
+ /**
35
+ * The path parameter carrying an opt-in or opt-out token.
36
+ *
37
+ * This bounds the *shape* of the segment; it does not authenticate anything and must not be mistaken
38
+ * for doing so. A well-formed token that belongs to nobody still fails, with the capability's own
39
+ * `testers/invalid_token` — the same code a real lookup miss raises, and deliberately so: a malformed
40
+ * segment is answered by the validator with `validation/invalid_input`, so the two failures a caller
41
+ * can tell apart are "that is not a token" and "that token opens nothing", never "that token belongs
42
+ * to somebody else".
43
+ */
44
+ export const OptInTokenParam = z
45
+ .object({
46
+ token: z
47
+ .string()
48
+ // The generator's own pattern, imported rather than restated. These two drifted apart once
49
+ // already — the schema still described the old signed `payload.signature` form after the token
50
+ // became a single 43-character value — and every tester link 400'd before reaching a handler.
51
+ .regex(OPT_IN_TOKEN_PATTERN)
52
+ .describe(
53
+ "The confirmation token from the path. Shape only, and deliberately not authenticity: a well-formed token matching no member still fails, with the same words.",
54
+ ),
55
+ })
56
+ .describe("The path parameter carrying a testers opt-in or opt-out token.");
57
+ export type OptInTokenParam = z.infer<typeof OptInTokenParam>;
58
+
59
+ /** Creating a cohort. Every field but the name inherits from config when omitted. */
60
+ export const CreateCohortRequest = z
61
+ .object({
62
+ name: z
63
+ .string()
64
+ .min(1)
65
+ .max(MAX_NAME_LENGTH)
66
+ .describe("A human label for the cohort, unique within the project. Shown in the CLI and the dashboard."),
67
+ targetSize: z
68
+ .number()
69
+ .int()
70
+ .positive()
71
+ .optional()
72
+ .describe(
73
+ "How many testers must be opted in simultaneously. Defaults to the configured value — twelve, for Google Play.",
74
+ ),
75
+ windowDays: z
76
+ .number()
77
+ .int()
78
+ .positive()
79
+ .optional()
80
+ .describe(
81
+ "How many continuous days the target must hold. Defaults to the configured value — fourteen, for Google Play.",
82
+ ),
83
+ maxRosterSize: z.number().int().positive().optional().describe("The roster cap. Defaults to the configured value."),
84
+ targetPlatform: z
85
+ .enum(["android", "ios"])
86
+ .optional()
87
+ .describe("Which store's program this cohort serves. Defaults to the configured value."),
88
+ })
89
+ .describe(
90
+ "Create a testing cohort. Omitted fields inherit the project's configured defaults and are then frozen on the row.",
91
+ );
92
+ export type CreateCohortRequest = z.infer<typeof CreateCohortRequest>;
93
+
94
+ /** Inviting one address onto a cohort. */
95
+ export const InviteRequest = z
96
+ .object({
97
+ cohortId: z.string().min(1).describe("The cohort to invite them onto."),
98
+ email: z
99
+ .email()
100
+ .max(320)
101
+ .describe("The tester's address. Lowercased on write, and the join key to the user record if they ever sign in."),
102
+ name: z.string().max(MAX_NAME_LENGTH).nullish().describe("A display name for the roster. Optional."),
103
+ sendInvitation: z
104
+ .boolean()
105
+ .default(true)
106
+ .describe(
107
+ "Whether to email the confirmation link now. False adds them to the roster silently — for importing a list of people already contacted elsewhere, so their history starts in the right place.",
108
+ ),
109
+ })
110
+ .describe("Invite one address onto a cohort.");
111
+ export type InviteRequest = z.output<typeof InviteRequest>;
112
+
113
+ /** Sending another invitation to someone already on the roster. */
114
+ export const ResendRequest = z
115
+ .object({
116
+ memberId: z.string().min(1).describe("The roster member to send another invitation to."),
117
+ })
118
+ .describe("Send another invitation, preserving the tester's existing history and their original invited date.");
119
+ export type ResendRequest = z.infer<typeof ResendRequest>;
120
+
121
+ /** Taking a tester off a cohort. */
122
+ export const RemoveRequest = z
123
+ .object({
124
+ memberId: z.string().min(1).describe("The roster member to remove."),
125
+ reason: z
126
+ .string()
127
+ .max(200)
128
+ .optional()
129
+ .describe("A short note recorded on the event, for the developer's own records. Never shown to the tester."),
130
+ })
131
+ .describe("Take a tester off a cohort's roster. Recorded as the developer's act, not the tester's.");
132
+ export type RemoveRequest = z.infer<typeof RemoveRequest>;
133
+
134
+ /**
135
+ * Triggering a nudge.
136
+ *
137
+ * **`subject` and `body` are the only overridable fields, and both are plain text.** There is no HTML
138
+ * field, no template id, and no layout option, because this Worker owns the envelope and signs it with
139
+ * the adopter's domain. `body` is split into paragraphs and each renders HTML-escaped, so markup
140
+ * arrives as visible text rather than as markup — structurally, not by filtering.
141
+ */
142
+ export const NudgeRequest = z
143
+ .object({
144
+ cohortId: z.string().min(1).describe("The cohort whose testers to nudge."),
145
+ kind: NudgeKind.describe(
146
+ "Which nudge to send. Decides the default copy and, for `confirm`, whether a link is attached.",
147
+ ),
148
+ memberIds: z
149
+ .array(z.string().min(1))
150
+ .max(500)
151
+ .optional()
152
+ .describe(
153
+ "Specific testers to nudge. Omit to let the kind select them — `confirm` targets everyone who has not confirmed, `inactive` everyone who has gone quiet.",
154
+ ),
155
+ subject: z
156
+ .string()
157
+ .max(MAX_SUBJECT_LENGTH)
158
+ .optional()
159
+ .describe(
160
+ "An overridden subject line, as plain text. Control characters and newlines are stripped before it reaches the renderer, because a newline in a header is how a subject becomes an injected one.",
161
+ ),
162
+ body: z
163
+ .string()
164
+ .max(MAX_BODY_LENGTH)
165
+ .optional()
166
+ .describe(
167
+ "An overridden body, as PLAIN TEXT only. Blank lines separate paragraphs. Markup is not accepted and not stripped — it is escaped, so it reaches the recipient as visible characters. There is no field that accepts HTML, deliberately: supplied words go out over your own DKIM signature.",
168
+ ),
169
+ dryRun: z
170
+ .boolean()
171
+ .default(false)
172
+ .describe(
173
+ "Report who would be nudged and enqueue nothing. The safe way to check a selection before mailing twelve people.",
174
+ ),
175
+ })
176
+ .describe(
177
+ "Trigger a nudge to selected testers. The per-tester cooldown is enforced server-side on this path and cannot be overridden by the caller.",
178
+ );
179
+ export type NudgeRequest = z.output<typeof NudgeRequest>;
180
+
181
+ /** Reading cohort state. Cheap by default; the expensive parts are opt-in. */
182
+ export const CohortsQuery = z
183
+ .object({
184
+ cohortId: z.string().min(1).optional().describe("Restrict to one cohort. Omit for every cohort in this Worker."),
185
+ members: z
186
+ .stringbool()
187
+ .default(false)
188
+ .describe("Include the full roster with per-tester activity and health. Costs the roster join."),
189
+ trend: z
190
+ .stringbool()
191
+ .default(false)
192
+ .describe("Include the trailing daily snapshots the chart is drawn from. Costs `trendDays` rows per cohort."),
193
+ trendDays: z.coerce
194
+ .number()
195
+ .int()
196
+ .min(1)
197
+ .max(180)
198
+ .default(30)
199
+ .describe("How many trailing daily snapshots to return when `trend` is set."),
200
+ })
201
+ .describe("Query contract for the control-plane cohort read. The summary is one row per cohort; extras are opt-in.");
202
+ export type CohortsQuery = z.output<typeof CohortsQuery>;
203
+
204
+ /** A tester reading their own position. */
205
+ export const StatusQuery = z
206
+ .object({
207
+ cohortId: z.string().min(1).optional().describe("Restrict to one cohort. Omit for every cohort this tester is on."),
208
+ })
209
+ .describe("Query contract for a tester's own view of where they stand.");
210
+ export type StatusQuery = z.output<typeof StatusQuery>;
@@ -0,0 +1,79 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
5
+ import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
6
+
7
+ /**
8
+ * Testers' control-plane scopes, and the admin surface a manifest advertises.
9
+ *
10
+ * **Separate from `guards.ts` because a scope name is a client's business** (#315). A management
11
+ * client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
12
+ * writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
13
+ * program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
14
+ * one compiled `PithyHonoEnv`, which reached core's `capability.ts`, which named Worker globals that
15
+ * program has none of. **This module imports types and nothing else, and a gate holds it there**:
16
+ * `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
17
+ */
18
+
19
+ /**
20
+ * The scopes this capability's admin routes require.
21
+ *
22
+ * One scope per operation, matched by exact string equality — there is no prefix rule and no wildcard.
23
+ * A single `testers:admin` flag would mean a dashboard credential issued to read a roster could also
24
+ * mail every person on it, and mailing is the operation with a blast radius outside the adopter's own
25
+ * systems.
26
+ */
27
+ export const TESTERS_ROSTER_READ_SCOPE: ControlPlaneScope = "testers:roster:read";
28
+ export const TESTERS_ROSTER_WRITE_SCOPE: ControlPlaneScope = "testers:roster:write";
29
+ export const TESTERS_NUDGE_SEND_SCOPE: ControlPlaneScope = "testers:nudge:send";
30
+
31
+ /** Every control-plane scope this capability defines. */
32
+ export const TESTERS_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
33
+ TESTERS_ROSTER_READ_SCOPE,
34
+ TESTERS_ROSTER_WRITE_SCOPE,
35
+ TESTERS_NUDGE_SEND_SCOPE,
36
+ ];
37
+
38
+ /**
39
+ * The admin routes this capability advertises on `GET /control-plane/manifest`.
40
+ *
41
+ * Built from the **resolved** `basePath`, never the default. An adopter who mounts this at `/beta` gets
42
+ * a manifest naming `/beta/testers/...`, where a client assuming the default would 404 — and the whole
43
+ * point of the manifest is that a management client composes its calls from the Worker rather than from
44
+ * a route table it shipped with.
45
+ */
46
+ export function testersAdminRoutes(basePath: string): AdminRoute[] {
47
+ return [
48
+ {
49
+ method: "GET",
50
+ path: `${basePath}/cohorts`,
51
+ scope: TESTERS_ROSTER_READ_SCOPE,
52
+ summary: "Cohort state, roster, per-tester activity, the forecast, and the trend series.",
53
+ },
54
+ {
55
+ method: "POST",
56
+ path: `${basePath}/invite`,
57
+ scope: TESTERS_ROSTER_WRITE_SCOPE,
58
+ summary: "Invite an address onto a cohort and send its confirmation link.",
59
+ },
60
+ {
61
+ method: "POST",
62
+ path: `${basePath}/resend`,
63
+ scope: TESTERS_ROSTER_WRITE_SCOPE,
64
+ summary: "Send another invitation, preserving the tester's existing history.",
65
+ },
66
+ {
67
+ method: "POST",
68
+ path: `${basePath}/remove`,
69
+ scope: TESTERS_ROSTER_WRITE_SCOPE,
70
+ summary: "Take a tester off a cohort's roster.",
71
+ },
72
+ {
73
+ method: "POST",
74
+ path: `${basePath}/nudge`,
75
+ scope: TESTERS_NUDGE_SEND_SCOPE,
76
+ summary: "Nudge selected testers. Copy may be overridden as text; the cooldown is enforced here.",
77
+ },
78
+ ];
79
+ }
@@ -0,0 +1,304 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { hasPlatformDevice } from "../activity/resolve";
5
+ import { dayKey, daysSince } from "../clock/days";
6
+ import type { TestersConfig } from "../config/config";
7
+ import type { TestersCohortSnapshot } from "../data/snapshot";
8
+ import { scoreHealth, survivalFor } from "../health/score";
9
+ import { cooldownUntil } from "../nudge/cooldown";
10
+ import type { MemberReading } from "../projection/build";
11
+ import { type ForecastMember, forecastCohort, type PipelineMember } from "../projection/forecast";
12
+ import { cohortAgeDays, conversionLatency } from "../projection/inputs";
13
+ import type { CohortReading } from "../roster/read";
14
+ import { LIVE_STATES } from "../roster/write";
15
+ import { type CohortView, DISCLAIMER, type MemberView, type SnapshotView, type TrendView } from "./responses";
16
+
17
+ /**
18
+ * Turning a cohort reading into the shape a dashboard receives.
19
+ *
20
+ * The one rule this file exists to enforce is the separation the response schema declares: the
21
+ * estimated half and the observed half go into different objects, each carrying a literal `source`, and
22
+ * the disclaimer is attached unconditionally rather than by any code path that could be missed. There
23
+ * is no branch here that produces a cohort without it.
24
+ */
25
+
26
+ /** An ISO string, or null. */
27
+ function iso(date: Date | null | undefined): string | null {
28
+ return date ? date.toISOString() : null;
29
+ }
30
+
31
+ /** Build one tester's view, health included. */
32
+ function memberView(
33
+ reading: MemberReading,
34
+ cohortReading: CohortReading,
35
+ config: TestersConfig,
36
+ now: Date,
37
+ ): MemberView {
38
+ const { member, activity } = reading;
39
+ const optedInAt = member.optedInAt;
40
+
41
+ // The dark clock floors at the opt-in date, so a tester who confirmed this morning reads as fresh
42
+ // rather than as however long ago they were first invited.
43
+ const lastSignOfLife = [activity.lastAuthenticatedAt, optedInAt]
44
+ .filter((date): date is Date => date !== null)
45
+ .reduce<Date | null>((latest, date) => (latest === null || date > latest ? date : latest), null);
46
+
47
+ const daysDark =
48
+ activity.observability === "observed" && lastSignOfLife !== null ? daysSince(lastSignOfLife, now) : null;
49
+
50
+ const health = scoreHealth(
51
+ {
52
+ observability: activity.observability,
53
+ daysDark,
54
+ sessionsInWindow: activity.sessionsInWindow,
55
+ deviceCount: activity.devices.length,
56
+ hasTargetPlatformDevice: hasPlatformDevice(activity, cohortReading.cohort.targetPlatform),
57
+ sessionSinceOptIn:
58
+ optedInAt !== null && activity.lastAuthenticatedAt !== null && activity.lastAuthenticatedAt >= optedInAt,
59
+ unansweredNudges: member.nudgeCount,
60
+ daysSinceOptIn: optedInAt === null ? null : daysSince(optedInAt, now),
61
+ targetPlatform: cohortReading.cohort.targetPlatform,
62
+ },
63
+ config.healthPenalties,
64
+ config.healthCredits,
65
+ );
66
+
67
+ return {
68
+ id: member.id,
69
+ email: member.email,
70
+ name: member.name,
71
+ state: member.state,
72
+ invitedAt: member.invitedAt.toISOString(),
73
+ acceptedAt: iso(member.acceptedAt),
74
+ estimatedOptedInAt: iso(member.optedInAt),
75
+ lapsedAt: iso(member.lapsedAt),
76
+ estimatedOptInDays: optedInAt === null ? 0 : daysSince(optedInAt, now),
77
+ activity: {
78
+ source: "observed",
79
+ observability: activity.observability,
80
+ state: activity.state,
81
+ lastAuthenticatedAt: iso(activity.lastAuthenticatedAt),
82
+ // Only an `inactive` tester has a "since". A never-linked one has no date to report, and
83
+ // inventing one would erase the distinction the whole activity model turns on.
84
+ inactiveSince: activity.state === "inactive" ? iso(activity.lastAuthenticatedAt) : null,
85
+ daysDark,
86
+ sessionsInWindow: activity.sessionsInWindow,
87
+ devices: activity.devices.map((device) => ({
88
+ platform: device.platform,
89
+ lastSeenAt: device.lastSeenAt.toISOString(),
90
+ appVersion: device.appVersion,
91
+ })),
92
+ },
93
+ health: health.health,
94
+ healthBasis: health.basis,
95
+ riskBand: health.riskBand,
96
+ dailySurvival: survivalFor(health.riskBand, activity.observability, config.survival),
97
+ factors: health.factors.map((factor) => ({ ...factor })),
98
+ lastNudgedAt: iso(member.lastNudgedAt),
99
+ nudgeCooldownUntil: iso(cooldownUntil(member, config.nudges.cooldownHours)),
100
+ unreachable: member.unreachable,
101
+ };
102
+ }
103
+
104
+ /** Project a stored snapshot into the chart's point shape. */
105
+ export function snapshotView(snapshot: TestersCohortSnapshot): SnapshotView {
106
+ return {
107
+ snapshotOn: snapshot.snapshotOn,
108
+ dayIndex: snapshot.dayIndex,
109
+ backfilled: snapshot.backfilled,
110
+ modelVersion: snapshot.modelVersion,
111
+ rosterSize: snapshot.rosterSize,
112
+ invitedCount: snapshot.invitedCount,
113
+ acceptedCount: snapshot.acceptedCount,
114
+ estimatedOptedInCount: snapshot.estimatedOptedInCount,
115
+ lapsedCount: snapshot.lapsedCount,
116
+ targetSize: snapshot.targetSize,
117
+ meetsTarget: snapshot.meetsTarget,
118
+ headroom: snapshot.headroom,
119
+ estimatedHeldDays: snapshot.estimatedHeldDays,
120
+ estimatedDaysRemaining: snapshot.estimatedDaysRemaining,
121
+ resetToday: snapshot.resetToday,
122
+ resetCount: snapshot.resetCount,
123
+ activeCount: snapshot.activeCount,
124
+ darkThreeToSevenCount: snapshot.darkThreeToSevenCount,
125
+ darkEightToThirteenCount: snapshot.darkEightToThirteenCount,
126
+ darkFourteenPlusCount: snapshot.darkFourteenPlusCount,
127
+ neverLinkedCount: snapshot.neverLinkedCount,
128
+ observedCoverage: snapshot.observedCoverage,
129
+ medianHealth: snapshot.medianHealth,
130
+ minHealth: snapshot.minHealth,
131
+ successProbability: snapshot.successProbability,
132
+ successProbabilityLow: snapshot.successProbabilityLow,
133
+ successProbabilityHigh: snapshot.successProbabilityHigh,
134
+ expectedSurvivors: snapshot.expectedSurvivors,
135
+ invitesNeeded: snapshot.invitesNeeded,
136
+ trendDirection: snapshot.trendDirection,
137
+ trendReason: snapshot.trendReason,
138
+ fragile: snapshot.fragile,
139
+ nudgesSent: snapshot.nudgesSent,
140
+ };
141
+ }
142
+
143
+ /** What the view needs beyond the reading itself. */
144
+ export interface ViewOptions {
145
+ readonly includeMembers: boolean;
146
+ /** Trailing snapshots, oldest first. Empty when the trend was not requested. */
147
+ readonly snapshots: readonly TestersCohortSnapshot[];
148
+ /** The most recent snapshot, whether or not the series was requested — it carries the precomputed trend. */
149
+ readonly latest: TestersCohortSnapshot | undefined;
150
+ }
151
+
152
+ /** Assemble the cohort view a dashboard receives. */
153
+ export function toCohortView(
154
+ reading: CohortReading,
155
+ config: TestersConfig,
156
+ options: ViewOptions,
157
+ now: Date,
158
+ ): CohortView {
159
+ const { cohort, clock, readings } = reading;
160
+
161
+ const members = readings.map((entry) => memberView(entry, reading, config, now));
162
+ const byState = (state: string) => members.filter((member) => member.state === state).length;
163
+ const optedIn = members.filter((member) => member.state === "opted_in");
164
+ const observed = optedIn.filter((member) => member.activity.observability === "observed");
165
+
166
+ const today = dayKey(now);
167
+ const forecast = forecastCohort({
168
+ today,
169
+ targetSize: cohort.targetSize,
170
+ optedInCount: clock.estimatedOptedInCount,
171
+ daysRemaining: clock.estimatedDaysRemaining,
172
+ members: optedIn.map(
173
+ (member): ForecastMember => ({
174
+ riskBand: member.riskBand,
175
+ observability: member.activity.observability,
176
+ dailySurvival: member.dailySurvival,
177
+ }),
178
+ ),
179
+ pipeline: members
180
+ .filter((member) => member.state === "invited" || member.state === "accepted")
181
+ .map(
182
+ (member): PipelineMember => ({
183
+ stage: member.state === "accepted" ? "accepted" : "invited",
184
+ unreachable: member.unreachable,
185
+ }),
186
+ ),
187
+ optedInEver: members.filter((member) => member.estimatedOptedInAt !== null).length,
188
+ invitedEver: members.length,
189
+ // Derived exactly as `buildSnapshot` derives them. Hardcoding `null`/`0` here could never clear the
190
+ // forecast's five-conversion evidence gate, so every live read used the default three-day prior
191
+ // even for a cohort with a hundred measured conversions — while the snapshot written the same
192
+ // morning used the real one, and the two projected different completion dates.
193
+ ...conversionLatency(readings.map((entry) => entry.member)),
194
+ cohortAgeDays: cohortAgeDays(cohort.createdAt, today),
195
+ maxRosterSize: cohort.maxRosterSize,
196
+ });
197
+
198
+ const weakMemberCount = optedIn.filter(
199
+ (member) => member.riskBand === "at_risk" || member.riskBand === "critical",
200
+ ).length;
201
+
202
+ const latest = options.latest;
203
+ const trend: TrendView = {
204
+ // The direction comes off the stored snapshot rather than being recomputed here: the deltas it
205
+ // needs are historical, and recomputing them on read would give a card and a chart two different
206
+ // answers to the same question.
207
+ direction: latest?.trendDirection ?? "unknown",
208
+ reason: latest?.trendReason ?? "Not enough history yet.",
209
+ // All three conjuncts, matching `computeTrend` and both schema descriptions: at target, no
210
+ // headroom, AND at least one weak tester. Dropping the third made a twelve-of-twelve cohort of
211
+ // entirely healthy testers raise "One lapse from a reset." until its first snapshot landed, at
212
+ // which point the warning vanished with nothing about the cohort having changed.
213
+ fragile: latest?.fragile ?? (clock.meetsTarget && clock.headroom === 0 && weakMemberCount > 0),
214
+ optedInDelta1d: latest?.optedInDelta1d ?? null,
215
+ optedInDelta7d: latest?.optedInDelta7d ?? null,
216
+ activeDelta7d: latest?.activeDelta7d ?? null,
217
+ successProbabilityDelta7d: latest?.successProbabilityDelta7d ?? null,
218
+ series: options.snapshots.map(snapshotView),
219
+ };
220
+
221
+ return {
222
+ id: cohort.id,
223
+ name: cohort.name,
224
+ targetPlatform: cohort.targetPlatform,
225
+ targetSize: cohort.targetSize,
226
+ windowDays: cohort.windowDays,
227
+ maxRosterSize: cohort.maxRosterSize,
228
+ resetPolicy: cohort.resetPolicy,
229
+ createdAt: cohort.createdAt.toISOString(),
230
+ closedAt: iso(cohort.closedAt),
231
+
232
+ roster: {
233
+ size: members.length,
234
+ // Counted against the same live states `inviteMember` enforces the cap on. Counting `lapsed`
235
+ // members as occupying slots reported no room when an invitation would in fact have succeeded.
236
+ headroomToMax: Math.max(0, cohort.maxRosterSize - members.filter((m) => LIVE_STATES.includes(m.state)).length),
237
+ invited: byState("invited"),
238
+ accepted: byState("accepted"),
239
+ optedIn: optedIn.length,
240
+ lapsed: byState("lapsed") + byState("removed"),
241
+ unreachable: members.filter((member) => member.unreachable).length,
242
+ neverLinked: optedIn.filter((member) => member.activity.state === "never_linked").length,
243
+ },
244
+
245
+ estimatedClock: {
246
+ source: "pithy_estimate",
247
+ meetsTarget: clock.meetsTarget,
248
+ headroom: clock.headroom,
249
+ estimatedHeldDays: clock.estimatedHeldDays,
250
+ estimatedDaysRemaining: clock.estimatedDaysRemaining,
251
+ estimatedWindowStartOn: clock.estimatedWindowStartOn,
252
+ resetCount: clock.resetCount,
253
+ dayBoundary: "UTC",
254
+ },
255
+
256
+ activity: {
257
+ source: "observed",
258
+ active: observed.filter((member) => member.activity.state === "active").length,
259
+ darkThreeToSeven: observed.filter(
260
+ (m) => m.activity.daysDark !== null && m.activity.daysDark >= 3 && m.activity.daysDark <= 7,
261
+ ).length,
262
+ darkEightToThirteen: observed.filter(
263
+ (m) => m.activity.daysDark !== null && m.activity.daysDark >= 8 && m.activity.daysDark <= 13,
264
+ ).length,
265
+ darkFourteenPlus: observed.filter((m) => m.activity.daysDark !== null && m.activity.daysDark >= 14).length,
266
+ neverLinked: optedIn.filter((member) => member.activity.state === "never_linked").length,
267
+ observedCoverage: optedIn.length === 0 ? 0 : observed.length / optedIn.length,
268
+ },
269
+
270
+ projection: {
271
+ basis: forecast.basis,
272
+ calibration: forecast.calibration,
273
+ method: forecast.method,
274
+ confidence: forecast.confidence,
275
+ observedCoverage: forecast.observedCoverage,
276
+ probabilityReachTarget: forecast.probabilityReachTarget,
277
+ probabilityHoldWindow: forecast.probabilityHoldWindow,
278
+ successProbability: forecast.successProbability,
279
+ successProbabilityRange:
280
+ forecast.successProbabilityLow === null || forecast.successProbabilityHigh === null
281
+ ? null
282
+ : { low: forecast.successProbabilityLow, high: forecast.successProbabilityHigh },
283
+ expectedSurvivors: forecast.expectedSurvivors,
284
+ projectedTargetMetOn: forecast.projectedTargetMetOn,
285
+ projectedCompleteOn: forecast.projectedCompleteOn,
286
+ invitesNeeded: forecast.invitesNeeded,
287
+ recommendedRosterSize: forecast.recommendedRosterSize,
288
+ },
289
+
290
+ trend,
291
+
292
+ reconciliation: {
293
+ supported: false,
294
+ lastReconciledAt: null,
295
+ reason:
296
+ "The Google Play Developer API exposes no tester roster, no opt-in count, and no opt-out signal, so there is nothing to reconcile against.",
297
+ },
298
+
299
+ // Unconditional. There is no code path here that returns a cohort without it, which is the point:
300
+ // a note in the docs is advice, and a required field is a fact about the wire format.
301
+ disclaimer: DISCLAIMER,
302
+ ...(options.includeMembers ? { members } : {}),
303
+ };
304
+ }
package/src/index.ts ADDED
@@ -0,0 +1,80 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add testers` wires into `pithy.config.ts`.
6
+ *
7
+ * Deliberately narrow: the capability factory and its type guard, the configuration schema, the four
8
+ * table schemas, and the two things a management client or the CLI needs by name — the control-plane
9
+ * scopes and the disclaimer every opt-in figure travels with. Every other module is reached by deep
10
+ * path (`@pithy-sh/testers/src/...`); this is the documented contract, not a barrel over the package.
11
+ *
12
+ * `workflows/worker.ts` is **not** here and must not be. It imports `cloudflare:workers`, and
13
+ * re-exporting it would make that import unresolvable in every Node context that touches this package —
14
+ * including the CLI, the seed harness, and every node-project test.
15
+ */
16
+
17
+ export {
18
+ isTestersCapability,
19
+ TESTERS_MIGRATION_ORDER,
20
+ type TestersCapability,
21
+ type TestersOptions,
22
+ testers,
23
+ } from "./capability";
24
+ export {
25
+ CohortDefaults,
26
+ HealthCredits,
27
+ HealthPenalties,
28
+ NudgePolicy,
29
+ ResetPolicy,
30
+ SurvivalPriors,
31
+ TesterPlatform,
32
+ TestersConfig,
33
+ type TestersConfigInput,
34
+ } from "./config/config";
35
+ export { TestersCohort } from "./data/cohort";
36
+ export {
37
+ ActivityState,
38
+ MemberEventKind,
39
+ MemberState,
40
+ NudgeKind,
41
+ Observability,
42
+ ProjectionBasis,
43
+ ProjectionConfidence,
44
+ RiskBand,
45
+ TrendDirection,
46
+ } from "./data/enums";
47
+ export { TestersEvent } from "./data/event";
48
+ export { TestersMember } from "./data/member";
49
+ export { TestersCohortSnapshot } from "./data/snapshot";
50
+ export {
51
+ TESTERS_COHORTS_TABLE,
52
+ TESTERS_EVENTS_TABLE,
53
+ TESTERS_MEMBERS_TABLE,
54
+ TESTERS_SNAPSHOTS_TABLE,
55
+ type TestersDatabase,
56
+ testersDatabase,
57
+ testersTables,
58
+ } from "./data/tables";
59
+ export { DISCLAIMER, ESTIMATE_STATEMENT } from "./http/responses";
60
+ export { TESTERS_ROUTES, type TestersRouteDeclaration } from "./http/routes";
61
+ export {
62
+ TESTERS_CONTROL_PLANE_SCOPES,
63
+ TESTERS_NUDGE_SEND_SCOPE,
64
+ TESTERS_ROSTER_READ_SCOPE,
65
+ TESTERS_ROSTER_WRITE_SCOPE,
66
+ } from "./http/scopes";
67
+ export {
68
+ deprovisionTesters,
69
+ provisionTesters,
70
+ type TestersDeprovisioner,
71
+ type TestersProvisioner,
72
+ type TestersProvisionResult,
73
+ testersWorkerName,
74
+ } from "./provision/provisionTesters";
75
+ export {
76
+ resolveTestersConfig,
77
+ type TestersConfigParams,
78
+ type TestersEmailIdentity,
79
+ } from "./provision/resolveTestersConfig";
80
+ export { TESTERS_CAPABILITY, TestersDailyParams, testersWorkflowRegistry, testersWorkflows } from "./workflows/specs";