@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/docs/store-apis.md +107 -0
- package/package.json +62 -0
- package/pithy.manifest.json +52 -0
- package/src/activity/resolve.ts +273 -0
- package/src/audit/actions.ts +56 -0
- package/src/capability.ts +128 -0
- package/src/clock/days.ts +70 -0
- package/src/clock/replay.ts +190 -0
- package/src/cloudflare-test.d.ts +13 -0
- package/src/config/config.ts +518 -0
- package/src/crypto/token.ts +60 -0
- package/src/data/cohort.ts +83 -0
- package/src/data/enums.ts +134 -0
- package/src/data/event.ts +81 -0
- package/src/data/member.ts +79 -0
- package/src/data/snapshot.ts +280 -0
- package/src/data/tables.ts +49 -0
- package/src/error/errors.ts +229 -0
- package/src/health/score.ts +225 -0
- package/src/http/guards.ts +37 -0
- package/src/http/pages.ts +66 -0
- package/src/http/responses.ts +634 -0
- package/src/http/routes.ts +933 -0
- package/src/http/schemas.ts +210 -0
- package/src/http/scopes.ts +79 -0
- package/src/http/view.ts +304 -0
- package/src/index.ts +80 -0
- package/src/migrations/0001_cohorts.ts +202 -0
- package/src/nudge/cooldown.ts +104 -0
- package/src/nudge/copy.ts +179 -0
- package/src/nudge/enqueueSeam.ts +95 -0
- package/src/nudge/send.ts +89 -0
- package/src/projection/build.ts +285 -0
- package/src/projection/forecast.ts +348 -0
- package/src/projection/inputs.ts +63 -0
- package/src/projection/poissonBinomial.ts +91 -0
- package/src/projection/trend.ts +185 -0
- package/src/provision/provisionTesters.ts +109 -0
- package/src/provision/resolveTestersConfig.ts +155 -0
- package/src/roster/read.ts +227 -0
- package/src/roster/write.ts +511 -0
- package/src/seeds/example.ts +219 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/daily.ts +513 -0
- package/src/workflows/pass.ts +100 -0
- package/src/workflows/report.ts +52 -0
- package/src/workflows/retryPolicy.ts +48 -0
- package/src/workflows/specs.ts +73 -0
- package/src/workflows/worker.ts +132 -0
- package/src/workflows/wrangler.jsonc +66 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Kysely } from "kysely";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The four testers tables: the cohort, its roster, the append-only event log the roster is replayed
|
|
9
|
+
* from, and the daily snapshot the trend chart is drawn from.
|
|
10
|
+
*
|
|
11
|
+
* Identifiers are camelCase throughout — `CamelCasePlugin` snake-cases the DDL. Dates are `integer`
|
|
12
|
+
* ms-epoch, booleans are `integer` 0/1, JSON columns are `text`.
|
|
13
|
+
*
|
|
14
|
+
* **Shape and value rules live in the Zod schemas, not here.** One Zod object per table is the entire
|
|
15
|
+
* table definition (CLAUDE.md §Data layer), so an enum's members, a number's bounds, a boolean's 0/1
|
|
16
|
+
* and the one cross-field rule (`maxRosterSize >= targetSize`) are all declared and enforced there —
|
|
17
|
+
* on `parse` *and* on `encode`, which is the boundary every write already crosses. Restating them as
|
|
18
|
+
* `CHECK` constraints bought a second source of truth that could drift from the first, and produced a
|
|
19
|
+
* raw `CHECK constraint failed` out of Kysely instead of a `PithyError` with an action line — no help
|
|
20
|
+
* to a human and no `--json` error object for an agent.
|
|
21
|
+
*
|
|
22
|
+
* What stays is what Zod cannot express, because it is a fact about the table rather than about a row:
|
|
23
|
+
* `UNIQUE` (a cohort name, a member's address within a cohort, a token, one snapshot per cohort-day)
|
|
24
|
+
* and the indexes the queries actually use.
|
|
25
|
+
*
|
|
26
|
+
* No foreign keys, matching the rest of the repo: D1 does not enforce them, so declaring them would be
|
|
27
|
+
* documentation pretending to be a constraint. Referential integrity is held by the writers, and cohort
|
|
28
|
+
* teardown deletes children first.
|
|
29
|
+
*/
|
|
30
|
+
export const testers_0001_cohorts: Migration = {
|
|
31
|
+
up: async (db: Kysely<unknown>): Promise<void> => {
|
|
32
|
+
await db.schema
|
|
33
|
+
.createTable("pithyTestersCohorts")
|
|
34
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
35
|
+
.addColumn("name", "text", (c) => c.notNull())
|
|
36
|
+
.addColumn("targetPlatform", "text", (c) => c.notNull())
|
|
37
|
+
.addColumn("targetSize", "integer", (c) => c.notNull())
|
|
38
|
+
.addColumn("windowDays", "integer", (c) => c.notNull())
|
|
39
|
+
.addColumn("maxRosterSize", "integer", (c) => c.notNull())
|
|
40
|
+
.addColumn("storeOptInUrl", "text")
|
|
41
|
+
.addColumn("resetPolicy", "text", (c) => c.notNull())
|
|
42
|
+
.addColumn("closedAt", "integer")
|
|
43
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
44
|
+
.addColumn("updatedAt", "integer", (c) => c.notNull())
|
|
45
|
+
.addUniqueConstraint("pithyTestersCohortsNameIdx", ["name"])
|
|
46
|
+
.execute();
|
|
47
|
+
|
|
48
|
+
await db.schema
|
|
49
|
+
.createTable("pithyTestersMembers")
|
|
50
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
51
|
+
.addColumn("cohortId", "text", (c) => c.notNull())
|
|
52
|
+
.addColumn("email", "text", (c) => c.notNull())
|
|
53
|
+
.addColumn("name", "text")
|
|
54
|
+
.addColumn("optInToken", "text", (c) => c.notNull())
|
|
55
|
+
.addColumn("state", "text", (c) => c.notNull())
|
|
56
|
+
.addColumn("invitedAt", "integer", (c) => c.notNull())
|
|
57
|
+
.addColumn("acceptedAt", "integer")
|
|
58
|
+
.addColumn("optedInAt", "integer")
|
|
59
|
+
.addColumn("lapsedAt", "integer")
|
|
60
|
+
.addColumn("lastInvitedAt", "integer", (c) => c.notNull())
|
|
61
|
+
.addColumn("lastNudgedAt", "integer")
|
|
62
|
+
.addColumn("nudgeCount", "integer", (c) => c.notNull().defaultTo(0))
|
|
63
|
+
.addColumn("unreachable", "integer", (c) => c.notNull().defaultTo(0))
|
|
64
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
65
|
+
.addColumn("updatedAt", "integer", (c) => c.notNull())
|
|
66
|
+
// One address per cohort. This is what makes an invitation idempotent at the storage layer rather
|
|
67
|
+
// than only in the handler: two concurrent invites of the same person cannot both land and split
|
|
68
|
+
// one tester's history across two rows, which would double-count them toward the target.
|
|
69
|
+
.addUniqueConstraint("pithyTestersMembersCohortEmailIdx", ["cohortId", "email"])
|
|
70
|
+
// The confirmation token is the whole credential, so it is unique across every cohort rather than
|
|
71
|
+
// within one: a collision would let one tester's link confirm another's opt-in.
|
|
72
|
+
.addUniqueConstraint("pithyTestersMembersOptInTokenIdx", ["optInToken"])
|
|
73
|
+
.execute();
|
|
74
|
+
|
|
75
|
+
// The roster read: every live member of one cohort, ordered by state. Covers the common
|
|
76
|
+
// "who is on this cohort" query without touching the event log.
|
|
77
|
+
await db.schema
|
|
78
|
+
.createIndex("pithyTestersMembersCohortStateIdx")
|
|
79
|
+
.on("pithyTestersMembers")
|
|
80
|
+
.columns(["cohortId", "state"])
|
|
81
|
+
.execute();
|
|
82
|
+
|
|
83
|
+
// The activity reader resolves testers by address across every cohort at once, so the daily pass
|
|
84
|
+
// does one lookup per distinct address rather than one per membership.
|
|
85
|
+
await db.schema.createIndex("pithyTestersMembersEmailIdx").on("pithyTestersMembers").column("email").execute();
|
|
86
|
+
|
|
87
|
+
await db.schema
|
|
88
|
+
.createTable("pithyTestersEvents")
|
|
89
|
+
.addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
|
|
90
|
+
.addColumn("cohortId", "text", (c) => c.notNull())
|
|
91
|
+
.addColumn("memberId", "text", (c) => c.notNull())
|
|
92
|
+
.addColumn("kind", "text", (c) => c.notNull())
|
|
93
|
+
.addColumn("actor", "text", (c) => c.notNull())
|
|
94
|
+
.addColumn("occurredAt", "integer", (c) => c.notNull())
|
|
95
|
+
.addColumn("metadata", "text", (c) => c.notNull())
|
|
96
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
97
|
+
.execute();
|
|
98
|
+
|
|
99
|
+
// The cohort-wide replay: every event for one cohort in `occurredAt` order. Ordering on the index
|
|
100
|
+
// rather than in memory matters because the replay walks the whole history to rebuild the streak.
|
|
101
|
+
await db.schema
|
|
102
|
+
.createIndex("pithyTestersEventsCohortTimeIdx")
|
|
103
|
+
.on("pithyTestersEvents")
|
|
104
|
+
.columns(["cohortId", "occurredAt"])
|
|
105
|
+
.execute();
|
|
106
|
+
|
|
107
|
+
// The per-tester replay, and the nudge-history read the cooldown and the health score both use.
|
|
108
|
+
await db.schema
|
|
109
|
+
.createIndex("pithyTestersEventsMemberTimeIdx")
|
|
110
|
+
.on("pithyTestersEvents")
|
|
111
|
+
.columns(["memberId", "occurredAt"])
|
|
112
|
+
.execute();
|
|
113
|
+
|
|
114
|
+
await db.schema
|
|
115
|
+
.createTable("pithyTestersCohortSnapshots")
|
|
116
|
+
.addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
|
|
117
|
+
.addColumn("cohortId", "text", (c) => c.notNull())
|
|
118
|
+
.addColumn("snapshotOn", "text", (c) => c.notNull())
|
|
119
|
+
.addColumn("dayIndex", "integer", (c) => c.notNull())
|
|
120
|
+
.addColumn("computedAt", "integer", (c) => c.notNull())
|
|
121
|
+
.addColumn("backfilled", "integer", (c) => c.notNull().defaultTo(0))
|
|
122
|
+
.addColumn("modelVersion", "text", (c) => c.notNull())
|
|
123
|
+
.addColumn("rosterSize", "integer", (c) => c.notNull())
|
|
124
|
+
.addColumn("invitedCount", "integer", (c) => c.notNull())
|
|
125
|
+
.addColumn("acceptedCount", "integer", (c) => c.notNull())
|
|
126
|
+
.addColumn("estimatedOptedInCount", "integer", (c) => c.notNull())
|
|
127
|
+
.addColumn("lapsedCount", "integer", (c) => c.notNull())
|
|
128
|
+
.addColumn("unreachableCount", "integer", (c) => c.notNull())
|
|
129
|
+
.addColumn("targetSize", "integer", (c) => c.notNull())
|
|
130
|
+
.addColumn("windowDays", "integer", (c) => c.notNull())
|
|
131
|
+
.addColumn("meetsTarget", "integer", (c) => c.notNull())
|
|
132
|
+
.addColumn("headroom", "integer", (c) => c.notNull())
|
|
133
|
+
.addColumn("estimatedHeldDays", "integer", (c) => c.notNull())
|
|
134
|
+
.addColumn("estimatedWindowStartOn", "text")
|
|
135
|
+
.addColumn("estimatedDaysRemaining", "integer", (c) => c.notNull())
|
|
136
|
+
.addColumn("resetCount", "integer", (c) => c.notNull())
|
|
137
|
+
.addColumn("resetToday", "integer", (c) => c.notNull())
|
|
138
|
+
.addColumn("observedCount", "integer", (c) => c.notNull())
|
|
139
|
+
.addColumn("neverLinkedCount", "integer", (c) => c.notNull())
|
|
140
|
+
.addColumn("observedCoverage", "real", (c) => c.notNull())
|
|
141
|
+
.addColumn("activeCount", "integer", (c) => c.notNull())
|
|
142
|
+
.addColumn("darkThreeToSevenCount", "integer", (c) => c.notNull())
|
|
143
|
+
.addColumn("darkEightToThirteenCount", "integer", (c) => c.notNull())
|
|
144
|
+
.addColumn("darkFourteenPlusCount", "integer", (c) => c.notNull())
|
|
145
|
+
.addColumn("sessionsInWindow", "integer", (c) => c.notNull())
|
|
146
|
+
.addColumn("targetPlatformDeviceCount", "integer", (c) => c.notNull())
|
|
147
|
+
.addColumn("healthyCount", "integer", (c) => c.notNull())
|
|
148
|
+
.addColumn("watchCount", "integer", (c) => c.notNull())
|
|
149
|
+
.addColumn("atRiskCount", "integer", (c) => c.notNull())
|
|
150
|
+
.addColumn("criticalCount", "integer", (c) => c.notNull())
|
|
151
|
+
.addColumn("unknownHealthCount", "integer", (c) => c.notNull())
|
|
152
|
+
.addColumn("medianHealth", "integer")
|
|
153
|
+
.addColumn("minHealth", "integer")
|
|
154
|
+
.addColumn("expectedSurvivors", "real", (c) => c.notNull())
|
|
155
|
+
.addColumn("probabilityReachTarget", "real", (c) => c.notNull())
|
|
156
|
+
.addColumn("probabilityHoldWindow", "real")
|
|
157
|
+
.addColumn("successProbability", "real")
|
|
158
|
+
.addColumn("successProbabilityLow", "real")
|
|
159
|
+
.addColumn("successProbabilityHigh", "real")
|
|
160
|
+
.addColumn("confidence", "text")
|
|
161
|
+
.addColumn("basis", "text", (c) => c.notNull())
|
|
162
|
+
.addColumn("projectedTargetMetOn", "text")
|
|
163
|
+
.addColumn("projectedCompleteOn", "text")
|
|
164
|
+
.addColumn("invitesNeeded", "integer", (c) => c.notNull())
|
|
165
|
+
.addColumn("recommendedRosterSize", "integer", (c) => c.notNull())
|
|
166
|
+
.addColumn("optedInDelta1d", "integer")
|
|
167
|
+
.addColumn("optedInDelta7d", "integer")
|
|
168
|
+
.addColumn("activeDelta7d", "integer")
|
|
169
|
+
.addColumn("successProbabilityDelta1d", "real")
|
|
170
|
+
.addColumn("successProbabilityDelta7d", "real")
|
|
171
|
+
.addColumn("trendDirection", "text", (c) => c.notNull())
|
|
172
|
+
.addColumn("fragile", "integer", (c) => c.notNull())
|
|
173
|
+
.addColumn("trendReason", "text", (c) => c.notNull())
|
|
174
|
+
.addColumn("nudgesSent", "text", (c) => c.notNull())
|
|
175
|
+
.addColumn("bouncedCount", "integer", (c) => c.notNull())
|
|
176
|
+
// One row per cohort per UTC day. This is what makes the daily pass idempotent: a re-run — a
|
|
177
|
+
// retried Workflow step, a manual backfill, two crons firing on a leap second — upserts the day
|
|
178
|
+
// rather than appending a second version of it, so a replay can never bend the chart.
|
|
179
|
+
.addUniqueConstraint("pithyTestersSnapshotsCohortDayIdx", ["cohortId", "snapshotOn"])
|
|
180
|
+
.execute();
|
|
181
|
+
|
|
182
|
+
// The trailing-series read: the last N days of one cohort, newest first. The summary card reads one
|
|
183
|
+
// row from the head of this index, which is what keeps the default response cheap.
|
|
184
|
+
await db.schema
|
|
185
|
+
.createIndex("pithyTestersSnapshotsCohortRecentIdx")
|
|
186
|
+
.on("pithyTestersCohortSnapshots")
|
|
187
|
+
.columns(["cohortId", "snapshotOn"])
|
|
188
|
+
.execute();
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
down: async (db: Kysely<unknown>): Promise<void> => {
|
|
192
|
+
await db.schema.dropIndex("pithyTestersSnapshotsCohortRecentIdx").execute();
|
|
193
|
+
await db.schema.dropTable("pithyTestersCohortSnapshots").execute();
|
|
194
|
+
await db.schema.dropIndex("pithyTestersEventsMemberTimeIdx").execute();
|
|
195
|
+
await db.schema.dropIndex("pithyTestersEventsCohortTimeIdx").execute();
|
|
196
|
+
await db.schema.dropTable("pithyTestersEvents").execute();
|
|
197
|
+
await db.schema.dropIndex("pithyTestersMembersEmailIdx").execute();
|
|
198
|
+
await db.schema.dropIndex("pithyTestersMembersCohortStateIdx").execute();
|
|
199
|
+
await db.schema.dropTable("pithyTestersMembers").execute();
|
|
200
|
+
await db.schema.dropTable("pithyTestersCohorts").execute();
|
|
201
|
+
},
|
|
202
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { TestersMember } from "../data/member";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The per-tester nudge cooldown.
|
|
8
|
+
*
|
|
9
|
+
* **Mandatory, server-side, and on every path.** A nudge trigger with no guard is a button that mails
|
|
10
|
+
* the same twelve people repeatedly, and the fastest way to lose a cohort is to become the reason they
|
|
11
|
+
* muted the sender. The dashboard cannot be trusted to hold the line here — not because it is
|
|
12
|
+
* malicious, but because a retried request, a double-clicked button, and a cron that overlaps its own
|
|
13
|
+
* previous run all produce the same duplicate send, and none of them is a bug anyone would notice
|
|
14
|
+
* before the testers did.
|
|
15
|
+
*
|
|
16
|
+
* The cooldown reads `lastNudgedAt`, which is stamped at **enqueue** rather than at delivery. That is
|
|
17
|
+
* the ordering that makes the guard actually hold: waiting for the send Workflow to confirm would leave
|
|
18
|
+
* a window in which a second request sees no recent nudge and mails again. An email enqueued and then
|
|
19
|
+
* failing to send is a delivery problem; an email sent twice is a lost tester.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Milliseconds in one hour. */
|
|
23
|
+
const MS_PER_HOUR = 3_600_000;
|
|
24
|
+
|
|
25
|
+
/** When a tester may next be nudged, or null if they may be nudged now. */
|
|
26
|
+
export function cooldownUntil(member: TestersMember, cooldownHours: number): Date | null {
|
|
27
|
+
if (!member.lastNudgedAt) return null;
|
|
28
|
+
return new Date(member.lastNudgedAt.getTime() + cooldownHours * MS_PER_HOUR);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether this tester may be nudged right now. */
|
|
32
|
+
export function mayNudge(member: TestersMember, cooldownHours: number, now: Date): boolean {
|
|
33
|
+
const until = cooldownUntil(member, cooldownHours);
|
|
34
|
+
return until === null || until <= now;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How many unanswered nudges a tester takes before we stop.
|
|
39
|
+
*
|
|
40
|
+
* The cooldown bounds how *often* somebody is mailed; this bounds how *many times*. The counter resets
|
|
41
|
+
* the moment they answer, so it only ever bites the genuinely unresponsive — who stay on the roster and
|
|
42
|
+
* stay visible, they simply stop being chased. Without it a tester who never replies is mailed every
|
|
43
|
+
* three days for the life of the cohort.
|
|
44
|
+
*
|
|
45
|
+
* It lives here rather than in the daily pass because the pass is not the only sender. It was read by
|
|
46
|
+
* `dueNudge` alone, so a dashboard holding `testers:nudge:send` could chase an unresponsive address
|
|
47
|
+
* indefinitely — the very thing https://pithy.sh/docs/capabilities/testers/use says cannot happen. The same reasoning that makes the
|
|
48
|
+
* cooldown re-enforced inside every handler applies to the cap.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_UNANSWERED_NUDGES = 3;
|
|
51
|
+
|
|
52
|
+
/** Whether this tester has stopped answering and should no longer be chased. */
|
|
53
|
+
export function chasedOut(member: TestersMember): boolean {
|
|
54
|
+
return member.nudgeCount >= MAX_UNANSWERED_NUDGES;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A roster split into who may be nudged and who is still cooling down. */
|
|
58
|
+
export interface CooldownSplit {
|
|
59
|
+
readonly eligible: readonly TestersMember[];
|
|
60
|
+
readonly cooling: readonly TestersMember[];
|
|
61
|
+
readonly unreachable: readonly TestersMember[];
|
|
62
|
+
/** Testers who have stopped answering. Separate again, because this one never resolves with time. */
|
|
63
|
+
readonly chasedOut: readonly TestersMember[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Split a set of testers by whether they may be nudged.
|
|
68
|
+
*
|
|
69
|
+
* Unreachable testers are separated rather than merged into `cooling`, because the two call for
|
|
70
|
+
* different actions: a cooling tester will be nudgeable tomorrow, while an unreachable one never will
|
|
71
|
+
* be and needs replacing. A response that reported them together would tell a developer to wait for
|
|
72
|
+
* something that is not going to happen.
|
|
73
|
+
*/
|
|
74
|
+
export function splitByCooldown(members: readonly TestersMember[], cooldownHours: number, now: Date): CooldownSplit {
|
|
75
|
+
const eligible: TestersMember[] = [];
|
|
76
|
+
const cooling: TestersMember[] = [];
|
|
77
|
+
const unreachable: TestersMember[] = [];
|
|
78
|
+
const exhausted: TestersMember[] = [];
|
|
79
|
+
for (const member of members) {
|
|
80
|
+
if (member.unreachable) unreachable.push(member);
|
|
81
|
+
else if (chasedOut(member)) exhausted.push(member);
|
|
82
|
+
else if (mayNudge(member, cooldownHours, now)) eligible.push(member);
|
|
83
|
+
else cooling.push(member);
|
|
84
|
+
}
|
|
85
|
+
return { eligible, cooling, unreachable, chasedOut: exhausted };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether this nudge answers something the tester just did, and so should not wait for the cooldown.
|
|
90
|
+
*
|
|
91
|
+
* **The cooldown exists to stop repeated chasing, not to delay a reply.** A tester who has just agreed
|
|
92
|
+
* to test and is waiting for the link is not being chased — they are waiting on us. Holding that email
|
|
93
|
+
* for three days because we happened to ask them two days ago is how a cohort loses the people who were
|
|
94
|
+
* most willing, and it is the single most avoidable way this capability could fail.
|
|
95
|
+
*
|
|
96
|
+
* The test is that their acceptance is newer than our last message to them. That needs no extra column
|
|
97
|
+
* and no second query: if we have not written to them since they said yes, this is the reply. Once it
|
|
98
|
+
* has gone, `lastNudgedAt` moves past `acceptedAt` and every later reminder falls under the normal
|
|
99
|
+
* cooldown like anything else.
|
|
100
|
+
*/
|
|
101
|
+
export function answersRecentAction(member: TestersMember): boolean {
|
|
102
|
+
if (!member.acceptedAt) return false;
|
|
103
|
+
return !member.lastNudgedAt || member.acceptedAt > member.lastNudgedAt;
|
|
104
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { NudgeKind } from "../data/enums";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The words a nudge goes out with.
|
|
8
|
+
*
|
|
9
|
+
* **Two messages, not one, and the reason is external.** A tester's store opt-in page only works once
|
|
10
|
+
* the developer has added that address to the tester list — and no API can do that, so it is a manual
|
|
11
|
+
* step in the console. Sending the store link before it completes produces `App not available`, which
|
|
12
|
+
* reads to the tester as a broken app. So `confirm` asks whether they will help, and `store` follows
|
|
13
|
+
* once they are actually on the list.
|
|
14
|
+
*
|
|
15
|
+
* **Every kind ships serviceable default copy, and that is a requirement rather than a courtesy.** A
|
|
16
|
+
* developer with no dashboard must be able to run `pithy testers run` and have it say something
|
|
17
|
+
* sensible. Shipping the mechanism without the words would functionally gate the library behind the
|
|
18
|
+
* paid tier, which is not on the table.
|
|
19
|
+
*
|
|
20
|
+
* **A caller may override the words, and only the words.** The route accepts a subject and a
|
|
21
|
+
* plain-text body. This capability wraps them in its own layout, branding, footer, and unsubscribe, and
|
|
22
|
+
* renders the HTML itself.
|
|
23
|
+
*
|
|
24
|
+
* **Why the route must never accept HTML.** A control-plane caller supplying nudge content is asking
|
|
25
|
+
* this Worker to mail arbitrary content to the adopter's own users, signed with the adopter's DKIM. If
|
|
26
|
+
* that content were unconstrained markup, a compromised dashboard credential would stop being a
|
|
27
|
+
* disclosure problem and become a phishing platform operating from a domain those users already trust —
|
|
28
|
+
* the trust being the whole point of DKIM. Bounding the input to text bounds the blast radius: the
|
|
29
|
+
* worst a compromised caller can send is a lie in prose, which is bad, rather than a credential-harvest
|
|
30
|
+
* form that renders as the adopter's own product, which is catastrophic.
|
|
31
|
+
*
|
|
32
|
+
* The body is split into paragraphs here and handed to the template as an **array of plain strings**,
|
|
33
|
+
* each rendered through Handlebars' escaping `{{this}}`. So markup in a supplied body cannot become
|
|
34
|
+
* markup in the sent mail even if every other check were bypassed: the escaping is structural, not a
|
|
35
|
+
* filter that has to anticipate what to strip.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The shipped default copy for one nudge kind. */
|
|
39
|
+
export interface NudgeCopy {
|
|
40
|
+
/** The subject line. */
|
|
41
|
+
readonly subject: string;
|
|
42
|
+
/** The lead sentence, rendered as the email's heading. */
|
|
43
|
+
readonly heading: string;
|
|
44
|
+
/** The body, already split into paragraphs. Each renders escaped. */
|
|
45
|
+
readonly paragraphs: readonly string[];
|
|
46
|
+
/** The call-to-action label, when the nudge carries a link. */
|
|
47
|
+
readonly ctaLabel: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The shipped defaults, in brand voice: short sentences, deliberate periods, nothing oversold.
|
|
52
|
+
*
|
|
53
|
+
* Each is written to be true regardless of what the adopter's app is, because this copy goes out from
|
|
54
|
+
* their domain and a nudge that overclaims costs them the relationship, not us.
|
|
55
|
+
*/
|
|
56
|
+
const DEFAULTS: Record<NudgeKind, NudgeCopy> = {
|
|
57
|
+
confirm: {
|
|
58
|
+
subject: "Can you help test an app?",
|
|
59
|
+
heading: "Will you be a tester?",
|
|
60
|
+
paragraphs: [
|
|
61
|
+
"You have been asked to help test an early build. Answering takes one tap, and it is only a yes — nothing installs yet.",
|
|
62
|
+
"Once you say yes, the developer adds your address to the test, and you will get a second email with the link to join and install. The test runs for a fixed period and needs everyone who joins to stay joined for the whole of it.",
|
|
63
|
+
],
|
|
64
|
+
ctaLabel: "Yes, count me in",
|
|
65
|
+
},
|
|
66
|
+
store: {
|
|
67
|
+
subject: "You're on the list — here's the link",
|
|
68
|
+
heading: "Time to join the test",
|
|
69
|
+
paragraphs: [
|
|
70
|
+
"You have been added to the test. Use the button below to join and install the app.",
|
|
71
|
+
"Open it in a browser rather than the store app, and sign in with the same address this email reached you at — the store will not let you join with a different account.",
|
|
72
|
+
"Stay on the test until the window closes. That is the part that actually counts.",
|
|
73
|
+
],
|
|
74
|
+
ctaLabel: "Join the test",
|
|
75
|
+
},
|
|
76
|
+
inactive: {
|
|
77
|
+
subject: "Still testing?",
|
|
78
|
+
heading: "We have not seen you in a while",
|
|
79
|
+
paragraphs: [
|
|
80
|
+
"You joined the test but have not opened the app recently. If something is broken or in the way, that is worth knowing — it is the reason the test exists.",
|
|
81
|
+
"If you have uninstalled, no hard feelings. Letting the developer know means they can invite someone else in time.",
|
|
82
|
+
],
|
|
83
|
+
ctaLabel: null,
|
|
84
|
+
},
|
|
85
|
+
closing: {
|
|
86
|
+
subject: "The test window closes soon",
|
|
87
|
+
heading: "Nearly there",
|
|
88
|
+
paragraphs: [
|
|
89
|
+
"The testing period is almost over. Staying enrolled until it closes is what makes it count.",
|
|
90
|
+
"Nothing is needed from you but that. Thank you for helping.",
|
|
91
|
+
],
|
|
92
|
+
ctaLabel: null,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The shipped default copy for a nudge kind. */
|
|
97
|
+
export function defaultCopy(kind: NudgeKind): NudgeCopy {
|
|
98
|
+
return DEFAULTS[kind];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Split a supplied plain-text body into paragraphs.
|
|
103
|
+
*
|
|
104
|
+
* Blank lines separate paragraphs; single newlines inside one are collapsed to spaces, because a
|
|
105
|
+
* hard-wrapped paragraph pasted from a terminal should not render as a column of one-line stanzas.
|
|
106
|
+
* Control characters are stripped: they cannot become markup, but they can smuggle direction overrides
|
|
107
|
+
* that make a subject line read as something other than what was approved.
|
|
108
|
+
*/
|
|
109
|
+
export function toParagraphs(body: string, maxParagraphs = 20): string[] {
|
|
110
|
+
return (
|
|
111
|
+
body
|
|
112
|
+
// Everything but the newline this function is about to split on. The bidi range is the one worth
|
|
113
|
+
// naming: a right-to-left override inside a body renders the text after it reversed, which is how
|
|
114
|
+
// an approved-looking sentence can display as something else entirely.
|
|
115
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point.
|
|
116
|
+
.replace(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
117
|
+
.split(/\n\s*\n/)
|
|
118
|
+
.map((paragraph) => paragraph.replace(/\s*\n\s*/g, " ").trim())
|
|
119
|
+
.filter((paragraph) => paragraph.length > 0)
|
|
120
|
+
.slice(0, maxParagraphs)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Flatten a supplied subject to a single safe line.
|
|
126
|
+
*
|
|
127
|
+
* **A subject is not an HTML context, so escaping it would be wrong** — `<` in an inbox is a
|
|
128
|
+
* rendering bug, not a defense, and the email engine precompiles subjects with escaping off for exactly
|
|
129
|
+
* that reason. The threat in a subject is different and this is what answers it: a carriage return or
|
|
130
|
+
* newline can terminate the header and let everything after it be read as another header, which is how
|
|
131
|
+
* a supplied subject becomes an injected `Bcc`. Every control character goes, newlines included, and
|
|
132
|
+
* the remaining whitespace collapses so a multi-line paste arrives as one line rather than a truncated
|
|
133
|
+
* one.
|
|
134
|
+
*/
|
|
135
|
+
export function sanitizeSubject(subject: string): string {
|
|
136
|
+
return (
|
|
137
|
+
subject
|
|
138
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point.
|
|
139
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, " ")
|
|
140
|
+
.replace(/\s+/g, " ")
|
|
141
|
+
.trim()
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Where a nudge's words came from. Recorded on the email job so a send is attributable. */
|
|
146
|
+
export type CopySource = "default" | "supplied";
|
|
147
|
+
|
|
148
|
+
/** The words one nudge will actually go out with, and their provenance. */
|
|
149
|
+
export interface ResolvedCopy {
|
|
150
|
+
readonly subject: string;
|
|
151
|
+
readonly heading: string;
|
|
152
|
+
readonly paragraphs: readonly string[];
|
|
153
|
+
readonly ctaLabel: string | null;
|
|
154
|
+
readonly source: CopySource;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolve the copy for a nudge: the caller's words if they supplied any and the deployment allows it,
|
|
159
|
+
* otherwise the shipped default.
|
|
160
|
+
*
|
|
161
|
+
* A caller supplying only a subject keeps the default body, and vice versa. Partial overrides are a
|
|
162
|
+
* normal thing to want — "same message, our subject line" — and forcing all-or-nothing would push
|
|
163
|
+
* callers into copying the default body into their request, where it would then never receive an
|
|
164
|
+
* improvement.
|
|
165
|
+
*/
|
|
166
|
+
export function resolveCopy(kind: NudgeKind, supplied: { subject?: string; body?: string } | undefined): ResolvedCopy {
|
|
167
|
+
const base = defaultCopy(kind);
|
|
168
|
+
const subject = supplied?.subject === undefined ? undefined : sanitizeSubject(supplied.subject);
|
|
169
|
+
const paragraphs = supplied?.body ? toParagraphs(supplied.body) : undefined;
|
|
170
|
+
const overridden = Boolean(subject) || (paragraphs !== undefined && paragraphs.length > 0);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
subject: subject && subject.length > 0 ? subject : base.subject,
|
|
174
|
+
heading: base.heading,
|
|
175
|
+
paragraphs: paragraphs && paragraphs.length > 0 ? paragraphs : base.paragraphs,
|
|
176
|
+
ctaLabel: base.ctaLabel,
|
|
177
|
+
source: overridden ? "supplied" : "default",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import type { EnqueueNudge } from "./send";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The email enqueue seam, loaded from the project's own install.
|
|
9
|
+
*
|
|
10
|
+
* A guarded dynamic import: a project can compose testers without email — inviting nobody and simply
|
|
11
|
+
* tracking a roster it imported — and the pass should still advance state and write its snapshot rather
|
|
12
|
+
* than fail on a dependency it does not strictly need.
|
|
13
|
+
*
|
|
14
|
+
* **Its own module, away from `worker.ts`, because of the clock.** The instant this seam stamps on an
|
|
15
|
+
* email job is a *liveness* value, not a stamp: `createdAt` is what the email scheduler's grace re-drive
|
|
16
|
+
* reads to decide a `pending` job never dispatched. The whole of pithy-sh/pithy#328 is that the pass's
|
|
17
|
+
* other clock — the one deciding the day key — must be journalled and this one must not, and a rule
|
|
18
|
+
* about a clock that lives in a module no test can import is a rule nothing holds anybody to.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The bindings this seam needs from the worker's env. `TestersWorkerEnv` supplies them. */
|
|
22
|
+
export interface NudgeEnqueueEnv {
|
|
23
|
+
/** The app database, which holds `pithy_email_jobs` beside the testers tables. */
|
|
24
|
+
DB: D1Database;
|
|
25
|
+
/** The sending identity, copied from the email capability's resolved config at provision. */
|
|
26
|
+
EMAIL_FROM_ADDRESS?: string;
|
|
27
|
+
EMAIL_FROM_NAME?: string;
|
|
28
|
+
/** The resolved email theme, as the email worker carries it. */
|
|
29
|
+
EMAIL_THEME?: string;
|
|
30
|
+
/**
|
|
31
|
+
* The project's message catalogs as one JSON var, exactly as the email host worker carries them.
|
|
32
|
+
*
|
|
33
|
+
* The **shell** is what this moves — the document's `lang` and `dir`, the footer's opt-out word. A
|
|
34
|
+
* nudge's own words are the adopter's copy, supplied per message and never in a catalog, so a
|
|
35
|
+
* translated tester email is only as translated as the copy somebody wrote for it. This capability
|
|
36
|
+
* holds no per-tester locale to choose with either: a roster is a list of addresses, and inventing a
|
|
37
|
+
* language for one of them would be worse than the honest English. So the catalogs are threaded and
|
|
38
|
+
* the tag is not, and the day a member row carries one, this is already the seam it renders through.
|
|
39
|
+
*/
|
|
40
|
+
[catalogVar: `EMAIL_MESSAGES_${string}`]: unknown;
|
|
41
|
+
/** The email send Workflow, so an immediate job dispatches now rather than waiting for a cron tick. */
|
|
42
|
+
EMAIL_SENDER?: { create(options: { params: { jobIds: string[] } }): Promise<unknown> };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the enqueue seam, or `undefined` when this deployment cannot send.
|
|
47
|
+
*
|
|
48
|
+
* **`clock` is a thunk, and it is read once per enqueued nudge.** That is the liveness half of
|
|
49
|
+
* pithy-sh/pithy#328 and it is load-bearing, not tidiness. `enqueueEmail` writes the clock it is given
|
|
50
|
+
* as the job's `createdAt`, and the email scheduler re-drives any `pending` job whose `createdAt` is
|
|
51
|
+
* older than `graceMs` — on the assumption that its dispatch died. A nudge enqueued under an instant
|
|
52
|
+
* the pass read minutes or hours ago is therefore born already past that cutoff, so the scheduler
|
|
53
|
+
* claims and dispatches it while the `EMAIL_SENDER.create` this function just made is still running.
|
|
54
|
+
* Two send Workflows, one job, and `runSend` short-circuits only a job already `sent`. That is a
|
|
55
|
+
* double-send, and it is why the pass's journalled instant must never reach this line.
|
|
56
|
+
*/
|
|
57
|
+
export async function buildNudgeEnqueue(
|
|
58
|
+
env: NudgeEnqueueEnv,
|
|
59
|
+
clock: () => Date = () => new Date(),
|
|
60
|
+
): Promise<EnqueueNudge | undefined> {
|
|
61
|
+
// No sending identity means no send. Falling back to a plausible-looking default address would mail
|
|
62
|
+
// the adopter's testers from a domain their DKIM does not cover, which is worse than sending nothing:
|
|
63
|
+
// it trains the recipients' providers to treat the real domain as spam.
|
|
64
|
+
if (!env.EMAIL_FROM_ADDRESS || !env.EMAIL_FROM_NAME) return undefined;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const { enqueueEmail } = await import("@pithy-sh/email/src/send/enqueue");
|
|
68
|
+
const { emailDatabase } = await import("@pithy-sh/email/src/data/tables");
|
|
69
|
+
const { defaultTheme, EmailTheme } = await import("@pithy-sh/email/src/templates/theme");
|
|
70
|
+
const { catalogLayers, catalogsFromEnv } = await import("@pithy-sh/email/src/templates/messages");
|
|
71
|
+
const theme = env.EMAIL_THEME ? EmailTheme.parse(JSON.parse(env.EMAIL_THEME)) : defaultTheme;
|
|
72
|
+
// One variable per locale, collected and validated by the same seam the email host uses.
|
|
73
|
+
const layersFor = catalogLayers(catalogsFromEnv(env as unknown as Record<string, unknown>));
|
|
74
|
+
const fromAddress = env.EMAIL_FROM_ADDRESS;
|
|
75
|
+
const fromName = env.EMAIL_FROM_NAME;
|
|
76
|
+
return async (input) =>
|
|
77
|
+
enqueueEmail(
|
|
78
|
+
{
|
|
79
|
+
db: emailDatabase(env.DB),
|
|
80
|
+
fromAddress,
|
|
81
|
+
fromName,
|
|
82
|
+
theme,
|
|
83
|
+
layersFor,
|
|
84
|
+
sender: env.EMAIL_SENDER,
|
|
85
|
+
// Read here, per nudge — see the note above. Hoisting this out of the closure is the
|
|
86
|
+
// double-send.
|
|
87
|
+
now: clock(),
|
|
88
|
+
newId: () => crypto.randomUUID(),
|
|
89
|
+
},
|
|
90
|
+
input,
|
|
91
|
+
);
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|