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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,22 +245,24 @@ Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccount

## Metrics

Hosted counts what its Workers serve in aggregate daily rows, server-side; no page or desktop sends any.
Hosted's Workers count what they serve in aggregate daily rows; no page or desktop sends any.

| Event | Labels | Counted when |
|---|---|---|
| `account.created` | none | a user row commits |
| `login` | `email`, `providerIds` | a sign-in's session commits, by method; never a link |
| `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 |
| `push.sent` | none | each delivery a send makes |
| `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.
- **Never count a label off its event's list** (`METRIC_LABELS`): the write throws. Row contents and readers: `docs/specs/security-hosted.md` -> "Account boundary".
- **Never fail or delay the request a metric counts**: the write is unawaited, kept alive by `waitUntil`, and only logs failure.
- Rows are kept forever (rationale). The database's UTC clock dates every row and the account Worker's `/admin/metrics` "today".

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`.
Source of truth: `hosted/server/metric-labels.ts`; `recordMetric` and `authMetricHooks` in `hosted/server/metrics.ts`; `hosted/server/dormouse-migrations/013_metrics.sql`; `AdminMetrics` in `hosted/src/AdminMetrics.tsx`.

## Development and release

Expand Down
4 changes: 4 additions & 0 deletions docs/specs/hosted.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,7 @@ A `RelayRoom` that opened a Postgres connection through Hyperdrive could not be
- A passkey outlives its Burrow (2026-10-20): Pocket signs in to every Burrow of the account with one passkey, retiring a later computer's setup code rather than registering again, and each Burrow's pairings pin that passkey, so removing the computer whose code added it would sign the phone out of the others and force it to pair with each again. The account page removes a passkey on its own instead, naming the computer that added it while that computer is enrolled.
- Confirmation digits (2026-10-20): the user code alone was passive, so a person who clicked a stranger's link and Approve without comparing handed that stranger an enrollment or a voice token. The digits make approving active, as pairing's are: only someone looking at the computer that asked can type them, and a link never carries them. They are derived rather than stored so begin still writes nothing, and checked at the poll because the account Worker never holds `RELAY_ENROLL_SECRET` (`docs/specs/security-hosted.md` -> "Origin boundary"); the account page therefore cannot say a typo was wrong, and the desktop reports it. One attempt at 1/100 per code, the approval limit bounding attempts per account. Desktops through v1.3.0 show no digits, so they cannot finish a Hosted approval: a path without digits would be a downgrade anyone could choose.
- Guessing: approving a well-formed code no Burrow holds enrolls nothing; to steal a pending enrollment the account would have to be the one approving its code, which is the flow itself.

## Metrics

Rows are kept forever because none describes anyone: a row holds a day, an event, a fixed label, and a count, never an identifier (`docs/specs/security-hosted.md` -> "Account boundary").
4 changes: 2 additions & 2 deletions hosted/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
"test:dom": "vitest run src"
},
"dependencies": {
"@pgstencil/auth": "^0.4.0",
"pgstencil": "^0.4.0",
"@pgstencil/auth": "^0.4.1",
"pgstencil": "^0.4.1",
"kysely": "^0.29.5",
"hono": "^4.13.8",
"@hono/node-server": "^2.0.10",
Expand Down
2 changes: 2 additions & 0 deletions hosted/server/account-auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { createBetterAuthWorker } from "@pgstencil/auth/better-auth-workers";
import { postmarkEmail } from "@pgstencil/auth/postmark";
import type { AccountEnv } from "./bindings";
import { authMetricHooks } from "./metrics";
import { authPolicy } from "./policy";

/** The account Worker's Better Auth handler, mailing through Postmark. */
export const auth = createBetterAuthWorker<AccountEnv>({
...authPolicy,
email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM),
hooks: (env) => authMetricHooks(env.HYPERDRIVE.connectionString),
});
2 changes: 2 additions & 0 deletions hosted/server/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createServer as createViteServer, type ViteDevServer } from "vite";
import { createAuthApp } from "@pgstencil/auth/better-auth";
import { developmentDatabase } from "pgstencil/database";
import { EmailDev, SystemTime } from "pgstencil";
import { authMetricHooks } from "./metrics";
import { authPolicy } from "./policy";
import { migrations } from "./migrations";
import { allowedDevRequest } from "./dev-host-guard";
Expand Down Expand Up @@ -58,6 +59,7 @@ const auth = createAuthApp({
origin,
secret: "dormouse-hosted-local-development-only",
email,
hooks: authMetricHooks(databaseUrl),
});
auth.app.get("/api/dev/emails", (c) =>
c.json(email.all().map(({ to, text }) => ({ to, text }))),
Expand Down
3 changes: 3 additions & 0 deletions hosted/server/metric-labels.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// Rules: docs/specs/hosted.md -> "Metrics". No I/O: the admin page's bundle
// imports this as well as the Workers.
import { providerIds } from "./providers.js";

/**
* Every event Hosted counts, and the only labels each may carry; `[""]` for
* one counted without a label.
*/
export const METRIC_LABELS = {
"account.created": [""],
login: ["email", ...providerIds],
"enroll.approved": ["remote", "voice"],
"burrow.enrolled": [""],
"voice.speak": ["ok", "capped", "error"],
Expand Down
27 changes: 21 additions & 6 deletions hosted/server/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Rules: docs/specs/hosted.md -> "Metrics". The labels: ./metric-labels.ts.
import type { AuthHooks } from "@pgstencil/auth/better-auth";
import type { Context } from "hono";
import { queryDatabase } from "pgstencil/postgres";
import {
Expand All @@ -12,7 +13,7 @@ import {
/**
* 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}.
* {@link recordMetric} or {@link authMetricHooks}.
*/
export async function countMetric(databaseUrl: string, event: MetricEvent, label = "", n = 1) {
await queryDatabase(
Expand All @@ -24,24 +25,38 @@ export async function countMetric(databaseUrl: string, event: MetricEvent, label
);
}

/** `write`, settled: it never rejects, and its failure is only logged. */
const loggedFailure = (what: string, write: Promise<unknown>) =>
write.then(
() => {},
(error: unknown) =>
console.error(`${what} not recorded: ${error instanceof Error ? error.message : "unknown error"}`),
);

/**
* 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<unknown>) {
const settled = write.then(
() => {},
(error: unknown) =>
console.error(`${what} not recorded: ${error instanceof Error ? error.message : "unknown error"}`),
);
const settled = loggedFailure(what, write);
try {
c.executionCtx.waitUntil(settled);
} catch {
// No ExecutionContext.
}
}

/**
* The account Worker's sign-up and login counts. pgstencil runs each hook
* after its write commits, never awaits it, and never fires one for linking a
* provider; the Workers adapter keeps it alive with `waitUntil`.
*/
export const authMetricHooks = (databaseUrl: string): AuthHooks => ({
userCreated: () => loggedFailure("Metric account.created", countMetric(databaseUrl, "account.created")),
login: ({ method }) => loggedFailure("Metric login", countMetric(databaseUrl, "login", method)),
});

/** {@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));
Expand Down
2 changes: 2 additions & 0 deletions hosted/server/preview-worker.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { createBetterAuthWorker } from "@pgstencil/auth/better-auth-workers";
import { accountApp } from "./account-app";
import { accountPreviewBindings, type AccountEnv } from "./bindings";
import { authMetricHooks } from "./metrics";
import { authPolicy } from "./policy";
import { postgresInbox, inboxPage, messagePage } from "./preview-inbox";

/** The account Worker's PR preview: mail lands in the preview database's inbox. */
const auth = createBetterAuthWorker<AccountEnv>({
...authPolicy,
email: (env) => postgresInbox(env.HYPERDRIVE.connectionString),
hooks: (env) => authMetricHooks(env.HYPERDRIVE.connectionString),
});
export default accountApp(
auth.fetch,
Expand Down
14 changes: 13 additions & 1 deletion hosted/server/tests/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { authMetricHooks, bestEffort, countMetric, readMetrics } from "../metrics";
import { workerDatabases } from "./worker-roles";

// docs/specs/hosted.md -> "Metrics": aggregate daily counts under fixed
Expand Down Expand Up @@ -143,3 +143,15 @@ test("a metric's write never fails or delays the response it rides on, and its f
error.mockRestore();
}
});

test("the auth hooks never reject: a login by a method off the list is only logged", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// pgstencil's providers include one Hosted never enables.
await expect(authMetricHooks(context.database.url).login!({ method: "facebook" })).resolves.toBeUndefined();
expect(error).toHaveBeenCalledWith('Metric login not recorded: login takes no label "facebook"');
expect(await today("login", "facebook")).toBe(0);
} finally {
error.mockRestore();
}
});
33 changes: 23 additions & 10 deletions hosted/server/tests/workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,8 +525,17 @@ test("independent logins, current-browser logout, 24-hour expiry and private coo
expect(await second.session()).toBeNull();
});

/** 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.for(providerIds)(
"%s: callback, replay and explicit linking",
"%s: callback, replay and explicit linking; counted as logins by method, never a link",
async (id, { onTestFinished }) => {
const f = await fixture();
onTestFinished(f.close);
Expand Down Expand Up @@ -556,6 +565,14 @@ test.for(providerIds)(
expect(
(await owner.request(linked.path)).headers.get("location"),
).toContain("/login?error=");
// One account; its email login and its provider login, but not the link.
await vi.waitFor(async () =>
expect([
await counted(f.database.url, "account.created"),
await counted(f.database.url, "login", "email"),
await counted(f.database.url, "login", id),
]).toEqual([1, 1, 1]),
);
},
);

Expand Down Expand Up @@ -1012,15 +1029,6 @@ 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,
}) => {
Expand Down Expand Up @@ -1094,6 +1102,11 @@ test("the admin page: its read answers only the verified admin, from this origin
await other.email("other@example.test");
expect((await read(other)).status).toBe(404);
await admin.email(ADMIN_EMAIL);
// Only the row inserted below, once both sign-ins' counts have landed.
await vi.waitFor(async () =>
expect([await counted(f.database.url, "account.created"), await counted(f.database.url, "login", "email")]).toEqual([2, 2]),
);
await queryDatabase(f.database.url, "DELETE FROM dormouse_metrics_daily");
await queryDatabase(
f.database.url,
`INSERT INTO dormouse_metrics_daily (day, event, label, count)
Expand Down
2 changes: 2 additions & 0 deletions hosted/src/AdminMetrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { HostedShell } from "./HostedShell";
export const isAdminMetricsPath = (path: string) => path === "/admin/metrics" || path === "/admin/metrics/";

const EVENT_TITLES = {
"account.created": "Accounts created",
login: "Logins",
"enroll.approved": "Approvals",
"burrow.enrolled": "Computers enrolled",
"voice.speak": "Natural Voice requests",
Expand Down
2 changes: 2 additions & 0 deletions hosted/src/admin-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ describe("summarize", () => {
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([
"account.created",
"login",
"enroll.approved",
"burrow.enrolled",
"voice.speak",
Expand Down
5 changes: 5 additions & 0 deletions hosted/src/stories/AdminMetrics.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const recent: MetricRow[] = Array.from({ length: 30 }, (_, i) => 29 - i).flatMap
const grown = 30 - back;
if (back === 12) return [];
return [
{ day: day(back), event: "account.created", label: "", count: 1 + (grown % 5) },
{ day: day(back), event: "login", label: "email", count: grown * 2 },
{ day: day(back), event: "login", label: "github", count: grown * 3 },
...(back % 2 === 0 ? [{ day: day(back), event: "login" as const, label: "google", count: grown }] : []),
...(back === 3 ? [{ day: day(back), event: "login" as const, label: "apple", count: 1 }] : []),
{ 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) },
Expand Down
Loading
Loading