Compare commits
6 Commits
faq/from-o
...
29842dc039
| Author | SHA1 | Date | |
|---|---|---|---|
| 29842dc039 | |||
|
|
d44133fcb3 | ||
|
|
578ee58f9c | ||
|
|
d2c2e6a4ee | ||
|
|
c718370674 | ||
|
|
210a7e6e75 |
@@ -1,6 +1,9 @@
|
|||||||
# Remote SSH copy — HELD, do not publish
|
# Remote SSH copy — HELD, do not publish
|
||||||
|
|
||||||
**Status:** removed from the live site on 2026-10-29 in the FRE-323 correction.
|
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
|
||||||
|
correction, and **not yet off the live site** — the deploy is blocked on a write
|
||||||
|
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
|
||||||
|
serves every sentence below until that push or a Vercel deploy lands.
|
||||||
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
||||||
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
||||||
verifies that flow, and a recording shows a remote agent joining the island.
|
verifies that flow, and a recording shows a remote agent joining the island.
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
|
|||||||
unverified and demo-gated. The CTO named it the largest launch risk because it
|
unverified and demo-gated. The CTO named it the largest launch risk because it
|
||||||
was already in front of buyers.
|
was already in front of buyers.
|
||||||
|
|
||||||
So the landing page now says only what the product can back today: local agents,
|
So the `freno-dev` source now says only what the product can back today: local
|
||||||
the card, and the notch. The remote story is not deleted — it is parked here with
|
agents, the card, and the notch. That correction is committed locally and is
|
||||||
the FRE-320/FRE-205 work so it can return the day the one-click install ships and
|
**not deployed** — the live page still carries the remote sentences until the
|
||||||
is recorded.
|
push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is
|
||||||
|
parked here with the FRE-320/FRE-205 work so it can return the day the one-click
|
||||||
|
install ships and is recorded.
|
||||||
|
|
||||||
## What has to be true before it returns
|
## What has to be true before it returns
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,37 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
|
|||||||
artifact invents its own framing, fix the artifact or fix this file — never both
|
artifact invents its own framing, fix the artifact or fix this file — never both
|
||||||
silently diverging.
|
silently diverging.
|
||||||
|
|
||||||
Primary source: `~/Code/the-nook/docs/marketing-strategy.md`.
|
**Reconciled 2026-10-29** 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.
|
||||||
|
|
||||||
|
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
|
||||||
|
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
|
||||||
|
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
|
||||||
|
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
|
||||||
|
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
|
||||||
|
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
|
||||||
|
|
||||||
## The sentence
|
## The sentence
|
||||||
|
|
||||||
> **Your Mac and your agents, under control.** The Nook puts your whole coding-agent
|
There is no single approved public sentence yet, and saying otherwise is the one
|
||||||
> fleet in the notch — thirteen agents — so you see what is running,
|
mistake this file exists to prevent. What exists is a **launch claim set** — two
|
||||||
> unblock it in one tap, and keep the machine from overheating while it works.
|
sentences, both from one 15-second take, recorded as the chosen route by the Chief
|
||||||
|
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
|
||||||
|
|
||||||
|
| Slot | Exact sentence | Status today |
|
||||||
|
|---|---|---|
|
||||||
|
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
|
||||||
|
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
|
||||||
|
|
||||||
|
Working line for internal writing, not for publication:
|
||||||
|
|
||||||
|
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
|
||||||
|
> see what is running, you unblock what is waiting, and the machine stays cool
|
||||||
|
> while it works.
|
||||||
|
|
||||||
## The wedge
|
## The wedge
|
||||||
|
|
||||||
@@ -22,15 +46,18 @@ The thing to say over and over, in every channel, in slightly different words:
|
|||||||
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
||||||
Allow, work resumes. Ten seconds, no explanation needed.
|
Allow, work resumes. Ten seconds, no explanation needed.
|
||||||
|
|
||||||
## The bundling reframe
|
## The consolidation reframe
|
||||||
|
|
||||||
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
||||||
$10 app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||||
|
|
||||||
> One $10 app instead of a notch toy, a fan utility, a battery limiter, a menu-bar
|
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
|
||||||
> stats strip, and a notification hack. Same one-time purchase, no subscription.
|
> strip, and a notification hack. One purchase, no subscription.
|
||||||
|
|
||||||
Price rises to $15 at 1.0 — dated and honest, not a permanent beta.
|
**No price, no discount and no future price in copy.** The price is $10, it stays
|
||||||
|
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
|
||||||
|
price change is a Chief of Staff + sponsor call; it is never a marketing decision
|
||||||
|
and never a launch asset.
|
||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
@@ -48,30 +75,49 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
|
|||||||
community replies written by a human, where quoting what a user said about another
|
community replies written by a human, where quoting what a user said about another
|
||||||
tool is normal conversation.
|
tool is normal conversation.
|
||||||
|
|
||||||
### 2. A claim requires a test to exist
|
### 2. A claim needs a verdict, not just a test
|
||||||
|
|
||||||
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
|
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
|
||||||
If a claim cannot be pointed at code or a test, it does not go in copy.
|
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
|
||||||
|
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
|
||||||
|
tables below, it does not go in copy.
|
||||||
|
|
||||||
### 3. Honest privacy scoping
|
### 3. Honest privacy scoping
|
||||||
|
|
||||||
Telemetry and the trial email **do** leave the device. The defensible claim is:
|
Telemetry and the licence check **do** leave the device: Sentry runs on **every
|
||||||
|
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
|
||||||
|
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
|
||||||
|
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
|
||||||
|
|
||||||
> **No prompt or code content leaves the device.**
|
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
|
||||||
|
- "nothing leaves your Mac"
|
||||||
|
|
||||||
Never "no data leaves the device".
|
The one favourable engine sentence — about the **hook and bridge only**, never
|
||||||
|
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
|
||||||
|
service**. Do not write "talk to nothing but your Mac" (too absolute: the
|
||||||
|
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
|
||||||
|
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
|
||||||
|
it to "over SSH" (a tailnet peer dials directly).
|
||||||
|
|
||||||
|
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
|
||||||
|
**held** until the disclosures land: Sentry gated behind a preference or removed,
|
||||||
|
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
|
||||||
|
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
|
||||||
|
false ones is worse than no sentence.
|
||||||
|
|
||||||
### 4. Sell what works today
|
### 4. Sell what works today
|
||||||
|
|
||||||
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)"
|
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
|
||||||
parentheticals. The launch story is the approval card: an agent blocked on a
|
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
|
||||||
permission prompt, unblocked from the Mac's notch, and work resumes.
|
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
|
||||||
|
all, and never claim approvals-from-phone as a shipped capability without its own
|
||||||
|
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||||
|
|
||||||
Remote SSH is **held**, not approved. The one-click install has not shipped and
|
Remote SSH is **held**, not approved: the one-click install has not
|
||||||
has not been recorded (FRE-320, FRE-205), and the current path needs hand-edited
|
been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
|
||||||
`~/.ssh/config` and a one-time `ssh-copy-id` (FRE-320). Its copy is parked in
|
`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
|
||||||
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow and a
|
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
|
||||||
demo exists.
|
recording shows a remote agent joining the notch.
|
||||||
|
|
||||||
### 5. Humans hold every account
|
### 5. Humans hold every account
|
||||||
|
|
||||||
@@ -79,28 +125,29 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
|
|||||||
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
||||||
nothing posts itself.
|
nothing posts itself.
|
||||||
|
|
||||||
## Approved claims (each backed by code)
|
## Claims: status at 2026-10-29
|
||||||
|
|
||||||
| Claim | Backing |
|
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||||
|---|---|
|
|
||||||
| Thirteen hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, muse, hermes, openclaw, zai, gemini, cline, qwen, deepseek |
|
|
||||||
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
|
|
||||||
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
|
|
||||||
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
|
|
||||||
| macOS 14+ | 27 source files use the `@Observable` framework |
|
|
||||||
| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
|
|
||||||
|
|
||||||
## Held claims (not publishable)
|
| Claim | Status | Evidence / next step |
|
||||||
|
|
||||||
| Claim | Why it is held | What has to be true first |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Remote agents over SSH, one click to install | The one-click path is app-UI-only and needs hand-edited `~/.ssh/config` plus a one-time `ssh-copy-id`; no demo exists (FRE-320). FRE-315 and FRE-322 hold it unverified and demo-gated. | FRE-320/FRE-205 ship a genuinely one-click install, the CTO verifies it, and a recording shows a remote agent joining the island. Copy parked in `marketing/drafts/remote-ssh/`. |
|
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
|
||||||
|
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
|
||||||
|
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
|
||||||
|
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
|
||||||
|
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
|
||||||
|
| Agent coverage numbers ("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. |
|
||||||
|
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
|
||||||
|
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
|
||||||
|
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
|
||||||
|
|
||||||
## Voice
|
## Voice
|
||||||
|
|
||||||
- Short, concrete, no hype adjectives. Numbers over adjectives ("thirteen agents",
|
- Short, concrete, no hype adjectives. Numbers over adjectives — but no coverage
|
||||||
"one tap", "$10 once").
|
counts (see the excluded row above).
|
||||||
- The notch/island 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",
|
||||||
"game-changer", "seamlessly". Unverifiable superlatives generally.
|
"game-changer", "seamlessly", any unverifiable superlative — and the specific
|
||||||
|
banned sentences in rule 3 and the table above.
|
||||||
|
|||||||
@@ -851,7 +851,7 @@ export default function FeatureBreakdowns() {
|
|||||||
title="Your whole fleet, in one pixel strip"
|
title="Your whole fleet, in one pixel strip"
|
||||||
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
||||||
bullets={[
|
bullets={[
|
||||||
"13 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
"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"
|
||||||
|
|||||||
@@ -722,7 +722,7 @@ function LayoutMock() {
|
|||||||
/* ── Marquee ─────────────────────────────────────────────────────── */
|
/* ── Marquee ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const CHIPS_A = [
|
const CHIPS_A = [
|
||||||
"13 agents",
|
"Agents wired in",
|
||||||
"Custom agents",
|
"Custom agents",
|
||||||
"Six chiptunes",
|
"Six chiptunes",
|
||||||
"Focus-aware slots",
|
"Focus-aware slots",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { describe, expect, it, mock, beforeEach } from "bun:test";
|
|||||||
|
|
||||||
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
||||||
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
||||||
|
/** Every rate-limit key the route derived, in order. */
|
||||||
|
const rateKeys: string[] = [];
|
||||||
|
|
||||||
mock.module("~/server/email", () => ({
|
mock.module("~/server/email", () => ({
|
||||||
default: async (to: string, subject: string, html: string) => {
|
default: async (to: string, subject: string, html: string) => {
|
||||||
@@ -19,7 +21,10 @@ mock.module("~/server/email", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("~/server/bug-report-rate-limit", () => ({
|
mock.module("~/server/bug-report-rate-limit", () => ({
|
||||||
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true }
|
takeBugReportToken: (key: string) => {
|
||||||
|
rateKeys.push(key);
|
||||||
|
return tokenAnswers.shift() ?? { allowed: true };
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { POST } = await import("./report");
|
const { POST } = await import("./report");
|
||||||
@@ -72,6 +77,7 @@ describe("POST /api/the-nook/report", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sentEmails.length = 0;
|
sentEmails.length = 0;
|
||||||
tokenAnswers.length = 0;
|
tokenAnswers.length = 0;
|
||||||
|
rateKeys.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emails the owner a report containing the snapshot, escaped", async () => {
|
it("emails the owner a report containing the snapshot, escaped", async () => {
|
||||||
@@ -133,10 +139,49 @@ describe("POST /api/the-nook/report", () => {
|
|||||||
|
|
||||||
it("still succeeds when no fingerprint header exists", async () => {
|
it("still succeeds when no fingerprint header exists", async () => {
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" })
|
request(validPayload, {
|
||||||
|
"cf-connecting-ip": "203.0.113.9",
|
||||||
|
"x-forwarded-for": "203.0.113.9, 10.0.0.1"
|
||||||
|
})
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(sentEmails).toHaveLength(1);
|
expect(sentEmails).toHaveLength(1);
|
||||||
|
// The quota key is the Cloudflare-recorded client, not a header hop the
|
||||||
|
// sender chose.
|
||||||
|
expect(rateKeys).toEqual(["203.0.113.9"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rotating leftmost x-forwarded-for does not mint a fresh quota key", async () => {
|
||||||
|
// FRE-371: the old read keyed on the leftmost hop, which the sender
|
||||||
|
// controls outright — one scripted loop with a rotating header had
|
||||||
|
// unlimited reports. With the same Cloudflare-recorded client and a
|
||||||
|
// different spoofed prefix per request, the key must be stable.
|
||||||
|
for (const spoof of ["1.1.1.1", "2.2.2.2", "3.3.3.3"]) {
|
||||||
|
const res = await POST(
|
||||||
|
request(validPayload, {
|
||||||
|
"cf-connecting-ip": "203.0.113.9",
|
||||||
|
"x-forwarded-for": `${spoof}, 203.0.113.9, 10.0.0.1`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rateKeys).toEqual([
|
||||||
|
"203.0.113.9",
|
||||||
|
"203.0.113.9",
|
||||||
|
"203.0.113.9"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys on the rightmost-minus-one hop when Cloudflare's header is absent", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
request(validPayload, {
|
||||||
|
"x-forwarded-for": "6.6.6.6, 203.0.113.9, 10.0.0.1"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Never the leftmost (sender-chosen) hop.
|
||||||
|
expect(rateKeys).toEqual(["203.0.113.9"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delivers the landing page's beta request with only the email", async () => {
|
it("delivers the landing page's beta request with only the email", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import sendEmail from "~/server/email";
|
import sendEmail from "~/server/email";
|
||||||
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
||||||
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
||||||
|
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||||
import { json, error } from "./_lib";
|
import { json, error } from "./_lib";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,10 +104,9 @@ export async function POST(event: APIEvent): Promise<Response> {
|
|||||||
)
|
)
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const forwarded = event.request.headers.get("x-forwarded-for");
|
// FRE-371: the IP fallback must be the spoof-resistant read — a rotating
|
||||||
const rateKey =
|
// leftmost x-forwarded-for used to mint unlimited report quota.
|
||||||
fingerprint ||
|
const rateKey = fingerprint || resolveTrustedClientIp(event);
|
||||||
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
|
|
||||||
const token = takeBugReportToken(rateKey);
|
const token = takeBugReportToken(rateKey);
|
||||||
if (!token.allowed) {
|
if (!token.allowed) {
|
||||||
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Regression test for the /agents roster (NOOK-336).
|
* 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,6 +9,12 @@
|
|||||||
* them. The registry lives in another repo, so the expected roster is
|
* them. The registry lives in another repo, so the expected roster is
|
||||||
* restated here; when an agent is added there, add it here and on the page.
|
* restated here; when an agent is added there, add it here and on the page.
|
||||||
*
|
*
|
||||||
|
* FRE-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.
|
||||||
|
*
|
||||||
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
@@ -16,7 +23,10 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
||||||
|
|
||||||
/** AgentSpec.all display names, in registry order. */
|
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||||
|
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
|
||||||
|
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
|
||||||
const REGISTRY = [
|
const REGISTRY = [
|
||||||
"Claude Code",
|
"Claude Code",
|
||||||
"Codex",
|
"Codex",
|
||||||
@@ -30,25 +40,59 @@ const REGISTRY = [
|
|||||||
"Gemini CLI",
|
"Gemini CLI",
|
||||||
"Cline",
|
"Cline",
|
||||||
"Qwen Code",
|
"Qwen Code",
|
||||||
"DeepSeek"
|
"DeepSeek",
|
||||||
|
"Kimi",
|
||||||
|
"GitHub Copilot CLI",
|
||||||
|
"Crush",
|
||||||
|
"Cursor",
|
||||||
|
"Amp"
|
||||||
];
|
];
|
||||||
|
|
||||||
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen"];
|
const 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]);
|
||||||
|
|
||||||
|
/** 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)", () => {
|
describe("Nook /agents roster (NOOK-336)", () => {
|
||||||
it("lists every registry agent, in registry order", () => {
|
it("lists only registry agents, in registry order", () => {
|
||||||
expect(entries).toEqual(REGISTRY);
|
expect(entries.length).toBeGreaterThan(0);
|
||||||
|
expect(subsetInOrder(entries, REGISTRY)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("states the same count everywhere it names one", () => {
|
it("lists each agent at most once", () => {
|
||||||
const word = NUMBER_WORDS[entries.length - 12];
|
expect(new Set(entries).size).toBe(entries.length);
|
||||||
for (const other of NUMBER_WORDS.filter((w) => w !== word)) {
|
});
|
||||||
expect(SOURCE.toLowerCase()).not.toContain(other);
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
expect(SOURCE).toContain(`all ${word}`);
|
|
||||||
expect(SOURCE).toContain(`The ${entries.length} agents`);
|
|
||||||
expect(SOURCE).toContain(`watches ${word} coding agents`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* `/agents` on the Nook subdomain — supported agents and their event coverage.
|
* `/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).
|
||||||
*
|
*
|
||||||
* The registry below mirrors `AgentSpec.all` in the app
|
* The registry below mirrors `AgentSpec.all` in the app
|
||||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
|
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
|
||||||
@@ -282,26 +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 thirteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
description="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 13 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 thirteen 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.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
|||||||
114
src/routes/nook/remote-claims.test.ts
Normal file
114
src/routes/nook/remote-claims.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Regression test for the removed remote / SSH claims and the removed agent
|
||||||
|
* count (FRE-323, FRE-359 verdict B).
|
||||||
|
*
|
||||||
|
* FRE-323 took the remote story off the site because it was unverified and
|
||||||
|
* demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
|
||||||
|
* installs the nook-hook" flow is app-UI-only, needs a hand-edited
|
||||||
|
* `~/.ssh/config` for any host not already in ssh config or on the tailnet,
|
||||||
|
* needs a one-time `ssh-copy-id` because the app runs ssh with
|
||||||
|
* `BatchMode=yes`, and has no manual fallback for minting the per-host
|
||||||
|
* credential. FRE-359 verdict B removed the coverage count for the same
|
||||||
|
* reason: the /agents table lists 13 of the registry's 18 rows.
|
||||||
|
*
|
||||||
|
* The copy was not thrown away — it is parked in
|
||||||
|
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
|
||||||
|
* it is still there so nobody deletes the parked story as stale. It goes back
|
||||||
|
* on the site only when the one-click install ships and is recorded.
|
||||||
|
*
|
||||||
|
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
|
||||||
|
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
|
||||||
|
*
|
||||||
|
* Deliberate exceptions, not audited here — both were weighed with the Chief
|
||||||
|
* of Staff at the go/no-go and left in place on purpose:
|
||||||
|
* - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip
|
||||||
|
* it" is a limitation disclosure, not a capability claim.
|
||||||
|
* - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to
|
||||||
|
* the removed remote story, but tailnet host lookup does exist. Raised on
|
||||||
|
* FRE-323 rather than widened into this correction.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "../../..");
|
||||||
|
|
||||||
|
/** Shipped page and component source. Tests and drafts may quote the old copy. */
|
||||||
|
const AUDITED = [
|
||||||
|
"src/routes/nook/index.tsx",
|
||||||
|
"src/routes/nook/agents.tsx",
|
||||||
|
"src/routes/nook/faq.tsx",
|
||||||
|
"src/components/nook/FeatureCollage.tsx",
|
||||||
|
"src/components/nook/FeatureBreakdowns.tsx",
|
||||||
|
"src/components/nook/IslandExtras.tsx"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Every sentence FRE-323 ordered off the site. */
|
||||||
|
const BANNED_REMOTE_CLAIMS = [
|
||||||
|
"Agents on other machines",
|
||||||
|
"one click installs the nook-hook",
|
||||||
|
"Any SSH box",
|
||||||
|
"Works across every hooked agent, local or remote",
|
||||||
|
"Remote sessions are ordinary rows",
|
||||||
|
"One-click SSH setup",
|
||||||
|
"remote machines over SSH",
|
||||||
|
"this Mac or remote machines"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** FRE-359 verdict B: no count, no coverage claim, in any form. */
|
||||||
|
const BANNED_COVERAGE = [
|
||||||
|
"all thirteen",
|
||||||
|
"thirteen coding agents",
|
||||||
|
"13 agents",
|
||||||
|
"every coding agent",
|
||||||
|
"Supported agents"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||||
|
function copyOf(file: string): string {
|
||||||
|
return readFileSync(join(ROOT, file), "utf8")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("remote claims stay off the site (FRE-323)", () => {
|
||||||
|
for (const file of AUDITED) {
|
||||||
|
it(`${file} does not restore a removed remote sentence`, () => {
|
||||||
|
const copy = copyOf(file);
|
||||||
|
for (const phrase of BANNED_REMOTE_CLAIMS) {
|
||||||
|
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("/agents no longer offers remote sessions", () => {
|
||||||
|
const copy = copyOf("src/routes/nook/agents.tsx");
|
||||||
|
expect(copy).not.toContain("remote sessions report into the same grid");
|
||||||
|
expect(copy).not.toContain("on this Mac or on remote");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => {
|
||||||
|
const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8");
|
||||||
|
for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) {
|
||||||
|
expect(parked).toContain(phrase);
|
||||||
|
}
|
||||||
|
expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain(
|
||||||
|
"FRE-320"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { getCookie, setCookie } from "vinxi/http";
|
|||||||
import type { H3Event } from "vinxi/http";
|
import type { H3Event } from "vinxi/http";
|
||||||
import { t } from "~/server/api/utils";
|
import { t } from "~/server/api/utils";
|
||||||
import { logAuditEvent } from "~/server/audit";
|
import { logAuditEvent } from "~/server/audit";
|
||||||
|
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import {
|
import {
|
||||||
AUTH_CONFIG,
|
AUTH_CONFIG,
|
||||||
@@ -328,39 +329,15 @@ async function cleanupExpiredRateLimits(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get client IP address from request headers.
|
* Get client IP address for a security decision (rate limiting, audit).
|
||||||
* Only trusts X-Forwarded-For outside of local development (set by the Vercel
|
*
|
||||||
* edge network in production; trusted in tests so the header-parsing path is
|
* Thin alias over `resolveTrustedClientIp` (./trusted-client-ip), which owns
|
||||||
* exercised). In local development, uses the socket address to prevent header
|
* the trust order: the previous leftmost `x-forwarded-for` read was
|
||||||
* spoofing.
|
* client-spoofable in our append-only proxy topology, and `x-real-ip` was
|
||||||
|
* read unconditionally — both fixed there (FRE-371).
|
||||||
*/
|
*/
|
||||||
export function getClientIP(event: H3Event): string {
|
export function getClientIP(event: H3Event): string {
|
||||||
// In production on Vercel, X-Forwarded-For is set by the edge network
|
return resolveTrustedClientIp(event);
|
||||||
// and cannot be spoofed by clients. In dev, ignore it.
|
|
||||||
if (env.NODE_ENV !== "development") {
|
|
||||||
const forwarded = getHeaderValue(event, "x-forwarded-for");
|
|
||||||
if (forwarded) {
|
|
||||||
return forwarded.split(",")[0].trim();
|
|
||||||
}
|
|
||||||
const realIP = getHeaderValue(event, "x-real-ip");
|
|
||||||
if (realIP) {
|
|
||||||
return realIP;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try socket remote address
|
|
||||||
try {
|
|
||||||
const nodeReq = event.node.req;
|
|
||||||
if (nodeReq?.socket?.remoteAddress) {
|
|
||||||
const addr = nodeReq.socket.remoteAddress;
|
|
||||||
// Clean up IPv6-mapped IPv4 addresses
|
|
||||||
return addr.replace(/^::ffff:/, "");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// socket access failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -150,29 +150,100 @@ describe("Rate Limiting", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getClientIP", () => {
|
describe("getClientIP", () => {
|
||||||
it("should extract IP from x-forwarded-for header", () => {
|
// FRE-371: the read is spoof-resistant now. In the pinned topology
|
||||||
|
// (client → Cloudflare → origin proxy → app) both proxies APPEND, so
|
||||||
|
// x-forwarded-for arrives as `<spoof…>, <true client>, <CF edge>` and the
|
||||||
|
// leftmost hop is whatever the sender typed. Trust order:
|
||||||
|
// cf-connecting-ip, then x-forwarded-for rightmost-minus-one, then the
|
||||||
|
// socket peer — never the leftmost hop, never x-real-ip.
|
||||||
|
|
||||||
|
it("takes x-forwarded-for rightmost-minus-one, never the leftmost hop", () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" }
|
headers: {
|
||||||
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = getClientIP(event);
|
const ip = getClientIP(event);
|
||||||
expect(ip).toBe("192.168.1.1");
|
expect(ip).toBe("192.168.1.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract IP from x-real-ip header", () => {
|
it("prefers cf-connecting-ip (Cloudflare overwrites it at the edge)", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rotating leftmost x-forwarded-for does not change the resolved client", () => {
|
||||||
|
// The spoof the old read fell for: same Cloudflare-recorded client,
|
||||||
|
// different sender-chosen prefix per request. The key must be stable,
|
||||||
|
// or each rotation mints a fresh rate-limit bucket.
|
||||||
|
const first = getClientIP(
|
||||||
|
createMockEvent({
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "1.1.1.1, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const second = getClientIP(
|
||||||
|
createMockEvent({
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "2.2.2.2, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first).toBe("203.0.113.7");
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores x-real-ip entirely (client-settable in this topology)", () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: { "x-real-ip": "192.168.1.2" }
|
headers: { "x-real-ip": "192.168.1.2" }
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = getClientIP(event);
|
const ip = getClientIP(event);
|
||||||
expect(ip).toBe("192.168.1.2");
|
expect(ip).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prefer x-forwarded-for over x-real-ip", () => {
|
it("ignores a client-supplied single-hop x-forwarded-for with no trusted proxy above it", () => {
|
||||||
|
// One hop only = the sender's own invention; discounting the trusted
|
||||||
|
// hop leaves nothing to believe.
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
|
headers: { "x-forwarded-for": "6.6.6.6" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-loopback socket peer is the client and beats every header", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
remoteAddress: "198.51.100.23",
|
||||||
headers: {
|
headers: {
|
||||||
"x-forwarded-for": "192.168.1.1",
|
"cf-connecting-ip": "6.6.6.6",
|
||||||
"x-real-ip": "192.168.1.2"
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1",
|
||||||
|
"x-real-ip": "6.6.6.6"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("198.51.100.23");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a loopback socket peer lets the forwarded headers speak", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: {
|
||||||
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,13 +269,23 @@ describe("Rate Limiting", () => {
|
|||||||
it("should handle IPv6 addresses", () => {
|
it("should handle IPv6 addresses", () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: {
|
headers: {
|
||||||
"x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
|
"x-forwarded-for":
|
||||||
|
"2001:0db8:85a3:0000:0000:8a2e:0370:7334, 10.0.0.1"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = getClientIP(event);
|
const ip = getClientIP(event);
|
||||||
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a candidate hop that is not an address", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
headers: { "x-forwarded-for": "not-an-ip, 10.0.0.1" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("unknown");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("rateLimitLogin", () => {
|
describe("rateLimitLogin", () => {
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ export function createMockEvent(options: {
|
|||||||
cookies?: Record<string, string>;
|
cookies?: Record<string, string>;
|
||||||
method?: string;
|
method?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/** Socket peer the server observed; omit for a peer-less synthetic event. */
|
||||||
|
remoteAddress?: string;
|
||||||
}): H3Event {
|
}): H3Event {
|
||||||
const {
|
const {
|
||||||
headers = {},
|
headers = {},
|
||||||
cookies = {},
|
cookies = {},
|
||||||
method = "POST",
|
method = "POST",
|
||||||
url = "http://localhost:3000/"
|
url = "http://localhost:3000/",
|
||||||
|
remoteAddress
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Build the cookie header string from the cookies object only
|
// Build the cookie header string from the cookies object only
|
||||||
@@ -49,7 +52,10 @@ export function createMockEvent(options: {
|
|||||||
headers: headersObj,
|
headers: headersObj,
|
||||||
node: {
|
node: {
|
||||||
req: {
|
req: {
|
||||||
headers: allHeaders
|
headers: allHeaders,
|
||||||
|
...(remoteAddress
|
||||||
|
? { socket: { remoteAddress } }
|
||||||
|
: {})
|
||||||
},
|
},
|
||||||
res: {
|
res: {
|
||||||
cookies: {}
|
cookies: {}
|
||||||
|
|||||||
171
src/server/trusted-client-ip.test.ts
Normal file
171
src/server/trusted-client-ip.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveTrustedClientIp,
|
||||||
|
isIpAddress,
|
||||||
|
isLoopbackAddress,
|
||||||
|
UNKNOWN_CLIENT_IP,
|
||||||
|
type ClientIpEventLike
|
||||||
|
} from "./trusted-client-ip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit coverage for the security-path client-IP read (FRE-371). The caller
|
||||||
|
* suites (rate-limit.test.ts, report.test.ts) pin the behaviour through
|
||||||
|
* getClientIP and the route; this file pins the helper's own edges: the peer
|
||||||
|
* forms a real socket can present, the APIEvent wrapper, and failing closed.
|
||||||
|
*/
|
||||||
|
function event(opts: {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
remoteAddress?: string;
|
||||||
|
wrapped?: boolean;
|
||||||
|
}): ClientIpEventLike {
|
||||||
|
const inner: ClientIpEventLike = {
|
||||||
|
...(opts.remoteAddress
|
||||||
|
? { node: { req: { socket: { remoteAddress: opts.remoteAddress } } } }
|
||||||
|
: {}),
|
||||||
|
request: { headers: new Headers(opts.headers ?? {}) }
|
||||||
|
};
|
||||||
|
// An APIEvent wraps its H3Event as `nativeEvent` (routes/api/* shape).
|
||||||
|
return opts.wrapped ? { nativeEvent: inner } : inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveTrustedClientIp", () => {
|
||||||
|
it("trust order: non-loopback peer, then cf-connecting-ip, then rightmost-minus-one x-forwarded-for", () => {
|
||||||
|
// Peer wins.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "198.51.100.23",
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("198.51.100.23");
|
||||||
|
|
||||||
|
// Loopback peer → Cloudflare's record.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
|
||||||
|
// No CF header → the hop below the trusted proxy.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the wrapped H3Event of an APIEvent-shaped input", () => {
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
wrapped: true,
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a truncated address", () => {
|
||||||
|
// The full /128 is the bucket key; a truncated one would pool attackers
|
||||||
|
// with their neighbours.
|
||||||
|
const ip = resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: { "cf-connecting-ip": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed to the stricter unknown key", () => {
|
||||||
|
// One hop = the sender's own invention; a non-address hop; nothing at all.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({ headers: { "x-forwarded-for": "6.6.6.6" } })
|
||||||
|
)
|
||||||
|
).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({ headers: { "cf-connecting-ip": "not-an-ip" } })
|
||||||
|
)
|
||||||
|
).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
expect(resolveTrustedClientIp(event({}))).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises the loopback forms a real peer presents", () => {
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
// IPv6-mapped IPv4 loopback from a node socket.
|
||||||
|
remoteAddress: "::ffff:127.0.0.1",
|
||||||
|
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "::1",
|
||||||
|
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
// A non-loopback peer that merely looks odd is still the client.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "::ffff:203.0.113.7",
|
||||||
|
headers: { "cf-connecting-ip": "6.6.6.6" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never believes x-real-ip", () => {
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({ headers: { "x-real-ip": "203.0.113.7" } })
|
||||||
|
)
|
||||||
|
).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("address helpers", () => {
|
||||||
|
it("isIpAddress", () => {
|
||||||
|
expect(isIpAddress("203.0.113.7")).toBe(true);
|
||||||
|
expect(isIpAddress("::1")).toBe(true);
|
||||||
|
expect(isIpAddress("::ffff:203.0.113.7")).toBe(true);
|
||||||
|
expect(isIpAddress("2001:db8::1")).toBe(true);
|
||||||
|
expect(isIpAddress("not-an-ip")).toBe(false);
|
||||||
|
expect(isIpAddress("256.1.1.1")).toBe(false);
|
||||||
|
expect(isIpAddress("")).toBe(false);
|
||||||
|
expect(isIpAddress(null)).toBe(false);
|
||||||
|
expect(isIpAddress(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isLoopbackAddress", () => {
|
||||||
|
expect(isLoopbackAddress("127.0.0.1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("127.255.0.3")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("::1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("203.0.113.7")).toBe(false);
|
||||||
|
expect(isLoopbackAddress("::2")).toBe(false);
|
||||||
|
// Unparseable input is not loopback — fails closed.
|
||||||
|
expect(isLoopbackAddress("garbage")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
250
src/server/trusted-client-ip.ts
Normal file
250
src/server/trusted-client-ip.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* The client address, resolved for security decisions (FRE-371).
|
||||||
|
*
|
||||||
|
* This is the ONLY client-IP read allowed to feed a security decision — rate
|
||||||
|
* limiting, abuse quotas, account lockout, audit trails. It exists separately
|
||||||
|
* from `request-client.ts`'s `resolveClientIp` (the analytics read) on
|
||||||
|
* purpose:
|
||||||
|
*
|
||||||
|
* - `resolveClientIp` carries a standing rule (FRE-369 review, 1d9287a):
|
||||||
|
* it never feeds a security decision. Its blast radius is a poisoned
|
||||||
|
* analytics row; ours is granting or denying access.
|
||||||
|
* - Rate limiting needs the UN-truncated address; the analytics module only
|
||||||
|
* ever hands out truncated prefixes.
|
||||||
|
* - `request-client.ts` ships on FRE-366's branch, which is under review;
|
||||||
|
* a security fix must not merge-depend on an analytics diff.
|
||||||
|
*
|
||||||
|
* The address-parsing primitives below are therefore a deliberate port of the
|
||||||
|
* reviewed ones, not an import. If the two ever drift, this file's version is
|
||||||
|
* the one security paths follow.
|
||||||
|
*
|
||||||
|
* WHY the old read was spoofable: `security.ts`'s `getClientIP` took the
|
||||||
|
* LEFTMOST `x-forwarded-for` hop, believing the header "cannot be spoofed".
|
||||||
|
* In the pinned topology — client → Cloudflare → same-host origin proxy →
|
||||||
|
* app — both proxies *append*, so the header arrives as
|
||||||
|
* `<client-supplied spoof…>, <true client>, <Cloudflare edge>` and the
|
||||||
|
* leftmost hop is exactly the one the sender chose. Rotating
|
||||||
|
* `X-Forwarded-For: <random>` per request minted a fresh rate-limit bucket
|
||||||
|
* and unlimited bug-report quota. `x-real-ip` was read unconditionally too;
|
||||||
|
* nothing in this topology overwrites it, so it is equally client text and is
|
||||||
|
* no longer read at all.
|
||||||
|
*
|
||||||
|
* Trust order (matches the convention FRE-366 landed after its security
|
||||||
|
* review, re-derived here for security stakes):
|
||||||
|
*
|
||||||
|
* 1. The direct socket peer, when it is a real routable address: it IS the
|
||||||
|
* client (dev, direct hit), and any forwarded header from it is the
|
||||||
|
* client describing itself — ignored.
|
||||||
|
* 2. `cf-connecting-ip` — reachable only when the peer is the loopback
|
||||||
|
* origin proxy (or unreadable, e.g. a synthetic event in tests).
|
||||||
|
* Cloudflare *overwrites* this header at the edge, so a client-supplied
|
||||||
|
* value never survives. Must parse as an address.
|
||||||
|
* 3. `x-forwarded-for` counted from the RIGHT: drop the rightmost hop (the
|
||||||
|
* Cloudflare edge address the origin proxy appended) and take the hop
|
||||||
|
* before it. Never the leftmost. Must parse as an address.
|
||||||
|
* 4. "unknown" — callers give unknown-IP keys stricter shared limits, so
|
||||||
|
* an unresolvable client fails toward a tighter bucket, never a looser
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* Deploy-side precondition this code cannot verify (same as the analytics
|
||||||
|
* read): the origin must be reachable only through Cloudflare and the
|
||||||
|
* same-host proxy. If the app is directly internet-reachable, a client can
|
||||||
|
* set every header read here and no in-app read can recover the truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Cloudflare's "who actually connected" header: overwritten at the edge. */
|
||||||
|
const CF_CONNECTING_IP = "cf-connecting-ip";
|
||||||
|
const X_FORWARDED_FOR = "x-forwarded-for";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hops discounted from the right of `x-forwarded-for` before the value is
|
||||||
|
* taken: the Cloudflare edge address the origin proxy appended. Pinned to 1
|
||||||
|
* because there is exactly one trusted proxy between Cloudflare and the app;
|
||||||
|
* raise it only if the topology gains a trusted hop.
|
||||||
|
*/
|
||||||
|
const FORWARDED_HOPS_TO_DISCOUNT = 1;
|
||||||
|
|
||||||
|
/** Returned when no trustworthy address exists. Callers rate-limit it harder. */
|
||||||
|
export const UNKNOWN_CLIENT_IP = "unknown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shapes this module reads: an H3Event (`security.ts` callers) or a
|
||||||
|
* SolidStart APIEvent (`routes/api/*`), which wraps its H3Event as
|
||||||
|
* `nativeEvent`. Every field is optional because test doubles are minimal;
|
||||||
|
* anything unreadable simply falls through to the next trust step.
|
||||||
|
*/
|
||||||
|
export interface ClientIpEventLike {
|
||||||
|
request?: { headers?: unknown };
|
||||||
|
headers?: unknown;
|
||||||
|
node?: {
|
||||||
|
req?: {
|
||||||
|
headers?: Record<string, unknown>;
|
||||||
|
socket?: { remoteAddress?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
nativeEvent?: ClientIpEventLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client address for a security decision, per the trust order above.
|
||||||
|
* Never truncated: rate-limit buckets are per-address, and truncating would
|
||||||
|
* let an attacker share a bucket with the innocents behind their /24.
|
||||||
|
*/
|
||||||
|
export function resolveTrustedClientIp(event: ClientIpEventLike): string {
|
||||||
|
// An APIEvent's socket and (authoritative) headers live on the wrapped
|
||||||
|
// H3Event; a bare APIEvent-shaped double without one is read as-is.
|
||||||
|
const source = event.nativeEvent ?? event;
|
||||||
|
|
||||||
|
const peer = directPeer(source);
|
||||||
|
if (peer && isIpAddress(peer) && !isLoopbackAddress(peer)) return peer;
|
||||||
|
|
||||||
|
// The peer is the loopback origin proxy (or unreadable): forwarded headers
|
||||||
|
// may be believed, in the order above.
|
||||||
|
const connecting = cleanHop(headerValue(source, CF_CONNECTING_IP));
|
||||||
|
if (isIpAddress(connecting)) return connecting;
|
||||||
|
|
||||||
|
const hops = (headerValue(source, X_FORWARDED_FOR) ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map(cleanHop)
|
||||||
|
.filter(Boolean);
|
||||||
|
const index = hops.length - 1 - FORWARDED_HOPS_TO_DISCOUNT;
|
||||||
|
if (index >= 0 && isIpAddress(hops[index])) return hops[index]!;
|
||||||
|
|
||||||
|
return UNKNOWN_CLIENT_IP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The socket peer, IPv6-mapped IPv4 flattened (`::ffff:1.2.3.4` → `1.2.3.4`). */
|
||||||
|
function directPeer(event: ClientIpEventLike): string | null {
|
||||||
|
try {
|
||||||
|
const addr = event.node?.req?.socket?.remoteAddress;
|
||||||
|
return addr ? addr.replace(/^::ffff:/, "") : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header read tolerant of every event shape in play: Fetch-style
|
||||||
|
* `request.headers.get`, an h3 `event.headers` (Headers or plain object), and
|
||||||
|
* the raw node request headers. Header names are case-insensitive per HTTP;
|
||||||
|
* the plain-object fallbacks lowercase to match how node and the test doubles
|
||||||
|
* store them.
|
||||||
|
*/
|
||||||
|
function headerValue(event: ClientIpEventLike, name: string): string | null {
|
||||||
|
try {
|
||||||
|
const fromHeaders = (h: unknown): string | null => {
|
||||||
|
if (!h) return null;
|
||||||
|
if (typeof (h as Headers).get === "function") {
|
||||||
|
return (h as Headers).get(name) ?? null;
|
||||||
|
}
|
||||||
|
if (typeof h === "object") {
|
||||||
|
const record = h as Record<string, unknown>;
|
||||||
|
const value = record[name] ?? record[name.toLowerCase()];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
fromHeaders(event.request?.headers) ??
|
||||||
|
fromHeaders(event.headers) ??
|
||||||
|
fromHeaders(event.node?.req?.headers)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHop(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Addresses: parse and validate ───────────────────────────────────────────
|
||||||
|
// Ported from the reviewed `request-client.ts` (FRE-366 @ 1d9287a); see the
|
||||||
|
// header comment for why this is a copy and not an import.
|
||||||
|
|
||||||
|
/** Any address this file understands: a dotted quad or an IPv6 literal. */
|
||||||
|
export function isIpAddress(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && addressBytes(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an address is the loopback the production proxy presents.
|
||||||
|
*
|
||||||
|
* Parses rather than string-matches, so the forms a real peer can present in —
|
||||||
|
* `::ffff:127.0.0.53`, `0:0:0:0:0:0:0:1`, an IPv4-mapped literal — are all
|
||||||
|
* recognised. An address this does not parse is *not* loopback, which fails
|
||||||
|
* closed: forwarded headers from an unrecognised peer are ignored rather than
|
||||||
|
* believed (a non-loopback peer short-circuits before headers are read).
|
||||||
|
*/
|
||||||
|
export function isLoopbackAddress(ip: string): boolean {
|
||||||
|
const bytes = addressBytes(ip);
|
||||||
|
if (!bytes) return false;
|
||||||
|
if (bytes.length === 4) return bytes[0] === 127;
|
||||||
|
|
||||||
|
// IPv6 loopback ::1 — and ::ffff:127/8, which is loopback too.
|
||||||
|
const mapped = ipv4FromBytes(bytes);
|
||||||
|
if (mapped) return isLoopbackAddress(mapped);
|
||||||
|
const tail = bytes[15]!;
|
||||||
|
return bytes.slice(0, 15).every((byte) => byte === 0) && tail === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpv4(value: string): boolean {
|
||||||
|
const parts = value.split(".");
|
||||||
|
if (parts.length !== 4) return false;
|
||||||
|
return parts.every(
|
||||||
|
(part) => /^\d{1,3}$/.test(part) && Number(part) <= 255 && !/^0\d/.test(part)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4 bytes for IPv4, 16 for IPv6 (including `::ffff:a.b.c.d`), else null. */
|
||||||
|
function addressBytes(value: string): number[] | null {
|
||||||
|
const raw = value.trim().replace(/^\[|\]$/g, "").split("%")[0]!;
|
||||||
|
if (isIpv4(raw)) return raw.split(".").map(Number);
|
||||||
|
if (!raw.includes(":")) return null;
|
||||||
|
|
||||||
|
const halves = raw.split("::");
|
||||||
|
if (halves.length > 2) return null;
|
||||||
|
const compressed = halves.length === 2;
|
||||||
|
|
||||||
|
const head = groupsToBytes(halves[0] ?? "");
|
||||||
|
const tail = groupsToBytes(halves[1] ?? "");
|
||||||
|
if (!head || !tail) return null;
|
||||||
|
|
||||||
|
const total = head.length + tail.length;
|
||||||
|
if (compressed) {
|
||||||
|
if (total > 8) return null;
|
||||||
|
return [...head, ...new Array(8 - total).fill(0), ...tail].flatMap(split16);
|
||||||
|
}
|
||||||
|
if (total !== 8) return null;
|
||||||
|
return [...head, ...tail].flatMap(split16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One side of `::` as 16-bit groups; a trailing `1.2.3.4` counts as two. */
|
||||||
|
function groupsToBytes(side: string): number[] | null {
|
||||||
|
if (side === "") return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const part of side.split(":")) {
|
||||||
|
if (/^[0-9a-fA-F]{1,4}$/.test(part)) {
|
||||||
|
out.push(parseInt(part, 16));
|
||||||
|
} else if (isIpv4(part)) {
|
||||||
|
const o = part.split(".").map(Number);
|
||||||
|
out.push((o[0]! << 8) | o[1]!, (o[2]! << 8) | o[3]!);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function split16(group: number): number[] {
|
||||||
|
return [(group >> 8) & 0xff, group & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`), else null. */
|
||||||
|
function ipv4FromBytes(bytes: number[]): string | null {
|
||||||
|
const prefix = bytes.slice(0, 10).every((byte) => byte === 0);
|
||||||
|
const marker = bytes[10] === 0xff && bytes[11] === 0xff;
|
||||||
|
if (!prefix || !marker) return null;
|
||||||
|
return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user