@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,229 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
+
7
+ /**
8
+ * The `testers/*` throw vehicles.
9
+ *
10
+ * **An opt-in link, a tester's email address, and a cohort's roster belong in `detail`, never
11
+ * `message`.** The HTTP codec strips `detail`, and that is the single security boundary — a public
12
+ * opt-in route answers unauthenticated strangers, so anything it says is said to everyone. Every
13
+ * default `message` below is written to be safe to hand a stranger, and every one of them is
14
+ * deliberately incurious: `TestersInvalidTokenError` covers a malformed link, an expired one, a forged
15
+ * signature, and a link naming a deleted cohort with the same words, so a caller holding a guess cannot
16
+ * use the response to narrow it down.
17
+ */
18
+
19
+ /** The variable parts every testers error accepts; the `code` and `status` are fixed by the subclass. */
20
+ interface TestersErrorArgs {
21
+ message?: string;
22
+ action?: string;
23
+ detail?: string;
24
+ /**
25
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
26
+ * unlike `action` and `detail` — these cross the boundary with `message`.
27
+ */
28
+ params?: MessageParams;
29
+ }
30
+
31
+ /** No cohort answers to that id. */
32
+ export class TestersCohortNotFoundError extends PithyError {
33
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
34
+ super(
35
+ {
36
+ code: "testers/cohort_not_found",
37
+ status: 404,
38
+ message: args.message ?? "No such cohort.",
39
+ action: args.action ?? "List your cohorts with `pithy testers list` and retry with an id from there.",
40
+ detail: args.detail,
41
+ params: args.params,
42
+ },
43
+ options,
44
+ );
45
+ }
46
+ }
47
+
48
+ /** No member of that cohort answers to that id or address. */
49
+ export class TestersMemberNotFoundError extends PithyError {
50
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
51
+ super(
52
+ {
53
+ code: "testers/member_not_found",
54
+ status: 404,
55
+ message: args.message ?? "No such tester on that cohort.",
56
+ action: args.action ?? "Check the roster with `pithy testers roster <cohort>`.",
57
+ detail: args.detail,
58
+ params: args.params,
59
+ },
60
+ options,
61
+ );
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The opt-in link failed verification, for any reason at all.
67
+ *
68
+ * One error for every failing step is the point. A stranger who guesses at a link should learn only
69
+ * that it did not work — not whether the signature was wrong, whether it had expired, or whether the
70
+ * cohort it named exists. `detail` carries the step for the log.
71
+ */
72
+ export class TestersInvalidTokenError extends PithyError {
73
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
74
+ super(
75
+ {
76
+ code: "testers/invalid_token",
77
+ status: 400,
78
+ message: args.message ?? "That confirmation link is no longer valid.",
79
+ action: args.action ?? "Ask the developer who invited you to send a fresh invitation.",
80
+ detail: args.detail,
81
+ params: args.params,
82
+ },
83
+ options,
84
+ );
85
+ }
86
+ }
87
+
88
+ /** The cohort is at its roster cap. */
89
+ export class TestersRosterFullError extends PithyError {
90
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
91
+ super(
92
+ {
93
+ code: "testers/roster_full",
94
+ status: 409,
95
+ message: args.message ?? "That cohort's roster is full.",
96
+ action: args.action ?? "Remove a lapsed tester, or raise the cohort's maximum roster size.",
97
+ detail: args.detail,
98
+ params: args.params,
99
+ },
100
+ options,
101
+ );
102
+ }
103
+ }
104
+
105
+ /** That address is already on the roster in a live state. */
106
+ export class TestersAlreadyOnRosterError extends PithyError {
107
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
108
+ super(
109
+ {
110
+ code: "testers/already_on_roster",
111
+ status: 409,
112
+ message: args.message ?? "That tester is already on this cohort.",
113
+ action: args.action ?? "Use resend to send them another invitation without losing their history.",
114
+ detail: args.detail,
115
+ params: args.params,
116
+ },
117
+ options,
118
+ );
119
+ }
120
+ }
121
+
122
+ /** Every selected tester is inside the per-tester nudge cooldown, so nothing was sent. */
123
+ export class TestersNudgeCooldownError extends PithyError {
124
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
125
+ super(
126
+ {
127
+ code: "testers/nudge_cooldown",
128
+ status: 429,
129
+ message: args.message ?? "Every one of those testers was nudged too recently.",
130
+ action: args.action ?? "Wait for the cooldown to pass, or select testers who have not been nudged.",
131
+ detail: args.detail,
132
+ params: args.params,
133
+ },
134
+ options,
135
+ );
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The address withdrew from this cohort itself, so it may not be invited back onto it.
141
+ *
142
+ * `lapsed` has exactly one producer: the tester's own `POST /opt-out/:token`. It is therefore never a
143
+ * roster state a developer chose — it is a person's decision, and the page they saw when they made it
144
+ * says "you will not hear from this test again". Reviving them restored the row to `invited` with a
145
+ * fresh token and a zeroed nudge count, and `POST /invite` sends by default, so one call re-mailed
146
+ * somebody who had asked to be left alone and restarted the whole chase sequence against them.
147
+ *
148
+ * Every other send path already refused a lapsed member — `resend`, `nudge`, and the daily pass all
149
+ * check. `invite` was the one that did not, and it is the path a routine contact-list re-import takes.
150
+ *
151
+ * `removed` stays revivable, deliberately: that is the developer's own act on their own roster, and
152
+ * undoing it takes nobody's consent away.
153
+ */
154
+ export class TestersWithdrawnError extends PithyError {
155
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
156
+ super(
157
+ {
158
+ code: "testers/withdrawn",
159
+ status: 409,
160
+ message: args.message ?? "That tester asked to be taken off this test.",
161
+ action:
162
+ args.action ??
163
+ "Their decision stands. Invite somebody else, or ask them directly and start a new cohort if they agree.",
164
+ detail: args.detail,
165
+ params: args.params,
166
+ },
167
+ options,
168
+ );
169
+ }
170
+ }
171
+
172
+ /**
173
+ * The cohort is closed, so this operation would write to or send from a finished program.
174
+ *
175
+ * `closedAt` was stamped by `closeCohort` and then read by exactly one filter, so everything that did
176
+ * not go through that filter carried on: the control-plane routes still invited, resent and nudged, and
177
+ * `pithy testers run --cohort <closed>` still mailed and still wrote a snapshot. Both the CLI and the
178
+ * schema's own description promise "nothing further is sent", and a promise enforced in one place is a
179
+ * promise for one path.
180
+ */
181
+ export class TestersCohortClosedError extends PithyError {
182
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
183
+ super(
184
+ {
185
+ code: "testers/cohort_closed",
186
+ status: 409,
187
+ message: args.message ?? "That cohort is closed.",
188
+ action: args.action ?? "Create a new cohort. A closed one keeps its history but sends nothing further.",
189
+ detail: args.detail,
190
+ params: args.params,
191
+ },
192
+ options,
193
+ );
194
+ }
195
+ }
196
+
197
+ /** The caller supplied nudge copy, but this deployment pins the shipped defaults. */
198
+ export class TestersCopyNotAllowedError extends PithyError {
199
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
200
+ super(
201
+ {
202
+ code: "testers/copy_not_allowed",
203
+ status: 403,
204
+ message: args.message ?? "This deployment does not accept supplied nudge copy.",
205
+ action: args.action ?? "Send the nudge without a subject or body, or enable nudges.allowCopyOverride.",
206
+ detail: args.detail,
207
+ params: args.params,
208
+ },
209
+ options,
210
+ );
211
+ }
212
+ }
213
+
214
+ /** The capability is composed but cannot complete this operation without more wiring. */
215
+ export class TestersNotConfiguredError extends PithyError {
216
+ constructor(args: TestersErrorArgs = {}, options?: { cause?: unknown }) {
217
+ super(
218
+ {
219
+ code: "testers/not_configured",
220
+ status: 500,
221
+ message: args.message ?? "The testers capability is not fully configured.",
222
+ action: args.action ?? "Complete the testers block in pithy.config.ts and redeploy.",
223
+ detail: args.detail,
224
+ params: args.params,
225
+ },
226
+ options,
227
+ );
228
+ }
229
+ }
@@ -0,0 +1,225 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { HealthCredits, HealthPenalties, SurvivalPriors, TesterPlatform } from "../config/config";
5
+ import type { Observability, RiskBand } from "../data/enums";
6
+
7
+ /**
8
+ * Per-tester health: how worried to be about one person, and why.
9
+ *
10
+ * **Health is a risk signal, never a membership signal.** A tester who opted in and never opens the app
11
+ * still counts toward Google's twelve — Google counts opt-ins, not engagement — so nothing in this file
12
+ * may remove anybody from the count. What it produces is the only early-warning signal that exists: a
13
+ * tester dark for eight days is the one most likely to have quietly opted out or uninstalled, and
14
+ * knowing that on day eight is worth a great deal more than discovering it on day fourteen.
15
+ *
16
+ * **Every term is reported individually.** A score is returned with the list of factors that produced
17
+ * it, so a developer can see that 45 of their missing points came from `dark_8_10` rather than being
18
+ * asked to accept a number. An unauditable score is an unaccountable one, and this whole capability is
19
+ * arguing that the developer should not have to take anyone's word for a figure.
20
+ *
21
+ * **The formula is additive and integer, on purpose.** A logistic curve would fit the data better if
22
+ * there were data to fit; there is not, these are declared priors, and a curve would only make the
23
+ * arbitrariness harder to see. Addition can be explained in one sentence per line.
24
+ */
25
+
26
+ /** The best score a tester can hold. */
27
+ const MAX_HEALTH = 100;
28
+
29
+ /** One term of a health score, with the sentence that justifies it. */
30
+ export interface HealthFactor {
31
+ /** A stable identifier for this term, e.g. `dark_8_10`. Safe to key a UI off. */
32
+ readonly code: string;
33
+ /** The signed contribution to the score. Penalties are negative, credits positive. */
34
+ readonly points: number;
35
+ /** One sentence explaining this factor to a developer. */
36
+ readonly reason: string;
37
+ }
38
+
39
+ /** What scoring one tester needs to know about them. Everything here is observed, not estimated. */
40
+ export interface HealthInput {
41
+ /** How much of this tester we can see at all. Decides whether they get a score or a stated absence of one. */
42
+ readonly observability: Observability;
43
+ /**
44
+ * Days since the last sign of life — a session refresh, a device sighting, or their own opt-in,
45
+ * whichever is most recent. Null when they were never linked to a user at all.
46
+ */
47
+ readonly daysDark: number | null;
48
+ /** Sessions inside the cohort's window. */
49
+ readonly sessionsInWindow: number;
50
+ /** How many devices are registered to them. */
51
+ readonly deviceCount: number;
52
+ /** Whether at least one of those devices runs the cohort's target platform. */
53
+ readonly hasTargetPlatformDevice: boolean;
54
+ /** Whether they have authenticated at all since following the opt-in link. */
55
+ readonly sessionSinceOptIn: boolean;
56
+ /** Nudges sent with no subsequent sign of life. Each is an unanswered probe. */
57
+ /** Nudges enqueued since they last answered one — cleared by accepting or confirming, not by activity. */
58
+ readonly unansweredNudges: number;
59
+ /** Days since they followed the opt-in link, or null if they have not. */
60
+ readonly daysSinceOptIn: number | null;
61
+ /** The cohort's target platform, or null to disable the device term entirely. */
62
+ readonly targetPlatform: TesterPlatform | null;
63
+ }
64
+
65
+ /** A scored tester: the number, the band it falls in, and every term that produced it. */
66
+ export interface HealthResult {
67
+ /** 0–100, or null for a tester we cannot observe. Null is not zero — absence of evidence is not evidence of risk. */
68
+ readonly health: number | null;
69
+ /** Why the score is null when it is null. A UI must render `unobservable` gray, never red. */
70
+ readonly basis: "scored" | "unobservable" | "unreachable";
71
+ /** The band the score falls in, which selects the survival prior. */
72
+ readonly riskBand: RiskBand;
73
+ /** Every term, so the number can be audited line by line. Empty for an unscored tester. */
74
+ readonly factors: readonly HealthFactor[];
75
+ }
76
+
77
+ /** The dark-day bands. Exclusive: exactly one fires, and the widest matching one wins. */
78
+ const DARK_BANDS: readonly { min: number; max: number; code: string; key: keyof HealthPenalties; reason: string }[] = [
79
+ {
80
+ min: 3,
81
+ max: 4,
82
+ code: "dark_3_4",
83
+ key: "darkThreeToFour",
84
+ reason: "Three days quiet is noise rather than signal — worth a nudge, not an alarm.",
85
+ },
86
+ {
87
+ min: 5,
88
+ max: 7,
89
+ code: "dark_5_7",
90
+ key: "darkFiveToSeven",
91
+ reason: "A week without opening the app is the first real sign of drift.",
92
+ },
93
+ {
94
+ min: 8,
95
+ max: 10,
96
+ code: "dark_8_10",
97
+ key: "darkEightToTen",
98
+ reason: "Eight days dark is the strongest single predictor of a silent uninstall.",
99
+ },
100
+ {
101
+ min: 11,
102
+ max: 13,
103
+ code: "dark_11_13",
104
+ key: "darkElevenToThirteen",
105
+ reason: "Nearly the whole window with no sign of life.",
106
+ },
107
+ {
108
+ min: 14,
109
+ max: Number.POSITIVE_INFINITY,
110
+ code: "dark_14_plus",
111
+ key: "darkFourteenPlus",
112
+ reason: "Gone for longer than the test you are asking them to complete.",
113
+ },
114
+ ];
115
+
116
+ /** The band a score falls in. Bands are inclusive at their lower bound. */
117
+ export function bandFor(health: number | null): RiskBand {
118
+ if (health === null) return "unknown";
119
+ if (health >= 80) return "healthy";
120
+ if (health >= 60) return "watch";
121
+ if (health >= 30) return "at_risk";
122
+ return "critical";
123
+ }
124
+
125
+ /** The published daily-survival prior for a band. */
126
+ export function survivalFor(band: RiskBand, observability: Observability, priors: SurvivalPriors): number {
127
+ if (observability === "unreachable") return priors.unreachable;
128
+ switch (band) {
129
+ case "healthy":
130
+ return priors.healthy;
131
+ case "watch":
132
+ return priors.watch;
133
+ case "at_risk":
134
+ return priors.atRisk;
135
+ case "critical":
136
+ return priors.critical;
137
+ default:
138
+ return priors.unknown;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Score one tester.
144
+ *
145
+ * An unobservable or unreachable tester returns `health: null` with a stated basis rather than a low
146
+ * number. This is the single most important decision in the file: if an adopter's test flow never asks
147
+ * anyone to sign in, *every* tester is unobservable, and scoring them badly would paint a perfectly
148
+ * healthy cohort red and drive a fortnight of pointless nudging. Null makes the blindness visible in
149
+ * the aggregate — as `observedCoverage` and a widening confidence band — instead of hiding it inside a
150
+ * number that looks like knowledge.
151
+ */
152
+ export function scoreHealth(input: HealthInput, penalties: HealthPenalties, credits: HealthCredits): HealthResult {
153
+ if (input.observability === "unreachable") {
154
+ return { health: null, basis: "unreachable", riskBand: "unknown", factors: [] };
155
+ }
156
+ if (input.observability === "unobservable" || input.daysDark === null) {
157
+ return { health: null, basis: "unobservable", riskBand: "unknown", factors: [] };
158
+ }
159
+
160
+ const factors: HealthFactor[] = [];
161
+
162
+ const band = DARK_BANDS.find(
163
+ (entry) => input.daysDark !== null && input.daysDark >= entry.min && input.daysDark <= entry.max,
164
+ );
165
+ if (band) {
166
+ factors.push({ code: band.code, points: -penalties[band.key], reason: band.reason });
167
+ }
168
+
169
+ // Gated on the cohort declaring a target platform. An `ios` cohort penalizing a missing Android
170
+ // device, or a cohort with no platform at all penalizing everybody, would both be noise.
171
+ if (input.targetPlatform !== null && !input.hasTargetPlatformDevice) {
172
+ factors.push({
173
+ code: "no_target_platform_device",
174
+ points: -penalties.noTargetPlatformDevice,
175
+ reason: `The test targets ${input.targetPlatform}, and no ${input.targetPlatform} device is registered for them.`,
176
+ });
177
+ }
178
+
179
+ if (input.unansweredNudges > 0) {
180
+ const raw = input.unansweredNudges * penalties.unansweredNudge;
181
+ factors.push({
182
+ code: "unanswered_nudges",
183
+ points: -Math.min(raw, penalties.unansweredNudgeCap),
184
+ reason: "Each nudge is a probe. Several unanswered probes are themselves an answer.",
185
+ });
186
+ }
187
+
188
+ if (input.daysSinceOptIn !== null && !input.sessionSinceOptIn) {
189
+ factors.push({
190
+ code: "session_never_since_optin",
191
+ points: -penalties.noSessionSinceOptIn,
192
+ reason: "They confirmed the opt-in link but have not opened the app since.",
193
+ });
194
+ }
195
+
196
+ if (input.sessionsInWindow >= credits.engagedSessionThreshold) {
197
+ factors.push({
198
+ code: "engaged",
199
+ points: credits.engaged,
200
+ reason: `${credits.engagedSessionThreshold} or more sessions in the window is a tester who is actually testing.`,
201
+ });
202
+ }
203
+
204
+ if (input.deviceCount >= 2) {
205
+ factors.push({
206
+ code: "multi_device",
207
+ points: credits.multiDevice,
208
+ reason: "Two registered devices is someone invested enough to install twice.",
209
+ });
210
+ }
211
+
212
+ // Without this, a tester who confirmed three hours ago reads as neglected on arrival: they have no
213
+ // sessions yet, so every engagement term is against them and none is for them.
214
+ if (input.daysSinceOptIn !== null && input.daysSinceOptIn <= credits.freshOptInDays) {
215
+ factors.push({
216
+ code: "fresh_optin",
217
+ points: credits.freshOptIn,
218
+ reason: "Opted in within the last few days, so there has been no time to decay.",
219
+ });
220
+ }
221
+
222
+ const total = factors.reduce((sum, factor) => sum + factor.points, MAX_HEALTH);
223
+ const health = Math.min(MAX_HEALTH, Math.max(0, total));
224
+ return { health, basis: "scored", riskBand: bandFor(health), factors };
225
+ }
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { MiddlewareHandler } from "hono";
7
+
8
+ /**
9
+ * Route guards.
10
+ *
11
+ * **The scope constants moved to `./scopes`** (#315). A management client reads them to render what a
12
+ * connection may do, and it reads them in a browser — so they cannot live in a module that imports
13
+ * Hono middleware and `PithyHonoEnv`. The gates stayed here; the names they demand are next door, and
14
+ * the routes import both.
15
+ *
16
+ * `requireAuth` is **copied** rather than imported from `@pithy-sh/auth`, matching every other
17
+ * capability in the repo. Importing a gate from another package would make that package a hard
18
+ * dependency, and a package that borrows its authorization fails *open* when the lender is absent. With
19
+ * the guard local, a project that never installed auth leaves `c.var.auth` null and every guarded route
20
+ * denies — which is the only acceptable direction for that failure to go.
21
+ */
22
+
23
+ /**
24
+ * Require an authenticated caller. Rejects a request whose `AuthContext` was never filled, which means
25
+ * either no credential or an invalid one.
26
+ */
27
+ export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
28
+ return async (c, next) => {
29
+ if (!c.var.auth) {
30
+ throw new UnauthorizedError({
31
+ message: "Authentication required.",
32
+ action: "Sign in and retry with a valid session or bearer token.",
33
+ });
34
+ }
35
+ await next();
36
+ };
37
+ }
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The three HTML pages a tester sees.
6
+ *
7
+ * Separate from `routes.ts` because they are pure — a string in, a `Response` out, no database and no
8
+ * config — and because that is what makes their contents assertable. The opt-out page in particular is
9
+ * a security surface: it exists so that withdrawing takes a form submission rather than a link
10
+ * following, and a test that proves it renders a form is worth more than a comment saying it should.
11
+ *
12
+ * Distinct from `view.ts`, which shapes the JSON a dashboard receives. Same layer, different audience.
13
+ */
14
+
15
+ /**
16
+ * Escape a value before it goes into the HTML these routes render.
17
+ *
18
+ * The store URL is developer-supplied and already host-allowlisted, so this is defense in depth rather
19
+ * than the only guard — but it is a URL interpolated into an `href` on a page shown to a stranger, and
20
+ * "already validated elsewhere" is how injection survives a refactor.
21
+ */
22
+ function escapeHtml(value: string): string {
23
+ return value
24
+ .replace(/&/g, "&amp;")
25
+ .replace(/</g, "&lt;")
26
+ .replace(/>/g, "&gt;")
27
+ .replace(/"/g, "&quot;")
28
+ .replace(/'/g, "&#39;");
29
+ }
30
+
31
+ export function page(title: string, body: string): Response {
32
+ return new Response(
33
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title></head><body style="font-family:system-ui,-apple-system,sans-serif;max-width:34rem;margin:15vh auto;padding:0 1.5rem;line-height:1.55"><h1 style="font-size:1.4rem;font-weight:600;margin:0 0 .75rem">${title}</h1><p style="margin:0;color:#555">${body}</p></body></html>`,
34
+ { status: 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
35
+ );
36
+ }
37
+
38
+ /**
39
+ * The page that hands a tester the store's own opt-in link.
40
+ *
41
+ * The two lines of guidance are not padding. Opening the link in the store app rather than a browser,
42
+ * and signing in with a different account than the one invited, are the two failures that produce
43
+ * `App not available` — the single most common way a closed test goes wrong for a tester who did
44
+ * everything they were asked.
45
+ */
46
+ export function storePage(url: string, platform: "android" | "ios"): Response {
47
+ const store = platform === "ios" ? "TestFlight" : "Google Play";
48
+ const safeUrl = escapeHtml(url);
49
+ return new Response(
50
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Join the test</title></head><body style="font-family:system-ui,-apple-system,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.5rem;line-height:1.55"><h1 style="font-size:1.4rem;font-weight:600;margin:0 0 .75rem">One more step.</h1><p style="margin:0 0 1.5rem;color:#555">Open ${escapeHtml(store)} to join the test and install the app.</p><p style="margin:0 0 1.75rem"><a href="${safeUrl}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:13px 26px;border-radius:8px;font-weight:600">Join on ${escapeHtml(store)}</a></p><p style="margin:0 0 .5rem;color:#555;font-size:.925rem">Two things worth knowing, because they are what usually goes wrong:</p><ul style="margin:0 0 1.5rem;padding-left:1.15rem;color:#555;font-size:.925rem"><li style="margin-bottom:.4rem">Open it in a browser, not inside the ${escapeHtml(store)} app.</li><li>Sign in with the same address this email reached you at. A different account cannot join.</li></ul><p style="margin:0;color:#888;font-size:.85rem;word-break:break-all">If the button does not work: ${safeUrl}</p></body></html>`,
51
+ { status: 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
52
+ );
53
+ }
54
+
55
+ /**
56
+ * The page that asks a tester to confirm they meant to withdraw.
57
+ *
58
+ * A form rather than a link, because the whole point is that following it requires an actor who
59
+ * submits forms. A mail client prefetching the URL renders this and changes nothing.
60
+ */
61
+ export function confirmOptOutPage(action: string): Response {
62
+ return new Response(
63
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Leave the test?</title></head><body style="font-family:system-ui,-apple-system,sans-serif;max-width:34rem;margin:15vh auto;padding:0 1.5rem;line-height:1.55"><h1 style="font-size:1.4rem;font-weight:600;margin:0 0 .75rem">Leave the test?</h1><p style="margin:0 0 1.5rem;color:#555">You will not be contacted about it again. If you meant to do this, confirm below.</p><form method="post" action="${escapeHtml(action)}"><button type="submit" style="background:#111;color:#fff;border:0;padding:13px 26px;border-radius:8px;font-weight:600;font-size:1rem;cursor:pointer">Yes, take me off</button></form><p style="margin:1.5rem 0 0;color:#888;font-size:.85rem">Close this page to stay on the test.</p></body></html>`,
64
+ { status: 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
65
+ );
66
+ }