9 Commits

Author SHA1 Message Date
71bb9ec5df FRE-1670: publish /terms and the support address on the Nessa site
nessa.freno.me/terms was a live 404 although AppSettings.termsOfServiceURL
links it from Settings > Legal and the paywall. The document existed only on
an unmerged branch and had no host-dispatch route, so it could not serve on
the subdomain. Carry the Terms of Use page, add the /terms dispatcher, and
publish nessa-support@freno.me on terms, privacy, contact and deletion.

- src/routes/terms.tsx: host-aware dispatch for nessa; other sites 404.
- src/routes/nessa/terms.tsx: the Terms page; its contact section names the
  support address.
- privacy: add the controller clause and the support address.
- contact/deletion: show the support address; deletion also accepts a
  request by email.
- subdomain-dispatch.test.ts: list directories before filtering to .tsx
  files. The old predicate only ever received file names, so it asserted
  over an empty list and missed the missing /terms dispatch.
- support-contact.test.ts: guard that every contact route renders the one
  address the app shows.

App side unchanged: termsOfServiceURL stays https://nessa.freno.me/terms.
2026-11-09 16:14:14 +00:00
97755042cb nessa: publish the Terms of Use the app links at /terms (FRE-194)
The Nessa app's Settings › Legal "Terms of Service" row had no Nessa-owned
document to resolve to: it linked another product's terms, and
nessa.freno.me/terms was a 404. Nessa sells an auto-renewing subscription, so
App Review 3.1.2 requires the app to link terms of use as well as a privacy
policy — the row has to land on a real document.

- src/routes/nessa/terms.tsx: the page, in the privacy.tsx house template
  (EdgeCacheHeaders, site-aware PageHead, SubdomainHeader, subdomain-relative
  internal links). Sections cover acceptance, the service and its limits,
  accounts, community content and acceptable use, auto-renewable
  subscriptions (payment, renewal, the 24-hour cancel window, managing it in
  the App Store, trials, refunds, price changes), the Apple third-party
  acknowledgements, IP, disclaimers and liability, termination, changes, and
  contact.
- src/lib/sitemap-routes.ts: add /terms to the Nessa set, and update the
  count assertion in sitemap-routes.test.ts to match.

Health and safety: Nessa is not a medical device, so section 2(c) says so
explicitly — the previous app-side link pointed at another product's terms,
which said nothing about Nessa's training features.

Deliberately left out, for Mike to decide rather than have me guess:
 - No legal entity name and no governing-law clause. No Nessa page states
   either today; the privacy policy speaks only as "we".
 - No NAV_CONFIG entry, so the nav link-set matrix tests are untouched. The
   route serves through the existing catch-all host rewrite in vercel.json;
   adding a nav item is a one-line change plus a matrix update if wanted.
