1 Commits

Author SHA1 Message Date
29842dc039 security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371)
getClientIP (the rate-limit and audit key) took the LEFTMOST x-forwarded-for
hop and read x-real-ip unconditionally. In our topology — client → Cloudflare
→ same-host origin proxy → app — both proxies APPEND, so the leftmost hop is
whatever the sender typed: a rotating X-Forwarded-For minted a fresh rate-limit
bucket per request, and unlimited bug-report quota via the same pattern in
routes/api/the-nook/report.ts. x-real-ip is client-settable too; nothing in
the chain overwrites it. It is no longer read at all.

New trust order (src/server/trusted-client-ip.ts, resolveTrustedClientIp):

  1. a non-loopback socket peer IS the client — its headers are ignored;
  2. cf-connecting-ip (Cloudflare overwrites it at the edge; parsed as an
     address or rejected);
  3. x-forwarded-for counted from the RIGHT, discounting one hop for the
     Cloudflare edge address the origin proxy appended (the topology has
     exactly one trusted proxy below Cloudflare);
  4. "unknown" — callers key stricter shared limits on it, so resolution
     fails closed toward a tighter bucket, never a looser one.

Addresses are never truncated: a /24-truncated key would pool attackers with
the innocents behind them.

Share-vs-copy, stated deliberately: this is a port of the parsing primitives
 FRE-366 reviewed in request-client.ts, NOT an import. resolveClientIp carries
a standing rule (1d9287a) that it never feeds a security decision; lifting
that rule for these callers would couple an access-control change to an
analytics branch under review, and the analytics helper only exposes
truncated prefixes while rate limiting needs the full address. If the two
drift, trusted-client-ip.ts is the one security paths follow. The deploy-side
precondition (origin reachable only via Cloudflare + the same-host proxy) is
documented in the file header; if the app is ever directly internet-reachable
no in-app read can recover the true client.

Tests updated deliberately, not to go green: rate-limit.test.ts now pins the
rightmost-minus-one order, cf-connecting-ip precedence, peer-over-headers,
x-real-ip rejection, and the spoof case (a rotating leftmost hop does not
change the resolved client); report.test.ts pins the quota key stability
under a rotating header. New trusted-client-ip.test.ts covers the helper's
own edges: APIEvent wrapper, loopback forms, unparseable hops failing closed.
2026-10-30 05:06:18 +00:00
18 changed files with 834 additions and 734 deletions

View File

@@ -0,0 +1,79 @@
# Remote SSH copy — HELD, do not publish
**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.
**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.
Until then this file is the only home for these sentences.
## What was removed
### 1. Landing collage tile
Source: `src/components/nook/FeatureCollage.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Agents on other machines
body: Run agents on Linux boxes and SSH servers — one click installs the
nook-hook, and their sessions join the same island.
```
The mock attached to it showed two session rows whose agent chips named
`build-box` and `gpu-rig`.
### 2. Landing feature breakdown section
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Any SSH box, one click away
body: Pick a host from your ssh config or your tailnet and click once — the
nook-hook installs itself and its agents appear beside your local ones,
indistinguishable.
bullets:
- One click: connect, push the hook, configure agents, check it can reach this Mac
- Hosts mirror this Mac's installed integrations automatically
- Remote sessions are ordinary rows; the agent chip names the machine
```
The section's demo animated a `gpu-rig` host moving from "Not set up" through
`connecting` → `pushing the hook binary` → `configuring agents` → `checking the
host can reach this Mac` → `Live`, then showed a remote session row.
### 3. Approvals bullet
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
- Works across every hooked agent, local or remote
```
### 4. Feature chip
Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
```
One-click SSH setup
```
## What replaced it
Nothing. The Remote tile and section were removed rather than replaced; the
existing Approvals cell already carries the verified local claim:
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
## Restore checklist
1. FRE-320/FRE-205 have shipped the one-click install and removed the
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
3. A recording exists showing a remote agent joining the island.
4. CMO restores the copy above, then owns the launch plan for it — with an
owner and date per item — before anything goes back on the site.

View File

