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
2 changes: 1 addition & 1 deletion docs/runtime/DASHBOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ off ([Why does my dashboard show no data?](#why-does-my-dashboard-show-no-data))

Run `npx @genclass/runtime@beta init` while not signed in. After you approve its diff it creates both the token and
the link, writes the token into what it adds, prints the link and saves it in `.genclass.local`
([INSTALL.md](INSTALL.md#how-does-init-create-a-token-and-a-dashboard)). Or click "Get a token" at
([INSTALL.md](INSTALL.md#how-does-init-create-a-token-and-a-dashboard)). Or click "Add to my app" at
[genclass.dev/start](https://genclass.dev/start). A link can be added to an account later (`connect`, or "Add an
existing dashboard" on your account page).

Expand Down
6 changes: 6 additions & 0 deletions packages/runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- `init` prints much less after it applies: the telemetry note is two lines (still what is sent, how to turn it off and
the link), the next steps name the overlay first, and the optional upgrades are one line pointing at `init --dry-run`,
which still lists them.

## 0.3.0-beta.5 (2026-11-02)

Security fixes from the adversarial review of the mode-raise fix (`0.3.0-beta.2`); the same four fixes are in `0.2.3`
Expand Down
22 changes: 8 additions & 14 deletions packages/runtime/bin/lib/init.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ export const SCAN_CMD = "npx @genclass/runtime@beta scan <url>";
function printLittleToWatch(kind) {
if (!kind) return;
const why = kind === "none" ? "No fetch, XHR, WebSocket or EventSource call was found in your sources" : "Your page has one script";
out(` ${c.bold("Note")} ${why}, so GenClass will have little to find here beyond request health (runtime.summary().requests); the measured visual checks (visual: true) and the page scan (${c.cyan(SCAN_CMD)}) apply to a mostly static site.`);
out(` ${c.bold("Note")} ${why}, so GenClass will have little to find here. For a mostly static site, try ${c.cyan(SCAN_CMD)}.`);
out();
}

Expand Down Expand Up @@ -395,13 +395,9 @@ function telemetryNotice(cfg, where) {
return;
}
row("Telemetry", `${c.bold("on")}${cfg.telemetry ? c.gray(" (--telemetry)") : c.gray(" (the default)")}`);
out(` GenClass sends anonymous diagnostics to its maintainers: its decisions, action outcomes,`);
out(` model status and counts. No text from the page (the situation text the model read is sent`);
out(` only with telemetry: { include: { situation: true } }); never cookies or IP addresses;`);
out(` never from localhost or private hosts. Turn it off: ${c.cyan(`${CMD} init --no-telemetry`)}`);
out(` (or GenClass.init({ telemetry: false }), ?genclass=no-telemetry in the URL).`);
out(` What is sent: ${TELEMETRY_URL}`);
out(` Privacy policy: ${PRIVACY_URL}`);
out(` Anonymous counts and decisions go to GenClass's maintainers: no page text, cookies or IP`);
out(` addresses, never from localhost. Off: ${c.cyan(`${CMD} init --no-telemetry`)}`);
out(` What is sent: ${TELEMETRY_URL} · Privacy: ${PRIVACY_URL}`);
}

/** Where to put the self-hosted model: the folder the framework serves as-is. */
Expand Down Expand Up @@ -984,16 +980,14 @@ export async function init(o, ctx = {}) {
const script = project.pkg?.scripts?.dev ? "dev" : project.pkg?.scripts?.start ? "start" : null;
if (project.framework === "html") out(` ${sym.dot} Open your page. ${o.sync ? "GenClass starts before your other scripts." : "GenClass starts once the page is parsed, before your deferred and module scripts (--sync: before every script)."}`);
else if (script) out(` ${sym.dot} Run ${c.cyan(runCmd(project.pm, script))} and open the app.`);
out(` ${sym.dot} Detections and actions appear in the browser console as ${c.cyan("[GenClass] ...")} lines.`);
if (devtools && changes.some((ch) => /devtools/.test(ch.after))) out(` ${sym.dot} An overlay at the bottom right of dev pages shows what GenClass sees (${c.bold("Alt+Shift+G")}).`);
out(` ${sym.dot} Findings also appear in the browser console as ${c.cyan("[GenClass] ...")} lines.`);
if (cfg.sinks?.length) out(` ${sym.dot} They also reach ${c.cyan(cfg.sinks.join(", "))}: ${cfg.sinks.map((s) => SINK_WHAT[sinkBase(s)] ?? s).join("; ")}.`);
if (devtools && changes.some((ch) => /devtools/.test(ch.after))) out(` ${sym.dot} In development an overlay shows what GenClass sees (bottom-right, ${c.bold("Alt+Shift+G")}).`);
out(` ${sym.dot} Rule it out while debugging: add ${c.cyan("?genclass=off")} to the URL.`);

out();
printRecommendations(recs);
printMoneyAdvice(money, cfgAt);
printLittleToWatch(quiet);
out(` Undo: ${c.cyan(`${CMD} remove`)}`);
if (recs.length) out(` ${c.gray(`${recs.length} optional upgrade${recs.length === 1 ? "" : "s"}:`)} ${c.cyan(`${CMD} init --dry-run`)}`);
out(` Off for one page: ${c.cyan("?genclass=off")}. Undo: ${c.cyan(`${CMD} remove`)}`);
out();
return 0;
}
Expand Down
17 changes: 13 additions & 4 deletions telemetry-worker/src/account-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ ${optional()}
export function signupPage(nonce: string, o: AuthPageOptions): string {
const inner = `
<h1>Create your GenClass account</h1>
<p class="lead">Your account keeps your apps' dashboards in one place, and lets <code>npx ${RUNTIME_PKG} login</code> connect an app for you.</p>
<p class="lead">Keep every app's dashboard in one place. Free, no card.</p>
<div class="ways">
${signInWays({ ...o, signup: true })}
<p class="hint">Want extra security? Add a passkey on your account page once you are in. Use a passkey signs in to an account that already has one.</p>
Expand Down Expand Up @@ -557,8 +557,8 @@ export interface AccountView {
notice?: { kind: "ok" | "bad"; text: string };
/** Dashboards: open (every plan today), or locked, with the plans to see (a plan file without the dashboard). */
dashboards?: "open" | "plans";
/** From the query: the locked dashboard's sign-in (?claim=1). */
pick?: { claim: boolean };
/** From the query: the locked dashboard's sign-in (?claim=1), and Save it to a free account on /start/<secret> (?save=1). */
pick?: { claim: boolean; save?: boolean };
/** Where to write when every passkey is lost. */
recovery?: string;
/** The plan's name (the organization's badge in the header); default Free. */
Expand Down Expand Up @@ -818,7 +818,7 @@ ${planBlock}
hash of your password if you set one (never the password), your passkeys' public keys, your Google account id and your GitHub id and login if
you link them, your sign-in sessions and the browsers where your password was right, and the projects you own. Nothing else. <a href="/account/billing">Plan and billing</a> · <a href="https://genclass.dev/privacy">Privacy</a> · <a href="https://genclass.dev/terms">Terms</a></footer>
</div>
<script type="application/json" id="acfg">${embed({ claim: !!v.pick?.claim })}</script>`;
<script type="application/json" id="acfg">${embed({ claim: !!v.pick?.claim, save: !!v.pick?.save })}</script>`;

const script = `
(function(){
Expand Down Expand Up @@ -912,6 +912,15 @@ you link them, your sign-in sessions and the browsers where your password was ri
var cfg = JSON.parse(document.getElementById("acfg").textContent);
// back from signing in on a locked dashboard link: return to it (its link was kept in this tab only), where the app
// can be added to this account; otherwise the link field below is ready for it
// back from "Save it to a free account" on /start/<secret>: add that app (its link was kept in this tab only) and open it
if (cfg.save) {
var sv = null;
try { sv = sessionStorage.getItem("gc.save"); sessionStorage.removeItem("gc.save"); } catch (e) {}
if (sv && /^[A-Za-z0-9]{32}$/.test(sv)) {
gcPost("/api/account/projects/claim", { dashboard_url: sv }).then(function(j){ location.replace("/account/apps/" + j.project.id + "#connect"); }).catch(function(){ var l = document.getElementById("link"); if (l) { l.value = location.origin + "/dashboard/" + sv; l.focus(); } });
return;
}
}
if (cfg.claim) {
var s = null;
try { s = sessionStorage.getItem("gc.claim"); sessionStorage.removeItem("gc.claim"); } catch (e) {}
Expand Down
23 changes: 20 additions & 3 deletions telemetry-worker/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import {
import { appExtrasData, appExtrasHtml } from "./billing-pages.js";
import { forgetExports } from "./export.js";
import { json, mediaType, readCapped } from "./http.js";
import { dashboardPage, ownEndpoint, securityHeaders } from "./pages.js";
import { dashboardPage, ownEndpoint, securityHeaders, setupCommand } from "./pages.js";
import { DEVICE_FAILURES, DEVICE_MS, DUMMY_HASH, FAILURE_WINDOW_MS, FREE_FAILURES, MAX_DELAY_MS, PASSWORD_MAX, delayAfter, hashPassword, needsRehash, passwordProblem, verifyPassword, waitText } from "./password.js";
import { accountAccess, accountBilling, appLimit, appLimitBody, hasDashboard, pickRange, viewRanges } from "./plans.js";
import { cancelForDeletion } from "./stripe.js";
Expand Down Expand Up @@ -1742,7 +1742,7 @@ async function accountView(c: C, a: Auth): Promise<AccountView> {
// dashboards: open (every plan today), or locked with the plans to see
dashboards: hasDashboard(access) ? "open" : "plans",
plan: access.plan.name,
pick: { claim: c.url.searchParams.get("claim") === "1" },
pick: { claim: c.url.searchParams.get("claim") === "1", save: c.url.searchParams.get("save") === "1" },
recovery: RECOVERY_CONTACT,
};
}
Expand All @@ -1756,7 +1756,7 @@ export function accountContext(request: Request, url: URL, env: Env, opts: Handl
}

const PREFIXES = ["/auth/", "/api/auth/", "/api/device/", "/api/account/", "/account/"];
const EXACT = new Set(["/signup", "/login", "/login/device", "/login/passkey", "/login/forgot", "/logout", "/account", "/api/me", "/api/account"]);
const EXACT = new Set(["/start", "/signup", "/login", "/login/device", "/login/passkey", "/login/forgot", "/logout", "/account", "/api/me", "/api/account"]);

/** Account routes. Returns null for paths this module does not own (the caller continues routing). */
export async function routeAuth(request: Request, url: URL, path: string, env: Env, opts: HandleOptions): Promise<Response | null> {
Expand All @@ -1772,6 +1772,23 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E
const method = (): Response => fail(405, "method not allowed");

switch (path) {
case "/start": {
// the one click, signed in: the new app goes in the account (not signed in, or any GET: routeSite answers)
if (!post) return null;
const a = await webSession(c);
if (!a) return null;
const json201 = (request.headers.get("accept") ?? "").includes("application/json");
const elsewhere = (): Response => (json201 ? json(200, { url: "/account/new", copiedUrl: "/account/new", token: null, command: null }) : redirect("/account/new", [], 303));
if (!originOk(c) || (await overAppLimit(c, a.account.id))) return elsewhere();
if (c.env.CREATE_LIMITER) {
const { success } = await c.env.CREATE_LIMITER.limit({ key: (await sha256Hex(`create:account:${a.account.id}`)).slice(0, 32) });
if (!success) return elsewhere();
}
if ((await projectsCreatedSince(c.db, new Date(c.now.getTime() - MINUTE), "account")) >= MAX_ACCOUNT_PROJECTS_PER_MINUTE) return elsewhere();
const p = await createProject(c.db, sanitizeName(""), c.now, a.account.id);
const to = `/account/apps/${p.id}#connect`;
return json201 ? json(201, { url: to, copiedUrl: to, token: p.token, command: setupCommand(p.token) }) : redirect(to, [], 303);
}
case "/signup":
case "/login": {
if (!get) return method();
Expand Down
78 changes: 65 additions & 13 deletions telemetry-worker/src/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// OPTIONS /v1/events (CORS preflight), GET /v1/health.
//
// Per-app tokens and dashboards (src/dashboard.ts, src/pages.ts; also routed on genclass.dev/{api/*,start*,dashboard*}):
// POST /api/projects, GET /api/projects/<secret>, GET /start, GET /start/open-source (no account, no token), GET
// POST /api/projects, GET /api/projects/<secret>, POST and GET /start, GET /start/<secret>, GET /start/open-source (no account, no token), GET
// /dashboard/<secret>. A batch with a known top-level `token` is also aggregated into D1 for that app's dashboard;
// without one it is stored in R2 only, exactly as before.
// Every plan includes the dashboard (billing/plans.json), so a private link opens it, also for an app with no account
Expand Down Expand Up @@ -43,7 +43,7 @@ import {
import { routeBilling } from "./billing.js";
import { json, readCapped } from "./http.js";
import { afterBatch } from "./paid.js";
import { dashboardPage, notFoundPage, openSourcePage, ownEndpoint, securityHeaders, startPage } from "./pages.js";
import { dashboardPage, notFoundPage, openSourcePage, ownEndpoint, securityHeaders, setupCommand, setupPage, startPage } from "./pages.js";
import { lockedLink } from "./locked.js";
import { hasDashboard, projectAccess } from "./plans.js";
import { SAMPLE_PROJECT, SAMPLE_SECRET, sampleStats } from "./sample.js";
Expand Down Expand Up @@ -327,6 +327,42 @@ function html(status: number, body: string, nonce: string): Response {
return new Response(body, { status, headers: securityHeaders(nonce) });
}

/** Why a new anonymous app must wait, or null: the per-client limiter (a hash of the IP, never stored), then a global ceiling. */
async function creationLimited(request: Request, db: D1Like, env: Env, now: Date): Promise<string | null> {
const busy = "too many projects created; try again in a minute";
if (env.CREATE_LIMITER) {
const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
const { success } = await env.CREATE_LIMITER.limit({ key: (await sha256Hex(`create:${ip}`)).slice(0, 32) });
if (!success) return busy;
}
// Backstop for the (per-location, eventually consistent) limiter: a global ceiling on new projects per minute.
return (await projectsCreatedSince(db, new Date(now.getTime() - 60_000), "anonymous")) >= MAX_PROJECTS_PER_MINUTE ? busy : null;
}

function accountHtml(status: number, body: string, nonce: string): Response {
return new Response(body, { status, headers: securityHeaders(nonce, {}, { account: true }) });
}

/**
* POST /start, the one click, for someone not signed in (routeAuth takes the signed-in case first). A form post is sent
* on to the app's setup page; the page's script asks for JSON, so it can copy the command while the click still counts.
*/
async function startApp(request: Request, env: Env, now: Date): Promise<Response> {
const wantsJson = (request.headers.get("accept") ?? "").includes("application/json");
const refuse = (status: number, error: string, headers: Record<string, string> = {}): Response => {
if (wantsJson) return json(status, { ok: false, error }, headers);
const nonce = randomBase62(22);
return new Response(startPage(nonce, { error }), { status, headers: { ...securityHeaders(nonce, {}, { account: true }), ...headers } });
};
if (!env.DB) return refuse(503, "dashboards unavailable");
const slow = await creationLimited(request, env.DB, env, now);
if (slow) return refuse(429, slow, { "retry-after": "60" });
const p = await createProject(env.DB, sanitizeName(""), now);
const to = `/start/${p.secret}`;
if (wantsJson) return json(201, { url: to, copiedUrl: `${to}?copied=1`, token: p.token, command: setupCommand(p.token) });
return new Response(null, { status: 303, headers: { location: to, "cache-control": "no-store" } });
}

async function routeSite(request: Request, url: URL, path: string, env: Env, opts: HandleOptions): Promise<Response | null> {
const m = request.method;
const now = (opts.now ?? (() => new Date()))();
Expand All @@ -335,16 +371,8 @@ async function routeSite(request: Request, url: URL, path: string, env: Env, opt
if (m === "OPTIONS") return new Response(null, { status: 204, headers: PROJECT_CORS });
if (m !== "POST") return json(405, { ok: false, error: "method not allowed" }, PROJECT_CORS);
if (!env.DB) return json(503, { ok: false, error: "dashboards unavailable" }, PROJECT_CORS);
if (env.CREATE_LIMITER) {
// The limiter key is a hash of the client IP; the IP itself is never stored or logged.
const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
const { success } = await env.CREATE_LIMITER.limit({ key: (await sha256Hex(`create:${ip}`)).slice(0, 32) });
if (!success) return json(429, { ok: false, error: "too many projects created; try again in a minute" }, { ...PROJECT_CORS, "retry-after": "60" });
}
// Backstop for the (per-location, eventually consistent) limiter: a global ceiling on new projects per minute.
if ((await projectsCreatedSince(env.DB, new Date(now.getTime() - 60_000), "anonymous")) >= MAX_PROJECTS_PER_MINUTE) {
return json(429, { ok: false, error: "too many projects created; try again in a minute" }, { ...PROJECT_CORS, "retry-after": "60" });
}
const slow = await creationLimited(request, env.DB, env, now);
if (slow) return json(429, { ok: false, error: slow }, { ...PROJECT_CORS, "retry-after": "60" });
const raw = await readCapped(request, 4096);
if (raw === null) return json(413, { ok: false, error: "body too large" }, PROJECT_CORS);
let body: unknown = {};
Expand Down Expand Up @@ -395,6 +423,15 @@ async function routeSite(request: Request, url: URL, path: string, env: Env, opt
return putFunnels(request, env.DB, p.token);
}

// whether the first page load has arrived (/start/<secret> asks every few seconds): one row, not the whole stats
const statusApi = /^\/api\/projects\/([^/]+)\/status$/.exec(path);
if (statusApi) {
if (m !== "GET" && m !== "HEAD") return json(405, { ok: false, error: "method not allowed" });
const p = env.DB && statusApi[1] !== SAMPLE_SECRET ? await projectBySecret(env.DB, statusApi[1]!) : null;
if (!p) return json(404, { ok: false, error: "not found" });
return json(200, { lastEvent: p.last_event ?? null, firstEvent: p.first_event ?? null });
}

const api = /^\/api\/projects\/([^/]+)$/.exec(path);
if (api) {
if (m !== "GET" && m !== "HEAD") return json(405, { ok: false, error: "method not allowed" });
Expand All @@ -408,9 +445,24 @@ async function routeSite(request: Request, url: URL, path: string, env: Env, opt
}

if (path === "/start") {
if (m === "POST") return startApp(request, env, now);
if (m !== "GET" && m !== "HEAD") return null;
const nonce = randomBase62(22);
return accountHtml(200, startPage(nonce), nonce);
}

// what to do after the one click: the command, the agent links and whether the first page load has arrived
const setup = /^\/start\/([A-Za-z0-9]{32})$/.exec(path);
if (setup) {
if (m !== "GET" && m !== "HEAD") return null;
const p = env.DB ? await projectBySecret(env.DB, setup[1]!) : null;
const nonce = randomBase62(22);
return html(200, startPage(nonce, { runtimeReady: runtimeHasTokens(env), endpoint: ownEndpoint(env) }), nonce);
if (!p) return html(404, notFoundPage(nonce), nonce);
return accountHtml(
200,
setupPage(nonce, { token: p.token, secret: setup[1]!, lastEvent: p.last_event ?? null, runtimeReady: runtimeHasTokens(env), endpoint: ownEndpoint(env), copied: url.searchParams.get("copied") === "1" }),
nonce,
);
}

// the open-source parts with no account and no token (pages.ts openSourcePage): static, nothing stored
Expand Down
Loading
Loading