Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ Azure, `git push`, `npm publish`.
code by itself. Sending the model more than the digest holds, storing a question or an answer, or letting a
terminal do something the approval page does not list needs the owner's OK and a change to PRIVACY.md.

13. **A report by email says what the account's dashboards already show, to that account only**
(`telemetry-worker/src/report*.ts`, migration 0020, PRIVACY.md "Reports and alerts by email"; owner request,
2026-11-13). Every number, page and item comes from an app's stats under its own plan (stripProblems and
stripAnalytics apply as on the dashboard), the GitHub App's stored reviews, the account's own sign-ins and its last
suggest answer; nothing new is collected for it and no IP address is stored or shown. Priorities are rules in
`appItems`; the model only writes the summary, from `appDigest` and the listed items, never an email, account id,
app key or the key. It goes only to the account's email, with one-click unsubscribe, at most 30 a day, an alert at
most once a day per app and rule. Sending more, to anyone else, or collecting anything for it needs the owner's OK
and a change to PRIVACY.md, like rule 8.

## Conventions and definition of done

- Modules open with a `//` header stating role and invariants; JSDoc on exports; named exports; relative imports
Expand Down
26 changes: 24 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# GenClass Runtime: privacy policy

_Last updated: 2026-11-12._
_Last updated: 2026-11-13._

