From 670a2cb9a43557075f4c1b02528fa693505fef0f Mon Sep 17 00:00:00 2001 From: Sanjevi Rau Date: Sat, 14 Nov 2026 16:01:18 +0800 Subject: [PATCH 1/2] feat(site): answer and count Firenook's release check at /api/version From 0.2.5 the npm package asks firenook.dev for firenook's version tags instead of the npm registry. /api/version answers npm's tags, cached, and adds one to the day's count for the version and platform the request names, in one SQLite Durable Object; a request naming anything else is answered and not counted. Nothing else is kept: no address, id or project. /api/usage answers the last 90 days' totals by version and platform. The privacy page, the FAQ and the docs that describe the check say what is counted and how to turn it off (now DO_NOT_TRACK too), and the link check accepts the Worker's two addresses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01APfZAFCQzCmvhJqWKMT47g --- AGENTS.md | 8 +- cloudflare.config.ts | 17 ++- scripts/check-site.mjs | 4 + src/content/docs/docs/get-started/install.mdx | 6 +- src/content/docs/docs/help/faq.md | 18 ++- src/content/docs/docs/help/no-login.md | 16 ++- src/content/docs/docs/help/versions.md | 6 +- .../docs/docs/reference/configuration.md | 5 +- src/content/docs/docs/tools/terminal.md | 8 +- src/lib/pages.ts | 4 +- src/pages/privacy.astro | 20 ++-- src/usage.ts | 108 ++++++++++++++++++ src/worker.ts | 16 ++- 13 files changed, 191 insertions(+), 45 deletions(-) create mode 100644 src/usage.ts diff --git a/AGENTS.md b/AGENTS.md index 7cb2d1b..65ece4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,8 +84,12 @@ npm run deploy # cf deploy --prebuilt; CI does this on every push to main ``` The site is the files in `dist/`, served as static assets, which cost -nothing. Its Worker's code (`src/worker.ts`) runs for `/docs/mcp` only -(`runWorkerFirst` in `cloudflare.config.ts`); keep every other address static. +nothing. Its Worker's code (`src/worker.ts`) runs for `/docs/mcp` and for +`/api/version` and `/api/usage`, the release check and its daily count +(`src/usage.ts`, one SQLite Durable Object; `runWorkerFirst` in +`cloudflare.config.ts`); keep every other address static. The count keeps a +day, a version and a platform, nothing else; `/privacy` says so, and must +change with it. Never add an adapter (`cf init` on this project adds the Astro Cloudflare adapter; don't run it). `www.firenook.dev` redirects to `firenook.dev` by a zone redirect rule; the Worker has no `workers.dev` address. diff --git a/cloudflare.config.ts b/cloudflare.config.ts index ef74ffd..62b9e73 100644 --- a/cloudflare.config.ts +++ b/cloudflare.config.ts @@ -1,12 +1,13 @@ // firenook.dev on Cloudflare: the files in dist/ (wrangler.config.ts), served -// without running code, and one Worker for one address: /docs/mcp, the docs' -// MCP server (src/worker.ts). Only requests under /docs/mcp reach it -// (runWorkerFirst); every other request is a static asset. /docs/install is +// without running code, and one Worker for three addresses: /docs/mcp, the +// docs' MCP server, and /api/version and /api/usage, the update check and its +// count, kept in one SQLite Durable Object (src/worker.ts, src/usage.ts). +// Only requests to those reach it (runWorkerFirst); every other request is a static asset. /docs/install is // served from docs/install.html, /docs/install.html redirects to // /docs/install, and an address with no file gets 404.html with a 404 // status. public/_headers sets the headers. www.firenook.dev is sent to // firenook.dev by a redirect rule. -import { bindings, defineConfig } from 'cf/config'; +import { bindings, defineConfig, exports } from 'cf/config'; export default defineConfig({ worker: { @@ -16,9 +17,13 @@ export default defineConfig({ assets: { htmlHandling: 'auto-trailing-slash', notFoundHandling: '404-page', - runWorkerFirst: ['/docs/mcp', '/docs/mcp/*'], + runWorkerFirst: ['/docs/mcp', '/docs/mcp/*', '/api/version', '/api/usage'], + }, + exports: { UsageCounter: exports.durableObject({ storage: 'sqlite' }) }, + env: { + ASSETS: bindings.assets(), + USAGE: bindings.durableObject({ worker: 'firenook-dev', exportName: 'UsageCounter' }), }, - env: { ASSETS: bindings.assets() }, domains: ['firenook.dev', 'www.firenook.dev'], // The site has one address: no copy on workers.dev. workersDev: false, diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs index 1dd900e..6114faf 100644 --- a/scripts/check-site.mjs +++ b/scripts/check-site.mjs @@ -23,9 +23,13 @@ function walk(dir) { const files = walk(dist); const pages = files.filter((file) => file.endsWith('.html')); +/** Addresses the Worker answers (src/worker.ts), which have no file. */ +const answered = new Set(['/api/usage', '/api/version']); + /** The file a site path is served from, as the host serves it. */ function target(path) { const clean = decodeURIComponent(path.replace(/[?#].*$/, '')); + if (answered.has(clean)) return clean; const candidates = clean === '/' ? ['index.html'] : [clean.slice(1), `${clean.slice(1)}.html`, `${clean.slice(1)}/index.html`]; return candidates.map((candidate) => join(dist, candidate)).find((file) => existsSync(file) && statSync(file).isFile()); } diff --git a/src/content/docs/docs/get-started/install.mdx b/src/content/docs/docs/get-started/install.mdx index 7fdaafb..ab5ae0c 100644 --- a/src/content/docs/docs/get-started/install.mdx +++ b/src/content/docs/docs/get-started/install.mdx @@ -128,8 +128,10 @@ Nothing else is downloaded, at install or at start: no Java, no firebase-tools, no emulator archives. The one exception is an Extension from the registry, which is fetched once, on first use; `firenook ext:vendor` copies it into the project so that even that works offline. In a terminal, -Firenook also asks the npm registry once a day whether a newer release is out. -It never asks in CI, and `FIRENOOK_NO_UPDATE_CHECK=1` turns the check off. +Firenook also asks once a day whether a newer release is out (firenook.dev +from 0.2.5, which counts the day's checks; see [privacy](/privacy)). It never +asks in CI, and `FIRENOOK_NO_UPDATE_CHECK=1` or `DO_NOT_TRACK=1` turns the +check off. ## Keep its working data out of Git diff --git a/src/content/docs/docs/help/faq.md b/src/content/docs/docs/help/faq.md index 0be4514..7b1ee8e 100644 --- a/src/content/docs/docs/help/faq.md +++ b/src/content/docs/docs/help/faq.md @@ -168,12 +168,18 @@ the official emulator imports. ### Does Firenook send telemetry? -No. Firenook sends no usage data, and the console loads nothing from the -internet. Three things can use the network: the release check, which asks -the npm registry for Firenook's version tags at most once a day in a -terminal; an Extension from the registry, on its first start; and your own -functions' code. Set `FIRENOOK_NO_UPDATE_CHECK=1` to turn the release check -off. See [without a Google login](/docs/help/no-login). +No telemetry, crash reports or anything about your project, and the console +loads nothing from the internet. One thing is counted: from 0.2.5, the +release check, which asks firenook.dev for Firenook's version tags at most +once a day in a terminal, names your Firenook version and platform, and +firenook.dev adds one to that day's count. No address, id or project is +kept; the daily totals are public at +[firenook.dev/api/usage](https://firenook.dev/api/usage). Three things can +use the network: that check, an Extension from the registry on its first +start, and your own functions' code. Set `FIRENOOK_NO_UPDATE_CHECK=1` or +`DO_NOT_TRACK=1` to turn the release check off. See +[without a Google login](/docs/help/no-login) and the +[privacy page](/privacy). ### What does --no-diagnostics turn off? diff --git a/src/content/docs/docs/help/no-login.md b/src/content/docs/docs/help/no-login.md index 9558c07..7180eaf 100644 --- a/src/content/docs/docs/help/no-login.md +++ b/src/content/docs/docs/help/no-login.md @@ -64,12 +64,16 @@ process, so the request never leaves your machine. The emulators, the console and the terminal work offline. The console loads nothing from the internet and sends nothing anywhere. Firenook sends no -telemetry. Three things can still use the network: - -- **The release check.** In a terminal, the npm package asks the npm registry - for `firenook`'s version tags at most once a day, in the background, and - shows a newer release on the next start. It is off in CI, in the plain - output, and with `FIRENOOK_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`. +telemetry and nothing about your project. Three things can still use the +network: + +- **The release check.** In a terminal, the npm package asks for + `firenook`'s version tags at most once a day, in the background, and shows + a newer release on the next start. From 0.2.5 it asks firenook.dev, which + counts the checks of each day by Firenook version and platform and keeps + nothing else, and the npm registry when firenook.dev does not answer. It is + off in CI, in the plain output, and with `FIRENOOK_NO_UPDATE_CHECK=1`, + `NO_UPDATE_NOTIFIER=1` or `DO_NOT_TRACK=1`. - **An Extension from the registry**, on its first start only (see below). - **Your own code.** Firenook is not a network sandbox. A function that calls a payment provider, an email service or any other API reaches it, as diff --git a/src/content/docs/docs/help/versions.md b/src/content/docs/docs/help/versions.md index bb1bb7b..d63ac59 100644 --- a/src/content/docs/docs/help/versions.md +++ b/src/content/docs/docs/help/versions.md @@ -38,11 +38,11 @@ npm records it as `^0.2.4`: an update can take a later `0.2` release, never `0.3` or a preview. To install one release in particular, name it, as in `npm install --save-dev firenook@0.2.4`. -Firenook never upgrades itself. In a terminal, it asks the npm registry for -the version tags at most once a day, in the background, and the next start +Firenook never upgrades itself. In a terminal, it asks for the version tags +(from firenook.dev from 0.2.5, the npm registry before) at most once a day, in the background, and the next start shows a newer release on your channel with the install command for your package manager. The check is off in CI, in the plain output, and with -`FIRENOOK_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`. +`FIRENOOK_NO_UPDATE_CHECK=1`, `NO_UPDATE_NOTIFIER=1` or `DO_NOT_TRACK=1`. To upgrade, stop the emulators first, then install the new release with `npm install --save-dev firenook@latest`. Never replace the package while diff --git a/src/content/docs/docs/reference/configuration.md b/src/content/docs/docs/reference/configuration.md index 5dfc3de..452b2a5 100644 --- a/src/content/docs/docs/reference/configuration.md +++ b/src/content/docs/docs/reference/configuration.md @@ -193,10 +193,11 @@ in `engines.node`, such as `>=22`, is read as npm reads it. | `WT_SESSION` | Set by Windows Terminal; without it, the Windows console gets plain characters | | `FIRENOOK_NO_UPDATE_CHECK=1` | No release check | | `NO_UPDATE_NOTIFIER=1` | No release check | +| `DO_NOT_TRACK=1` | No release check (from 0.2.5) | | `XDG_CACHE_HOME`, `LOCALAPPDATA` | Where the release check keeps its last answer, in `firenook/update-check.json`. Defaults: `~/Library/Caches` on macOS, `%LOCALAPPDATA%` on Windows, `~/.cache` elsewhere | -`FIRENOOK_PLAIN`, `FIRENOOK_ASCII`, `CI`, `FIRENOOK_NO_UPDATE_CHECK` and -`NO_UPDATE_NOTIFIER` count as set unless they are empty, `0` or `false`, so +`FIRENOOK_PLAIN`, `FIRENOOK_ASCII`, `CI`, `FIRENOOK_NO_UPDATE_CHECK`, +`NO_UPDATE_NOTIFIER` and `DO_NOT_TRACK` count as set unless they are empty, `0` or `false`, so `CI=false` is not CI. ### Extensions diff --git a/src/content/docs/docs/tools/terminal.md b/src/content/docs/docs/tools/terminal.md index 232c4bc..8b1b4f4 100644 --- a/src/content/docs/docs/tools/terminal.md +++ b/src/content/docs/docs/tools/terminal.md @@ -420,14 +420,16 @@ folder that already has a `firebase.json`, the card shows what ## Release check -Once a day at most, in the background, Firenook asks the npm registry for -its latest version tags. The answer shows on the next start, as the Update +Once a day at most, in the background, Firenook asks for its latest version +tags: from 0.2.5 it asks firenook.dev, which answers npm's tags and counts +the day's checks by version and platform (see [privacy](/privacy)), and the +npm registry when firenook.dev does not answer. The answer shows on the next start, as the Update row of the ready summary, so the check never slows a start down. It follows the channel of the version you have: `latest` for a release such as 0.2.4, and `next` for a preview, which also hears of a later release on `latest`. It gives the install command for your package manager, from your lockfile. It never runs in plain output, in CI, or with -`FIRENOOK_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`. As everywhere, a +`FIRENOOK_NO_UPDATE_CHECK=1`, `NO_UPDATE_NOTIFIER=1` or `DO_NOT_TRACK=1`. As everywhere, a variable set to nothing, `0` or `false` is not set: `CI=false` is not CI. ## Next diff --git a/src/lib/pages.ts b/src/lib/pages.ts index 5a94bb9..9971c3b 100644 --- a/src/lib/pages.ts +++ b/src/lib/pages.ts @@ -83,8 +83,8 @@ export const pages = { }, '/privacy': { title: 'Privacy · Firenook', - description: 'What firenook.dev and Firenook collect: the site counts visits without cookies, and Firenook sends no telemetry.', - card: { title: 'Privacy', subtitle: 'The site counts visits without cookies. Firenook sends no telemetry.' }, + description: 'What firenook.dev and Firenook collect: the site counts visits without cookies, and Firenook’s daily release check is counted by version and platform only.', + card: { title: 'Privacy', subtitle: 'The site counts visits without cookies. Firenook counts its daily release checks, nothing else.' }, crumb: 'Privacy', }, '/security': { diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index 74e4936..930354a 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -3,10 +3,10 @@ import PolicyPage from '../components/PolicyPage.astro'; import { site } from '../lib/site'; --- - + What firenook.dev and the Firenook package collect about you. In short: the site counts visits without cookies, and - Firenook sends no telemetry. + Firenook counts its daily release check by version and platform, and sends nothing else.