2026-11-07 21:36:44 +00:00
a0e6ff1044 Merge pull request 'security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371)' (#1) from agent-security-engineer/freno-dev:freno-dev/FRE-371-trusted-client-ip-clean into master
Reviewed-on: Mike/freno-dev#1
2026-11-04 10:58:26 -05:00
6fd0f84806 Merge pull request 'analytics: make each weekly funnel stage separately measurable (FRE-366)' (#2) from agent-code-review/freno-dev:freno-dev/FRE-366-funnel-stages into master
Reviewed-on: Mike/freno-dev#2
2026-11-04 10:58:19 -05:00
c7071b0e32 Merge pull request 'metrics: stand up the weekly funnel job (FRE-202)' (#3) from agent-cto/freno-dev:freno-dev/FRE-202-weekly-metrics into master
Reviewed-on: Mike/freno-dev#3
2026-11-04 10:58:12 -05:00
7fde259e0b Merge pull request 'site: state the agent count as eighteen (FRE-997, count half)' (#5) from freno-dev/FRE-997-count-18 into master
Reviewed-on: Mike/freno-dev#5
2026-11-04 10:57:54 -05:00
25ec84ceb4 site: state the agent count as eighteen (FRE-997, count half)
FRE-359 verdict B dropped the agent count instead of correcting it: the page
said thirteen while `AgentSpec.all` held eighteen, and the /agents table listed
13 of those rows. The count is a fact, not a claim, so this states it and makes
the table match the registry again.

- Landing island bullet and the marquee chip say 18 agents.
- /agents: title/description/ogTitle/H1 at eighteen, and the five missing rows
  are on the page — Kimi, GitHub Copilot CLI, Crush, Cursor, Amp — with events
  and gating taken from hook/setup.go, hook/normalize.go,
  hook/normalize_hosts.go, Sources/NookCore/Agents/HookConfigInstaller.swift
  and plugins/amp/index.ts. All five are Observe (kimi/copilot PreToolUse and
  PermissionRequest fire before the host's own check; Crush documents one
  event; a silent Cursor permission hook blocks; Amp's plugin API has no
  permission-wait event).
- agents.test.ts asserts the roster as an equality with the 18-name registry
  in registry order again, plus the count, instead of a subset check.
- remote-claims.test.ts keeps every FRE-323 remote removal guard and the parked
  copy, and drops only the two completeness bans the corrected count
  contradicts ("every coding agent", "Supported agents") — the ban existed
  because the page claimed the set and listed 13 of 18.
- positioning.md: the coverage row is publishable at 18.

This is the count/roster half of FRE-997 only. The remote-SSH restore is held
by the Chief of Staff against FRE-346 §3 and is not in this commit: no remote
tile, breakdown, demo, chip, /agents remote paragraph or drafts deletion here.

Gate: `bun test src/routes/nook` 18 pass / 0 fail; `bun run build` succeeds.
2026-11-04 00:21:35 +00:00
dd368d21e7 smarter-remote: publish the app's privacy policy and terms of use
A Smarter Remote's iOS paywall sells an auto-renewing subscription, and
App Review 3.1.2 requires it to link a privacy policy and terms of use.
No such pages existed. They now live on the main site at
/smarter-remote/privacy and /smarter-remote/terms, so no new subdomain
or DNS change is needed. The policy describes what the app actually
does: local-network control, pairing data kept on the phone, purchases
through Apple and RevenueCat, and QR sharing phone to phone.

Tests: bun test shows the same 52 pre-existing failures with and
without the pages; tsc reports nothing in the new files.

TVR-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:34:18 -05:00
2fd2a4a71a security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371)
getClientIP (the rate-limit and audit key) took the LEFTMOST x-forwarded-for
hop and read x-real-ip unconditionally. In our topology — client → Cloudflare
→ same-host origin proxy → app — both proxies APPEND, so the leftmost hop is
whatever the sender typed: a rotating X-Forwarded-For minted a fresh rate-limit
bucket per request, and unlimited bug-report quota via the same pattern in
routes/api/the-nook/report.ts. x-real-ip is client-settable too; nothing in
the chain overwrites it. It is no longer read at all.

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

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

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

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

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

View File

@@ -4,8 +4,9 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
artifact invents its own framing, fix the artifact or fix this file — never both artifact invents its own framing, fix the artifact or fix this file — never both
silently diverging. silently diverging.
**Reconciled 2026-10-29** against the CTO's verdicts ([FRE-315], [FRE-322], **Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 count
[FRE-354]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B 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 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 **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 version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
@@ -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-320]: https://paperclip.freno.me/FRE/issues/FRE-320
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346 [FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203 [FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
## The sentence ## The sentence
@@ -125,7 +127,7 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
without disclosure. `approved/` **is the only thing a human ever publishes from** — without disclosure. `approved/` **is the only thing a human ever publishes from** —
nothing posts itself. nothing posts itself.
## Claims: status at 2026-10-29 ## Claims: status at 2026-11-03
Each row names the evidence, not a feeling. Re-read the verdict before using a row. Each row names the evidence, not a feeling. Re-read the verdict before using a row.
@@ -136,7 +138,7 @@ Each row names the evidence, not a feeling. Re-read the verdict before using a r
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". | | The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. | | Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. | | One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
| Agent coverage numbers ("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. |
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. | | macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
| Remote agents over SSH, one click to install | **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 | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). | | "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
@@ -144,8 +146,9 @@ Each row names the evidence, not a feeling. Re-read the verdict before using a r
## Voice ## Voice
- Short, concrete, no hype adjectives. Numbers over adjectives — but no coverage - Short, concrete, no hype adjectives. Numbers over adjectives — the only count
counts (see the excluded row above). 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. - The notch is the product's home; agents are the product's point.
- Never apologize for being a utility. The utility is the point. - Never apologize for being a utility. The utility is the point.
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary", - Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",

View File

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

View File

@@ -722,7 +722,7 @@ function LayoutMock() {
/* ── Marquee ─────────────────────────────────────────────────────── */ /* ── Marquee ─────────────────────────────────────────────────────── */
const CHIPS_A = [ const CHIPS_A = [
"Agents wired in", "18 agents",
"Custom agents", "Custom agents",
"Six chiptunes", "Six chiptunes",
"Focus-aware slots", "Focus-aware slots",

View File

@@ -67,7 +67,8 @@ describe("generateSitemap", () => {
expect(locs).toContain("https://nessa.freno.me/"); expect(locs).toContain("https://nessa.freno.me/");
expect(locs).toContain("https://nessa.freno.me/contact"); expect(locs).toContain("https://nessa.freno.me/contact");
expect(locs).toContain("https://nessa.freno.me/privacy"); expect(locs).toContain("https://nessa.freno.me/privacy");
expect(locs.length).toBe(3); expect(locs).toContain("https://nessa.freno.me/terms");
expect(locs.length).toBe(4);
for (const loc of locs) { for (const loc of locs) {
expect(loc).not.toContain("://freno.me/"); expect(loc).not.toContain("://freno.me/");

View File

@@ -57,7 +57,8 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
nessa: [ nessa: [
{ path: "/", changefreq: "weekly", priority: 1.0 }, { path: "/", changefreq: "weekly", priority: 1.0 },
{ path: "/contact", changefreq: "monthly", priority: 0.6 }, { path: "/contact", changefreq: "monthly", priority: 0.6 },
{ path: "/privacy", changefreq: "yearly", priority: 0.4 } { path: "/privacy", changefreq: "yearly", priority: 0.4 },
{ path: "/terms", changefreq: "yearly", priority: 0.4 }
], ],
lineage: [ lineage: [

View File

@@ -10,6 +10,8 @@ import { describe, expect, it, mock, beforeEach } from "bun:test";
const sentEmails: { to: string; subject: string; html: string }[] = []; const sentEmails: { to: string; subject: string; html: string }[] = [];
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = []; const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
/** Every rate-limit key the route derived, in order. */
const rateKeys: string[] = [];
mock.module("~/server/email", () => ({ mock.module("~/server/email", () => ({
default: async (to: string, subject: string, html: string) => { default: async (to: string, subject: string, html: string) => {
@@ -19,7 +21,10 @@ mock.module("~/server/email", () => ({
})); }));
mock.module("~/server/bug-report-rate-limit", () => ({ mock.module("~/server/bug-report-rate-limit", () => ({
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true } takeBugReportToken: (key: string) => {
rateKeys.push(key);
return tokenAnswers.shift() ?? { allowed: true };
}
})); }));
const { POST } = await import("./report"); const { POST } = await import("./report");
@@ -72,6 +77,7 @@ describe("POST /api/the-nook/report", () => {
beforeEach(() => { beforeEach(() => {
sentEmails.length = 0; sentEmails.length = 0;
tokenAnswers.length = 0; tokenAnswers.length = 0;
rateKeys.length = 0;
}); });
it("emails the owner a report containing the snapshot, escaped", async () => { it("emails the owner a report containing the snapshot, escaped", async () => {
@@ -133,10 +139,49 @@ describe("POST /api/the-nook/report", () => {
it("still succeeds when no fingerprint header exists", async () => { it("still succeeds when no fingerprint header exists", async () => {
const res = await POST( const res = await POST(
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" }) request(validPayload, {
"cf-connecting-ip": "203.0.113.9",
"x-forwarded-for": "203.0.113.9, 10.0.0.1"
})
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(sentEmails).toHaveLength(1); expect(sentEmails).toHaveLength(1);
// The quota key is the Cloudflare-recorded client, not a header hop the
// sender chose.
expect(rateKeys).toEqual(["203.0.113.9"]);
});
it("a rotating leftmost x-forwarded-for does not mint a fresh quota key", async () => {
// FRE-371: the old read keyed on the leftmost hop, which the sender
// controls outright — one scripted loop with a rotating header had
// unlimited reports. With the same Cloudflare-recorded client and a
// different spoofed prefix per request, the key must be stable.
for (const spoof of ["1.1.1.1", "2.2.2.2", "3.3.3.3"]) {
const res = await POST(
request(validPayload, {
"cf-connecting-ip": "203.0.113.9",
"x-forwarded-for": `${spoof}, 203.0.113.9, 10.0.0.1`
})
);
expect(res.status).toBe(200);
}
expect(rateKeys).toEqual([
"203.0.113.9",
"203.0.113.9",
"203.0.113.9"
]);
});
it("keys on the rightmost-minus-one hop when Cloudflare's header is absent", async () => {
const res = await POST(
request(validPayload, {
"x-forwarded-for": "6.6.6.6, 203.0.113.9, 10.0.0.1"
})
);
expect(res.status).toBe(200);
// Never the leftmost (sender-chosen) hop.
expect(rateKeys).toEqual(["203.0.113.9"]);
}); });
it("delivers the landing page's beta request with only the email", async () => { it("delivers the landing page's beta request with only the email", async () => {

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import sendEmail from "~/server/email"; import sendEmail from "~/server/email";
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config"; import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
import { takeBugReportToken } from "~/server/bug-report-rate-limit"; import { takeBugReportToken } from "~/server/bug-report-rate-limit";
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
import { json, error } from "./_lib"; import { json, error } from "./_lib";
/** /**
@@ -103,10 +104,9 @@ export async function POST(event: APIEvent): Promise<Response> {
) )
.trim() .trim()
.toLowerCase(); .toLowerCase();
const forwarded = event.request.headers.get("x-forwarded-for"); // FRE-371: the IP fallback must be the spoof-resistant read — a rotating
const rateKey = // leftmost x-forwarded-for used to mint unlimited report quota.
fingerprint || const rateKey = fingerprint || resolveTrustedClientIp(event);
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
const token = takeBugReportToken(rateKey); const token = takeBugReportToken(rateKey);
if (!token.allowed) { if (!token.allowed) {
return new Response(JSON.stringify({ error: "Too many reports" }), { return new Response(JSON.stringify({ error: "Too many reports" }), {

View File

@@ -1,6 +1,7 @@
import { ContactForm } from "~/components/ContactForm"; import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders"; import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader"; import SubdomainHeader from "~/components/SubdomainHeader";
import { SUPPORT_EMAIL } from "./content";
/** /**
* Nessa contact page (`nessa.freno.me/contact`). * Nessa contact page (`nessa.freno.me/contact`).
@@ -16,14 +17,27 @@ import SubdomainHeader from "~/components/SubdomainHeader";
* *
* Acceptance: `nessa.localhost:3000/contact` renders the contact form with * Acceptance: `nessa.localhost:3000/contact` renders the contact form with
* Nessa branding; submissions email `michael@freno.me` with subject * Nessa branding; submissions email `michael@freno.me` with subject
* `[Nessa] Contact Request`. * `[Nessa] Contact Request`. The support address shown by the app is also
* published here, so the contact route does not depend on the form alone
* (FRE-1670).
*/ */
export default function NessaContactPage() { export default function NessaContactPage() {
return ( return (
<> <>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} /> <EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader /> <SubdomainHeader />
<ContactForm /> <ContactForm>
<p class="mt-8 text-center text-sm opacity-80">
You can also email us directly at{" "}
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="text-blue hover-underline-animation"
>
{SUPPORT_EMAIL}
</a>
.
</p>
</ContactForm>
</> </>
); );
} }

View File

@@ -14,6 +14,15 @@ export const TAGLINE = "The fitness app that puts you first." as const;
export const SUBTITLE = export const SUBTITLE =
"Track, train, and connect — without the paywall." as const; "Track, train, and connect — without the paywall." as const;
/**
* The support address the Nessa app shows (`SubscriptionView` /
* `HelpSupportView`). Every published Nessa document that offers a contact
* route names this same address, so a user who leaves the app to read the
* terms, privacy policy, contact page, or deletion page can still reach us
* (FRE-1670). Keep in step with `nessa-support@freno.me` in the app.
*/
export const SUPPORT_EMAIL = "nessa-support@freno.me" as const;
export const ICON_DEFAULT = export const ICON_DEFAULT =
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const; "/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const; export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const;

View File

@@ -33,6 +33,7 @@ import {
DELETION_GRACE_PERIOD_LABEL, DELETION_GRACE_PERIOD_LABEL,
PAGE_META PAGE_META
} from "~/routes/nessa/deletion-content"; } from "~/routes/nessa/deletion-content";
import { SUPPORT_EMAIL } from "./content";
export default function NessaDeletionPage() { export default function NessaDeletionPage() {
return ( return (
@@ -54,6 +55,18 @@ export default function NessaDeletionPage() {
removed. No data related to the account is retained in any way. removed. No data related to the account is retained in any way.
</div> </div>
<div class="text-text mt-4 w-full justify-center">
Prefer email? Send your deletion request from the address on your
Nessa account to{" "}
<a
href={`mailto:${SUPPORT_EMAIL}?subject=Account%20Deletion%20Request`}
class="text-blue hover-underline-animation"
>
{SUPPORT_EMAIL}
</a>
; the same {DELETION_GRACE_PERIOD_LABEL} grace period applies.
</div>
<DeletionForm <DeletionForm
product={DELETION_PRODUCT_KEY} product={DELETION_PRODUCT_KEY}
cookieName={DELETION_COOKIE_NAME} cookieName={DELETION_COOKIE_NAME}

View File

@@ -30,6 +30,7 @@ import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders"; import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader"; import SubdomainHeader from "~/components/SubdomainHeader";
import { SUPPORT_EMAIL } from "./content";
export default function NessaPrivacyPolicy() { export default function NessaPrivacyPolicy() {
return ( return (
@@ -174,15 +175,29 @@ export default function NessaPrivacyPolicy() {
<span class="-ml-4 pr-2">5.</span> Contact Us <span class="-ml-4 pr-2">5.</span> Contact Us
</div> </div>
<div class="pb-2 pl-4"> <div class="pb-2 pl-4">
<div class="-ml-6">(a) Reaching Out:</div> If there are any <div class="-ml-6">(a) Data Controller:</div> Nessa is operated by
questions or comments regarding this privacy policy, you can FrenoCorp. For the purposes of applicable data-protection law,
FrenoCorp is the controller of the personal information described
in this policy.
<div class="pt-2 pb-2">
<div class="-ml-6">(b) Reaching Out:</div> If there are any
questions or comments regarding this privacy policy, or you want
to access, correct, or delete your personal information, you can
contact us{" "} contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation"> <A href="/contact" class="text-blue hover-underline-animation">
here through the contact form
</A> </A>{" "}
or email{" "}
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="text-blue hover-underline-animation"
>
{SUPPORT_EMAIL}
</a>
. .
</div> </div>
</div> </div>
</div>
</ol> </ol>
</div> </div>
</> </>

View File

@@ -0,0 +1,36 @@
/**
* Regression guard for the published Nessa support address (FRE-1670).
*
* The app tells users to mail `nessa-support@freno.me` from the subscription
* screen and Help & Support. Before this guard, no published Nessa page named
* that address: a user who left the app to read the terms, privacy policy,
* contact page, or deletion page found no way to reach support. That is a
* privacy-policy contact gap (GDPR/CCPA-style) and an App Review question
* waiting to happen.
*
* The address lives in one place (`SUPPORT_EMAIL`), and every page that
* offers a contact route must render it. Reading the route sources is
* deliberate: the defect is a page that exists but does not mention the
* address, which no runtime assertion over a single page can observe.
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "fs";
import { SUPPORT_EMAIL } from "./content";
const CONTACT_ROUTE_FILES = [
"src/routes/nessa/terms.tsx",
"src/routes/nessa/privacy.tsx",
"src/routes/nessa/contact.tsx",
"src/routes/nessa/deletion.tsx"
];
describe("Nessa published support address", () => {
it("is the address the app shows", () => {
expect(SUPPORT_EMAIL).toBe("nessa-support@freno.me");
});
it.each(CONTACT_ROUTE_FILES)("%s renders it", (file) => {
const source = readFileSync(file, "utf8");
expect(source).toContain("SUPPORT_EMAIL");
});
});

454
src/routes/nessa/terms.tsx Normal file
View File

@@ -0,0 +1,454 @@
/**
* Nessa terms of use — `nessa.freno.me/terms`.
*
* The document behind the `Terms of Service` row in the Nessa app
* (`Nessa/Models/AppSettings.swift`, Settings › Legal). Before this page the
* row had no Nessa-owned document to resolve to: the app linked another
* product's terms, and `nessa.freno.me/terms` was a 404 (FRE-194).
*
* Nessa sells an auto-renewing subscription through the App Store, so App
* Review 3.1.2 requires the app to link a privacy policy **and** terms of
* use. Section 5 (subscriptions) and section 6 (Apple acknowledgements) are
* the clauses that requirement is read against, so keep them when editing.
*
* House voice follows `privacy.tsx`: "we" / "Nessa", no legal entity name,
* and no governing-law clause — the Nessa pages have never stated either, and
* naming an entity or a jurisdiction is a decision to make deliberately, not
* to introduce as a side effect of a link fix.
*
* PageHead is site-aware: only the base title is supplied; the ` | Nessa`
* suffix, the `https://nessa.freno.me/terms` canonical and the OG image are
* derived automatically. Internal links use public subdomain-relative paths
* (`/privacy`, `/deletion`, `/contact`) per nav-config.ts and
* page-head-meta.ts.
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import { SUPPORT_EMAIL } from "./content";
export default function NessaTermsOfUse() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Terms of Use"
description="Terms of use for Nessa, a fitness app for tracking workouts and training with clubs, challenges, and friends."
/>
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">Nessa&apos;s Terms of Use</div>
<div class="py-2">Last Updated: November 9, 2026</div>
<div class="py-2">
Welcome to Nessa (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
These terms of use govern your access to and use of the Nessa app and
its related services. By using Nessa you agree to these terms. Our{" "}
<A href="/privacy" class="text-blue hover-underline-animation">
privacy policy
</A>{" "}
explains how we handle your data, and it forms part of these terms.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> Acceptance of These Terms
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Agreement:</div> By creating an account,
downloading, or otherwise using Nessa you agree to these terms.
If you do not agree to them, do not use Nessa.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Eligibility:</div> You must be at least
the age of digital consent in your country to use Nessa. If you
are using Nessa with a parent&apos;s or guardian&apos;s
account, they are responsible for your compliance with these
terms.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Where these terms apply:</div> These
terms are between you and us. Where you obtained Nessa through
a third party such as the Apple App Store, that
party&apos;s own terms apply to your relationship with them as
well.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">2.</span> The Service
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) What Nessa is:</div> Nessa is a fitness
application for iOS, with an Apple Watch companion, that lets
you record workouts and routes, review your training history,
and take part in community features — clubs, club challenges,
posts, comments, and leaderboards. Some features are free and
some require a paid subscription (see section 5).
</div>
<div class="pb-2">
<div class="-ml-6">(b) Changes and availability:</div> We are
continually improving Nessa, so features may change, move
between tiers, or be withdrawn. We do not guarantee that Nessa
will be available at all times, and we may suspend or discontinue
part or all of the service. Where a change materially affects a
paid feature you have subscribed to, we will tell you on the
subscription management page or by contact.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Not medical advice:</div> Nessa is not a
medical device and is not intended to diagnose, treat, cure, or
prevent any condition. Workout data, heart-rate and other
health readings, training plans, and estimates you see in Nessa
are for general information and motivation only. Consult a
qualified professional before starting or changing a training
programme, especially if you have a health condition. Never
ignore professional advice, or delay seeking it, because of
something you read in Nessa.
</div>
<div class="pb-2">
<div class="-ml-6">(d) Your surroundings:</div> You are
responsible for your own safety while using Nessa, including
when recording an activity. Follow local laws and do not use
Nessa in a way that distracts you from driving or other
activities that require your full attention.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">3.</span> Your Account
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Sign-in:</div> Nessa accounts are
authenticated through Clerk, our identity provider. You are
responsible for keeping the credentials you use to sign in
secure, and you are responsible for the activity that happens
under your account.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Notify us:</div> Tell us promptly, using
the contact details in section 11, if you believe someone else
has used or may use your account without your permission.
</div>
<div class="pb-2">
<div class="-ml-6">(c) One account:</div> Keep to one account,
and give us accurate information so we can keep your training
history and your place in the community correct. Do not create
accounts to evade a moderation decision or to gain a fair
advantage in challenges or leaderboards.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">4.</span> Community Content and Acceptable
Use
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) You keep your content:</div> You own what
you post. By posting to a club, challenge, or other shared area
of Nessa you grant us a limited, non-exclusive licence to store,
reproduce, and display that content inside Nessa so that the
feature you used can work — for example, showing your post to
the members of your club. That licence ends when your content is
removed from our systems. We handle your content as described in
our{" "}
<A href="/privacy" class="text-blue hover-underline-animation">
privacy policy
</A>
; we do not sell it.
</div>
<div class="pb-2">
<div class="-ml-6">(b) What you are responsible for:</div> You
are responsible for the content you post and the data you record
in Nessa, and for who you share it with. Use the privacy
settings on your profile and the privacy rules of the clubs you
join before sharing anything you would not want others to see.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Do not:</div> (i) post anything unlawful,
harassing, hateful, threatening, or sexually explicit involving
minors; (ii) post content you do not have the right to share, or
that infringes someone else&apos;s intellectual property;
(iii) falsify activity data, spoof a location, or otherwise
cheat a challenge, leaderboard, or competition; (iv) scrape,
bulk-download, or access Nessa through automated means we have
not authorised; (v) interfere with, overload, or probe Nessa for
vulnerabilities, or attempt to access another user&apos;s
account or data; (vi) resell, sublicense, or offer Nessa to
others as your own service.
</div>
<div class="pb-2">
<div class="-ml-6">(d) Moderation:</div> We may remove or
restrict content, disqualify an entry from a challenge, or
suspend or close an account that breaks these terms. We are not
obliged to pre-monitor content. You can report content or a user
to us at any time using the contact details in section 11.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">5.</span> Subscriptions and Payments
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Paid tiers:</div> Nessa offers paid
subscription tiers, Nessa Plus and Nessa Pro, which unlock
features described in the app. Subscriptions are sold as
auto-renewing subscriptions through the Apple App Store and are
billed to your Apple Account.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Payment:</div> Payment is charged to your
Apple Account at confirmation of purchase. The price of your
plan is shown in the app before you buy, and it is the price on
your App Store purchase receipt.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Auto-renewal:</div> Unless you cancel at
least 24 hours before the end of the current period, your
subscription renews automatically. Your account is charged for
renewal within 24 hours before the end of the current period, at
the price of the plan you selected.
</div>
<div class="pb-2">
<div class="-ml-6">(d) Managing your subscription:</div> You can
turn off auto-renewal, or cancel, at any time in your App Store
account settings after purchase. Changes made there take effect
at the end of the current period, and you keep access to the
paid features you have already paid for until then.
</div>
<div class="pb-2">
<div class="-ml-6">(e) Trials:</div> If we offer a free trial,
any unused portion of it is forfeited when you purchase a
subscription, and the trial converts to a paid auto-renewing
subscription unless you cancel it before it ends.
</div>
<div class="pb-2">
<div class="-ml-6">(f) Refunds:</div> Purchases made through the
App Store are refunded according to Apple&apos;s refund policy
and, where applicable law gives you a right to a refund,
according to that law. Tell us using the contact details in
section 11 and we will do what we can.
</div>
<div class="pb-2">
<div class="-ml-6">(g) Price changes:</div> We may change our
prices. A price change does not apply to a period you have
already paid for; it applies from your next renewal, and we give
notice where the law requires it.
</div>
<div class="pb-2">
<div class="-ml-6">(h) Downgrade:</div> When a subscription
lapses or is cancelled you keep the free features, and anything
you created that belongs to a paid feature (such as a custom
route) is kept but is not editable or usable until you
subscribe again.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">6.</span> Apple and Other Third Parties
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Not Apple&apos;s terms:</div> These terms
are between you and us, not with Apple Inc. Apple is not
responsible for Nessa or its content, and your use of Nessa must
comply with the Apple App Store Terms of Service.
</div>
<div class="pb-2">
<div class="-ml-6">(b) No Apple support:</div> Apple has no
obligation to furnish maintenance and support services for
Nessa. If Nessa fails, you may notify Apple, but Apple has no
further support obligation, and support and any warranties
relating to Nessa are ours to provide as described in these
terms.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Apple is not responsible for claims:</div>{" "}
Apple is not responsible for product liability claims,
intellectual property infringement claims, or any other claims
relating to Nessa or your possession and use of it. We, not
Apple, are responsible for addressing any such claims and for
any claims that you may have relating to Nessa.
</div>
<div class="pb-2">
<div class="-ml-6">(d) Compliance:</div> You confirm that you
are not located in a country subject to an embargo of the United
States or listed by the United States government as a
terrorist-supporting country, and that you are not listed on any
United States government list of prohibited or restricted
parties.
</div>
<div class="pb-2">
<div class="-ml-6">(e) Third-party services:</div> Nessa depends
on third-party services — Clerk for authentication, Apple
HealthKit for health and workout data, Apple&apos;s In-App
Purchase system for subscriptions, and our own hosting — each
governed by its own terms. Where those terms affect how you may
use the feature they power, they apply alongside these terms.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">7.</span> Intellectual Property
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Our rights:</div> The Nessa software, its
design, text, icons, and branding are owned by us or our
licensors and are protected by copyright and other laws. We
grant you a personal, non-exclusive, non-transferable, revocable
licence to use Nessa for your own personal, non-commercial
purposes.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Restrictions:</div> You may not copy,
modify, translate, distribute, sell, lease, sublicense, or
create derivative works from Nessa, and you may not reverse
engineer, decompile, or attempt to obtain its source code,
except to the extent applicable law allows this despite a
restriction.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">8.</span> Disclaimers and Limitation of
Liability
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) As is:</div> Nessa is provided &quot;as
is&quot; and &quot;as available&quot; to the fullest extent
permitted by applicable law. We do not warrant that Nessa will
be uninterrupted, secure, error-free, or that recorded data will
be accurate — GPS-derived distance, pace, elevation, and
estimates can be wrong, and device and sensor limits apply.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Your data:</div> You are responsible for
backing up the data you store on your device. We keep
Nessa&apos;s service data as described in the{" "}
<A href="/privacy" class="text-blue hover-underline-animation">
privacy policy
</A>{" "}
and{" "}
<A href="/deletion" class="text-blue hover-underline-animation">
deletion page
</A>
, but no service is a substitute for your own backup.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Liability:</div> To the fullest extent
permitted by law, we are not liable for indirect, incidental,
special, consequential, or punitive damages, or for lost data,
lost profits, or loss of training records, arising out of your
use of, or inability to use, Nessa. Where a court would not
allow that exclusion, our liability for claims arising from your
use of Nessa is limited to the greater of the subscription fees
you paid us in the twelve months before the event giving rise to
the claim, or the minimum the applicable law requires. Nothing
in these terms limits liability that cannot lawfully be limited,
including for fraud or for death or personal injury caused by
our negligence.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">9.</span> Ending Your Use
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) You can leave:</div> You can stop using
Nessa at any time. You can delete your account and the content
associated with it through our{" "}
<A href="/deletion" class="text-blue hover-underline-animation">
account deletion page
</A>
. Deleting your account does not cancel a subscription that is
still in its paid period; cancel that in your App Store account
settings as described in section 5.
</div>
<div class="pb-2">
<div class="-ml-6">(b) We may end access:</div> We may suspend
or close your access to Nessa if you break these terms, if we
are required to by law, or if we reasonably believe your account
has been compromised. Where we can, we will tell you why.
</div>
<div class="pb-2">
<div class="-ml-6">(c) On termination:</div> Your licence to use
Nessa ends, and sections 4(a), 6, 7, 8, 10, and 11 continue to
apply.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">10.</span> Changes to These Terms
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Updates:</div> We may update these terms
as Nessa changes. The current version is always the one at{" "}
<A href="/terms" class="text-blue hover-underline-animation">
nessa.freno.me/terms
</A>
, and the &quot;Last Updated&quot; date at the top of this page
shows when it last changed.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Continued use:</div> If you keep using
Nessa after a change takes effect, you accept the revised terms.
For changes that materially reduce your rights or your paid
features, we will give notice in the app or by contact before
they take effect.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">11.</span> Contact Us
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Reaching Out:</div> If you have any
questions or comments about these terms, want to report content,
or need help with a subscription, you can contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
through the contact form
</A>{" "}
or email{" "}
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="text-blue hover-underline-animation"
>
{SUPPORT_EMAIL}
</a>
.
</div>
</div>
</ol>
</div>
</>
);
}

View File

@@ -1,6 +1,5 @@
/** /**
* Regression test for the /agents roster (NOOK-336) and its coverage wording * Regression test for the /agents roster and its coverage count.
* (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
@@ -9,11 +8,11 @@
* them. The registry lives in another repo, so the expected roster is * them. The registry lives in another repo, so the expected roster is
* restated here; when an agent is added there, add it here and on the page. * restated here; when an agent is added there, add it here and on the page.
* *
* FRE-359 verdict B: the page no longer states a count or claims coverage * FRE-997 restored the count. The page said thirteen while the registry held
* ("all thirteen", "every coding agent The Nook supports"). The page still * eighteen, so the count was dropped rather than corrected (FRE-359 verdict B);
* lists 13 of the registry's 18 rows — the table is hand-mirrored and its * now that all eighteen rows are on the page, the roster check is an equality
* regeneration is its own ticket — so the roster check here is a subset * check again and the page states eighteen. A subset check would let a shipped
* check in registry order, not an equality check. * agent go missing behind a passing suite, which is the NOOK-336 failure.
* *
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions. * STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
*/ */
@@ -48,51 +47,23 @@ const REGISTRY = [
"Amp" "Amp"
]; ];
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen"];
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]); const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
/** The page's rows are a subset of the registry, in registry order. */ describe("Nook /agents roster", () => {
function subsetInOrder(page: string[], registry: string[]): boolean { it("lists every registry agent, once, in registry order", () => {
let cursor = 0; expect(entries).toEqual(REGISTRY);
for (const name of page) {
const at = registry.indexOf(name, cursor);
if (at === -1) return false;
cursor = at + 1;
}
return true;
}
describe("Nook /agents roster (NOOK-336)", () => {
it("lists only registry agents, in registry order", () => {
expect(entries.length).toBeGreaterThan(0);
expect(subsetInOrder(entries, REGISTRY)).toBe(true);
}); });
it("lists each agent at most once", () => { it("states the registry's size", () => {
expect(new Set(entries).size).toBe(entries.length); expect(entries.length).toBe(18);
}); expect(COPY).toContain("all eighteen");
expect(COPY).toContain("The 18 agents");
expect(COPY).toContain("eighteen coding agents");
}); });
describe("Nook /agents coverage wording (FRE-359)", () => { it("carries no stale count", () => {
it("states no agent count anywhere on the page", () => { expect(COPY.toLowerCase()).not.toContain("thirteen");
for (const word of NUMBER_WORDS) { expect(COPY).not.toMatch(/\b13 agents\b/);
expect(COPY.toLowerCase()).not.toContain(word); expect(COPY).not.toContain("all thirteen");
}
// 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);
}
}); });
}); });

View File

@@ -1,11 +1,10 @@
/** /**
* `/agents` on the Nook subdomain — the agent roster and each row's event * `/agents` on the Nook subdomain — all eighteen agents in the registry and
* coverage. It states no agent count and claims no completeness over the * each row's event coverage.
* 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/AgentRegistry.swift) and each
* installed hook surface (hook/setup.go) or plugin event subscription * agent's installed hook surface (hook/setup.go) or plugin event subscription
* (plugins/*), normalized onto the shared event vocabulary * (plugins/*), normalized onto the shared event vocabulary
* (~/Code/the-nook/docs/agent-interface.md). Copy follows * (~/Code/the-nook/docs/agent-interface.md). Copy follows
* marketing/positioning.md: category nouns, honest scoping, no unverifiable * marketing/positioning.md: category nouns, honest scoping, no unverifiable
@@ -261,6 +260,92 @@ const AGENTS: AgentEntry[] = [
], ],
gating: gating:
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent" "Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
},
{
name: "Kimi",
wiring: "Hook rules in its TOML config, one per event",
events: [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"AgentStart",
"PreToolUse",
"PermissionRequest",
"PermissionResolved",
"PostToolUse",
"PostToolUseFailure",
"Stop",
"SessionError",
"SubagentStart",
"SubagentStop",
"PreCompact",
"Notification"
],
gating:
"Observation — every event appears as a card; its PreToolUse fires before its own permission check, so the agent keeps its own prompting"
},
{
name: "GitHub Copilot CLI",
wiring: "One hook file of ours in its hooks directory",
events: [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PostToolUseFailure",
"Stop",
"SubagentStart",
"SubagentStop",
"ErrorOccurred",
"PreCompact",
"Notification"
],
gating:
"Observation — every event appears as a card; its permission request runs before its own policy check, so the agent keeps its own prompting"
},
{
name: "Crush",
wiring:
"One entry in its JSONC config, the only event it documents",
events: ["PreToolUse"],
gating:
"Observation — tool calls appear as cards; its own permission prompts decide"
},
{
name: "Cursor",
wiring:
"Hook entries in its hooks.json, merged with your own. No permission hook, on purpose",
events: [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PostToolUse",
"PostToolUseFailure",
"Stop",
"SessionError",
"AfterAgentResponse",
"SubagentStop",
"PreCompact"
],
gating:
"Observation — a permission hook that stays silent blocks the action here, so Cursor keeps its own prompting"
},
{
name: "Amp",
wiring: "In-process extension package",
events: [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"SessionError"
],
gating:
"Observation — its plugin API has no permission-wait event, so the agent keeps its own prompting"
} }
]; ];
@@ -284,19 +369,19 @@ export default function NookAgentsPage() {
<> <>
<EdgeCacheHeaders maxAge={300} /> <EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Agent coverage" title="Supported agents"
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." 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 coding agents The Nook connects to, and what each hook sees" ogTitle="The 18 agents The Nook supports, and what each hook sees"
/> />
<SubdomainHeader /> <SubdomainHeader />
<main class="min-h-screen px-[8vw] py-[8vh]"> <main class="min-h-screen px-[8vw] py-[8vh]">
<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">
Agent coverage Supported agents
</h1> </h1>
<p class="text-subtext0 mb-2 max-w-2xl text-lg"> <p class="text-subtext0 mb-2 max-w-2xl text-lg">
The Nook watches coding agents in one place — your menu bar and notch The Nook watches eighteen coding agents in one place — your menu bar and
show sessions that are running, thinking, or blocked, and a card can notch show every session that is running, thinking, or blocked, and a
answer a permission prompt without leaving your keyboard. card answers permission prompts 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"

View File

@@ -11,6 +11,16 @@
* credential. FRE-359 verdict B removed the coverage count for the same * credential. FRE-359 verdict B removed the coverage count for the same
* reason: the /agents table lists 13 of the registry's 18 rows. * reason: the /agents table lists 13 of the registry's 18 rows.
* *
* FRE-997 corrects the second half without touching the first. The count was
* stale, not unverifiable: `AgentSpec.all` holds eighteen, /agents now lists
* all eighteen and `agents.test.ts` asserts that roster as an equality again.
* So the two completeness phrasings FRE-359 banned ("every coding agent",
* "Supported agents") no longer rest on a 13-of-18 gap — the ban existed
* because the page claimed the set and listed two thirds of it. They are out
* of `BANNED_COVERAGE` on this branch for that reason alone; the stale counts
* stay banned, and every FRE-323 remote sentence stays banned, because this
* branch still carries the FRE-323 site state.
*
* The copy was not thrown away — it is parked in * The copy was not thrown away — it is parked in
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts * `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 * it is still there so nobody deletes the parked story as stale. It goes back
@@ -55,13 +65,15 @@ const BANNED_REMOTE_CLAIMS = [
"this Mac or remote machines" "this Mac or remote machines"
]; ];
/** FRE-359 verdict B: no count, no coverage claim, in any form. */ /** FRE-359 verdict B: no stale count, in any form.
*
* "every coding agent" and "Supported agents" left this list with FRE-997:
* the page now lists all eighteen registry rows and `agents.test.ts` asserts
* that equality, so the completeness claim is backed rather than incomplete. */
const BANNED_COVERAGE = [ const BANNED_COVERAGE = [
"all thirteen", "all thirteen",
"thirteen coding agents", "thirteen coding agents",
"13 agents", "13 agents"
"every coding agent",
"Supported agents"
]; ];
/** User-facing copy only: comments are not copy and may name the old wording. */ /** User-facing copy only: comments are not copy and may name the old wording. */
@@ -98,9 +110,9 @@ describe("remote claims stay off the site (FRE-323)", () => {
}); });
}); });
describe("agent coverage count stays off the page (FRE-359 verdict B)", () => { describe("the agent count is corrected, not dropped (FRE-997)", () => {
for (const file of AUDITED) { for (const file of AUDITED) {
it(`${file} states no coverage count`, () => { it(`${file} states no stale count`, () => {
const copy = copyOf(file); const copy = copyOf(file);
for (const phrase of BANNED_COVERAGE) { for (const phrase of BANNED_COVERAGE) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase()); expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
@@ -108,7 +120,7 @@ describe("agent coverage count stays off the page (FRE-359 verdict B)", () => {
}); });
} }
it("/agents titles the table Agent coverage", () => { it("/agents states the registry's size", () => {
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Agent coverage"); expect(copyOf("src/routes/nook/agents.tsx")).toContain("Supported agents");
}); });
}); });

View 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&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: November 2, 2026</div>
<div class="py-2">
Welcome to A Smarter Remote (&apos;We&apos;, &apos;Us&apos;,
&apos;Our&apos;). The app controls your TVs over your own Wi-Fi
network. It has no accounts, no ads, and no analytics. This policy
explains the little data it does handle.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> Data We Collect
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Your TVs:</div> The app finds TVs on
your local network and stores their names, network addresses,
and platform on your phone. Pairing codes and tokens are kept
in your phone&apos;s Keychain. This data is sent only to the
TV itself, over your local network. It never reaches us.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Purchases:</div> Payments are handled
by Apple. We use RevenueCat to check whether you own A Smarter
Remote Pro. RevenueCat receives an anonymous app user ID, your
purchase and subscription history, and basic device and app
details such as OS version, app version, and region. It does
not receive your name, email, or payment details.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Voice input:</div> If you use voice
search, your speech is converted to text by Apple&apos;s
speech recognition. Apple may process it on its servers. The
resulting text is sent only to your TV.
</div>
<div class="pb-2">
<div class="-ml-6">(d) Sharing:</div> If you share a TV with
someone, its name, address, and platform are put into a QR
code on your screen for them to scan. It goes from one phone
to the other and does not pass through our servers.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">2.</span> How We Use It
</div>
<div class="pb-2 pl-4">
Data about your TVs is used only to control them. Purchase
records are used only to unlock Pro. We do not use any of it for
advertising, and we do not sell it.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">3.</span> Local Network Access
</div>
<div class="pb-2 pl-4">
iOS asks for permission before the app can use your local
network. The app needs it to find and control your TVs. If you
decline, the app cannot reach your TVs. You can change this in
Settings &gt; Privacy &amp; Security &gt; Local Network.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">4.</span> Deleting Your Data
</div>
<div class="pb-2 pl-4">
Removing a TV in the app deletes its stored details and
credentials from your phone. Deleting the app removes all of
them. Subscriptions are managed and cancelled in your App Store
account settings.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">5.</span> Children&apos;s Privacy
</div>
<div class="pb-2 pl-4">
The app does not knowingly collect personal information from
anyone, including children under 13.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">6.</span> Changes to the Privacy Policy
</div>
<div class="pb-2 pl-4">
We may update this policy periodically. Any changes are posted on
this page, so please review it from time to time.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">7.</span> Contact Us
</div>
<div class="pb-2 pl-4">
If you have any questions about this policy, you can contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
here
</A>
.
</div>
</div>
</ol>
</div>
</div>
</>
);
}

View File

@@ -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&apos;s Terms of Use</div>
<div class="py-2">Last Updated: November 2, 2026</div>
<div class="py-2">
These terms cover your use of the A Smarter Remote app (&apos;the
App&apos;). By using the App you agree to them. The App is licensed
to you under Apple&apos;s{" "}
<a
href="https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
target="_blank"
rel="noopener noreferrer"
class="text-blue hover-underline-animation"
>
Licensed Application End User License Agreement
</a>
, and the terms below add to it.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> A Smarter Remote Pro
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) What it includes:</div> The free App
controls one TV. Pro removes that limit and unlocks the
features marked Pro in the App.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Yearly subscription:</div> Payment is
charged to your Apple account when you confirm the purchase,
or when any free trial ends. The subscription renews
automatically at the same price unless you cancel at least 24
hours before the end of the current period. Your account is
charged for renewal within 24 hours before the period ends.
Any unused part of a free trial is forfeited when you buy a
subscription.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Lifetime purchase:</div> A one-time
payment that unlocks Pro with no recurring charge.
</div>
<div class="pb-2">
<div class="-ml-6">(d) Managing and refunds:</div> You can
manage or cancel a subscription in your App Store account
settings. Refunds are handled by Apple under its policies.
Restore Purchases in the App re-unlocks Pro on another device
signed in to the same Apple account.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">2.</span> Your TVs
</div>
<div class="pb-2 pl-4">
The App controls TVs through the network features their makers
provide. Which features work depends on your TV&apos;s model and
software, which can change without notice. We do not guarantee
that every feature works on every TV. Only control TVs you own
or have permission to use.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">3.</span> No Warranty
</div>
<div class="pb-2 pl-4">
The App is provided &apos;as is&apos;, without warranties of any
kind, to the extent the law allows. To the extent the law allows,
we are not liable for indirect or consequential damages arising
from your use of the App.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">4.</span> Privacy
</div>
<div class="pb-2 pl-4">
How the App handles data is described in the{" "}
<A
href="/smarter-remote/privacy"
class="text-blue hover-underline-animation"
>
Privacy Policy
</A>
.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">5.</span> Changes to These Terms
</div>
<div class="pb-2 pl-4">
We may update these terms periodically. Any changes are posted on
this page. Continuing to use the App after a change means you
accept the new terms.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">6.</span> Contact Us
</div>
<div class="pb-2 pl-4">
If you have any questions about these terms, you can contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
here
</A>
.
</div>
</div>
</ol>
</div>
</div>
</>
);
}

View File

@@ -14,7 +14,10 @@ import { describe, it, expect } from "bun:test";
import { readdirSync, existsSync, statSync } from "fs"; import { readdirSync, existsSync, statSync } from "fs";
function isRouteFile(name: string): boolean { function isRouteFile(name: string): boolean {
return name.endsWith(".tsx") || name.endsWith(".ts"); // Page routes are components; the `.ts` files beside them are pure content /
// metadata modules (content.ts, meta.ts, *-content.ts) with no default
// export the router can render, so they are not dispatch targets.
return name.endsWith(".tsx");
} }
function listRouteFiles(dir: string): string[] { function listRouteFiles(dir: string): string[] {
@@ -25,6 +28,20 @@ function listRouteFiles(dir: string): string[] {
} }
} }
function listDirectories(dir: string): string[] {
try {
return readdirSync(dir).filter((name) => {
try {
return statSync(`${dir}/${name}`).isDirectory();
} catch {
return false;
}
});
} catch {
return [];
}
}
function hasDispatchRoute(routeName: string, routesDir: string): boolean { function hasDispatchRoute(routeName: string, routesDir: string): boolean {
// Check for src/routes/<name>.tsx // Check for src/routes/<name>.tsx
if (existsSync(`${routesDir}/${routeName}.tsx`)) { if (existsSync(`${routesDir}/${routeName}.tsx`)) {
@@ -41,14 +58,12 @@ describe("subdomain routing dispatch invariant", () => {
const routesDir = "./src/routes"; const routesDir = "./src/routes";
it("every subdomain route has a top-level dispatch route", () => { it("every subdomain route has a top-level dispatch route", () => {
const prefixDirs = listRouteFiles(routesDir) // List directory entries before filtering to route files. The previous
.filter((name) => { // shape filtered with `listRouteFiles` first, which only ever returns
try { // `.tsx`/`.ts` names, so `statSync(...).isDirectory()` was never true
return statSync(`${routesDir}/${name}`).isDirectory(); // and the assertion ran over an empty list — it passed while `/terms`
} catch { // had no dispatch file (FRE-1670).
return false; const prefixDirs = listDirectories(routesDir)
}
})
// Exclude non-product subdomains // Exclude non-product subdomains
.filter((name) => .filter((name) =>
["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name) ["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name)

30
src/routes/terms.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NessaTermsOfUse from "./nessa/terms";
/**
* Host-aware terms-of-use route — `/terms`.
*
* The public browser path `/terms` is what the Nessa app links from
* Settings › Legal and from the subscription paywall
* (`AppSettings.termsOfServiceURL`). Production Vercel deployments do not
* apply the `vercel.json` host rewrites (the Nitro Build Output API config
* supersedes them), so the subdomain page needs this top-level dispatch file
* to be reachable — without it `nessa.freno.me/terms` is a 404 even though
* `src/routes/nessa/terms.tsx` exists.
*
* Only Nessa publishes terms of use today. Every other site — and the main
* site — falls back to the 404 handler rather than borrowing another
* product's legal document.
*/
export default function TermsPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nessa"}>
<NessaTermsOfUse />
</Match>
</Switch>
);
}

View File

@@ -3,6 +3,7 @@ import { getCookie, setCookie } from "vinxi/http";
import type { H3Event } from "vinxi/http"; import type { H3Event } from "vinxi/http";
import { t } from "~/server/api/utils"; import { t } from "~/server/api/utils";
import { logAuditEvent } from "~/server/audit"; import { logAuditEvent } from "~/server/audit";
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { import {
AUTH_CONFIG, AUTH_CONFIG,
@@ -328,39 +329,15 @@ async function cleanupExpiredRateLimits(): Promise<void> {
} }
/** /**
* Get client IP address from request headers. * Get client IP address for a security decision (rate limiting, audit).
* Only trusts X-Forwarded-For outside of local development (set by the Vercel *
* edge network in production; trusted in tests so the header-parsing path is * Thin alias over `resolveTrustedClientIp` (./trusted-client-ip), which owns
* exercised). In local development, uses the socket address to prevent header * the trust order: the previous leftmost `x-forwarded-for` read was
* spoofing. * client-spoofable in our append-only proxy topology, and `x-real-ip` was
* read unconditionally — both fixed there (FRE-371).
*/ */
export function getClientIP(event: H3Event): string { export function getClientIP(event: H3Event): string {
// In production on Vercel, X-Forwarded-For is set by the edge network return resolveTrustedClientIp(event);
// and cannot be spoofed by clients. In dev, ignore it.
if (env.NODE_ENV !== "development") {
const forwarded = getHeaderValue(event, "x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0].trim();
}
const realIP = getHeaderValue(event, "x-real-ip");
if (realIP) {
return realIP;
}
}
// Fallback: try socket remote address
try {
const nodeReq = event.node.req;
if (nodeReq?.socket?.remoteAddress) {
const addr = nodeReq.socket.remoteAddress;
// Clean up IPv6-mapped IPv4 addresses
return addr.replace(/^::ffff:/, "");
}
} catch {
// socket access failed
}
return "unknown";
} }
/** /**

View File

@@ -150,29 +150,100 @@ describe("Rate Limiting", () => {
}); });
describe("getClientIP", () => { describe("getClientIP", () => {
it("should extract IP from x-forwarded-for header", () => { // FRE-371: the read is spoof-resistant now. In the pinned topology
// (client → Cloudflare → origin proxy → app) both proxies APPEND, so
// x-forwarded-for arrives as `<spoof…>, <true client>, <CF edge>` and the
// leftmost hop is whatever the sender typed. Trust order:
// cf-connecting-ip, then x-forwarded-for rightmost-minus-one, then the
// socket peer — never the leftmost hop, never x-real-ip.
it("takes x-forwarded-for rightmost-minus-one, never the leftmost hop", () => {
const event = createMockEvent({ const event = createMockEvent({
headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" } headers: {
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
}
}); });
const ip = getClientIP(event); const ip = getClientIP(event);
expect(ip).toBe("192.168.1.1"); expect(ip).toBe("192.168.1.1");
}); });
it("should extract IP from x-real-ip header", () => { it("prefers cf-connecting-ip (Cloudflare overwrites it at the edge)", () => {
const event = createMockEvent({
headers: {
"cf-connecting-ip": "203.0.113.7",
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
}
});
const ip = getClientIP(event);
expect(ip).toBe("203.0.113.7");
});
it("a rotating leftmost x-forwarded-for does not change the resolved client", () => {
// The spoof the old read fell for: same Cloudflare-recorded client,
// different sender-chosen prefix per request. The key must be stable,
// or each rotation mints a fresh rate-limit bucket.
const first = getClientIP(
createMockEvent({
headers: {
"cf-connecting-ip": "203.0.113.7",
"x-forwarded-for": "1.1.1.1, 203.0.113.7, 10.0.0.1"
}
})
);
const second = getClientIP(
createMockEvent({
headers: {
"cf-connecting-ip": "203.0.113.7",
"x-forwarded-for": "2.2.2.2, 203.0.113.7, 10.0.0.1"
}
})
);
expect(first).toBe("203.0.113.7");
expect(second).toBe(first);
});
it("ignores x-real-ip entirely (client-settable in this topology)", () => {
const event = createMockEvent({ const event = createMockEvent({
headers: { "x-real-ip": "192.168.1.2" } headers: { "x-real-ip": "192.168.1.2" }
}); });
const ip = getClientIP(event); const ip = getClientIP(event);
expect(ip).toBe("192.168.1.2"); expect(ip).toBe("unknown");
}); });
it("should prefer x-forwarded-for over x-real-ip", () => { it("ignores a client-supplied single-hop x-forwarded-for with no trusted proxy above it", () => {
// One hop only = the sender's own invention; discounting the trusted
// hop leaves nothing to believe.
const event = createMockEvent({ const event = createMockEvent({
headers: { "x-forwarded-for": "6.6.6.6" }
});
const ip = getClientIP(event);
expect(ip).toBe("unknown");
});
it("a non-loopback socket peer is the client and beats every header", () => {
const event = createMockEvent({
remoteAddress: "198.51.100.23",
headers: { headers: {
"x-forwarded-for": "192.168.1.1", "cf-connecting-ip": "6.6.6.6",
"x-real-ip": "192.168.1.2" "x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1",
"x-real-ip": "6.6.6.6"
}
});
const ip = getClientIP(event);
expect(ip).toBe("198.51.100.23");
});
it("a loopback socket peer lets the forwarded headers speak", () => {
const event = createMockEvent({
remoteAddress: "127.0.0.1",
headers: {
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
} }
}); });
@@ -198,13 +269,23 @@ describe("Rate Limiting", () => {
it("should handle IPv6 addresses", () => { it("should handle IPv6 addresses", () => {
const event = createMockEvent({ const event = createMockEvent({
headers: { headers: {
"x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" "x-forwarded-for":
"2001:0db8:85a3:0000:0000:8a2e:0370:7334, 10.0.0.1"
} }
}); });
const ip = getClientIP(event); const ip = getClientIP(event);
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
}); });
it("rejects a candidate hop that is not an address", () => {
const event = createMockEvent({
headers: { "x-forwarded-for": "not-an-ip, 10.0.0.1" }
});
const ip = getClientIP(event);
expect(ip).toBe("unknown");
});
}); });
describe("rateLimitLogin", () => { describe("rateLimitLogin", () => {

View File

@@ -14,12 +14,15 @@ export function createMockEvent(options: {
cookies?: Record<string, string>; cookies?: Record<string, string>;
method?: string; method?: string;
url?: string; url?: string;
/** Socket peer the server observed; omit for a peer-less synthetic event. */
remoteAddress?: string;
}): H3Event { }): H3Event {
const { const {
headers = {}, headers = {},
cookies = {}, cookies = {},
method = "POST", method = "POST",
url = "http://localhost:3000/" url = "http://localhost:3000/",
remoteAddress
} = options; } = options;
// Build the cookie header string from the cookies object only // Build the cookie header string from the cookies object only
@@ -49,7 +52,10 @@ export function createMockEvent(options: {
headers: headersObj, headers: headersObj,
node: { node: {
req: { req: {
headers: allHeaders headers: allHeaders,
...(remoteAddress
? { socket: { remoteAddress } }
: {})
}, },
res: { res: {
cookies: {} cookies: {}

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from "bun:test";
import {
resolveTrustedClientIp,
isIpAddress,
isLoopbackAddress,
UNKNOWN_CLIENT_IP,
type ClientIpEventLike
} from "./trusted-client-ip";
/**
* Unit coverage for the security-path client-IP read (FRE-371). The caller
* suites (rate-limit.test.ts, report.test.ts) pin the behaviour through
* getClientIP and the route; this file pins the helper's own edges: the peer
* forms a real socket can present, the APIEvent wrapper, and failing closed.
*/
function event(opts: {
headers?: Record<string, string>;
remoteAddress?: string;
wrapped?: boolean;
}): ClientIpEventLike {
const inner: ClientIpEventLike = {
...(opts.remoteAddress
? { node: { req: { socket: { remoteAddress: opts.remoteAddress } } } }
: {}),
request: { headers: new Headers(opts.headers ?? {}) }
};
// An APIEvent wraps its H3Event as `nativeEvent` (routes/api/* shape).
return opts.wrapped ? { nativeEvent: inner } : inner;
}
describe("resolveTrustedClientIp", () => {
it("trust order: non-loopback peer, then cf-connecting-ip, then rightmost-minus-one x-forwarded-for", () => {
// Peer wins.
expect(
resolveTrustedClientIp(
event({
remoteAddress: "198.51.100.23",
headers: {
"cf-connecting-ip": "203.0.113.7",
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
}
})
)
).toBe("198.51.100.23");
// Loopback peer → Cloudflare's record.
expect(
resolveTrustedClientIp(
event({
remoteAddress: "127.0.0.1",
headers: {
"cf-connecting-ip": "203.0.113.7",
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
}
})
)
).toBe("203.0.113.7");
// No CF header → the hop below the trusted proxy.
expect(
resolveTrustedClientIp(
event({
remoteAddress: "127.0.0.1",
headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1" }
})
)
).toBe("203.0.113.7");
});
it("reads the wrapped H3Event of an APIEvent-shaped input", () => {
expect(
resolveTrustedClientIp(
event({
wrapped: true,
remoteAddress: "127.0.0.1",
headers: { "cf-connecting-ip": "203.0.113.7" }
})
)
).toBe("203.0.113.7");
});
it("never returns a truncated address", () => {
// The full /128 is the bucket key; a truncated one would pool attackers
// with their neighbours.
const ip = resolveTrustedClientIp(
event({
remoteAddress: "127.0.0.1",
headers: { "cf-connecting-ip": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" }
})
);
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
});
it("fails closed to the stricter unknown key", () => {
// One hop = the sender's own invention; a non-address hop; nothing at all.
expect(
resolveTrustedClientIp(
event({ headers: { "x-forwarded-for": "6.6.6.6" } })
)
).toBe(UNKNOWN_CLIENT_IP);
expect(
resolveTrustedClientIp(
event({ headers: { "cf-connecting-ip": "not-an-ip" } })
)
).toBe(UNKNOWN_CLIENT_IP);
expect(resolveTrustedClientIp(event({}))).toBe(UNKNOWN_CLIENT_IP);
});
it("recognises the loopback forms a real peer presents", () => {
expect(
resolveTrustedClientIp(
event({
// IPv6-mapped IPv4 loopback from a node socket.
remoteAddress: "::ffff:127.0.0.1",
headers: { "cf-connecting-ip": "203.0.113.7" }
})
)
).toBe("203.0.113.7");
expect(
resolveTrustedClientIp(
event({
remoteAddress: "::1",
headers: { "cf-connecting-ip": "203.0.113.7" }
})
)
).toBe("203.0.113.7");
// A non-loopback peer that merely looks odd is still the client.
expect(
resolveTrustedClientIp(
event({
remoteAddress: "::ffff:203.0.113.7",
headers: { "cf-connecting-ip": "6.6.6.6" }
})
)
).toBe("203.0.113.7");
});
it("never believes x-real-ip", () => {
expect(
resolveTrustedClientIp(
event({ headers: { "x-real-ip": "203.0.113.7" } })
)
).toBe(UNKNOWN_CLIENT_IP);
});
});
describe("address helpers", () => {
it("isIpAddress", () => {
expect(isIpAddress("203.0.113.7")).toBe(true);
expect(isIpAddress("::1")).toBe(true);
expect(isIpAddress("::ffff:203.0.113.7")).toBe(true);
expect(isIpAddress("2001:db8::1")).toBe(true);
expect(isIpAddress("not-an-ip")).toBe(false);
expect(isIpAddress("256.1.1.1")).toBe(false);
expect(isIpAddress("")).toBe(false);
expect(isIpAddress(null)).toBe(false);
expect(isIpAddress(undefined)).toBe(false);
});
it("isLoopbackAddress", () => {
expect(isLoopbackAddress("127.0.0.1")).toBe(true);
expect(isLoopbackAddress("127.255.0.3")).toBe(true);
expect(isLoopbackAddress("::1")).toBe(true);
expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true);
expect(isLoopbackAddress("203.0.113.7")).toBe(false);
expect(isLoopbackAddress("::2")).toBe(false);
// Unparseable input is not loopback — fails closed.
expect(isLoopbackAddress("garbage")).toBe(false);
});
});

View File

@@ -0,0 +1,250 @@
/**
* The client address, resolved for security decisions (FRE-371).
*
* This is the ONLY client-IP read allowed to feed a security decision — rate
* limiting, abuse quotas, account lockout, audit trails. It exists separately
* from `request-client.ts`'s `resolveClientIp` (the analytics read) on
* purpose:
*
* - `resolveClientIp` carries a standing rule (FRE-369 review, 1d9287a):
* it never feeds a security decision. Its blast radius is a poisoned
* analytics row; ours is granting or denying access.
* - Rate limiting needs the UN-truncated address; the analytics module only
* ever hands out truncated prefixes.
* - `request-client.ts` ships on FRE-366's branch, which is under review;
* a security fix must not merge-depend on an analytics diff.
*
* The address-parsing primitives below are therefore a deliberate port of the
* reviewed ones, not an import. If the two ever drift, this file's version is
* the one security paths follow.
*
* WHY the old read was spoofable: `security.ts`'s `getClientIP` took the
* LEFTMOST `x-forwarded-for` hop, believing the header "cannot be spoofed".
* In the pinned topology — client → Cloudflare → same-host origin proxy →
* app — both proxies *append*, so the header arrives as
* `<client-supplied spoof…>, <true client>, <Cloudflare edge>` and the
* leftmost hop is exactly the one the sender chose. Rotating
* `X-Forwarded-For: <random>` per request minted a fresh rate-limit bucket
* and unlimited bug-report quota. `x-real-ip` was read unconditionally too;
* nothing in this topology overwrites it, so it is equally client text and is
* no longer read at all.
*
* Trust order (matches the convention FRE-366 landed after its security
* review, re-derived here for security stakes):
*
* 1. The direct socket peer, when it is a real routable address: it IS the
* client (dev, direct hit), and any forwarded header from it is the
* client describing itself — ignored.
* 2. `cf-connecting-ip` — reachable only when the peer is the loopback
* origin proxy (or unreadable, e.g. a synthetic event in tests).
* Cloudflare *overwrites* this header at the edge, so a client-supplied
* value never survives. Must parse as an address.
* 3. `x-forwarded-for` counted from the RIGHT: drop the rightmost hop (the
* Cloudflare edge address the origin proxy appended) and take the hop
* before it. Never the leftmost. Must parse as an address.
* 4. "unknown" — callers give unknown-IP keys stricter shared limits, so
* an unresolvable client fails toward a tighter bucket, never a looser
* one.
*
* Deploy-side precondition this code cannot verify (same as the analytics
* read): the origin must be reachable only through Cloudflare and the
* same-host proxy. If the app is directly internet-reachable, a client can
* set every header read here and no in-app read can recover the truth.
*/
/** Cloudflare's "who actually connected" header: overwritten at the edge. */
const CF_CONNECTING_IP = "cf-connecting-ip";
const X_FORWARDED_FOR = "x-forwarded-for";
/**
* Hops discounted from the right of `x-forwarded-for` before the value is
* taken: the Cloudflare edge address the origin proxy appended. Pinned to 1
* because there is exactly one trusted proxy between Cloudflare and the app;
* raise it only if the topology gains a trusted hop.
*/
const FORWARDED_HOPS_TO_DISCOUNT = 1;
/** Returned when no trustworthy address exists. Callers rate-limit it harder. */
export const UNKNOWN_CLIENT_IP = "unknown";
/**
* The shapes this module reads: an H3Event (`security.ts` callers) or a
* SolidStart APIEvent (`routes/api/*`), which wraps its H3Event as
* `nativeEvent`. Every field is optional because test doubles are minimal;
* anything unreadable simply falls through to the next trust step.
*/
export interface ClientIpEventLike {
request?: { headers?: unknown };
headers?: unknown;
node?: {
req?: {
headers?: Record<string, unknown>;
socket?: { remoteAddress?: string };
};
};
nativeEvent?: ClientIpEventLike;
}
/**
* The client address for a security decision, per the trust order above.
* Never truncated: rate-limit buckets are per-address, and truncating would
* let an attacker share a bucket with the innocents behind their /24.
*/
export function resolveTrustedClientIp(event: ClientIpEventLike): string {
// An APIEvent's socket and (authoritative) headers live on the wrapped
// H3Event; a bare APIEvent-shaped double without one is read as-is.
const source = event.nativeEvent ?? event;
const peer = directPeer(source);
if (peer && isIpAddress(peer) && !isLoopbackAddress(peer)) return peer;
// The peer is the loopback origin proxy (or unreadable): forwarded headers
// may be believed, in the order above.
const connecting = cleanHop(headerValue(source, CF_CONNECTING_IP));
if (isIpAddress(connecting)) return connecting;
const hops = (headerValue(source, X_FORWARDED_FOR) ?? "")
.split(",")
.map(cleanHop)
.filter(Boolean);
const index = hops.length - 1 - FORWARDED_HOPS_TO_DISCOUNT;
if (index >= 0 && isIpAddress(hops[index])) return hops[index]!;
return UNKNOWN_CLIENT_IP;
}
/** The socket peer, IPv6-mapped IPv4 flattened (`::ffff:1.2.3.4` → `1.2.3.4`). */
function directPeer(event: ClientIpEventLike): string | null {
try {
const addr = event.node?.req?.socket?.remoteAddress;
return addr ? addr.replace(/^::ffff:/, "") : null;
} catch {
return null;
}
}
/**
* Header read tolerant of every event shape in play: Fetch-style
* `request.headers.get`, an h3 `event.headers` (Headers or plain object), and
* the raw node request headers. Header names are case-insensitive per HTTP;
* the plain-object fallbacks lowercase to match how node and the test doubles
* store them.
*/
function headerValue(event: ClientIpEventLike, name: string): string | null {
try {
const fromHeaders = (h: unknown): string | null => {
if (!h) return null;
if (typeof (h as Headers).get === "function") {
return (h as Headers).get(name) ?? null;
}
if (typeof h === "object") {
const record = h as Record<string, unknown>;
const value = record[name] ?? record[name.toLowerCase()];
return typeof value === "string" ? value : null;
}
return null;
};
return (
fromHeaders(event.request?.headers) ??
fromHeaders(event.headers) ??
fromHeaders(event.node?.req?.headers)
);
} catch {
return null;
}
}
function cleanHop(value: string | null | undefined): string {
return (value ?? "").trim();
}
// ── Addresses: parse and validate ───────────────────────────────────────────
// Ported from the reviewed `request-client.ts` (FRE-366 @ 1d9287a); see the
// header comment for why this is a copy and not an import.
/** Any address this file understands: a dotted quad or an IPv6 literal. */
export function isIpAddress(value: string | null | undefined): boolean {
return value !== null && value !== undefined && addressBytes(value) !== null;
}
/**
* Whether an address is the loopback the production proxy presents.
*
* Parses rather than string-matches, so the forms a real peer can present in —
* `::ffff:127.0.0.53`, `0:0:0:0:0:0:0:1`, an IPv4-mapped literal — are all
* recognised. An address this does not parse is *not* loopback, which fails
* closed: forwarded headers from an unrecognised peer are ignored rather than
* believed (a non-loopback peer short-circuits before headers are read).
*/
export function isLoopbackAddress(ip: string): boolean {
const bytes = addressBytes(ip);
if (!bytes) return false;
if (bytes.length === 4) return bytes[0] === 127;
// IPv6 loopback ::1 — and ::ffff:127/8, which is loopback too.
const mapped = ipv4FromBytes(bytes);
if (mapped) return isLoopbackAddress(mapped);
const tail = bytes[15]!;
return bytes.slice(0, 15).every((byte) => byte === 0) && tail === 1;
}
function isIpv4(value: string): boolean {
const parts = value.split(".");
if (parts.length !== 4) return false;
return parts.every(
(part) => /^\d{1,3}$/.test(part) && Number(part) <= 255 && !/^0\d/.test(part)
);
}
/** 4 bytes for IPv4, 16 for IPv6 (including `::ffff:a.b.c.d`), else null. */
function addressBytes(value: string): number[] | null {
const raw = value.trim().replace(/^\[|\]$/g, "").split("%")[0]!;
if (isIpv4(raw)) return raw.split(".").map(Number);
if (!raw.includes(":")) return null;
const halves = raw.split("::");
if (halves.length > 2) return null;
const compressed = halves.length === 2;
const head = groupsToBytes(halves[0] ?? "");
const tail = groupsToBytes(halves[1] ?? "");
if (!head || !tail) return null;
const total = head.length + tail.length;
if (compressed) {
if (total > 8) return null;
return [...head, ...new Array(8 - total).fill(0), ...tail].flatMap(split16);
}
if (total !== 8) return null;
return [...head, ...tail].flatMap(split16);
}
/** One side of `::` as 16-bit groups; a trailing `1.2.3.4` counts as two. */
function groupsToBytes(side: string): number[] | null {
if (side === "") return [];
const out: number[] = [];
for (const part of side.split(":")) {
if (/^[0-9a-fA-F]{1,4}$/.test(part)) {
out.push(parseInt(part, 16));
} else if (isIpv4(part)) {
const o = part.split(".").map(Number);
out.push((o[0]! << 8) | o[1]!, (o[2]! << 8) | o[3]!);
} else {
return null;
}
}
return out;
}
function split16(group: number): number[] {
return [(group >> 8) & 0xff, group & 0xff];
}
/** The embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`), else null. */
function ipv4FromBytes(bytes: number[]): string | null {
const prefix = bytes.slice(0, 10).every((byte) => byte === 0);
const marker = bytes[10] === 0xff && bytes[11] === 0xff;
if (!prefix || !marker) return null;
return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`;
}