diff --git a/.github/audit/canaries/passkey-removal-keeps-sessions.patch b/.github/audit/canaries/passkey-removal-keeps-sessions.patch
index 4f16f9322..5fb95a6e3 100644
--- a/.github/audit/canaries/passkey-removal-keeps-sessions.patch
+++ b/.github/audit/canaries/passkey-removal-keeps-sessions.patch
@@ -20,10 +20,10 @@ index 319cd5c41..b5300f378 100644
-- Down Migration
-ALTER TABLE dormouse_relay_sessions ALTER COLUMN "credentialId" DROP NOT NULL;
diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts
-index a68a75d36..d6f13c53a 100644
+index 0ad1a0fc3..67e0c5ad5 100644
--- a/hosted/server/relay-api.ts
+++ b/hosted/server/relay-api.ts
-@@ -746,17 +746,14 @@ export async function admit(
+@@ -762,17 +762,14 @@ export async function admit(
});
}
@@ -45,10 +45,10 @@ index a68a75d36..d6f13c53a 100644
);
return { sessionToken, expiresAt: expiresAt! };
diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts
-index 09806ad75..cb7005345 100644
+index 8670a8a38..a465c8d79 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
-@@ -32,9 +32,9 @@ const PINNED: Record = {
+@@ -32,7 +32,7 @@ const PINNED: Record = {
"009_relay_passkey_removal.sql":
"c6c220960187e33a1350dc85e93d24f99866a72a579ed9def6a3663643f3e928",
"010_relay_session_credential_required.sql":
@@ -57,9 +57,7 @@ index 09806ad75..cb7005345 100644
"011_enrollment_confirm_digits.sql":
"54afe9ae8a2efd3c654503d479fce96c1ce8ff4f2df249aa93b5a91e925def6e",
"012_relay_burrow_label.sql":
- "45b3df1530402622edeb12e12e1572698840ecc8ea6b9cfc585f3a9e28b4f230",
- };
-@@ -49,7 +49,7 @@ test("no merged Dormouse migration changes, and every one is pinned", () => {
+@@ -53,7 +53,7 @@ test("no merged Dormouse migration changes, and every one is pinned", () => {
}
});
@@ -68,17 +66,17 @@ index 09806ad75..cb7005345 100644
onTestFinished,
}) => {
// The Dormouse migrations through 009, then 010 over the rows they allowed.
-@@ -75,5 +75,4 @@ test("010 deletes the sessions that name no passkey, and every later one must na
+@@ -79,5 +79,4 @@ test("010 deletes the sessions that name no passkey, and every later one must na
copyFileSync(join(source, "010_relay_session_credential_required.sql"), join(before, "010_relay_session_credential_required.sql"));
await migrate(context.database.url, await readMigrations([betterAuthMigrations, before]));
expect(await sql(`SELECT "tokenHash" FROM dormouse_relay_sessions`)).toEqual([{ tokenHash: "named" }]);
- await expect(session("later", null)).rejects.toThrow(/credentialId/);
});
diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts
-index 424ccd2ba..3441c7e86 100644
+index 86d1036ad..c7660b441 100644
--- a/hosted/server/tests/relay.test.ts
+++ b/hosted/server/tests/relay.test.ts
-@@ -456,32 +456,20 @@ test("a passkey names the computer whose code registered it and outlives it; rem
+@@ -472,32 +472,20 @@ test("a passkey names the computer whose code registered it and outlives it; rem
expect(await minter(phone)).toEqual([{ burrowId: null }]);
expect((await f.call("GET", API_ROUTES.burrows, { bearer: setupSession })).status).toBe(200);
diff --git a/README.md b/README.md
index c253fc42a..7ad5a1aef 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ A multitasking terminal for VS Code and the desktop — a real tiling layout, tm
- [Compatible agents](https://dormouse.sh/compatible-agents) — conversation recovery, watching, and contributing an agent integration
- [Agent skill](https://dormouse.sh/agent-skill) — the operating guide Dormouse bundles for coding agents
- [Self-host](https://dormouse.sh/self-host) — run the coordinating Relay on your own tailnet
-- [Hosted](https://dormouse.sh/hosted/) — the Hosted Relay and Natural Voice, from $10 a month
+- [Hosted](https://dormouse.sh/hosted/?ref=readme) — the Hosted Relay and Natural Voice, from $10 a month
- [Security](https://dormouse.sh/security) — what Dormouse guarantees, what it does not, and how that is checked
## Features
diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md
index a6f179f3e..b7fa86780 100644
--- a/docs/specs/hosted.md
+++ b/docs/specs/hosted.md
@@ -19,7 +19,7 @@ Every Worker answers `/api/health` and `/api/ready`, 404s anything else under it
**Must run committed Better Auth migrations before deploying code that needs them, never during a Worker request.** Postgres is reached through an uncached Hyperdrive binding, one per Worker.
-**Must grant `dormouse_relay` and `dormouse_voice` only what `hosted/server/runtime-roles.sql` lists, never default privileges**, so a new table needs a grant there; each reaches only its Worker's tables and the entitlement's user columns, and the relay inserts approved voice tokens ("Managed voice"). **Must bind each production Worker to its own Hyperdrive connecting as its role**: account `dormouse_app`, relay `dormouse_relay`, voice `dormouse_voice` (`PRODUCTION` in `hosted/scripts/production.mjs`).
+**Must grant `dormouse_relay` and `dormouse_voice` only what `hosted/server/runtime-roles.sql` lists, never default privileges**, so a new table needs a grant there; each reaches only its Worker's tables and the entitlement's user columns, the relay inserts approved voice tokens ("Managed voice"), and both add to the daily counts ("Metrics"). **Must bind each production Worker to its own Hyperdrive connecting as its role**: account `dormouse_app`, relay `dormouse_relay`, voice `dormouse_voice` (`PRODUCTION` in `hosted/scripts/production.mjs`).
**Must install released core/auth packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth migrations remain owned by the package; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`. **Never edit a merged migration**: a migrated database never reruns one, so append the next number (pinned by `hosted/server/tests/migrations.test.ts`).
@@ -243,6 +243,25 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that their
Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `takeEnrollment` in `hosted/src/enrollment.ts`.
+## Metrics
+
+Hosted counts what its Workers serve in aggregate daily rows, server-side; no page or desktop sends any.
+
+| Event | Labels | Counted when |
+|---|---|---|
+| `enroll.approved` | `remote`, `voice` | an approval succeeds, under its purpose |
+| `burrow.enrolled` | none | a remote poll enrolls a Burrow |
+| `voice.speak` | `ok`, `capped`, `error` | speak answers 200, 429, or 502 |
+| `voice.fallback-cap` | none | a speak spends its owner's last call of the UTC day |
+| `push.sent` | none | a send delivers, once per delivery |
+| `pocket.signin` | none | a passkey sign-in mints a session |
+
+- **Never count a label off its event's list** (`METRIC_LABELS`): the write throws instead. What a row may hold, and who reads it: `docs/specs/security-hosted.md` -> "Account boundary".
+- **Never fail or delay the request a metric counts**: the write runs after the response, through `waitUntil`, and its failure is only logged.
+- Rows are kept forever, since none describes anyone. The account Worker's `/admin/metrics` shows the last 30 UTC days and all-time totals, "today" by the database's clock, which dates every row.
+
+Source of truth: `hosted/server/metric-labels.ts`; `recordMetric` in `hosted/server/metrics.ts`; `hosted/server/dormouse-migrations/013_metrics.sql`; `AdminMetrics` in `hosted/src/AdminMetrics.tsx`.
+
## Development and release
**Must run local development with `dor tool hosted` inside Dormouse.** Its loopback `http://localhost:` origin serves Vite and Node auth on a disposable development database, with the account's Natural Voice and Relay routes but never the voice origin's. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. **It also runs the relay Worker on that database, with this origin as `ACCOUNT_ORIGIN`** (`docs/specs/one-time.md` -> "Dev loop").
diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md
index 490c64caa..5d08d026a 100644
--- a/docs/specs/pricing.md
+++ b/docs/specs/pricing.md
@@ -11,6 +11,8 @@
**Settings is the front door.** The spoken-alarm row's managed-voice link lands on `/hosted#voice`, and the Pocket playground tutorial on `/hosted#remote-control`. `#pricing` (the plan cards, which each JSON-LD `Offer` url names), `#remote-control`, and `#voice` keep resolving as section ids.
+**Must give every link to the page a `?ref=` from `HOSTED_REFS`, one per link, ahead of its fragment, and strip it from the address bar on arrival**, so a copied link never carries it. Nothing counts or sends it yet; `takeVisitRef` answers it, an unlisted value as `other`, for whatever will. Source of truth: `lib/src/lib/hosted-links.ts`; `takeVisitRef` in `website/src/lib/hosted-ref.ts`.
+
**Prices, inclusions, and the FAQ are prerendered text**, and the page emits `Product` / `Offer` JSON-LD carrying one `Offer` per paid plan at its current price, so an assistant fetching the page can quote it. **Offers stay `PreOrder` while checkout is unbuilt.**
**Every price on the site has one owner**, `website/src/lib/hosted-pricing.ts`; site code reads it rather than restating a number.
diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md
index 46f9b6f65..dfbafc35f 100644
--- a/docs/specs/security-hosted.md
+++ b/docs/specs/security-hosted.md
@@ -12,7 +12,7 @@
| **Each Hosted Worker answers only its own origin and runs only its own code.** No third-party script, analytics, or framing; sign-in cookies stay on the account origin, and its session never reaches browser storage. | One-time link, Hosted phone | `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts` |
- **FAIL IF** `S77` a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421; inspect `workerApp` in `hosted/server/worker-app.ts`.
-- **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale).
+- **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `cookieAdmin` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale).
- **FAIL IF** `S79` the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the account's or relay's passes `ELEVENLABS_API_KEY`, the account's or voice's passes `RELAY_ENROLL_SECRET` or `RELAY_VAPID_PRIVATE_KEY`, or the relay or voice entry imports Better Auth, or `WORKERS` in `hosted/scripts/workers.mjs` provisions either an auth secret; inspect `hosted/server/bindings.ts` and each entry's import graph.
- **FAIL IF** `S80` authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or account (Better Auth) session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`.
- **FAIL IF** `S81` the account origin's policy permits third-party scripts, framing, inline script execution, or any worker (`worker-src 'none'`); a voice response, or a relay response under a `RELAY_NON_PAGE_PREFIXES` prefix (`/api`, `/ws`), carries any policy but `RUNS_NOTHING_POLICY`; any response but a 101 WebSocket upgrade bypasses `secureHeaders`, a misconfigured deployment's error included; or a response is cached past its class: immutable only for a content-hashed file under the account's `/assets/` or the relay's `/assets/` and `/connect/assets/`, `no-cache` only on Pocket's other paths, `no-store` everywhere else, the account's SPA shell included. Inspect `secureHeaders` / `accountRules` / `relayRules` / `relayPathKind` in `hosted/server/headers.ts`, binding resolution in `hosted/server/worker-app.ts`, and asset routing in `hosted/wrangler.jsonc` and `hosted/wrangler.relay.jsonc`.
@@ -24,14 +24,15 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t
| Guarantee | Feature | Pinned by |
| --- | --- | --- |
-| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts` |
+| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. Hosted's own counts are daily totals that name no one, and only the admin reads them. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts` |
- **FAIL IF** `S83` the consumer changes `authPolicy` away from explicit linking or multiple independent logins, or accepts an explicit connection callback after its initiating login was revoked; inspect `hosted/server/policy.ts` and the packed adapter.
- **FAIL IF** `S84` an unused provider credential enables login, an unknown provider name is accepted, or incomplete enabled credentials silently degrade; inspect `providerBindings` in `hosted/server/policy.ts`.
- **FAIL IF** `S85` speak or a voice token's mint admits an account the entitlement (`docs/specs/hosted.md` -> "Entitlement") does not, without rechecking per request, stores a voice token other than as its SHA-256, accepts one idle past `VOICE_TOKEN_IDLE_DAYS`, lets a bearer delete any token but its own, logs or stores speak text, forwards an ElevenLabs error body or status, or lets a binding or request field choose the upstream origin, path prefix, model, or format, or a voice outside `MANAGED_VOICES`; or the relay mints a voice token without redeeming a voice-purpose approval, or remote enrollment mints voice credentials; inspect `hosted/server/entitlement.ts`, `hosted/server/voice.ts`, and `hosted/server/relay-api.ts`.
+- **FAIL IF** `S278` a `dormouse_metrics_daily` row can hold anything but a UTC day, an event, a label on that event's `METRIC_LABELS` list, and a count; or `GET /api/admin/metrics` answers anyone but the verified `ADMIN_EMAIL` (401 without a login, 404 to any other), skips the cookie read gate, or answers more than those rows. Inspect `hosted/server/dormouse-migrations/013_metrics.sql`, `countMetric` in `hosted/server/metrics.ts`, `metricLabel` in `hosted/server/metric-labels.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, and `adminSql` in `hosted/server/entitlement.ts`.
- **FAIL IF** `S86` Hosted account login mints a Burrow ACL grant or substitutes for the existing encrypted pairing/presence proof. The one-time rendezvous carries only handshake ciphertext and authorizes nothing; the ends' handshake and the laptop's confirmation do.
-What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended". Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy.test.ts`.
+What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended". Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/policy.test.ts`, and `hosted/server/tests/metrics.test.ts`.
## Rendezvous boundary
@@ -76,7 +77,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended
- **FAIL IF** `S107` a push endpoint is registered or fetched that `knownPushEndpoint` does not admit (`https:`, default port, no credentials, a listed push service's host), or a delivery follows a redirect or keeps more than 1 KiB of a response body; inspect `deliverPush` and `reasonOf`.
- **FAIL IF** `S108` `RELAY_VAPID_PRIVATE_KEY` is anything but a relay Worker secret, a Wrangler `vars` entry or a preview config included, or push answers a key while the private key does not sign for it; inspect `pushConfigOf` in `hosted/server/relay-push.ts`, `vapidSigner` in `remote-lib-common/src/remote/web-push.ts`, and `hosted/scripts/preview.mjs`.
- **FAIL IF** `S109` the WebCrypto sender stops reproducing the RFC 8291 Appendix A message byte for byte in `remote-lib-common/test/web-push.test.mjs`, or that test takes an expected value from the code under test.
-- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables and `user` (voice-token insertion under "Managed voice" excepted), or reads a user column but the entitlement's; inspect the relay bundle's imports and queries.
+- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables and `user` (voice-token insertion under "Managed voice" and the metric upsert (`docs/specs/hosted.md` -> "Metrics") excepted), or reads a user column but the entitlement's; inspect the relay bundle's imports and queries.
- **FAIL IF** `S111` `RelayRoom` stores, logs, or decodes a frame or its `ct`, reads a frame other than through the shared frame layer (`readClientFrame`, `readBurrowFrame`), which parses only the routing envelope through the shared guards and copies `ct` field by field and reads it nowhere else, parses a frame before measuring its UTF-8 bytes against the shared `MAX_RELAY_FRAME_BYTES`, or puts anything but its account id in `ctx.storage` (routing state lives in hibernation attachments); `scripts/e2e-lint.mjs` holds the name, parse, decode, log, storage, and attachment half textually in both modules.
- **FAIL IF** `S112` a `scripts/e2e-lint.mjs` rule whose `spec` is this file names a line it does not enforce, or one outside "Rendezvous boundary" and "Relay boundary".
- **FAIL IF** `S113` a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored.
diff --git a/hosted/server/account-app.ts b/hosted/server/account-app.ts
index 2b64ed98e..ce6725459 100644
--- a/hosted/server/account-app.ts
+++ b/hosted/server/account-app.ts
@@ -1,7 +1,9 @@
import type { Context, ExecutionContext, Hono } from "hono";
-import { openAuthRead, ownPageRead } from "./account-gate";
+import { cookieAdmin, openAuthRead, ownPageRead } from "./account-gate";
import type { AccountEnv } from "./bindings";
import { accountRules } from "./headers";
+import { ADMIN_METRICS_PATH } from "./metric-labels";
+import { readMetrics } from "./metrics";
import { relayAccountRoutes, type RelayAccountHost } from "./relay-account";
import { relayRoom } from "./relay-room-contract";
import { termsRoutes } from "./terms";
@@ -10,8 +12,8 @@ import { workerApp } from "./worker-app";
/**
* The account Worker (`hosted.dormouse.sh`): auth, providers, readiness,
- * terms acceptance, Natural Voice on or off, the Relay's account routes, and the
- * frontend. The production and preview entries differ only in `fetchAuth`'s
+ * terms acceptance, Natural Voice on or off, the Relay's account routes, the
+ * admin's metrics, and the frontend. The production and preview entries differ only in `fetchAuth`'s
* mail and in `bindings`.
*/
export function accountApp(
@@ -52,6 +54,9 @@ export function accountApp(
termsRoutes(app, host);
voiceAccountRoutes(app, host);
relayAccountRoutes(app, host);
+ app.get(ADMIN_METRICS_PATH, cookieAdmin(host), async (c) =>
+ c.json(await readMetrics(c.env.HYPERDRIVE.connectionString)),
+ );
},
// Every desktop since v1.2.0 opens `/account` by a hard-coded path
// (`ACCOUNT_PAGE_PATH`), which this SPA fallback answers.
diff --git a/hosted/server/account-gate.ts b/hosted/server/account-gate.ts
index 13bb01e97..c5dacdb9c 100644
--- a/hosted/server/account-gate.ts
+++ b/hosted/server/account-gate.ts
@@ -2,7 +2,7 @@
// docs/specs/security-hosted.md -> "Origin boundary".
import type { Context, MiddlewareHandler } from "hono";
import { queryDatabase } from "pgstencil/postgres";
-import { entitled } from "./entitlement";
+import { entitled, isAdmin } from "./entitlement";
/** What one request's account deployment provides to its cookie routes. */
export interface AccountHost {
@@ -64,7 +64,7 @@ export function cookieEntitled(
host: (c: Context) => AccountHost,
refuse: (c: Context) => Response,
): MiddlewareHandler<{ Variables: { login: AccountLogin } }> {
- return cookieGate(host, refuse);
+ return cookieGate(host, { admits: entitled, refuse });
}
/** {@link cookieEntitled}'s gate for a route any signed-in account may use. */
@@ -74,9 +74,23 @@ export function cookieLogin(
return cookieGate(host);
}
+/**
+ * {@link cookieEntitled}'s gate for the admin's own routes: any login but
+ * the admin's (`isAdmin`, read per request) answers 404, as if the route
+ * were not there.
+ */
+export function cookieAdmin(
+ host: (c: Context) => AccountHost,
+): MiddlewareHandler<{ Variables: { login: AccountLogin } }> {
+ return cookieGate(host, { admits: isAdmin, refuse: (c) => c.json({ message: "Not found." }, 404) });
+}
+
function cookieGate(
host: (c: Context) => AccountHost,
- refuse?: (c: Context) => Response,
+ only?: {
+ admits: (databaseUrl: string, userId: string) => Promise;
+ refuse: (c: Context) => Response;
+ },
): MiddlewareHandler<{ Variables: { login: AccountLogin } }> {
return async (c, next) => {
const origin = new URL(c.req.url).origin;
@@ -99,7 +113,7 @@ function cookieGate(
session?: { createdAt?: unknown };
} | null;
if (!session?.user) return c.json({ message: "Sign in first." }, 401);
- if (refuse && !(await entitled(host(c).databaseUrl, session.user.id))) return refuse(c);
+ if (only && !(await only.admits(host(c).databaseUrl, session.user.id))) return only.refuse(c);
c.set("login", {
userId: session.user.id,
createdAt: session.session?.createdAt,
diff --git a/hosted/server/dormouse-migrations/013_metrics.sql b/hosted/server/dormouse-migrations/013_metrics.sql
new file mode 100644
index 000000000..e24085e90
--- /dev/null
+++ b/hosted/server/dormouse-migrations/013_metrics.sql
@@ -0,0 +1,16 @@
+-- Up Migration
+-- Hosted's aggregate daily counts (docs/specs/hosted.md -> "Metrics"): how
+-- many times each event happened on a UTC day, under one of its event's
+-- fixed labels ('' for none). A row describes no one, so rows are kept
+-- forever. The CHECKs refuse what an identifier would need: an address's @
+-- and dots, capitals, spaces, or more than 40 characters.
+CREATE TABLE dormouse_metrics_daily (
+ day date NOT NULL,
+ event text NOT NULL CHECK (char_length(event) <= 40 AND event ~ '^[a-z_]+(\.[a-z_-]+)?$'),
+ label text NOT NULL DEFAULT '' CHECK (char_length(label) <= 40 AND label ~ '^[a-z0-9:-]*$'),
+ count bigint NOT NULL CHECK (count >= 0),
+ PRIMARY KEY (day, event, label)
+);
+
+-- Down Migration
+DROP TABLE dormouse_metrics_daily;
diff --git a/hosted/server/entitlement.ts b/hosted/server/entitlement.ts
index a6724c057..7f06d9be6 100644
--- a/hosted/server/entitlement.ts
+++ b/hosted/server/entitlement.ts
@@ -1,7 +1,7 @@
// Rules: docs/specs/hosted.md -> "Entitlement".
import { queryDatabase } from "pgstencil/postgres";
-/** The one address the entitlement keys on until billing ships. */
+/** The admin's address: the one the entitlement keys on until billing ships, and the admin page's. */
export const ADMIN_EMAIL = "ned.twigg@diffplug.com";
// Inlined into SQL below, so it may never carry a quote or a backslash.
@@ -14,15 +14,25 @@ if (!/^[^'\\]+$/.test(ADMIN_EMAIL)) throw new Error("ADMIN_EMAIL cannot be inlin
* until billing ships: the verified `ADMIN_EMAIL`.
*/
export function entitledSql(user = "u"): string {
+ return adminSql(user);
+}
+
+/** Whether the `"user"` row aliased by `user` is the admin's: `ADMIN_EMAIL`, verified. */
+export function adminSql(user = "u"): string {
return `(${user}."emailVerified" IS TRUE AND ${user}.email = '${ADMIN_EMAIL}')`;
}
/** Whether `userId` is entitled now, read from `databaseUrl` in one query. */
-export async function entitled(databaseUrl: string, userId: string): Promise {
- const [row] = await queryDatabase<{ entitled: boolean }>(
+export const entitled = (databaseUrl: string, userId: string) => userIs(entitledSql(), databaseUrl, userId);
+
+/** Whether `userId` is the admin now, read from `databaseUrl` in one query. */
+export const isAdmin = (databaseUrl: string, userId: string) => userIs(adminSql(), databaseUrl, userId);
+
+async function userIs(predicate: string, databaseUrl: string, userId: string): Promise {
+ const [row] = await queryDatabase<{ is: boolean }>(
databaseUrl,
- `SELECT ${entitledSql()} AS entitled FROM "user" u WHERE u.id = $1`,
+ `SELECT ${predicate} AS is FROM "user" u WHERE u.id = $1`,
[userId],
);
- return row?.entitled === true;
+ return row?.is === true;
}
diff --git a/hosted/server/metric-labels.ts b/hosted/server/metric-labels.ts
new file mode 100644
index 000000000..063006dd7
--- /dev/null
+++ b/hosted/server/metric-labels.ts
@@ -0,0 +1,48 @@
+// Rules: docs/specs/hosted.md -> "Metrics". No I/O: the admin page's bundle
+// imports this as well as the Workers.
+
+/**
+ * Every event Hosted counts, and the only labels each may carry; `[""]` for
+ * one counted without a label.
+ */
+export const METRIC_LABELS = {
+ "enroll.approved": ["remote", "voice"],
+ "burrow.enrolled": [""],
+ "voice.speak": ["ok", "capped", "error"],
+ "voice.fallback-cap": [""],
+ "push.sent": [""],
+ "pocket.signin": [""],
+} as const satisfies Record;
+export type MetricEvent = keyof typeof METRIC_LABELS;
+
+/** `label` for `event`; throws when it is not on that event's list, so it is never counted. */
+export function metricLabel(event: MetricEvent, label: string): string {
+ if ((METRIC_LABELS[event] as readonly string[]).includes(label)) return label;
+ throw new Error(`${event} takes no label ${JSON.stringify(label)}`);
+}
+
+/** The admin page's JSON route; the account app's `/admin/metrics` reads it. */
+export const ADMIN_METRICS_PATH = "/api/admin/metrics";
+
+/** How many UTC days, today included, `recent` covers. */
+export const METRICS_DAYS = 30;
+
+/** One day's count of one event under one label. */
+export interface MetricRow {
+ /** `YYYY-MM-DD`, UTC. */
+ day: string;
+ event: MetricEvent;
+ label: string;
+ count: number;
+}
+
+/** `GET /api/admin/metrics`. */
+export interface AdminMetricsBody {
+ days: number;
+ /** Today's UTC date by the database's clock, which counts every row: the last day `recent` can hold. */
+ today: string;
+ /** Every row of the last `days` days, today included. */
+ recent: MetricRow[];
+ /** Each event and label's all-time count. */
+ totals: Omit[];
+}
diff --git a/hosted/server/metrics.ts b/hosted/server/metrics.ts
new file mode 100644
index 000000000..01b26848c
--- /dev/null
+++ b/hosted/server/metrics.ts
@@ -0,0 +1,76 @@
+// Rules: docs/specs/hosted.md -> "Metrics". The labels: ./metric-labels.ts.
+import type { Context } from "hono";
+import { queryDatabase } from "pgstencil/postgres";
+import {
+ METRICS_DAYS,
+ metricLabel,
+ type AdminMetricsBody,
+ type MetricEvent,
+ type MetricRow,
+} from "./metric-labels";
+
+/**
+ * Adds `n` to today's (UTC) count of `event` under `label`. Rejects on a
+ * label off the event's list or a database failure; callers go through
+ * {@link recordMetric}.
+ */
+export async function countMetric(databaseUrl: string, event: MetricEvent, label = "", n = 1) {
+ await queryDatabase(
+ databaseUrl,
+ `INSERT INTO dormouse_metrics_daily AS m (day, event, label, count)
+ VALUES ((now() AT TIME ZONE 'UTC')::date, $1, $2, $3)
+ ON CONFLICT (day, event, label) DO UPDATE SET count = m.count + EXCLUDED.count`,
+ [event, metricLabel(event, label), n],
+ );
+}
+
+/**
+ * Lets `write` finish after the response, through the request's `waitUntil`,
+ * and only logs its failure: it never fails or delays the request it rides
+ * on. The Node dev loop has no ExecutionContext; there it runs unawaited.
+ */
+export function bestEffort(c: Context, what: string, write: Promise) {
+ const settled = write.then(
+ () => {},
+ (error: unknown) =>
+ console.error(`${what} not recorded: ${error instanceof Error ? error.message : "unknown error"}`),
+ );
+ try {
+ c.executionCtx.waitUntil(settled);
+ } catch {
+ // No ExecutionContext.
+ }
+}
+
+/** {@link countMetric}, best effort. */
+export const recordMetric = (c: Context, databaseUrl: string, event: MetricEvent, label = "", n = 1) =>
+ bestEffort(c, `Metric ${event}`, countMetric(databaseUrl, event, label, n));
+
+/**
+ * The rows of the `METRICS_DAYS` UTC days to today, and every event's
+ * all-time totals. Today is the database's, the clock every row was counted by.
+ */
+export async function readMetrics(databaseUrl: string): Promise {
+ const [{ today }] = await queryDatabase<{ today: string }>(
+ databaseUrl,
+ `SELECT to_char((now() AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') AS today`,
+ [],
+ );
+ const [recent, totals] = await Promise.all([
+ queryDatabase(
+ databaseUrl,
+ `SELECT to_char(day, 'YYYY-MM-DD') AS day, event, label, count::float8 AS count
+ FROM dormouse_metrics_daily
+ WHERE day > $1::date - $2::int AND day <= $1::date
+ ORDER BY day, event, label`,
+ [today, METRICS_DAYS],
+ ),
+ queryDatabase>(
+ databaseUrl,
+ `SELECT event, label, sum(count)::float8 AS count FROM dormouse_metrics_daily
+ GROUP BY event, label ORDER BY event, label`,
+ [],
+ ),
+ ]);
+ return { days: METRICS_DAYS, today, recent, totals };
+}
diff --git a/hosted/server/relay-account.ts b/hosted/server/relay-account.ts
index 89b1d7885..8322af13d 100644
--- a/hosted/server/relay-account.ts
+++ b/hosted/server/relay-account.ts
@@ -1,4 +1,4 @@
-// Rules: docs/specs/hosted.md -> "Burrow enrollment".
+// Rules: docs/specs/hosted.md -> "Burrow enrollment" and "Metrics".
import type { Context, Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import {
@@ -9,6 +9,7 @@ import {
readJson,
} from "remote-lib-common";
import { accountQuery, cookieEntitled, type AccountHost } from "./account-gate";
+import { recordMetric } from "./metrics";
import { ENROLLMENT_TTL_MS, LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW } from "./policy-constants";
/** What one request's account deployment provides to the Relay's account routes. */
@@ -82,7 +83,9 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc
[userCode, login.userId, ENROLLMENT_TTL_MS, purpose, confirmDigits],
)
).length > 0;
- return approved ? c.body(null, 204) : c.json({ message: ALREADY_APPROVED }, 409);
+ if (!approved) return c.json({ message: ALREADY_APPROVED }, 409);
+ recordMetric(c, host(c).databaseUrl, "enroll.approved", purpose);
+ return c.body(null, 204);
});
// What a poll made of this account's live approval of the code: typed with
diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts
index d84f2e943..0ad1a0fc3 100644
--- a/hosted/server/relay-api.ts
+++ b/hosted/server/relay-api.ts
@@ -63,6 +63,7 @@ import { ENROLLMENT_POLL_INTERVAL_S, ENROLLMENT_TTL_MS } from "./policy-constant
import { relayRoom } from "./relay-room-contract";
import { relayPushRoutes } from "./relay-push";
import { mintVoiceToken } from "./voice-token";
+import { recordMetric } from "./metrics";
import {
OWNER_COLUMNS,
database,
@@ -361,6 +362,7 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) {
accountId: userId,
passkeyPublicKey: publicKey,
};
+ recordMetric(c, c.env.HYPERDRIVE.connectionString, "pocket.signin");
return c.json(res);
}),
);
@@ -619,6 +621,7 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) {
);
if (outcome === "expired") return answer({ status: "expired" });
if (outcome !== "enrolled") return answer({ status: "redeemed", burrowId: outcome.redeemed });
+ recordMetric(c, c.env.HYPERDRIVE.connectionString, "burrow.enrolled");
return answer({
status: "enrolled",
// The Burrow enforces `origin`/`rpId` as its ConnectionPolicy; Hosted
diff --git a/hosted/server/relay-push.ts b/hosted/server/relay-push.ts
index 9f6aa3b4a..201e277b3 100644
--- a/hosted/server/relay-push.ts
+++ b/hosted/server/relay-push.ts
@@ -35,6 +35,7 @@ import type {
WebPushKeys,
} from "remote-lib-common";
import type { RelayEnv } from "./bindings";
+import { recordMetric } from "./metrics";
import { database, locked, readAsBurrow, requireBurrow, requireSession, type Client } from "./relay-auth";
/**
@@ -421,6 +422,7 @@ export function relayPushRoutes(app: Hono<{ Bindings: RelayEnv }>) {
unknown: recipients.length - targets.length,
failed: results.filter((r) => r.result === "failed").length,
};
+ recordMetric(c, c.env.HYPERDRIVE.connectionString, "push.sent", "", res.delivered);
return c.json(res);
});
}
diff --git a/hosted/server/runtime-roles.sql b/hosted/server/runtime-roles.sql
index 9d3312f89..4d64048bc 100644
--- a/hosted/server/runtime-roles.sql
+++ b/hosted/server/runtime-roles.sql
@@ -63,6 +63,11 @@ GRANT USAGE ON SCHEMA public TO dormouse_relay, dormouse_voice;
-- Both: the entitlement check reads the owner's address and its verification.
GRANT SELECT (id, email, "emailVerified") ON "user" TO dormouse_relay, dormouse_voice;
+-- Both: their metrics' upsert-increment (hosted/server/metrics.ts), whose
+-- conflict needs the read. A row is an aggregate count.
+GRANT SELECT, INSERT ON dormouse_metrics_daily TO dormouse_relay, dormouse_voice;
+GRANT UPDATE (count) ON dormouse_metrics_daily TO dormouse_relay, dormouse_voice;
+
-- The relay Worker, its Cron sweep, and its RelayRoom's RelayRows.
GRANT SELECT, INSERT ON dormouse_relay_burrows TO dormouse_relay;
-- Only for the push subscribe's FOR KEY SHARE, which needs UPDATE on some
diff --git a/hosted/server/tests/metrics.test.ts b/hosted/server/tests/metrics.test.ts
new file mode 100644
index 000000000..2101b6e91
--- /dev/null
+++ b/hosted/server/tests/metrics.test.ts
@@ -0,0 +1,145 @@
+import { test, expect, beforeAll, afterAll, vi } from "vitest";
+import { Hono } from "hono";
+import { createTestContext } from "pgstencil/testing";
+import { queryDatabase } from "pgstencil/postgres";
+import { migrations } from "../migrations";
+import { METRIC_LABELS, metricLabel, type MetricEvent } from "../metric-labels";
+import { bestEffort, countMetric, readMetrics } from "../metrics";
+import { workerDatabases } from "./worker-roles";
+
+// docs/specs/hosted.md -> "Metrics": aggregate daily counts under fixed
+// labels, written by the relay and voice Workers on their own roles. Each
+// counted route's wiring is pinned beside its route's tests.
+
+let context: Awaited>;
+let databases: Awaited>;
+
+beforeAll(async () => {
+ context = await createTestContext({ migrations });
+ databases = await workerDatabases(context.database.url);
+});
+afterAll(async () => {
+ await context?.close();
+});
+
+const today = async (event: MetricEvent, label = "") =>
+ Number(
+ (
+ await queryDatabase<{ count: string }>(
+ context.database.url,
+ `SELECT count FROM dormouse_metrics_daily
+ WHERE day = (now() AT TIME ZONE 'UTC')::date AND event = $1 AND label = $2`,
+ [event, label],
+ )
+ )[0]?.count ?? 0,
+ );
+
+test("a label off its event's list is refused, never counted", async () => {
+ expect(metricLabel("voice.speak", "ok")).toBe("ok");
+ expect(metricLabel("enroll.approved", "voice")).toBe("voice");
+ expect(() => metricLabel("voice.speak", "nonsense")).toThrow(/takes no label/);
+ expect(() => metricLabel("burrow.enrolled", "user_123")).toThrow(/takes no label/);
+ await expect(countMetric(context.database.url, "pocket.signin", "ada@example.test")).rejects.toThrow(/takes no label/);
+ expect(await today("pocket.signin", "ada@example.test")).toBe(0);
+});
+
+test("a row holds only day, event, label, and count, and refuses what an identifier looks like", async () => {
+ const columns = await queryDatabase<{ name: string; type: string }>(
+ context.database.url,
+ `SELECT column_name AS name, data_type AS type FROM information_schema.columns
+ WHERE table_name = 'dormouse_metrics_daily' ORDER BY ordinal_position`,
+ );
+ expect(columns).toEqual([
+ { name: "day", type: "date" },
+ { name: "event", type: "text" },
+ { name: "label", type: "text" },
+ { name: "count", type: "bigint" },
+ ]);
+ const insert = (event: string, label: string, count = 1) =>
+ queryDatabase(
+ context.database.url,
+ `INSERT INTO dormouse_metrics_daily (day, event, label, count) VALUES ('2000-01-01', $1, $2, $3)`,
+ [event, label, count],
+ );
+ for (const label of ["ada@example.test", "203.0.113.10", "Mozilla/5.0", "a b", "x".repeat(41)])
+ await expect(insert("voice.speak", label), label).rejects.toMatchObject({ code: "23514" });
+ for (const event of ["Voice.speak", "voice.speak.ok", "a.b c", `${"x".repeat(39)}.y`])
+ await expect(insert(event, ""), event).rejects.toMatchObject({ code: "23514" });
+ await expect(insert("voice.speak", "ok", -1)).rejects.toMatchObject({ code: "23514" });
+ // Every allowlisted pair fits the checks.
+ for (const [event, labels] of Object.entries(METRIC_LABELS) as [MetricEvent, readonly string[]][])
+ for (const label of labels) await countMetric(context.database.url, event, label);
+});
+
+test("counting upserts today's row, adding n; the relay and voice roles add to it and change nothing else", async () => {
+ const before = await today("push.sent");
+ await countMetric(databases.relay, "push.sent", "", 3);
+ await countMetric(databases.relay, "push.sent");
+ expect(await today("push.sent")).toBe(before + 4);
+ const capped = await today("voice.speak", "capped");
+ await countMetric(databases.voice, "voice.speak", "capped");
+ expect(await today("voice.speak", "capped")).toBe(capped + 1);
+ for (const url of [databases.relay, databases.voice])
+ for (const text of [
+ `DELETE FROM dormouse_metrics_daily`,
+ `UPDATE dormouse_metrics_daily SET label = 'ok'`,
+ `UPDATE dormouse_metrics_daily SET day = day - 1`,
+ ])
+ await expect(queryDatabase(url, text), text).rejects.toMatchObject({ code: "42501" });
+});
+
+test("readMetrics answers the window's rows, ending today, and all-time totals", async () => {
+ const day = (back: number) => `(now() AT TIME ZONE 'UTC')::date - ${back}`;
+ await queryDatabase(context.database.url, `DELETE FROM dormouse_metrics_daily WHERE event = 'enroll.approved'`);
+ await queryDatabase(
+ context.database.url,
+ `INSERT INTO dormouse_metrics_daily (day, event, label, count) VALUES
+ (${day(0)}, 'enroll.approved', 'voice', 2),
+ (${day(29)}, 'enroll.approved', 'voice', 3),
+ (${day(30)}, 'enroll.approved', 'voice', 5),
+ (${day(-1)}, 'enroll.approved', 'voice', 7)`,
+ );
+ const body = await readMetrics(context.database.url);
+ const [{ now, first }] = await queryDatabase<{ now: string; first: string }>(
+ context.database.url,
+ `SELECT to_char(${day(0)}, 'YYYY-MM-DD') AS now, to_char(${day(29)}, 'YYYY-MM-DD') AS first`,
+ );
+ expect(body.days).toBe(30);
+ expect(body.today).toBe(now);
+ // The 31st day back, and tomorrow, are outside the window and inside the totals.
+ expect(body.recent.filter((row) => row.event === "enroll.approved")).toEqual([
+ { day: first, event: "enroll.approved", label: "voice", count: 3 },
+ { day: now, event: "enroll.approved", label: "voice", count: 2 },
+ ]);
+ expect(body.totals.find((row) => row.event === "enroll.approved" && row.label === "voice")).toEqual({
+ event: "enroll.approved",
+ label: "voice",
+ count: 17,
+ });
+});
+
+test("a metric's write never fails or delays the response it rides on, and its failure is only logged", async () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ const scheduled: Promise[] = [];
+ const app = new Hono();
+ app.get("/failing", (c) => {
+ bestEffort(c, "Metric test", Promise.reject(new Error("permission denied")));
+ return c.text("answered");
+ });
+ app.get("/hung", (c) => {
+ bestEffort(c, "Metric test", new Promise(() => {}));
+ return c.text("answered");
+ });
+ const ctx = { waitUntil: (promise: Promise) => scheduled.push(promise), passThroughOnException() {}, props: {} };
+ for (const path of ["/failing", "/hung"]) {
+ const response = await app.request(path, {}, {}, ctx);
+ expect([path, response.status, await response.text()]).toEqual([path, 200, "answered"]);
+ }
+ expect(scheduled).toHaveLength(2);
+ await scheduled[0];
+ expect(error).toHaveBeenCalledWith("Metric test not recorded: permission denied");
+ } finally {
+ error.mockRestore();
+ }
+});
diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts
index adb2b8ef7..8670a8a38 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
@@ -37,6 +37,8 @@ const PINNED: Record = {
"54afe9ae8a2efd3c654503d479fce96c1ce8ff4f2df249aa93b5a91e925def6e",
"012_relay_burrow_label.sql":
"45b3df1530402622edeb12e12e1572698840ecc8ea6b9cfc585f3a9e28b4f230",
+ "013_metrics.sql":
+ "ffb460491d0d9d3cf00b33067b6cbe8dae04ca68fc663cb005aabf15619a08eb",
};
const directory = new URL("../dormouse-migrations/", import.meta.url);
diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts
index 31c12e5e4..86d1036ad 100644
--- a/hosted/server/tests/relay.test.ts
+++ b/hosted/server/tests/relay.test.ts
@@ -1,4 +1,4 @@
-import { test, expect } from "vitest";
+import { test, expect, vi } from "vitest";
import { createDecipheriv, createECDH, hkdfSync, randomBytes, randomUUID } from "node:crypto";
import { digest } from "@pgstencil/auth/security";
import { Miniflare, Response as WorkerResponse } from "miniflare";
@@ -389,6 +389,12 @@ test("setup, sign-in, and a presence proof end to end, scoped to the account tha
expect(signed.status).toBe(200);
expect(signed.json).toMatchObject({ accountId: owner, passkeyPublicKey: phone.publicKey });
const { sessionToken, expiresAt } = signed.json!;
+ // Counted once, as a sign-in; the setup finish's session is none.
+ await vi.waitFor(async () =>
+ expect(await f.sql(`SELECT event, label, count::int FROM dormouse_metrics_daily`)).toEqual([
+ { event: "pocket.signin", label: "", count: 1 },
+ ]),
+ );
expect(Math.abs(expiresAt - (Date.now() + 12 * 3600_000))).toBeLessThan(60_000);
expect(
@@ -1468,6 +1474,12 @@ test("push end to end: a Burrow's sealed envelope reaches the push service encry
});
expect(sent.json).toEqual({ delivered: 1, expired: 0, unknown: 0, failed: 0 });
expect(f.pushed).toHaveLength(1);
+ // Counted by delivery.
+ await vi.waitFor(async () =>
+ expect(await f.sql(`SELECT event, label, count::int FROM dormouse_metrics_daily`)).toEqual([
+ { event: "push.sent", label: "", count: 1 },
+ ]),
+ );
const [request] = f.pushed;
expect(request.url).toBe(phone.subscription.endpoint);
expect(request.headers["content-encoding"]).toBe("aes128gcm");
diff --git a/hosted/server/tests/runtime-roles.test.ts b/hosted/server/tests/runtime-roles.test.ts
index 0ab444a70..a18c82004 100644
--- a/hosted/server/tests/runtime-roles.test.ts
+++ b/hosted/server/tests/runtime-roles.test.ts
@@ -71,6 +71,8 @@ const EXPECTED: Record = {
relay: [
// PUBLIC's defaults.
"database: CONNECT, TEMPORARY",
+ "dormouse_metrics_daily.count: UPDATE",
+ "dormouse_metrics_daily: INSERT, SELECT",
"dormouse_relay_burrows.enrolledAt: UPDATE",
"dormouse_relay_burrows: INSERT, SELECT",
"dormouse_relay_challenges: DELETE, INSERT, SELECT",
@@ -98,6 +100,8 @@ const EXPECTED: Record = {
],
voice: [
"database: CONNECT, TEMPORARY",
+ "dormouse_metrics_daily.count: UPDATE",
+ "dormouse_metrics_daily: INSERT, SELECT",
"dormouse_voice_tokens.createdAt: SELECT",
"dormouse_voice_tokens.hash: SELECT",
"dormouse_voice_tokens.id: SELECT",
diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts
index 478d164f5..646e55bbe 100644
--- a/hosted/server/tests/workers.test.ts
+++ b/hosted/server/tests/workers.test.ts
@@ -902,8 +902,9 @@ test("Natural Voice: only the verified admin speaks; the account sees on or off
body: { text: "Build passed.", model_id: "eleven_flash_v2_5" },
},
]);
- // A successful speech schedules one history sweep (undelayed in the test entry).
- expect(await f.waitUntilCalls()).toBe(1);
+ // A successful speech schedules one history sweep (undelayed in the test
+ // entry) and its metric's write.
+ expect(await f.waitUntilCalls()).toBe(2);
await vi.waitFor(() =>
expect(f.elevenLabs.sweeps).toEqual([
`https://api.elevenlabs.io/v1/history?page_size=${SPEECH_SWEEP_CAP}`,
@@ -961,8 +962,9 @@ test("Natural Voice: only the verified admin speaks; the account sees on or off
const otherToken = await other.mint();
expect((await other.speak(otherToken, hi)).status).toBe(403);
- // No refused or failed speech (400, 401, 403, 429, 502) scheduled a sweep.
- expect(await f.waitUntilCalls()).toBe(1);
+ // No refused or failed speech (400, 401, 403, 429, 502) scheduled a sweep:
+ // past the first speech's two, only the two 502s' and the 429's metrics.
+ expect(await f.waitUntilCalls()).toBe(5);
// A year without a speak, counted from the mint until the first, refuses
// the token as if it never existed; inside it, speak reaches the cap.
@@ -1010,6 +1012,114 @@ test("Natural Voice: only the verified admin speaks; the account sees on or off
expect((await admin.speak(lapsed, hi)).status).toBe(401);
});
+/** Today's (UTC) count of `event` under `label`, once its best-effort write landed. */
+const counted = (database: string, event: string, label = "") =>
+ queryDatabase<{ count: string }>(
+ database,
+ `SELECT count FROM dormouse_metrics_daily
+ WHERE day = (now() AT TIME ZONE 'UTC')::date AND event = $1 AND label = $2`,
+ [event, label],
+ ).then(([row]) => Number(row?.count ?? 0));
+
+test("metrics: speech by outcome and the call that spends the day, approvals by purpose, an enrollment; a failing write changes no answer", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const admin = f.browser();
+ await admin.email(ADMIN_EMAIL);
+ const token = await admin.mint();
+ const usage = (count: number) =>
+ queryDatabase(f.database.url, "UPDATE dormouse_voice_usage SET count = $1", [count]);
+
+ expect((await admin.speak(token, hi)).status).toBe(200);
+ await vi.waitFor(async () => expect(await counted(f.database.url, "voice.speak", "ok")).toBe(1));
+ expect(await counted(f.database.url, "voice.fallback-cap")).toBe(0);
+ // The call that spends the day's last counts once; the next is capped.
+ await usage(VOICE_DAILY_CAP - 1);
+ expect((await admin.speak(token, hi)).status).toBe(200);
+ expect((await admin.speak(token, hi)).status).toBe(429);
+ await vi.waitFor(async () => {
+ expect(await counted(f.database.url, "voice.speak", "ok")).toBe(2);
+ expect(await counted(f.database.url, "voice.fallback-cap")).toBe(1);
+ expect(await counted(f.database.url, "voice.speak", "capped")).toBe(1);
+ });
+ await usage(0);
+ f.elevenLabs.respond = () => new WorkerResponse(null, { status: 500 });
+ expect((await admin.speak(token, hi)).status).toBe(502);
+ await vi.waitFor(async () => expect(await counted(f.database.url, "voice.speak", "error")).toBe(1));
+
+ // A write the role is refused still answers the speech in full.
+ await queryDatabase(f.database.url, `REVOKE INSERT ON dormouse_metrics_daily FROM ${RUNTIME_ROLES.voice}`);
+ f.elevenLabs.respond = () =>
+ new WorkerResponse(new Uint8Array([0xff, 0xfb]), { headers: { "content-type": "audio/mpeg" } });
+ await queryDatabase(f.database.url, "DELETE FROM dormouse_metrics_daily");
+ const spoken = await admin.speak(token, hi);
+ expect(spoken.status).toBe(200);
+ expect([...new Uint8Array(await spoken.arrayBuffer())]).toEqual([0xff, 0xfb]);
+ expect(await counted(f.database.url, "voice.speak", "ok")).toBe(0);
+
+ // An approval counts under its purpose, and an enrollment once its poll redeems it.
+ const remote = await f.begin();
+ expect((await admin.approve(remote.userCode, remote.confirmDigits)).status).toBe(204);
+ expect((await admin.approve(remote.userCode, remote.confirmDigits)).status).toBe(409);
+ const voice = (
+ await f.burrowCall(API_ROUTES.burrowEnrollBegin, { origin: ORIGINS.relay, purpose: "voice" })
+ ).json as { userCode: string; confirmDigits: string };
+ const approved = await admin.request("/api/relay/enrollments/approve", {
+ method: "POST",
+ body: JSON.stringify({ userCode: voice.userCode, purpose: "voice", confirmDigits: voice.confirmDigits }),
+ headers: { origin, "content-type": "application/json" },
+ });
+ expect(approved.status).toBe(204);
+ expect((await f.poll(remote.deviceCode)).json.status).toBe("enrolled");
+ expect((await f.poll(remote.deviceCode)).json.status).toBe("redeemed");
+ await vi.waitFor(async () => {
+ expect(await counted(f.database.url, "enroll.approved", "remote")).toBe(1);
+ expect(await counted(f.database.url, "enroll.approved", "voice")).toBe(1);
+ expect(await counted(f.database.url, "burrow.enrolled")).toBe(1);
+ });
+});
+
+test("the admin page: its read answers only the verified admin, from this origin, and its path serves the app", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const admin = f.browser(),
+ other = f.browser();
+ const read = (who: typeof admin, headers?: Record) =>
+ who.request("/api/admin/metrics", { headers });
+ expect((await read(admin)).status).toBe(401);
+ await other.email("other@example.test");
+ expect((await read(other)).status).toBe(404);
+ await admin.email(ADMIN_EMAIL);
+ await queryDatabase(
+ f.database.url,
+ `INSERT INTO dormouse_metrics_daily (day, event, label, count)
+ VALUES ((now() AT TIME ZONE 'UTC')::date, 'push.sent', '', 4)`,
+ );
+ const response = await read(admin);
+ expect(response.status).toBe(200);
+ const today = new Date().toISOString().slice(0, 10);
+ expect(await response.json()).toEqual({
+ days: 30,
+ today,
+ recent: [{ day: today, event: "push.sent", label: "", count: 4 }],
+ totals: [{ event: "push.sent", label: "", count: 4 }],
+ });
+ // A navigation or another site's page carries the cookie but is no read of this page's.
+ for (const site of ["none", "same-site", "cross-site"])
+ expect((await read(admin, { "sec-fetch-site": site })).status, site).toBe(403);
+ // The admin address counts only while verified.
+ await queryDatabase(f.database.url, `UPDATE "user" SET "emailVerified" = false WHERE email = $1`, [ADMIN_EMAIL]);
+ expect((await read(admin)).status).toBe(404);
+ for (const path of ["/admin/metrics", "/admin/metrics/"]) {
+ const page = await admin.request(path);
+ expect([path, page.status, page.headers.get("content-type")]).toEqual([path, 200, "text/html"]);
+ }
+});
+
test("the voice Worker's daily cron deletes only tokens a year idle, and never touches ElevenLabs", async ({
onTestFinished,
}) => {
diff --git a/hosted/server/voice.ts b/hosted/server/voice.ts
index f119aceae..1e0417d5d 100644
--- a/hosted/server/voice.ts
+++ b/hosted/server/voice.ts
@@ -1,10 +1,11 @@
-// Rules: docs/specs/hosted.md -> "Managed voice".
+// Rules: docs/specs/hosted.md -> "Managed voice" and "Metrics".
import type { Context, Hono } from "hono";
import { digest } from "@pgstencil/auth/security";
import { queryDatabase, withClient } from "pgstencil/postgres";
import { isManagedVoiceId, isManagedVoiceToken, parseBearer } from "remote-lib-common";
import { accountQuery, cookieLogin, type AccountHost } from "./account-gate";
import { entitledSql } from "./entitlement";
+import { recordMetric } from "./metrics";
export const VOICE_DAILY_CAP = 500;
/** A token unused this long is refused, as if it never existed. */
@@ -171,12 +172,18 @@ export function voiceBearerRoutes(app: Hono, host: (c: Context) => SpeakHos
RETURNING count`,
[owner.userId, owner.id, VOICE_DAILY_CAP],
);
- if (!counted.rows.length)
+ if (!counted.rows.length) {
+ recordMetric(c, databaseUrl, "voice.speak", "capped");
return fail(
c,
429,
"Daily voice limit reached. It resets at 00:00 UTC.",
);
+ }
+ // The call that spends the account's last of the day: its alarms fall
+ // back to the system voice until 00:00 UTC.
+ if (Number(counted.rows[0].count) === VOICE_DAILY_CAP)
+ recordMetric(c, databaseUrl, "voice.fallback-cap");
return { synthesize, voiceId, spoken };
});
if (!("spoken" in speech)) return speech;
@@ -187,8 +194,10 @@ export function voiceBearerRoutes(app: Hono, host: (c: Context) => SpeakHos
.catch(() => null);
if (!upstream?.ok || !upstream.body) {
await upstream?.body?.cancel().catch(() => {});
+ recordMetric(c, databaseUrl, "voice.speak", "error");
return fail(c, 502, "The voice service did not respond. Try again.");
}
+ recordMetric(c, databaseUrl, "voice.speak", "ok");
return new Response(upstream.body, {
headers: { "content-type": "audio/mpeg", "cache-control": "no-store" },
});
diff --git a/hosted/src/AdminMetrics.test.tsx b/hosted/src/AdminMetrics.test.tsx
new file mode 100644
index 000000000..087877dc8
--- /dev/null
+++ b/hosted/src/AdminMetrics.test.tsx
@@ -0,0 +1,70 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { AdminMetrics, isAdminMetricsPath } from "./AdminMetrics";
+
+let root: Root;
+let container: HTMLDivElement;
+
+beforeEach(() => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+/** Mounts the page with its read answering `response`. */
+async function mount(response: () => Response) {
+ const fetch = vi.fn(async () => response());
+ vi.stubGlobal("fetch", fetch);
+ await act(async () => root.render());
+ return fetch;
+}
+
+it("answers its path with or without the trailing slash, and no other", () => {
+ expect(["/admin/metrics", "/admin/metrics/", "/admin", "/account"].map(isAdminMetricsPath)).toEqual([
+ true,
+ true,
+ false,
+ false,
+ ]);
+});
+
+it("reads the counts as the page's own fetch, and shows each event", async () => {
+ const fetch = await mount(() =>
+ Response.json({
+ days: 30,
+ today: "2026-10-28",
+ recent: [{ day: "2026-10-28", event: "push.sent", label: "", count: 1234 }],
+ totals: [{ event: "push.sent", label: "", count: 1234 }],
+ }),
+ );
+ expect(fetch).toHaveBeenCalledWith("/api/admin/metrics", expect.objectContaining({ credentials: "same-origin" }));
+ expect(container.querySelector("h1")?.textContent).toBe("Metrics");
+ expect(container.textContent).toContain("through 2026-10-28");
+ const push = container.querySelector('section[aria-label="Push notifications delivered"]')!;
+ expect(push.textContent).toContain("1,234");
+});
+
+it("asks a signed-out visitor to sign in, and tells anyone but the admin there is no page", async () => {
+ await mount(() => Response.json({ message: "Sign in first." }, { status: 401 }));
+ expect(container.querySelector('a[href="/login?signin"]')).not.toBeNull();
+ await act(async () => root.unmount());
+ root = createRoot(container);
+ await mount(() => Response.json({ message: "Not found." }, { status: 404 }));
+ expect(container.querySelector("h1")?.textContent).toBe("Not found");
+ expect(container.textContent).not.toContain("Metrics");
+});
+
+it("says an outage, and Retry reads again", async () => {
+ const fetch = await mount(() => new Response(null, { status: 503 }));
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain("Metrics are temporarily unavailable");
+ await act(async () => container.querySelector("button")!.click());
+ expect(fetch).toHaveBeenCalledTimes(2);
+});
diff --git a/hosted/src/AdminMetrics.tsx b/hosted/src/AdminMetrics.tsx
new file mode 100644
index 000000000..ecd2eae22
--- /dev/null
+++ b/hosted/src/AdminMetrics.tsx
@@ -0,0 +1,171 @@
+import { useCallback, useEffect, useState } from "react";
+import { useDocsThemedBody } from "dormouse-lib/site/docs-paint";
+import {
+ CARD_CLASS,
+ CODE_CLASS,
+ DASHED_ROW_CLASS,
+ LINK_CLASS,
+ MUTED_TEXT_CLASS,
+ PAGE_INTRO_CLASS,
+ PAGE_TITLE_CLASS,
+ STATUS_BAD_TEXT_CLASS,
+} from "dormouse-lib/site/docs-tokens";
+import type { AdminMetricsBody, MetricEvent } from "../server/metric-labels";
+import { summarize, type EventSummary } from "./admin-metrics";
+import { getAdminMetrics } from "./api";
+import { HostedShell } from "./HostedShell";
+
+/** The admin page's paths; the trailing slash is the same page. */
+export const isAdminMetricsPath = (path: string) => path === "/admin/metrics" || path === "/admin/metrics/";
+
+const EVENT_TITLES = {
+ "enroll.approved": "Approvals",
+ "burrow.enrolled": "Computers enrolled",
+ "voice.speak": "Natural Voice requests",
+ "voice.fallback-cap": "Accounts that spent the day’s voice",
+ "push.sent": "Push notifications delivered",
+ "pocket.signin": "Pocket sign-ins",
+} satisfies Record;
+
+export type AdminMetricsState =
+ | { kind: "loading" }
+ | { kind: "signed-out" }
+ | { kind: "not-found" }
+ | { kind: "failed"; message: string }
+ | { kind: "ok"; body: AdminMetricsBody };
+
+const count = (n: number) => n.toLocaleString("en-US");
+
+/** A day's sums as bars, scaled to the window's busiest day. */
+function DailyBars({ daily }: { daily: EventSummary["daily"] }) {
+ const busiest = Math.max(1, ...daily.map((day) => day.count));
+ return (
+
+
@@ -1140,7 +1141,7 @@ export default function Home() {
CLI referenceAgent skillHow to self-host
- Dormouse Hosted
+ Dormouse HostedSecurityHosted privacyHosted terms
diff --git a/website/src/pages/Hosted.test.tsx b/website/src/pages/Hosted.test.tsx
index fe49a41c1..687b7aea9 100644
--- a/website/src/pages/Hosted.test.tsx
+++ b/website/src/pages/Hosted.test.tsx
@@ -137,6 +137,23 @@ describe("the Hosted card's billing toggle", () => {
});
});
+describe("a visit's ref", () => {
+ it("comes off the address bar on arrival, leaving the rest of the URL", async () => {
+ for (const [arrived, left] of [
+ ["/hosted/?ref=settings-voice#voice", "/hosted/#voice"],
+ ["/hosted/?a=1&ref=someone%40example.test", "/hosted/?a=1"],
+ ["/hosted/#pricing", "/hosted/#pricing"],
+ ]) {
+ history.replaceState(null, "", arrived);
+ await mount(async () => new Response("{}", { status: 404 }));
+ expect(location.pathname + location.search + location.hash).toBe(left);
+ act(() => root?.unmount());
+ container?.remove();
+ root = null;
+ }
+ });
+});
+
/** Mounts the page with the cohort endpoint answering `body`. */
const mountCohort = (body: unknown) =>
mount(async (url) => {
diff --git a/website/src/pages/Hosted.tsx b/website/src/pages/Hosted.tsx
index 17b7d69af..bc30893aa 100644
--- a/website/src/pages/Hosted.tsx
+++ b/website/src/pages/Hosted.tsx
@@ -55,6 +55,7 @@ import {
type Tier,
} from "../lib/hosted-pricing";
import { canonicalUrl, siteMeta, sitePath } from "../lib/site-meta";
+import { takeVisitRef } from "../lib/hosted-ref";
const PAGE_PATH = "/hosted";
@@ -448,6 +449,12 @@ export default function Hosted() {
return () => controller.abort();
}, []);
+ // Off the address bar on arrival. What it answers goes nowhere yet: this
+ // is where a visit count would read it.
+ useEffect(() => {
+ takeVisitRef();
+ }, []);
+
return (
Run your own Relay, or{" "}
-
+
use the Hosted Relay
{" "}
and let me run it for you. Your terminal still runs on your awake, online
diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx
index 07cf35f3d..d03bd9249 100644
--- a/website/src/pages/Privacy.tsx
+++ b/website/src/pages/Privacy.tsx
@@ -51,7 +51,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [
We use this information to create and recognize your account, verify sign-in, connect methods you explicitly select, send requested sign-in codes, check on each request that your subscription is active, deliver the features you pay for, apply fair-use limits, prevent abuse, troubleshoot failures, and answer support requests. We discard provider access, refresh, and identity tokens after identity verification rather than storing them in your account.
Every request to Hosted carries your IP address and connection information, which we and our infrastructure providers use to deliver the request, secure the service, and limit request rates. Rate-limit records we store hold a keyed hash of the address, not the address itself, and are kept only briefly.
DiffPlug does not sell Hosted information, use it for targeted advertising, or use it to train AI models. The Natural Voice section describes ElevenLabs’ separate processing. Signing in or subscribing does not subscribe you to a newsletter. The account site uses necessary authentication and security cookies and does not load marketing analytics.
-
To see whether Hosted is working, we may keep aggregate daily counts on our own servers — for example, checkouts per plan, sign-ins per method, and voice requests answered or over the limit — with no per-person analytics. A count row carries no account id, email address, IP address, browser information, or text. Links to the Hosted page from Dormouse or this site may carry a short label naming where you came from (such as the voice setting or the tutorial), which may be counted, in the same aggregate way, if you check out. There are no analytics scripts or tracking cookies. Because we do not track you across sites or sell your information, browser Do Not Track and Global Privacy Control signals have nothing further to turn off, and we do not change our practices in response to them.
+
To see whether Hosted is working, we keep aggregate daily counts on our own servers, with no per-person analytics: computers approved and enrolled, Pocket sign-ins, push notifications delivered, Natural Voice requests answered, over the daily limit, or failed, and accounts that reached the daily voice limit. A count row holds only the day, what was counted, a fixed label (such as “voice” or “over the limit”), and the number; it carries no account id, email address, IP address, browser information, or text. Links to the Hosted page from Dormouse or this site carry a short label naming which link it was (such as the voice setting or the tutorial). The page removes it from the address bar when it loads, and we neither count nor store it. There are no analytics scripts or tracking cookies. Because we do not track you across sites or sell your information, browser Do Not Track and Global Privacy Control signals have nothing further to turn off, and we do not change our practices in response to them.