This website

@@ -32,16 +32,20 @@ import { site } from '../lib/site';

Firenook

- Firenook runs on your machine. It sends no telemetry, usage data or crash reports, and keeps your data, logs and - diagnostics on your machine, mostly in your project’s .firenook folder. + Firenook runs on your machine. It sends no telemetry, crash reports or anything about your project, and keeps your + data, logs and diagnostics on your machine, mostly in your project’s .firenook folder.

It reaches the network in two cases:

  • - A check for a newer release. At most once a day, the npm package asks the npm registry which versions of - firenook are current, in the background, and shows the answer on your next start. The request carries nothing about - you or your project beyond what any web request does. It is off in CI and in the plain output, and FIRENOOK_NO_UPDATE_CHECK=1 or{' '} - NO_UPDATE_NOTIFIER=1 turns it off anywhere. + A check for a newer release. At most once a day, the npm package asks which versions of firenook are + current, in the background, and shows the answer on your next start. From 0.2.5 it asks firenook.dev, and the npm + registry when firenook.dev does not answer. The request names your Firenook version and platform (such as{' '} + darwin-arm64), and firenook.dev adds one to that day’s count for them. Nothing else is kept: not your + address, not an id, nothing about your project, so the count says how many machines checked that day, never which. + The daily totals are public at firenook.dev/api/usage. Before 0.2.5 the check asked the npm registry + and nothing was counted. It is off in CI and in the plain output, and FIRENOOK_NO_UPDATE_CHECK=1,{' '} + NO_UPDATE_NOTIFIER=1 or DO_NOT_TRACK=1 turns it off anywhere.
  • An Extension from the registry, fetched once the first time it runs. npx firenook ext:vendor diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000..92f68fd --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,108 @@ +// The count of Firenook's update checks (from firenook 0.2.5). The npm +// package asks /api/version which versions are current at most once a day on +// a machine, never in CI or the plain output, and never when +// FIRENOOK_NO_UPDATE_CHECK, NO_UPDATE_NOTIFIER or DO_NOT_TRACK is set. This +// Worker answers with npm's version tags for firenook, as the registry would, +// and adds one to that day's count for the version and platform the request +// names. Nothing else is kept: no address, no id, no project. /api/usage +// answers the counts of the last 90 days. + +/** The Durable Object's SQLite storage, as much of it as this file uses. */ +interface Sql { + exec(query: string, ...bindings: unknown[]): { toArray(): Record[] }; +} + +interface CounterState { + storage: { sql: Sql }; +} + +interface CounterStub { + fetch(input: string, init?: RequestInit): Promise; +} + +export interface UsageEnv { + USAGE: { idFromName(name: string): unknown; get(id: unknown): CounterStub }; +} + +interface Context { + waitUntil(promise: Promise): void; +} + +const REGISTRY = 'https://registry.npmjs.org/-/package/firenook/dist-tags'; +/** A release's version, as the package sends it. */ +const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}(?:-[0-9A-Za-z.]{1,32})?$/; +/** The platforms Firenook publishes an engine for. */ +const PLATFORMS = new Set(['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']); +const DAYS = 90; + +/** One counter for the whole site: a few thousand checks a day at most. */ +const counter = (env: UsageEnv) => env.USAGE.get(env.USAGE.idFromName('update-checks')); + +export class UsageCounter { + private readonly sql: Sql; + + constructor(state: CounterState) { + this.sql = state.storage.sql; + this.sql.exec( + 'CREATE TABLE IF NOT EXISTS checks (day TEXT NOT NULL, version TEXT NOT NULL, platform TEXT NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (day, version, platform))', + ); + } + + async fetch(request: Request): Promise { + if (request.method === 'POST') { + const { day, version, platform } = (await request.json()) as Record; + this.sql.exec( + 'INSERT INTO checks VALUES (?, ?, ?, 1) ON CONFLICT (day, version, platform) DO UPDATE SET count = count + 1', + day, + version, + platform, + ); + return new Response(null, { status: 204 }); + } + const since = new URL(request.url).searchParams.get('since') ?? ''; + const rows = this.sql + .exec('SELECT day, version, platform, count FROM checks WHERE day >= ? ORDER BY day, version, platform', since) + .toArray(); + return Response.json(rows); + } +} + +const today = (offset = 0) => new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10); + +/** GET /api/version?v=&p=: npm's version tags, counted. */ +export async function version(request: Request, env: UsageEnv, context: Context): Promise { + const query = new URL(request.url).searchParams; + const named = query.get('v') ?? ''; + const platform = query.get('p') ?? ''; + // Only what a release sends is counted, so the table holds no free text. + if (VERSION.test(named) && PLATFORMS.has(platform)) { + const body = JSON.stringify({ day: today(), version: named, platform }); + context.waitUntil(counter(env).fetch('https://counter/', { method: 'POST', body })); + } + const tags = await fetch(REGISTRY, { cf: { cacheTtl: 300, cacheEverything: true } } as RequestInit); + if (!tags.ok) return new Response('The npm registry did not answer.\n', { status: 502 }); + return new Response(await tags.text(), { + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }); +} + +/** GET /api/usage: each day's checks, by version and by platform. */ +export async function usage(env: UsageEnv): Promise { + const response = await counter(env).fetch(`https://counter/?since=${today(DAYS - 1)}`); + const rows = (await response.json()) as { day: string; version: string; platform: string; count: number }[]; + const days = new Map; platforms: Record }>(); + for (const row of rows) { + const entry = days.get(row.day) ?? { day: row.day, checks: 0, versions: {}, platforms: {} }; + entry.checks += row.count; + entry.versions[row.version] = (entry.versions[row.version] ?? 0) + row.count; + entry.platforms[row.platform] = (entry.platforms[row.platform] ?? 0) + row.count; + days.set(row.day, entry); + } + return Response.json( + { + about: 'Firenook update checks per day: one machine checks at most once a day, never in CI. https://firenook.dev/privacy', + days: [...days.values()], + }, + { headers: { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' } }, + ); +} diff --git a/src/worker.ts b/src/worker.ts index 4401833..91ae56b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,14 +1,18 @@ -// The one address on firenook.dev that runs code: /docs/mcp, an MCP server -// over the docs (Streamable HTTP, without sessions). Everything else is a -// static file and never reaches this Worker (assets.runWorkerFirst in +// The addresses on firenook.dev that run code: /docs/mcp, an MCP server +// over the docs (Streamable HTTP, without sessions), and /api/version and +// /api/usage, the update check and its count (src/usage.ts). Everything else +// is a static file and never reaches this Worker (assets.runWorkerFirst in // cloudflare.config.ts). The pages it reads are the build's: each page's // Markdown, llms.txt, and the search index the build writes beside it // (src/lib/agent-files.mjs). import { docsMcp, docsMcpInstructions, docsTools, serverCard } from './lib/agents'; import { site } from './lib/site'; +import { type UsageEnv, usage, version } from './usage'; -interface Env { +export { UsageCounter } from './usage'; + +interface Env extends UsageEnv { ASSETS: { fetch(input: Request | URL | string): Promise }; } @@ -230,8 +234,10 @@ async function mcp(request: Request, env: Env): Promise { } export default { - async fetch(request: Request, env: Env): Promise { + async fetch(request: Request, env: Env, context: { waitUntil(promise: Promise): void }): Promise { const { pathname } = new URL(request.url); + if (pathname === '/api/version' && request.method === 'GET') return version(request, env, context); + if (pathname === '/api/usage' && request.method === 'GET') return usage(env); if (pathname === docsMcp.path) return mcp(request, env); if (pathname === `${docsMcp.path}/server-card`) return json(serverCard, 200, 'application/mcp-server-card+json'); // Nothing else is served under /docs/mcp, the search index included. From fc9f4aa23c43669b07f4d8a80fa7e5d83f5436d5 Mon Sep 17 00:00:00 2001 From: Sanjevi Rau Date: Sat, 14 Nov 2026 16:23:37 +0800 Subject: [PATCH 2/2] feat(site): keep the release check's totals private /api/usage answers the daily totals only to a request carrying the Worker's USAGE_TOKEN secret, compared in constant time, and 404 to any other, and to every request while the secret is not set. The privacy page and the FAQ no longer point at public totals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01APfZAFCQzCmvhJqWKMT47g --- AGENTS.md | 3 ++- src/content/docs/docs/help/faq.md | 3 +-- src/pages/privacy.astro | 3 +-- src/usage.ts | 23 +++++++++++++++++++---- src/worker.ts | 2 +- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 65ece4f..510f0dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,8 @@ nothing. Its Worker's code (`src/worker.ts`) runs for `/docs/mcp` and for (`src/usage.ts`, one SQLite Durable Object; `runWorkerFirst` in `cloudflare.config.ts`); keep every other address static. The count keeps a day, a version and a platform, nothing else; `/privacy` says so, and must -change with it. +change with it. The totals are private: `/api/usage` answers only a request +carrying the Worker's `USAGE_TOKEN` secret, and 404 while it is not set. Never add an adapter (`cf init` on this project adds the Astro Cloudflare adapter; don't run it). `www.firenook.dev` redirects to `firenook.dev` by a zone redirect rule; the Worker has no `workers.dev` address. diff --git a/src/content/docs/docs/help/faq.md b/src/content/docs/docs/help/faq.md index 7b1ee8e..2cc1e80 100644 --- a/src/content/docs/docs/help/faq.md +++ b/src/content/docs/docs/help/faq.md @@ -173,8 +173,7 @@ loads nothing from the internet. One thing is counted: from 0.2.5, the release check, which asks firenook.dev for Firenook's version tags at most once a day in a terminal, names your Firenook version and platform, and firenook.dev adds one to that day's count. No address, id or project is -kept; the daily totals are public at -[firenook.dev/api/usage](https://firenook.dev/api/usage). Three things can +kept, and only the daily totals are seen. Three things can use the network: that check, an Extension from the registry on its first start, and your own functions' code. Set `FIRENOOK_NO_UPDATE_CHECK=1` or `DO_NOT_TRACK=1` to turn the release check off. See diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index 930354a..b78a3c5 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -43,8 +43,7 @@ import { site } from '../lib/site'; registry when firenook.dev does not answer. The request names your Firenook version and platform (such as{' '} darwin-arm64), and firenook.dev adds one to that day’s count for them. Nothing else is kept: not your address, not an id, nothing about your project, so the count says how many machines checked that day, never which. - The daily totals are public at firenook.dev/api/usage. Before 0.2.5 the check asked the npm registry - and nothing was counted. It is off in CI and in the plain output, and FIRENOOK_NO_UPDATE_CHECK=1,{' '} + Only we see the daily totals. Before 0.2.5 the check asked the npm registry and nothing was counted. It is off in CI and in the plain output, and FIRENOOK_NO_UPDATE_CHECK=1,{' '} NO_UPDATE_NOTIFIER=1 or DO_NOT_TRACK=1 turns it off anywhere.
  • diff --git a/src/usage.ts b/src/usage.ts index 92f68fd..3d9a9b3 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -5,7 +5,9 @@ // Worker answers with npm's version tags for firenook, as the registry would, // and adds one to that day's count for the version and platform the request // names. Nothing else is kept: no address, no id, no project. /api/usage -// answers the counts of the last 90 days. +// answers the counts of the last 90 days to a request carrying the Worker's +// USAGE_TOKEN secret, and 404 to any other (and to every request while the +// secret is not set): the totals are private. /** The Durable Object's SQLite storage, as much of it as this file uses. */ interface Sql { @@ -22,6 +24,8 @@ interface CounterStub { export interface UsageEnv { USAGE: { idFromName(name: string): unknown; get(id: unknown): CounterStub }; + /** The secret that reads /api/usage; unset, nothing reads it. */ + USAGE_TOKEN?: string; } interface Context { @@ -86,8 +90,19 @@ export async function version(request: Request, env: UsageEnv, context: Context) }); } -/** GET /api/usage: each day's checks, by version and by platform. */ -export async function usage(env: UsageEnv): Promise { +/** Whether `request` carries the USAGE_TOKEN secret, compared in constant time. */ +function reader(request: Request, env: UsageEnv): boolean { + const token = env.USAGE_TOKEN; + const given = request.headers.get('Authorization')?.replace(/^Bearer /, '') ?? ''; + if (!token || given.length !== token.length) return false; + const encoder = new TextEncoder(); + const subtle = crypto.subtle as SubtleCrypto & { timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean }; + return subtle.timingSafeEqual(encoder.encode(given), encoder.encode(token)); +} + +/** GET /api/usage: each day's checks, by version and by platform, for the token's holder. */ +export async function usage(request: Request, env: UsageEnv): Promise { + if (!reader(request, env)) return new Response('Not found\n', { status: 404 }); const response = await counter(env).fetch(`https://counter/?since=${today(DAYS - 1)}`); const rows = (await response.json()) as { day: string; version: string; platform: string; count: number }[]; const days = new Map; platforms: Record }>(); @@ -103,6 +118,6 @@ export async function usage(env: UsageEnv): Promise { about: 'Firenook update checks per day: one machine checks at most once a day, never in CI. https://firenook.dev/privacy', days: [...days.values()], }, - { headers: { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' } }, + { headers: { 'Cache-Control': 'private, no-store' } }, ); } diff --git a/src/worker.ts b/src/worker.ts index 91ae56b..94691fc 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -237,7 +237,7 @@ export default { async fetch(request: Request, env: Env, context: { waitUntil(promise: Promise): void }): Promise { const { pathname } = new URL(request.url); if (pathname === '/api/version' && request.method === 'GET') return version(request, env, context); - if (pathname === '/api/usage' && request.method === 'GET') return usage(env); + if (pathname === '/api/usage' && request.method === 'GET') return usage(request, env); if (pathname === docsMcp.path) return mcp(request, env); if (pathname === `${docsMcp.path}/server-card`) return json(serverCard, 200, 'application/mcp-server-card+json'); // Nothing else is served under /docs/mcp, the search index included.