forked from Mike/freno-dev
Compare commits
3 Commits
29842dc039
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ff31346ff | |||
| 25ec84ceb4 | |||
| dd368d21e7 |
@@ -1,79 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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.
|
||||
@@ -4,12 +4,13 @@ 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
|
||||
silently diverging.
|
||||
|
||||
**Reconciled 2026-10-29** against the CTO's verdicts ([FRE-315], [FRE-322],
|
||||
[FRE-354]), 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.
|
||||
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 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
|
||||
@@ -17,6 +18,7 @@ no longer exists; the verdicts below are the source of truth it was pointing at.
|
||||
[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
|
||||
|
||||
@@ -113,11 +115,16 @@ 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
|
||||
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||
|
||||
Remote SSH is **held**, not approved: the one-click install has not
|
||||
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 stays parked in
|
||||
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
|
||||
recording shows a remote agent joining the notch.
|
||||
Remote SSH is **publishable**, and the site sells it again ([FRE-997]). The
|
||||
copy that was pulled on 2026-10-29 had been checked against claims it never made:
|
||||
hosts come from `~/.ssh/config` and Tailscale (`RemoteHostDiscovery`), the
|
||||
one-time `ssh-copy-id` is a prerequisite the row hands the user as a command
|
||||
(`RemoteHostStatus.fixCommand`), the per-host credential is minted and copied
|
||||
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
|
||||
|
||||
@@ -125,7 +132,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** —
|
||||
nothing posts itself.
|
||||
|
||||
## Claims: status at 2026-10-29
|
||||
## Claims: status at 2026-11-03
|
||||
|
||||
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||
|
||||
@@ -136,16 +143,17 @@ 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". |
|
||||
| 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 ("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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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". |
|
||||
| "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
|
||||
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives — but no coverage
|
||||
counts (see the excluded row above).
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives — the only count
|
||||
that ships is eighteen, and it is the registry's own size (see the coverage row
|
||||
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.
|
||||
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
@@ -20,6 +21,7 @@ import { Campfire, type CampfireState } from "./Campfire";
|
||||
import {
|
||||
ApprovalCardMock,
|
||||
SessionRowMock,
|
||||
Glyph,
|
||||
CLAUDE,
|
||||
CODEX
|
||||
} from "./ModuleMocks";
|
||||
@@ -35,7 +37,7 @@ import {
|
||||
* Detailed feature breakdowns: alternating copy + live-demo sections.
|
||||
* Each demo stages the real interaction loop: attention flares, approval
|
||||
* resolves, fans throttle, the charge ceiling holds, meetings surface,
|
||||
* the camera wakes. Newer demos live in
|
||||
* the camera wakes, a remote host goes live. Newer demos live in
|
||||
* MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx.
|
||||
*/
|
||||
|
||||
@@ -805,6 +807,112 @@ 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 ───────────────────────────────────────── */
|
||||
|
||||
function CampfireDemo() {
|
||||
@@ -851,7 +959,7 @@ export default function FeatureBreakdowns() {
|
||||
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."
|
||||
bullets={[
|
||||
"Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, Cursor 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",
|
||||
"Slot rankings — you choose which data earns the notch",
|
||||
"You don't need a notch — external displays get the same island, floating top-center"
|
||||
@@ -868,6 +976,7 @@ export default function FeatureBreakdowns() {
|
||||
"Approval cards pin above the session list; questions answer inside their row",
|
||||
"Allow for session waves through the rest of that session's requests",
|
||||
"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
|
||||
>
|
||||
@@ -887,6 +996,20 @@ export default function FeatureBreakdowns() {
|
||||
<TerminalReplyDemo />
|
||||
</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
|
||||
kicker="The campfire"
|
||||
title="A hearth that tells the truth"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
||||
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
|
||||
import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
|
||||
import {
|
||||
ApprovalCardMock,
|
||||
SessionRowMock,
|
||||
DieLine,
|
||||
CLAUDE,
|
||||
CODEX
|
||||
} from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Feature collage: five cells sold as the app's actual UI. The hero cell
|
||||
@@ -99,6 +105,35 @@ 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. */
|
||||
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);
|
||||
@@ -221,6 +256,19 @@ export default function FeatureCollage() {
|
||||
</InkStage>
|
||||
</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 */}
|
||||
<CollageCell
|
||||
kicker="Your Mac"
|
||||
|
||||
@@ -722,7 +722,7 @@ function LayoutMock() {
|
||||
/* ── Marquee ─────────────────────────────────────────────────────── */
|
||||
|
||||
const CHIPS_A = [
|
||||
"Agents wired in",
|
||||
"18 agents",
|
||||
"Custom agents",
|
||||
"Six chiptunes",
|
||||
"Focus-aware slots",
|
||||
@@ -740,6 +740,7 @@ const CHIPS_B = [
|
||||
"tmux · iTerm2 · kitty",
|
||||
"Markdown replies",
|
||||
"Tailscale hosts",
|
||||
"One-click SSH setup",
|
||||
"iPhone widgets",
|
||||
"Watch-face widgets",
|
||||
"Screenshots to clipboard",
|
||||
|
||||
@@ -10,8 +10,6 @@ import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
|
||||
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
||||
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
||||
/** Every rate-limit key the route derived, in order. */
|
||||
const rateKeys: string[] = [];
|
||||
|
||||
mock.module("~/server/email", () => ({
|
||||
default: async (to: string, subject: string, html: string) => {
|
||||
@@ -21,10 +19,7 @@ mock.module("~/server/email", () => ({
|
||||
}));
|
||||
|
||||
mock.module("~/server/bug-report-rate-limit", () => ({
|
||||
takeBugReportToken: (key: string) => {
|
||||
rateKeys.push(key);
|
||||
return tokenAnswers.shift() ?? { allowed: true };
|
||||
}
|
||||
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true }
|
||||
}));
|
||||
|
||||
const { POST } = await import("./report");
|
||||
@@ -77,7 +72,6 @@ describe("POST /api/the-nook/report", () => {
|
||||
beforeEach(() => {
|
||||
sentEmails.length = 0;
|
||||
tokenAnswers.length = 0;
|
||||
rateKeys.length = 0;
|
||||
});
|
||||
|
||||
it("emails the owner a report containing the snapshot, escaped", async () => {
|
||||
@@ -139,49 +133,10 @@ describe("POST /api/the-nook/report", () => {
|
||||
|
||||
it("still succeeds when no fingerprint header exists", async () => {
|
||||
const res = await POST(
|
||||
request(validPayload, {
|
||||
"cf-connecting-ip": "203.0.113.9",
|
||||
"x-forwarded-for": "203.0.113.9, 10.0.0.1"
|
||||
})
|
||||
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" })
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
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 () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { z } from "zod";
|
||||
import sendEmail from "~/server/email";
|
||||
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
||||
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
||||
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||
import { json, error } from "./_lib";
|
||||
|
||||
/**
|
||||
@@ -104,9 +103,10 @@ export async function POST(event: APIEvent): Promise<Response> {
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
// FRE-371: the IP fallback must be the spoof-resistant read — a rotating
|
||||
// leftmost x-forwarded-for used to mint unlimited report quota.
|
||||
const rateKey = fingerprint || resolveTrustedClientIp(event);
|
||||
const forwarded = event.request.headers.get("x-forwarded-for");
|
||||
const rateKey =
|
||||
fingerprint ||
|
||||
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
|
||||
const token = takeBugReportToken(rateKey);
|
||||
if (!token.allowed) {
|
||||
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Regression test for the /agents roster (NOOK-336) and its coverage wording
|
||||
* (FRE-359).
|
||||
* Regression test for the /agents roster and its coverage count.
|
||||
*
|
||||
* The page mirrors the app's registry, `AgentSpec.all` in
|
||||
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
||||
@@ -9,11 +8,11 @@
|
||||
* 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.
|
||||
*
|
||||
* FRE-359 verdict B: the page no longer states a count or claims coverage
|
||||
* ("all thirteen", "every coding agent The Nook supports"). The page still
|
||||
* lists 13 of the registry's 18 rows — the table is hand-mirrored and its
|
||||
* regeneration is its own ticket — so the roster check here is a subset
|
||||
* check in registry order, not an equality check.
|
||||
* 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.
|
||||
*/
|
||||
@@ -26,6 +25,9 @@ const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
||||
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||
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 */
|
||||
const REGISTRY = [
|
||||
"Claude Code",
|
||||
@@ -48,51 +50,41 @@ const REGISTRY = [
|
||||
"Amp"
|
||||
];
|
||||
|
||||
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen"];
|
||||
|
||||
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
||||
|
||||
/** The page's rows are a subset of the registry, in registry order. */
|
||||
function subsetInOrder(page: string[], registry: string[]): boolean {
|
||||
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);
|
||||
describe("Nook /agents roster", () => {
|
||||
it("lists every registry agent, once, in registry order", () => {
|
||||
expect(entries).toEqual(REGISTRY);
|
||||
});
|
||||
|
||||
it("lists each agent at most once", () => {
|
||||
expect(new Set(entries).size).toBe(entries.length);
|
||||
it("states the registry's size", () => {
|
||||
expect(entries.length).toBe(18);
|
||||
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 coverage wording (FRE-359)", () => {
|
||||
it("states no agent count anywhere on the page", () => {
|
||||
for (const word of NUMBER_WORDS) {
|
||||
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/);
|
||||
describe("Nook /agents remote coverage", () => {
|
||||
it("says agents can run on remote machines over SSH", () => {
|
||||
expect(PROSE).toContain("remote machines");
|
||||
expect(PROSE).toContain("remote sessions report into the same grid");
|
||||
});
|
||||
|
||||
it("makes no completeness claim over the agent set", () => {
|
||||
for (const banned of [
|
||||
"every coding agent",
|
||||
"the agents the nook supports",
|
||||
"agents the nook supports",
|
||||
"supported agents"
|
||||
]) {
|
||||
expect(COPY.toLowerCase()).not.toContain(banned);
|
||||
}
|
||||
it("names the three agents that stay on this Mac", () => {
|
||||
expect(PROSE).toContain("Muse, Cline and Amp run on this Mac only");
|
||||
});
|
||||
|
||||
it("discloses the local-only rows where they are listed", () => {
|
||||
const localOnly = [
|
||||
...SOURCE.matchAll(/This Mac only — remote hosts skip it/g)
|
||||
];
|
||||
expect(localOnly.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* `/agents` on the Nook subdomain — the agent roster and each row's event
|
||||
* coverage. It states no agent count and claims no completeness over the
|
||||
* registry (FRE-359 verdict B).
|
||||
* `/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
|
||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
|
||||
* installed hook surface (hook/setup.go) or plugin event subscription
|
||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentRegistry.swift) and each
|
||||
* agent's installed hook surface (hook/setup.go) or plugin event subscription
|
||||
* (plugins/*), normalized onto the shared event vocabulary
|
||||
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
||||
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
||||
@@ -210,7 +209,8 @@ const AGENTS: AgentEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "Cline",
|
||||
wiring: "One hook file per event in its hooks directory",
|
||||
wiring:
|
||||
"One hook file per event in its hooks directory. This Mac only — remote hosts skip it",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
@@ -261,6 +261,93 @@ const AGENTS: AgentEntry[] = [
|
||||
],
|
||||
gating:
|
||||
"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"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -284,25 +371,27 @@ export default function NookAgentsPage() {
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Agent coverage"
|
||||
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 coding agents The Nook connects to, and what each hook sees"
|
||||
title="Supported agents"
|
||||
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 18 agents The Nook supports, and what each hook sees"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Agent coverage
|
||||
Supported agents
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
||||
The Nook watches coding agents in one place — your menu bar and notch
|
||||
show sessions that are running, thinking, or blocked, and a card can
|
||||
answer a permission prompt without leaving your keyboard.
|
||||
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
|
||||
card answers permission prompts without leaving your keyboard.
|
||||
</p>
|
||||
<p class="text-subtext1 mb-10 max-w-2xl text-sm">
|
||||
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;
|
||||
"Observe" means every event still appears live, but the agent keeps
|
||||
its own prompting.
|
||||
its own prompting. Agents can run on this Mac or on remote machines
|
||||
over SSH — remote sessions report into the same grid. Muse, Cline and
|
||||
Amp run on this Mac only.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
/**
|
||||
* Regression test for the removed remote / SSH claims and the removed agent
|
||||
* count (FRE-323, FRE-359 verdict B).
|
||||
* Guard for the remote / SSH story on the Nook site, and for the two phrasings
|
||||
* that must not come back with it (FRE-323 → FRE-997).
|
||||
*
|
||||
* 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-323 removed the remote copy because it had been checked against claims
|
||||
* the copy never made: the app does list hosts from `~/.ssh/config` and
|
||||
* Tailscale, a one-time `ssh-copy-id` is a normal prerequisite the row explains,
|
||||
* and the per-host credential is minted and copied automatically
|
||||
* (`RemoteHostDiscovery`, `RemoteHostStatus.fixCommand`, `RemoteMirror`). Two
|
||||
* things in the old copy were genuinely wrong, and this file guards those
|
||||
* instead of the copy as a whole:
|
||||
*
|
||||
* 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.
|
||||
* - "Works across every hooked agent, local or remote" overstated it. Muse,
|
||||
* Cline and Amp are `.unsupported` for remote in `AgentSpec.remote`, so the
|
||||
* landing bullet and the /agents paragraph name them.
|
||||
* - The count said thirteen while `AgentSpec.all` holds eighteen. The fix was
|
||||
* 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
|
||||
* `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";
|
||||
@@ -33,7 +29,7 @@ import { join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "../../..");
|
||||
|
||||
/** Shipped page and component source. Tests and drafts may quote the old copy. */
|
||||
/** Shipped page and component source. */
|
||||
const AUDITED = [
|
||||
"src/routes/nook/index.tsx",
|
||||
"src/routes/nook/agents.tsx",
|
||||
@@ -43,25 +39,15 @@ const AUDITED = [
|
||||
"src/components/nook/IslandExtras.tsx"
|
||||
];
|
||||
|
||||
/** Every sentence FRE-323 ordered off the site. */
|
||||
/** The overstatements the restored copy must never carry again. */
|
||||
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 count, no coverage claim, in any form. */
|
||||
const BANNED_COVERAGE = [
|
||||
"all thirteen",
|
||||
"thirteen coding agents",
|
||||
// a universal claim: three registry agents cannot mirror over SSH
|
||||
"every hooked agent, local or remote",
|
||||
"Works across every hooked agent",
|
||||
// the stale count this task replaced
|
||||
"13 agents",
|
||||
"every coding agent",
|
||||
"Supported agents"
|
||||
"all thirteen",
|
||||
"thirteen coding agents"
|
||||
];
|
||||
|
||||
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||
@@ -71,9 +57,46 @@ function copyOf(file: string): string {
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
describe("remote claims stay off the site (FRE-323)", () => {
|
||||
describe("the restored remote copy is on the site (FRE-997)", () => {
|
||||
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) {
|
||||
it(`${file} does not restore a removed remote sentence`, () => {
|
||||
it(`${file} claims no universal remote coverage and no stale count`, () => {
|
||||
const copy = copyOf(file);
|
||||
for (const phrase of BANNED_REMOTE_CLAIMS) {
|
||||
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||
@@ -81,34 +104,9 @@ describe("remote claims stay off the site (FRE-323)", () => {
|
||||
});
|
||||
}
|
||||
|
||||
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"
|
||||
it("the landing names the agents that stay local", () => {
|
||||
expect(copyOf("src/components/nook/FeatureBreakdowns.tsx")).toContain(
|
||||
"Muse, Cline and Amp are this Mac only"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { getCookie, setCookie } from "vinxi/http";
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import { t } from "~/server/api/utils";
|
||||
import { logAuditEvent } from "~/server/audit";
|
||||
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||
import { env } from "~/env/server";
|
||||
import {
|
||||
AUTH_CONFIG,
|
||||
@@ -329,15 +328,39 @@ async function cleanupExpiredRateLimits(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address for a security decision (rate limiting, audit).
|
||||
*
|
||||
* Thin alias over `resolveTrustedClientIp` (./trusted-client-ip), which owns
|
||||
* the trust order: the previous leftmost `x-forwarded-for` read was
|
||||
* client-spoofable in our append-only proxy topology, and `x-real-ip` was
|
||||
* read unconditionally — both fixed there (FRE-371).
|
||||
* Get client IP address from request headers.
|
||||
* 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
|
||||
* exercised). In local development, uses the socket address to prevent header
|
||||
* spoofing.
|
||||
*/
|
||||
export function getClientIP(event: H3Event): string {
|
||||
return resolveTrustedClientIp(event);
|
||||
// In production on Vercel, X-Forwarded-For is set by the edge network
|
||||
// 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,100 +150,29 @@ describe("Rate Limiting", () => {
|
||||
});
|
||||
|
||||
describe("getClientIP", () => {
|
||||
// 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", () => {
|
||||
it("should extract IP from x-forwarded-for header", () => {
|
||||
const event = createMockEvent({
|
||||
headers: {
|
||||
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||
}
|
||||
headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" }
|
||||
});
|
||||
|
||||
const ip = getClientIP(event);
|
||||
expect(ip).toBe("192.168.1.1");
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
it("should extract IP from x-real-ip header", () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-real-ip": "192.168.1.2" }
|
||||
});
|
||||
|
||||
const ip = getClientIP(event);
|
||||
expect(ip).toBe("unknown");
|
||||
expect(ip).toBe("192.168.1.2");
|
||||
});
|
||||
|
||||
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.
|
||||
it("should prefer x-forwarded-for over x-real-ip", () => {
|
||||
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: {
|
||||
"cf-connecting-ip": "6.6.6.6",
|
||||
"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"
|
||||
"x-forwarded-for": "192.168.1.1",
|
||||
"x-real-ip": "192.168.1.2"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -269,23 +198,13 @@ describe("Rate Limiting", () => {
|
||||
it("should handle IPv6 addresses", () => {
|
||||
const event = createMockEvent({
|
||||
headers: {
|
||||
"x-forwarded-for":
|
||||
"2001:0db8:85a3:0000:0000:8a2e:0370:7334, 10.0.0.1"
|
||||
"x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
|
||||
}
|
||||
});
|
||||
|
||||
const ip = getClientIP(event);
|
||||
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", () => {
|
||||
|
||||
@@ -14,15 +14,12 @@ export function createMockEvent(options: {
|
||||
cookies?: Record<string, string>;
|
||||
method?: string;
|
||||
url?: string;
|
||||
/** Socket peer the server observed; omit for a peer-less synthetic event. */
|
||||
remoteAddress?: string;
|
||||
}): H3Event {
|
||||
const {
|
||||
headers = {},
|
||||
cookies = {},
|
||||
method = "POST",
|
||||
url = "http://localhost:3000/",
|
||||
remoteAddress
|
||||
url = "http://localhost:3000/"
|
||||
} = options;
|
||||
|
||||
// Build the cookie header string from the cookies object only
|
||||
@@ -52,10 +49,7 @@ export function createMockEvent(options: {
|
||||
headers: headersObj,
|
||||
node: {
|
||||
req: {
|
||||
headers: allHeaders,
|
||||
...(remoteAddress
|
||||
? { socket: { remoteAddress } }
|
||||
: {})
|
||||
headers: allHeaders
|
||||
},
|
||||
res: {
|
||||
cookies: {}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,250 +0,0 @@
|
||||
/**
|
||||
* 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]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user