From 862422e2384e5a0b1cf746ecde65b718f4fe2608 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 00:17:11 -0700 Subject: [PATCH 1/4] Hosted counts launch events in aggregate daily rows A dormouse_metrics_daily row is a UTC day, an event, one of that event's allowlisted labels, and a count: nothing that names an account. The relay and voice Workers write theirs on their own roles, best effort through waitUntil, so a failed write is logged and never changes or delays the answer it counts. Counted now: approvals by purpose, enrollments, speech by outcome and the call that spends the day's last, push deliveries, and Pocket sign-ins. GET /api/admin/metrics reads the last 30 days and all-time totals for the verified admin only. Co-Authored-By: Claude Opus 5.5 --- hosted/server/account-app.ts | 11 +- hosted/server/account-gate.ts | 22 ++- .../dormouse-migrations/013_metrics.sql | 16 ++ hosted/server/entitlement.ts | 20 ++- hosted/server/metric-labels.ts | 48 ++++++ hosted/server/metrics.ts | 76 +++++++++ hosted/server/relay-account.ts | 7 +- hosted/server/relay-api.ts | 3 + hosted/server/relay-push.ts | 2 + hosted/server/runtime-roles.sql | 5 + hosted/server/tests/metrics.test.ts | 145 ++++++++++++++++++ hosted/server/tests/migrations.test.ts | 2 + hosted/server/tests/relay.test.ts | 14 +- hosted/server/tests/runtime-roles.test.ts | 4 + hosted/server/tests/workers.test.ts | 118 +++++++++++++- hosted/server/voice.ts | 13 +- 16 files changed, 485 insertions(+), 21 deletions(-) create mode 100644 hosted/server/dormouse-migrations/013_metrics.sql create mode 100644 hosted/server/metric-labels.ts create mode 100644 hosted/server/metrics.ts create mode 100644 hosted/server/tests/metrics.test.ts 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" }, }); From c4b2327abf7f11a75b678ff1706aefd19195aaf5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 00:19:36 -0700 Subject: [PATCH 2/4] Links to the Hosted page name which link they are Every link to dormouse.sh/hosted carries ?ref= from one shared list (lib/src/lib/hosted-links.ts), ahead of its fragment: the Settings plans and voice links, the home page, the Pocket tutorial and playground, the self-host page, both READMEs, and the account page's no-plan offer. The Hosted page takes it off the address bar on arrival, so a copied link never carries it; nothing counts or sends it yet. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- hosted/src/App.test.tsx | 2 +- hosted/src/NoPlanOffer.tsx | 3 +- lib/src/components/HostedEnrollment.tsx | 18 ++++++----- .../components/ManagedVoiceSection.test.tsx | 24 ++++++++++++-- lib/src/components/ManagedVoiceSection.tsx | 3 +- .../components/RemoteControlSection.test.tsx | 5 ++- lib/src/components/SettingsDialog.test.tsx | 3 +- lib/src/components/SettingsDialog.tsx | 3 +- lib/src/lib/hosted-links.test.ts | 18 +++++++++++ lib/src/lib/hosted-links.ts | 31 +++++++++++++++++++ scripts/outbound-lint-selftest.mjs | 4 +-- scripts/public-docs-lint.mjs | 11 ++++--- vscode-ext/README.md | 4 +-- .../components/HostingRequirementNotice.tsx | 3 +- .../components/PocketTerminalExperience.tsx | 3 +- website/src/lib/hosted-ref.test.ts | 16 ++++++++++ website/src/lib/hosted-ref.ts | 20 ++++++++++++ website/src/pages/Home.tsx | 7 +++-- website/src/pages/Hosted.test.tsx | 17 ++++++++++ website/src/pages/Hosted.tsx | 7 +++++ website/src/pages/PocketPlayground.tsx | 3 +- 22 files changed, 175 insertions(+), 32 deletions(-) create mode 100644 lib/src/lib/hosted-links.test.ts create mode 100644 lib/src/lib/hosted-links.ts create mode 100644 website/src/lib/hosted-ref.test.ts create mode 100644 website/src/lib/hosted-ref.ts 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/hosted/src/App.test.tsx b/hosted/src/App.test.tsx index 3ec887613..a59da41e8 100644 --- a/hosted/src/App.test.tsx +++ b/hosted/src/App.test.tsx @@ -240,7 +240,7 @@ describe("an account with no plan", () => { expect(container.querySelector("#confirm-digits")).toBeNull(); expect(noPlanShown()).toBe(true); expect(button("Don’t approve").disabled).toBe(false); - const plans = container.querySelector('a[href="https://dormouse.sh/hosted/#pricing"]')!; + const plans = container.querySelector('a[href="https://dormouse.sh/hosted/?ref=hosted-no-plan#pricing"]')!; // In another tab, so this one keeps the code that lives only in memory. expect(plans.target).toBe("_blank"); // Natural Voice has no self-hosted equivalent; a Relay does. diff --git a/hosted/src/NoPlanOffer.tsx b/hosted/src/NoPlanOffer.tsx index b2c06d183..c1c13c581 100644 --- a/hosted/src/NoPlanOffer.tsx +++ b/hosted/src/NoPlanOffer.tsx @@ -6,10 +6,11 @@ import { CARD_MUTED_TEXT_CLASS, DASHED_ROW_CLASS, } from "dormouse-lib/site/docs-tokens"; +import { HOSTED_REFS, withHostedRef } from "dormouse-lib/lib/hosted-links"; import { foundingTier, LIST_MONTHLY } from "../../website/src/lib/hosted-pricing.ts"; /** The Hosted page's plan cards, in a new tab so this one keeps its code. */ -export const HOSTED_PLANS_URL = "https://dormouse.sh/hosted/#pricing"; +export const HOSTED_PLANS_URL = withHostedRef("https://dormouse.sh/hosted/", HOSTED_REFS.hostedNoPlan, "pricing"); const VOICE = "Natural Voice powered by ElevenLabs for spoken alarms"; const POCKET = "Dormouse Pocket on your phone through the Hosted Relay"; diff --git a/lib/src/components/HostedEnrollment.tsx b/lib/src/components/HostedEnrollment.tsx index a1ff805d2..f9abf93f2 100644 --- a/lib/src/components/HostedEnrollment.tsx +++ b/lib/src/components/HostedEnrollment.tsx @@ -4,18 +4,22 @@ import { ExternalTextLink } from './ExternalTextLink'; import { FIELD_HINT, FIELD_LABEL, hostOf, useBusyAction, useMinutesLeft } from './remote-control-shared'; import { ACCOUNT_PAGE_PATH, HOSTED_ACCOUNT_ORIGIN } from '../host/relay-origin'; import type { HostedEnrollmentEndReason, HostedEnrollmentState } from '../host/remote/service-protocol'; +import { HOSTED_REFS, withHostedRef, type HostedRef } from '../lib/hosted-links'; import { getPlatform } from '../lib/platform'; import { BURROW_IS_AN_APP } from '../remote/setup-copy'; import { beginHostedEnrollment, cancelHostedEnrollment } from '../remote/burrow/burrow-status-store'; -/** Where the plans are; linked wherever an account turns out to have none. */ -export const HOSTED_PRICING_URL = 'https://dormouse.sh/hosted/#pricing'; +const HOSTED_PAGE_URL = 'https://dormouse.sh/hosted/'; /** The Hosted page's voice section, the spoken-alarm row's link where the build has no managed voice. */ -export const HOSTED_VOICE_URL = 'https://dormouse.sh/hosted/#voice'; +export const HOSTED_VOICE_URL = withHostedRef(HOSTED_PAGE_URL, HOSTED_REFS.settingsVoice, 'voice'); -/** The link to {@link HOSTED_PRICING_URL}, the one way every no-plan line names the plans. */ -export function HostedPlansLink() { - return See Hosted plans; +/** + * The Hosted page's plans, the one way every no-plan line names them; `from` + * is the ref of the Settings section it sits in. (Not `ref`, which React + * keeps for itself.) + */ +export function HostedPlansLink({ from }: { from: HostedRef }) { + return See Hosted plans; } /** The one field the offer card and the typed form both ask for. */ @@ -119,7 +123,7 @@ export function HostedEnrollmentEnded({ {ended.reason === 'failed' && ended.message ?
{ended.message}
: null} {ended.reason === 'not-entitled' ? (
- +
) : null} {page ? ( diff --git a/lib/src/components/ManagedVoiceSection.test.tsx b/lib/src/components/ManagedVoiceSection.test.tsx index df827091a..c296d18fc 100644 --- a/lib/src/components/ManagedVoiceSection.test.tsx +++ b/lib/src/components/ManagedVoiceSection.test.tsx @@ -17,7 +17,7 @@ import { import { DEFAULT_RELAY_ORIGIN } from '../host/relay-origin'; import type { BurrowConsoleStatus } from '../host/remote/service-protocol'; import { networkPolicyResult, nothingPolicy } from '../remote/network-policy'; -import { HOSTED_APPROVAL_ENDED_COPY, HOSTED_ENROLLMENT_CODE_LABEL, HOSTED_PRICING_URL } from './HostedEnrollment'; +import { HOSTED_APPROVAL_ENDED_COPY, HOSTED_ENROLLMENT_CODE_LABEL } from './HostedEnrollment'; import { MANAGED_VOICE_DISCLOSURE, ManagedVoiceSection, NO_PLAN_COPY } from './ManagedVoiceSection'; import { AlarmSettingsSection } from './SettingsDialog'; @@ -121,7 +121,7 @@ describe('ManagedVoiceSection', () => { const openExternal = vi.fn(); Object.assign(getPlatform(), { openExternal }); await act(async () => button('See Hosted plans')!.click()); - expect(openExternal).toHaveBeenCalledWith(HOSTED_PRICING_URL); + expect(openExternal).toHaveBeenCalledWith('https://dormouse.sh/hosted/?ref=settings-voice#pricing'); }); it('offers voice independently when remote control is already enrolled', async () => { @@ -182,6 +182,18 @@ describe('ManagedVoiceSection', () => { ); expect(text()).toContain(HOSTED_APPROVAL_ENDED_COPY.voice.mismatch); expect(button('Turn on Natural Voice')).toBeDefined(); + + // A refusal for want of a plan links the plans, from the voice setting. + await act(async () => root.unmount()); + root = createRoot(container); + await render( + { status: UNENROLLED_STATUS }, + makeStubManagedVoicePort(false, false, { enrollment: { status: 'ended', reason: 'not-entitled' } }), + ); + const openExternal = vi.fn(); + Object.assign(getPlatform(), { openExternal }); + await act(async () => button('See Hosted plans')!.click()); + expect(openExternal).toHaveBeenCalledWith('https://dormouse.sh/hosted/?ref=settings-voice#pricing'); }); }); @@ -195,4 +207,12 @@ describe('the spoken-alarm copy', () => { await act(async () => root.render()); expect(text()).toContain(copy); }); + + it('links the Hosted page’s voice section from a build without the port', async () => { + const openExternal = vi.fn(); + setPlatform(Object.assign(new FakePtyAdapter(), { openExternal })); + await act(async () => root.render()); + await act(async () => button('Get ElevenLabs voices with Natural Voice.')!.click()); + expect(openExternal).toHaveBeenCalledWith('https://dormouse.sh/hosted/?ref=settings-voice#voice'); + }); }); diff --git a/lib/src/components/ManagedVoiceSection.tsx b/lib/src/components/ManagedVoiceSection.tsx index 873da592f..86873bed6 100644 --- a/lib/src/components/ManagedVoiceSection.tsx +++ b/lib/src/components/ManagedVoiceSection.tsx @@ -1,6 +1,7 @@ import { useCallback, useState, useSyncExternalStore } from 'react'; import { INLINE_ACTION_CLASS, SELECT_CLASS, modalActionButton } from './design'; import { HostedApprovalProgress, HostedPlansLink } from './HostedEnrollment'; +import { HOSTED_REFS } from '../lib/hosted-links'; import { FIELD_LABEL, useBusyAction, useNetworkPolicy } from './remote-control-shared'; import { getPlatform } from '../lib/platform'; import { @@ -81,7 +82,7 @@ export function ManagedVoiceSection({ onShowNetwork }: { onShowNetwork?: () => v <>
{voice.notEntitled ? ( - <>{NO_PLAN_COPY} + <>{NO_PLAN_COPY} ) : 'Natural Voice is on.'}