This policy covers the diagnostics that `@genclass/runtime` (from `0.1.0-beta.3`) sends to its maintainers. It does not
cover the website or app that uses GenClass; that site has its own privacy policy. It also covers the
Expand Down Expand Up @@ -103,7 +103,8 @@ creates one (at genclass.dev/signup, or with `npx @genclass/runtime@beta login`,
- **An email address:** as Google returned it when you sign in with Google, or as you typed it (trimmed and
lowercased) when you sign up with an email and a password or add a password to your account, together with whether
it is verified (only Google can mark it verified; we do not verify a typed address). A typed address is also your
username. We use it only for notices about your account. A GitHub sign-in stores no email.
username. We use it for notices about your account and for the reports about your apps described below (which
you can change or stop). A GitHub sign-in stores no email.
- **Your password, if you set one,** only as a salted one-way hash (PBKDF2-HMAC-SHA256, 600,000 iterations; the
password itself is never stored or logged), with when it was set and last used, and how many wrong passwords were
entered for the account in a row and when (to slow down guessing).
Expand Down Expand Up @@ -283,6 +284,27 @@ show the same answer again and a paid plan can show all of it; it is replaced by
account. We also keep how many plans an account asked for on a day, for 3 days, to hold each plan's daily limit.
OpenAI processes the digest under its API terms. Nothing from a digest is used to train GenClass's own model.

## Reports and alerts by email

An account with an email address gets a GenClass report about its own apps, by default once a day at 13:00 UTC, and
an alert when GenClass sees something critical on one of them (an endpoint that keeps failing, a page that stopped
working for visitors, an app that stopped reporting, problems that jumped within the hour). On the account's Email
reports page (genclass.dev/account/report), or from the links at the foot of every report, you can get it every hour
(only when something happened), every day, week or month, turn the alerts off, or stop both. Stopping takes one click
and needs no sign-in; most mail apps also show it as Unsubscribe.

A report holds what your apps' dashboards already show you under each app's plan (the counts, the problems by kind and
page, the measured checks, visitor numbers when the plan has them), the GitHub App's reviews of your linked
repositories, new sign-ins, terminals and passkeys on your account, and your last `genclass suggest` answer. Nothing
new is collected for it, and visitors' IP addresses never are: the section on unusual activity reads patterns in the
counts, not addresses. For an app on a paid plan, the numbers its dashboard shows and the report's own list of
problems (titles, counts and page names, never your email, your account id, an app's key or anything a visitor typed)
are sent to OpenAI's API, with the same key as `genclass ask`, so a model can write the short summary at the top; it
is not stored or logged. The report is sent only to your account's email, through Cloudflare Email Sending, from
report@genclass.dev. We keep, per account, how often you chose and when the last report went out, how many emails it
was sent on a day (for 3 days, to cap them), and when each alert last went out per app (7 days), so an alert is sent
once a day at most.

## Where it goes and how long we keep it

Data is sent to our collector on Cloudflare and stored in Cloudflare R2. It is deleted automatically after
Expand Down
64 changes: 64 additions & 0 deletions telemetry-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,70 @@ One new table, `suggest_usage`. Additive; apply it before or after the deploy:
`npx --yes wrangler@4 d1 migrations apply genclass-dashboard --remote`. Then set the key, as a secret, from your own
terminal: `npx --yes wrangler@4 secret put OPENAI_API_KEY`.

## Reports by email

A GenClass report about an account's apps goes to the account's email on the schedule it chooses, and an alert goes
out as soon as the hourly run sees a critical (P0) problem (the owner's request, 2026-11-13). `src/report.ts` builds
it (data, priority rules, the model's summary, signed links, sending), `src/report-email.ts` renders it (subject, HTML,
text), `src/report-sweep.ts` is the hourly run, `src/report-routes.ts` the pages and the settings API.

- **Schedule.** `accounts.report_every`: `hourly`, `daily` (the default, NULL), `weekly` (Mondays), `monthly` (the
1st) or `off`; daily, weekly and monthly go at 13:00 UTC. The cron `7 * * * *` runs `reportSweep`: an account with
no `report_next` gets its next slot and nothing is sent that run; a due one is claimed (report_next moved on in one
statement) and sent. An hourly report is sent only when something critical or important is listed, or the last
hour had a problem. Period: hourly and daily the last 24 hours, weekly 7 days, monthly 30, cut to what the app's
plan shows (Free: 7 days).
- **What is in it, in order.** The model's summary (Pro apps, src/suggest.ts modelText; an hourly report asks at most
every 6 hours), critical and important items with a prompt for a coding agent each, the apps' numbers against the
period before, the pull requests the GitHub App reviewed on linked repositories, visitors (Pro), security and
unusual activity (new terminals, passkeys and password changes on the account; request storms, odd behaviour and
traffic that tripled; no IP addresses, none are stored), setup (no data yet, no data for 3 days, the model failing
to load, an app past Free's apps, GitHub not connected), worth a look, the last `genclass suggest` answer, and
"Upgrade your website design" (measured contrast and layout checks, the suggest answer's designs with their prompts,
and three sites to learn from that day, one of them 3D, `SHOWCASE`). An app on Free gets its totals, its setup items
and the P0 rules on its hours (silent, spike), never its problems' kinds, pages or prompts, and the email has Pro's
box with the trial.
- **Priorities** are rules in `appItems`, tested in `test/report.test.ts`: P0 a failing endpoint 3 times, a duplicate,
race or failure on a payment page (checkout, pay, billing, cart, order, subscribe, invoice), a blank page, a page that
stopped taking clicks or scrolling (3 times), an app silent for 6 hours after 36 page loads in the 18 before,
problems in the last two hours at least 10 and 4 times the usual; P1 the app's own bugs (duplicate, stale,
conflict, broken state), a failing endpoint, visible checks (invisible text, controls out of reach, covered
dialogs, stalled requests, broken images, template text), rage clicks, setup items; P2 the rest. The model never
sets a priority or adds an item.
- **Alerts.** After the reports, the run reads the 24-hour stats of up to 60 apps that sent something in the last 30
hours and emails each account its P0 items that no email told it about in the last day (`report_alerts`, per app
and rule). An account whose report went out in that run gets no alert; a report marks its P0 items as told.
- **Limits.** At most 30 emails an account a day (`report_sends`), 5 test reports a day ("Send me one now"), 25
reports a run, 4 built at once.
- **Links.** The email's links to change how often, to unsubscribe (`List-Unsubscribe` with one-click
`List-Unsubscribe-Post`) and to open a prompt (`/account/report/prompt`, Copy, Open in Claude Code or Codex) carry
an HMAC from a key derived from `INTEGRATIONS_KEY`. Without that secret there are no such links: the email says to
change it on the account page and the prompts are only in the email's text.
- **Sending.** The `[[send_email]]` binding `EMAIL`, allowed to send only as report@genclass.dev (genclass.dev is
onboarded: `npx --yes wrangler@4 email sending list`); replies go to mehar@genclass.dev. `wrangler dev` simulates
it. A self-hosted collector has no such binding and sends nothing.

| route | who | what |
|---|---|---|
| `GET /account/report` | browser session | how often, alerts, Send me one now, Preview |
| `GET /account/report/preview` | browser session | the email as HTML (nothing sent, no summary) |
| `GET`, `POST /api/account/report` | browser session (+ Origin) | `{ every, alerts }` |
| `POST /api/account/report/test` | browser session + Origin | sends this account's report now |
| `GET /account/report/email?a=&t=[&every=]` | the email's token | the same choice and Save; a GET changes nothing |
| `POST /api/report/settings` | the email's token | `{ a, t, every, alerts }`, a form or JSON |
| `POST /api/report/unsubscribe?a=&t=` | the email's token | one click: reports and alerts off (a GET opens the page) |
| `GET /account/report/prompt?q=&s=` | the prompt's signature | the prompt, Copy, Open in Claude Code or Codex |

A terminal's session is refused on all of them.

### Migration 0020 (`migrations/0020_reports.sql`)

Five columns on `accounts` (`report_every`, `report_next`, `report_sent`, `report_alerts` default 1, `report_model`)
and two tables (`report_alerts`, `report_sends`). Additive. Apply it before the deploy:
`CLOUDFLARE_ACCOUNT_ID=144bd5f5270b51dbe7faf46227a154f0 npx --yes wrangler@4 d1 migrations apply genclass-dashboard --remote`.
Until it is applied the routes answer "reports are unavailable" and the hourly run sends nothing. The first run after
the deploy gives every account with an email its next 13:00 UTC; the first reports go out then.

## Self-host the collector (private mode)

Telemetry sent to genclass.dev passes through GenClass: the maintainers can read every batch (README of the runtime,
Expand Down
31 changes: 31 additions & 0 deletions telemetry-worker/migrations/0020_reports.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- GenClass reports by email (src/report.ts; the owner's request, 2026-11-13): a report about an account's apps, sent
-- to the account's email on a schedule it chooses, and alerts for critical (P0) problems as soon as the hourly run
-- sees them.
--
-- accounts.report_every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'off'; NULL is the default, daily.
-- accounts.report_next: ISO time the next scheduled report is due; NULL until the first hourly run gives it one.
-- accounts.report_sent: ISO time the last report or alert went out.
-- accounts.report_alerts: 1 sends P0 alerts between reports; 0 does not. 'off' stops both.
-- accounts.report_model: ISO time the model last wrote a report's summary (an hourly report asks it every 6 hours).
ALTER TABLE accounts ADD COLUMN report_every TEXT;
ALTER TABLE accounts ADD COLUMN report_next TEXT;
ALTER TABLE accounts ADD COLUMN report_sent TEXT;
ALTER TABLE accounts ADD COLUMN report_alerts INTEGER NOT NULL DEFAULT 1;
ALTER TABLE accounts ADD COLUMN report_model TEXT;
CREATE INDEX IF NOT EXISTS accounts_report_next ON accounts (report_next);

-- One row per app and P0 rule: when its alert last went out, so the same alert is sent at most once a day.
CREATE TABLE IF NOT EXISTS report_alerts (
project_id TEXT NOT NULL,
rule TEXT NOT NULL,
sent TEXT NOT NULL,
PRIMARY KEY (project_id, rule)
) WITHOUT ROWID;

-- Emails sent per account and UTC day (reports, alerts and tests together): at most REPORT_DAILY_CAP. Kept 3 days.
CREATE TABLE IF NOT EXISTS report_sends (
account_id TEXT NOT NULL,
day TEXT NOT NULL,
n INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, day)
) WITHOUT ROWID;
1 change: 1 addition & 0 deletions telemetry-worker/src/account-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@ const SETTINGS_MENU: ProductMenu = {
groups: [
{ label: "Security", items: [{ href: "#security", label: "Ways in", icon: "shield" }, { href: "#password", label: "Password", icon: "key" }, { href: "#passkeys", label: "Passkeys", icon: "key" }] },
{ label: "Access", items: [{ href: "#sessions", label: "Sessions", icon: "monitor" }, { href: "#cli", label: "Terminals", icon: "terminal" }] },
{ label: "Notifications", items: [{ href: "/account/report", label: "Email reports", icon: "message" }] },
{ label: "Danger zone", items: [{ href: "#delete", label: "Delete account", icon: "x" }] },
],
};
Expand Down
8 changes: 8 additions & 0 deletions telemetry-worker/src/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import {
} from "./dashboard.js";
import { routeBilling } from "./billing.js";
import { routeTeam } from "./team-routes.js";
import { routeReport } from "./report-routes.js";
import type { EmailBinding } from "./report.js";
import type { GithubEnv } from "./github/config.js";
import { routeGithub } from "./github/http.js";
import { routeCli } from "./cli.js";
Expand Down Expand Up @@ -133,6 +135,9 @@ export interface Env extends GithubEnv {
EXPORT_LIMIT?: RateLimiterLike;
/** 32 bytes, base64: encrypts forwarding settings at rest (src/forward.ts). Without it they cannot be saved. */
INTEGRATIONS_KEY?: string;
/** Cloudflare Email Sending (`[[send_email]]` in wrangler.toml): GenClass reports and alerts (src/report.ts). Without
* it nothing is emailed and the report settings page says so. */
EMAIL?: EmailBinding;
/** Optional https URL that gets short JSON notices: a Custom purchase, a duplicate subscription, a plan switch
* that needs a refund by hand, a billing record holding something that is not GenClass's. */
NOTIFY_WEBHOOK?: string;
Expand Down Expand Up @@ -247,6 +252,9 @@ export async function handle(request: Request, env: Env, opts: HandleOptions = {
// teams (src/team-routes.ts): /api/team and the invite page /account/join/<code>, before the account routes
const team = await routeTeam(request, url, path, env, opts);
if (team) return team;
// email reports (src/report-routes.ts): /account/report*, /api/account/report*, /api/report/*, before the account routes
const report = await routeReport(request, url, path, env, opts);
if (report) return report;
const account = await routeAuth(request, url, path, env, opts);
if (account) return account;
const site = await routeSite(request, url, path, env, opts);
Expand Down
13 changes: 11 additions & 2 deletions telemetry-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { githubSweep, SWEEP_CRON } from "./github/handlers.js";
import { githubRetention } from "./github/store.js";
import { suggestRetention } from "./suggest.js";
import { planRetention, stripeMode } from "./plans.js";
import { REPORT_CRON } from "./report.js";
import { reportRetention, reportSweep } from "./report-sweep.js";

interface Ctx {
waitUntil(p: Promise<unknown>): void;
Expand All @@ -22,7 +24,9 @@ export default {
);
},
/**
* Two cron triggers (wrangler.toml `crons`), told apart by the trigger's own expression (`event.cron`).
* Three cron triggers (wrangler.toml `crons`), told apart by the trigger's own expression (`event.cron`).
*
* At 7 minutes past every hour (REPORT_CRON): GenClass reports that are due and P0 alerts (src/report-sweep.ts).
*
* Every 20 minutes (SWEEP_CRON): the GitHub App's owed reviews and nothing else (src/github/handlers.ts
* githubSweep), which is what finishes a check that an unfinished job left in progress.
Expand All @@ -40,8 +44,13 @@ export default {
ctx.waitUntil(githubSweep(env).catch(() => undefined));
return;
}
if (event?.cron === REPORT_CRON) {
// GenClass reports and P0 alerts by email (src/report-sweep.ts); nothing without the EMAIL binding
ctx.waitUntil(reportSweep(env, new Date()).catch(() => undefined));
return;
}
const now = new Date();
// each sweep on its own: the GitHub App's tables may not exist yet on a database whose migration 0013 is not applied
ctx.waitUntil(Promise.all([planRetention(db, now, env.BUCKET, stripeMode(env)), authRetention(db, now).catch(() => undefined), githubRetention(db, now).catch(() => undefined), suggestRetention(db, now).catch(() => undefined), githubSweep(env).catch(() => undefined)]));
ctx.waitUntil(Promise.all([planRetention(db, now, env.BUCKET, stripeMode(env)), authRetention(db, now).catch(() => undefined), githubRetention(db, now).catch(() => undefined), suggestRetention(db, now).catch(() => undefined), reportRetention(db, now).catch(() => undefined), githubSweep(env).catch(() => undefined)]));
},
};
Loading
Loading