@@ -0,0 +1,30 @@
# Why the remote claim is parked
The site was selling a setup path a buyer does not get.
FRE-320 measured today's actual path (2026-10-29): the one-click 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-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
was already in front of buyers.
So the `freno-dev` source now says only what the product can back today: local
agents, the card, and the notch. That correction is committed locally and is
**not deployed** — the live page still carries the remote sentences until the
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
| Requirement | Owner | Date |
|---|---|---|
| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships |
| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves |
| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore |
| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true |
Nothing in this folder is publishable. `marketing/approved/` remains the only
place a human publishes from.

View File

@@ -4,13 +4,12 @@ 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.
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 correction, **Reconciled 2026-10-29** against the CTO's verdicts ([FRE-315], [FRE-322],
against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354], [FRE-997]), the Chief [FRE-354]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
of Staff's price decision (2026-10-29 04:01), the Plan B launch route ([FRE-346] → launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
[FRE-203] `plan-b`), and `The-Nook` master (app floor is **14.2**, not 14.0 — **14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
`freno-dev` `881190d`, site merge `134f41e`). The previous version of this file version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
named an upstream `the-nook/docs/marketing-strategy.md` that no longer exists; the no longer exists; the verdicts below are the source of truth it was pointing at.
verdicts below are the source of truth it was pointing at.
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315 [FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322 [FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
@@ -18,7 +17,6 @@ verdicts below are the source of truth it was pointing at.
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320 [FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346 [FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203 [FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
## The sentence ## The sentence
@@ -115,16 +113,11 @@ one thing forbidden: say "TestFlight beta" when the phone exists in a sentence a
all, and never claim approvals-from-phone as a shipped capability without its own all, and never claim approvals-from-phone as a shipped capability without its own
CTO verdict — no "(coming soon)" parentheticals either way. CTO verdict — no "(coming soon)" parentheticals either way.
Remote SSH is **publishable**, and the site sells it again ([FRE-997]). The Remote SSH is **held**, not approved: the one-click install has not
copy that was pulled on 2026-10-29 had been checked against claims it never made: been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
hosts come from `~/.ssh/config` and Tailscale (`RemoteHostDiscovery`), the `~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
one-time `ssh-copy-id` is a prerequisite the row hands the user as a command `marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
(`RemoteHostStatus.fixCommand`), the per-host credential is minted and copied recording shows a remote agent joining the notch.
over (`RemoteMirror`), and a remote host mirrors this Mac's integrations
(`RemoteMirrorReconcile`). Two things in the old copy were wrong and were fixed
rather than restored: the "every hooked agent" overstatement, and a count that
said thirteen against a registry of eighteen. Muse, Cline and Amp are
`.unsupported` for remote in `AgentSpec.remote`, and the copy names them.
### 5. Humans hold every account ### 5. Humans hold every account
@@ -132,7 +125,7 @@ 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.
## Claims: status at 2026-11-03 ## Claims: status at 2026-10-29
Each row names the evidence, not a feeling. Re-read the verdict before using a row. Each row names the evidence, not a feeling. Re-read the verdict before using a row.
@@ -143,17 +136,16 @@ Each row names the evidence, not a feeling. Re-read the verdict before using a r
| 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". | | 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. | | 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. | | 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, and `every` / `all` are still banned without naming the three local-only agents. | | Agent coverage numbers ("thirteen agents", "8 agents", "all your agents") | **Excluded — verdict B, count removed** | [FRE-359] verdict B (2026-10-29): the page's 13 rows are the registry's first 13 of 18, so no number is publishable and `every` / `all` / `the agents The Nook supports` are banned. Removed from `agents.tsx`, the landing island section and the marquee. [FRE-322] §1 matrix for the sets. |
| 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. | | 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 | **Publishable, with the three local-only agents named** | [FRE-997]: `RemoteHostDiscovery` (ssh config + tailnet), `RemoteMirror` (hook push, credential, mirroring), `RemoteMirrorReconcile`, `RemoteHostStatus.fixCommand` (the one-time `ssh-copy-id` the row shows). `AgentSpec.remote` is `.unsupported` for Muse, Cline and Amp — the landing bullet and /agents paragraph name them; never say "every hooked agent". | | 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). | | "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. | | 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 — the only count - Short, concrete, no hype adjectives. Numbers over adjectives — but no coverage
that ships is eighteen, and it is the registry's own size (see the coverage row counts (see the excluded row above).
above).
- The notch is the product's home; agents are the product's point. - 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",

View File

@@ -1,6 +1,5 @@
import { import {
For, For,
Show,
createEffect, createEffect,
createSignal, createSignal,
onCleanup, onCleanup,
@@ -21,7 +20,6 @@ import { Campfire, type CampfireState } from "./Campfire";
import { import {
ApprovalCardMock, ApprovalCardMock,
SessionRowMock, SessionRowMock,
Glyph,
CLAUDE, CLAUDE,
CODEX CODEX
} from "./ModuleMocks"; } from "./ModuleMocks";
@@ -37,7 +35,7 @@ import {
* Detailed feature breakdowns: alternating copy + live-demo sections. * Detailed feature breakdowns: alternating copy + live-demo sections.
* Each demo stages the real interaction loop: attention flares, approval * Each demo stages the real interaction loop: attention flares, approval
* resolves, fans throttle, the charge ceiling holds, meetings surface, * resolves, fans throttle, the charge ceiling holds, meetings surface,
* the camera wakes, a remote host goes live. Newer demos live in * the camera wakes. Newer demos live in
* MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx. * MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx.
*/ */
@@ -807,112 +805,6 @@ function CameraDemo() {
); );
} }
/* ── Demo 7: Settings ▸ Remote hosts — one click to Live ──────────── */
/** RemoteMirror's progress steps, in order. */
const SETUP_STEPS = [
"connecting",
"pushing the hook binary",
"configuring agents",
"checking the host can reach this Mac"
];
function RemoteHostDemo() {
/* -1 not set up · 0…3 setting up · 4 live */
const [step, setStep] = createSignal(-1);
const [pressed, setPressed] = createSignal(false);
useDemoLoop(11000, [
[0, () => (setStep(-1), setPressed(false))],
[1400, () => setPressed(true)],
[1700, () => (setPressed(false), setStep(0))],
[2700, () => setStep(1)],
[3700, () => setStep(2)],
[4700, () => setStep(3)],
[5800, () => setStep(4)]
]);
const live = () => step() === 4;
const working = () => step() >= 0 && step() < 4;
const chip = () =>
live()
? { color: "rgb(48,209,88)", label: "Live" }
: working()
? { color: "rgba(255,255,255,0.55)", label: "Setting up" }
: { color: "rgba(255,255,255,0.35)", label: "Not set up" };
return (
<div class="relative mx-auto w-full max-w-[360px] space-y-3">
{/* the host's card in Settings ▸ Agents ▸ Remote hosts */}
<ModuleCard>
<div class="space-y-2 text-left">
<div class="flex items-center gap-2.5">
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
<p class="truncate text-[11px] text-white/45">tailnet peer</p>
</div>
<Show when={working()}>
<span class="h-3 w-3 animate-spin rounded-full border-[1.5px] border-white/25 border-t-white/80" />
</Show>
<span class="flex items-center gap-1.5 text-[10.5px] font-medium text-white/85">
<span
class="h-[7px] w-[7px] rounded-full transition-colors duration-300"
style={{ background: chip().color }}
/>
{chip().label}
</span>
<Show
when={live()}
fallback={
<span
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
style={{
background: ACCENT,
opacity: working() ? 0 : 1,
transform: pressed() ? "scale(0.92)" : "scale(1)"
}}
>
Set up
</span>
}
>
<Glyph name="refresh" size={11} class="text-white/50" />
<span class="rounded-md bg-white/10 px-2 py-1 text-[11px] font-semibold text-[#ff6961]">
Remove
</span>
</Show>
</div>
<p class="h-4 text-[11px] text-white/50">
{working() ? SETUP_STEPS[step()] : " "}
</p>
</div>
</ModuleCard>
{/* its agents arrive as ordinary rows, the machine named in the chip */}
<div
class="transition-all duration-500"
style={{
opacity: live() ? 1 : 0,
transform: live() ? "translateY(0)" : "translateY(6px)"
}}
>
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Running python bench.py…"
status="running"
agent={CODEX}
host="gpu-rig"
origin="iTerm2"
age="now"
/>
</div>
<FakeCursor
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
at={{ x: 88, y: 16 }}
/>
</div>
);
}
/* ── Demo 8: campfire states ───────────────────────────────────────── */ /* ── Demo 8: campfire states ───────────────────────────────────────── */
function CampfireDemo() { function CampfireDemo() {
@@ -959,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={[
"18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more", "Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, Cursor 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"
@@ -976,7 +868,6 @@ export default function FeatureBreakdowns() {
"Approval cards pin above the session list; questions answer inside their row", "Approval cards pin above the session list; questions answer inside their row",
"Allow for session waves through the rest of that session's requests", "Allow for session waves through the rest of that session's requests",
"Answer from iPhone or Apple Watch too, now in TestFlight beta", "Answer from iPhone or Apple Watch too, now in TestFlight beta",
"Works across local and remote sessions; Muse, Cline and Amp are this Mac only"
]} ]}
reversed reversed
> >
@@ -996,20 +887,6 @@ export default function FeatureBreakdowns() {
<TerminalReplyDemo /> <TerminalReplyDemo />
</BreakdownSection> </BreakdownSection>
<BreakdownSection
kicker="Remote"
title="Any SSH box, one click away"
body="Pick a host from your ssh config or your tailnet and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
bullets={[
"One click: connect, push the hook, configure agents, check it can reach this Mac",
"Hosts mirror this Mac's installed integrations automatically",
"Remote sessions are ordinary rows; the agent chip names the machine"
]}
reversed
>
<RemoteHostDemo />
</BreakdownSection>
<BreakdownSection <BreakdownSection
kicker="The campfire" kicker="The campfire"
title="A hearth that tells the truth" title="A hearth that tells the truth"

View File

@@ -1,12 +1,6 @@
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js"; import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock"; import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
import { import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
ApprovalCardMock,
SessionRowMock,
DieLine,
CLAUDE,
CODEX
} from "./ModuleMocks";
/** /**
* Feature collage: five cells sold as the app's actual UI. The hero cell * Feature collage: five cells sold as the app's actual UI. The hero cell
@@ -105,35 +99,6 @@ function FansPanelMock() {
); );
} }
/**
* Remote agents as the panel draws them: ordinary session rows whose agent
* chip names the machine ("Claude Code · build-box").
*/
function RemoteMock() {
return (
<div class="space-y-1.5">
<SessionRowMock
title="api · main"
prompt="fix the flaky test"
state="Running pytest -x…"
status="running"
agent={CLAUDE}
host="build-box"
age="4m"
/>
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Done · replied 1m ago"
status="ready"
agent={CODEX}
host="gpu-rig"
age="12m"
/>
</div>
);
}
/* Deterministic usage wander for the traces. */ /* Deterministic usage wander for the traces. */
const wander = (n: number, k: number) => const wander = (n: number, k: number) =>
0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k); 0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k);
@@ -256,19 +221,6 @@ export default function FeatureCollage() {
</InkStage> </InkStage>
</CollageCell> </CollageCell>
{/* Remote agents */}
<CollageCell
kicker="Remote"
title="Agents on other machines"
body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island."
>
<InkStage class="w-full">
<div class="w-full px-3 text-left">
<RemoteMock />
</div>
</InkStage>
</CollageCell>
{/* Gauges */} {/* Gauges */}
<CollageCell <CollageCell
kicker="Your Mac" kicker="Your Mac"

View File

@@ -722,7 +722,7 @@ function LayoutMock() {
/* ── Marquee ─────────────────────────────────────────────────────── */ /* ── Marquee ─────────────────────────────────────────────────────── */
const CHIPS_A = [ const CHIPS_A = [
"18 agents", "Agents wired in",
"Custom agents", "Custom agents",
"Six chiptunes", "Six chiptunes",
"Focus-aware slots", "Focus-aware slots",
@@ -740,7 +740,6 @@ const CHIPS_B = [
"tmux · iTerm2 · kitty", "tmux · iTerm2 · kitty",
"Markdown replies", "Markdown replies",
"Tailscale hosts", "Tailscale hosts",
"One-click SSH setup",
"iPhone widgets", "iPhone widgets",
"Watch-face widgets", "Watch-face widgets",
"Screenshots to clipboard", "Screenshots to clipboard",

View File

@@ -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 () => {

View File

@@ -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" }), {

View File

@@ -1,5 +1,6 @@
/** /**
* Regression test for the /agents roster and its coverage count. * Regression test for the /agents roster (NOOK-336) and its coverage wording
* (FRE-359).
* *
* 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,11 +9,11 @@
* 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 * FRE-359 verdict B: the page no longer states a count or claims coverage
* eighteen, so the count was dropped rather than corrected (FRE-359 verdict B); * ("all thirteen", "every coding agent The Nook supports"). The page still
* now that all eighteen rows are on the page, the roster check is an equality * lists 13 of the registry's 18 rows — the table is hand-mirrored and its
* check again and the page states eighteen. A subset check would let a shipped * regeneration is its own ticket — so the roster check here is a subset
* agent go missing behind a passing suite, which is the NOOK-336 failure. * check in registry order, not an equality check.
* *
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions. * STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
*/ */
@@ -25,9 +26,6 @@ const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
/** User-facing copy only: comments are not copy and may name the old wording. */ /** User-facing copy only: comments are not copy and may name the old wording. */
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
/** JSX wraps prose across lines; match a sentence, not its indentation. */
const PROSE = COPY.replace(/\s+/g, " ");
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */ /** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
const REGISTRY = [ const REGISTRY = [
"Claude Code", "Claude Code",
@@ -50,41 +48,51 @@ const REGISTRY = [
"Amp" "Amp"
]; ];
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen"];
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", () => { /** The page's rows are a subset of the registry, in registry order. */
it("lists every registry agent, once, in registry order", () => { function subsetInOrder(page: string[], registry: string[]): boolean {
expect(entries).toEqual(REGISTRY); let cursor = 0;
for (const name of page) {
const at = registry.indexOf(name, cursor);
if (at === -1) return false;
cursor = at + 1;
}
return true;
}
describe("Nook /agents roster (NOOK-336)", () => {
it("lists only registry agents, in registry order", () => {
expect(entries.length).toBeGreaterThan(0);
expect(subsetInOrder(entries, REGISTRY)).toBe(true);
}); });
it("states the registry's size", () => { it("lists each agent at most once", () => {
expect(entries.length).toBe(18); expect(new Set(entries).size).toBe(entries.length);
expect(COPY).toContain("all eighteen");
expect(COPY).toContain("The 18 agents");
expect(COPY).toContain("eighteen 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");
}); });
}); });
describe("Nook /agents remote coverage", () => { describe("Nook /agents coverage wording (FRE-359)", () => {
it("says agents can run on remote machines over SSH", () => { it("states no agent count anywhere on the page", () => {
expect(PROSE).toContain("remote machines"); for (const word of NUMBER_WORDS) {
expect(PROSE).toContain("remote sessions report into the same grid"); expect(COPY.toLowerCase()).not.toContain(word);
}
// A bare numeral count ("13 agents", "The 13") is just as precise and just
// as unverified; the page must not state one in any form.
expect(COPY).not.toMatch(/\b\d{1,2} agents\b/);
expect(COPY).not.toMatch(/\ball \d{1,2}\b/);
}); });
it("names the three agents that stay on this Mac", () => { it("makes no completeness claim over the agent set", () => {
expect(PROSE).toContain("Muse, Cline and Amp run on this Mac only"); for (const banned of [
}); "every coding agent",
"the agents the nook supports",
it("discloses the local-only rows where they are listed", () => { "agents the nook supports",
const localOnly = [ "supported agents"
...SOURCE.matchAll(/This Mac only — remote hosts skip it/g) ]) {
]; expect(COPY.toLowerCase()).not.toContain(banned);
expect(localOnly.length).toBe(3); }
}); });
}); });

View File

@@ -1,10 +1,11 @@
/** /**
* `/agents` on the Nook subdomain — all eighteen agents in the registry and * `/agents` on the Nook subdomain — the agent roster and each row's event
* each row's event coverage. * coverage. It states no agent count and claims no completeness over the
* registry (FRE-359 verdict B).
* *
* The registry below mirrors `AgentSpec.all` in the app * The registry below mirrors `AgentSpec.all` in the app
* (~/Code/the-nook/Sources/NookCore/Agents/AgentRegistry.swift) and each * (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
* agent's installed hook surface (hook/setup.go) or plugin event subscription * 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
@@ -209,8 +210,7 @@ const AGENTS: AgentEntry[] = [
}, },
{ {
name: "Cline", name: "Cline",
wiring: wiring: "One hook file per event in its hooks directory",
"One hook file per event in its hooks directory. This Mac only — remote hosts skip it",
events: [ events: [
"SessionStart", "SessionStart",
"SessionEnd", "SessionEnd",
@@ -261,93 +261,6 @@ 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. This Mac only — remote hosts skip it",
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"
} }
]; ];
@@ -371,27 +284,25 @@ export default function NookAgentsPage() {
<> <>
<EdgeCacheHeaders maxAge={300} /> <EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Supported agents" title="Agent coverage"
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." description="The coding agents The Nook connects to, with the events each hook reports and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
ogTitle="The 18 agents The Nook supports, and what each hook sees" ogTitle="The coding agents The Nook connects to, 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]">
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl"> <h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
Supported agents Agent coverage
</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 eighteen coding agents in one place — your menu bar and The Nook watches coding agents in one place — your menu bar and notch
notch show every session that is running, thinking, or blocked, and a show sessions that are running, thinking, or blocked, and a card can
card answers permission prompts without leaving your keyboard. answer a permission prompt without leaving your keyboard.
</p> </p>
<p class="text-subtext1 mb-10 max-w-2xl text-sm"> <p class="text-subtext1 mb-10 max-w-2xl text-sm">
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. Muse, Cline and
Amp run on this Mac only.
</p> </p>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">

View File

@@ -1,27 +1,31 @@
/** /**
* Guard for the remote / SSH story on the Nook site, and for the two phrasings * Regression test for the removed remote / SSH claims and the removed agent
* that must not come back with it (FRE-323 → FRE-997). * count (FRE-323, FRE-359 verdict B).
* *
* FRE-323 removed the remote copy because it had been checked against claims * FRE-323 took the remote story off the site because it was unverified and
* the copy never made: the app does list hosts from `~/.ssh/config` and * demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
* Tailscale, a one-time `ssh-copy-id` is a normal prerequisite the row explains, * installs the nook-hook" flow is app-UI-only, needs a hand-edited
* and the per-host credential is minted and copied automatically * `~/.ssh/config` for any host not already in ssh config or on the tailnet,
* (`RemoteHostDiscovery`, `RemoteHostStatus.fixCommand`, `RemoteMirror`). Two * needs a one-time `ssh-copy-id` because the app runs ssh with
* things in the old copy were genuinely wrong, and this file guards those * `BatchMode=yes`, and has no manual fallback for minting the per-host
* instead of the copy as a whole: * credential. FRE-359 verdict B removed the coverage count for the same
* reason: the /agents table lists 13 of the registry's 18 rows.
* *
* - "Works across every hooked agent, local or remote" overstated it. Muse, * The copy was not thrown away — it is parked in
* Cline and Amp are `.unsupported` for remote in `AgentSpec.remote`, so the * `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
* landing bullet and the /agents paragraph name them. * it is still there so nobody deletes the parked story as stale. It goes back
* - The count said thirteen while `AgentSpec.all` holds eighteen. The fix was * on the site only when the one-click install ships and is recorded.
* to say eighteen, not to drop the count.
*
* So the removal guards from `d44133f` are gone: a removed sentence is exactly
* what this task restores. What stays is the pair of bans, because either one
* coming back would repeat the original defect.
* *
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and * STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
* `downloads.test.ts` (SolidJS does not render under `bun:test`). * `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 { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
@@ -29,7 +33,7 @@ import { join } from "node:path";
const ROOT = join(import.meta.dir, "../../.."); const ROOT = join(import.meta.dir, "../../..");
/** Shipped page and component source. */ /** Shipped page and component source. Tests and drafts may quote the old copy. */
const AUDITED = [ const AUDITED = [
"src/routes/nook/index.tsx", "src/routes/nook/index.tsx",
"src/routes/nook/agents.tsx", "src/routes/nook/agents.tsx",
@@ -39,15 +43,25 @@ const AUDITED = [
"src/components/nook/IslandExtras.tsx" "src/components/nook/IslandExtras.tsx"
]; ];
/** The overstatements the restored copy must never carry again. */ /** Every sentence FRE-323 ordered off the site. */
const BANNED_REMOTE_CLAIMS = [ const BANNED_REMOTE_CLAIMS = [
// a universal claim: three registry agents cannot mirror over SSH "Agents on other machines",
"every hooked agent, local or remote", "one click installs the nook-hook",
"Works across every hooked agent", "Any SSH box",
// the stale count this task replaced "Works across every hooked agent, local or remote",
"13 agents", "Remote sessions are ordinary rows",
"One-click SSH setup",
"remote machines over SSH",
"this Mac or remote machines"
];
/** FRE-359 verdict B: no count, no coverage claim, in any form. */
const BANNED_COVERAGE = [
"all thirteen", "all thirteen",
"thirteen coding agents" "thirteen coding agents",
"13 agents",
"every coding agent",
"Supported agents"
]; ];
/** User-facing copy only: comments are not copy and may name the old wording. */ /** User-facing copy only: comments are not copy and may name the old wording. */
@@ -57,46 +71,9 @@ function copyOf(file: string): string {
.replace(/^\s*\/\/.*$/gm, ""); .replace(/^\s*\/\/.*$/gm, "");
} }
describe("the restored remote copy is on the site (FRE-997)", () => { describe("remote claims stay off the site (FRE-323)", () => {
it("the landing collage sells agents on other machines", () => {
const copy = copyOf("src/components/nook/FeatureCollage.tsx");
for (const phrase of [
"Agents on other machines",
"one click installs the nook-hook",
"Remote"
]) {
expect(copy).toContain(phrase);
}
});
it("the landing breakdown keeps the SSH section and its demo", () => {
const copy = copyOf("src/components/nook/FeatureBreakdowns.tsx");
for (const phrase of [
"Any SSH box, one click away",
"Pick a host from your ssh config or your tailnet and click once",
"Remote sessions are ordinary rows",
"RemoteHostDemo"
]) {
expect(copy).toContain(phrase);
}
});
it("the chip is back", () => {
expect(copyOf("src/components/nook/IslandExtras.tsx")).toContain(
"One-click SSH setup"
);
});
it("/agents says remote sessions report into the same grid", () => {
const copy = copyOf("src/routes/nook/agents.tsx");
expect(copy).toContain("remote machines");
expect(copy).toContain("remote sessions report into the same grid");
});
});
describe("the two original overstatements stay off the site (FRE-323, FRE-359)", () => {
for (const file of AUDITED) { for (const file of AUDITED) {
it(`${file} claims no universal remote coverage and no stale count`, () => { it(`${file} does not restore a removed remote sentence`, () => {
const copy = copyOf(file); const copy = copyOf(file);
for (const phrase of BANNED_REMOTE_CLAIMS) { for (const phrase of BANNED_REMOTE_CLAIMS) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase()); expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
@@ -104,9 +81,34 @@ describe("the two original overstatements stay off the site (FRE-323, FRE-359)",
}); });
} }
it("the landing names the agents that stay local", () => { it("/agents no longer offers remote sessions", () => {
expect(copyOf("src/components/nook/FeatureBreakdowns.tsx")).toContain( const copy = copyOf("src/routes/nook/agents.tsx");
"Muse, Cline and Amp are this Mac only" 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("agent coverage count stays off the page (FRE-359 verdict B)", () => {
for (const file of AUDITED) {
it(`${file} states no coverage count`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_COVERAGE) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
});
}
it("/agents titles the table Agent coverage", () => {
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Agent coverage");
});
});

View File

@@ -1,139 +0,0 @@
/**
* 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&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: November 2, 2026</div>
<div class="py-2">
Welcome to A Smarter Remote (&apos;We&apos;, &apos;Us&apos;,
&apos;Our&apos;). 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&apos;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&apos;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 &gt; Privacy &amp; Security &gt; 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&apos;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>
</>
);
}

View File

@@ -1,141 +0,0 @@
/**
* 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&apos;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 (&apos;the
App&apos;). By using the App you agree to them. The App is licensed
to you under Apple&apos;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&apos;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 &apos;as is&apos;, 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>
</>
);
}

View File

@@ -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";
} }
/** /**

View File

@@ -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", () => {

View File

@@ -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: {}

View 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);
});
});

View 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]}`;
}