Compare commits
15 Commits
7ea654069c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a0e6ff1044 | |||
| 6fd0f84806 | |||
| c7071b0e32 | |||
| 7fde259e0b | |||
| 25ec84ceb4 | |||
| dd368d21e7 | |||
| 2fd2a4a71a | |||
|
|
1d9287a199 | ||
|
|
39d874a7e2 | ||
|
|
89f56fc78a | ||
|
|
d44133fcb3 | ||
|
|
578ee58f9c | ||
|
|
d2c2e6a4ee | ||
|
|
c718370674 | ||
|
|
210a7e6e75 |
@@ -22,3 +22,12 @@ a human reviews and moves it to `approved/` → the human publishes from `approv
|
|||||||
under their own account → the artifact (or a pointer) lands in `posted/`.
|
under their own account → the artifact (or a pointer) lands in `posted/`.
|
||||||
|
|
||||||
Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means.
|
Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means.
|
||||||
|
|
||||||
|
## The weekly read
|
||||||
|
|
||||||
|
`bun --env-file=.env scripts/weekly-metrics.ts` (or `bun run metrics:weekly`)
|
||||||
|
rewrites `metrics/weekly.md` from the live databases: the staged funnel, the
|
||||||
|
campaign breakdown (`getAnalyticsSummary.topCampaigns`' grouping), and the one
|
||||||
|
stage that moved. It needs `TURSO_DB_URL`, `TURSO_DB_TOKEN`, `NOOK_DB_URL` and
|
||||||
|
`NOOK_DB_TOKEN` — no other production secret. The file is generated; never
|
||||||
|
hand-edit it. The numbers are engineering's; the interpretation is the CMO's.
|
||||||
|
|||||||
@@ -1,391 +0,0 @@
|
|||||||
# `/faq` from the objections we already have
|
|
||||||
|
|
||||||
**Date:** 2026-10-29 · **Owner:** CMO · **Status:** draft — not approved, not published · **Source issue:** FRE-326
|
|
||||||
**Page:** `nook.freno.me/faq` · **Page source:** `freno-dev/src/routes/nook/faq.tsx` · **Site commit read:** `134f41e` (plus local `c1c498e`, remote-claim removal)
|
|
||||||
**Product commit read:** The-Nook `b5659415` (0.13.4, build 37)
|
|
||||||
|
|
||||||
Every entry below is traceable to a real question or a real report. Nothing is invented to fill a
|
|
||||||
category. No testimonial is used. No price, refund term, or umbrella/cross-promotion answer appears
|
|
||||||
here — those stay with FRE-204, the Chief of Staff and the sponsor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What I could read, and what I could not
|
|
||||||
|
|
||||||
| Source | What it is | Read on | What it yielded |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `nook.freno.me/faq` | The live page | 2026-10-29 | Six questions already published: privacy, notch, battery, permissions, macOS 14.2, offline. |
|
|
||||||
| FRE-326 description | This board issue | 2026-10-29 | Two purchase-blocking questions named verbatim: *"does it work with Cursor?"* and *"what happens when my agent is idle?"* |
|
|
||||||
| NOOK issue corpus | 948 issues imported from Plane, plus the 324-issue Paperclip board | 2026-10-08 → 2026-10-28 | User-observable defect reports. These are reports, not captured asks; they prove an objection is live. |
|
|
||||||
| `freno-dev/marketing/positioning.md` | Voice, approved claims, competitor ban | 2026-10-29 | The register and the "never name a competitor" rule. |
|
|
||||||
| The-Nook code | `AgentRegistry.swift`, `SessionModels.swift`, `Terminal*`, `hook/setup.go`, `PluginInstaller.swift`, `LicenseService.swift`, `main.swift` | 2026-10-29 | The technical facts each answer is allowed to use. |
|
|
||||||
| NOOK-121 | The original `/faq` creation (2026-10-08) | 2026-10-29 | Confirms the six live questions came from the strategy's named objections, not from a support corpus. Refund was deliberately dropped by Mike. |
|
|
||||||
|
|
||||||
### The gap that shapes this draft
|
|
||||||
|
|
||||||
**Support mail is not readable from this run.** There is no mailbox on the machine; the in-app
|
|
||||||
"Report a bug" form `POST`s to `freno-dev/src/routes/api/the-nook/report.ts`, which emails the
|
|
||||||
developer and stores nothing queryable. `/privacy` and the repo contain no support corpus.
|
|
||||||
|
|
||||||
**Reply threads are not collected.** `freno-dev/marketing/listening.md` is an empty table; FRE-201
|
|
||||||
(the listening job) has not produced a thread yet.
|
|
||||||
|
|
||||||
**Directory comments do not exist yet.** FRE-207 has a submission packet, not live listings; none of
|
|
||||||
the directories has a comment thread about The Nook to mine.
|
|
||||||
|
|
||||||
**Consequence:** I cannot rank by observed ask frequency, because no ask corpus exists. What follows
|
|
||||||
ranks by **purchase-blocking weight first**, then by **the number of named reports in our own issue
|
|
||||||
corpus that belong to the cluster**. The report counts are a proxy for "this objection is live", not
|
|
||||||
a count of people who asked. I have not put a percentage on anything.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The register — clusters, sources, frequency, and whether they block a purchase
|
|
||||||
|
|
||||||
| # | Cluster | The question in the buyer's words | Verbatim source(s), with dates | Frequency evidence | Blocks a purchase? | Disposition |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| 1 | Privacy / what leaves the Mac | "Does The Nook read my prompts or my code?" | Live `/faq`, 2026-10-29; code findings in `main.swift:272-278`, `LicenseService.swift:254`, `docs/sidecar-frames.md` | 1 live question + 3 named code findings | **Yes — the whole decision for this audience** | **Withdraw the live answer; replacement held.** See §4. |
|
|
||||||
| 2 | Agent fit / Cursor | "Does it work with Cursor — and can I approve from the notch, or only watch?" | FRE-326, 2026-10-29: *"does it work with Cursor?"*; NOOK-643 (2026-10-20), NOOK-695 (2026-10-20), NOOK-699 (2026-10-20), NOOK-707 (2026-10-21), NOOK-638 (2026-10-20) | 1 board question + 5 reports | **Yes** | **Propose E1** |
|
|
||||||
| 3 | Idle / is it actually working? | "What happens when my agent is idle?" | FRE-326, 2026-10-29: *"what happens when my agent is idle?"*; NOOK-281 (2026-10-12), NOOK-415 (2026-10-15), NOOK-417 (2026-10-15), NOOK-823 (2026-10-22), NOOK-898 (2026-10-23) | 1 board question + 5 reports | **Yes** | **Propose E2** |
|
|
||||||
| 4 | Install trust / permissions | "What permissions does it need — and what does it write into my agent's config?" | Live `/faq`, 2026-10-29; NOOK-733 (2026-10-21), NOOK-648 (2026-10-20), NOOK-685 (2026-10-20), NOOK-418 (2026-10-15), NOOK-568 (2026-10-17) | 1 live question + 5 reports | **Yes** | **Propose E3** |
|
|
||||||
| 5 | Terminal / tmux fit | "Does it work with my terminal, including tmux?" | NOOK-302 (2026-10-13), NOOK-310 (2026-10-13), NOOK-788 (2026-10-22) | 3 reports; no captured ask | **Yes for tmux-first buyers** | **Propose E4** — report-sourced, not an ask |
|
|
||||||
| 6 | Notch / display | "Do I need a Mac with a notch?" | Live `/faq`, 2026-10-29; NOOK-290 (2026-10-12), NOOK-291 (2026-10-12), NOOK-386 (2026-10-14) | 1 live question + 3 reports | Compatibility; rarely the blocker | **Keep current answer** |
|
|
||||||
| 7 | Battery / cost | "How much battery does it cost?" | Live `/faq`, 2026-10-29; NOOK-850 (2026-10-23), NOOK-851 (2026-10-23), NOOK-877 (2026-10-23) | 1 live question + 3 reports | Curious, not a hard blocker | **Rewrite E6 without a number** |
|
|
||||||
| 8 | macOS floor | "Why macOS 14.2 or later?" | Live `/faq`, 2026-10-29; NOOK-881 (2026-10-23) | 1 live question + 1 report | Compatibility; a hard no on 14.0/14.1 | **Keep current answer** |
|
|
||||||
| 9 | Offline | "Does it work offline?" | Live `/faq`, 2026-10-29; NOOK-494 (2026-10-16) | 1 live question + 1 report | Compatibility; a hard no for offline-only | **Rewrite E8 for the phone exception** |
|
|
||||||
| 10 | Trial / licence mechanics | "What am I actually buying — trial, Macs, updates?" | NOOK-682 (2026-10-20), NOOK-684 (2026-10-20), NOOK-60 (2026-10-08) | 2 reports; no captured ask | **Yes** | **Held** — no verbatim ask; do not invent one |
|
|
||||||
| 11 | Remote agents / containers | "Can I approve an agent running on a remote box or in Docker?" | FRE-323, FRE-320, FRE-315; `marketing/positioning.md` claims remote SSH | Many reports; the claim is unverified | **Yes — the launch claim** | **Held** — no answer before the demo |
|
|
||||||
| 12 | Phone / Watch | "Is there an iPhone app? Do I need one?" | NOOK-939 (2026-10-27), NOOK-294 (backlog) | 2 reports; status unverified | Curious; would be blocking if sold | **Held** — CTO status answer first |
|
|
||||||
| 13 | Refund / price | "What's your refund policy? Is $10 the real price?" | FRE-204, NOOK-121 | No readable source; contractual | **Yes** | **Out of scope** — FRE-204, CoS + sponsor |
|
|
||||||
|
|
||||||
### What changed since the last register (FRE-204)
|
|
||||||
|
|
||||||
- The live page already has six entries; this draft **keeps four, rewrites two, and proposes three
|
|
||||||
new ones** (E1, E2, E4). It does not re-ask the six questions as if they were new.
|
|
||||||
- The privacy answer is not merely unverified — it is **false at the current build**. It must come
|
|
||||||
down in the same edit, not wait for a nicer replacement.
|
|
||||||
- Cursor is now a supported agent row (`AgentRegistry.swift:524`), so "does it work with Cursor?"
|
|
||||||
has a factual answer today. It was an open question when the live page was written.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Proposed entries — page-ready
|
|
||||||
|
|
||||||
Each entry carries the six required things: **claim, audience, channel, owner, date, and what has to
|
|
||||||
be true before it ships.** The answer text is the copy to paste; the trace is the evidence a CTO can
|
|
||||||
check.
|
|
||||||
|
|
||||||
### E1 · "Does it work with my agent — and can I approve from the notch, or only watch?"
|
|
||||||
|
|
||||||
> **It depends on the agent, and Cursor is the useful example.** Cursor is watched, not gated: its
|
|
||||||
> sessions, finished turns and errors appear in the island, but when Cursor asks for permission the
|
|
||||||
> prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs
|
|
||||||
> and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the
|
|
||||||
> decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. `/agents` lists
|
|
||||||
> which is which for every agent. One limit: `cursor-agent -p` one-shot runs send no turn end, so
|
|
||||||
> they appear but do not get a completion card.
|
|
||||||
|
|
||||||
- **Question source:** FRE-326, 2026-10-29 (*"does it work with Cursor?"*); NOOK-643, 2026-10-20
|
|
||||||
(*"No permission hook is installed, deliberately: Cursor blocks the action when a permission hook
|
|
||||||
prints nothing…"*).
|
|
||||||
- **Claim:** agent gating is per-agent; Cursor is watch-only; `/agents` carries the matrix.
|
|
||||||
- **Audience:** the buyer whose agent is not Claude Code.
|
|
||||||
- **Channel:** `/faq`, plus the same answer in any FAQ structured data.
|
|
||||||
- **Owner:** CMO writes; CTO confirms.
|
|
||||||
- **Date:** CTO confirmation 2026-11-03; publish 2026-11-06.
|
|
||||||
- **Must be true:** at the shipped commit, Cursor still reads `.missing(.approvals)` with the same
|
|
||||||
reason, and `.available(.sessions)`, `.available(.completion)` and `.available(.errors)` as stated
|
|
||||||
(`AgentRegistry.swift:524-552`); `/agents` adds Cursor (and Kimi) in the same edit so the two pages
|
|
||||||
cannot disagree. If either changes, the answer changes with it.
|
|
||||||
|
|
||||||
### E2 · "What happens when my agent is idle?"
|
|
||||||
|
|
||||||
> **Idle means the last turn is over, or the session has not produced anything yet — not that the
|
|
||||||
> process is gone.** A running process is liveness, not work: a just-opened agent window can show
|
|
||||||
> idle until you type. When an agent finishes a turn with a final message, the row stays green for
|
|
||||||
> ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot
|
|
||||||
> report for that session, and the row says so instead of pretending it is idle. Nothing is lost when
|
|
||||||
> a row goes idle — the next prompt flips it back to running. If a session that is obviously working
|
|
||||||
> says idle, that is a bug; tell us.
|
|
||||||
|
|
||||||
- **Question source:** FRE-326, 2026-10-29 (*"what happens when my agent is idle?"*); NOOK-898,
|
|
||||||
2026-10-23 (*"kimi payloads fail to decode, so every Kimi session stays idle"*); NOOK-823,
|
|
||||||
2026-10-22 (*"Codex terminal: sessions become idle or disappear prematurely"*); NOOK-417,
|
|
||||||
2026-10-15 (*"Never render plain Idle for a session whose integration is dark"*).
|
|
||||||
- **Claim:** idle is a statement about the last turn, not a guess at the process; stale is a separate
|
|
||||||
state.
|
|
||||||
- **Audience:** anyone watching a fleet and wondering whether to intervene.
|
|
||||||
- **Channel:** `/faq`.
|
|
||||||
- **Owner:** CMO writes; CTO confirms.
|
|
||||||
- **Date:** CTO confirmation 2026-11-03; publish 2026-11-06.
|
|
||||||
- **Must be true:** `SessionModels.swift:540-580` still defines `islandStatus` this way (no content →
|
|
||||||
idle; complete → ready for 10 minutes → idle; `integrationStale` → stale) and `SessionState.swift`
|
|
||||||
still retains idle rows for 5 minutes. If an open bug makes a working agent render idle at the
|
|
||||||
shipped commit, the "that is a bug" sentence stays but the bug gets a named issue.
|
|
||||||
|
|
||||||
### E3 · "What permissions does it need — and what does it write into my agent's config?"
|
|
||||||
|
|
||||||
> **The app itself runs unprivileged.** Fan control and charge limiting go through a small helper
|
|
||||||
> that macOS requires to run as root for hardware access; the app installs it on first use and you
|
|
||||||
> approve it once with your password.
|
|
||||||
>
|
|
||||||
> **For each agent, it adds one entry — a hook or a plugin — inside that agent's own config
|
|
||||||
> directory.** It does not write outside that directory, and it only touches the records it created:
|
|
||||||
> a hook entry you added yourself in the same file survives install and uninstall. Uninstall removes
|
|
||||||
> exactly what it wrote, with no leftover links or orphaned files.
|
|
||||||
|
|
||||||
- **Question source:** live `/faq`, 2026-10-29 (*"What permissions does it need?"*); NOOK-418,
|
|
||||||
2026-10-15 (a custom agent's install target is never shown to the user); NOOK-733, 2026-10-21 (a
|
|
||||||
Codex hook installed but not trusted, so it never ran); NOOK-648, 2026-10-20 and NOOK-685,
|
|
||||||
2026-10-20 (uninstall leftovers); NOOK-568, 2026-10-17 (helper re-registration tore down an
|
|
||||||
approved helper).
|
|
||||||
- **Claim:** reversible install; the helper is the only root step; uninstall is exact.
|
|
||||||
- **Audience:** the buyer who has a hand-tuned `~/.claude/settings.json` or `~/.codex/hooks.json`.
|
|
||||||
- **Channel:** `/faq`.
|
|
||||||
- **Owner:** CMO writes; CTO confirms.
|
|
||||||
- **Date:** CTO confirmation 2026-11-03; publish 2026-11-06.
|
|
||||||
- **Must be true:** `hook/setup.go`'s strip functions and `PluginInstaller.uninstall` still remove
|
|
||||||
only their own records at the shipped commit; the custom-agent target is still shown and accepted
|
|
||||||
before install (NOOK-418); the helper is still the only privileged component.
|
|
||||||
|
|
||||||
### E4 · "Does it work with my terminal, including tmux?"
|
|
||||||
|
|
||||||
> **Yes — Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux.** A session is tied to the
|
|
||||||
> terminal that started it, so Jump lands in the right window and, in tmux, the right pane. Replies
|
|
||||||
> type back into that same pane. A remote agent's pane is resolved by walking the process tree,
|
|
||||||
> including panes on a private tmux server.
|
|
||||||
|
|
||||||
- **Question source:** no captured ask. Underlying reports: NOOK-302, 2026-10-13 (*"a tmux session
|
|
||||||
shows only 'tmux', and its outer terminal comes from whoever started the tmux server"*); NOOK-310,
|
|
||||||
2026-10-13 (*"jump and send address tmux panes on the default server, ignoring the session's tmux
|
|
||||||
socket"*); NOOK-788, 2026-10-22 (*"a remote reply inside a private tmux server resolves to
|
|
||||||
nothing"*).
|
|
||||||
- **Claim:** seven terminal families, tmux panes and private tmux servers included.
|
|
||||||
- **Audience:** anyone whose workflow is tmux-first.
|
|
||||||
- **Channel:** `/faq`.
|
|
||||||
- **Owner:** CMO writes; CTO confirms.
|
|
||||||
- **Date:** CTO confirmation 2026-11-03; publish 2026-11-06.
|
|
||||||
- **Must be true:** `Sources/NookCore/Terminal` still resolves those seven identities, and
|
|
||||||
`RemotePaneHop` still walks every tmux server's socket directory by ancestry. This entry is
|
|
||||||
**report-sourced, not ask-sourced**; if a captured ask never arrives, it is still publishable
|
|
||||||
because the reports are real, but it ranks below E1–E3 until one does.
|
|
||||||
|
|
||||||
### E5 · "Do I need a Mac with a notch?"
|
|
||||||
|
|
||||||
> **No.** On a notched MacBook the island sits in the notch. On machines and displays without one,
|
|
||||||
> it anchors to the display your cursor is on, so a desktop setup with external monitors gets the
|
|
||||||
> same treatment. Everything inside the island — agent cards, fan control, the stats strip — works
|
|
||||||
> identically either way.
|
|
||||||
|
|
||||||
- **Question source:** live `/faq`, 2026-10-29; NOOK-290, 2026-10-12 and NOOK-291, 2026-10-12
|
|
||||||
(non-notch display geometry bugs), NOOK-386, 2026-10-14 (empty pill tucks away on a non-notch
|
|
||||||
display).
|
|
||||||
- **Claim:** a notch is optional; external displays work.
|
|
||||||
- **Audience:** Mac mini / Studio / external-monitor buyers.
|
|
||||||
- **Channel:** `/faq`.
|
|
||||||
- **Owner:** CMO keeps; CTO confirms.
|
|
||||||
- **Date:** CTO confirmation 2026-11-03; publish 2026-11-06.
|
|
||||||
- **Must be true:** the external-display path still anchors to the pointer's display and the
|
|
||||||
non-notch geometry tests still pass.
|
|
||||||
|
|
||||||
### E6 · "How much battery does it cost?"
|
|
||||||
|
|
||||||
> **It is a native app, not a background agent.** It watches the agents you already run and does not
|
|
||||||
> run them itself, so their draw is the number that matters. We do not have a published battery
|
|
||||||
> measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs
|
|
||||||
> more than it should on your Mac, that is a bug — tell us.
|
|
||||||
|
|
||||||
- **Question source:** live `/faq`, 2026-10-29; NOOK-850, 2026-10-23 (*"the idle app does work
|
|
||||||
nothing asked for (CPU wins from a live profile)"*), NOOK-851, 2026-10-23 (a 30 fps timeline ticks
|
|
||||||
the fan icon at idle), NOOK-877, 2026-10-23 (compact Nook still costs too much CPU at rest).
|
|
||||||
- **Claim:** native app, not a background agent; **no numeric battery claim**.
|
|
||||||
- **Audience:** the buyer checking whether this is another menu-bar battery sink.
|
|
||||||
- **Channel:** `/faq`.
|
|
||||||
- **Owner:** CMO writes; CTO measures or approves the no-number wording.
|
|
||||||
- **Date:** CTO measurement/decision 2026-11-03; publish 2026-11-06.
|
|
||||||
- **Must be true:** either a measurement exists and is quoted, or the no-number sentence ships. Do
|
|
||||||
not restore "the heaviest thing it does is a sensor read" — the profile findings above contradict
|
|
||||||
it.
|
|
||||||
|
|
||||||
### E7 · "Why macOS 14.2 or later?"
|
|
||||||
|
|
||||||
> **The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS
|
|
||||||
> 14.2 (Sonoma).** The app links against it, so 14.0 and 14.1 cannot launch it.
|
|
||||||
|
|
||||||
- **Question source:** live `/faq`, 2026-10-29; NOOK-881, 2026-10-23 (*"A macOS 14.0/14.1 Mac cannot
|
|
||||||
launch the app at all: dyld dies on a strongly linked CATapDescription overlay symbol"*).
|
|
||||||
- **Claim:** the floor is 14.2 because of the audio tap API, not a policy choice.
|
|
||||||
- **Audience:** anyone on an older macOS.
|
|
||||||
- **Channel:** `/faq`.
|
|
||||||
- **Owner:** CMO keeps; CTO confirms.
|
|
||||||
- **Date:** CTO confirmation 2026-11-03; publish 2026-11-06.
|
|
||||||
- **Must be true:** `Package.swift:12` and `LSMinimumSystemVersion` still say 14.2, and
|
|
||||||
`DeploymentFloorTests` still refuses an older floor.
|
|
||||||
|
|
||||||
### E8 · "Does it work offline?"
|
|
||||||
|
|
||||||
> **The Mac app does.** The island, agent cards, permission cards and fan control work with no
|
|
||||||
> network; the hooks talk to the app over a local socket. The network is used for update checks,
|
|
||||||
> licence activation and the optional trial email. The phone link is the exception: when you are away
|
|
||||||
> from your Mac, the phone reaches it through our relay, so that part needs a connection.
|
|
||||||
|
|
||||||
- **Question source:** live `/faq`, 2026-10-29; NOOK-494, 2026-10-16 (*"iOS: the main list gives no
|
|
||||||
sign the phone is offline from the Mac"*).
|
|
||||||
- **Claim:** the Mac app is offline-first; the phone link is not.
|
|
||||||
- **Audience:** the buyer with a disconnected or privacy-sensitive setup.
|
|
||||||
- **Channel:** `/faq`.
|
|
||||||
- **Owner:** CMO writes; CTO confirms.
|
|
||||||
- **Date:** CTO confirmation 2026-11-03; publish 2026-11-06.
|
|
||||||
- **Must be true:** the only network calls at the shipped commit are the update check, licence
|
|
||||||
activation, the optional trial email, and the phone relay; `/privacy` lists the same set. This
|
|
||||||
entry shares its gate with the privacy correction below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. The correction that is not a new entry: privacy
|
|
||||||
|
|
||||||
**Live answer, quoted from `nook.freno.me/faq` on 2026-10-29:**
|
|
||||||
|
|
||||||
> "No. The hooks and plugins that connect your agents to The Nook send session metadata only — which
|
|
||||||
> agent is running, which session, which event happened, which terminal it lives in. No prompt text
|
|
||||||
> and no code content leaves your device. Two things do leave the device, and we'd rather say so
|
|
||||||
> plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start.
|
|
||||||
> That's the whole list."
|
|
||||||
|
|
||||||
**Why it cannot stay up:** it is contradicted by the current build.
|
|
||||||
|
|
||||||
1. **Sentry starts unconditionally with `sendDefaultPii = true`** (`Sources/NookApp/main.swift:272-278`).
|
|
||||||
That is crash and error reporting with personal identifiers, not "anonymous usage telemetry".
|
|
||||||
2. **The licence/trial call sends `IOPlatformUUID` and the Mac's device name** (`LicenseService.swift:254`,
|
|
||||||
`:305`), on launch, not only at trial start.
|
|
||||||
3. **With the phone link on, prompt and reply text transit `nook-relay.freno.me`, sealed end to end**
|
|
||||||
(`docs/sidecar-frames.md`). The relay can route the payload but cannot read it; the live answer
|
|
||||||
still says no prompt text leaves the device.
|
|
||||||
|
|
||||||
`/privacy` discloses the hardware UUID, device name and Stripe, but **not** Sentry and **not** the
|
|
||||||
relay. So there is no replacement sentence I can write that is both true and consistent with the
|
|
||||||
privacy page today.
|
|
||||||
|
|
||||||
**Proposed:** remove the privacy entry from `/faq` in the same edit that ships the other entries,
|
|
||||||
rather than replace it with another sentence I cannot stand behind. The replacement is held until
|
|
||||||
the Security Engineer's egress inventory and the `/privacy` update land.
|
|
||||||
|
|
||||||
- **Claim:** none until the inventory exists — deliberately.
|
|
||||||
- **Audience:** the buyer for whom this is the whole decision.
|
|
||||||
- **Channel:** `/faq` (withdrawal) and `/privacy` (replacement).
|
|
||||||
- **Owner:** CoS for the withdrawal decision; Security Engineer for the egress inventory and the
|
|
||||||
disclosure text; CTO for any build change; CMO for the replacement copy.
|
|
||||||
- **Date:** withdrawal decision 2026-10-31; inventory 2026-11-05; replacement copy 2026-11-09.
|
|
||||||
- **Must be true:** every destination in `main.swift`, `LicenseService.swift`, `Info.plist`,
|
|
||||||
`ChangelogRelease.swift` and `SidecarController.swift` is either in the answer or gone from the
|
|
||||||
build, and `/privacy` lists the same set. Until then, do not publish a privacy answer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Held — clusters with no entry yet
|
|
||||||
|
|
||||||
| Cluster | Why it is held | Owner and date |
|
|
||||||
|---|---|---|
|
|
||||||
| Trial / licence mechanics ("trial, Macs, updates") | No captured ask. NOOK-682 and NOOK-684 are defect reports, not a buyer question. The answer is factual (14 days, 3 Macs, Sparkle updates), but the task says do not invent a question to fill the slot. | CMO to capture an ask; CTO confirms `LicenseService.swift:43`, `:262` by 2026-11-03. |
|
|
||||||
| Remote agents / containers | The launch claim is unverified (FRE-315, FRE-322, FRE-323, FRE-320). No FAQ answer before the demo. | CTO evidence; CMO copy the day it exists. Revisit 2026-11-12. |
|
|
||||||
| Phone / Watch | Status is unverified; the landing page and FAQ currently disagree. | CTO status answer by 2026-11-03; CMO copy after. |
|
|
||||||
| Refund / price | Contractual, and explicitly out of this issue. | FRE-204, CoS + sponsor. |
|
|
||||||
| Custom agents | No captured ask; the `/agents` page already says a custom agent can wire itself in. | No entry until asked. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. What the page edit would change
|
|
||||||
|
|
||||||
**File:** `freno-dev/src/routes/nook/faq.tsx` (site `master` at `134f41e`; local `c1c498e` removes
|
|
||||||
the remote claim from the landing page, not from the FAQ).
|
|
||||||
|
|
||||||
| Action | Entry |
|
|
||||||
|---|---|
|
|
||||||
| **Remove** | "Does The Nook read my prompts or my code?" — until the egress inventory and `/privacy` are true |
|
|
||||||
| **Keep, with the exact text above** | "Do I need a Mac with a notch?" |
|
|
||||||
| **Keep, with the exact text above** | "Why macOS 14.2 or later?" |
|
|
||||||
| **Rewrite** | "What permissions does it need?" → E3 (adds the config/uninstall answer) |
|
|
||||||
| **Rewrite** | "How much battery does it cost?" → E6 (no number) |
|
|
||||||
| **Rewrite** | "Does it work offline?" → E8 (phone exception) |
|
|
||||||
| **Add** | E1 (agent fit / Cursor) |
|
|
||||||
| **Add** | E2 (idle) |
|
|
||||||
| **Add** | E4 (terminal / tmux) |
|
|
||||||
|
|
||||||
Order on the page: E1, E2, E3, E4, E5, E6, E7, E8. The header, the mailto link, the accordion
|
|
||||||
behavior, and the page metadata stay unchanged.
|
|
||||||
|
|
||||||
**Not in this pass:** FAQPage JSON-LD. The page has none today; adding structured data is an SEO
|
|
||||||
change, not an objection-mining change. It should be its own reviewed edit.
|
|
||||||
|
|
||||||
**Patch artifact:** `freno-dev/marketing/drafts/faq-page.patch` (a proposal, not applied). The page
|
|
||||||
file itself is untouched until the gates below are satisfied.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Publish gates and sequence
|
|
||||||
|
|
||||||
| Step | Owner | Date | What settles it |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1. CTO verifies the claims and evidence pointers in E1–E8 | CTO | 2026-11-03 | A verdict per entry: verified / not verified / needs a test, with the exact sentence to change. The review issue is [FRE-327](/FRE/issues/FRE-327). |
|
|
||||||
| 2. Human approves the draft | Sponsor | 2026-11-04 | The draft moves from `marketing/drafts/` to `marketing/approved/`, or the approval card is accepted. No approval before the CTO verdict. |
|
|
||||||
| 3. Apply the page patch and commit on `freno-dev` | CMO | 2026-11-06 | The patch applies cleanly; `bun test` (or the site's suite) passes; the diff contains only the FAQ entries. |
|
|
||||||
| 4. Merge to `freno-dev/master` (the publish) | Sponsor or Chief of Staff | 2026-11-06 | A human merge; Vercel deploys. No agent pushes to master. |
|
|
||||||
| 5. Verify the live page | CMO | 2026-11-06 | `https://nook.freno.me/faq` returns 200 and shows the eight entries with no privacy answer; the text matches the approved draft. |
|
|
||||||
|
|
||||||
**If the CTO rejects an entry:** cut it from the patch and ship the rest. No entry ships unverified.
|
|
||||||
**If the sponsor does not approve:** nothing is applied; the draft stays in `drafts/`.
|
|
||||||
**If the support mailbox becomes readable:** re-sort the register by observed asks before step 1;
|
|
||||||
the top three may change, and the page order should follow the asks, not this draft's proxy ranking.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A — verbatim source notes
|
|
||||||
|
|
||||||
| Date | Source | Verbatim |
|
|
||||||
|---|---|---|
|
|
||||||
| 2026-10-29 | Live `/faq` | "Does The Nook read my prompts or my code?" / "Do I need a Mac with a notch?" / "How much battery does it cost?" / "What permissions does it need?" / "Why macOS 14.2 or later?" / "Does it work offline?" |
|
|
||||||
| 2026-10-29 | FRE-326 description | "does it work with Cursor?" / "what happens when my agent is idle?" |
|
|
||||||
| 2026-10-08 | NOOK-121 | "Add /faq from real objections" — shipped the six live topics; refund dropped by Mike. |
|
|
||||||
| 2026-10-20 | NOOK-643 | "agents: Cursor via hooks.json" — "No permission hook is installed, deliberately: Cursor blocks the action when a permission hook prints nothing…" |
|
|
||||||
| 2026-10-21 | NOOK-707 | "agents: Cursor desktop chats bind to the shared extension-host pid and never end" |
|
|
||||||
| 2026-10-23 | NOOK-898 | "hook: kimi payloads fail to decode, so every Kimi session stays idle" |
|
|
||||||
| 2026-10-22 | NOOK-823 | "Codex terminal: sessions become idle or disappear prematurely" |
|
|
||||||
| 2026-10-15 | NOOK-417 | "Never render plain Idle for a session whose integration is dark" |
|
|
||||||
| 2026-10-15 | NOOK-415 | "Sessions whose integration predates the bridge credential freeze as Idle forever" |
|
|
||||||
| 2026-10-21 | NOOK-733 | "codex: a non-interactive session never runs an installed hook (0.163.0)" |
|
|
||||||
| 2026-10-20 | NOOK-648 | "opencode: uninstalling a pre-package install leaves its old nook.js link dangling" |
|
|
||||||
| 2026-10-20 | NOOK-685 | "tests: InstallerTests.clineHookFiles writes through the managed TaskComplete symlink and then expects that path to survive uninstall" |
|
|
||||||
| 2026-10-15 | NOOK-418 | "security: a custom agent's install target is never shown to, or approved by, the user" |
|
|
||||||
| 2026-10-17 | NOOK-568 | "helper: launch-time re-registration tears down an approved helper and leaves it unregistered" |
|
|
||||||
| 2026-10-13 | NOOK-302 | "terminal: a tmux session shows only 'tmux', and its outer terminal comes from whoever started the tmux server" |
|
|
||||||
| 2026-10-13 | NOOK-310 | "terminal: jump and send address tmux panes on the default server, ignoring the session's tmux socket" |
|
|
||||||
| 2026-10-22 | NOOK-788 | "pane: a remote reply inside a private tmux server resolves to nothing, and a pane id can be sent without its socket" |
|
|
||||||
| 2026-10-12 | NOOK-290 | "Compact island: weather slot clips mid-glyph on non-notch displays (no text cap)" |
|
|
||||||
| 2026-10-12 | NOOK-291 | "Compact island: non-notch pill is 36 pt wider than its slot row (padding double-counted in pillWidth)" |
|
|
||||||
| 2026-10-14 | NOOK-386 | "island: hide the empty pill under the notch (or above the top edge) and keep a narrower hover trigger" |
|
|
||||||
| 2026-10-23 | NOOK-850 | "Nook: the idle app does work nothing asked for (CPU wins from a live profile)" |
|
|
||||||
| 2026-10-23 | NOOK-851 | "island: a 30 fps timeline ticks the fan icon even when the fan is idle" |
|
|
||||||
| 2026-10-23 | NOOK-877 | "island: compact Nook still costs too much CPU at rest (round two, A/B per fix)" |
|
|
||||||
| 2026-10-23 | NOOK-881 | "A macOS 14.0/14.1 Mac cannot launch the app at all: dyld dies on a strongly linked CATapDescription overlay symbol" |
|
|
||||||
| 2026-10-16 | NOOK-494 | "iOS: the main list gives no sign the phone is offline from the Mac" |
|
|
||||||
| 2026-10-20 | NOOK-682 | "licensing: a new user can be blocked at launch instead of getting the 14-day trial" |
|
|
||||||
| 2026-10-20 | NOOK-684 | "licensing: parseISO rejects the server's fractional-second trial timestamp, so an expired server trial resets to 14 days" |
|
|
||||||
| 2026-10-08 | NOOK-60 | "Capture email at trial start" |
|
|
||||||
|
|
||||||
## Appendix B — frequency method
|
|
||||||
|
|
||||||
- **Live questions:** one per entry that quotes the live page. There are six, and they are the only
|
|
||||||
questions on the page today.
|
|
||||||
- **Board questions:** the two examples in FRE-326. They are not customer quotes; they are the
|
|
||||||
board's own words for the purchase-blocking objections this issue was created to answer.
|
|
||||||
- **Report counts:** the number of distinct NOOK issues I read whose subject or resolution directly
|
|
||||||
produced the objection, listed by identifier and date in §2 and Appendix A. I did not use a raw
|
|
||||||
keyword count: "agent" appears in 344 issues and "privacy" in 177, which says nothing about what
|
|
||||||
buyers ask. The counts are deliberately small and inspectable.
|
|
||||||
- **No ask frequency, no percentages.** The first thing a real support corpus changes is the order,
|
|
||||||
not the wording.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Draft ends. Nothing here is published. The page file stays unchanged until the CTO verdict and the
|
|
||||||
human move to `approved/`.*
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
diff --git a/src/routes/nook/faq.tsx b/src/routes/nook/faq.tsx
|
|
||||||
index 03d135a..6c2da28 100644
|
|
||||||
--- a/src/routes/nook/faq.tsx
|
|
||||||
+++ b/src/routes/nook/faq.tsx
|
|
||||||
@@ -1,8 +1,7 @@
|
|
||||||
/**
|
|
||||||
* `/faq` on the Nook subdomain — answers to the objections that actually
|
|
||||||
- * stop a purchase. Privacy scoping follows marketing/positioning.md rule 3:
|
|
||||||
- * "no prompt or code content leaves the device" (telemetry and the trial
|
|
||||||
- * email do leave — never claim "no data leaves the device").
|
|
||||||
+ * stop a purchase. Privacy is deliberately absent until the egress
|
|
||||||
+ * inventory and /privacy make an honest answer possible.
|
|
||||||
*/
|
|
||||||
import { For } from "solid-js";
|
|
||||||
import { createSignal, Show } from "solid-js";
|
|
||||||
@@ -17,34 +16,46 @@ interface FaqEntry {
|
|
||||||
|
|
||||||
const FAQ: FaqEntry[] = [
|
|
||||||
{
|
|
||||||
- question: "Does The Nook read my prompts or my code?",
|
|
||||||
+ question:
|
|
||||||
+ "Does it work with my agent — and can I approve from the notch, or only watch?",
|
|
||||||
answer:
|
|
||||||
- "No. The hooks and plugins that connect your agents to The Nook send session metadata only — which agent is running, which session, which event happened, which terminal it lives in. No prompt text and no code content leaves your device. Two things do leave the device, and we'd rather say so plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start. That's the whole list."
|
|
||||||
+ "It depends on the agent, and Cursor is the useful example. Cursor is watched, not gated: its sessions, finished turns and errors appear in the island, but when Cursor asks for permission the prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. /agents lists which is which for every agent. One limit: cursor-agent -p one-shot runs send no turn end, so they appear but do not get a completion card."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
- question: "Do I need a Mac with a notch?",
|
|
||||||
+ question: "What happens when my agent is idle?",
|
|
||||||
answer:
|
|
||||||
- "No. On a notched MacBook the island sits in the notch. On machines and displays without one, it anchors to the display your cursor is on, so a desktop setup with external monitors gets the same treatment. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
|
||||||
+ "Idle means the last turn is over, or the session has not produced anything yet — not that the process is gone. A running process is liveness, not work: a just-opened agent window can show idle until you type. When an agent finishes a turn with a final message, the row stays green for ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot report for that session, and the row says so instead of pretending it is idle. Nothing is lost when a row goes idle — the next prompt flips it back to running. If a session that is obviously working says idle, that is a bug; tell us."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
- question: "How much battery does it cost?",
|
|
||||||
+ question:
|
|
||||||
+ "What permissions does it need — and what does it write into my agent's config?",
|
|
||||||
+ answer:
|
|
||||||
+ "The app itself runs unprivileged. Fan control and charge limiting go through a small helper that macOS requires to run as root for hardware access; the app installs it on first use and you approve it once with your password. For each agent, it adds one entry — a hook or a plugin — inside that agent's own config directory. It does not write outside that directory, and it only touches the records it created: a hook entry you added yourself in the same file survives install and uninstall. Uninstall removes exactly what it wrote, with no leftover links or orphaned files."
|
|
||||||
+ },
|
|
||||||
+ {
|
|
||||||
+ question: "Does it work with my terminal, including tmux?",
|
|
||||||
answer:
|
|
||||||
- "The Nook is a small native UI, not a background agent — it watches the agents you're already running; it doesn't run anything itself. The heaviest thing it does is read and write your Mac's fan and charging controllers, which is a lightweight sensor read. If you're running enough coding agents to need it, those agents dwarf the app's own draw."
|
|
||||||
+ "Yes — Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux. A session is tied to the terminal that started it, so Jump lands in the right window and, in tmux, the right pane. Replies type back into that same pane. A remote agent's pane is resolved by walking the process tree, including panes on a private tmux server."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
- question: "What permissions does it need?",
|
|
||||||
+ question: "Do I need a Mac with a notch?",
|
|
||||||
+ answer:
|
|
||||||
+ "No. On a notched MacBook the island sits in the notch. On machines and displays without one, it anchors to the display your cursor is on, so a desktop setup with external monitors gets the same treatment. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
|
||||||
+ },
|
|
||||||
+ {
|
|
||||||
+ question: "How much battery does it cost?",
|
|
||||||
answer:
|
|
||||||
- "The app itself runs unprivileged. Fan control and charge limiting go through a small helper component that macOS requires to run as root for hardware access — the app installs it on first use and you approve it once with your password. The hooks live in each agent's own config directory; you can inspect or remove every file it writes."
|
|
||||||
+ "It is a native app, not a background agent. It watches the agents you already run and does not run them itself, so their draw is the number that matters. We do not have a published battery measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs more than it should on your Mac, that is a bug — tell us."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Why macOS 14.2 or later?",
|
|
||||||
answer:
|
|
||||||
- "The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma)."
|
|
||||||
+ "The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma). The app links against it, so 14.0 and 14.1 cannot launch it."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Does it work offline?",
|
|
||||||
answer:
|
|
||||||
- "Yes. Everything The Nook does — the agent grid, permission cards, fan and thermal control — works with no network at all, and your agents' hooks talk to the app over a local socket. The network is only used for update checks, license activation, and the optional trial email."
|
|
||||||
+ "The Mac app does. The island, agent cards, permission cards and fan control work with no network; the hooks talk to the app over a local socket. The network is used for update checks, licence activation and the optional trial email. The phone link is the exception: when you are away from your Mac, the phone reaches it through our relay, so that part needs a connection."
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
@@ -56,7 +67,7 @@ export default function NookFaqPage() {
|
|
||||||
<EdgeCacheHeaders maxAge={300} />
|
|
||||||
<PageHead
|
|
||||||
title="Frequently asked questions"
|
|
||||||
- description="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14.2 requirement, offline use — answered straight."
|
|
||||||
+ description="Which agents can you approve from the notch? What does idle mean? Terminal and tmux support, permissions, the macOS 14.2 requirement, offline use — answered straight."
|
|
||||||
ogTitle="The Nook — FAQ"
|
|
||||||
/>
|
|
||||||
<SubdomainHeader />
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# Remote SSH copy — HELD, do not publish
|
# Remote SSH copy — HELD, do not publish
|
||||||
|
|
||||||
**Status:** removed from the live site on 2026-10-29 in the FRE-323 correction.
|
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
|
||||||
|
correction, and **not yet off the live site** — the deploy is blocked on a write
|
||||||
|
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
|
||||||
|
serves every sentence below until that push or a Vercel deploy lands.
|
||||||
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
||||||
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
||||||
verifies that flow, and a recording shows a remote agent joining the island.
|
verifies that flow, and a recording shows a remote agent joining the island.
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
|
|||||||
unverified and demo-gated. The CTO named it the largest launch risk because it
|
unverified and demo-gated. The CTO named it the largest launch risk because it
|
||||||
was already in front of buyers.
|
was already in front of buyers.
|
||||||
|
|
||||||
So the landing page now says only what the product can back today: local agents,
|
So the `freno-dev` source now says only what the product can back today: local
|
||||||
the card, and the notch. The remote story is not deleted — it is parked here with
|
agents, the card, and the notch. That correction is committed locally and is
|
||||||
the FRE-320/FRE-205 work so it can return the day the one-click install ships and
|
**not deployed** — the live page still carries the remote sentences until the
|
||||||
is recorded.
|
push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is
|
||||||
|
parked here with the FRE-320/FRE-205 work so it can return the day the one-click
|
||||||
|
install ships and is recorded.
|
||||||
|
|
||||||
## What has to be true before it returns
|
## What has to be true before it returns
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,43 @@
|
|||||||
# Weekly funnel snapshot
|
# Weekly funnel snapshot
|
||||||
|
|
||||||
Generated weekly from the analytics DB + `trials`/`licenses`/`activations`.
|
Window: **2026-10-22 18:05:00 → 2026-10-29 18:05:00 UTC** (7 days). Previous: 2026-10-15 18:05:00 → 2026-10-22 18:05:00 UTC.
|
||||||
Format: the funnel numbers, what moved, and the one thing worth acting on.
|
|
||||||
|
Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's.
|
||||||
|
|
||||||
|
## Funnel
|
||||||
|
|
||||||
|
| Stage | This week | Last week | Change |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| visit (nook.freno.me page views) | 0 | 0 | 0 |
|
||||||
|
| download click | 0 | 0 | 0 |
|
||||||
|
| DMG served | 0 | 0 | 0 |
|
||||||
|
| trial started | 2 | 0 | +2 |
|
||||||
|
| checkout started (Stripe session) | 0 | 0 | 0 |
|
||||||
|
| purchase (licence issued) | 0 | 0 | 0 |
|
||||||
|
| activation | 0 | 0 | 0 |
|
||||||
|
|
||||||
|
## Campaigns
|
||||||
|
|
||||||
|
Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.topCampaigns` returns.
|
||||||
|
|
||||||
|
| Source | Medium | Campaign | Rows |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| x | — | — | 1 |
|
||||||
|
|
||||||
|
1 of 291 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
|
||||||
|
|
||||||
|
## The one thing that moved
|
||||||
|
|
||||||
|
**trial started: 0 → 2 (+2).**
|
||||||
|
|
||||||
|
## Before acting on these numbers
|
||||||
|
|
||||||
|
- **291 of 291 rows in the window predate the FRE-366 instrumentation.** They carry no host, and their recorded address is the production proxy's loopback (127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. They are excluded from every host-scoped stage above (visits, and the download click/served split), and "unique visitors" over them still collapses to one host. Rows recorded since carry the site they addressed and the visitor's address truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so "unique visitors" counts networks, not machines.
|
||||||
|
- **Download rows are automation-heavy.** Of this window's 137 download rows, 0 are clicks and 0 are served files; the remaining 137 predate the click/served stage label and cannot be split, so they count in neither stage. 112 of all 137 are not browser user agents (curl, Sparkle, link-preview crawlers).
|
||||||
|
- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
|
||||||
|
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
|
||||||
|
- Generated 2026-10-29T18:05:00Z.
|
||||||
|
|||||||
@@ -4,13 +4,39 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
|
|||||||
artifact invents its own framing, fix the artifact or fix this file — never both
|
artifact invents its own framing, fix the artifact or fix this file — never both
|
||||||
silently diverging.
|
silently diverging.
|
||||||
|
|
||||||
Primary source: `~/Code/the-nook/docs/marketing-strategy.md`.
|
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 count
|
||||||
|
correction, against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354],
|
||||||
|
[FRE-997]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
|
||||||
|
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
|
||||||
|
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
|
||||||
|
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
|
||||||
|
no longer exists; the verdicts below are the source of truth it was pointing at.
|
||||||
|
|
||||||
|
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
|
||||||
|
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
|
||||||
|
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
|
||||||
|
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
|
||||||
|
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
|
||||||
|
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
|
||||||
|
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
|
||||||
|
|
||||||
## The sentence
|
## The sentence
|
||||||
|
|
||||||
> **Your Mac and your agents, under control.** The Nook puts your whole coding-agent
|
There is no single approved public sentence yet, and saying otherwise is the one
|
||||||
> fleet in the notch — thirteen agents — so you see what is running,
|
mistake this file exists to prevent. What exists is a **launch claim set** — two
|
||||||
> unblock it in one tap, and keep the machine from overheating while it works.
|
sentences, both from one 15-second take, recorded as the chosen route by the Chief
|
||||||
|
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
|
||||||
|
|
||||||
|
| Slot | Exact sentence | Status today |
|
||||||
|
|---|---|---|
|
||||||
|
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
|
||||||
|
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
|
||||||
|
|
||||||
|
Working line for internal writing, not for publication:
|
||||||
|
|
||||||
|
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
|
||||||
|
> see what is running, you unblock what is waiting, and the machine stays cool
|
||||||
|
> while it works.
|
||||||
|
|
||||||
## The wedge
|
## The wedge
|
||||||
|
|
||||||
@@ -22,15 +48,18 @@ The thing to say over and over, in every channel, in slightly different words:
|
|||||||
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
||||||
Allow, work resumes. Ten seconds, no explanation needed.
|
Allow, work resumes. Ten seconds, no explanation needed.
|
||||||
|
|
||||||
## The bundling reframe
|
## The consolidation reframe
|
||||||
|
|
||||||
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
||||||
$10 app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||||
|
|
||||||
> One $10 app instead of a notch toy, a fan utility, a battery limiter, a menu-bar
|
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
|
||||||
> stats strip, and a notification hack. Same one-time purchase, no subscription.
|
> strip, and a notification hack. One purchase, no subscription.
|
||||||
|
|
||||||
Price rises to $15 at 1.0 — dated and honest, not a permanent beta.
|
**No price, no discount and no future price in copy.** The price is $10, it stays
|
||||||
|
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
|
||||||
|
price change is a Chief of Staff + sponsor call; it is never a marketing decision
|
||||||
|
and never a launch asset.
|
||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
@@ -48,30 +77,49 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
|
|||||||
community replies written by a human, where quoting what a user said about another
|
community replies written by a human, where quoting what a user said about another
|
||||||
tool is normal conversation.
|
tool is normal conversation.
|
||||||
|
|
||||||
### 2. A claim requires a test to exist
|
### 2. A claim needs a verdict, not just a test
|
||||||
|
|
||||||
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
|
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
|
||||||
If a claim cannot be pointed at code or a test, it does not go in copy.
|
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
|
||||||
|
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
|
||||||
|
tables below, it does not go in copy.
|
||||||
|
|
||||||
### 3. Honest privacy scoping
|
### 3. Honest privacy scoping
|
||||||
|
|
||||||
Telemetry and the trial email **do** leave the device. The defensible claim is:
|
Telemetry and the licence check **do** leave the device: Sentry runs on **every
|
||||||
|
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
|
||||||
|
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
|
||||||
|
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
|
||||||
|
|
||||||
> **No prompt or code content leaves the device.**
|
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
|
||||||
|
- "nothing leaves your Mac"
|
||||||
|
|
||||||
Never "no data leaves the device".
|
The one favourable engine sentence — about the **hook and bridge only**, never
|
||||||
|
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
|
||||||
|
service**. Do not write "talk to nothing but your Mac" (too absolute: the
|
||||||
|
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
|
||||||
|
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
|
||||||
|
it to "over SSH" (a tailnet peer dials directly).
|
||||||
|
|
||||||
|
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
|
||||||
|
**held** until the disclosures land: Sentry gated behind a preference or removed,
|
||||||
|
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
|
||||||
|
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
|
||||||
|
false ones is worse than no sentence.
|
||||||
|
|
||||||
### 4. Sell what works today
|
### 4. Sell what works today
|
||||||
|
|
||||||
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)"
|
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
|
||||||
parentheticals. The launch story is the approval card: an agent blocked on a
|
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
|
||||||
permission prompt, unblocked from the Mac's notch, and work resumes.
|
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
|
||||||
|
all, and never claim approvals-from-phone as a shipped capability without its own
|
||||||
|
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||||
|
|
||||||
Remote SSH is **held**, not approved. The one-click install has not shipped and
|
Remote SSH is **held**, not approved: the one-click install has not
|
||||||
has not been recorded (FRE-320, FRE-205), and the current path needs hand-edited
|
been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
|
||||||
`~/.ssh/config` and a one-time `ssh-copy-id` (FRE-320). Its copy is parked in
|
`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
|
||||||
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow and a
|
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
|
||||||
demo exists.
|
recording shows a remote agent joining the notch.
|
||||||
|
|
||||||
### 5. Humans hold every account
|
### 5. Humans hold every account
|
||||||
|
|
||||||
@@ -79,28 +127,30 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
|
|||||||
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
||||||
nothing posts itself.
|
nothing posts itself.
|
||||||
|
|
||||||
## Approved claims (each backed by code)
|
## Claims: status at 2026-11-03
|
||||||
|
|
||||||
| Claim | Backing |
|
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||||
|---|---|
|
|
||||||
| Thirteen hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, muse, hermes, openclaw, zai, gemini, cline, qwen, deepseek |
|
|
||||||
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
|
|
||||||
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
|
|
||||||
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
|
|
||||||
| macOS 14+ | 27 source files use the `@Observable` framework |
|
|
||||||
| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
|
|
||||||
|
|
||||||
## Held claims (not publishable)
|
| Claim | Status | Evidence / next step |
|
||||||
|
|
||||||
| Claim | Why it is held | What has to be true first |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Remote agents over SSH, one click to install | The one-click path is app-UI-only and needs hand-edited `~/.ssh/config` plus a one-time `ssh-copy-id`; no demo exists (FRE-320). FRE-315 and FRE-322 hold it unverified and demo-gated. | FRE-320/FRE-205 ship a genuinely one-click install, the CTO verifies it, and a recording shows a remote agent joining the island. Copy parked in `marketing/drafts/remote-ssh/`. |
|
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
|
||||||
|
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
|
||||||
|
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
|
||||||
|
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
|
||||||
|
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
|
||||||
|
| Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable. |
|
||||||
|
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
|
||||||
|
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
|
||||||
|
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
|
||||||
|
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
|
||||||
|
|
||||||
## Voice
|
## Voice
|
||||||
|
|
||||||
- Short, concrete, no hype adjectives. Numbers over adjectives ("thirteen agents",
|
- Short, concrete, no hype adjectives. Numbers over adjectives — the only count
|
||||||
"one tap", "$10 once").
|
that ships is eighteen, and it is the registry's own size (see the coverage row
|
||||||
- The notch/island is the product's home; agents are the product's point.
|
above).
|
||||||
|
- The notch is the product's home; agents are the product's point.
|
||||||
- Never apologize for being a utility. The utility is the point.
|
- Never apologize for being a utility. The utility is the point.
|
||||||
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
||||||
"game-changer", "seamlessly". Unverifiable superlatives generally.
|
"game-changer", "seamlessly", any unverifiable superlative — and the specific
|
||||||
|
banned sentences in rule 3 and the table above.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
"perf": "bun run scripts/perf-test.ts",
|
"perf": "bun run scripts/perf-test.ts",
|
||||||
"perf:compare": "bun run scripts/perf-compare.ts",
|
"perf:compare": "bun run scripts/perf-compare.ts",
|
||||||
|
"metrics:weekly": "bun --env-file=.env scripts/weekly-metrics.ts",
|
||||||
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
159
scripts/weekly-metrics.ts
Normal file
159
scripts/weekly-metrics.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { createClient } from "@libsql/client/web";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
ensureAnalyticsSchema,
|
||||||
|
getWeeklyReport,
|
||||||
|
renderWeeklyMarkdown,
|
||||||
|
type Connections
|
||||||
|
} from "~/server/weekly-metrics";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write `marketing/metrics/weekly.md` from the live analytics and licence DBs.
|
||||||
|
*
|
||||||
|
* bun --env-file=.env scripts/weekly-metrics.ts
|
||||||
|
* bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z
|
||||||
|
*
|
||||||
|
* Required environment (all four; the analytics token also opens the licence
|
||||||
|
* DB, which is why the weekly job can run from a checkout that has no Stripe
|
||||||
|
* or Apple secrets):
|
||||||
|
*
|
||||||
|
* TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics
|
||||||
|
* NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations
|
||||||
|
*
|
||||||
|
* This script deliberately does not import `~/env/server`: that schema demands
|
||||||
|
* every production secret, and the metrics job needs two databases and nothing
|
||||||
|
* else.
|
||||||
|
*
|
||||||
|
* The output path is anchored at the repo root, so running it from any cwd
|
||||||
|
* writes the same file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
days: number;
|
||||||
|
now: Date;
|
||||||
|
out: string;
|
||||||
|
stdout: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage(): string {
|
||||||
|
return [
|
||||||
|
"Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]",
|
||||||
|
"",
|
||||||
|
" --days N window length in days (default 7)",
|
||||||
|
" --now ISO end of the window (default: now)",
|
||||||
|
" --out PATH where to write (default: marketing/metrics/weekly.md)",
|
||||||
|
" --stdout print the report instead of writing a file"
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Options {
|
||||||
|
const repoRoot = resolve(import.meta.dir, "..");
|
||||||
|
const options: Options = {
|
||||||
|
days: 7,
|
||||||
|
now: new Date(),
|
||||||
|
out: resolve(repoRoot, "marketing/metrics/weekly.md"),
|
||||||
|
stdout: false
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i]!;
|
||||||
|
const value = () => {
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`);
|
||||||
|
i += 1;
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (arg) {
|
||||||
|
case "--days": {
|
||||||
|
const days = Number(value());
|
||||||
|
if (!Number.isFinite(days) || days <= 0) {
|
||||||
|
throw new Error(`--days must be a positive number\n\n${usage()}`);
|
||||||
|
}
|
||||||
|
options.days = days;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "--now": {
|
||||||
|
const now = new Date(value());
|
||||||
|
if (Number.isNaN(now.getTime())) {
|
||||||
|
throw new Error(`--now must be an ISO timestamp\n\n${usage()}`);
|
||||||
|
}
|
||||||
|
options.now = now;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "--out":
|
||||||
|
options.out = resolve(value());
|
||||||
|
break;
|
||||||
|
case "--stdout":
|
||||||
|
options.stdout = true;
|
||||||
|
break;
|
||||||
|
case "-h":
|
||||||
|
case "--help":
|
||||||
|
console.log(usage());
|
||||||
|
process.exit(0);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown argument ${arg}\n\n${usage()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredEnv(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value || value.trim() === "") {
|
||||||
|
throw new Error(
|
||||||
|
`${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` +
|
||||||
|
`NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` +
|
||||||
|
`\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(url: string, token: string) {
|
||||||
|
return createClient({ url, authToken: token });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
const connections: Connections = {
|
||||||
|
analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")),
|
||||||
|
nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN"))
|
||||||
|
};
|
||||||
|
|
||||||
|
// The app migrates the table on boot, but this job must not depend on a
|
||||||
|
// deploy having happened: add any missing post-hoc columns (stage, host)
|
||||||
|
// through the same idempotent bootstrap path first.
|
||||||
|
await ensureAnalyticsSchema(connections.analytics);
|
||||||
|
|
||||||
|
const report = await getWeeklyReport(connections, {
|
||||||
|
days: options.days,
|
||||||
|
now: options.now
|
||||||
|
});
|
||||||
|
const markdown = renderWeeklyMarkdown(report);
|
||||||
|
|
||||||
|
if (options.stdout) {
|
||||||
|
process.stdout.write(markdown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(dirname(options.out), { recursive: true });
|
||||||
|
await writeFile(options.out, markdown, "utf8");
|
||||||
|
|
||||||
|
const f = report.counts;
|
||||||
|
console.log(
|
||||||
|
`wrote ${options.out}\n` +
|
||||||
|
`window ${report.window.start} → ${report.window.end}\n` +
|
||||||
|
`visit ${f.visit}, download click ${f.downloadClick}, DMG served ${f.dmgServed}, ` +
|
||||||
|
`trial ${f.trial}, checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` +
|
||||||
|
`mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -851,7 +851,7 @@ export default function FeatureBreakdowns() {
|
|||||||
title="Your whole fleet, in one pixel strip"
|
title="Your whole fleet, in one pixel strip"
|
||||||
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
||||||
bullets={[
|
bullets={[
|
||||||
"13 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
"18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
||||||
"Custom agents: your own coding agent can wire itself in from the published contract",
|
"Custom agents: your own coding agent can wire itself in from the published contract",
|
||||||
"Slot rankings — you choose which data earns the notch",
|
"Slot rankings — you choose which data earns the notch",
|
||||||
"You don't need a notch — external displays get the same island, floating top-center"
|
"You don't need a notch — external displays get the same island, floating top-center"
|
||||||
|
|||||||
@@ -722,7 +722,7 @@ function LayoutMock() {
|
|||||||
/* ── Marquee ─────────────────────────────────────────────────────── */
|
/* ── Marquee ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const CHIPS_A = [
|
const CHIPS_A = [
|
||||||
"13 agents",
|
"18 agents",
|
||||||
"Custom agents",
|
"Custom agents",
|
||||||
"Six chiptunes",
|
"Six chiptunes",
|
||||||
"Focus-aware slots",
|
"Focus-aware slots",
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ export interface VisitorAnalytics {
|
|||||||
ttfb?: number | null;
|
ttfb?: number | null;
|
||||||
dom_load?: number | null;
|
dom_load?: number | null;
|
||||||
load_complete?: number | null;
|
load_complete?: number | null;
|
||||||
|
stage?: string | null;
|
||||||
|
host?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
src/env/server.ts
vendored
6
src/env/server.ts
vendored
@@ -69,6 +69,12 @@ const serverEnvSchema = z.object({
|
|||||||
// Bearer secret for the daily lifecycle-email cron. Falls back to
|
// Bearer secret for the daily lifecycle-email cron. Falls back to
|
||||||
// Vercel's CRON_SECRET; unset + no CRON_SECRET = the endpoint denies all.
|
// Vercel's CRON_SECRET; unset + no CRON_SECRET = the endpoint denies all.
|
||||||
NOOK_LIFECYCLE_SECRET: z.string().min(1).optional(),
|
NOOK_LIFECYCLE_SECRET: z.string().min(1).optional(),
|
||||||
|
// Daily VisitorAnalytics retention cron (/api/analytics-retention). Same
|
||||||
|
// shape: falls back to CRON_SECRET, and unset denies every request.
|
||||||
|
ANALYTICS_RETENTION_SECRET: z.string().min(1).optional(),
|
||||||
|
// How long a VisitorAnalytics row lives. Unset (or unusable) = 365 days;
|
||||||
|
// see src/server/analytics-retention.ts before shortening it.
|
||||||
|
ANALYTICS_RETENTION_DAYS: z.string().optional(),
|
||||||
CRON_SECRET: z.string().min(1).optional()
|
CRON_SECRET: z.string().min(1).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
35
src/routes/api/analytics-retention.ts
Normal file
35
src/routes/api/analytics-retention.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { env } from "~/env/server";
|
||||||
|
import { ConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { runRetentionCron } from "~/server/analytics-retention";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/analytics-retention — daily Vercel cron.
|
||||||
|
*
|
||||||
|
* Deletes `VisitorAnalytics` rows older than the retention window, so visitor
|
||||||
|
* data has a bound instead of accumulating forever (FRE-369, finding Q4 — the
|
||||||
|
* table's only cleaner used to be a manual admin call, and since FRE-366 the
|
||||||
|
* rows carry a truncated but still real client address).
|
||||||
|
*
|
||||||
|
* Window: `ANALYTICS_RETENTION_DAYS`, defaulting to 365 when unset or unusable
|
||||||
|
* (see `~/server/analytics-retention` for why the default is conservative
|
||||||
|
* rather than the reviewer's 90).
|
||||||
|
*
|
||||||
|
* Auth: `Authorization: Bearer <secret>` where the secret is
|
||||||
|
* `ANALYTICS_RETENTION_SECRET` or Vercel's `CRON_SECRET`. An unconfigured secret
|
||||||
|
* denies every request — an unauthenticated trigger of a bulk delete is the one
|
||||||
|
* outcome this endpoint must never have.
|
||||||
|
*/
|
||||||
|
export async function GET(event: APIEvent) {
|
||||||
|
const result = await runRetentionCron({
|
||||||
|
authorization: event.request.headers.get("authorization"),
|
||||||
|
secret: env.ANALYTICS_RETENTION_SECRET ?? env.CRON_SECRET,
|
||||||
|
configuredDays: env.ANALYTICS_RETENTION_DAYS,
|
||||||
|
conn: ConnectionFactory()
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(result.body), {
|
||||||
|
status: result.status,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ export async function GET(event: APIEvent) {
|
|||||||
|
|
||||||
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
||||||
|
|
||||||
await trackDownload(event, assetKeys[asset]!);
|
await trackDownload(event, assetKeys[asset]!, "served");
|
||||||
|
|
||||||
// SAFETY: `transformToByteArray` yields `Uint8Array<ArrayBufferLike>`, which the
|
// SAFETY: `transformToByteArray` yields `Uint8Array<ArrayBufferLike>`, which the
|
||||||
// DOM lib's `BufferSource` (ArrayBuffer-backed) does not accept, though undici
|
// DOM lib's `BufferSource` (ArrayBuffer-backed) does not accept, though undici
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { env } from "~/env/server";
|
|||||||
import { TURNSTILE_CONFIG } from "~/config";
|
import { TURNSTILE_CONFIG } from "~/config";
|
||||||
import { verifyTurnstileToken } from "~/server/fetch-utils";
|
import { verifyTurnstileToken } from "~/server/fetch-utils";
|
||||||
import { json, error } from "./_lib";
|
import { json, error } from "./_lib";
|
||||||
|
import { trackRequest } from "~/server/track-download";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/the-nook/checkout
|
* POST /api/the-nook/checkout
|
||||||
@@ -12,6 +13,14 @@ import { json, error } from "./_lib";
|
|||||||
* session for the one-time $10 The Nook license. Returns the hosted
|
* session for the one-time $10 The Nook license. Returns the hosted
|
||||||
* checkout URL for the client to redirect to.
|
* checkout URL for the client to redirect to.
|
||||||
*
|
*
|
||||||
|
* Analytics: a created session is the funnel's "checkout started" stage
|
||||||
|
* (FRE-366). Until then the only trace was the client-side /checkout page
|
||||||
|
* view — a page, not a started checkout. Recorded through the shared
|
||||||
|
* `trackRequest` writer (referrer, campaign tags, country, real client
|
||||||
|
* address, host) under this route's own path, best-effort: a failed
|
||||||
|
* analytics write never blocks a checkout, and an attempt Turnstile or
|
||||||
|
* Stripe rejects is correctly not a started checkout.
|
||||||
|
*
|
||||||
* Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no
|
* Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no
|
||||||
* `stripe` npm dependency added.
|
* `stripe` npm dependency added.
|
||||||
*/
|
*/
|
||||||
@@ -67,5 +76,14 @@ export async function POST(event: APIEvent) {
|
|||||||
if (!data.url) {
|
if (!data.url) {
|
||||||
return error("Stripe returned no checkout URL", 502);
|
return error("Stripe returned no checkout URL", 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The session exists: that is "checkout started". Awaited so the row is
|
||||||
|
// flushed before the response — a buffered write is not guaranteed to
|
||||||
|
// survive the response on serverless.
|
||||||
|
await trackRequest(event, {
|
||||||
|
path: "/api/the-nook/checkout",
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
|
||||||
return json({ checkoutUrl: data.url });
|
return json({ checkoutUrl: data.url });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||||
|
import { truncateIpAddress } from "~/server/request-client";
|
||||||
|
|
||||||
// The route's contracts:
|
// The route's contracts:
|
||||||
// 1. Every click is recorded through the shared analytics buffer with the
|
// 1. Every click is recorded through the shared analytics buffer with the
|
||||||
@@ -55,9 +56,13 @@ mock.module("~/server/analytics", () => ({
|
|||||||
flushAnalytics: async () => {
|
flushAnalytics: async () => {
|
||||||
flushCalls += 1;
|
flushCalls += 1;
|
||||||
},
|
},
|
||||||
// Mirrors the real enrichment contract: deviceType is derived from the UA.
|
// Mirrors the real enrichment contract: deviceType is derived from the UA,
|
||||||
|
// and the address is cut to its prefix by the FRE-369 privacy floor. The
|
||||||
|
// real `enrichAnalyticsEntry` does both, so the mirror has to apply the same
|
||||||
|
// truncation — otherwise this file would prove the door stores a full address.
|
||||||
enrichAnalyticsEntry: (entry: Entry) => ({
|
enrichAnalyticsEntry: (entry: Entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
|
ipAddress: truncateIpAddress(entry.ipAddress as string | null),
|
||||||
deviceType: String(entry.userAgent ?? "").includes("Macintosh")
|
deviceType: String(entry.userAgent ?? "").includes("Macintosh")
|
||||||
? "desktop"
|
? "desktop"
|
||||||
: null
|
: null
|
||||||
@@ -113,6 +118,10 @@ describe("GET /api/the-nook/download", () => {
|
|||||||
expect(logged[0]).toMatchObject({
|
expect(logged[0]).toMatchObject({
|
||||||
path: "/api/downloads/thenook",
|
path: "/api/downloads/thenook",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
// The door is the click half of the split; the file route writes
|
||||||
|
// "served". And the row names the site it came from (FRE-366).
|
||||||
|
stage: "click",
|
||||||
|
host: "nook.freno.me",
|
||||||
referrer: "https://nook.freno.me/"
|
referrer: "https://nook.freno.me/"
|
||||||
});
|
});
|
||||||
// Flushed within the request, not left to the batch timer.
|
// Flushed within the request, not left to the batch timer.
|
||||||
@@ -137,9 +146,16 @@ describe("GET /api/the-nook/download", () => {
|
|||||||
expect(logged[2]!.country).toBe("JP");
|
expect(logged[2]!.country).toBe("JP");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the forwarded address when no request IP is available", async () => {
|
it("records the forwarded visitor below the trusted edge, truncated", async () => {
|
||||||
await GET(request({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" }));
|
// Client → Cloudflare → origin proxy: the sender's spoof is first, the real
|
||||||
expect(logged[0]!.ipAddress).toBe("198.51.100.9");
|
// client second, the edge last. And the stored value is the /24, never the
|
||||||
|
// address itself (FRE-369).
|
||||||
|
await GET(
|
||||||
|
request({
|
||||||
|
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(logged[0]!.ipAddress).toBe("203.0.113.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still logs the click when the appcast cannot be read", async () => {
|
it("still logs the click when the appcast cannot be read", async () => {
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import { resolveLatestDownload } from "./_download";
|
|||||||
* Why a redirect and not a direct S3 link: the appcast route and the DMG
|
* Why a redirect and not a direct S3 link: the appcast route and the DMG
|
||||||
* route never logged anything, so step 2 of the funnel (download click →
|
* route never logged anything, so step 2 of the funnel (download click →
|
||||||
* DMG served) was invisible. Every click now lands one row in
|
* DMG served) was invisible. Every click now lands one row in
|
||||||
* VisitorAnalytics under `/api/downloads/thenook`, alongside the clicks and
|
* VisitorAnalytics under `/api/downloads/thenook` with `stage = "click"`,
|
||||||
* served files of every other product — see `~/server/track-download`.
|
* distinct from the `stage = "served"` row the file route writes when the
|
||||||
|
* DMG is actually streamed — the split FRE-366 added, alongside the clicks
|
||||||
|
* and served files of every other product — see `~/server/track-download`.
|
||||||
*
|
*
|
||||||
* Referrer, campaign tags, edge country (with `?c=` override) and user agent
|
* Referrer, campaign tags, edge country (with `?c=` override) and user agent
|
||||||
* are read by `trackDownload`, which is shared with the other download doors.
|
* are read by `trackDownload`, which is shared with the other download doors.
|
||||||
@@ -28,7 +30,7 @@ import { resolveLatestDownload } from "./_download";
|
|||||||
export async function GET(event: APIEvent): Promise<Response> {
|
export async function GET(event: APIEvent): Promise<Response> {
|
||||||
// Recorded before resolving: a click that dies at resolution is exactly the
|
// Recorded before resolving: a click that dies at resolution is exactly the
|
||||||
// failure the funnel needs to show.
|
// failure the funnel needs to show.
|
||||||
await trackDownload(event, "thenook");
|
await trackDownload(event, "thenook", "click");
|
||||||
|
|
||||||
let target: string | null = null;
|
let target: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { describe, expect, it, mock, beforeEach } from "bun:test";
|
|||||||
|
|
||||||
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
||||||
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
||||||
|
/** Every rate-limit key the route derived, in order. */
|
||||||
|
const rateKeys: string[] = [];
|
||||||
|
|
||||||
mock.module("~/server/email", () => ({
|
mock.module("~/server/email", () => ({
|
||||||
default: async (to: string, subject: string, html: string) => {
|
default: async (to: string, subject: string, html: string) => {
|
||||||
@@ -19,7 +21,10 @@ mock.module("~/server/email", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("~/server/bug-report-rate-limit", () => ({
|
mock.module("~/server/bug-report-rate-limit", () => ({
|
||||||
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true }
|
takeBugReportToken: (key: string) => {
|
||||||
|
rateKeys.push(key);
|
||||||
|
return tokenAnswers.shift() ?? { allowed: true };
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { POST } = await import("./report");
|
const { POST } = await import("./report");
|
||||||
@@ -72,6 +77,7 @@ describe("POST /api/the-nook/report", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sentEmails.length = 0;
|
sentEmails.length = 0;
|
||||||
tokenAnswers.length = 0;
|
tokenAnswers.length = 0;
|
||||||
|
rateKeys.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emails the owner a report containing the snapshot, escaped", async () => {
|
it("emails the owner a report containing the snapshot, escaped", async () => {
|
||||||
@@ -133,10 +139,49 @@ describe("POST /api/the-nook/report", () => {
|
|||||||
|
|
||||||
it("still succeeds when no fingerprint header exists", async () => {
|
it("still succeeds when no fingerprint header exists", async () => {
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" })
|
request(validPayload, {
|
||||||
|
"cf-connecting-ip": "203.0.113.9",
|
||||||
|
"x-forwarded-for": "203.0.113.9, 10.0.0.1"
|
||||||
|
})
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(sentEmails).toHaveLength(1);
|
expect(sentEmails).toHaveLength(1);
|
||||||
|
// The quota key is the Cloudflare-recorded client, not a header hop the
|
||||||
|
// sender chose.
|
||||||
|
expect(rateKeys).toEqual(["203.0.113.9"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rotating leftmost x-forwarded-for does not mint a fresh quota key", async () => {
|
||||||
|
// FRE-371: the old read keyed on the leftmost hop, which the sender
|
||||||
|
// controls outright — one scripted loop with a rotating header had
|
||||||
|
// unlimited reports. With the same Cloudflare-recorded client and a
|
||||||
|
// different spoofed prefix per request, the key must be stable.
|
||||||
|
for (const spoof of ["1.1.1.1", "2.2.2.2", "3.3.3.3"]) {
|
||||||
|
const res = await POST(
|
||||||
|
request(validPayload, {
|
||||||
|
"cf-connecting-ip": "203.0.113.9",
|
||||||
|
"x-forwarded-for": `${spoof}, 203.0.113.9, 10.0.0.1`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rateKeys).toEqual([
|
||||||
|
"203.0.113.9",
|
||||||
|
"203.0.113.9",
|
||||||
|
"203.0.113.9"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys on the rightmost-minus-one hop when Cloudflare's header is absent", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
request(validPayload, {
|
||||||
|
"x-forwarded-for": "6.6.6.6, 203.0.113.9, 10.0.0.1"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Never the leftmost (sender-chosen) hop.
|
||||||
|
expect(rateKeys).toEqual(["203.0.113.9"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delivers the landing page's beta request with only the email", async () => {
|
it("delivers the landing page's beta request with only the email", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import sendEmail from "~/server/email";
|
import sendEmail from "~/server/email";
|
||||||
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
||||||
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
||||||
|
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||||
import { json, error } from "./_lib";
|
import { json, error } from "./_lib";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,10 +104,9 @@ export async function POST(event: APIEvent): Promise<Response> {
|
|||||||
)
|
)
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const forwarded = event.request.headers.get("x-forwarded-for");
|
// FRE-371: the IP fallback must be the spoof-resistant read — a rotating
|
||||||
const rateKey =
|
// leftmost x-forwarded-for used to mint unlimited report quota.
|
||||||
fingerprint ||
|
const rateKey = fingerprint || resolveTrustedClientIp(event);
|
||||||
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
|
|
||||||
const token = takeBugReportToken(rateKey);
|
const token = takeBugReportToken(rateKey);
|
||||||
if (!token.allowed) {
|
if (!token.allowed) {
|
||||||
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Regression test for the /agents roster (NOOK-336).
|
* Regression test for the /agents roster and its coverage count.
|
||||||
*
|
*
|
||||||
* The page mirrors the app's registry, `AgentSpec.all` in
|
* The page mirrors the app's registry, `AgentSpec.all` in
|
||||||
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
* them. The registry lives in another repo, so the expected roster is
|
* them. The registry lives in another repo, so the expected roster is
|
||||||
* restated here; when an agent is added there, add it here and on the page.
|
* restated here; when an agent is added there, add it here and on the page.
|
||||||
*
|
*
|
||||||
|
* FRE-997 restored the count. The page said thirteen while the registry held
|
||||||
|
* eighteen, so the count was dropped rather than corrected (FRE-359 verdict B);
|
||||||
|
* now that all eighteen rows are on the page, the roster check is an equality
|
||||||
|
* check again and the page states eighteen. A subset check would let a shipped
|
||||||
|
* agent go missing behind a passing suite, which is the NOOK-336 failure.
|
||||||
|
*
|
||||||
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
@@ -16,7 +22,10 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
||||||
|
|
||||||
/** AgentSpec.all display names, in registry order. */
|
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||||
|
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
|
||||||
|
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
|
||||||
const REGISTRY = [
|
const REGISTRY = [
|
||||||
"Claude Code",
|
"Claude Code",
|
||||||
"Codex",
|
"Codex",
|
||||||
@@ -30,25 +39,31 @@ const REGISTRY = [
|
|||||||
"Gemini CLI",
|
"Gemini CLI",
|
||||||
"Cline",
|
"Cline",
|
||||||
"Qwen Code",
|
"Qwen Code",
|
||||||
"DeepSeek"
|
"DeepSeek",
|
||||||
|
"Kimi",
|
||||||
|
"GitHub Copilot CLI",
|
||||||
|
"Crush",
|
||||||
|
"Cursor",
|
||||||
|
"Amp"
|
||||||
];
|
];
|
||||||
|
|
||||||
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen"];
|
|
||||||
|
|
||||||
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
||||||
|
|
||||||
describe("Nook /agents roster (NOOK-336)", () => {
|
describe("Nook /agents roster", () => {
|
||||||
it("lists every registry agent, in registry order", () => {
|
it("lists every registry agent, once, in registry order", () => {
|
||||||
expect(entries).toEqual(REGISTRY);
|
expect(entries).toEqual(REGISTRY);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("states the same count everywhere it names one", () => {
|
it("states the registry's size", () => {
|
||||||
const word = NUMBER_WORDS[entries.length - 12];
|
expect(entries.length).toBe(18);
|
||||||
for (const other of NUMBER_WORDS.filter((w) => w !== word)) {
|
expect(COPY).toContain("all eighteen");
|
||||||
expect(SOURCE.toLowerCase()).not.toContain(other);
|
expect(COPY).toContain("The 18 agents");
|
||||||
}
|
expect(COPY).toContain("eighteen coding agents");
|
||||||
expect(SOURCE).toContain(`all ${word}`);
|
});
|
||||||
expect(SOURCE).toContain(`The ${entries.length} agents`);
|
|
||||||
expect(SOURCE).toContain(`watches ${word} coding agents`);
|
it("carries no stale count", () => {
|
||||||
|
expect(COPY.toLowerCase()).not.toContain("thirteen");
|
||||||
|
expect(COPY).not.toMatch(/\b13 agents\b/);
|
||||||
|
expect(COPY).not.toContain("all thirteen");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* `/agents` on the Nook subdomain — supported agents and their event coverage.
|
* `/agents` on the Nook subdomain — all eighteen agents in the registry and
|
||||||
|
* each row's event coverage.
|
||||||
*
|
*
|
||||||
* The registry below mirrors `AgentSpec.all` in the app
|
* The registry below mirrors `AgentSpec.all` in the app
|
||||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
|
* (~/Code/the-nook/Sources/NookCore/Agents/AgentRegistry.swift) and each
|
||||||
* installed hook surface (hook/setup.go) or plugin event subscription
|
* agent's installed hook surface (hook/setup.go) or plugin event subscription
|
||||||
* (plugins/*), normalized onto the shared event vocabulary
|
* (plugins/*), normalized onto the shared event vocabulary
|
||||||
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
||||||
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
||||||
@@ -259,6 +260,92 @@ const AGENTS: AgentEntry[] = [
|
|||||||
],
|
],
|
||||||
gating:
|
gating:
|
||||||
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kimi",
|
||||||
|
wiring: "Hook rules in its TOML config, one per event",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"AgentStart",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PermissionResolved",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact",
|
||||||
|
"Notification"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — every event appears as a card; its PreToolUse fires before its own permission check, so the agent keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GitHub Copilot CLI",
|
||||||
|
wiring: "One hook file of ours in its hooks directory",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"ErrorOccurred",
|
||||||
|
"PreCompact",
|
||||||
|
"Notification"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — every event appears as a card; its permission request runs before its own policy check, so the agent keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Crush",
|
||||||
|
wiring:
|
||||||
|
"One entry in its JSONC config, the only event it documents",
|
||||||
|
events: ["PreToolUse"],
|
||||||
|
gating:
|
||||||
|
"Observation — tool calls appear as cards; its own permission prompts decide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cursor",
|
||||||
|
wiring:
|
||||||
|
"Hook entries in its hooks.json, merged with your own. No permission hook, on purpose",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"AfterAgentResponse",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — a permission hook that stays silent blocks the action here, so Cursor keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amp",
|
||||||
|
wiring: "In-process extension package",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"SessionError"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — its plugin API has no permission-wait event, so the agent keeps its own prompting"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -283,8 +370,8 @@ export default function NookAgentsPage() {
|
|||||||
<EdgeCacheHeaders maxAge={300} />
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Supported agents"
|
title="Supported agents"
|
||||||
description="Every coding agent The Nook supports — all thirteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
description="Every coding agent The Nook supports — all eighteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||||
ogTitle="The 13 agents The Nook supports, and what each hook sees"
|
ogTitle="The 18 agents The Nook supports, and what each hook sees"
|
||||||
/>
|
/>
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||||
@@ -292,7 +379,7 @@ export default function NookAgentsPage() {
|
|||||||
Supported agents
|
Supported agents
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
||||||
The Nook watches thirteen coding agents in one place — your menu bar and
|
The Nook watches eighteen coding agents in one place — your menu bar and
|
||||||
notch show every session that is running, thinking, or blocked, and a
|
notch show every session that is running, thinking, or blocked, and a
|
||||||
card answers permission prompts without leaving your keyboard.
|
card answers permission prompts without leaving your keyboard.
|
||||||
</p>
|
</p>
|
||||||
@@ -300,8 +387,7 @@ export default function NookAgentsPage() {
|
|||||||
Each agent reports its own set of events. "Allow / Deny from a card"
|
Each agent reports its own set of events. "Allow / Deny from a card"
|
||||||
means the agent hands the decision to The Nook while it waits;
|
means the agent hands the decision to The Nook while it waits;
|
||||||
"Observe" means every event still appears live, but the agent keeps
|
"Observe" means every event still appears live, but the agent keeps
|
||||||
its own prompting. Agents can run on this Mac or on remote machines
|
its own prompting.
|
||||||
over SSH — remote sessions report into the same grid.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
|||||||
126
src/routes/nook/remote-claims.test.ts
Normal file
126
src/routes/nook/remote-claims.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Regression test for the removed remote / SSH claims and the removed agent
|
||||||
|
* count (FRE-323, FRE-359 verdict B).
|
||||||
|
*
|
||||||
|
* FRE-323 took the remote story off the site because it was unverified and
|
||||||
|
* demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
|
||||||
|
* installs the nook-hook" flow is app-UI-only, needs a hand-edited
|
||||||
|
* `~/.ssh/config` for any host not already in ssh config or on the tailnet,
|
||||||
|
* needs a one-time `ssh-copy-id` because the app runs ssh with
|
||||||
|
* `BatchMode=yes`, and has no manual fallback for minting the per-host
|
||||||
|
* credential. FRE-359 verdict B removed the coverage count for the same
|
||||||
|
* reason: the /agents table lists 13 of the registry's 18 rows.
|
||||||
|
*
|
||||||
|
* FRE-997 corrects the second half without touching the first. The count was
|
||||||
|
* stale, not unverifiable: `AgentSpec.all` holds eighteen, /agents now lists
|
||||||
|
* all eighteen and `agents.test.ts` asserts that roster as an equality again.
|
||||||
|
* So the two completeness phrasings FRE-359 banned ("every coding agent",
|
||||||
|
* "Supported agents") no longer rest on a 13-of-18 gap — the ban existed
|
||||||
|
* because the page claimed the set and listed two thirds of it. They are out
|
||||||
|
* of `BANNED_COVERAGE` on this branch for that reason alone; the stale counts
|
||||||
|
* stay banned, and every FRE-323 remote sentence stays banned, because this
|
||||||
|
* branch still carries the FRE-323 site state.
|
||||||
|
*
|
||||||
|
* The copy was not thrown away — it is parked in
|
||||||
|
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
|
||||||
|
* it is still there so nobody deletes the parked story as stale. It goes back
|
||||||
|
* on the site only when the one-click install ships and is recorded.
|
||||||
|
*
|
||||||
|
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
|
||||||
|
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
|
||||||
|
*
|
||||||
|
* Deliberate exceptions, not audited here — both were weighed with the Chief
|
||||||
|
* of Staff at the go/no-go and left in place on purpose:
|
||||||
|
* - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip
|
||||||
|
* it" is a limitation disclosure, not a capability claim.
|
||||||
|
* - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to
|
||||||
|
* the removed remote story, but tailnet host lookup does exist. Raised on
|
||||||
|
* FRE-323 rather than widened into this correction.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "../../..");
|
||||||
|
|
||||||
|
/** Shipped page and component source. Tests and drafts may quote the old copy. */
|
||||||
|
const AUDITED = [
|
||||||
|
"src/routes/nook/index.tsx",
|
||||||
|
"src/routes/nook/agents.tsx",
|
||||||
|
"src/routes/nook/faq.tsx",
|
||||||
|
"src/components/nook/FeatureCollage.tsx",
|
||||||
|
"src/components/nook/FeatureBreakdowns.tsx",
|
||||||
|
"src/components/nook/IslandExtras.tsx"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Every sentence FRE-323 ordered off the site. */
|
||||||
|
const BANNED_REMOTE_CLAIMS = [
|
||||||
|
"Agents on other machines",
|
||||||
|
"one click installs the nook-hook",
|
||||||
|
"Any SSH box",
|
||||||
|
"Works across every hooked agent, local or remote",
|
||||||
|
"Remote sessions are ordinary rows",
|
||||||
|
"One-click SSH setup",
|
||||||
|
"remote machines over SSH",
|
||||||
|
"this Mac or remote machines"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** FRE-359 verdict B: no stale count, in any form.
|
||||||
|
*
|
||||||
|
* "every coding agent" and "Supported agents" left this list with FRE-997:
|
||||||
|
* the page now lists all eighteen registry rows and `agents.test.ts` asserts
|
||||||
|
* that equality, so the completeness claim is backed rather than incomplete. */
|
||||||
|
const BANNED_COVERAGE = [
|
||||||
|
"all thirteen",
|
||||||
|
"thirteen coding agents",
|
||||||
|
"13 agents"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||||
|
function copyOf(file: string): string {
|
||||||
|
return readFileSync(join(ROOT, file), "utf8")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("remote claims stay off the site (FRE-323)", () => {
|
||||||
|
for (const file of AUDITED) {
|
||||||
|
it(`${file} does not restore a removed remote sentence`, () => {
|
||||||
|
const copy = copyOf(file);
|
||||||
|
for (const phrase of BANNED_REMOTE_CLAIMS) {
|
||||||
|
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("/agents no longer offers remote sessions", () => {
|
||||||
|
const copy = copyOf("src/routes/nook/agents.tsx");
|
||||||
|
expect(copy).not.toContain("remote sessions report into the same grid");
|
||||||
|
expect(copy).not.toContain("on this Mac or on remote");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => {
|
||||||
|
const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8");
|
||||||
|
for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) {
|
||||||
|
expect(parked).toContain(phrase);
|
||||||
|
}
|
||||||
|
expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain(
|
||||||
|
"FRE-320"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the agent count is corrected, not dropped (FRE-997)", () => {
|
||||||
|
for (const file of AUDITED) {
|
||||||
|
it(`${file} states no stale count`, () => {
|
||||||
|
const copy = copyOf(file);
|
||||||
|
for (const phrase of BANNED_COVERAGE) {
|
||||||
|
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("/agents states the registry's size", () => {
|
||||||
|
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Supported agents");
|
||||||
|
});
|
||||||
|
});
|
||||||
139
src/routes/smarter-remote/privacy.tsx
Normal file
139
src/routes/smarter-remote/privacy.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* A Smarter Remote privacy policy — `freno.me/smarter-remote/privacy`.
|
||||||
|
*
|
||||||
|
* Linked from the iOS app's paywall (App Review 3.1.2). Reflects what the app
|
||||||
|
* actually does: TV control stays on the local network, pairing data stays on
|
||||||
|
* the phone, purchases go through Apple and RevenueCat, and sharing is a QR
|
||||||
|
* code passed between phones.
|
||||||
|
*/
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
|
||||||
|
export default function SmarterRemotePrivacyPolicy() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
|
<PageHead
|
||||||
|
title="A Smarter Remote Privacy Policy"
|
||||||
|
description="Privacy policy for A Smarter Remote, a universal TV remote app that works over your local Wi-Fi network."
|
||||||
|
/>
|
||||||
|
<div class="bg-base">
|
||||||
|
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||||
|
<div class="py-4 text-xl">A Smarter Remote's Privacy Policy</div>
|
||||||
|
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
Welcome to A Smarter Remote ('We', 'Us',
|
||||||
|
'Our'). The app controls your TVs over your own Wi-Fi
|
||||||
|
network. It has no accounts, no ads, and no analytics. This policy
|
||||||
|
explains the little data it does handle.
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Your TVs:</div> The app finds TVs on
|
||||||
|
your local network and stores their names, network addresses,
|
||||||
|
and platform on your phone. Pairing codes and tokens are kept
|
||||||
|
in your phone's Keychain. This data is sent only to the
|
||||||
|
TV itself, over your local network. It never reaches us.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Purchases:</div> Payments are handled
|
||||||
|
by Apple. We use RevenueCat to check whether you own A Smarter
|
||||||
|
Remote Pro. RevenueCat receives an anonymous app user ID, your
|
||||||
|
purchase and subscription history, and basic device and app
|
||||||
|
details such as OS version, app version, and region. It does
|
||||||
|
not receive your name, email, or payment details.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Voice input:</div> If you use voice
|
||||||
|
search, your speech is converted to text by Apple's
|
||||||
|
speech recognition. Apple may process it on its servers. The
|
||||||
|
resulting text is sent only to your TV.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Sharing:</div> If you share a TV with
|
||||||
|
someone, its name, address, and platform are put into a QR
|
||||||
|
code on your screen for them to scan. It goes from one phone
|
||||||
|
to the other and does not pass through our servers.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
Data about your TVs is used only to control them. Purchase
|
||||||
|
records are used only to unlock Pro. We do not use any of it for
|
||||||
|
advertising, and we do not sell it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">3.</span> Local Network Access
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
iOS asks for permission before the app can use your local
|
||||||
|
network. The app needs it to find and control your TVs. If you
|
||||||
|
decline, the app cannot reach your TVs. You can change this in
|
||||||
|
Settings > Privacy & Security > Local Network.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">4.</span> Deleting Your Data
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
Removing a TV in the app deletes its stored details and
|
||||||
|
credentials from your phone. Deleting the app removes all of
|
||||||
|
them. Subscriptions are managed and cancelled in your App Store
|
||||||
|
account settings.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">5.</span> Children's Privacy
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
The app does not knowingly collect personal information from
|
||||||
|
anyone, including children under 13.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">6.</span> Changes to the Privacy Policy
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
We may update this policy periodically. Any changes are posted on
|
||||||
|
this page, so please review it from time to time.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">7.</span> Contact Us
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
If you have any questions about this policy, you can contact us{" "}
|
||||||
|
<A href="/contact" class="text-blue hover-underline-animation">
|
||||||
|
here
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/routes/smarter-remote/terms.tsx
Normal file
141
src/routes/smarter-remote/terms.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* A Smarter Remote terms of use — `freno.me/smarter-remote/terms`.
|
||||||
|
*
|
||||||
|
* Linked from the iOS app's paywall (App Review 3.1.2). Builds on Apple's
|
||||||
|
* standard EULA and adds the Pro subscription and lifetime purchase terms.
|
||||||
|
*/
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
|
||||||
|
export default function SmarterRemoteTerms() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
|
<PageHead
|
||||||
|
title="A Smarter Remote Terms of Use"
|
||||||
|
description="Terms of use for A Smarter Remote and its Pro subscription."
|
||||||
|
/>
|
||||||
|
<div class="bg-base">
|
||||||
|
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||||
|
<div class="py-4 text-xl">A Smarter Remote's Terms of Use</div>
|
||||||
|
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
These terms cover your use of the A Smarter Remote app ('the
|
||||||
|
App'). By using the App you agree to them. The App is licensed
|
||||||
|
to you under Apple's{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue hover-underline-animation"
|
||||||
|
>
|
||||||
|
Licensed Application End User License Agreement
|
||||||
|
</a>
|
||||||
|
, and the terms below add to it.
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">1.</span> A Smarter Remote Pro
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) What it includes:</div> The free App
|
||||||
|
controls one TV. Pro removes that limit and unlocks the
|
||||||
|
features marked Pro in the App.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Yearly subscription:</div> Payment is
|
||||||
|
charged to your Apple account when you confirm the purchase,
|
||||||
|
or when any free trial ends. The subscription renews
|
||||||
|
automatically at the same price unless you cancel at least 24
|
||||||
|
hours before the end of the current period. Your account is
|
||||||
|
charged for renewal within 24 hours before the period ends.
|
||||||
|
Any unused part of a free trial is forfeited when you buy a
|
||||||
|
subscription.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Lifetime purchase:</div> A one-time
|
||||||
|
payment that unlocks Pro with no recurring charge.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Managing and refunds:</div> You can
|
||||||
|
manage or cancel a subscription in your App Store account
|
||||||
|
settings. Refunds are handled by Apple under its policies.
|
||||||
|
Restore Purchases in the App re-unlocks Pro on another device
|
||||||
|
signed in to the same Apple account.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">2.</span> Your TVs
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
The App controls TVs through the network features their makers
|
||||||
|
provide. Which features work depends on your TV's model and
|
||||||
|
software, which can change without notice. We do not guarantee
|
||||||
|
that every feature works on every TV. Only control TVs you own
|
||||||
|
or have permission to use.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">3.</span> No Warranty
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
The App is provided 'as is', without warranties of any
|
||||||
|
kind, to the extent the law allows. To the extent the law allows,
|
||||||
|
we are not liable for indirect or consequential damages arising
|
||||||
|
from your use of the App.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">4.</span> Privacy
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
How the App handles data is described in the{" "}
|
||||||
|
<A
|
||||||
|
href="/smarter-remote/privacy"
|
||||||
|
class="text-blue hover-underline-animation"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">5.</span> Changes to These Terms
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
We may update these terms periodically. Any changes are posted on
|
||||||
|
this page. Continuing to use the App after a change means you
|
||||||
|
accept the new terms.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">6.</span> Contact Us
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
If you have any questions about these terms, you can contact us{" "}
|
||||||
|
<A href="/contact" class="text-blue hover-underline-animation">
|
||||||
|
here
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
src/server/analytics-retention.test.ts
Normal file
200
src/server/analytics-retention.test.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { VISITOR_ANALYTICS_DDL } from "./analytics-schema";
|
||||||
|
import {
|
||||||
|
DEFAULT_ANALYTICS_RETENTION_DAYS,
|
||||||
|
MAX_ANALYTICS_RETENTION_DAYS,
|
||||||
|
isAuthorizedCron,
|
||||||
|
pruneVisitorAnalytics,
|
||||||
|
resolveRetentionDays,
|
||||||
|
runRetentionCron
|
||||||
|
} from "./analytics-retention";
|
||||||
|
|
||||||
|
// Why a real in-memory SQLite database: retention is a `DELETE` whose whole
|
||||||
|
// job is a date comparison. A stub returning a canned count would pass with a
|
||||||
|
// window that is off by a unit, inverted, or — the failure this file exists for
|
||||||
|
// — never applied at all. Same reasoning as the backfill and schema suites, and
|
||||||
|
// nothing is module-mocked here on purpose: bun's `mock.module` is process-wide.
|
||||||
|
|
||||||
|
let db: Database;
|
||||||
|
|
||||||
|
const conn = {
|
||||||
|
async execute(query: { sql: string; args?: unknown[] }) {
|
||||||
|
const statement = db.query(query.sql);
|
||||||
|
const args = (query.args ?? []) as never[];
|
||||||
|
// `DELETE … RETURNING id` answers with rows, which is how the prune counts
|
||||||
|
// what it removed; bun:sqlite exposes those through `.all()`.
|
||||||
|
if (
|
||||||
|
/^\s*(select|pragma|with)\b/i.test(query.sql) ||
|
||||||
|
/\breturning\b/i.test(query.sql)
|
||||||
|
) {
|
||||||
|
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rows: [] as unknown[],
|
||||||
|
rowsAffected: statement.run(...args).changes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function insert(id: string, createdAt: string) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES (?, '/x', 'GET', ?)`,
|
||||||
|
[id, createdAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowCount(): number {
|
||||||
|
return (db.query(`SELECT COUNT(*) AS n FROM VisitorAnalytics`).get() as {
|
||||||
|
n: number;
|
||||||
|
}).n;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new Database(":memory:");
|
||||||
|
db.run(VISITOR_ANALYTICS_DDL);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRetentionDays", () => {
|
||||||
|
it("takes a usable configured window", () => {
|
||||||
|
expect(resolveRetentionDays("90")).toBe(90);
|
||||||
|
expect(resolveRetentionDays(30)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default when the value is missing or nonsense", () => {
|
||||||
|
expect(resolveRetentionDays(undefined)).toBe(
|
||||||
|
DEFAULT_ANALYTICS_RETENTION_DAYS
|
||||||
|
);
|
||||||
|
expect(resolveRetentionDays("")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||||
|
expect(resolveRetentionDays("0")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||||
|
expect(resolveRetentionDays("-5")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||||
|
expect(resolveRetentionDays("1.5")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||||
|
expect(resolveRetentionDays("all")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a window so long it means 'never prune'", () => {
|
||||||
|
expect(resolveRetentionDays(String(MAX_ANALYTICS_RETENTION_DAYS + 1))).toBe(
|
||||||
|
DEFAULT_ANALYTICS_RETENTION_DAYS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneVisitorAnalytics", () => {
|
||||||
|
it("deletes only rows older than the window", async () => {
|
||||||
|
insert("young", "2020-01-01 00:00:00");
|
||||||
|
insert("old", "2019-01-01 00:00:00");
|
||||||
|
// A row exactly at `now - 365 days` boundary in the other direction: the
|
||||||
|
// comparison is `<`, so the recent one survives.
|
||||||
|
db.run(
|
||||||
|
`UPDATE VisitorAnalytics SET created_at = datetime('now', '-10 days') WHERE id = 'young'`
|
||||||
|
);
|
||||||
|
db.run(
|
||||||
|
`UPDATE VisitorAnalytics SET created_at = datetime('now', '-400 days') WHERE id = 'old'`
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleted = await pruneVisitorAnalytics(conn, 365);
|
||||||
|
|
||||||
|
expect(deleted).toBe(1);
|
||||||
|
expect(rowCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes nothing when every row is inside the window", async () => {
|
||||||
|
insert("a", "x");
|
||||||
|
db.run(
|
||||||
|
`UPDATE VisitorAnalytics SET created_at = datetime('now', '-1 day') WHERE id = 'a'`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await pruneVisitorAnalytics(conn, 365)).toBe(0);
|
||||||
|
expect(rowCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a window it was not given a number for", async () => {
|
||||||
|
insert("a", "2019-01-01 00:00:00");
|
||||||
|
|
||||||
|
await expect(pruneVisitorAnalytics(conn, 0)).rejects.toThrow(/window/);
|
||||||
|
await expect(pruneVisitorAnalytics(conn, -30)).rejects.toThrow(/window/);
|
||||||
|
await expect(pruneVisitorAnalytics(conn, NaN)).rejects.toThrow(/window/);
|
||||||
|
// "0" is not a window either — and must not delete the table.
|
||||||
|
await expect(
|
||||||
|
pruneVisitorAnalytics(conn, "0" as unknown as number)
|
||||||
|
).rejects.toThrow(/window/);
|
||||||
|
|
||||||
|
expect(rowCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the window as a parameter, not as interpolated SQL", async () => {
|
||||||
|
insert("a", "2019-01-01 00:00:00");
|
||||||
|
const injected = "365); DROP TABLE VisitorAnalytics; --" as unknown as number;
|
||||||
|
|
||||||
|
// Truncating a non-numeric string yields NaN, which the guard refuses — so
|
||||||
|
// the statement never runs and the table is still there.
|
||||||
|
await expect(pruneVisitorAnalytics(conn, injected)).rejects.toThrow(/window/);
|
||||||
|
expect(rowCount()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAuthorizedCron", () => {
|
||||||
|
it("matches the bearer secret and nothing else", () => {
|
||||||
|
expect(isAuthorizedCron("Bearer sekrit", "sekrit")).toBe(true);
|
||||||
|
expect(isAuthorizedCron("bearer SEKRIT".toLowerCase(), "sekrit")).toBe(true);
|
||||||
|
expect(isAuthorizedCron("Bearer nope", "sekrit")).toBe(false);
|
||||||
|
expect(isAuthorizedCron(null, "sekrit")).toBe(false);
|
||||||
|
expect(isAuthorizedCron("Bearer sekrit", null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies everything when no secret is configured (fail closed)", () => {
|
||||||
|
expect(isAuthorizedCron("Bearer ", undefined)).toBe(false);
|
||||||
|
expect(isAuthorizedCron(undefined, "")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runRetentionCron", () => {
|
||||||
|
it("denies an unauthenticated trigger before touching the table", async () => {
|
||||||
|
insert("a", "2019-01-01 00:00:00");
|
||||||
|
|
||||||
|
const res = await runRetentionCron({
|
||||||
|
authorization: null,
|
||||||
|
secret: "sekrit",
|
||||||
|
configuredDays: "365",
|
||||||
|
conn
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(rowCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes on its window and reports what it removed", async () => {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES ('old','/x','GET',datetime('now','-400 days'))`
|
||||||
|
);
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES ('new','/x','GET',datetime('now','-1 day'))`
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await runRetentionCron({
|
||||||
|
authorization: "Bearer sekrit",
|
||||||
|
secret: "sekrit",
|
||||||
|
configuredDays: "365",
|
||||||
|
conn
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ success: true, days: 365, deleted: 1 });
|
||||||
|
expect(rowCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says it failed rather than reporting a quiet zero", async () => {
|
||||||
|
db.close(); // the table is gone: the prune's statement must now throw
|
||||||
|
|
||||||
|
const res = await runRetentionCron({
|
||||||
|
authorization: "Bearer sekrit",
|
||||||
|
secret: "sekrit",
|
||||||
|
configuredDays: "365",
|
||||||
|
conn
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.days).toBe(365);
|
||||||
|
expect(res.body.deleted).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
129
src/server/analytics-retention.ts
Normal file
129
src/server/analytics-retention.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Scheduled retention for `VisitorAnalytics` (FRE-369, finding Q4).
|
||||||
|
*
|
||||||
|
* The table used to grow without bound: `cleanupOldAnalytics` existed but was
|
||||||
|
* reachable only as a manual tRPC call, and once FRE-366 started recording a
|
||||||
|
* real client address instead of the proxy's loopback, "unbounded" meant
|
||||||
|
* unbounded personal data in a hosted third-party database. Two halves answer
|
||||||
|
* that — the write-time prefix in `./request-client`, and this: a bound on how
|
||||||
|
* long any row exists at all.
|
||||||
|
*
|
||||||
|
* The default (365 days) is deliberately conservative so enabling the schedule
|
||||||
|
* is not itself a destructive event: it deletes nothing on its first run
|
||||||
|
* against a database whose history starts inside the window, and it bounds
|
||||||
|
* growth from then on. Tighten it to the reviewer's suggested 90 by setting
|
||||||
|
* `ANALYTICS_RETENTION_DAYS=90` — that first run *will* delete months of
|
||||||
|
* history that the /analytics dashboard and the weekly report read, which is an
|
||||||
|
* operational decision, not one this file should make silently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Kept when `ANALYTICS_RETENTION_DAYS` is unset or unusable. */
|
||||||
|
export const DEFAULT_ANALYTICS_RETENTION_DAYS = 365;
|
||||||
|
|
||||||
|
/** Anything longer is a misconfiguration, not a policy (≈ 2 years). */
|
||||||
|
export const MAX_ANALYTICS_RETENTION_DAYS = 730;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice of a connection this module needs — the same shape
|
||||||
|
* `weekly-metrics.ts` pins as `MetricsConn`, so a libsql client satisfies it
|
||||||
|
* without a cast (and the in-memory SQLite test adapter satisfies it too).
|
||||||
|
*/
|
||||||
|
export interface AnalyticsConn {
|
||||||
|
execute(query: {
|
||||||
|
sql: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
args?: any[];
|
||||||
|
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The retention window to enforce: a positive integer at most
|
||||||
|
* {@link MAX_ANALYTICS_RETENTION_DAYS}, else the default. Accepts the env
|
||||||
|
* string form and a number, because both callers hand over a different shape.
|
||||||
|
*/
|
||||||
|
export function resolveRetentionDays(
|
||||||
|
configured: string | number | undefined | null
|
||||||
|
): number {
|
||||||
|
const days = typeof configured === "number" ? configured : Number(configured);
|
||||||
|
if (!Number.isInteger(days) || days <= 0) return DEFAULT_ANALYTICS_RETENTION_DAYS;
|
||||||
|
if (days > MAX_ANALYTICS_RETENTION_DAYS) return DEFAULT_ANALYTICS_RETENTION_DAYS;
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete analytics rows older than `olderThanDays` and report how many went.
|
||||||
|
*
|
||||||
|
* The window is a SQL parameter, not interpolated text: `cleanupOldAnalytics`
|
||||||
|
* used to splice the number into the statement, which only ever sat behind a
|
||||||
|
* `z.number()` input on the tRPC side — one unvalidated caller away from
|
||||||
|
* injection. A non-integer is refused here rather than passed on, so neither
|
||||||
|
* path can delete on a window the code did not check.
|
||||||
|
*/
|
||||||
|
export async function pruneVisitorAnalytics(
|
||||||
|
conn: AnalyticsConn,
|
||||||
|
olderThanDays: number
|
||||||
|
): Promise<number> {
|
||||||
|
const days = Math.trunc(Number(olderThanDays));
|
||||||
|
if (!Number.isInteger(days) || days <= 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to prune VisitorAnalytics on a non-positive window: ${olderThanDays}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: `DELETE FROM VisitorAnalytics
|
||||||
|
WHERE created_at < datetime('now', ?)
|
||||||
|
RETURNING id`,
|
||||||
|
args: [`-${days} days`]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Counted from `RETURNING id` rather than `rowsAffected`, keeping the contract
|
||||||
|
// `cleanupOldAnalytics` had: the caller is told how many rows actually went.
|
||||||
|
return result.rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a cron may run: Bearer match, and an unset secret denies everything. */
|
||||||
|
export function isAuthorizedCron(
|
||||||
|
authorization: string | null | undefined,
|
||||||
|
secret: string | undefined | null
|
||||||
|
): boolean {
|
||||||
|
if (!secret) return false;
|
||||||
|
const provided = (authorization ?? "").replace(/^Bearer\s+/i, "").trim();
|
||||||
|
return Boolean(provided) && provided === secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetentionOutcome {
|
||||||
|
status: number;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole cron decision, away from the route: authorise, resolve the window,
|
||||||
|
* prune. Returns the status and body the route should send, so the behaviour is
|
||||||
|
* testable without a database or an environment.
|
||||||
|
*
|
||||||
|
* Fail closed on auth (an unauthenticated trigger deleting real history is the
|
||||||
|
* worst thing this endpoint can do), and 500 rather than a silent zero on a
|
||||||
|
* failed prune — a retention job that quietly stopped pruning is exactly the
|
||||||
|
* state that created this issue.
|
||||||
|
*/
|
||||||
|
export async function runRetentionCron(options: {
|
||||||
|
authorization: string | null | undefined;
|
||||||
|
secret: string | undefined | null;
|
||||||
|
configuredDays: string | number | undefined | null;
|
||||||
|
conn: AnalyticsConn;
|
||||||
|
}): Promise<RetentionOutcome> {
|
||||||
|
if (!isAuthorizedCron(options.authorization, options.secret)) {
|
||||||
|
return { status: 401, body: { error: "Unauthorized" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = resolveRetentionDays(options.configuredDays);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deleted = await pruneVisitorAnalytics(options.conn, days);
|
||||||
|
return { status: 200, body: { success: true, days, deleted } };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Analytics retention job failed:", error);
|
||||||
|
return { status: 500, body: { error: "Analytics retention job failed", days } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,9 @@ const sampleArgs = [
|
|||||||
2, // inp
|
2, // inp
|
||||||
3, // ttfb
|
3, // ttfb
|
||||||
4, // dom_load
|
4, // dom_load
|
||||||
5 // load_complete
|
5, // load_complete
|
||||||
|
"click", // stage
|
||||||
|
"nook.freno.me" // host
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -69,7 +71,7 @@ describe("VisitorAnalytics schema", () => {
|
|||||||
}
|
}
|
||||||
// The reverse direction matters too: a column the code writes but the
|
// The reverse direction matters too: a column the code writes but the
|
||||||
// DDL omits is the failure this whole file exists to catch.
|
// DDL omits is the failure this whole file exists to catch.
|
||||||
expect(VISITOR_ANALYTICS_COLUMNS.length).toBe(23);
|
expect(VISITOR_ANALYTICS_COLUMNS.length).toBe(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has one placeholder per inserted column", () => {
|
it("has one placeholder per inserted column", () => {
|
||||||
@@ -83,7 +85,7 @@ describe("VisitorAnalytics schema", () => {
|
|||||||
|
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT path, country, utm_source, utm_medium, utm_campaign
|
`SELECT path, country, utm_source, utm_medium, utm_campaign, stage, host
|
||||||
FROM VisitorAnalytics`
|
FROM VisitorAnalytics`
|
||||||
)
|
)
|
||||||
.get() as Record<string, unknown>;
|
.get() as Record<string, unknown>;
|
||||||
@@ -93,7 +95,9 @@ describe("VisitorAnalytics schema", () => {
|
|||||||
country: "GB",
|
country: "GB",
|
||||||
utm_source: "hn",
|
utm_source: "hn",
|
||||||
utm_medium: "post",
|
utm_medium: "post",
|
||||||
utm_campaign: "launch"
|
utm_campaign: "launch",
|
||||||
|
stage: "click",
|
||||||
|
host: "nook.freno.me"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export const VISITOR_ANALYTICS_DDL = `
|
|||||||
utm_source TEXT,
|
utm_source TEXT,
|
||||||
utm_medium TEXT,
|
utm_medium TEXT,
|
||||||
utm_campaign TEXT,
|
utm_campaign TEXT,
|
||||||
|
stage TEXT,
|
||||||
|
host TEXT,
|
||||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL
|
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
@@ -79,6 +81,18 @@ export const VISITOR_ANALYTICS_ADDED_COLUMNS = [
|
|||||||
{
|
{
|
||||||
name: "utm_campaign",
|
name: "utm_campaign",
|
||||||
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN utm_campaign TEXT`
|
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN utm_campaign TEXT`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Which funnel stage a download row is: "click" (a door) or "served"
|
||||||
|
// (the file route). Null on every non-download row (FRE-366).
|
||||||
|
name: "stage",
|
||||||
|
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN stage TEXT`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The Host the client addressed, e.g. "nook.freno.me" — the site
|
||||||
|
// dimension the table never had (FRE-366).
|
||||||
|
name: "host",
|
||||||
|
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN host TEXT`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -110,7 +124,9 @@ export const VISITOR_ANALYTICS_COLUMNS = [
|
|||||||
"inp",
|
"inp",
|
||||||
"ttfb",
|
"ttfb",
|
||||||
"dom_load",
|
"dom_load",
|
||||||
"load_complete"
|
"load_complete",
|
||||||
|
"stage",
|
||||||
|
"host"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
VISITOR_ANALYTICS_INDEXES,
|
VISITOR_ANALYTICS_INDEXES,
|
||||||
VISITOR_ANALYTICS_INSERT
|
VISITOR_ANALYTICS_INSERT
|
||||||
} from "./analytics-schema";
|
} from "./analytics-schema";
|
||||||
|
import { truncateIpAddress } from "./request-client";
|
||||||
|
import { pruneVisitorAnalytics } from "./analytics-retention";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Idempotent schema bootstrap, mirroring `nook.ts`. The table had no DDL in
|
* Idempotent schema bootstrap, mirroring `nook.ts`. The table had no DDL in
|
||||||
@@ -64,6 +66,10 @@ export interface AnalyticsEntry {
|
|||||||
ttfb?: number | null;
|
ttfb?: number | null;
|
||||||
domLoad?: number | null;
|
domLoad?: number | null;
|
||||||
loadComplete?: number | null;
|
loadComplete?: number | null;
|
||||||
|
/** Funnel stage for download rows ("click" | "served"); null elsewhere. */
|
||||||
|
stage?: string | null;
|
||||||
|
/** Host the client addressed, e.g. "nook.freno.me" — the site dimension. */
|
||||||
|
host?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,7 +130,9 @@ async function flushAnalyticsBuffer(): Promise<void> {
|
|||||||
entry.inp || null,
|
entry.inp || null,
|
||||||
entry.ttfb || null,
|
entry.ttfb || null,
|
||||||
entry.domLoad || null,
|
entry.domLoad || null,
|
||||||
entry.loadComplete || null
|
entry.loadComplete || null,
|
||||||
|
entry.stage || null,
|
||||||
|
entry.host || null
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -258,6 +266,8 @@ export async function queryAnalytics(
|
|||||||
utm_medium: row.utm_medium as string | null,
|
utm_medium: row.utm_medium as string | null,
|
||||||
utm_campaign: row.utm_campaign as string | null,
|
utm_campaign: row.utm_campaign as string | null,
|
||||||
duration_ms: row.duration_ms as number | null,
|
duration_ms: row.duration_ms as number | null,
|
||||||
|
stage: row.stage as string | null,
|
||||||
|
host: row.host as string | null,
|
||||||
created_at: row.created_at as string
|
created_at: row.created_at as string
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -309,6 +319,10 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
|||||||
});
|
});
|
||||||
const totalApiCalls = (totalApiCallsResult.rows[0]?.count as number) || 0;
|
const totalApiCalls = (totalApiCallsResult.rows[0]?.count as number) || 0;
|
||||||
|
|
||||||
|
// Since the FRE-369 privacy floor, `ip_address` holds a truncated prefix
|
||||||
|
// (/24 for v4, /56 for v6), so this counts distinct prefixes — households and
|
||||||
|
// mobile exits, not machines. It was never accurate before that either: every
|
||||||
|
// row held the proxy's loopback, so the count was 1.
|
||||||
const uniqueVisitorsResult = await conn.execute({
|
const uniqueVisitorsResult = await conn.execute({
|
||||||
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics
|
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics
|
||||||
WHERE ip_address IS NOT NULL
|
WHERE ip_address IS NOT NULL
|
||||||
@@ -620,19 +634,15 @@ export async function getPerformanceStats(days: number = 30): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete visitor analytics older than `olderThanDays`. Reachable from tRPC
|
||||||
|
* (admin) and from the daily retention cron (`/api/analytics/retention`);
|
||||||
|
* the SQL and the input rules live in `./analytics-retention`.
|
||||||
|
*/
|
||||||
export async function cleanupOldAnalytics(
|
export async function cleanupOldAnalytics(
|
||||||
olderThanDays: number
|
olderThanDays: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const conn = ConnectionFactory();
|
return pruneVisitorAnalytics(ConnectionFactory(), olderThanDays);
|
||||||
|
|
||||||
const result = await conn.execute({
|
|
||||||
sql: `DELETE FROM VisitorAnalytics
|
|
||||||
WHERE created_at < datetime('now', '-${olderThanDays} days')
|
|
||||||
RETURNING id`,
|
|
||||||
args: []
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUserAgent(userAgent?: string): {
|
function parseUserAgent(userAgent?: string): {
|
||||||
@@ -667,6 +677,16 @@ function parseUserAgent(userAgent?: string): {
|
|||||||
return { deviceType, browser, os };
|
return { deviceType, browser, os };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill in what the writer did not supply, and apply the privacy floor.
|
||||||
|
*
|
||||||
|
* Every analytics entry — page views, tRPC context, downloads, checkout —
|
||||||
|
* passes through here, which is why the truncation lives here and not at the
|
||||||
|
* call sites: a future writer cannot forget it, and no caller can persist a
|
||||||
|
* full client address by accident (FRE-369, finding Q4). The audit log
|
||||||
|
* (`./audit.ts`) is a separate, pre-existing surface with its own stakes and is
|
||||||
|
* deliberately untouched by this change.
|
||||||
|
*/
|
||||||
export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
|
export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
|
||||||
const { deviceType, browser, os } = parseUserAgent(
|
const { deviceType, browser, os } = parseUserAgent(
|
||||||
entry.userAgent || undefined
|
entry.userAgent || undefined
|
||||||
@@ -674,6 +694,7 @@ export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...entry,
|
...entry,
|
||||||
|
ipAddress: truncateIpAddress(entry.ipAddress),
|
||||||
deviceType: entry.deviceType || deviceType,
|
deviceType: entry.deviceType || deviceType,
|
||||||
browser: entry.browser || browser,
|
browser: entry.browser || browser,
|
||||||
os: entry.os || os
|
os: entry.os || os
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "~/server/analytics";
|
} from "~/server/analytics";
|
||||||
import { ConnectionFactory } from "~/server/database";
|
import { ConnectionFactory } from "~/server/database";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import { getRequestIP } from "vinxi/http";
|
import { resolveClientIp, requestHost } from "~/server/request-client";
|
||||||
import { parseUtm } from "~/server/utm";
|
import { parseUtm } from "~/server/utm";
|
||||||
import { VISITOR_ANALYTICS_INSERT } from "~/server/analytics-schema";
|
import { VISITOR_ANALYTICS_INSERT } from "~/server/analytics-schema";
|
||||||
|
|
||||||
@@ -98,7 +98,8 @@ export const analyticsRouter = createTRPCRouter({
|
|||||||
getHeader(req.headers, "referer") ||
|
getHeader(req.headers, "referer") ||
|
||||||
getHeader(req.headers, "referrer") ||
|
getHeader(req.headers, "referrer") ||
|
||||||
getHeader(ctx.event.request?.headers, "referer");
|
getHeader(ctx.event.request?.headers, "referer");
|
||||||
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
|
const ipAddress = resolveClientIp(ctx.event) || undefined;
|
||||||
|
const host = requestHost(ctx.event) || undefined;
|
||||||
const enriched = enrichAnalyticsEntry({
|
const enriched = enrichAnalyticsEntry({
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
path: input.path,
|
path: input.path,
|
||||||
@@ -106,6 +107,7 @@ export const analyticsRouter = createTRPCRouter({
|
|||||||
userAgent,
|
userAgent,
|
||||||
referrer,
|
referrer,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
|
host,
|
||||||
// The client beacon sends `pathname + search`, so the page URL's
|
// The client beacon sends `pathname + search`, so the page URL's
|
||||||
// own query string is the campaign source; the referrer fills any
|
// own query string is the campaign source; the referrer fills any
|
||||||
// gap (e.g. clicks that arrive on a clean path).
|
// gap (e.g. clicks that arrive on a clean path).
|
||||||
@@ -145,7 +147,9 @@ export const analyticsRouter = createTRPCRouter({
|
|||||||
enriched.inp || null,
|
enriched.inp || null,
|
||||||
enriched.ttfb || null,
|
enriched.ttfb || null,
|
||||||
enriched.domLoad || null,
|
enriched.domLoad || null,
|
||||||
enriched.loadComplete || null
|
enriched.loadComplete || null,
|
||||||
|
enriched.stage || null,
|
||||||
|
enriched.host || null
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
// exactly the event the funnel needs to show. Unknown asset names are
|
// exactly the event the funnel needs to show. Unknown asset names are
|
||||||
// skipped rather than recorded as a download that never happened.
|
// skipped rather than recorded as a download that never happened.
|
||||||
if (assets[input.asset_name] || latestAssets[input.asset_name]) {
|
if (assets[input.asset_name] || latestAssets[input.asset_name]) {
|
||||||
await trackDownload(ctx.event, input.asset_name);
|
await trackDownload(ctx.event, input.asset_name, "click");
|
||||||
}
|
}
|
||||||
|
|
||||||
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
||||||
import { getRequestIP } from "vinxi/http";
|
import { resolveClientIp, requestHost } from "~/server/request-client";
|
||||||
import { getAuthPayloadFromEvent } from "~/server/auth";
|
import { getAuthPayloadFromEvent } from "~/server/auth";
|
||||||
|
|
||||||
export type Context = {
|
export type Context = {
|
||||||
@@ -51,7 +51,11 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
|||||||
getHeader(req.headers, "referer") ||
|
getHeader(req.headers, "referer") ||
|
||||||
getHeader(req.headers, "referrer") ||
|
getHeader(req.headers, "referrer") ||
|
||||||
getHeader(event.request?.headers, "referer");
|
getHeader(event.request?.headers, "referer");
|
||||||
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
// Behind the production proxy the direct peer is loopback, so the real
|
||||||
|
// client address comes from the forwarded header the proxy sets — see
|
||||||
|
// `~/server/request-client` for the trust boundary (FRE-366).
|
||||||
|
const ipAddress = resolveClientIp(event) || undefined;
|
||||||
|
const host = requestHost(event) || undefined;
|
||||||
|
|
||||||
// Don't log the performance logging endpoint itself to avoid circular tracking
|
// Don't log the performance logging endpoint itself to avoid circular tracking
|
||||||
if (!path.includes("analytics.logPerformance")) {
|
if (!path.includes("analytics.logPerformance")) {
|
||||||
@@ -62,7 +66,8 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
|||||||
method,
|
method,
|
||||||
userAgent,
|
userAgent,
|
||||||
referrer,
|
referrer,
|
||||||
ipAddress
|
ipAddress,
|
||||||
|
host
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
203
src/server/request-client.test.ts
Normal file
203
src/server/request-client.test.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||||
|
|
||||||
|
// The trust boundary of FRE-366, as corrected by the FRE-369 security review.
|
||||||
|
// Two things must stay true here:
|
||||||
|
// 1. The address we record is the visitor, not whoever typed a header first.
|
||||||
|
// Cloudflare and the origin proxy both *append* to x-forwarded-for, so the
|
||||||
|
// leftmost hop is the client's own invention — `cf-connecting-ip` (which
|
||||||
|
// Cloudflare overwrites) comes first, then the hop below the trusted edge.
|
||||||
|
// 2. What reaches the database is a prefix, never a full address
|
||||||
|
// (`truncateIpAddress`), because a visitor row is not worth a precise
|
||||||
|
// location.
|
||||||
|
// A regression on (1) re-records whatever an attacker claims; on (2) it stores
|
||||||
|
// personal data we promised not to.
|
||||||
|
|
||||||
|
let directIp: string | null = null;
|
||||||
|
mock.module("vinxi/http", () => ({
|
||||||
|
getRequestIP: () => directIp
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
resolveClientIp,
|
||||||
|
isLoopbackAddress,
|
||||||
|
isIpAddress,
|
||||||
|
truncateIpAddress,
|
||||||
|
normaliseHost,
|
||||||
|
requestHost
|
||||||
|
} = await import("./request-client");
|
||||||
|
|
||||||
|
function event(
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
url = "https://nook.freno.me/downloads"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
request: new Request(url, { headers }),
|
||||||
|
nativeEvent: {}
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
directIp = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveClientIp", () => {
|
||||||
|
it("prefers Cloudflare's connecting address over any forwarded header", () => {
|
||||||
|
directIp = "127.0.0.1";
|
||||||
|
expect(
|
||||||
|
resolveClientIp(
|
||||||
|
event({
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the hop below the trusted edge — never the leftmost one", () => {
|
||||||
|
// client → Cloudflare → same-host origin proxy: the sender's spoof is
|
||||||
|
// first, the real client second, the edge last.
|
||||||
|
directIp = "::1";
|
||||||
|
expect(
|
||||||
|
resolveClientIp(
|
||||||
|
event({
|
||||||
|
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a single-hop forwarded header: nothing above it to discount", () => {
|
||||||
|
directIp = "127.0.0.1";
|
||||||
|
expect(
|
||||||
|
resolveClientIp(event({ "x-forwarded-for": "198.51.100.9" }))
|
||||||
|
).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a forwarded header from a real peer — it is spoofable", () => {
|
||||||
|
directIp = "203.0.113.7";
|
||||||
|
expect(
|
||||||
|
resolveClientIp(
|
||||||
|
event({
|
||||||
|
"cf-connecting-ip": "198.51.100.9",
|
||||||
|
"x-forwarded-for": "198.51.100.9, 10.0.0.1"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a header value that is not an address", () => {
|
||||||
|
directIp = "127.0.0.1";
|
||||||
|
expect(
|
||||||
|
resolveClientIp(
|
||||||
|
event({
|
||||||
|
"cf-connecting-ip": "not-an-address",
|
||||||
|
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
|
||||||
|
directIp = "127.0.0.1";
|
||||||
|
expect(
|
||||||
|
resolveClientIp(event({ "cf-connecting-ip": "evil.example.com" }))
|
||||||
|
).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the loopback peer when the proxy set nothing usable", () => {
|
||||||
|
directIp = "::ffff:127.0.0.1";
|
||||||
|
expect(resolveClientIp(event())).toBe("::ffff:127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records nothing rather than inventing an address", () => {
|
||||||
|
expect(resolveClientIp(event())).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isLoopbackAddress", () => {
|
||||||
|
it("covers the v4 range and every spelling of v6 loopback", () => {
|
||||||
|
expect(isLoopbackAddress("127.0.0.1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("127.0.0.53")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("::1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("0:0:0:0:0:0:0:1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("::ffff:127.9.9.9")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not loopback for a real peer, or for text it cannot parse", () => {
|
||||||
|
expect(isLoopbackAddress("203.0.113.7")).toBe(false);
|
||||||
|
expect(isLoopbackAddress("10.0.0.1")).toBe(false);
|
||||||
|
expect(isLoopbackAddress("localhost")).toBe(false);
|
||||||
|
expect(isLoopbackAddress("127.0.53")).toBe(false); // not a complete address
|
||||||
|
expect(isLoopbackAddress("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isIpAddress", () => {
|
||||||
|
it("accepts a dotted quad and an IPv6 literal", () => {
|
||||||
|
expect(isIpAddress("198.51.100.9")).toBe(true);
|
||||||
|
expect(isIpAddress("2001:db8::1")).toBe(true);
|
||||||
|
expect(isIpAddress("::ffff:198.51.100.9")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects anything a proxy could not have meant", () => {
|
||||||
|
expect(isIpAddress("999.1.1.1")).toBe(false);
|
||||||
|
expect(isIpAddress("1.2.3")).toBe(false);
|
||||||
|
expect(isIpAddress("2001:db8:::1")).toBe(false);
|
||||||
|
expect(isIpAddress("evil.example.com")).toBe(false);
|
||||||
|
expect(isIpAddress("")).toBe(false);
|
||||||
|
expect(isIpAddress(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("truncateIpAddress", () => {
|
||||||
|
it("cuts IPv4 to its /24", () => {
|
||||||
|
expect(truncateIpAddress("198.51.100.9")).toBe("198.51.100.0");
|
||||||
|
expect(truncateIpAddress("127.0.0.1")).toBe("127.0.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cuts IPv6 to its /56, uncompressed and stable", () => {
|
||||||
|
expect(
|
||||||
|
truncateIpAddress("2001:db8:85a3:1234:5678:9abc:defa:1234")
|
||||||
|
).toBe("2001:db8:85a3:1200:0:0:0:0");
|
||||||
|
// The compressed spelling of the same address truncates to the same string,
|
||||||
|
// so `COUNT(DISTINCT ip_address)` cannot be split by how a proxy rendered it.
|
||||||
|
expect(truncateIpAddress("2001:db8:85a3:1234::")).toBe(
|
||||||
|
"2001:db8:85a3:1200:0:0:0:0"
|
||||||
|
);
|
||||||
|
expect(truncateIpAddress("::1")).toBe("0:0:0:0:0:0:0:0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an IPv4-mapped v6 address as the v4 it is", () => {
|
||||||
|
expect(truncateIpAddress("::ffff:198.51.100.9")).toBe("198.51.100.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops what it cannot parse rather than storing it", () => {
|
||||||
|
expect(truncateIpAddress("203.0.113.999")).toBeNull();
|
||||||
|
expect(truncateIpAddress("<script>alert(1)</script>")).toBeNull();
|
||||||
|
expect(truncateIpAddress("")).toBeNull();
|
||||||
|
expect(truncateIpAddress(null)).toBeNull();
|
||||||
|
expect(truncateIpAddress(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normaliseHost", () => {
|
||||||
|
it("lowercases and strips the port so the weekly host scope can match", () => {
|
||||||
|
expect(normaliseHost("Nook.Freno.ME:443")).toBe("nook.freno.me");
|
||||||
|
expect(normaliseHost("nook.freno.me.")).toBe("nook.freno.me");
|
||||||
|
expect(normaliseHost("[::1]:3000")).toBe("::1");
|
||||||
|
expect(normaliseHost(" ")).toBeNull();
|
||||||
|
expect(normaliseHost(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requestHost", () => {
|
||||||
|
it("reads the host the client addressed from the request URL", () => {
|
||||||
|
expect(requestHost(event())).toBe("nook.freno.me");
|
||||||
|
expect(requestHost(event({}, "https://freno.me/"))).toBe("freno.me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises a host that carries a port", () => {
|
||||||
|
expect(requestHost(event({}, "https://nook.freno.me:443/downloads"))).toBe(
|
||||||
|
"nook.freno.me"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
282
src/server/request-client.ts
Normal file
282
src/server/request-client.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { getRequestIP } from "vinxi/http";
|
||||||
|
import { hostFromEventLike } from "./site-context-server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client address and the site a request addressed, for the analytics
|
||||||
|
* writers (FRE-366).
|
||||||
|
*
|
||||||
|
* Until this existed every writer called `getRequestIP` and only looked at
|
||||||
|
* `x-forwarded-for` when it returned null — so behind the production proxy the
|
||||||
|
* recorded address was the proxy itself, and all 12,035 addressed rows in
|
||||||
|
* VisitorAnalytics carried 127.0.0.1 / ::1.
|
||||||
|
*
|
||||||
|
* STANDING RULE (FRE-369, the security review of that change): nothing here is
|
||||||
|
* a security signal. The address is a *best guess at a visitor*, not an
|
||||||
|
* authenticated identity — do not import it into auth, rate limiting, abuse
|
||||||
|
* control, fraud checks, or anything that grants or denies access. A caller
|
||||||
|
* that needs a security decision must re-derive trust for its own stakes; the
|
||||||
|
* blast radius of a wrong answer here is a poisoned analytics row, and that is
|
||||||
|
* the only reason the shortcuts below are acceptable.
|
||||||
|
*
|
||||||
|
* Trust boundary, stated plainly: `x-forwarded-for` is client-controlled text.
|
||||||
|
* It is believed only when the direct peer is a loopback address — the
|
||||||
|
* production shape, where a same-host proxy (nginx/caddy) terminates TLS and
|
||||||
|
* sets the header itself. Anywhere else the direct peer IS the client (dev,
|
||||||
|
* direct hit) and a forwarded header from it is ignored as spoofable.
|
||||||
|
*
|
||||||
|
* Which hop is the visitor (FRE-369 finding Q1): the request arrives as
|
||||||
|
* `client → Cloudflare → same-host origin proxy → app`, and both proxies
|
||||||
|
* *append*. So `x-forwarded-for` reaches us as
|
||||||
|
* `<client-supplied spoof…>, <true client>, <Cloudflare edge>` and the leftmost
|
||||||
|
* hop is exactly the one the sender chose — the original FRE-366 read took it
|
||||||
|
* and was wrong. Order of preference:
|
||||||
|
*
|
||||||
|
* 1. `cf-connecting-ip` — Cloudflare *overwrites* this header, so a
|
||||||
|
* client-supplied value never survives the edge. Authoritative.
|
||||||
|
* 2. `x-forwarded-for`, counted from the right: drop the rightmost hop (the
|
||||||
|
* Cloudflare edge address the origin proxy appended) and take the one
|
||||||
|
* before it. Never the leftmost.
|
||||||
|
* 3. the direct peer, as before.
|
||||||
|
*
|
||||||
|
* h3's own `getRequestIP(event, { xForwardedFor: true })` also takes the
|
||||||
|
* leftmost hop, so it is not a fix for this and must not be swapped in.
|
||||||
|
*
|
||||||
|
* Residual caveat, deliberately not papered over: step 2 assumes the pinned
|
||||||
|
* topology (one trusted proxy above us, i.e. Cloudflare). Behind a *single*
|
||||||
|
* forwarding proxy that does not set `cf-connecting-ip`, a client that sent its
|
||||||
|
* own `x-forwarded-for` sits at the hop we read. That is why the value is only
|
||||||
|
* ever stored truncated (see `truncateIpAddress`) and why it stays out of every
|
||||||
|
* security path. Deploy-side precondition this repo cannot verify: the origin
|
||||||
|
* must be reachable only from Cloudflare (origin firewall / authenticated
|
||||||
|
* origin pull). If it is directly reachable, an attacker can set every header
|
||||||
|
* in this file and no read here can recover the truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Cloudflare's "who actually connected" header: overwritten at the edge. */
|
||||||
|
const CF_CONNECTING_IP = "cf-connecting-ip";
|
||||||
|
const X_FORWARDED_FOR = "x-forwarded-for";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hops discounted from the right of `x-forwarded-for` before the value is
|
||||||
|
* taken: the Cloudflare edge address the origin proxy appended. Pinned to 1
|
||||||
|
* because there is exactly one trusted proxy between Cloudflare and the app;
|
||||||
|
* raise it only if the topology gains a trusted hop.
|
||||||
|
*/
|
||||||
|
const FORWARDED_HOPS_TO_DISCOUNT = 1;
|
||||||
|
|
||||||
|
function header(event: APIEvent, name: string): string | null {
|
||||||
|
try {
|
||||||
|
return event.request?.headers?.get?.(name) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visitor's address, resolved through the trust boundary above.
|
||||||
|
*
|
||||||
|
* Never store the result of this directly: `enrichAnalyticsEntry` runs every
|
||||||
|
* analytics entry through {@link truncateIpAddress} so a full address cannot
|
||||||
|
* reach the database even from a writer that forgets. Callers that need the
|
||||||
|
* un-truncated value must justify it in review.
|
||||||
|
*/
|
||||||
|
export function resolveClientIp(event: APIEvent): string | null {
|
||||||
|
const direct = getRequestIP(event.nativeEvent);
|
||||||
|
|
||||||
|
// A real peer is its own client: believed, and no forwarded header from it
|
||||||
|
// is (it would be the peer lying about who it is).
|
||||||
|
if (direct && !isLoopbackAddress(direct)) return direct;
|
||||||
|
|
||||||
|
// 1. Cloudflare's own record of the connecting address.
|
||||||
|
const connecting = cleanHop(header(event, CF_CONNECTING_IP));
|
||||||
|
if (isIpAddress(connecting)) return connecting;
|
||||||
|
|
||||||
|
// 2. Rightmost-minus-the-trusted-hop, never leftmost.
|
||||||
|
const hops = (header(event, X_FORWARDED_FOR) ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map(cleanHop)
|
||||||
|
.filter(Boolean);
|
||||||
|
const index = hops.length - 1 - FORWARDED_HOPS_TO_DISCOUNT;
|
||||||
|
if (index >= 0) {
|
||||||
|
const candidate = hops[index]!;
|
||||||
|
if (isIpAddress(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Nothing trustworthy: record what we actually saw, even though it is the
|
||||||
|
// proxy. `readTraffic` reports these rows rather than hiding them.
|
||||||
|
return direct ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHop(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an address is the loopback the production proxy presents.
|
||||||
|
*
|
||||||
|
* Parses rather than string-matches, so the forms a real peer can present in —
|
||||||
|
* `::ffff:127.0.0.53`, `0:0:0:0:0:0:0:1`, an IPv4-mapped literal — are all
|
||||||
|
* recognised. An address this does not parse is *not* loopback, which fails
|
||||||
|
* closed: forwarded headers from an unrecognised peer are ignored rather than
|
||||||
|
* believed.
|
||||||
|
*/
|
||||||
|
export function isLoopbackAddress(ip: string): boolean {
|
||||||
|
const bytes = addressBytes(ip);
|
||||||
|
if (!bytes) return false;
|
||||||
|
if (bytes.length === 4) return bytes[0] === 127;
|
||||||
|
|
||||||
|
// IPv6 loopback ::1 — and ::ffff:127/8, which is loopback too.
|
||||||
|
const mapped = ipv4FromBytes(bytes);
|
||||||
|
if (mapped) return isLoopbackAddress(mapped);
|
||||||
|
const tail = bytes[15]!;
|
||||||
|
return bytes.slice(0, 15).every((byte) => byte === 0) && tail === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Addresses: parse, validate, truncate ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The privacy floor the security review required before this ships (FRE-369,
|
||||||
|
* finding Q4): VisitorAnalytics must never hold a full client address. IPv4 is
|
||||||
|
* cut to /24, IPv6 to /56 — the prefix that identifies a household and that a
|
||||||
|
* rotating "privacy" IPv6 address does not actually change. Funnel stages count
|
||||||
|
* rows, and `uniqueVisitors` survives as a count of distinct prefixes, so
|
||||||
|
* nothing measured here needs the last octet.
|
||||||
|
*
|
||||||
|
* An address that does not parse is dropped rather than stored: an unparseable
|
||||||
|
* string is client text we cannot reason about, and keeping it would smuggle
|
||||||
|
* the very granularity this function exists to remove.
|
||||||
|
*/
|
||||||
|
export function truncateIpAddress(ip: string | null | undefined): string | null {
|
||||||
|
const value = cleanHop(ip);
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const bytes = addressBytes(value);
|
||||||
|
if (!bytes) return null;
|
||||||
|
|
||||||
|
const mapped = bytes.length === 16 ? ipv4FromBytes(bytes) : null;
|
||||||
|
if (bytes.length === 4 || mapped) {
|
||||||
|
const octets = (mapped ?? value).split(".").map(Number) as number[];
|
||||||
|
return `${octets[0]}.${octets[1]}.${octets[2]}.0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /56 = keep the first 7 bytes, zero the rest. Rendered uncompressed and
|
||||||
|
// lowercase so one client always truncates to one stored string.
|
||||||
|
const kept = bytes.map((_, i) => (i >= 7 ? 0 : _));
|
||||||
|
const groups: string[] = [];
|
||||||
|
for (let i = 0; i < 16; i += 2) {
|
||||||
|
groups.push((((kept[i]! << 8) | kept[i + 1]!) >>> 0).toString(16));
|
||||||
|
}
|
||||||
|
return groups.join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any address this file understands: a dotted quad or an IPv6 literal. */
|
||||||
|
export function isIpAddress(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && addressBytes(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpv4(value: string): boolean {
|
||||||
|
const parts = value.split(".");
|
||||||
|
if (parts.length !== 4) return false;
|
||||||
|
return parts.every(
|
||||||
|
(part) => /^\d{1,3}$/.test(part) && Number(part) <= 255 && !/^0\d/.test(part)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4 bytes for IPv4, 16 for IPv6 (including `::ffff:a.b.c.d`), else null. */
|
||||||
|
function addressBytes(value: string): number[] | null {
|
||||||
|
const raw = value.trim().replace(/^\[|\]$/g, "").split("%")[0]!;
|
||||||
|
if (isIpv4(raw)) return raw.split(".").map(Number);
|
||||||
|
if (!raw.includes(":")) return null;
|
||||||
|
|
||||||
|
const halves = raw.split("::");
|
||||||
|
if (halves.length > 2) return null;
|
||||||
|
const compressed = halves.length === 2;
|
||||||
|
|
||||||
|
const head = groupsToBytes(halves[0] ?? "");
|
||||||
|
const tail = groupsToBytes(halves[1] ?? "");
|
||||||
|
if (!head || !tail) return null;
|
||||||
|
|
||||||
|
const total = head.length + tail.length;
|
||||||
|
if (compressed) {
|
||||||
|
if (total > 8) return null;
|
||||||
|
return [...head, ...new Array(8 - total).fill(0), ...tail].flatMap(split16);
|
||||||
|
}
|
||||||
|
if (total !== 8) return null;
|
||||||
|
return [...head, ...tail].flatMap(split16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One side of `::` as 16-bit groups; a trailing `1.2.3.4` counts as two. */
|
||||||
|
function groupsToBytes(side: string): number[] | null {
|
||||||
|
if (side === "") return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const part of side.split(":")) {
|
||||||
|
if (/^[0-9a-fA-F]{1,4}$/.test(part)) {
|
||||||
|
out.push(parseInt(part, 16));
|
||||||
|
} else if (isIpv4(part)) {
|
||||||
|
const o = part.split(".").map(Number);
|
||||||
|
out.push((o[0]! << 8) | o[1]!, (o[2]! << 8) | o[3]!);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function split16(group: number): number[] {
|
||||||
|
return [(group >> 8) & 0xff, group & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`), else null. */
|
||||||
|
function ipv4FromBytes(bytes: number[]): string | null {
|
||||||
|
const prefix = bytes.slice(0, 10).every((byte) => byte === 0);
|
||||||
|
const marker = bytes[10] === 0xff && bytes[11] === 0xff;
|
||||||
|
if (!prefix || !marker) return null;
|
||||||
|
return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The site the request addressed — the `Host` the client asked for, normalised
|
||||||
|
* to `nook.freno.me`. Recorded on every analytics row so a weekly read can
|
||||||
|
* scope to one site; without it `/` on nook.freno.me is the same row as `/` on
|
||||||
|
* freno.me. Thin alias over the site-context extractor so the analytics writers
|
||||||
|
* share one host-parsing implementation with routing.
|
||||||
|
*
|
||||||
|
* Normalised (lowercased, port stripped) because the weekly read scopes with
|
||||||
|
* `host = ?`: `nook.freno.me:443` or `Nook.Freno.ME` would otherwise store a
|
||||||
|
* value that silently never matches the scope (FRE-369, finding Q3 — no
|
||||||
|
* injection risk, but a correctness one worth fixing at write time).
|
||||||
|
*/
|
||||||
|
export function requestHost(event: APIEvent): string | null {
|
||||||
|
return normaliseHost(hostFromEventLike(event)) ?? normaliseHost(urlHost(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlHost(event: APIEvent): string | null {
|
||||||
|
// `host` is a forbidden header and some server shapes drop it from
|
||||||
|
// `event.request.headers`; the request URL is absolute on the server and
|
||||||
|
// carries the same host the client addressed.
|
||||||
|
try {
|
||||||
|
const url = event.request?.url;
|
||||||
|
return url ? new URL(url).host || null : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase, drop the port and any trailing dot; null when nothing is left. */
|
||||||
|
export function normaliseHost(host: string | null | undefined): string | null {
|
||||||
|
const raw = cleanHop(host).toLowerCase();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// A bracketed IPv6 literal keeps its brackets' contents (`[::1]:3000` →
|
||||||
|
// `::1`); anything else drops a trailing `:port`.
|
||||||
|
const closing = raw.indexOf("]");
|
||||||
|
const withoutPort = raw.startsWith("[")
|
||||||
|
? closing > 0
|
||||||
|
? raw.slice(1, closing)
|
||||||
|
: raw.slice(1)
|
||||||
|
: raw.replace(/:\d+$/, "");
|
||||||
|
|
||||||
|
const trimmed = withoutPort.replace(/\.$/, "");
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { getCookie, setCookie } from "vinxi/http";
|
|||||||
import type { H3Event } from "vinxi/http";
|
import type { H3Event } from "vinxi/http";
|
||||||
import { t } from "~/server/api/utils";
|
import { t } from "~/server/api/utils";
|
||||||
import { logAuditEvent } from "~/server/audit";
|
import { logAuditEvent } from "~/server/audit";
|
||||||
|
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import {
|
import {
|
||||||
AUTH_CONFIG,
|
AUTH_CONFIG,
|
||||||
@@ -328,39 +329,15 @@ async function cleanupExpiredRateLimits(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get client IP address from request headers.
|
* Get client IP address for a security decision (rate limiting, audit).
|
||||||
* Only trusts X-Forwarded-For outside of local development (set by the Vercel
|
*
|
||||||
* edge network in production; trusted in tests so the header-parsing path is
|
* Thin alias over `resolveTrustedClientIp` (./trusted-client-ip), which owns
|
||||||
* exercised). In local development, uses the socket address to prevent header
|
* the trust order: the previous leftmost `x-forwarded-for` read was
|
||||||
* spoofing.
|
* client-spoofable in our append-only proxy topology, and `x-real-ip` was
|
||||||
|
* read unconditionally — both fixed there (FRE-371).
|
||||||
*/
|
*/
|
||||||
export function getClientIP(event: H3Event): string {
|
export function getClientIP(event: H3Event): string {
|
||||||
// In production on Vercel, X-Forwarded-For is set by the edge network
|
return resolveTrustedClientIp(event);
|
||||||
// and cannot be spoofed by clients. In dev, ignore it.
|
|
||||||
if (env.NODE_ENV !== "development") {
|
|
||||||
const forwarded = getHeaderValue(event, "x-forwarded-for");
|
|
||||||
if (forwarded) {
|
|
||||||
return forwarded.split(",")[0].trim();
|
|
||||||
}
|
|
||||||
const realIP = getHeaderValue(event, "x-real-ip");
|
|
||||||
if (realIP) {
|
|
||||||
return realIP;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try socket remote address
|
|
||||||
try {
|
|
||||||
const nodeReq = event.node.req;
|
|
||||||
if (nodeReq?.socket?.remoteAddress) {
|
|
||||||
const addr = nodeReq.socket.remoteAddress;
|
|
||||||
// Clean up IPv6-mapped IPv4 addresses
|
|
||||||
return addr.replace(/^::ffff:/, "");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// socket access failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -150,29 +150,100 @@ describe("Rate Limiting", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getClientIP", () => {
|
describe("getClientIP", () => {
|
||||||
it("should extract IP from x-forwarded-for header", () => {
|
// FRE-371: the read is spoof-resistant now. In the pinned topology
|
||||||
|
// (client → Cloudflare → origin proxy → app) both proxies APPEND, so
|
||||||
|
// x-forwarded-for arrives as `<spoof…>, <true client>, <CF edge>` and the
|
||||||
|
// leftmost hop is whatever the sender typed. Trust order:
|
||||||
|
// cf-connecting-ip, then x-forwarded-for rightmost-minus-one, then the
|
||||||
|
// socket peer — never the leftmost hop, never x-real-ip.
|
||||||
|
|
||||||
|
it("takes x-forwarded-for rightmost-minus-one, never the leftmost hop", () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" }
|
headers: {
|
||||||
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = getClientIP(event);
|
const ip = getClientIP(event);
|
||||||
expect(ip).toBe("192.168.1.1");
|
expect(ip).toBe("192.168.1.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract IP from x-real-ip header", () => {
|
it("prefers cf-connecting-ip (Cloudflare overwrites it at the edge)", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rotating leftmost x-forwarded-for does not change the resolved client", () => {
|
||||||
|
// The spoof the old read fell for: same Cloudflare-recorded client,
|
||||||
|
// different sender-chosen prefix per request. The key must be stable,
|
||||||
|
// or each rotation mints a fresh rate-limit bucket.
|
||||||
|
const first = getClientIP(
|
||||||
|
createMockEvent({
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "1.1.1.1, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const second = getClientIP(
|
||||||
|
createMockEvent({
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "2.2.2.2, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first).toBe("203.0.113.7");
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores x-real-ip entirely (client-settable in this topology)", () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: { "x-real-ip": "192.168.1.2" }
|
headers: { "x-real-ip": "192.168.1.2" }
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = getClientIP(event);
|
const ip = getClientIP(event);
|
||||||
expect(ip).toBe("192.168.1.2");
|
expect(ip).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prefer x-forwarded-for over x-real-ip", () => {
|
it("ignores a client-supplied single-hop x-forwarded-for with no trusted proxy above it", () => {
|
||||||
|
// One hop only = the sender's own invention; discounting the trusted
|
||||||
|
// hop leaves nothing to believe.
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
|
headers: { "x-forwarded-for": "6.6.6.6" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-loopback socket peer is the client and beats every header", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
remoteAddress: "198.51.100.23",
|
||||||
headers: {
|
headers: {
|
||||||
"x-forwarded-for": "192.168.1.1",
|
"cf-connecting-ip": "6.6.6.6",
|
||||||
"x-real-ip": "192.168.1.2"
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1",
|
||||||
|
"x-real-ip": "6.6.6.6"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("198.51.100.23");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a loopback socket peer lets the forwarded headers speak", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: {
|
||||||
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,13 +269,23 @@ describe("Rate Limiting", () => {
|
|||||||
it("should handle IPv6 addresses", () => {
|
it("should handle IPv6 addresses", () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: {
|
headers: {
|
||||||
"x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
|
"x-forwarded-for":
|
||||||
|
"2001:0db8:85a3:0000:0000:8a2e:0370:7334, 10.0.0.1"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = getClientIP(event);
|
const ip = getClientIP(event);
|
||||||
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a candidate hop that is not an address", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
headers: { "x-forwarded-for": "not-an-ip, 10.0.0.1" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("unknown");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("rateLimitLogin", () => {
|
describe("rateLimitLogin", () => {
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ export function createMockEvent(options: {
|
|||||||
cookies?: Record<string, string>;
|
cookies?: Record<string, string>;
|
||||||
method?: string;
|
method?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/** Socket peer the server observed; omit for a peer-less synthetic event. */
|
||||||
|
remoteAddress?: string;
|
||||||
}): H3Event {
|
}): H3Event {
|
||||||
const {
|
const {
|
||||||
headers = {},
|
headers = {},
|
||||||
cookies = {},
|
cookies = {},
|
||||||
method = "POST",
|
method = "POST",
|
||||||
url = "http://localhost:3000/"
|
url = "http://localhost:3000/",
|
||||||
|
remoteAddress
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Build the cookie header string from the cookies object only
|
// Build the cookie header string from the cookies object only
|
||||||
@@ -49,7 +52,10 @@ export function createMockEvent(options: {
|
|||||||
headers: headersObj,
|
headers: headersObj,
|
||||||
node: {
|
node: {
|
||||||
req: {
|
req: {
|
||||||
headers: allHeaders
|
headers: allHeaders,
|
||||||
|
...(remoteAddress
|
||||||
|
? { socket: { remoteAddress } }
|
||||||
|
: {})
|
||||||
},
|
},
|
||||||
res: {
|
res: {
|
||||||
cookies: {}
|
cookies: {}
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ export interface ServerSiteEvent {
|
|||||||
* the raw Node request via `event.nativeEvent.node.req.headers`. We try
|
* the raw Node request via `event.nativeEvent.node.req.headers`. We try
|
||||||
* both so this works for API route handlers (`APIEvent`), the SSR document
|
* both so this works for API route handlers (`APIEvent`), the SSR document
|
||||||
* handler (`PageEvent`), and tRPC procedures operating on the underlying
|
* handler (`PageEvent`), and tRPC procedures operating on the underlying
|
||||||
* `H3Event`.
|
* `H3Event`. Also used by the analytics writers to record the site dimension
|
||||||
|
* (`host` column) on every row (FRE-366).
|
||||||
*/
|
*/
|
||||||
function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
|
export function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
|
||||||
// 1) Web FetchEvent / APIEvent / PageEvent → standard `Request` headers.
|
// 1) Web FetchEvent / APIEvent / PageEvent → standard `Request` headers.
|
||||||
try {
|
try {
|
||||||
const req = (event as { request?: Request } | null)?.request;
|
const req = (event as { request?: Request } | null)?.request;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { getRequestIP } from "vinxi/http";
|
|
||||||
import {
|
import {
|
||||||
logVisit,
|
logVisit,
|
||||||
enrichAnalyticsEntry,
|
enrichAnalyticsEntry,
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
} from "~/server/analytics";
|
} from "~/server/analytics";
|
||||||
import { parseUtm } from "~/server/utm";
|
import { parseUtm } from "~/server/utm";
|
||||||
import { downloadPath } from "~/lib/download-asset";
|
import { downloadPath } from "~/lib/download-asset";
|
||||||
|
import { resolveClientIp, requestHost } from "~/server/request-client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record one download event under the canonical path `/api/downloads/<asset>`.
|
* Record one download event under the canonical path `/api/downloads/<asset>`.
|
||||||
@@ -15,35 +15,75 @@ import { downloadPath } from "~/lib/download-asset";
|
|||||||
* label per asset to group on: the tRPC click proxy, the S3 file route that
|
* label per asset to group on: the tRPC click proxy, the S3 file route that
|
||||||
* serves Sparkle updates, and the Nook door.
|
* serves Sparkle updates, and the Nook door.
|
||||||
*
|
*
|
||||||
|
* The `stage` is what separates the doors within one asset label: "click"
|
||||||
|
* for a door the visitor chose (landing button, tRPC proxy), "served" for
|
||||||
|
* the file route that actually streams the DMG. Until FRE-366 both wrote the
|
||||||
|
* same unlabelled row, so "download click → DMG served" could not be split —
|
||||||
|
* one served click wrote two identical rows. `summariseDownloads()` and the
|
||||||
|
* /analytics downloads block still group on the path alone; the stage is a
|
||||||
|
* separate column precisely so that grouping cannot change.
|
||||||
|
*
|
||||||
* Best-effort by design — a failed analytics write must never block a
|
* Best-effort by design — a failed analytics write must never block a
|
||||||
* download. Flushed rather than merely buffered: a download is low-volume and
|
* download. Flushed rather than merely buffered: a download is low-volume and
|
||||||
* the 30s batch timer is not guaranteed to survive the response on serverless.
|
* the 30s batch timer is not guaranteed to survive the response on serverless.
|
||||||
|
*/
|
||||||
|
export async function trackDownload(
|
||||||
|
event: APIEvent,
|
||||||
|
asset: string,
|
||||||
|
stage: DownloadStage
|
||||||
|
): Promise<void> {
|
||||||
|
await trackRequest(event, {
|
||||||
|
path: downloadPath(asset),
|
||||||
|
method: "GET",
|
||||||
|
stage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DownloadStage = "click" | "served";
|
||||||
|
|
||||||
|
export interface TrackedRequest {
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
/** Funnel stage label; only download rows carry one today. */
|
||||||
|
stage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record one server-side request event with the shared visitor dimensions:
|
||||||
|
* referrer, campaign tags, edge country, real client address and host.
|
||||||
|
*
|
||||||
|
* This is the one writer every measured door goes through — download doors
|
||||||
|
* via `trackDownload`, the checkout door directly — so the dimensions can
|
||||||
|
* never drift between doors. Same best-effort, flushed-immediately contract
|
||||||
|
* as `trackDownload`.
|
||||||
*
|
*
|
||||||
* `event.request` is expected to be a Fetch `Request` (true for Vinxi route
|
* `event.request` is expected to be a Fetch `Request` (true for Vinxi route
|
||||||
* handlers and browser tRPC calls). A server-side `createCaller` hands over a
|
* handlers and browser tRPC calls). A server-side `createCaller` hands over a
|
||||||
* Node request, whose headers have no `get` — that throw is swallowed below,
|
* Node request, whose headers have no `get` — that throw is swallowed below,
|
||||||
* so such a click is not recorded. No server-side caller downloads today.
|
* so such an event is not recorded. No server-side caller downloads today.
|
||||||
*/
|
*/
|
||||||
export async function trackDownload(
|
export async function trackRequest(
|
||||||
event: APIEvent,
|
event: APIEvent,
|
||||||
asset: string
|
tracked: TrackedRequest
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const referrer =
|
const referrer =
|
||||||
request.headers.get("referer") ?? request.headers.get("referrer");
|
request.headers.get("referer") ?? request.headers.get("referrer");
|
||||||
const forwarded = request.headers.get("x-forwarded-for");
|
|
||||||
|
|
||||||
await logVisit(
|
await logVisit(
|
||||||
enrichAnalyticsEntry({
|
enrichAnalyticsEntry({
|
||||||
path: downloadPath(asset),
|
path: tracked.path,
|
||||||
method: "GET",
|
method: tracked.method,
|
||||||
|
stage: tracked.stage ?? null,
|
||||||
referrer,
|
referrer,
|
||||||
userAgent: request.headers.get("user-agent"),
|
userAgent: request.headers.get("user-agent"),
|
||||||
ipAddress:
|
// Behind the production proxy the direct peer is loopback; the real
|
||||||
getRequestIP(event.nativeEvent) ??
|
// client address comes from the forwarded header the proxy sets —
|
||||||
(forwarded ? forwarded.split(",")[0]!.trim() : null),
|
// see `~/server/request-client` for the trust boundary.
|
||||||
|
ipAddress: resolveClientIp(event),
|
||||||
|
host: requestHost(event),
|
||||||
// The click carries its own tags when a campaign links straight at the
|
// The click carries its own tags when a campaign links straight at the
|
||||||
// door; otherwise the landing page's tags ride along in the referer.
|
// door; otherwise the landing page's tags ride along in the referer.
|
||||||
...parseUtm(request.url, referrer),
|
...parseUtm(request.url, referrer),
|
||||||
@@ -56,6 +96,6 @@ export async function trackDownload(
|
|||||||
);
|
);
|
||||||
await flushAnalytics();
|
await flushAnalytics();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to log download of ${asset}:`, error);
|
console.error(`Failed to log ${tracked.method} ${tracked.path}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
171
src/server/trusted-client-ip.test.ts
Normal file
171
src/server/trusted-client-ip.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveTrustedClientIp,
|
||||||
|
isIpAddress,
|
||||||
|
isLoopbackAddress,
|
||||||
|
UNKNOWN_CLIENT_IP,
|
||||||
|
type ClientIpEventLike
|
||||||
|
} from "./trusted-client-ip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit coverage for the security-path client-IP read (FRE-371). The caller
|
||||||
|
* suites (rate-limit.test.ts, report.test.ts) pin the behaviour through
|
||||||
|
* getClientIP and the route; this file pins the helper's own edges: the peer
|
||||||
|
* forms a real socket can present, the APIEvent wrapper, and failing closed.
|
||||||
|
*/
|
||||||
|
function event(opts: {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
remoteAddress?: string;
|
||||||
|
wrapped?: boolean;
|
||||||
|
}): ClientIpEventLike {
|
||||||
|
const inner: ClientIpEventLike = {
|
||||||
|
...(opts.remoteAddress
|
||||||
|
? { node: { req: { socket: { remoteAddress: opts.remoteAddress } } } }
|
||||||
|
: {}),
|
||||||
|
request: { headers: new Headers(opts.headers ?? {}) }
|
||||||
|
};
|
||||||
|
// An APIEvent wraps its H3Event as `nativeEvent` (routes/api/* shape).
|
||||||
|
return opts.wrapped ? { nativeEvent: inner } : inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveTrustedClientIp", () => {
|
||||||
|
it("trust order: non-loopback peer, then cf-connecting-ip, then rightmost-minus-one x-forwarded-for", () => {
|
||||||
|
// Peer wins.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "198.51.100.23",
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("198.51.100.23");
|
||||||
|
|
||||||
|
// Loopback peer → Cloudflare's record.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
|
||||||
|
// No CF header → the hop below the trusted proxy.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the wrapped H3Event of an APIEvent-shaped input", () => {
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
wrapped: true,
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a truncated address", () => {
|
||||||
|
// The full /128 is the bucket key; a truncated one would pool attackers
|
||||||
|
// with their neighbours.
|
||||||
|
const ip = resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: { "cf-connecting-ip": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed to the stricter unknown key", () => {
|
||||||
|
// One hop = the sender's own invention; a non-address hop; nothing at all.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({ headers: { "x-forwarded-for": "6.6.6.6" } })
|
||||||
|
)
|
||||||
|
).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({ headers: { "cf-connecting-ip": "not-an-ip" } })
|
||||||
|
)
|
||||||
|
).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
expect(resolveTrustedClientIp(event({}))).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises the loopback forms a real peer presents", () => {
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
// IPv6-mapped IPv4 loopback from a node socket.
|
||||||
|
remoteAddress: "::ffff:127.0.0.1",
|
||||||
|
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "::1",
|
||||||
|
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
// A non-loopback peer that merely looks odd is still the client.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "::ffff:203.0.113.7",
|
||||||
|
headers: { "cf-connecting-ip": "6.6.6.6" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never believes x-real-ip", () => {
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({ headers: { "x-real-ip": "203.0.113.7" } })
|
||||||
|
)
|
||||||
|
).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("address helpers", () => {
|
||||||
|
it("isIpAddress", () => {
|
||||||
|
expect(isIpAddress("203.0.113.7")).toBe(true);
|
||||||
|
expect(isIpAddress("::1")).toBe(true);
|
||||||
|
expect(isIpAddress("::ffff:203.0.113.7")).toBe(true);
|
||||||
|
expect(isIpAddress("2001:db8::1")).toBe(true);
|
||||||
|
expect(isIpAddress("not-an-ip")).toBe(false);
|
||||||
|
expect(isIpAddress("256.1.1.1")).toBe(false);
|
||||||
|
expect(isIpAddress("")).toBe(false);
|
||||||
|
expect(isIpAddress(null)).toBe(false);
|
||||||
|
expect(isIpAddress(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isLoopbackAddress", () => {
|
||||||
|
expect(isLoopbackAddress("127.0.0.1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("127.255.0.3")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("::1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("203.0.113.7")).toBe(false);
|
||||||
|
expect(isLoopbackAddress("::2")).toBe(false);
|
||||||
|
// Unparseable input is not loopback — fails closed.
|
||||||
|
expect(isLoopbackAddress("garbage")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
250
src/server/trusted-client-ip.ts
Normal file
250
src/server/trusted-client-ip.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* The client address, resolved for security decisions (FRE-371).
|
||||||
|
*
|
||||||
|
* This is the ONLY client-IP read allowed to feed a security decision — rate
|
||||||
|
* limiting, abuse quotas, account lockout, audit trails. It exists separately
|
||||||
|
* from `request-client.ts`'s `resolveClientIp` (the analytics read) on
|
||||||
|
* purpose:
|
||||||
|
*
|
||||||
|
* - `resolveClientIp` carries a standing rule (FRE-369 review, 1d9287a):
|
||||||
|
* it never feeds a security decision. Its blast radius is a poisoned
|
||||||
|
* analytics row; ours is granting or denying access.
|
||||||
|
* - Rate limiting needs the UN-truncated address; the analytics module only
|
||||||
|
* ever hands out truncated prefixes.
|
||||||
|
* - `request-client.ts` ships on FRE-366's branch, which is under review;
|
||||||
|
* a security fix must not merge-depend on an analytics diff.
|
||||||
|
*
|
||||||
|
* The address-parsing primitives below are therefore a deliberate port of the
|
||||||
|
* reviewed ones, not an import. If the two ever drift, this file's version is
|
||||||
|
* the one security paths follow.
|
||||||
|
*
|
||||||
|
* WHY the old read was spoofable: `security.ts`'s `getClientIP` took the
|
||||||
|
* LEFTMOST `x-forwarded-for` hop, believing the header "cannot be spoofed".
|
||||||
|
* In the pinned topology — client → Cloudflare → same-host origin proxy →
|
||||||
|
* app — both proxies *append*, so the header arrives as
|
||||||
|
* `<client-supplied spoof…>, <true client>, <Cloudflare edge>` and the
|
||||||
|
* leftmost hop is exactly the one the sender chose. Rotating
|
||||||
|
* `X-Forwarded-For: <random>` per request minted a fresh rate-limit bucket
|
||||||
|
* and unlimited bug-report quota. `x-real-ip` was read unconditionally too;
|
||||||
|
* nothing in this topology overwrites it, so it is equally client text and is
|
||||||
|
* no longer read at all.
|
||||||
|
*
|
||||||
|
* Trust order (matches the convention FRE-366 landed after its security
|
||||||
|
* review, re-derived here for security stakes):
|
||||||
|
*
|
||||||
|
* 1. The direct socket peer, when it is a real routable address: it IS the
|
||||||
|
* client (dev, direct hit), and any forwarded header from it is the
|
||||||
|
* client describing itself — ignored.
|
||||||
|
* 2. `cf-connecting-ip` — reachable only when the peer is the loopback
|
||||||
|
* origin proxy (or unreadable, e.g. a synthetic event in tests).
|
||||||
|
* Cloudflare *overwrites* this header at the edge, so a client-supplied
|
||||||
|
* value never survives. Must parse as an address.
|
||||||
|
* 3. `x-forwarded-for` counted from the RIGHT: drop the rightmost hop (the
|
||||||
|
* Cloudflare edge address the origin proxy appended) and take the hop
|
||||||
|
* before it. Never the leftmost. Must parse as an address.
|
||||||
|
* 4. "unknown" — callers give unknown-IP keys stricter shared limits, so
|
||||||
|
* an unresolvable client fails toward a tighter bucket, never a looser
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* Deploy-side precondition this code cannot verify (same as the analytics
|
||||||
|
* read): the origin must be reachable only through Cloudflare and the
|
||||||
|
* same-host proxy. If the app is directly internet-reachable, a client can
|
||||||
|
* set every header read here and no in-app read can recover the truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Cloudflare's "who actually connected" header: overwritten at the edge. */
|
||||||
|
const CF_CONNECTING_IP = "cf-connecting-ip";
|
||||||
|
const X_FORWARDED_FOR = "x-forwarded-for";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hops discounted from the right of `x-forwarded-for` before the value is
|
||||||
|
* taken: the Cloudflare edge address the origin proxy appended. Pinned to 1
|
||||||
|
* because there is exactly one trusted proxy between Cloudflare and the app;
|
||||||
|
* raise it only if the topology gains a trusted hop.
|
||||||
|
*/
|
||||||
|
const FORWARDED_HOPS_TO_DISCOUNT = 1;
|
||||||
|
|
||||||
|
/** Returned when no trustworthy address exists. Callers rate-limit it harder. */
|
||||||
|
export const UNKNOWN_CLIENT_IP = "unknown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shapes this module reads: an H3Event (`security.ts` callers) or a
|
||||||
|
* SolidStart APIEvent (`routes/api/*`), which wraps its H3Event as
|
||||||
|
* `nativeEvent`. Every field is optional because test doubles are minimal;
|
||||||
|
* anything unreadable simply falls through to the next trust step.
|
||||||
|
*/
|
||||||
|
export interface ClientIpEventLike {
|
||||||
|
request?: { headers?: unknown };
|
||||||
|
headers?: unknown;
|
||||||
|
node?: {
|
||||||
|
req?: {
|
||||||
|
headers?: Record<string, unknown>;
|
||||||
|
socket?: { remoteAddress?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
nativeEvent?: ClientIpEventLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client address for a security decision, per the trust order above.
|
||||||
|
* Never truncated: rate-limit buckets are per-address, and truncating would
|
||||||
|
* let an attacker share a bucket with the innocents behind their /24.
|
||||||
|
*/
|
||||||
|
export function resolveTrustedClientIp(event: ClientIpEventLike): string {
|
||||||
|
// An APIEvent's socket and (authoritative) headers live on the wrapped
|
||||||
|
// H3Event; a bare APIEvent-shaped double without one is read as-is.
|
||||||
|
const source = event.nativeEvent ?? event;
|
||||||
|
|
||||||
|
const peer = directPeer(source);
|
||||||
|
if (peer && isIpAddress(peer) && !isLoopbackAddress(peer)) return peer;
|
||||||
|
|
||||||
|
// The peer is the loopback origin proxy (or unreadable): forwarded headers
|
||||||
|
// may be believed, in the order above.
|
||||||
|
const connecting = cleanHop(headerValue(source, CF_CONNECTING_IP));
|
||||||
|
if (isIpAddress(connecting)) return connecting;
|
||||||
|
|
||||||
|
const hops = (headerValue(source, X_FORWARDED_FOR) ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map(cleanHop)
|
||||||
|
.filter(Boolean);
|
||||||
|
const index = hops.length - 1 - FORWARDED_HOPS_TO_DISCOUNT;
|
||||||
|
if (index >= 0 && isIpAddress(hops[index])) return hops[index]!;
|
||||||
|
|
||||||
|
return UNKNOWN_CLIENT_IP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The socket peer, IPv6-mapped IPv4 flattened (`::ffff:1.2.3.4` → `1.2.3.4`). */
|
||||||
|
function directPeer(event: ClientIpEventLike): string | null {
|
||||||
|
try {
|
||||||
|
const addr = event.node?.req?.socket?.remoteAddress;
|
||||||
|
return addr ? addr.replace(/^::ffff:/, "") : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header read tolerant of every event shape in play: Fetch-style
|
||||||
|
* `request.headers.get`, an h3 `event.headers` (Headers or plain object), and
|
||||||
|
* the raw node request headers. Header names are case-insensitive per HTTP;
|
||||||
|
* the plain-object fallbacks lowercase to match how node and the test doubles
|
||||||
|
* store them.
|
||||||
|
*/
|
||||||
|
function headerValue(event: ClientIpEventLike, name: string): string | null {
|
||||||
|
try {
|
||||||
|
const fromHeaders = (h: unknown): string | null => {
|
||||||
|
if (!h) return null;
|
||||||
|
if (typeof (h as Headers).get === "function") {
|
||||||
|
return (h as Headers).get(name) ?? null;
|
||||||
|
}
|
||||||
|
if (typeof h === "object") {
|
||||||
|
const record = h as Record<string, unknown>;
|
||||||
|
const value = record[name] ?? record[name.toLowerCase()];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
fromHeaders(event.request?.headers) ??
|
||||||
|
fromHeaders(event.headers) ??
|
||||||
|
fromHeaders(event.node?.req?.headers)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHop(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Addresses: parse and validate ───────────────────────────────────────────
|
||||||
|
// Ported from the reviewed `request-client.ts` (FRE-366 @ 1d9287a); see the
|
||||||
|
// header comment for why this is a copy and not an import.
|
||||||
|
|
||||||
|
/** Any address this file understands: a dotted quad or an IPv6 literal. */
|
||||||
|
export function isIpAddress(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && addressBytes(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an address is the loopback the production proxy presents.
|
||||||
|
*
|
||||||
|
* Parses rather than string-matches, so the forms a real peer can present in —
|
||||||
|
* `::ffff:127.0.0.53`, `0:0:0:0:0:0:0:1`, an IPv4-mapped literal — are all
|
||||||
|
* recognised. An address this does not parse is *not* loopback, which fails
|
||||||
|
* closed: forwarded headers from an unrecognised peer are ignored rather than
|
||||||
|
* believed (a non-loopback peer short-circuits before headers are read).
|
||||||
|
*/
|
||||||
|
export function isLoopbackAddress(ip: string): boolean {
|
||||||
|
const bytes = addressBytes(ip);
|
||||||
|
if (!bytes) return false;
|
||||||
|
if (bytes.length === 4) return bytes[0] === 127;
|
||||||
|
|
||||||
|
// IPv6 loopback ::1 — and ::ffff:127/8, which is loopback too.
|
||||||
|
const mapped = ipv4FromBytes(bytes);
|
||||||
|
if (mapped) return isLoopbackAddress(mapped);
|
||||||
|
const tail = bytes[15]!;
|
||||||
|
return bytes.slice(0, 15).every((byte) => byte === 0) && tail === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpv4(value: string): boolean {
|
||||||
|
const parts = value.split(".");
|
||||||
|
if (parts.length !== 4) return false;
|
||||||
|
return parts.every(
|
||||||
|
(part) => /^\d{1,3}$/.test(part) && Number(part) <= 255 && !/^0\d/.test(part)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4 bytes for IPv4, 16 for IPv6 (including `::ffff:a.b.c.d`), else null. */
|
||||||
|
function addressBytes(value: string): number[] | null {
|
||||||
|
const raw = value.trim().replace(/^\[|\]$/g, "").split("%")[0]!;
|
||||||
|
if (isIpv4(raw)) return raw.split(".").map(Number);
|
||||||
|
if (!raw.includes(":")) return null;
|
||||||
|
|
||||||
|
const halves = raw.split("::");
|
||||||
|
if (halves.length > 2) return null;
|
||||||
|
const compressed = halves.length === 2;
|
||||||
|
|
||||||
|
const head = groupsToBytes(halves[0] ?? "");
|
||||||
|
const tail = groupsToBytes(halves[1] ?? "");
|
||||||
|
if (!head || !tail) return null;
|
||||||
|
|
||||||
|
const total = head.length + tail.length;
|
||||||
|
if (compressed) {
|
||||||
|
if (total > 8) return null;
|
||||||
|
return [...head, ...new Array(8 - total).fill(0), ...tail].flatMap(split16);
|
||||||
|
}
|
||||||
|
if (total !== 8) return null;
|
||||||
|
return [...head, ...tail].flatMap(split16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One side of `::` as 16-bit groups; a trailing `1.2.3.4` counts as two. */
|
||||||
|
function groupsToBytes(side: string): number[] | null {
|
||||||
|
if (side === "") return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const part of side.split(":")) {
|
||||||
|
if (/^[0-9a-fA-F]{1,4}$/.test(part)) {
|
||||||
|
out.push(parseInt(part, 16));
|
||||||
|
} else if (isIpv4(part)) {
|
||||||
|
const o = part.split(".").map(Number);
|
||||||
|
out.push((o[0]! << 8) | o[1]!, (o[2]! << 8) | o[3]!);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function split16(group: number): number[] {
|
||||||
|
return [(group >> 8) & 0xff, group & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`), else null. */
|
||||||
|
function ipv4FromBytes(bytes: number[]): string | null {
|
||||||
|
const prefix = bytes.slice(0, 10).every((byte) => byte === 0);
|
||||||
|
const marker = bytes[10] === 0xff && bytes[11] === 0xff;
|
||||||
|
if (!prefix || !marker) return null;
|
||||||
|
return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`;
|
||||||
|
}
|
||||||
520
src/server/weekly-metrics.test.ts
Normal file
520
src/server/weekly-metrics.test.ts
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import {
|
||||||
|
VISITOR_ANALYTICS_ADDED_COLUMNS,
|
||||||
|
VISITOR_ANALYTICS_COLUMNS,
|
||||||
|
VISITOR_ANALYTICS_DDL,
|
||||||
|
VISITOR_ANALYTICS_INSERT
|
||||||
|
} from "./analytics-schema";
|
||||||
|
import {
|
||||||
|
NOOK_CHECKOUT_PATH,
|
||||||
|
NOOK_DOWNLOAD_PATH,
|
||||||
|
NOOK_HOST,
|
||||||
|
ensureAnalyticsSchema,
|
||||||
|
getWeeklyReport,
|
||||||
|
pickMover,
|
||||||
|
readCampaigns,
|
||||||
|
readFunnel,
|
||||||
|
readTraffic,
|
||||||
|
renderWeeklyMarkdown,
|
||||||
|
windowsFor,
|
||||||
|
type Connections,
|
||||||
|
type FunnelCounts,
|
||||||
|
type MetricsConn
|
||||||
|
} from "./weekly-metrics";
|
||||||
|
|
||||||
|
// Same reasoning as the backfill suite: the queries run real SQL, so an
|
||||||
|
// in-memory SQLite shaped like production is the only honest stand-in. A stub
|
||||||
|
// that returned canned rows would pass with a column or a label the live table
|
||||||
|
// does not have.
|
||||||
|
|
||||||
|
const NOOK_DDL = [
|
||||||
|
`CREATE TABLE trials (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
email TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE licenses (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
stripe_session_id TEXT UNIQUE NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE activations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
license_id TEXT NOT NULL,
|
||||||
|
device_fingerprint TEXT NOT NULL,
|
||||||
|
device_name TEXT NOT NULL,
|
||||||
|
activated_at TEXT NOT NULL,
|
||||||
|
deactivated_at TEXT
|
||||||
|
)`
|
||||||
|
];
|
||||||
|
|
||||||
|
function adapter(db: Database): MetricsConn {
|
||||||
|
return {
|
||||||
|
async execute(query) {
|
||||||
|
const args = (query.args ?? []) as never[];
|
||||||
|
const statement = db.query(query.sql);
|
||||||
|
if (/^\s*(select|pragma|with)\b/i.test(query.sql)) {
|
||||||
|
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rows: [] as unknown[],
|
||||||
|
rowsAffected: statement.run(...args).changes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let analyticsDb: Database;
|
||||||
|
let nookDb: Database;
|
||||||
|
let conns: Connections;
|
||||||
|
|
||||||
|
const NOW = new Date("2026-10-29T16:00:00Z");
|
||||||
|
|
||||||
|
// A row as the post-FRE-366 writers record it: real client address, the host
|
||||||
|
// it addressed, and a stage label where the row is a download.
|
||||||
|
const row = (
|
||||||
|
overrides: Record<string, unknown>
|
||||||
|
): Record<string, unknown> => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
user_id: null,
|
||||||
|
path: "/",
|
||||||
|
method: "GET",
|
||||||
|
referrer: null,
|
||||||
|
user_agent: "Mozilla/5.0 (Macintosh)",
|
||||||
|
// What the writers store since the FRE-369 privacy floor: a /24 (or /56 for
|
||||||
|
// IPv6), never the address itself.
|
||||||
|
ip_address: "203.0.113.0",
|
||||||
|
country: "GB",
|
||||||
|
device_type: "desktop",
|
||||||
|
browser: "safari",
|
||||||
|
os: "macos",
|
||||||
|
utm_source: null,
|
||||||
|
utm_medium: null,
|
||||||
|
utm_campaign: null,
|
||||||
|
duration_ms: null,
|
||||||
|
fcp: null,
|
||||||
|
lcp: null,
|
||||||
|
cls: null,
|
||||||
|
fid: null,
|
||||||
|
inp: null,
|
||||||
|
ttfb: null,
|
||||||
|
dom_load: null,
|
||||||
|
load_complete: null,
|
||||||
|
stage: null,
|
||||||
|
host: NOOK_HOST,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
function insertAnalytics(overrides: Record<string, unknown>): void {
|
||||||
|
const values = row(overrides);
|
||||||
|
analyticsDb
|
||||||
|
.prepare(VISITOR_ANALYTICS_INSERT)
|
||||||
|
.run(...(VISITOR_ANALYTICS_COLUMNS.map((column) => values[column]) as never[]));
|
||||||
|
|
||||||
|
// `created_at` is not in VISITOR_ANALYTICS_COLUMNS (it defaults to now), so
|
||||||
|
// the window each row belongs to has to be set after the insert.
|
||||||
|
if (values.created_at) {
|
||||||
|
analyticsDb
|
||||||
|
.prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`)
|
||||||
|
.run(values.created_at as string, values.id as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside the current window for NOW - 7 days: 2026-10-22 16:00 → 2026-10-29 16:00.
|
||||||
|
const THIS_WEEK = "2026-10-27 10:00:00";
|
||||||
|
// Inside the previous window: 2026-10-15 16:00 → 2026-10-22 16:00.
|
||||||
|
const LAST_WEEK = "2026-10-18 10:00:00";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
analyticsDb = new Database(":memory:");
|
||||||
|
analyticsDb.run(VISITOR_ANALYTICS_DDL);
|
||||||
|
nookDb = new Database(":memory:");
|
||||||
|
for (const ddl of NOOK_DDL) nookDb.run(ddl);
|
||||||
|
conns = { analytics: adapter(analyticsDb), nook: adapter(nookDb) };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("windowsFor", () => {
|
||||||
|
it("puts the previous window directly before the current one", () => {
|
||||||
|
const windows = windowsFor(NOW, 7);
|
||||||
|
expect(windows.current).toEqual({
|
||||||
|
start: "2026-10-22 16:00:00",
|
||||||
|
end: "2026-10-29 16:00:00"
|
||||||
|
});
|
||||||
|
expect(windows.previous).toEqual({
|
||||||
|
start: "2026-10-15 16:00:00",
|
||||||
|
end: "2026-10-22 16:00:00"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readFunnel", () => {
|
||||||
|
it("counts each stage by its own label inside its window", async () => {
|
||||||
|
// Page views on the Nook host count; other sites, API calls, legacy
|
||||||
|
// rows with no host, and the other window do not.
|
||||||
|
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: "/downloads", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: "/", host: "freno.me", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: "/", host: null, created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: "/api/trpc/anything", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: "/", created_at: LAST_WEEK });
|
||||||
|
// The old checkout proxy: a /checkout page view is a visit, not a
|
||||||
|
// started checkout.
|
||||||
|
insertAnalytics({ path: "/checkout", created_at: THIS_WEEK });
|
||||||
|
|
||||||
|
// The download label splits by stage; a row with no stage predates the
|
||||||
|
// split and counts in neither.
|
||||||
|
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "click", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "served", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "served", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: null, created_at: THIS_WEEK });
|
||||||
|
|
||||||
|
// A created Stripe session, recorded by the checkout route itself.
|
||||||
|
insertAnalytics({
|
||||||
|
path: NOOK_CHECKOUT_PATH,
|
||||||
|
method: "POST",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
|
||||||
|
// The licence DB stores ISO-8601 with milliseconds.
|
||||||
|
nookDb
|
||||||
|
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
|
||||||
|
.run("fp-1", "2026-10-28T20:28:46.806Z");
|
||||||
|
nookDb
|
||||||
|
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
|
||||||
|
.run("fp-old", "2026-08-26T19:26:12.791Z");
|
||||||
|
nookDb
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO licenses (id, key, email, stripe_session_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run("lic-1", "NOOK-1", "buyer@example.com", "cs_1", "2026-10-25T12:00:00.000Z");
|
||||||
|
nookDb
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO activations (id, license_id, device_fingerprint, device_name, activated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run("act-1", "lic-1", "device-1", "Hermes", "2026-10-25T12:05:00.000Z");
|
||||||
|
|
||||||
|
const window = windowsFor(NOW, 7).current;
|
||||||
|
const funnel = await readFunnel(conns, window);
|
||||||
|
|
||||||
|
expect(funnel).toEqual({
|
||||||
|
visit: 3, // "/", "/downloads" and the "/checkout" page view on the Nook host
|
||||||
|
downloadClick: 1,
|
||||||
|
dmgServed: 2,
|
||||||
|
trial: 1,
|
||||||
|
checkout: 1,
|
||||||
|
purchase: 1,
|
||||||
|
activation: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports zeros against an empty database", async () => {
|
||||||
|
const window = windowsFor(NOW, 7).current;
|
||||||
|
expect(await readFunnel(conns, window)).toEqual({
|
||||||
|
visit: 0,
|
||||||
|
downloadClick: 0,
|
||||||
|
dmgServed: 0,
|
||||||
|
trial: 0,
|
||||||
|
checkout: 0,
|
||||||
|
purchase: 0,
|
||||||
|
activation: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readTraffic", () => {
|
||||||
|
it("separates legacy rows, live loopback and automation from clean traffic", async () => {
|
||||||
|
// Post-instrumentation rows: host set. Two of them still loopback — in the
|
||||||
|
// truncated shapes the privacy floor writes — which is the shape the report
|
||||||
|
// must flag as "the fix is not live".
|
||||||
|
insertAnalytics({
|
||||||
|
path: NOOK_DOWNLOAD_PATH,
|
||||||
|
stage: "click",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
insertAnalytics({
|
||||||
|
path: NOOK_DOWNLOAD_PATH,
|
||||||
|
stage: "served",
|
||||||
|
ip_address: "127.0.0.0",
|
||||||
|
user_agent: "The Nook/0.13.4 Sparkle/2.9.6",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
insertAnalytics({
|
||||||
|
path: "/",
|
||||||
|
ip_address: "0:0:0:0:0:0:0:0",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||||
|
|
||||||
|
// Pre-instrumentation rows: no host, proxy's address — legacy, never
|
||||||
|
// counted as "the fix is not live".
|
||||||
|
insertAnalytics({
|
||||||
|
path: NOOK_DOWNLOAD_PATH,
|
||||||
|
stage: null,
|
||||||
|
host: null,
|
||||||
|
ip_address: "127.0.0.1",
|
||||||
|
user_agent: "curl/8.7.1",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
insertAnalytics({
|
||||||
|
path: "/",
|
||||||
|
host: null,
|
||||||
|
ip_address: "::1",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
|
||||||
|
const traffic = await readTraffic(conns, windowsFor(NOW, 7).current);
|
||||||
|
|
||||||
|
expect(traffic).toEqual({
|
||||||
|
analyticsRows: 6,
|
||||||
|
hostRows: 4,
|
||||||
|
legacyRows: 2,
|
||||||
|
hostLoopbackRows: 2,
|
||||||
|
downloadRows: 3,
|
||||||
|
downloadClickRows: 1,
|
||||||
|
downloadServedRows: 1,
|
||||||
|
downloadUnstagedRows: 1,
|
||||||
|
downloadNonBrowserRows: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readCampaigns", () => {
|
||||||
|
it("groups tagged rows, excludes untagged, and orders by volume", async () => {
|
||||||
|
insertAnalytics({
|
||||||
|
path: "/",
|
||||||
|
utm_source: "hn",
|
||||||
|
utm_medium: "post",
|
||||||
|
utm_campaign: "launch",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
insertAnalytics({
|
||||||
|
path: "/",
|
||||||
|
utm_source: "hn",
|
||||||
|
utm_medium: "post",
|
||||||
|
utm_campaign: "launch",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
insertAnalytics({
|
||||||
|
path: "/",
|
||||||
|
utm_source: "r_macapps",
|
||||||
|
utm_campaign: "week2",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||||
|
|
||||||
|
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
|
||||||
|
|
||||||
|
expect(campaigns).toEqual([
|
||||||
|
{ source: "hn", medium: "post", campaign: "launch", count: 2 },
|
||||||
|
{ source: "r_macapps", medium: null, campaign: "week2", count: 1 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the list at ten rows", async () => {
|
||||||
|
for (let i = 0; i < 12; i += 1) {
|
||||||
|
insertAnalytics({
|
||||||
|
path: "/",
|
||||||
|
utm_source: `source-${i}`,
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
|
||||||
|
expect(campaigns.length).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ensureAnalyticsSchema", () => {
|
||||||
|
it("adds the post-hoc columns to a table that predates them, idempotently", async () => {
|
||||||
|
// The table as production held it before any post-hoc column existed.
|
||||||
|
// (`created_at` is not in the insert column list — it defaults DB-side —
|
||||||
|
// so the simulated table has to name it explicitly.)
|
||||||
|
const oldDb = new Database(":memory:");
|
||||||
|
const withoutAdded = [
|
||||||
|
...VISITOR_ANALYTICS_COLUMNS.filter(
|
||||||
|
(c) => !VISITOR_ANALYTICS_ADDED_COLUMNS.some((a) => a.name === c)
|
||||||
|
),
|
||||||
|
"created_at"
|
||||||
|
];
|
||||||
|
oldDb.run(
|
||||||
|
`CREATE TABLE VisitorAnalytics (${withoutAdded.map((c) => `${c} TEXT`).join(", ")})`
|
||||||
|
);
|
||||||
|
const oldConn = adapter(oldDb);
|
||||||
|
|
||||||
|
await ensureAnalyticsSchema(oldConn);
|
||||||
|
await ensureAnalyticsSchema(oldConn); // a second run is a no-op
|
||||||
|
|
||||||
|
// The writers' full INSERT — stage and host included — now works.
|
||||||
|
const values = row({ stage: "click", path: NOOK_DOWNLOAD_PATH });
|
||||||
|
expect(() =>
|
||||||
|
oldDb
|
||||||
|
.prepare(VISITOR_ANALYTICS_INSERT)
|
||||||
|
.run(
|
||||||
|
...(VISITOR_ANALYTICS_COLUMNS.map((c) => values[c]) as never[])
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
|
oldDb
|
||||||
|
.prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`)
|
||||||
|
.run(THIS_WEEK, values.id as string);
|
||||||
|
|
||||||
|
// And the funnel reads its stage split from the migrated table.
|
||||||
|
const funnel = await readFunnel(
|
||||||
|
{ analytics: oldConn, nook: conns.nook },
|
||||||
|
windowsFor(NOW, 7).current
|
||||||
|
);
|
||||||
|
expect(funnel.downloadClick).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickMover", () => {
|
||||||
|
const base: FunnelCounts = {
|
||||||
|
visit: 0,
|
||||||
|
downloadClick: 0,
|
||||||
|
dmgServed: 0,
|
||||||
|
trial: 0,
|
||||||
|
checkout: 0,
|
||||||
|
purchase: 0,
|
||||||
|
activation: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
it("picks the stage with the largest absolute change", () => {
|
||||||
|
const mover = pickMover(
|
||||||
|
{ ...base, visit: 61, dmgServed: 156, trial: 2 },
|
||||||
|
{ ...base, visit: 56 }
|
||||||
|
);
|
||||||
|
expect(mover).toEqual({
|
||||||
|
key: "dmgServed",
|
||||||
|
label: "DMG served",
|
||||||
|
now: 156,
|
||||||
|
before: 0,
|
||||||
|
delta: 156
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks ties toward the earlier stage", () => {
|
||||||
|
const mover = pickMover(
|
||||||
|
{ ...base, visit: 5, downloadClick: 5 },
|
||||||
|
{ ...base, visit: 0, downloadClick: 0 }
|
||||||
|
);
|
||||||
|
expect(mover?.key).toBe("visit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing moved", () => {
|
||||||
|
expect(pickMover({ ...base, visit: 3 }, { ...base, visit: 3 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getWeeklyReport / renderWeeklyMarkdown", () => {
|
||||||
|
it("renders seven distinct stages with no merged-label or proxy caveats", async () => {
|
||||||
|
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "click", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({
|
||||||
|
path: NOOK_DOWNLOAD_PATH,
|
||||||
|
stage: "served",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
insertAnalytics({
|
||||||
|
path: NOOK_DOWNLOAD_PATH,
|
||||||
|
stage: "served",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
insertAnalytics({ path: NOOK_CHECKOUT_PATH, method: "POST", created_at: THIS_WEEK });
|
||||||
|
nookDb
|
||||||
|
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
|
||||||
|
.run("fp-1", "2026-10-28T20:28:46.806Z");
|
||||||
|
|
||||||
|
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||||
|
const markdown = renderWeeklyMarkdown(report);
|
||||||
|
|
||||||
|
expect(markdown).toContain(
|
||||||
|
"Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days)"
|
||||||
|
);
|
||||||
|
expect(markdown).toContain(`| visit (${NOOK_HOST} page views) | 1 | 0 | +1 |`);
|
||||||
|
expect(markdown).toContain("| download click | 1 | 0 | +1 |");
|
||||||
|
expect(markdown).toContain("| DMG served | 2 | 0 | +2 |");
|
||||||
|
expect(markdown).toContain("| checkout started (Stripe session) | 1 | 0 | +1 |");
|
||||||
|
|
||||||
|
const order = [
|
||||||
|
`visit (${NOOK_HOST} page views)`,
|
||||||
|
"download click",
|
||||||
|
"DMG served",
|
||||||
|
"trial started",
|
||||||
|
"checkout started (Stripe session)",
|
||||||
|
"purchase (licence issued)",
|
||||||
|
"activation"
|
||||||
|
];
|
||||||
|
for (let i = 1; i < order.length; i += 1) {
|
||||||
|
expect(markdown.indexOf(order[i - 1]!)).toBeLessThan(
|
||||||
|
markdown.indexOf(order[i]!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caveats this issue retired stay retired.
|
||||||
|
expect(markdown).not.toContain("one label");
|
||||||
|
expect(markdown).not.toContain("not recorded");
|
||||||
|
expect(markdown).not.toContain("no host dimension");
|
||||||
|
expect(markdown).not.toContain("†");
|
||||||
|
|
||||||
|
expect(report.mover?.key).toBe("dmgServed");
|
||||||
|
expect(markdown).toContain("**DMG served: 0 → 2 (+2).**");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states what pre-instrumentation rows cannot say, computed from the window", async () => {
|
||||||
|
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||||
|
// Legacy rows: no host, proxy's address, no stage label.
|
||||||
|
insertAnalytics({ path: "/", host: null, ip_address: "127.0.0.1", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({
|
||||||
|
path: NOOK_DOWNLOAD_PATH,
|
||||||
|
stage: null,
|
||||||
|
host: null,
|
||||||
|
ip_address: "127.0.0.1",
|
||||||
|
user_agent: "curl/8.7.1",
|
||||||
|
created_at: THIS_WEEK
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||||
|
const markdown = renderWeeklyMarkdown(report);
|
||||||
|
|
||||||
|
expect(markdown).toContain(
|
||||||
|
"2 of 3 rows in the window predate the FRE-366 instrumentation"
|
||||||
|
);
|
||||||
|
expect(markdown).toContain("predate the click/served stage label");
|
||||||
|
// Legacy loopback is not "the fix is not live".
|
||||||
|
expect(markdown).not.toContain("still carry a loopback address");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags loopback only when rows recorded since the instrumentation carry it", async () => {
|
||||||
|
// Both spellings a live writer can produce: the raw proxy address, and the
|
||||||
|
// /24 the privacy floor stores instead of it.
|
||||||
|
insertAnalytics({ path: "/", ip_address: "127.0.0.1", created_at: THIS_WEEK });
|
||||||
|
insertAnalytics({ path: "/", ip_address: "127.0.53.0", created_at: THIS_WEEK });
|
||||||
|
|
||||||
|
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||||
|
const markdown = renderWeeklyMarkdown(report);
|
||||||
|
|
||||||
|
expect(markdown).toContain(
|
||||||
|
"2 rows recorded since the instrumentation still carry a loopback address"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing moved rather than inventing a winner", async () => {
|
||||||
|
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||||
|
const markdown = renderWeeklyMarkdown(report);
|
||||||
|
|
||||||
|
expect(report.mover).toBeNull();
|
||||||
|
expect(markdown).toContain("_Nothing moved: every stage held its previous-week value._");
|
||||||
|
expect(markdown).toContain("_No tagged traffic in the window._");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a window that is not a positive number of days", async () => {
|
||||||
|
await expect(getWeeklyReport(conns, { now: NOW, days: 0 })).rejects.toThrow(
|
||||||
|
"days must be a positive number"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
647
src/server/weekly-metrics.ts
Normal file
647
src/server/weekly-metrics.ts
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
/**
|
||||||
|
* The weekly funnel reading: `marketing/metrics/weekly.md`'s data half.
|
||||||
|
*
|
||||||
|
* The funnel the marketing cluster agreed on is
|
||||||
|
*
|
||||||
|
* visit → download click → DMG served → trial started → checkout started
|
||||||
|
* → purchase → activation
|
||||||
|
*
|
||||||
|
* and this module reads it from the two live databases, plus the campaign
|
||||||
|
* dimension the /analytics page already exposes through
|
||||||
|
* `getAnalyticsSummary.topCampaigns`.
|
||||||
|
*
|
||||||
|
* Two properties matter more than the numbers:
|
||||||
|
*
|
||||||
|
* - It takes both connections as arguments. `~/server/analytics` imports the
|
||||||
|
* full server env (Stripe keys, Apple secret, licence key …); the weekly
|
||||||
|
* job only needs the two databases, and must be runnable from a checkout
|
||||||
|
* whose `.env` carries nothing else. The SQL here is the same shape as the
|
||||||
|
* analytics summaries, so the weekly read and the dashboard cannot drift
|
||||||
|
* into different label sets.
|
||||||
|
* - Every stage is measured by its own label. FRE-366 gave the funnel that:
|
||||||
|
* the download door and the DMG file route record distinct `stage` values
|
||||||
|
* (`click` / `served`), a created Stripe checkout session records its own
|
||||||
|
* row, and every row carries the real client address and the host it
|
||||||
|
* addressed, so the visit stage scopes to the Nook. Rows that predate
|
||||||
|
* that instrumentation are stated next to the numbers, never silently
|
||||||
|
* counted (see `renderWeeklyMarkdown`).
|
||||||
|
*
|
||||||
|
* No `~/env`, no `~/server/database`: the caller owns the connections, the
|
||||||
|
* tests own an in-memory SQLite, and the script owns the libsql clients.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DOWNLOAD_PATH_PREFIX } from "~/lib/download-asset";
|
||||||
|
import {
|
||||||
|
VISITOR_ANALYTICS_ADDED_COLUMNS,
|
||||||
|
VISITOR_ANALYTICS_DDL
|
||||||
|
} from "~/server/analytics-schema";
|
||||||
|
|
||||||
|
/** The one asset key the Nook lands under — `downloadPath("thenook")`. */
|
||||||
|
export const NOOK_DOWNLOAD_PATH = `${DOWNLOAD_PATH_PREFIX}thenook`;
|
||||||
|
|
||||||
|
/** The host the visit stage scopes to. Recorded per row since FRE-366. */
|
||||||
|
export const NOOK_HOST = "nook.freno.me";
|
||||||
|
|
||||||
|
/** The path a created Stripe checkout session records (FRE-366). */
|
||||||
|
export const NOOK_CHECKOUT_PATH = "/api/the-nook/checkout";
|
||||||
|
|
||||||
|
/** An `execute`-shaped connection: the libsql client and the test adapter both fit. */
|
||||||
|
export interface MetricsConn {
|
||||||
|
execute(query: {
|
||||||
|
sql: string;
|
||||||
|
args?: any[];
|
||||||
|
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Connections {
|
||||||
|
/** freno.me Turso DB — VisitorAnalytics. */
|
||||||
|
analytics: MetricsConn;
|
||||||
|
/** the-nook Turso DB — trials, licenses, activations. */
|
||||||
|
nook: MetricsConn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StageKey =
|
||||||
|
| "visit"
|
||||||
|
| "downloadClick"
|
||||||
|
| "dmgServed"
|
||||||
|
| "trial"
|
||||||
|
| "checkout"
|
||||||
|
| "purchase"
|
||||||
|
| "activation";
|
||||||
|
|
||||||
|
export interface StageDef {
|
||||||
|
key: StageKey;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seven agreed stages, each with its own label in the data:
|
||||||
|
* `downloadClick` and `dmgServed` split what used to be one merged download
|
||||||
|
* label (the `stage` column, FRE-366), and `checkout` reads created Stripe
|
||||||
|
* sessions instead of the /checkout page-view proxy.
|
||||||
|
*/
|
||||||
|
export const STAGES: StageDef[] = [
|
||||||
|
{ key: "visit", label: `visit (${NOOK_HOST} page views)` },
|
||||||
|
{ key: "downloadClick", label: "download click" },
|
||||||
|
{ key: "dmgServed", label: "DMG served" },
|
||||||
|
{ key: "trial", label: "trial started" },
|
||||||
|
{ key: "checkout", label: "checkout started (Stripe session)" },
|
||||||
|
{ key: "purchase", label: "purchase (licence issued)" },
|
||||||
|
{ key: "activation", label: "activation" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface FunnelCounts {
|
||||||
|
visit: number;
|
||||||
|
downloadClick: number;
|
||||||
|
dmgServed: number;
|
||||||
|
trial: number;
|
||||||
|
checkout: number;
|
||||||
|
purchase: number;
|
||||||
|
activation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignRow {
|
||||||
|
source: string | null;
|
||||||
|
medium: string | null;
|
||||||
|
campaign: string | null;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Traffic-shape facts the report has to state next to the funnel. */
|
||||||
|
export interface WindowTraffic {
|
||||||
|
analyticsRows: number;
|
||||||
|
/** Rows carrying a host — written since the FRE-366 instrumentation. */
|
||||||
|
hostRows: number;
|
||||||
|
/** Rows with no host — pre-FRE-366; their address is the proxy's loopback. */
|
||||||
|
legacyRows: number;
|
||||||
|
/** Post-instrumentation rows still recording loopback: the fix is not live. */
|
||||||
|
hostLoopbackRows: number;
|
||||||
|
downloadRows: number;
|
||||||
|
downloadClickRows: number;
|
||||||
|
downloadServedRows: number;
|
||||||
|
/** Download rows with no stage label — they predate FRE-366 and cannot be split. */
|
||||||
|
downloadUnstagedRows: number;
|
||||||
|
downloadNonBrowserRows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mover {
|
||||||
|
key: StageKey;
|
||||||
|
label: string;
|
||||||
|
now: number;
|
||||||
|
before: number;
|
||||||
|
delta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyReport {
|
||||||
|
window: { start: string; end: string; days: number };
|
||||||
|
previous: { start: string; end: string };
|
||||||
|
counts: FunnelCounts;
|
||||||
|
previousCounts: FunnelCounts;
|
||||||
|
campaigns: CampaignRow[];
|
||||||
|
taggedRows: number;
|
||||||
|
traffic: WindowTraffic;
|
||||||
|
mover: Mover | null;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recorded address that is not a visitor, in every spelling the column can
|
||||||
|
* hold: the proxy the request came through (`127.0.0.1`, `::1`, an IPv4-mapped
|
||||||
|
* form) *and* the prefixes the FRE-369 privacy floor writes instead of them
|
||||||
|
* (`127.0.0.0`, all-zero IPv6). Whole `127/8` because truncation keeps the first
|
||||||
|
* three octets, so `127.0.53.1` and `127.0.53.0` are the same non-visitor.
|
||||||
|
*/
|
||||||
|
const LOOPBACK_SQL = `(ip_address LIKE '127.%'
|
||||||
|
OR ip_address LIKE '::ffff:127.%'
|
||||||
|
OR ip_address IN ('::1', '0:0:0:0:0:0:0:1', '0:0:0:0:0:0:0:0'))`;
|
||||||
|
|
||||||
|
/** SQLite's `datetime()` string, UTC: `YYYY-MM-DD HH:MM:SS`. */
|
||||||
|
export function sqlTime(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 19).replace("T", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Windows {
|
||||||
|
current: { start: string; end: string };
|
||||||
|
previous: { start: string; end: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current window is `[now - days, now)` and the previous one sits directly
|
||||||
|
* before it, so a weekly read always compares like with like. Boundaries are
|
||||||
|
* passed as SQL parameters rather than `datetime('now', …)` so the file a run
|
||||||
|
* writes is reproducible and the two queries cannot straddle a second.
|
||||||
|
*/
|
||||||
|
export function windowsFor(now: Date, days: number): Windows {
|
||||||
|
const span = days * 24 * 60 * 60 * 1000;
|
||||||
|
const end = now;
|
||||||
|
const start = new Date(end.getTime() - span);
|
||||||
|
const previousEnd = start;
|
||||||
|
const previousStart = new Date(previousEnd.getTime() - span);
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: { start: sqlTime(start), end: sqlTime(end) },
|
||||||
|
previous: { start: sqlTime(previousStart), end: sqlTime(previousEnd) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: unknown): number {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (typeof value === "bigint") return Number(value);
|
||||||
|
if (typeof value === "string") return Number(value) || 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scalar(
|
||||||
|
conn: MetricsConn,
|
||||||
|
sql: string,
|
||||||
|
args: any[]
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await conn.execute({ sql, args });
|
||||||
|
const row = result.rows[0] as Record<string, unknown> | undefined;
|
||||||
|
if (!row) return 0;
|
||||||
|
return toNumber(Object.values(row)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the analytics table up to the schema the readers expect, through the
|
||||||
|
* same idempotent bootstrap path the app server uses (`analytics.ts`): the
|
||||||
|
* DDL is a no-op against the live table and each post-hoc column is ALTERed
|
||||||
|
* in only when absent. The weekly job runs against databases the app may not
|
||||||
|
* have migrated yet — a fresh checkout, a restored backup, or production
|
||||||
|
* before the next deploy — and must render its seven stages regardless.
|
||||||
|
*/
|
||||||
|
export async function ensureAnalyticsSchema(conn: MetricsConn): Promise<void> {
|
||||||
|
await conn.execute({ sql: VISITOR_ANALYTICS_DDL });
|
||||||
|
|
||||||
|
const info = await conn.execute({
|
||||||
|
sql: `PRAGMA table_info(VisitorAnalytics)`
|
||||||
|
});
|
||||||
|
const present = new Set(info.rows.map((row) => String((row as any).name)));
|
||||||
|
for (const column of VISITOR_ANALYTICS_ADDED_COLUMNS) {
|
||||||
|
if (!present.has(column.name)) {
|
||||||
|
await conn.execute({ sql: column.ddl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countInWindow(
|
||||||
|
conn: MetricsConn,
|
||||||
|
sql: string,
|
||||||
|
start: string,
|
||||||
|
end: string
|
||||||
|
): Promise<number> {
|
||||||
|
return scalar(conn, sql, [start, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One window's funnel. Two databases, one call. */
|
||||||
|
export async function readFunnel(
|
||||||
|
conns: Connections,
|
||||||
|
window: { start: string; end: string }
|
||||||
|
): Promise<FunnelCounts> {
|
||||||
|
const { analytics, nook } = conns;
|
||||||
|
const { start, end } = window;
|
||||||
|
|
||||||
|
// Scoped to the Nook host (FRE-366): before the host column existed, a
|
||||||
|
// page view of `/` on nook.freno.me was the same row as freno.me's root.
|
||||||
|
// Pre-instrumentation rows carry no host and are excluded — the traffic
|
||||||
|
// section states how many.
|
||||||
|
const visit = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND path NOT LIKE '/api/%'
|
||||||
|
AND host = ?`,
|
||||||
|
[start, end, NOOK_HOST]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadClick = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND path = ? AND stage = 'click'`,
|
||||||
|
[start, end, NOOK_DOWNLOAD_PATH]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dmgServed = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND path = ? AND stage = 'served'`,
|
||||||
|
[start, end, NOOK_DOWNLOAD_PATH]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Created Stripe checkout sessions, recorded by the checkout route itself
|
||||||
|
// (FRE-366) — not the /checkout page view that used to proxy for them.
|
||||||
|
const checkout = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND path = ?`,
|
||||||
|
[start, end, NOOK_CHECKOUT_PATH]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The licence DB stores ISO-8601 (`2026-10-28T20:28:46.806Z`); the analytics
|
||||||
|
// DB stores `YYYY-MM-DD HH:MM:SS`. `datetime()` normalises both sides so a
|
||||||
|
// boundary seconds apart cannot include or drop a row on string comparison.
|
||||||
|
const trial = await scalar(
|
||||||
|
nook,
|
||||||
|
`SELECT COUNT(*) AS n FROM trials
|
||||||
|
WHERE datetime(started_at) >= datetime(?) AND datetime(started_at) < datetime(?)`,
|
||||||
|
[start, end]
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = await scalar(
|
||||||
|
nook,
|
||||||
|
`SELECT COUNT(*) AS n FROM licenses
|
||||||
|
WHERE datetime(created_at) >= datetime(?) AND datetime(created_at) < datetime(?)`,
|
||||||
|
[start, end]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activation = await scalar(
|
||||||
|
nook,
|
||||||
|
`SELECT COUNT(*) AS n FROM activations
|
||||||
|
WHERE datetime(activated_at) >= datetime(?) AND datetime(activated_at) < datetime(?)`,
|
||||||
|
[start, end]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visit,
|
||||||
|
downloadClick,
|
||||||
|
dmgServed,
|
||||||
|
trial,
|
||||||
|
checkout,
|
||||||
|
purchase,
|
||||||
|
activation
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traffic shape for one window: how much of it predates the FRE-366
|
||||||
|
* instrumentation (no host, proxy's address), whether rows recorded since
|
||||||
|
* still see loopback (which would mean the forwarded-header fix is not
|
||||||
|
* live), and how the download label splits. Without these facts the numbers
|
||||||
|
* read as demand, which they are not.
|
||||||
|
*/
|
||||||
|
export async function readTraffic(
|
||||||
|
conns: Connections,
|
||||||
|
window: { start: string; end: string }
|
||||||
|
): Promise<WindowTraffic> {
|
||||||
|
const { analytics } = conns;
|
||||||
|
const { start, end } = window;
|
||||||
|
|
||||||
|
const analyticsRows = await countInWindow(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ?`,
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
);
|
||||||
|
|
||||||
|
const hostRows = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND host IS NOT NULL`,
|
||||||
|
[start, end]
|
||||||
|
);
|
||||||
|
|
||||||
|
const legacyRows = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND host IS NULL`,
|
||||||
|
[start, end]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hostLoopbackRows = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND host IS NOT NULL
|
||||||
|
AND ${LOOPBACK_SQL}`,
|
||||||
|
[start, end]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadRows = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ? AND path = ?`,
|
||||||
|
[start, end, NOOK_DOWNLOAD_PATH]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadClickRows = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ? AND path = ? AND stage = 'click'`,
|
||||||
|
[start, end, NOOK_DOWNLOAD_PATH]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadServedRows = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ? AND path = ? AND stage = 'served'`,
|
||||||
|
[start, end, NOOK_DOWNLOAD_PATH]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadUnstagedRows = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ? AND path = ? AND stage IS NULL`,
|
||||||
|
[start, end, NOOK_DOWNLOAD_PATH]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anything whose UA is not a browser engine: curl, Sparkle's updater,
|
||||||
|
// link-preview crawlers. A real install click arrives as `Mozilla/5.0 …`.
|
||||||
|
const downloadNonBrowserRows = await scalar(
|
||||||
|
analytics,
|
||||||
|
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ? AND path = ?
|
||||||
|
AND (user_agent IS NULL OR user_agent NOT LIKE 'Mozilla/%')`,
|
||||||
|
[start, end, NOOK_DOWNLOAD_PATH]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
analyticsRows,
|
||||||
|
hostRows,
|
||||||
|
legacyRows,
|
||||||
|
hostLoopbackRows,
|
||||||
|
downloadRows,
|
||||||
|
downloadClickRows,
|
||||||
|
downloadServedRows,
|
||||||
|
downloadUnstagedRows,
|
||||||
|
downloadNonBrowserRows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The campaign dimension, grouped exactly as `getAnalyticsSummary.topCampaigns`
|
||||||
|
* groups it: top 10 by volume, untagged rows excluded (the untagged bucket is
|
||||||
|
* a different question and would otherwise swallow the list).
|
||||||
|
*/
|
||||||
|
export async function readCampaigns(
|
||||||
|
analytics: MetricsConn,
|
||||||
|
window: { start: string; end: string }
|
||||||
|
): Promise<CampaignRow[]> {
|
||||||
|
const result = await analytics.execute({
|
||||||
|
sql: `SELECT utm_source, utm_medium, utm_campaign, COUNT(*) AS count
|
||||||
|
FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND (utm_source IS NOT NULL OR utm_medium IS NOT NULL OR utm_campaign IS NOT NULL)
|
||||||
|
GROUP BY utm_source, utm_medium, utm_campaign
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
args: [window.start, window.end]
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows.map((raw) => {
|
||||||
|
const row = raw as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
source: (row.utm_source as string | null) ?? null,
|
||||||
|
medium: (row.utm_medium as string | null) ?? null,
|
||||||
|
campaign: (row.utm_campaign as string | null) ?? null,
|
||||||
|
count: toNumber(row.count)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "The one thing that moved": the stage with the largest absolute change
|
||||||
|
* against the previous window. Ties go to the earlier stage, so a week when
|
||||||
|
* nothing moved returns null rather than a made-up winner.
|
||||||
|
*/
|
||||||
|
export function pickMover(
|
||||||
|
current: FunnelCounts,
|
||||||
|
previous: FunnelCounts
|
||||||
|
): Mover | null {
|
||||||
|
let best: Mover | null = null;
|
||||||
|
|
||||||
|
for (const stage of STAGES) {
|
||||||
|
const delta = current[stage.key] - previous[stage.key];
|
||||||
|
if (delta === 0) continue;
|
||||||
|
if (!best || Math.abs(delta) > Math.abs(best.delta)) {
|
||||||
|
best = {
|
||||||
|
key: stage.key,
|
||||||
|
label: stage.label,
|
||||||
|
now: current[stage.key],
|
||||||
|
before: previous[stage.key],
|
||||||
|
delta
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportOptions {
|
||||||
|
/** End of the current window; defaults to the wall clock. */
|
||||||
|
now?: Date;
|
||||||
|
/** Window length in days; defaults to 7. */
|
||||||
|
days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read everything `.md` needs, in one call. */
|
||||||
|
export async function getWeeklyReport(
|
||||||
|
conns: Connections,
|
||||||
|
options: ReportOptions = {}
|
||||||
|
): Promise<WeeklyReport> {
|
||||||
|
const days = options.days ?? 7;
|
||||||
|
if (!Number.isFinite(days) || days <= 0) {
|
||||||
|
throw new Error(`days must be a positive number, got ${days}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = options.now ?? new Date();
|
||||||
|
const windows = windowsFor(now, days);
|
||||||
|
|
||||||
|
const [counts, previousCounts, traffic, campaigns] = await Promise.all([
|
||||||
|
readFunnel(conns, windows.current),
|
||||||
|
readFunnel(conns, windows.previous),
|
||||||
|
readTraffic(conns, windows.current),
|
||||||
|
readCampaigns(conns.analytics, windows.current)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
window: { ...windows.current, days },
|
||||||
|
previous: windows.previous,
|
||||||
|
counts,
|
||||||
|
previousCounts,
|
||||||
|
campaigns,
|
||||||
|
taggedRows: campaigns.reduce((sum, row) => sum + row.count, 0),
|
||||||
|
traffic,
|
||||||
|
mover: pickMover(counts, previousCounts),
|
||||||
|
generatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cell(value: string | null): string {
|
||||||
|
return value === null || value === "" ? "—" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signed(value: number): string {
|
||||||
|
if (value === 0) return "0";
|
||||||
|
return value > 0 ? `+${value}` : `−${Math.abs(value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the weekly file. Interpretation lives in the CMO's read; this renderer
|
||||||
|
* only states what the data is, what moved, and what the numbers cannot say.
|
||||||
|
* The Gaps section is not boilerplate: each bullet is computed from the window
|
||||||
|
* it describes, so it empties itself if the underlying hole is ever fixed.
|
||||||
|
*/
|
||||||
|
export function renderWeeklyMarkdown(report: WeeklyReport): string {
|
||||||
|
const { counts, previousCounts, traffic } = report;
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push("# Weekly funnel snapshot");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
`Window: **${report.window.start} → ${report.window.end} UTC** (${report.window.days} days). ` +
|
||||||
|
`Previous: ${report.previous.start} → ${report.previous.end} UTC.`
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
"Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated " +
|
||||||
|
"weekly, never hand-edited. The analysis is the CMO's."
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## Funnel");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("| Stage | This week | Last week | Change |");
|
||||||
|
lines.push("|---|---:|---:|---:|");
|
||||||
|
for (const stage of STAGES) {
|
||||||
|
const now = counts[stage.key];
|
||||||
|
const before = previousCounts[stage.key];
|
||||||
|
lines.push(`| ${stage.label} | ${now} | ${before} | ${signed(now - before)} |`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## Campaigns");
|
||||||
|
lines.push("");
|
||||||
|
if (report.campaigns.length === 0) {
|
||||||
|
lines.push("_No tagged traffic in the window._");
|
||||||
|
} else {
|
||||||
|
lines.push(
|
||||||
|
"Top 10 by volume, untagged excluded — the same grouping " +
|
||||||
|
"`getAnalyticsSummary.topCampaigns` returns."
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("| Source | Medium | Campaign | Rows |");
|
||||||
|
lines.push("|---|---|---|---:|");
|
||||||
|
for (const row of report.campaigns) {
|
||||||
|
lines.push(
|
||||||
|
`| ${cell(row.source)} | ${cell(row.medium)} | ${cell(row.campaign)} | ${row.count} |`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
`${report.taggedRows} of ${traffic.analyticsRows} rows in the window carry ` +
|
||||||
|
`campaign tags; the rest are untagged and therefore unattributed.`
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## The one thing that moved");
|
||||||
|
lines.push("");
|
||||||
|
if (!report.mover) {
|
||||||
|
lines.push("_Nothing moved: every stage held its previous-week value._");
|
||||||
|
} else {
|
||||||
|
const mover = report.mover;
|
||||||
|
lines.push(
|
||||||
|
`**${mover.label}: ${mover.before} → ${mover.now} (${signed(mover.delta)}).**`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## Before acting on these numbers");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (traffic.legacyRows > 0) {
|
||||||
|
lines.push(
|
||||||
|
`- **${traffic.legacyRows} of ${traffic.analyticsRows} rows in the window predate the FRE-366 instrumentation.** ` +
|
||||||
|
`They carry no host, and their recorded address is the production proxy's loopback ` +
|
||||||
|
`(127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. ` +
|
||||||
|
`They are excluded from every host-scoped stage above (visits, and the download ` +
|
||||||
|
`click/served split), and "unique visitors" over them still collapses to one host. ` +
|
||||||
|
`Rows recorded since carry the site they addressed and the visitor's address ` +
|
||||||
|
`truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so ` +
|
||||||
|
`"unique visitors" counts networks, not machines.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (traffic.hostLoopbackRows > 0) {
|
||||||
|
lines.push(
|
||||||
|
`- **${traffic.hostLoopbackRows} rows recorded since the instrumentation still carry a ` +
|
||||||
|
`loopback address** — the forwarded-header fix is not taking effect in production ` +
|
||||||
|
`(no \`cf-connecting-ip\`, and no usable \`x-forwarded-for\` hop). Do not trust ` +
|
||||||
|
`per-channel reach until this is zero.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (traffic.downloadRows > 0) {
|
||||||
|
lines.push(
|
||||||
|
`- **Download rows are automation-heavy.** Of this window's ${traffic.downloadRows} ` +
|
||||||
|
`download rows, ${traffic.downloadClickRows} are clicks and ` +
|
||||||
|
`${traffic.downloadServedRows} are served files` +
|
||||||
|
(traffic.downloadUnstagedRows > 0
|
||||||
|
? `; the remaining ${traffic.downloadUnstagedRows} predate the click/served stage ` +
|
||||||
|
`label and cannot be split, so they count in neither stage`
|
||||||
|
: "") +
|
||||||
|
`. ${traffic.downloadNonBrowserRows} of all ${traffic.downloadRows} are not browser ` +
|
||||||
|
`user agents (curl, Sparkle, link-preview crawlers).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature ` +
|
||||||
|
`Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.`
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Sources");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.");
|
||||||
|
lines.push("- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.");
|
||||||
|
lines.push(`- Generated ${report.generatedAt}.`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"crons": [
|
"crons": [
|
||||||
|
{ "path": "/api/analytics-retention", "schedule": "0 12 * * *" },
|
||||||
{ "path": "/api/the-nook/lifecycle", "schedule": "0 13 * * *" }
|
{ "path": "/api/the-nook/lifecycle", "schedule": "0 13 * * *" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user