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
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,13 @@ 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. 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.
Expand Down
17 changes: 11 additions & 6 deletions cloudflare.config.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions scripts/check-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
6 changes: 4 additions & 2 deletions src/content/docs/docs/get-started/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 11 additions & 6 deletions src/content/docs/docs/help/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,17 @@ 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, 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
[without a Google login](/docs/help/no-login) and the
[privacy page](/privacy).

### What does --no-diagnostics turn off?

Expand Down
16 changes: 10 additions & 6 deletions src/content/docs/docs/help/no-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/content/docs/docs/help/versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/content/docs/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/content/docs/docs/tools/terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/lib/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
19 changes: 11 additions & 8 deletions src/pages/privacy.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import PolicyPage from '../components/PolicyPage.astro';
import { site } from '../lib/site';
---

<PolicyPage path="/privacy" heading="Privacy" updated="2026-11-02">
<PolicyPage path="/privacy" heading="Privacy" updated="2026-11-14">
<Fragment slot="lede">
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.
</Fragment>

<h2 id="site">This website</h2>
Expand All @@ -32,16 +32,19 @@ import { site } from '../lib/site';

<h2 id="firenook">Firenook</h2>
<p>
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 <code>.firenook</code> 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 <code>.firenook</code> folder.
</p>
<p>It reaches the network in two cases:</p>
<ul>
<li>
<strong>A check for a newer release.</strong> 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 <code>FIRENOOK_NO_UPDATE_CHECK=1</code> or{' '}
<code>NO_UPDATE_NOTIFIER=1</code> turns it off anywhere.
<strong>A check for a newer release.</strong> 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{' '}
<code>darwin-arm64</code>), 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.
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 <code>FIRENOOK_NO_UPDATE_CHECK=1</code>,{' '}
<code>NO_UPDATE_NOTIFIER=1</code> or <code>DO_NOT_TRACK=1</code> turns it off anywhere.
</li>
<li>
<strong>An Extension from the registry,</strong> fetched once the first time it runs. <code>npx firenook ext:vendor</code>
Expand Down
123 changes: 123 additions & 0 deletions src/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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 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 {
exec(query: string, ...bindings: unknown[]): { toArray(): Record<string, unknown>[] };
}

interface CounterState {
storage: { sql: Sql };
}

interface CounterStub {
fetch(input: string, init?: RequestInit): Promise<Response>;
}

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 {
waitUntil(promise: Promise<unknown>): 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<Response> {
if (request.method === 'POST') {
const { day, version, platform } = (await request.json()) as Record<string, string>;
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=<version>&p=<platform>: npm's version tags, counted. */
export async function version(request: Request, env: UsageEnv, context: Context): Promise<Response> {
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' },
});
}

/** 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<Response> {
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<string, { day: string; checks: number; versions: Record<string, number>; platforms: Record<string, number> }>();
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': 'private, no-store' } },
);
}
Loading
Loading