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