@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pithy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @pithy-sh/testers
2
+
3
+ The roster, the invitations, and the fourteen-day clock — for the closed test Google Play makes every new personal developer account run before it will grant production access.
4
+
5
+ Twelve testers, opted in for fourteen continuous days. Lose one on day nine and the clock effectively restarts. There is no screen anywhere that tells you where you stand, which is the entire problem.
6
+
7
+ ```sh
8
+ pithy add testers
9
+ ```
10
+
11
+ **Documentation: [pithy.sh/docs/capabilities/testers](https://pithy.sh/docs/capabilities/testers).** Overview, adding it, using it, and the reference: the estimate, provisioning, what the store APIs cannot tell you.
12
+
13
+ _Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
14
+
15
+ ## License
16
+
17
+ MIT.
@@ -0,0 +1,107 @@
1
+ # What the store APIs actually expose
2
+
3
+ _The reader's version of this page is [pithy.sh/docs/capabilities/testers/store-apis](https://pithy.sh/docs/capabilities/testers/store-apis). This copy ships in the package because `packages/testers/src/crypto/token.ts` sends an adopter to it by name._
4
+
5
+ This document exists because the most valuable thing `@pithy-sh/testers` can tell you is what it cannot do.
6
+
7
+ Read it before assuming any part of this capability talks to Google. It does not. Nothing here reads the Play Console, and nothing here can.
8
+
9
+ Every claim below carries the URL it came from. Where a claim is inferred rather than stated by the vendor, it says so.
10
+
11
+ ---
12
+
13
+ ## Google Play: the twelve-testers requirement
14
+
15
+ The rule, verbatim from the authoritative page: developers with personal accounts created after November 13, 2023 must "run a closed test for your app with a minimum of 12 testers who have been opted-in for at least the last 14 days continuously." ([App testing requirements for new personal developer accounts](https://support.google.com/googleplay/android-developer/answer/14151465))
16
+
17
+ Three details do most of the damage in practice.
18
+
19
+ **The days must be consecutive.** Google states it outright: "we won't count testers who opted in, tested for less than 14 days, and then opted out. Even if they opt back in so that they are opted in for a total of 14 days, these 14 days must be consecutive." ([same page](https://support.google.com/googleplay/android-developer/answer/14151465)) Lose one tester on day nine and the clock effectively restarts.
20
+
21
+ **Internal testing does not count.** Only closed testing satisfies the requirement. ([same page](https://support.google.com/googleplay/android-developer/answer/14151465))
22
+
23
+ **It applies to personal accounts.** Organization accounts are not subject to it. ([same page](https://support.google.com/googleplay/android-developer/answer/14151465))
24
+
25
+ The requirement launched at twenty testers and was later reduced to twelve, with the same fourteen-day window. Google maintains a [community guide](https://support.google.com/googleplay/android-developer/community-guide/255621488/everything-about-the-12-testers-requirement) on it. Treat "it used to be 20, it is now 12" as well established and the exact change date as unconfirmed — the secondary sources reporting December 11, 2024 are not Google.
26
+
27
+ ### The hundred-tester figure is about a different track
28
+
29
+ You will see "maximum 100 testers" repeated widely. It is real, and it is about **internal** testing: "up to 100 testers per app." ([Set up an open, closed, or internal test](https://support.google.com/googleplay/android-developer/answer/9845334))
30
+
31
+ **Closed testing — the track the requirement actually runs on — has no such cap.** Its limits are 2,000 users per email list, 50 lists per track, and 200 lists per account. ([same page](https://support.google.com/googleplay/android-developer/answer/9845334)) Google states no maximum tester count on the requirement page at all.
32
+
33
+ This capability defaults a cohort's roster cap to 100 anyway, and the config field says why: a hundred is roughly the number of people one developer can still chase by hand. That is a management judgment, not a store limit, and you can raise it to 2,000.
34
+
35
+ ---
36
+
37
+ ## What the Play Developer API exposes
38
+
39
+ Very little, and none of the part you want.
40
+
41
+ **Tester management is Google-Groups-only.** The `edits.testers` resource has exactly one field — `googleGroups[]`, "All testing Google Groups, as email addresses." Its methods are `get`, `patch`, and `update`. There is no `list`, no per-tester resource, and no create or delete of an individual tester. ([edits.testers](https://developers.google.com/android-publisher/api-ref/rest/v3/edits.testers))
42
+
43
+ The limitation is documented on every method: "while it is possible in the Play Console UI to add testers via email lists, email lists are not supported by this resource." ([edits.testers.get](https://developers.google.com/android-publisher/api-ref/rest/v3/edits.testers/get))
44
+
45
+ So for the ordinary closed-testing setup — a list of email addresses added in the Console — the API is blind. It cannot read your roster, and it cannot write it.
46
+
47
+ **The opt-in count is not readable.** Not the streak, not the plain count. The full v3 resource inventory contains no testing-metrics resource at all ([REST index](https://developers.google.com/android-publisher/api-ref/rest)), and the Play Developer Reporting API covers app quality — crashes, ANRs, wake-locks — not tester counts ([Play Developer Reporting](https://developers.google.com/play/developer/reporting)). The figure lives on one Play Console screen, and developers report it as unreliable even there ([thread](https://support.google.com/googleplay/android-developer/thread/312314620)).
48
+
49
+ **Opt-out is not detectable.** There is no tester-state resource, and there is no webhook: Play's only push channel carries subscription and voided-purchase events, not testing events. Google clearly tracks opt-out server-side — its own wording depends on it — but exposes it only to its own eligibility check. ([REST index](https://developers.google.com/android-publisher/api-ref/rest))
50
+
51
+ **What the API does cover** is release management: `edits.tracks` handles releases, version codes, staged-rollout fractions, country targeting, and halt/resume. ([edits.tracks](https://developers.google.com/android-publisher/api-ref/rest/v3/edits.tracks)) That is genuinely useful and genuinely not this capability.
52
+
53
+ ---
54
+
55
+ ## Apple TestFlight, for contrast
56
+
57
+ App Store Connect exposes almost exactly what Google withholds.
58
+
59
+ `GET /v1/betaTesters` returns the roster with `firstName`, `lastName`, `email`, `inviteType`, and **`state`** — and testers can be added and removed individually. ([List beta testers](https://developer.apple.com/documentation/appstoreconnectapi/get-v1-betatesters))
60
+
61
+ `BetaTesterState` is a first-class readable attribute: `NOT_INVITED`, `INVITED`, `ACCEPTED`, `INSTALLED`, and `REVOKED` — where `REVOKED` means "The beta tester chose to stop testing, or was removed from the app." ([BetaTesterState](https://developer.apple.com/documentation/appstoreconnectapi/betatesterstate)) That is a machine-readable opt-out signal, which is the single largest gap between the two stores.
62
+
63
+ Per-tester sessions and crashes are still Console-only, and excluded even from Apple's own CSV export. ([View and manage tester information](https://www.developer.apple.com/help/app-store-connect/test-a-beta-version/view-and-manage-tester-information))
64
+
65
+ **Apple has no minimum-tester or minimum-duration requirement.** Nothing analogous to Google's twelve-for-fourteen exists. What Apple does have is guideline 2.2, which prohibits distributing TestFlight builds "in exchange for compensation of any kind" — so the paid-tester-farm ecosystem that grew around Google's rule is an App Review violation on iOS. ([App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/))
66
+
67
+ Auth is an ES256 JWT signed with a `.p8` key, and App Store Connect "rejects tokens with a lifetime greater than 20 minutes" for the TestFlight resources. ([Generating tokens](https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests))
68
+
69
+ ---
70
+
71
+ ## The sequencing this forces
72
+
73
+ Because the API cannot add an address to an email list, that step is manual — the developer does it in Play Console. And the opt-in link does not work for a tester until it is done: they get `App not available`, which reads to them as a broken app rather than as a step you have not taken yet.
74
+
75
+ So the tester's journey has to be two messages, not one:
76
+
77
+ 1. **"Will you help test?"** — Pithy's own link, recording consent and confirming the address is deliverable and correct *before* it goes on a Google-visible list.
78
+ 2. You add the confirmed addresses to Play Console.
79
+ 3. **"You're on the list — here's the link"** — carrying the store's own opt-in page.
80
+
81
+ Pithy's second link records the click and then *renders* the store link with instructions rather than redirecting to it. A 302 would make the adopter's Worker a redirector, and it would leave nowhere to say the two things that prevent most failures: open it in a browser rather than the store app, and sign in with the address the email reached you at.
82
+
83
+ ## What this capability therefore does
84
+
85
+ It owns its own model, and says so everywhere.
86
+
87
+ Pithy records who you invited, who agreed to test, and who followed the link through to the store, and replays a fourteen-day clock from those events. That is a well-informed estimate of the figure Google computes. It is not that figure, and the two diverge — most obviously when a tester opts out without telling anyone, which Google sees and Pithy cannot.
88
+
89
+ Every field derived from it is named `estimated`. Every response carries a required `disclaimer` naming Google as the authority and stating that no API exposes it. `reconciliation.supported` is `false` with the reason attached, because "we checked and cannot" and "we never considered it" are different facts.
90
+
91
+ **What Pithy reads that nobody else does is activity.** Because `@pithy-sh/auth` owns sessions and the device registry, a tester's invited address resolves to their user, and from there to when they last opened the app. That is fact, not estimate, and it is the only early-warning signal that exists: a tester dark for eight days is the one most likely to have quietly gone, and knowing on day eight beats discovering on day fourteen.
92
+
93
+ Activity is never treated as opt-in continuity. A tester who confirmed and never opens the app still counts toward Google's twelve, so silence makes someone look alarming in the health column and never removes them from the count.
94
+
95
+ The caveat that has to be stated rather than discovered: **activity exists only for testers who authenticate.** Someone who installs and never signs in is invisible, and an app whose test flow requires no sign-in produces no activity data at all. Those testers report `never_linked` rather than `inactive`, their health is `null` rather than a low score, and the forecast's confidence band widens to say how much of the cohort is unobservable.
96
+
97
+ ---
98
+
99
+ ## What a follow-up could add, and what it could not
100
+
101
+ Store-API integration is viable, but only if it is scoped honestly.
102
+
103
+ **Apple is worth building.** Roster reconciliation against `GET /v1/betaTesters`, opt-out alerting on the `REVOKED` transition, and feedback ingestion through the App Store Connect webhooks are all backed by documented endpoints. That is a real capability with no asterisks.
104
+
105
+ **Google is release automation, not tester automation.** Track promotion, staged rollout, and country targeting are first-class; tester management is not. A follow-up framed around "testers" on the Google side would guarantee that the most-requested feature — the twelve-of-fourteen meter — is the one thing that cannot be built.
106
+
107
+ **What must never be built:** a progress meter that claims to read Google's number. It would have to be either hand-entered by the developer or scraped from the Console, and scraping is fragile, terms-adjacent, and needs a real user session, which the service-account model does not have. A meter that looked authoritative and was neither would be worse than no meter at all.
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@pithy-sh/testers",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pithy-sh/pithy.git",
8
+ "directory": "packages/testers"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "pithy.manifest.json",
13
+ "docs",
14
+ "!src/**/*.test.*"
15
+ ],
16
+ "type": "module",
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "exports": {
21
+ "./src/*": "./src/*.ts"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json --noEmit false --outDir dist",
25
+ "typecheck": "tsc -p tsconfig.json",
26
+ "test": "vitest run",
27
+ "test:node": "vitest run --project=node",
28
+ "test:workers": "vitest run --project=workers",
29
+ "clean": "rm -rf dist .turbo",
30
+ "reset": "bun run clean && rm -rf node_modules"
31
+ },
32
+ "peerDependencies": {
33
+ "@pithy-sh/auth": "workspace:*"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@pithy-sh/auth": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "dependencies": {
41
+ "@cloudflare/workers-types": "^5.20260729.1",
42
+ "@hono/zod-validator": "^0.9.0",
43
+ "@pithy-sh/core": "workspace:*",
44
+ "@pithy-sh/email": "workspace:*",
45
+ "@pithy-sh/secrets": "workspace:*",
46
+ "hono": "^4.13.2",
47
+ "kysely": "^0.29.0",
48
+ "zod": "^4.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@cloudflare/vitest-plugin": "^1.0.0",
52
+ "@pithy-sh/auth": "workspace:*",
53
+ "@pithy-sh/tsconfig": "workspace:*",
54
+ "@types/node": "^22.15.0",
55
+ "@vitest/coverage-v8": "^4.1.0",
56
+ "comment-json": "^5.0.0",
57
+ "kysely-d1": "^0.4.0",
58
+ "typescript": "^7.0.2",
59
+ "vitest": "^4.1.0",
60
+ "wrangler": "^4.115.0"
61
+ }
62
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "testers",
3
+ "package": "@pithy-sh/testers",
4
+ "requiredBindings": [
5
+ {
6
+ "type": "d1",
7
+ "name": "DB"
8
+ },
9
+ {
10
+ "type": "workflow",
11
+ "name": "TESTERS_DAILY",
12
+ "job": "daily",
13
+ "className": "TestersDailyWorkflow",
14
+ "optional": true
15
+ }
16
+ ],
17
+ "peerCapabilities": ["email"],
18
+ "optionalCapabilities": ["auth", "controlplane", "audit", "secrets"],
19
+ "migrationNamespace": "testers",
20
+ "whenToEnable": "Google Play makes every new personal developer account run a closed test with at least 12 testers opted in for 14 continuous days before it will grant production access, and there is no screen anywhere that tells you where you stand. Lose one tester on day nine and the clock effectively starts again. This runs that process: a cohort with a target and a window, a roster you invite by email from your own domain, a one-tap confirmation link that needs no account, and a fourteen-day clock replayed from your own event log so a correction is a recomputation rather than an overwrite. The clock is honest about what it is. Pithy's count is an estimate from your own invite records; Google's count is authoritative and no API exposes it — not the roster, not the number, not a tester quietly opting out — so every figure derived from it is named `estimated`, the response carries that sentence as a required field, and nothing here claims to read the console. What Pithy can read is activity, and that is the part nobody else has: resolve each tester to their user through `@pithy-sh/auth` and you learn who has actually opened the app, so a tester dark for eight days shows up on day eight rather than on day fourteen when the count finally moves. A daily Workflow advances state, chases whoever needs chasing under a mandatory per-tester cooldown, and writes one snapshot per cohort per day — which is what makes the trend chartable at all, because activity cannot be reconstructed after the fact the way an event log can. Every nudge ships default copy, so this works from the CLI with no dashboard; a control-plane caller may override the words as plain text and never as markup, because those words go out over your own DKIM signature.",
21
+ "scaffold": [
22
+ "Add a `testers({ baseUrl: 'https://<your-worker>' })` block to pithy.config.ts. `baseUrl` is required before you can send an invitation — an email cannot carry a relative link.",
23
+ "Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
24
+ "Run `pithy migrate` to create pithy_testers_cohorts, pithy_testers_members, pithy_testers_events, and pithy_testers_cohort_snapshots.",
25
+ "Paste the store's opt-in link when you create the cohort — `pithy testers create <name> --store-url https://play.google.com/apps/testing/<package>`. Copy it from Play Console; it is where a tester actually enrols, and Pithy will not guess the format.",
26
+ "Add `email(...)` to the same Worker if it is not there already. Invitations and nudges are enqueued through it, so they inherit its retries, suppression list, and bounce handling.",
27
+ "Run `pithy testers provision` to deploy the daily-pass Worker and bind TESTERS_DAILY. It creates nothing else — no bucket, no secret — because the confirmation token is a random value on the tester's own row rather than a signature.",
28
+ "Create a cohort: `pithy testers create <name>`. It inherits your configured target and window and then freezes its own copy, so a later config change cannot rewrite whether last Tuesday counted.",
29
+ "Invite testers: `pithy testers invite <cohort> --email <address>`. Carry more than twelve — twelve is the number that must still be standing at the end, not the number to start with.",
30
+ "Watch it: `pithy testers status <cohort>` reports the estimated clock, observed activity, the forecast, and the trend. `--json` for an agent.",
31
+ "Run the pass: `pithy testers run` sends what is due, advances state, and records the day's snapshot. The provisioned Worker does the same at 05:00 UTC; `run` is how you exercise it now.",
32
+ "Read `docs/store-apis.md` in this package before assuming any of this talks to Google. It does not, and that document explains exactly what the Play Developer API does and does not expose."
33
+ ],
34
+ "configOptions": [
35
+ {
36
+ "key": "basePath",
37
+ "default": "/testers",
38
+ "describe": "Where the testers routes mount, the public opt-in link included. Change it and the confirmation links in already-sent invitations break, so pick it before you invite anybody."
39
+ },
40
+ {
41
+ "key": "baseUrl",
42
+ "default": "https://example.com",
43
+ "constant": "publicOrigin",
44
+ "describe": "The absolute origin the opt-in link is built from. Required before an invitation can be sent, because an email cannot carry a relative URL. Set it to the origin your Worker actually answers on, per environment."
45
+ },
46
+ {
47
+ "key": "modelVersion",
48
+ "default": "1",
49
+ "describe": "The label stamped on every daily snapshot identifying which set of survival and health constants produced its forecast. Bump it whenever you change one: a trend line silently spanning two models is a lie, and the dashboard uses this to annotate where your change took effect."
50
+ }
51
+ ]
52
+ }
@@ -0,0 +1,273 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
6
+ import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
7
+ import { JsonDate } from "@pithy-sh/core/src/data/codecs";
8
+ import { messageOf } from "@pithy-sh/core/src/error/pithyError";
9
+ import { type Logger, noopLogger } from "@pithy-sh/core/src/logger/logger";
10
+ import type { TesterPlatform } from "../config/config";
11
+ import type { ActivityState, Observability } from "../data/enums";
12
+
13
+ /**
14
+ * Reading what testers actually did — the only figures in this package that are fact rather than
15
+ * estimate.
16
+ *
17
+ * **Activity is not a proxy for opt-in continuity, and conflating them would be the worst mistake this
18
+ * capability could make.** A tester who confirmed and never opens the app still counts toward Google's
19
+ * twelve. What activity buys is the only early-warning signal available: a tester dark for eight days
20
+ * is the one most likely to have quietly opted out or uninstalled, and that is actionable on day eight
21
+ * rather than on day fourteen when the count finally moves. So this module scores worry, never
22
+ * membership.
23
+ *
24
+ * **`@pithy-sh/auth` is an optional peer, reached through a guarded dynamic import.** A static import
25
+ * would make auth a hard dependency of a capability that works without it: a project whose test flow
26
+ * has no sign-in gets no activity data and should still be able to run cohorts, send invitations, and
27
+ * track the clock. When auth is absent, every tester resolves `unobservable` and the forecast widens
28
+ * its band to say so — which is exactly the right answer, and a much better one than failing to boot.
29
+ *
30
+ * **Two date encodings live in auth's own tables, and mixing them silently returns nothing.** Better
31
+ * Auth's columns (`pithy_auth_sessions`) are ISO-8601 **text**; Pithy's own (`pithy_auth_devices`) are
32
+ * ms-epoch **integers**. A window query written with the wrong codec compares a number to a string,
33
+ * matches no rows, and reports a perfectly active cohort as entirely dark. `JsonDate` for the first,
34
+ * `SQLiteDate` for the second — the comments at each call site say which and why.
35
+ */
36
+
37
+ /** One registered device, as the roster view reports it. */
38
+ export interface TesterDevice {
39
+ readonly platform: TesterPlatform | "web";
40
+ readonly lastSeenAt: Date;
41
+ readonly appVersion: string | null;
42
+ }
43
+
44
+ /** What we can observe about one tester. Every field here is fact; none of it is inferred. */
45
+ export interface TesterActivity {
46
+ /** The invited address this reading is for, lowercased. */
47
+ readonly email: string;
48
+ /** The matched user's id, or null when the address never matched a user. */
49
+ readonly userId: string | null;
50
+ /** Whether we can see this tester at all, and why not when we cannot. */
51
+ readonly observability: Observability;
52
+ /** What the activity says. `never_linked` is deliberately distinct from `inactive`. */
53
+ readonly state: ActivityState;
54
+ /** The most recent sign of life — session refresh or device sighting. Null when never linked. */
55
+ readonly lastAuthenticatedAt: Date | null;
56
+ /** Sessions *active* inside the requested window — refreshed within it, whenever they were created. */
57
+ readonly sessionsInWindow: number;
58
+ /** Registered devices, most recently seen first. */
59
+ readonly devices: readonly TesterDevice[];
60
+ }
61
+
62
+ /** What the reader needs to know to interpret what it finds. */
63
+ export interface ActivityOptions {
64
+ /** The start of the window sessions are counted inside. */
65
+ readonly since: Date;
66
+ /** A tester who authenticated at or after this is `active`. */
67
+ readonly activeSince: Date;
68
+ /** Addresses known to have bounced or be suppressed — reported `unreachable` whatever else we find. */
69
+ readonly unreachable: ReadonlySet<string>;
70
+ }
71
+
72
+ /** The auth modules this reader needs, or null when `@pithy-sh/auth` is not installed. */
73
+ type AuthModules = {
74
+ authDatabase: typeof import("@pithy-sh/auth/src/data/tables").authDatabase;
75
+ User: typeof import("@pithy-sh/auth/src/data/betterAuth").User;
76
+ Device: typeof import("@pithy-sh/auth/src/data/device").Device;
77
+ } | null;
78
+
79
+ /**
80
+ * Load `@pithy-sh/auth`, or `null` if it is not installed.
81
+ *
82
+ * A missing optional peer is not an error here — it is a project that has no sign-in, which is a
83
+ * legitimate way to run a closed test. Returning null lets every tester resolve `unobservable`, which
84
+ * is the honest reading, rather than raising in a daily Workflow nobody is watching.
85
+ */
86
+ async function loadAuth(): Promise<AuthModules> {
87
+ try {
88
+ const [tables, betterAuth, device] = await Promise.all([
89
+ import("@pithy-sh/auth/src/data/tables"),
90
+ import("@pithy-sh/auth/src/data/betterAuth"),
91
+ import("@pithy-sh/auth/src/data/device"),
92
+ ]);
93
+ return { authDatabase: tables.authDatabase, User: betterAuth.User, Device: device.Device };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /** A tester we cannot see, with the reason stated rather than implied. */
100
+ function blind(email: string, observability: Observability): TesterActivity {
101
+ return {
102
+ email,
103
+ userId: null,
104
+ observability,
105
+ state: observability === "unreachable" ? "unreachable" : "never_linked",
106
+ lastAuthenticatedAt: null,
107
+ sessionsInWindow: 0,
108
+ devices: [],
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Resolve activity for a set of invited addresses.
114
+ *
115
+ * Batched by address rather than looked up one tester at a time: a hundred-member roster would
116
+ * otherwise be three hundred round trips inside a Workflow step. `chunkByBoundParameters` keeps each
117
+ * `IN (…)` inside D1's statement limits.
118
+ *
119
+ * **`log` is the caller's logger, not one built here, because this reader has two callers of different
120
+ * shapes.** It runs inside the daily Workflow, and it runs inside `GET /testers/status` and
121
+ * `GET /testers/cohorts` — so a request reaches it, and a request logger already carries the
122
+ * correlation an operator needs. Building a fresh one here would emit the line that explains an empty
123
+ * roster with nothing tying it to the request that asked for it. Defaults to the no-op so a caller with
124
+ * no logger reads activity rather than null-checking.
125
+ */
126
+ export async function resolveActivity(
127
+ d1: D1Database,
128
+ emails: readonly string[],
129
+ options: ActivityOptions,
130
+ log: Logger = noopLogger,
131
+ ): Promise<Map<string, TesterActivity>> {
132
+ const normalized = [...new Set(emails.map(normalizeAddress))];
133
+ const results = new Map<string, TesterActivity>();
134
+ for (const email of normalized) {
135
+ results.set(email, blind(email, options.unreachable.has(email) ? "unreachable" : "unobservable"));
136
+ }
137
+ if (normalized.length === 0) return results;
138
+
139
+ const auth = await loadAuth();
140
+ if (!auth) return results;
141
+
142
+ try {
143
+ await readActivityInto(results, auth, d1, normalized, options);
144
+ } catch (error) {
145
+ // The auth tables are not there — the package is installed but its migration has not run, or this
146
+ // project composes testers without composing auth. Either way it is the same fact as an uninstalled
147
+ // package, and it must degrade the same way: every tester resolves `unobservable`, the forecast
148
+ // widens its band to say so, and the daily pass still advances state and records the day. Throwing
149
+ // here would take down a cron at 05:00 over data that was never promised to exist.
150
+ //
151
+ // Logged rather than silent, because the other thing this catch swallows is a transient D1 failure,
152
+ // and that records a cohort as entirely dark for a day with no trace of why. An operator reading a
153
+ // sudden coverage cliff needs somewhere to look. `warn` rather than `error`: the reading degraded,
154
+ // the caller carried on, and a project that simply has no auth would otherwise page somebody.
155
+ log.warn("activity unreadable, treating the roster as unobservable", {
156
+ addresses: normalized.length,
157
+ reason: messageOf(error),
158
+ });
159
+ }
160
+ return results;
161
+ }
162
+
163
+ /** Fill in what the auth tables can tell us about these addresses. Throws if they are not there. */
164
+ async function readActivityInto(
165
+ results: Map<string, TesterActivity>,
166
+ auth: NonNullable<AuthModules>,
167
+ d1: D1Database,
168
+ normalized: readonly string[],
169
+ options: ActivityOptions,
170
+ ): Promise<void> {
171
+ const db = auth.authDatabase(d1);
172
+
173
+ // Address → user. Auth's `email` column is unique and every Pithy sign-in is provider-verified, so
174
+ // this is an exact match with no ambiguity to resolve.
175
+ const users: { id: string; email: string }[] = [];
176
+ for (const chunk of chunkByBoundParameters(normalized, 0)) {
177
+ const rows = await db.selectFrom("pithyAuthUsers").select(["id", "email"]).where("email", "in", chunk).execute();
178
+ for (const row of rows) users.push({ id: String(row.id), email: normalizeAddress(String(row.email)) });
179
+ }
180
+ if (users.length === 0) return;
181
+
182
+ const userIds = users.map((user) => user.id);
183
+ const emailByUserId = new Map(users.map((user) => [user.id, user.email]));
184
+
185
+ // Sessions in the window. `createdAt` is ISO-8601 TEXT on this table — Better Auth's own encoding —
186
+ // so the bound must be `JsonDate`. `SQLiteDate` here would compare a number against a string, match
187
+ // nothing, and report every tester as dark.
188
+ const sessions = new Map<string, { count: number; lastSeen: Date | null }>();
189
+ for (const chunk of chunkByBoundParameters(userIds, 1)) {
190
+ const rows = await db
191
+ .selectFrom("pithyAuthSessions")
192
+ .select(["userId", "createdAt", "updatedAt"])
193
+ .where("userId", "in", chunk)
194
+ // Bounded on `updatedAt`, which is the column the reading below actually uses. Filtering on
195
+ // `createdAt` excluded the long-lived session — created before the window, refreshed inside it
196
+ // every day since — and reported its owner as dark. That is the most engaged tester there is.
197
+ .where("updatedAt", ">=", JsonDate.encode(options.since))
198
+ .execute();
199
+ for (const row of rows) {
200
+ const userId = String(row.userId);
201
+ const entry = sessions.get(userId) ?? { count: 0, lastSeen: null };
202
+ entry.count++;
203
+ // `updatedAt` slides forward when a session is refreshed, so it is a genuine "last seen the app"
204
+ // signal rather than "last signed in" — which token rotation would otherwise reset.
205
+ const seen = JsonDate.parse(row.updatedAt ?? row.createdAt);
206
+ if (!entry.lastSeen || seen > entry.lastSeen) entry.lastSeen = seen;
207
+ sessions.set(userId, entry);
208
+ }
209
+ }
210
+
211
+ // Devices. This is a Pithy-owned table, so `lastSeenAt` is a ms-epoch INTEGER and the codec is
212
+ // `SQLiteDate`. Devices cover the tester whose long-lived session is refreshed off-device.
213
+ const devices = new Map<string, TesterDevice[]>();
214
+ for (const chunk of chunkByBoundParameters(userIds, 0)) {
215
+ const rows = await db
216
+ .selectFrom("pithyAuthDevices")
217
+ .selectAll()
218
+ .where("userId", "in", chunk)
219
+ .orderBy("lastSeenAt", "desc")
220
+ .execute();
221
+ for (const row of rows) {
222
+ const parsed = auth.Device.parse(row);
223
+ const list = devices.get(parsed.userId) ?? [];
224
+ list.push({ platform: parsed.platform, lastSeenAt: parsed.lastSeenAt, appVersion: parsed.appVersion });
225
+ devices.set(parsed.userId, list);
226
+ }
227
+ }
228
+
229
+ for (const [userId, email] of emailByUserId) {
230
+ if (options.unreachable.has(email)) continue; // Unreachable overrides whatever we can see.
231
+
232
+ const session = sessions.get(userId);
233
+ const deviceList = devices.get(userId) ?? [];
234
+ const lastDevice = deviceList[0]?.lastSeenAt ?? null;
235
+ const lastSession = session?.lastSeen ?? null;
236
+ const lastAuthenticatedAt =
237
+ lastSession && lastDevice ? (lastSession > lastDevice ? lastSession : lastDevice) : (lastSession ?? lastDevice);
238
+
239
+ // A user row with no session and no device is a match we cannot learn anything from. Calling it
240
+ // `observed` would let a tester with zero signal score as merely quiet, when the truth is that we
241
+ // have never seen them use the app at all.
242
+ if (!lastAuthenticatedAt) {
243
+ results.set(email, blind(email, "unobservable"));
244
+ continue;
245
+ }
246
+
247
+ const state: ActivityState = lastAuthenticatedAt >= options.activeSince ? "active" : "inactive";
248
+ results.set(email, {
249
+ email,
250
+ userId,
251
+ observability: "observed",
252
+ state,
253
+ lastAuthenticatedAt,
254
+ sessionsInWindow: session?.count ?? 0,
255
+ devices: deviceList,
256
+ });
257
+ }
258
+ }
259
+
260
+ /** Whether any of a tester's devices runs the cohort's target platform. */
261
+ export function hasPlatformDevice(activity: TesterActivity, platform: TesterPlatform | null): boolean {
262
+ if (platform === null) return true;
263
+ return activity.devices.some((device) => device.platform === platform);
264
+ }
265
+
266
+ /** The most recent device sighting, for a tester whose sessions have all rotated away. */
267
+ export function lastDeviceSeen(activity: TesterActivity): Date | null {
268
+ let latest: Date | null = null;
269
+ for (const device of activity.devices) {
270
+ if (!latest || device.lastSeenAt > latest) latest = device.lastSeenAt;
271
+ }
272
+ return latest;
273
+ }
@@ -0,0 +1,56 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The audit actions this capability emits, through the core `emit()` seam.
6
+ *
7
+ * Every roster change is audited, because a roster is a list of real people's email addresses that a
8
+ * management credential can add to, remove from, and mail. "Who took Ada off the cohort on the twelfth,
9
+ * and what did the dashboard send to the other eleven that afternoon" is a question with an answer, and
10
+ * these are what make it one.
11
+ *
12
+ * Emitted with `c.var.emit`, never by importing `@pithy-sh/audit` — the seam is always present
13
+ * (`noopEmit` when no audit capability is composed), so there is no null check and no hard dependency.
14
+ * Identifiers and outcomes only in metadata: never a token, never the body of a nudge.
15
+ */
16
+ export const TestersAuditActions = {
17
+ /** A cohort was created. The start of a roster's life, and the frozen copy of its rules. */
18
+ /** An address was added to a roster. A real person's email address, added by whoever held the credential. */
19
+ memberInvited: "testers/member_invited",
20
+ /** Another invitation went to someone already on the roster. */
21
+ memberReinvited: "testers/member_reinvited",
22
+ /**
23
+ * A tester answered the invitation and agreed to test.
24
+ *
25
+ * Audited separately from the opt-in because it is the developer's cue to put a real person's address
26
+ * on a store tester list — a list Google can see. When that address got there, and on whose say-so, is
27
+ * a question worth being able to answer.
28
+ */
29
+ memberAccepted: "testers/member_accepted",
30
+ /**
31
+ * A tester followed the confirmation link.
32
+ *
33
+ * Audited because it is the single event the entire opt-in count rests on. If the count is ever
34
+ * disputed, this is the trail that says when each confirmation arrived and from where.
35
+ */
36
+ memberOptedIn: "testers/member_opted_in",
37
+ /** A tester followed the opt-out link. Their own act, recorded as theirs. */
38
+ memberLapsed: "testers/member_lapsed",
39
+ /** A tester was taken off a roster by the developer. */
40
+ memberRemoved: "testers/member_removed",
41
+ /**
42
+ * A nudge was enqueued.
43
+ *
44
+ * Audited with the copy's provenance rather than its text: knowing that a message went out over the
45
+ * adopter's own sending domain, to whom, and whether the words were Pithy's or a caller's, is what
46
+ * makes a leaked dashboard credential investigable after the fact.
47
+ */
48
+ nudgeSent: "testers/nudge_sent",
49
+ /** A nudge was refused because every selected tester was inside the cooldown. */
50
+ nudgeThrottled: "testers/nudge_throttled",
51
+ /** A caller supplied nudge copy on a deployment that pins the defaults. */
52
+ copyRejected: "testers/copy_rejected",
53
+ } as const;
54
+
55
+ /** One of the testers capability's audit actions. */
56
+ export type TestersAuditAction = (typeof TestersAuditActions)[keyof typeof TestersAuditActions];