+
);
}
diff --git a/hosted/src/HostedShell.tsx b/hosted/src/HostedShell.tsx
new file mode 100644
index 000000000..fa6352f53
--- /dev/null
+++ b/hosted/src/HostedShell.tsx
@@ -0,0 +1,59 @@
+import type { ReactNode } from "react";
+import DocsThemeControl from "dormouse-lib/site/DocsThemeControl";
+import SiteHeader, { type SiteNavLink } from "dormouse-lib/site/SiteHeader";
+import { DOCS_HEADER_STYLE } from "dormouse-lib/site/docs-paint";
+import { MUTED_ACCENT_LINK_CLASS, MUTED_TEXT_CLASS } from "dormouse-lib/site/docs-tokens";
+
+/** The site header's own links (`NAV_LINKS` in website/src/lib/site-nav.ts),
+ * absolute because this is another origin. Playground also gives way on a
+ * phone, making room for the "Hosted" beside the wordmark. */
+const SITE_LINKS: readonly SiteNavLink[] = [
+ { href: "https://dormouse.sh/playground/", label: "Playground", hideOnMobile: true },
+ { href: "https://dormouse.sh/#download", label: "Download", hideOnMobile: true },
+ { href: "https://dormouse.sh/docs", label: "Docs" },
+ { href: "https://github.com/diffplug/dormouse", label: "GitHub", external: true },
+];
+
+const FOOTER_LINKS = [
+ ["https://dormouse.sh/security/", "Dormouse security"],
+ ["https://dormouse.sh/privacy/", "Privacy"],
+ ["https://dormouse.sh/terms/", "Terms"],
+] as const;
+
+/** Every Hosted page's header, main column, and footer. */
+export function HostedShell({
+ themeControl = false,
+ mainWidth = "max-w-[33rem]",
+ children,
+}: {
+ /** The docs pages' theme control. Off in story frames, whose theme the
+ * control (which paints the top-level document) cannot reach. */
+ themeControl?: boolean;
+ /** The main column's `max-w-*` class. */
+ mainWidth?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ {themeControl ? : null}
+
+ );
+}
diff --git a/hosted/src/admin-metrics.test.ts b/hosted/src/admin-metrics.test.ts
new file mode 100644
index 000000000..810453de5
--- /dev/null
+++ b/hosted/src/admin-metrics.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { summarize } from "./admin-metrics";
+
+describe("summarize", () => {
+ const body = {
+ days: 3,
+ today: "2026-03-01",
+ recent: [
+ { day: "2026-02-27", event: "voice.speak" as const, label: "ok", count: 4 },
+ { day: "2026-03-01", event: "voice.speak" as const, label: "ok", count: 2 },
+ { day: "2026-03-01", event: "voice.speak" as const, label: "error", count: 1 },
+ { day: "2026-03-01", event: "push.sent" as const, label: "", count: 9 },
+ ],
+ totals: [
+ { event: "voice.speak" as const, label: "ok", count: 10 },
+ { event: "voice.speak" as const, label: "error", count: 1 },
+ { event: "push.sent" as const, label: "", count: 9 },
+ ],
+ };
+
+ it("sums each event by day across a month boundary, a day without rows as zero", () => {
+ const speak = summarize(body).find((summary) => summary.event === "voice.speak")!;
+ expect(speak).toEqual({
+ event: "voice.speak",
+ total: 11,
+ window: 7,
+ today: 3,
+ labels: [
+ { label: "ok", total: 10, window: 6 },
+ { label: "capped", total: 0, window: 0 },
+ { label: "error", total: 1, window: 1 },
+ ],
+ daily: [
+ { day: "2026-02-27", count: 4 },
+ { day: "2026-02-28", count: 0 },
+ { day: "2026-03-01", count: 3 },
+ ],
+ });
+ });
+
+ it("lists every event, one never counted at zero, and no label rows for an unlabeled one", () => {
+ const summaries = summarize(body);
+ expect(summaries.map((summary) => summary.event)).toEqual([
+ "enroll.approved",
+ "burrow.enrolled",
+ "voice.speak",
+ "voice.fallback-cap",
+ "push.sent",
+ "pocket.signin",
+ ]);
+ expect(summaries.find((summary) => summary.event === "push.sent")!.labels).toEqual([]);
+ expect(summaries.find((summary) => summary.event === "pocket.signin")!.total).toBe(0);
+ });
+});
diff --git a/hosted/src/admin-metrics.ts b/hosted/src/admin-metrics.ts
new file mode 100644
index 000000000..381b1036d
--- /dev/null
+++ b/hosted/src/admin-metrics.ts
@@ -0,0 +1,57 @@
+import {
+ METRIC_LABELS,
+ type AdminMetricsBody,
+ type MetricEvent,
+} from "../server/metric-labels";
+
+/** One label's counts within an event. */
+export interface LabelSummary {
+ label: string;
+ total: number;
+ window: number;
+}
+
+/** One event as the admin page shows it. */
+export interface EventSummary {
+ event: MetricEvent;
+ total: number;
+ /** The window's sum, today included. */
+ window: number;
+ today: number;
+ /** Every listed label, in the allowlist's order; empty for an unlabeled event. */
+ labels: LabelSummary[];
+ /** One sum per day of the window, oldest first, a day with no row as 0. */
+ daily: { day: string; count: number }[];
+}
+
+/** `YYYY-MM-DD`, `back` UTC days before `today`. */
+function dayBefore(today: string, back: number): string {
+ const date = new Date(`${today}T00:00:00Z`);
+ date.setUTCDate(date.getUTCDate() - back);
+ return date.toISOString().slice(0, 10);
+}
+
+/** Every counted event, in the allowlist's order, from one read. */
+export function summarize(body: AdminMetricsBody): EventSummary[] {
+ const days = Array.from({ length: body.days }, (_, i) => dayBefore(body.today, body.days - 1 - i));
+ return (Object.keys(METRIC_LABELS) as MetricEvent[]).map((event) => {
+ const recent = body.recent.filter((row) => row.event === event);
+ const totals = body.totals.filter((row) => row.event === event);
+ const sum = (rows: { count: number }[]) => rows.reduce((total, row) => total + row.count, 0);
+ const listed: readonly string[] = METRIC_LABELS[event];
+ return {
+ event,
+ total: sum(totals),
+ window: sum(recent),
+ today: sum(recent.filter((row) => row.day === body.today)),
+ labels: listed.includes("")
+ ? []
+ : listed.map((label) => ({
+ label,
+ total: sum(totals.filter((row) => row.label === label)),
+ window: sum(recent.filter((row) => row.label === label)),
+ })),
+ daily: days.map((day) => ({ day, count: sum(recent.filter((row) => row.day === day)) })),
+ };
+ });
+}
diff --git a/hosted/src/api.ts b/hosted/src/api.ts
index 15463dd94..8a985c0d5 100644
--- a/hosted/src/api.ts
+++ b/hosted/src/api.ts
@@ -1,6 +1,7 @@
import { providerIds, providerNames } from "../server/providers.js";
import type { ProviderId } from "../server/providers.js";
import { NOT_ENTITLED_ERROR } from "../../remote-lib-common/src/remote/wire.ts";
+import { ADMIN_METRICS_PATH, type AdminMetricsBody } from "../server/metric-labels";
export const providers = providerIds;
export type Provider = ProviderId;
@@ -201,3 +202,17 @@ export async function getApproval(userCode: string): Promise {
if (!response.ok) throw await refused(response);
return (await response.json()) as ApprovalState;
}
+
+/** What the admin page's read answered. */
+export type AdminMetricsRead =
+ | { status: "ok"; body: AdminMetricsBody }
+ | { status: "signed-out" }
+ /** Anyone but the admin: the route answers as if it were not there. */
+ | { status: "not-found" };
+export async function getAdminMetrics(): Promise {
+ const response = await request(ADMIN_METRICS_PATH, undefined, "Metrics are temporarily unavailable. Try again.");
+ if (response.status === 401) return { status: "signed-out" };
+ if (response.status === 404) return { status: "not-found" };
+ if (!response.ok) throw await refused(response);
+ return { status: "ok", body: (await response.json()) as AdminMetricsBody };
+}
diff --git a/hosted/src/main.tsx b/hosted/src/main.tsx
index ebc5ee2e7..61f075828 100644
--- a/hosted/src/main.tsx
+++ b/hosted/src/main.tsx
@@ -1,10 +1,13 @@
import { createRoot } from "react-dom/client";
+import { AdminMetrics, isAdminMetricsPath } from "./AdminMetrics";
import { App } from "./App";
import { takeEnrollment } from "./enrollment";
import "./index.css";
-// Taken before anything renders; a later fragment change on `/enroll` is App's.
-const enrollment = takeEnrollment();
-createRoot(document.getElementById("root")!).render(
- ,
-);
+const root = createRoot(document.getElementById("root")!);
+if (isAdminMetricsPath(location.pathname)) root.render();
+else {
+ // Taken before anything renders; a later fragment change on `/enroll` is App's.
+ const enrollment = takeEnrollment();
+ root.render();
+}
diff --git a/hosted/src/stories/AdminMetrics.stories.tsx b/hosted/src/stories/AdminMetrics.stories.tsx
new file mode 100644
index 000000000..55fa89b9c
--- /dev/null
+++ b/hosted/src/stories/AdminMetrics.stories.tsx
@@ -0,0 +1,66 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import type { AdminMetricsBody, MetricRow } from "../../server/metric-labels";
+import { AdminMetricsPage, type AdminMetricsState } from "../AdminMetrics";
+import { HostedFrame } from "./HostedFrame";
+import { settledFrame } from "./canvas";
+
+const TODAY = "2026-10-28";
+const day = (back: number) => {
+ const date = new Date(`${TODAY}T00:00:00Z`);
+ date.setUTCDate(date.getUTCDate() - back);
+ return date.toISOString().slice(0, 10);
+};
+
+/** A launch month: counts that grow toward today, a quiet weekend, a day with none. */
+const recent: MetricRow[] = Array.from({ length: 30 }, (_, i) => 29 - i).flatMap((back): MetricRow[] => {
+ const grown = 30 - back;
+ if (back === 12) return [];
+ return [
+ { day: day(back), event: "enroll.approved", label: "remote", count: 1 + (grown % 4) },
+ ...(back % 3 === 0 ? [{ day: day(back), event: "enroll.approved" as const, label: "voice", count: 1 }] : []),
+ { day: day(back), event: "burrow.enrolled", label: "", count: 1 + (grown % 3) },
+ { day: day(back), event: "voice.speak", label: "ok", count: grown * 9 },
+ ...(back < 6 ? [{ day: day(back), event: "voice.speak" as const, label: "capped", count: 6 - back }] : []),
+ ...(back === 20 ? [{ day: day(back), event: "voice.speak" as const, label: "error", count: 14 }] : []),
+ ...(back < 6 ? [{ day: day(back), event: "voice.fallback-cap" as const, label: "", count: 1 }] : []),
+ { day: day(back), event: "push.sent", label: "", count: grown * 31 + (back % 7 < 2 ? 0 : 40) },
+ { day: day(back), event: "pocket.signin", label: "", count: Math.ceil(grown / 2) },
+ ];
+});
+const totals = Object.values(
+ recent.reduce>>((sums, { event, label, count }) => {
+ const key = `${event}/${label}`;
+ sums[key] = { event, label, count: (sums[key]?.count ?? 0) + count * 2 };
+ return sums;
+ }, {}),
+);
+const BODY: AdminMetricsBody = { days: 30, today: TODAY, recent, totals };
+
+function AdminMetricsStory({ state, phone, light }: { state: AdminMetricsState; phone?: boolean; light?: boolean }) {
+ return (
+
+ {}} />
+
+ );
+}
+
+const meta = {
+ title: "Hosted/Admin metrics",
+ component: AdminMetricsStory,
+ parameters: { layout: "fullscreen" },
+ args: { state: { kind: "ok", body: BODY } },
+ play: async ({ canvasElement }) => {
+ await settledFrame(canvasElement);
+ },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const Counts: Story = {};
+export const Phone: Story = { args: { phone: true } };
+export const Light: Story = { args: { light: true } };
+export const NothingCounted: Story = { args: { state: { kind: "ok", body: { ...BODY, recent: [], totals: [] } } } };
+export const Loading: Story = { args: { state: { kind: "loading" } } };
+export const SignedOut: Story = { args: { state: { kind: "signed-out" } } };
+export const NotFound: Story = { args: { state: { kind: "not-found" } } };
+export const Unavailable: Story = { args: { state: { kind: "failed", message: "Metrics are temporarily unavailable. Try again." } } };
From d6d7c5258db0235f66e4684bd29ef72e711fc036 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 00:28:00 -0700
Subject: [PATCH 4/4] Specs and Privacy: Hosted's daily counts and link refs
hosted.md gains a Metrics section (the events, labels refused off-list,
best-effort writes); security-hosted.md a FAIL IF (S278) on what a row may
hold and who reads the admin route, with cookieAdmin on the account-boundary
inspect list; pricing.md the ref every Hosted page link carries. Privacy
names each kind of count and says a link's label is stripped, never counted.
A canary patch takes the new migration pin as context.
Co-Authored-By: Claude Opus 5.5
---
.../passkey-removal-keeps-sessions.patch | 18 +++++++---------
docs/specs/hosted.md | 21 ++++++++++++++++++-
docs/specs/pricing.md | 2 ++
docs/specs/security-hosted.md | 9 ++++----
scripts/security-rule-ids.json | 2 +-
scripts/spec-word-budgets.json | 4 ++--
website/src/pages/Privacy.tsx | 2 +-
7 files changed, 39 insertions(+), 19 deletions(-)
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/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/scripts/security-rule-ids.json b/scripts/security-rule-ids.json
index fe4337260..7771d39b4 100644
--- a/scripts/security-rule-ids.json
+++ b/scripts/security-rule-ids.json
@@ -1,5 +1,5 @@
{
- "next": 278,
+ "next": 279,
"retired": [
265
]
diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index 8249db967..d00038f6f 100644
--- a/scripts/spec-word-budgets.json
+++ b/scripts/spec-word-budgets.json
@@ -12,7 +12,7 @@
"docs/specs/dor-tools-builtin.md": 1750,
"docs/specs/dor-tools-lib.md": 350,
"docs/specs/glossary.md": 2950,
- "docs/specs/hosted.md": 4700,
+ "docs/specs/hosted.md": 4850,
"docs/specs/layout.md": 7250,
"docs/specs/mobile-terminal-ui.md": 1250,
"docs/specs/mouse-and-clipboard.md": 4200,
@@ -26,7 +26,7 @@
"docs/specs/reopen.md": 1200,
"docs/specs/security-audit.md": 5400,
"docs/specs/security-ci.md": 3400,
- "docs/specs/security-hosted.md": 3150,
+ "docs/specs/security-hosted.md": 3250,
"docs/specs/security-local.md": 6300,
"docs/specs/security-remote.md": 7200,
"docs/specs/security-supply-chain.md": 1650,
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.