10 Commits

Author SHA1 Message Date
91bedd915d docs(deploy): name the retention cron the first mirror fires, and probe the root (FRE-1707)
Two findings from the FRE-1695 review (approved at df788cb6).

1. The mirrored deploy activates a destructive cron the deploy docs never
   mentioned. vercel.json carries two crons: /api/the-nook/lifecycle (13:00
   UTC), already on the tree Vercel builds, and /api/analytics-retention
   (12:00 UTC, 1d9287a / FRE-369), which exists only on Gitea master. The first
   deploy that follows the mirror starts calling it, and the next 12:00 UTC
   deletes every VisitorAnalytics row older than the window —
   ANALYTICS_RETENTION_DAYS, 365 by default — irreversibly and unstaged. The
   behaviour is intended; the silence was not, because the operator is the one
   who presses the button. docs/deploy-source.md "Failure modes" now names the
   cron, the ANALYTICS_RETENTION_SECRET ?? CRON_SECRET bearer check, the 365-day
   default, the fails-closed-without-a-secret path, and that the first run after
   enabling prunes at that default.

2. scripts/verify-deploy.sh could read green with a broken root deploy: it
   probed nook.freno.me, nessa.freno.me/terms and the two remotes, never
   freno.me itself — and FRE-1695's acceptance names the root domain. Adds a
   ROOT_HOST (default https://freno.me) status check ahead of the subdomain
   probes: expects a direct 200, no -L, so a redirect reads as a failure. It
   deliberately does not probe https://freno.me/terms — SITEMAP_ROUTES.main has
   no /terms; Terms of Use is a Nessa surface, so a root /terms 404 is the
   design. The script stays read-only and credential-free.
2026-11-10 03:02:57 +00:00
df788cb689 docs(deploy): one canonical repo, a mirror to Vercel, and a read-only probe (FRE-1695)
Records the measured divergence, the decision (Gitea canonical, GitHub
mikefreno/freno-dev as the push mirror Vercel builds), why Vercel cannot
build from Gitea and why agents cannot write to GitHub, and the one-time
operator runbook.

scripts/verify-deploy.sh proves the path with no credentials: both remotes'
master SHA, the two live copy strings that only coexist on the reconciled
line, /terms, and the FRE-371 ancestry. Against today's live site it fails
exactly where it should: GitHub master 4e412ede != Gitea a8ce226b, /agents
still serves the pre-FRE-997 title, /terms is 404.
2026-11-09 20:46:59 +00:00
f371cf6192 FRE-1695: reconcile the GitHub deploy line into Gitea master
Production builds GitHub `mikefreno/freno-dev`; agents merge into Gitea
`Mike/freno-dev`. Around merge-base dd368d2 the two masters diverged: Gitea
was 12 commits ahead (FRE-371, FRE-366, FRE-202, FRE-997, FRE-1670), GitHub
2 ahead (NOOK-1037 landing refresh, NOOK-1038 scoped-remote restore). Every
Gitea merge since dd368d2 was therefore undeployed.

This merge brings the GitHub line into Gitea so the two become one line,
with the Gitea master as the fast-forward ancestor of GitHub master. No
content is dropped from either side.

Conflicts and how they were resolved (only two, both copy contracts):

- `src/components/nook/FeatureBreakdowns.tsx`: took NOOK-1038's refreshed
  island bullets and kept FRE-997's "18 agents" bullet, because the roster
  on /agents lists all eighteen (agents.test.ts asserts equality).
- `src/routes/nook/remote-claims.test.ts`: rewrote as one file that holds
  both contracts — NOOK-1038 supersedes FRE-323's blanket remote hold
  (scoped one-click setup for Tailscale and authorized known hosts is a
  supported claim; blanket promises stay banned), and FRE-997's coverage
  correction stands (18 is publishable, stale thirteens stay banned). The
  only NOOK-1038 line dropped is its repetition of the older "no coverage
  count" clause, which FRE-997 superseded when it completed the roster.
  `marketing/positioning.md` already records both rows side by side.

Tests: bun test src/routes/nook -> 38 pass, 0 fail. Full suite matches the
pre-merge baseline failure set (see docs/deploy-source.md).
2026-11-09 20:42:33 +00:00
a8ce226b77 Merge pull request 'nessa: publish /terms and the support address the app promises (FRE-1670)' (#8) 2026-11-09 11:16:30 -05:00
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
4e412ede8e site: restore scoped one-click remote setup
[gpt-6.1-sol] A blanket restriction on remote claims hid the working setup flow for Tailscale and authorized known hosts and left the opening grid unbalanced. Restore the host-labelled session card and setup demo, and make the copy safeguards reflect the supported scope rather than deny the feature.

Tests: 4 restoration checks fail without the fix; 38 Nook tests pass; bun run build; Playwright at 1440/768/375px in both themes

NOOK-1038
2026-11-09 10:40:19 -05:00
8790cc155e site: refresh Nook demos and landing copy
[gpt-6.1-sol] The meetings, mixer and charging previews had drifted from the current app, while slogans obscured what the modules do. Match the shipped controls and behavior, reuse the existing demo machinery, and explain the landing-page features plainly without changing routing or purchase flows.

Tests: bun test src/routes/nook src/components/nook (36 passed)

NOOK-1037
2026-11-09 10:40:19 -05: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: #1
2026-11-04 10:58:26 -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
33 changed files with 2523 additions and 742 deletions

150
docs/deploy-source.md Normal file
View File

@@ -0,0 +1,150 @@
# The deploy line (FRE-1695)
Written 2026-11-09 by the CTO. Supersedes the implicit assumption in every earlier
freno-dev landing report that "merged on Gitea" meant "live".
## The defect
Production `freno.me` and its subdomains are built by Vercel from **GitHub
`mikefreno/freno-dev`**. Agents merge into **Gitea `Mike/freno-dev`**. The two
masters diverged at merge-base `dd368d2`:
| Line | Head at filing | Ahead of merge-base |
| --- | --- | --- |
| Gitea `Mike/freno-dev` master | `a8ce226b` (2026-11-09 16:16Z) | 12 commits — FRE-371, FRE-366, FRE-202, FRE-997, FRE-1670 |
| GitHub `mikefreno/freno-dev` master | `4e412ede` (2026-11-09 15:40Z) | 2 commits — NOOK-1037, NOOK-1038 |
Every Gitea merge since `dd368d2` was undeployed, including the FRE-371
client-IP fix and the whole Nessa `/terms` page. Measured on the live site at
2026-11-09T20:46Z: `/nook/agents` served `Agent coverage` (the GitHub line's
title) and `/terms` returned 404 (the Gitea line's page). No agent run can push
to GitHub — `/paperclip/.git-credentials` holds `git.freno.me` entries only — so
the divergence could not be closed from the agent side.
## Decision
**Gitea `Mike/freno-dev` is the canonical repository. GitHub
`mikefreno/freno-dev` is a mirror, and it is the tree Vercel builds.**
One line, one direction: Gitea master → GitHub master → Vercel → live.
### Why this shape and not the alternatives
- **Vercel cannot build from Gitea.** Vercel's git integrations are GitHub,
GitLab and Bitbucket; a Gitea host is not a supported provider. So "point
Vercel at the repo agents push to" is not a setting that exists. Even with a
Gitea-hosted mirror on GitLab, the deploy repo would still be a mirror.
- **Agents can only push to Gitea.** The per-agent credential is Gitea-only
(`git.freno.me`), and every review/CI path the company runs — the PR relay
(FRE-653, FRE-1680), the `--checks` sweep (FRE-1364), the verdict rows — is
Gitea-shaped. Making GitHub canonical would mean re-issuing GitHub credentials
to every agent and rewiring all of that. Keeping Gitea canonical keeps the
authoring side exactly as it is.
- **Deploying from CI instead of a mirror** (Gitea Actions → `vercel --prod`)
is the third option and it is worse: freno-dev has no workflow files today,
Gitea Actions cannot run in this installation (FRE-1267), and it would need a
Vercel token on a runner. A mirror needs one credential, in one place, and no
new CI.
### Reconcile first, then mirror
The mirror must fast-forward. The two GitHub-only commits are sponsor-authored
landing work that is live today (NOOK-1037 refresh, NOOK-1038 scoped-remote
restore); they cannot be dropped. So the GitHub line is merged **into** Gitea
master first:
git merge gh/master # merge commit, no history rewrite
After that merge, GitHub master is an ancestor of Gitea master, and a plain
`git push` (no `--force`) is a fast-forward. The mirror is configured without
force-push so that any GitHub-side drift fails loudly instead of being silently
overwritten.
Two conflicts were resolved in the merge, both copy contracts:
1. `src/components/nook/FeatureBreakdowns.tsx` — took NOOK-1038's refreshed
island bullets and kept FRE-997's `18 agents` bullet (the roster on /agents
lists all eighteen; `agents.test.ts` asserts equality).
2. `src/routes/nook/remote-claims.test.ts` — one file holding both contracts.
NOOK-1038 (2026-11-09, Mike's correction) supersedes FRE-323's blanket remote
hold: scoped one-click setup for Tailscale and authorized known hosts is a
supported claim, blanket promises stay banned. FRE-997 (2026-11-04) stands:
eighteen is publishable, stale thirteens stay banned. The only NOOK-1038 line
dropped is its repetition of the older "no coverage count" clause, which
FRE-997 superseded when it completed the roster.
## How a merge becomes a deploy
1. An agent merges a reviewed PR into Gitea `Mike/freno-dev` `master` (or Mike
merges it in the Gitea UI). Nothing else changes for the engineer.
2. A **Gitea push mirror** on `Mike/freno-dev` pushes `master` to
`https://github.com/mikefreno/freno-dev.git` with sync-on-commit enabled.
3. Vercel sees the GitHub push and builds production from GitHub `master`.
4. `scripts/verify-deploy.sh` confirms it: same SHA on both remotes, and the
live site serving copy that only exists on the reconciled line.
**Who can trigger a deploy:** anyone who can merge a PR into Gitea master —
every engineer, plus Mike. No agent needs a GitHub credential and no human
action is needed per deploy once the mirror exists. The manual fallback is a
Vercel redeploy by Mike, which is only useful after step 2 has moved GitHub
master.
**Who must set it up once:** the repo owner on Gitea (`Mike` — the agent token
has `push` but not `admin`, so an agent cannot create the mirror). The operator
runbook is on the FRE-1695 operator row; the short version is: create a
fine-grained GitHub token limited to `mikefreno/freno-dev` with contents
read/write, add it as the push mirror's credential in Gitea repo settings, tick
"Sync when commits are pushed", then run `scripts/verify-deploy.sh`.
Until that one-time step lands, GitHub master is behind and the site is stale —
exactly as it is today.
## Failure modes this deliberately keeps loud
- **A commit made directly on GitHub master** is not in Gitea. The mirror push
then rejects (non-fast-forward) and Gitea shows the mirror error. Do not commit
to GitHub `master`; land it in Gitea and let it mirror.
- **A merge that is only a Gitea merge** is not live until the mirror runs. Check
`scripts/verify-deploy.sh` before calling a site change published.
- **The first mirrored deploy switches on a cron that deletes production
rows.** `vercel.json` carries two crons. `/api/the-nook/lifecycle`
(13:00 UTC) is already on the tree Vercel builds today, so it runs.
`/api/analytics-retention` (12:00 UTC, added in `1d9287a`, FRE-369) is
only on Gitea master: the first deploy that follows the mirror starts
calling it, and the next 12:00 UTC runs
`DELETE FROM VisitorAnalytics WHERE created_at < datetime('now','-365 days')`
against the production database. Not staged, not reversible — whatever is
older than the window
is gone the first time it fires. The window is
`ANALYTICS_RETENTION_DAYS`, and unset, non-positive, or over 730 each
resolve to the 365-day default (`src/server/analytics-retention.ts`).
365 is the conservative end — picked so that switching the schedule on is
not itself a data-loss event — but conservative still means *over a year
old gets deleted*. Set the window in Vercel **before** the first mirrored
deploy if 365 is not what you want. The cron call is authorized with
`Authorization: Bearer <ANALYTICS_RETENTION_SECRET ?? CRON_SECRET>`
(`src/routes/api/analytics-retention.ts`), and the endpoint fails closed:
with no secret configured every call is a 401, so a missing secret is a
table that quietly stops being pruned, never an unauthenticated bulk
delete. Vercel supplies `CRON_SECRET` to its own crons and production
already runs the 13:00 job, so assume the secret is there and it runs.
- **The FRE-371 fix's actual exploitability in production** depends on how the
edge sets `x-forwarded-for`/`cf-connecting-ip`. That is measured and gated by
the Security Engineer, not assumed here.
## Verifying
scripts/verify-deploy.sh
It is read-only and needs no credentials — that is what makes it safe to run
from an agent run as well as by the operator, and it is why the checks stay
read-only. It checks both remotes' master SHA, that `https://freno.me/`
answers a direct 200, the live Nook/Nessa copy, and (when the commit is
present locally) that `2fd2a4a` is an ancestor of the deployed commit.
The root probe is there because every other check can stay green while the
root deploy is broken. It is status-only, and it deliberately does not probe
`https://freno.me/terms`: `SITEMAP_ROUTES.main` in
`src/lib/sitemap-routes.ts` has no `/terms` — Terms of Use is a Nessa
surface (`nessa.freno.me/terms`), so a 404 on the root domain's `/terms` is
the design, not a defect.

View File

@@ -1,13 +1,8 @@
# Remote SSH copy — HELD, do not publish
# Remote SSH copy — historical unqualified wording
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
correction, and **not yet off the live site** — the deploy is blocked on a write
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
serves every sentence below until that push or a Vercel deploy lands.
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
verifies that flow, and a recording shows a remote agent joining the island.
Until then this file is the only home for these sentences.
[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring the remote-agent cards and corrected the old blanket restriction. One-click setup works for **Tailscale and authorized known hosts**. The current page describes that scope; this file preserves the old unqualified wording for reference, not for verbatim reuse.
[gpt-6.1-sol] The earlier FRE-323 removal treated SSH authorization needed by a new host as though it prevented one-click setup for hosts already authorized. Current evidence is `RemoteHostCatalog.discover`, `RemoteHostsController.setUp`, `RemoteMirror.apply`, and the existing `RemoteHostsControllerLiveTests.setUpLeavesItLive` test. No new live-host test run is claimed by this website change.
## What was removed
@@ -62,18 +57,8 @@ Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
One-click SSH setup
```
## What replaced it
## Current restoration
Nothing. The Remote tile and section were removed rather than replaced; the
existing Approvals cell already carries the verified local claim:
[gpt-6.1-sol] NOOK-1038 restores the collage's Remote tile, host-labelled session examples, and the detailed setup section. The collage says: “One-click setup for Tailscale and authorized known hosts.” The detailed section shows choosing a discovered host, clicking Set up, installation progress, and Live status.
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
## Restore checklist
1. FRE-320/FRE-205 have shipped the one-click install and removed the
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
3. A recording exists showing a remote agent joining the island.
4. CMO restores the copy above, then owns the launch plan for it — with an
owner and date per item — before anything goes back on the site.
[gpt-6.1-sol] The historical “Any SSH box” claim, universal agent-coverage bullet, and unqualified setup chip above are not restored. Discovery and an existing authorization path are not the same as granting access to a new machine. This does not limit or deny the working one-click flow for authorized hosts.

View File

@@ -1,30 +1,13 @@
# Why the remote claim is parked
# Remote setup marketing decision
The site was selling a setup path a buyer does not get.
[gpt-6.1-sol] **NOOK-1038 supersedes the blanket hold.** Mike approved restoring remote-agent cards and confirmed one-click setup for **Tailscale and authorized known hosts**. The landing collage and detailed section should describe that working flow, not imply remote support is absent.
FRE-320 measured today's actual path (2026-10-29): the one-click flow is
app-UI-only, needs a hand-edited `~/.ssh/config` for any host not already in ssh
config or on the tailnet, needs a one-time `ssh-copy-id` because the app runs ssh
with `BatchMode=yes`, and has no manual fallback for minting the per-host
credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
unverified and demo-gated. The CTO named it the largest launch risk because it
was already in front of buyers.
## Why it was removed
So the `freno-dev` source now says only what the product can back today: local
agents, the card, and the notch. That correction is committed locally and is
**not deployed** — the live page still carries the remote sentences until the
push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is
parked here with the FRE-320/FRE-205 work so it can return the day the one-click
install ships and is recorded.
[gpt-6.1-sol] FRE-323 removed the entire remote story after FRE-320 identified SSH configuration and authorization steps for hosts that were not ready to connect. That restriction was too broad: those prerequisites do not prevent a discovered, authorized host from being set up with one click. The historical unqualified copy remains in `copy.md` so the change in scope is explicit.
## What has to be true before it returns
## Current evidence and scope
| Requirement | Owner | Date |
|---|---|---|
| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships |
| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves |
| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore |
| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true |
[gpt-6.1-sol] `RemoteHostCatalog.discover` reads SSH config and Tailscale peers. `RemoteHostsController.setUp` binds the bridge and calls `RemoteMirror.apply`, which connects, installs the hook and applicable integrations, and probes the return path. The existing `RemoteHostsControllerLiveTests.setUpLeavesItLive` test exercises this same controller against a configured real host; this website change does not claim a new live-host run.
Nothing in this folder is publishable. `marketing/approved/` remains the only
place a human publishes from.
[gpt-6.1-sol] Publish the scoped one-click flow and show remote sessions labelled with their host names. Do not replace it with a “manual setup only” disclaimer. Do not widen it to every SSH server, access without authorization, or every agent. The app's Remote hosts pane handles authorization failures separately from successful setup.

View File

@@ -115,11 +115,13 @@ one thing forbidden: say "TestFlight beta" when the phone exists in a sentence a
all, and never claim approvals-from-phone as a shipped capability without its own
CTO verdict — no "(coming soon)" parentheticals either way.
Remote SSH is **held**, not approved: the one-click install has not
been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
recording shows a remote agent joining the notch.
[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring remote-agent marketing.
One-click setup works for **Tailscale and authorized known hosts**. Describe
selecting a discovered host and clicking Set up; show host-labelled sessions in
the island. `RemoteHostsController.setUp` runs installation and a return-path
probe. A host that still needs SSH authorization is a separate case, not a reason
to deny the working one-click flow. `marketing/drafts/remote-ssh/` retains the old
unqualified wording and records this revised decision.
### 5. Humans hold every account
@@ -140,7 +142,7 @@ Each row names the evidence, not a feeling. Re-read the verdict before using a r
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
| Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable. |
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
| One-click remote setup for Tailscale and authorized known hosts | **Approved by Mike — NOOK-1038** | [gpt-6.1-sol] Restores the scoped flow: host discovery, `RemoteHostsController.setUp`, `RemoteMirror.apply`, and host-labelled sessions. Supersedes the blanket FRE-323 hold, not the requirement for SSH authorization. |
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |

101
scripts/verify-deploy.sh Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Live probe for the freno-dev deploy path (FRE-1695). Read-only; no credentials.
#
# Proves, in one run:
# 1. Gitea master and GitHub master are the same commit (the mirror converged).
# 2. The root domain answers on its own terms: https://freno.me/ returns a direct 200,
# so a broken root deploy cannot read as green while the subdomains look fine.
# 3. The live site serves copy that only exists on the reconciled line:
# NOOK-1038's scoped remote claim (GitHub-only before the merge) *and*
# FRE-997's "Supported agents" (Gitea-only before the merge). Both at once
# can only come from the merge commit.
# 4. The served tree therefore contains FRE-371 (`2fd2a4a`), which is an
# ancestor of that merge — checked locally when the commit is in the object
# store, so run this from a freno-dev checkout.
#
# Usage: scripts/verify-deploy.sh [expected-gitea-sha]
set -uo pipefail
GITEA_REMOTE="${GITEA_REMOTE:-https://git.freno.me/Mike/freno-dev.git}"
GITHUB_REMOTE="${GITHUB_REMOTE:-https://github.com/mikefreno/freno-dev.git}"
ROOT_HOST="${ROOT_HOST:-https://freno.me}"
NOOK_HOST="${NOOK_HOST:-https://nook.freno.me}"
NESSA_HOST="${NESSA_HOST:-https://nessa.freno.me}"
fail=0
ok() { printf 'ok %s\n' "$1"; }
bad() { printf 'FAIL %s\n' "$1"; fail=1; }
echo "== remotes =="
gitea_sha=$(git ls-remote "$GITEA_REMOTE" refs/heads/master | cut -f1)
github_sha=$(git ls-remote "$GITHUB_REMOTE" refs/heads/master | cut -f1)
printf 'gitea master %s\ngithub master %s\n' "${gitea_sha:-<none>}" "${github_sha:-<none>}"
[ -n "$gitea_sha" ] && ok "gitea master readable" || bad "gitea master unreadable"
if [ -n "$gitea_sha" ] && [ "$gitea_sha" = "$github_sha" ]; then
ok "github master is the same commit (mirror converged)"
else
bad "github master differs from gitea master — the mirror has not run"
fi
if [ -n "${1:-}" ]; then
if [ "$1" = "$gitea_sha" ]; then ok "gitea master is the expected commit $1"
else bad "gitea master is $gitea_sha, expected $1"; fi
fi
echo
echo "== live copy =="
# The root is every other surface's entry point and deploys from the same tree, so it is
# probed first. No -L: a redirect away from 200 is the failure we want to see, not a pass.
root_code=$(curl -sS -o /dev/null -w '%{http_code}' "$ROOT_HOST/" 2>/dev/null)
if [ "$root_code" = "200" ]; then
ok "$ROOT_HOST/ serves 200"
elif [ -z "$root_code" ] || [ "$root_code" = "000" ]; then
bad "$ROOT_HOST/ gave no response, expected 200"
else
bad "$ROOT_HOST/ returned $root_code, expected 200 (green subdomains do not cover the root)"
fi
page=$(curl -sS "$NOOK_HOST/" 2>/dev/null)
case "$page" in
*"One-click setup for Tailscale and authorized known hosts"*)
ok "nook landing carries NOOK-1038's scoped remote claim" ;;
*) bad "nook landing is missing NOOK-1038's scoped remote claim" ;;
esac
agents=$(curl -sS "$NOOK_HOST/agents" 2>/dev/null)
case "$agents" in
*"Supported agents"*) ok "nook /agents carries FRE-997's restored coverage title" ;;
*) bad "nook /agents still serves the pre-FRE-997 title" ;;
esac
terms_code=$(curl -sS -o /tmp/verify-deploy-terms.$$ -w '%{http_code}' "$NESSA_HOST/terms" 2>/dev/null)
if [ "$terms_code" = "200" ] && grep -q 'nessa-support@freno.me' /tmp/verify-deploy-terms.$$; then
ok "nessa /terms is published and names the support address (FRE-1670)"
else
bad "nessa /terms returned $terms_code without the support address (FRE-1670 undeployed)"
fi
rm -f /tmp/verify-deploy-terms.$$
echo
echo "== ancestry =="
for commit in 2fd2a4a 7fde259; do
if git cat-file -e "${commit}^{commit}" 2>/dev/null; then
if git merge-base --is-ancestor "$commit" "$gitea_sha" 2>/dev/null; then
ok "$commit is an ancestor of gitea master"
else
bad "$commit is NOT an ancestor of gitea master"
fi
else
printf 'skip %s not in this clone; run from a freno-dev checkout to check ancestry\n' "$commit"
fi
done
echo
if [ "$fail" -eq 0 ]; then
echo "PASS — the deploy follows gitea master and the served tree contains FRE-371."
else
echo "FAIL — see the lines above."
fi
exit "$fail"

View File

@@ -761,6 +761,14 @@ a.hover-underline-animation:hover::after {
animation: nook-pulse 0.8s steps(2, jump-none) infinite;
}
/* Directional dashes in the power-flow preview. */
.nook-power-flow {
animation: nook-power-flow 1.6s linear infinite;
}
@keyframes nook-power-flow {
to { stroke-dashoffset: -21; }
}
/* Live signal inside a mixer fader */
.nook-meter {
transform-origin: left center;
@@ -840,6 +848,7 @@ a.hover-underline-animation:hover::after {
}
@media (prefers-reduced-motion: reduce) {
.nook-power-flow,
.nook-meter,
.nook-wave,
.nook-progress,

View File

@@ -10,7 +10,6 @@ import {
IslandPill,
AgentDotGrid,
CALM,
WARM,
ACCENT,
CRITICAL,
ModuleCard,
@@ -24,9 +23,10 @@ import {
CODEX
} from "./ModuleMocks";
import { useDemoLoop } from "./useDemoLoop";
import { BatteryDemo, CalendarDemo, MixerDemo } from "./UtilityDemos";
import {
TerminalReplyDemo,
MixerDemo,
RemoteHostDemo,
CaptureDemo,
FloatingActionsDemo
} from "./MoreDemos";
@@ -168,11 +168,11 @@ function FleetDemo() {
{phase() === "flare"
? "Codex needs your input"
: phase() === "resolve"
? "Answer delivered — agents back to work"
: "Fleet running calmly"}
? "Reply sent. Agents running."
: "Agents running"}
</p>
<p class="mt-0.5 text-[11px] text-white/50">
{phase() === "flare" ? "question · in the island now" : "\u00a0"}
{phase() === "flare" ? "Question waiting in the island" : "\u00a0"}
</p>
</div>
</div>
@@ -234,7 +234,7 @@ function ApprovalDemo() {
prompt="harden the bridge"
state={
asking()
? "Approve Bash — swift test --filter Bridge?"
? "Approve Bash: swift test --filter Bridge?"
: "Running swift test --filter Bridge…"
}
status={asking() ? "attention" : "running"}
@@ -408,313 +408,6 @@ function FansDemo() {
);
}
/* ── Demo 4: the charge ceiling holds, top-up overrules ───── */
function BatteryDemo() {
const [level, setLevel] = createSignal(62);
const [phase, setPhase] = createSignal<"charge" | "held" | "topup">("charge");
onMount(() => {
let frame = 0;
const timers: ReturnType<typeof setTimeout>[] = [];
const ramp = (target: number, durationMs: number) => {
cancelAnimationFrame(frame);
const start = level();
const startTime = performance.now();
const tick = (now: number) => {
const t = Math.min((now - startTime) / durationMs, 1);
setLevel(start + (target - start) * t);
if (t < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
};
const loop = () => {
setLevel(62);
setPhase("charge");
ramp(80, 2600);
timers.push(setTimeout(() => setPhase("held"), 2800));
timers.push(
setTimeout(() => {
setPhase("topup");
ramp(100, 2600);
}, 5200)
);
};
loop();
const interval = setInterval(loop, 9600);
onCleanup(() => {
clearInterval(interval);
timers.forEach(clearTimeout);
cancelAnimationFrame(frame);
});
});
const pct = () => Math.round(level());
const watts = () => (phase() === "held" ? null : pct() >= 100 ? 9 : 38);
const subline = () =>
phase() === "held"
? "charging paused — at the limit"
: pct() >= 100
? "topped up — re-arms on unplug"
: phase() === "topup"
? `${watts()} W · top-up past the limit`
: `${watts()} W · ${Math.max(1, Math.round((100 - pct()) * 1.5))} min to full`;
return (
<ModuleCard class="mx-auto w-full max-w-[320px]">
<div class="space-y-3 text-left">
<div class="flex items-center gap-2">
<span class="text-[10px] font-semibold tracking-wide text-white/40 uppercase">
Battery
</span>
<span
class="ml-auto rounded-md px-2 py-0.5 font-mono text-[10px] font-semibold transition-transform duration-300"
style={{
color: "rgba(255,255,255,0.75)",
background: "rgba(255,255,255,0.08)",
transform: phase() === "held" ? "scale(1.12)" : "scale(1)"
}}
>
Limit 80%
</span>
</div>
<div class="flex items-baseline gap-1.5">
<span class="font-mono text-[22px] font-semibold text-white">
{pct()}%
</span>
<span class="text-[11px] text-white/50">{subline()}</span>
</div>
<div
class="relative h-[6px] overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{
width: `${pct()}%`,
background: pct() > 80 ? WARM : CALM,
transition: "background 0.4s"
}}
/>
<span
class="absolute top-0 h-full w-px"
style={{ left: "80%", background: "rgba(255,255,255,0.5)" }}
/>
</div>
<div
class="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors duration-500"
style={{
background:
phase() === "topup" ? "rgba(110,167,255,0.10)" : "transparent"
}}
>
<div class="flex-1">
<p class="text-[11px] font-semibold text-white/85">Top-up mode</p>
<p class="text-[9px] text-white/40">
charge past the limit while plugged in
</p>
</div>
<span
class="relative inline-flex h-[16px] w-[30px] items-center rounded-full transition-colors duration-300"
style={{
background:
phase() === "topup" ? ACCENT : "rgba(255,255,255,0.15)"
}}
>
<span
class="absolute h-[12px] w-[12px] rounded-full bg-white transition-all duration-300"
style={{ left: phase() === "topup" ? "16px" : "2px" }}
/>
</span>
</div>
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
<div class="flex items-center gap-2">
<svg
width="10"
height="13"
viewBox="0 0 10 13"
style={{
animation: watts()
? "nook-pulse 1.1s ease-in-out infinite"
: "none",
opacity: watts() ? 1 : 0.3
}}
>
<path
d="M6.2 0 0.8 7h2.6l-1 5.2L7.8 5.4H5l1.2-5.4z"
fill="#FFD23F"
/>
</svg>
<span class="text-[10px] text-white/50">
{phase() === "held"
? "holding — the pack stays young longer"
: phase() === "topup"
? "topping up — re-arms when you unplug"
: "on the way to the ceiling"}
</span>
</div>
</div>
</ModuleCard>
);
}
/* ── Demo 5: the slot counts down, then opens the day ──────── */
const WEEK_LETTERS = ["S", "M", "T", "W", "T", "F", "S"];
const DAY_EVENTS = [
{ time: "09:30–09:45", title: "Standup", loc: "Daily", meeting: true },
{ time: "15:30–16:00", title: "Design review", loc: "4th floor", meeting: false }
];
function CalendarDemo() {
const [mins, setMins] = createSignal(14);
const [today, setToday] = createSignal(new Date());
onMount(() => {
setToday(new Date());
const interval = setInterval(
() => setMins((m) => (m <= 0 ? 14 : m - 1)),
900
);
onCleanup(() => clearInterval(interval));
});
const imminent = () => mins() <= 5;
const dialDays = () => {
const base = today();
return [-4, -3, -2, -1, 0, 1, 2, 3, 4].map((off) => {
const d = new Date(
base.getFullYear(),
base.getMonth(),
base.getDate() + off
);
return {
letter: WEEK_LETTERS[d.getDay()],
num: d.getDate(),
today: off === 0,
past: off < 0,
dots: (d.getDate() * 7 + d.getDay() * 3) % 4
};
});
};
return (
/* Fixed height: the day panel expands into reserved space, never
shifts the page (matters most on mobile's stacked layout). */
<div class="flex h-[316px] flex-col items-center gap-4">
<IslandPill
campfire="running"
right={
<div class="flex items-center gap-1">
<svg width="10" height="8" viewBox="0 0 12 9" class="shrink-0">
<path
d="M0 2A1.4 1.4 0 0 1 1.4 0.6h5.6A1.4 1.4 0 0 1 8.4 2v5A1.4 1.4 0 0 1 7 8.4H1.4A1.4 1.4 0 0 1 0 7z M12 2.6 9 4.5l3 1.9z"
fill="rgba(255,255,255,0.75)"
/>
</svg>
<span
class="whitespace-nowrap font-mono text-[9.5px] font-semibold text-white/90"
style={{ color: imminent() ? "#ff9e42" : "rgba(255,255,255,0.9)" }}
>
{mins() === 0 ? "now" : `${mins()}m`}
</span>
</div>
}
/>
{/* the island opening onto the day panel */}
<div
class="w-full max-w-[380px] overflow-hidden rounded-[14px] bg-black transition-all duration-700"
style={{
"max-height": imminent() ? "240px" : "0px",
opacity: imminent() ? 1 : 0,
transform: imminent() ? "translateY(0)" : "translateY(-8px)"
}}
>
<div class="px-4 pt-3 pb-3 text-left">
<div class="mb-2 flex items-center">
<span class="text-[11px] font-semibold text-white/50">
Calendar
</span>
<span class="ml-auto flex h-4 w-4 items-center justify-center text-[12px] font-semibold text-white/50">
+
</span>
</div>
<div class="mb-2 flex gap-1">
<For each={dialDays()}>
{(d) => (
<div
class="flex flex-1 flex-col items-center gap-[3px]"
style={{ opacity: d.today ? 1 : d.past ? 0.55 : 0.8 }}
>
<span class="text-[8px] font-semibold text-white/45">
{d.letter}
</span>
<span
class="flex h-[20px] w-[20px] items-center justify-center rounded-full text-[10px]"
style={{
background: d.today
? "rgb(255,158,66)"
: "rgba(255,255,255,0.06)",
color: "rgba(255,255,255,0.95)",
"font-weight": d.today ? 700 : 500
}}
>
{d.num}
</span>
<span class="flex h-[4px] gap-[2px]">
<For each={Array.from({ length: Math.min(d.dots, 3) })}>
{() => (
<span
class="rounded-full"
style={{
width: "3.5px",
height: "3.5px",
background: d.today
? "rgb(255,158,66)"
: "rgba(255,255,255,0.55)"
}}
/>
)}
</For>
</span>
</div>
)}
</For>
</div>
<div
class="mb-1 h-px"
style={{ background: "rgba(255,255,255,0.06)" }}
/>
<For each={DAY_EVENTS}>
{(ev) => (
<div
class="flex items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors duration-500"
style={{
background:
ev.meeting && imminent()
? "rgba(72,151,178,0.16)"
: "transparent"
}}
>
<div>
<p class="font-mono text-[10px] text-white/45">{ev.time}</p>
<p class="text-[11px] font-semibold text-white">{ev.title}</p>
{ev.loc && <p class="text-[10px] text-white/35">{ev.loc}</p>}
</div>
{ev.meeting && (
<span
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white transition-colors duration-500"
style={{
background: imminent() ? ACCENT : "rgba(255,255,255,0.10)"
}}
>
Open
</span>
)}
</div>
)}
</For>
</div>
</div>
</div>
);
}
/* ── Demo 6: the lens only wakes for a click ──────────────── */
function CameraDemo() {
@@ -797,8 +490,8 @@ function CameraDemo() {
</div>
<p class="text-[10px] text-white/40">
{phase() === "live"
? "live — stops the moment you leave the panel"
: "inert — the lens stays dark until you click"}
? "Camera on while this panel is open"
: "Camera off until you click Start"}
</p>
</div>
</ModuleCard>
@@ -816,11 +509,11 @@ function CampfireDemo() {
"error"
];
const labels = [
"fleet running",
"pending question",
"ready to review",
"all quiet",
"something failed"
"Running",
"Question",
"Ready",
"Idle",
"Error"
];
return (
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
@@ -848,13 +541,13 @@ export default function FeatureBreakdowns() {
<div class="space-y-24">
<BreakdownSection
kicker="The island"
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."
title="See which agents need your attention"
body="Each square represents an agent session. Its color shows whether the agent is working, finished, or waiting for you. Questions appear in the island so you can respond without switching windows."
bullets={[
"18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
"Custom agents: your own coding agent can wire itself in from the published contract",
"Slot rankings — you choose which data earns the notch",
"You don't need a notch — external displays get the same island, floating top-center"
"Connect custom agents using the published integration contract",
"Choose which information appears in the compact view",
"Works on displays without a notch, centered at the top of the screen"
]}
>
<FleetDemo />
@@ -862,12 +555,12 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Approvals"
title="Answer from the island — or your phone"
body="A gated tool call surfaces the island: the fire turns to a question, the card pins above your sessions, and the row says what it's waiting on. Allow, Allow for session, Deny, or deny with a reason, and the run picks up where it stopped."
title="Approve agent requests"
body="When an agent needs permission, the island opens with a request card above your sessions. Review the tool and its input, then allow or deny it. You can include a reason when you deny a request."
bullets={[
"Approval cards pin above the session list; questions answer inside their row",
"Allow for session waves through the rest of that session's requests",
"Answer from iPhone or Apple Watch too, now in TestFlight beta",
"Review permissions above the session list and answer questions in the session row",
"Allow for session approves subsequent requests in that session",
"Respond from iPhone or Apple Watch with the TestFlight beta",
]}
reversed
>
@@ -876,8 +569,8 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Terminals"
title="Reply without hunting for the tab"
body="Every session knows the terminal it runs in. Type a reply in the island and it lands in that exact pane; click Jump and the right window comes forward."
title="Reply to agents and open their terminals"
body="Send a reply from the island to the terminal pane running that session. Use Jump to bring its window forward when you want to work in the terminal."
bullets={[
"Replies reach agents in tmux, iTerm2, Terminal, and kitty",
"Jump focuses the exact window, even for sessions over SSH",
@@ -888,11 +581,26 @@ export default function FeatureBreakdowns() {
</BreakdownSection>
<BreakdownSection
kicker="The campfire"
title="A hearth that tells the truth"
body="Stop-motion pixel art, four frames a step. It dances when tokens stream, putters blue when an agent asks, and goes to dead embers the moment something breaks."
kicker="Remote"
title="Set up remote agents in one click"
body="Choose a Tailscale peer or an authorized host from your SSH config and click Set up. The Nook installs its integrations and checks that the host can reach your Mac."
bullets={[
"Idle, running, ready, question, and error — at a glance",
"Local and remote sessions in the same island, labelled by host",
"Remote integrations follow the agents installed on this Mac",
"Direct connections over Tailscale, or an SSH tunnel for other hosts",
"Uses the host\'s existing SSH authorization"
]}
reversed
>
<RemoteHostDemo />
</BreakdownSection>
<BreakdownSection
kicker="The campfire"
title="Agent status in the campfire"
body="The pixel-art campfire changes with your agents' activity. Different animations indicate work in progress, a question waiting for you, or an error."
bullets={[
"Separate animations for idle, running, ready, question, and error states",
"Hand-drawn 14×14 sprites, no tweening",
"Six chiptunes you can assign to any agent event"
]}
@@ -902,13 +610,13 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Cooling"
title="Fans that answer to you, not the scheduler"
body="Live RPM for every fan, and the curve is yours. Apple's stock curve waits until you're already cooking — swap to Aggressive and it climbs earlier and harder, or slam MAX before the compile."
title="Monitor and control your Mac's fans"
body="Check each fan's speed and choose how cooling responds to load. Keep the stock fan curve, use Aggressive for earlier cooling, or start a MAX session to run the fans at full speed."
bullets={[
"Stock, Aggressive, and MAX — swap curves in one click",
"MAX and COOL sessions surface right in the pill",
"CPU/GPU die temps tier-color before things get loud",
"All local: your hardware data never leaves the device"
"Switch between Stock, Aggressive, and MAX",
"See active MAX and COOL sessions in the compact view",
"CPU and GPU temperature readings change color as they rise",
"Hardware monitoring runs locally on your Mac"
]}
reversed
>
@@ -917,14 +625,14 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Power"
title="Wall power, on your terms"
body="Live watts from the adapter to the battery to the chassis — and a charge ceiling you set yourself. On recent macOS The Nook drives the system's own limiter; below that it enforces the limit itself. One slider, 50–100%, any version."
title="Set a battery charge limit"
body="Set the charge limit on the battery bar, or enter a percentage. The power diagram shows how much power goes to your Mac and battery. Turn on Top-up mode when you need a full charge."
bullets={[
"One slider, 50–100%, on any macOS version",
"Hooks the system limiter where it exists, enforces elsewhere",
"Top-up mode charges past, then re-arms on unplug",
"Thermal protection pauses charging when the pack runs hot",
"Energy flow figure: adapter → battery → system, live watts"
"Drag the limit marker or type a percentage; available limits depend on your Mac",
"Uses macOS charge limits where available, with helper-based control on supported Macs",
"Top-up mode allows charging past the limit and turns off when you unplug",
"Overheat protection can pause charging when the battery gets hot",
"Separate readings for system draw and battery charging, not a wall-power measurement"
]}
>
<BatteryDemo />
@@ -932,12 +640,12 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Sound"
title="Every app gets its own fader"
body="A real volume mixer: set each app's level, mute the noisy one, and send any app to its own output — music to headphones while the meeting stays on the speakers."
title="Adjust volume and output for each app"
body="Set an app's volume without changing the others, mute it, or choose a different output. You can play music through headphones and keep a meeting on the speakers."
bullets={[
"Per-app volume, mute, and output routing",
"Master output, system sounds, and microphone pickers in one panel",
"Catches players that aren't apps, like command-line podcast tools"
"Includes audio from command-line players"
]}
reversed
>
@@ -946,8 +654,8 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Capture"
title="Screenshots and recordings from the notch"
body="Drag out an area, pick a screen, or choose one app. While a recording runs, Wings unfold beside the island: pause and stop on the right, system audio and microphone on the left."
title="Take screenshots and record your screen"
body="Capture an area, a whole screen, or one app. During a recording, controls beside the island let you pause, stop, and toggle system audio or your microphone."
bullets={[
"Area, screen, or single-app capture",
"Screenshots save to your screenshot folder and copy to the clipboard",
@@ -960,13 +668,13 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Your day"
title="Meetings come to the notch"
body="Today sits in a dial — days behind, days ahead, dots where things happen. The slot counts your next call down, and when it gets close the island opens the day on its own."
title="Check your calendar and join meetings"
body="Browse your calendar from the day strip and open a meeting from its event row. The compact view shows today's date, a countdown before an event, and an ON AIR indicator while a meeting is in progress."
bullets={[
"Next meeting counts down right in the slot",
"Near a call, the island opens the day itself",
"Meeting links get an Open button in the row",
"Real calendars and reminders — no sync service"
"Date, countdown, and meeting progress in the compact view",
"Choose how early the island opens before a meeting",
"Join meetings from the video button beside an event",
"Uses your Mac's calendars and reminders"
]}
reversed
>
@@ -975,13 +683,13 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Camera"
title="A lens that stays asleep"
body="A live camera preview in the island for the seconds before a call. It never wakes on its own: permission-gated, inert until you click Start, and dead the moment you leave the panel."
title="Preview your camera before a call"
body="Check your camera in the island before joining a call. The preview starts only when you click Start camera and stops when you leave the panel."
bullets={[
"Nothing watches until Start camera gets a click",
"Live only while the panel sits open",
"Requires camera permission and a click on Start camera",
"Preview runs only while the panel is open",
"A device picker for every connected camera",
"Opt-in module — in no default panel"
"Add the camera module to a panel when you want it"
]}
>
<CameraDemo />
@@ -989,8 +697,8 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Floating actions"
title="Your buttons, orbiting the island"
body="Pin up to seven one-tap actions around the expanded island. They ride its expansion in and out, so the things you reach for most are always one click away."
title="Add shortcuts beside the island"
body="Add up to seven actions beside the expanded island. Use them to open apps, run shortcuts, or change settings without opening a panel."
bullets={[
"Run an Apple Shortcut, open an app or URL, or run a shell command",
"Toggle Low Power Mode, top-up, or overheat protection",

View File

@@ -1,6 +1,6 @@
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
import { ApprovalCardMock, SessionRowMock, DieLine, CLAUDE, CODEX } from "./ModuleMocks";
/**
* Feature collage: five cells sold as the app's actual UI. The hero cell
@@ -99,6 +99,31 @@ function FansPanelMock() {
);
}
function RemoteMock() {
return (
<div class="space-y-1.5">
<SessionRowMock
title="api · main"
prompt="fix the flaky test"
state="Running pytest -x…"
status="running"
agent={CLAUDE}
host="build-box"
age="4m"
/>
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Done · replied 1m ago"
status="ready"
agent={CODEX}
host="gpu-rig"
age="12m"
/>
</div>
);
}
/* Deterministic usage wander for the traces. */
const wander = (n: number, k: number) =>
0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k);
@@ -137,7 +162,7 @@ export default function FeatureCollage() {
Why the Nook
</p>
<h2 class="text-text mb-12 text-center text-3xl font-bold">
One glance. Everything your agents are doing.
Keep track of your agents while you work
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
@@ -148,12 +173,12 @@ export default function FeatureCollage() {
The island
</p>
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight">
Every agent, one glance
Agent status at the top of your screen
</h3>
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
A pill tucked into your notch — a campfire for your whole fleet
on the left, live data on the right. Blue running, green ready,
amber needs you.
The island sits around your Mac's notch, with agent activity on
the left and your choice of information on the right. Blue means
running, green means ready, and amber means an agent needs you.
</p>
</div>
<div class="my-10 flex w-full justify-center">
@@ -186,15 +211,15 @@ export default function FeatureCollage() {
/>
</div>
<p class="text-subtext0 -mb-1 text-center text-xs">
expand for the full panel ↓
Open the island to see your sessions
</p>
</div>
{/* Approvals */}
<CollageCell
kicker="Approvals"
title="Unblock agents, anywhere"
body="Permission prompts pin as cards above your sessions — Allow, Allow for session, or Deny. Away from the desk, answer from your iPhone or Apple Watch (beta)."
title="Review permission requests"
body="Review permission requests above your sessions and choose Allow, Allow for session, or Deny. The iPhone and Apple Watch beta lets you respond away from your Mac."
>
<InkStage>
<div class="w-full max-w-sm px-3">
@@ -211,8 +236,8 @@ export default function FeatureCollage() {
{/* Fans */}
<CollageCell
kicker="Cooling"
title="Silence it or max it"
body="Read both fans live, then take over the curve completely. Flip to MAX before a compile, whisper-quiet when the fleet idles."
title="Fan speeds and cooling modes"
body="Monitor each fan's speed and choose a cooling mode. Keep automatic control or start a MAX session before a demanding task."
>
<InkStage class="w-full">
<div class="w-full max-w-[260px] text-left">
@@ -224,8 +249,8 @@ export default function FeatureCollage() {
{/* Gauges */}
<CollageCell
kicker="Your Mac"
title="The gauges that matter"
body="Each die's temperature beside its 60-second usage trace, tinted amber from 70° and red at 85°, so you see a compile heating up before the fans do."
title="CPU and GPU monitoring"
body="See CPU and GPU temperatures beside their last 60 seconds of usage. Temperature readings turn amber at 70°C and red at 85°C."
>
<InkStage class="w-full">
<div class="w-full max-w-[300px] text-left">
@@ -233,15 +258,27 @@ export default function FeatureCollage() {
</div>
</InkStage>
</CollageCell>
<CollageCell
kicker="Remote"
title="Agents on other machines"
body="One-click setup for Tailscale and authorized known hosts. Remote sessions appear in the island with the host name beside the agent."
>
<InkStage class="w-full">
<div class="w-full min-w-0 px-3" role="img" aria-label="Remote agent sessions on build-box and gpu-rig">
<RemoteMock />
</div>
</InkStage>
</CollageCell>
</div>
<p class="text-subtext1 mt-6 text-center text-xs">
Plus a mixer, capture, timers, weather, and a dozen more modules —{" "}
Also includes a volume mixer, screen capture, timers, weather, and more.{" "}
<a
href="#island-extras"
class="underline decoration-dotted hover:opacity-80"
>
see them all ↓
Browse the other modules
</a>
</p>
</div>

View File

@@ -13,7 +13,6 @@ import {
ACCENT,
CALM,
WARM,
CRITICAL,
CARD_FILL,
CARD_STROKE
} from "./IslandMock";
@@ -571,8 +570,8 @@ function KeepAwakeMock() {
const MODES = [
{ name: "Auto", note: "follows each agent's own settings", card: "asks", tint: "rgba(234,179,8,0.10)" },
{ name: "Yolo", note: "gated tools pass straight through", card: "passed", tint: "rgba(111,185,130,0.12)" },
{ name: "Verify", note: "every gated tool waits for you", card: "held", tint: "rgba(229,72,77,0.12)" }
{ name: "Yolo", note: "automatically allows received requests", card: "passed", tint: "rgba(111,185,130,0.12)" },
{ name: "Verify", note: "received requests wait for approval", card: "held", tint: "rgba(229,72,77,0.12)" }
];
function ApprovalModesMock() {
@@ -773,14 +772,14 @@ export default function IslandExtras() {
<section class="bg-base relative z-20 py-16" id="island-extras">
<div class="px-4 md:px-8">
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
And everything else
More modules
</p>
<h2 class="text-text mb-3 text-center text-3xl font-bold">
One island instead of a menu bar full of utilities
Other tools in the island
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl text-center leading-relaxed">
Every module below lives in the same notch, arranged however you
like, under the same one-time license.
Add the modules you need and arrange them in your panels.
They're all included with your license.
</p>
</div>
@@ -793,8 +792,8 @@ export default function IslandExtras() {
<CollageCell
class="md:col-span-2"
kicker="Now Playing"
title="Music in the notch"
body="Track, source, and transport controls in a module. Sources without artwork, like some browsers, get a live waveform in the slot instead."
title="Playback controls and track information"
body="See what's playing and control playback. For sources without artwork, including some browsers, the compact view shows an audio waveform."
>
<InkStage class="h-full w-full items-center">
<NowPlayingMock />
@@ -803,8 +802,8 @@ export default function IslandExtras() {
<CollageCell
kicker="Timers"
title="Timers that stack"
body="A readout over a tick tape that drains with a soft crest. Run several at once; finished timers ring and hold the island open until you dismiss them."
title="Run several timers at once"
body="Keep multiple timers running and check their progress in the island. When a timer finishes, it rings and keeps the island open until you dismiss it."
>
<InkStage class="h-full w-full items-center px-3">
<TimersMock />
@@ -813,8 +812,8 @@ export default function IslandExtras() {
<CollageCell
kicker="Weather"
title="The sky, in the slot"
body="Saved places with hourly and daily forecasts. The compact slot stacks the sky over the temperature."
title="Weather for your saved places"
body="Check hourly and daily forecasts for saved places. The compact view shows the current conditions and temperature."
>
<InkStage class="h-full w-full items-center px-3">
<WeatherMock />
@@ -824,8 +823,8 @@ export default function IslandExtras() {
<CollageCell
class="md:col-span-2"
kicker="Interceptions"
title="Volume and brightness, under the notch"
body="An optional takeover of the system HUD: levels peek out below the island. Extra brightness spends an XDR panel's HDR headroom past the usual maximum, and external displays get real brightness control."
title="Volume and brightness controls"
body="Show volume and brightness changes below the island instead of the system overlay. Adjust supported external displays and use extra brightness on compatible XDR displays."
>
<InkStage class="h-full w-full items-center">
<div class="flex min-h-[110px] w-full justify-center pt-2">
@@ -836,8 +835,8 @@ export default function IslandExtras() {
<CollageCell
kicker="Approval modes"
title="One stance for every agent"
body="Auto follows each agent's own settings, Yolo waves gated tools through, and Verify holds every one until you decide."
title="Choose how approvals are handled"
body="Auto follows each agent's settings. Yolo automatically allows requests that reach The Nook. Verify waits for your decision on each request it receives."
>
<InkStage class="h-full w-full items-center px-3">
<ApprovalModesMock />
@@ -846,8 +845,8 @@ export default function IslandExtras() {
<CollageCell
kicker="Network"
title="Know your connection"
body="Public IP, ISP, gateway, and DNS one click from your clipboard. VPN routes get flagged, and a speed test is always a click away."
title="Network information and speed tests"
body="View and copy your public IP, ISP, gateway, and DNS details. Check for VPN routes or run a speed test."
>
<InkStage class="h-full w-full items-center px-3">
<NetworkMock />
@@ -856,8 +855,8 @@ export default function IslandExtras() {
<CollageCell
kicker="Disks"
title="Storage, measured"
body="Live read and write throughput, capacity that matches Finder, and SMART health for your drives."
title="Disk usage and drive health"
body="Check read and write speeds, free space, and SMART health information for supported drives."
>
<InkStage class="h-full w-full items-center px-3">
<DiskMock />
@@ -866,8 +865,8 @@ export default function IslandExtras() {
<CollageCell
kicker="Keep Awake"
title="Sleep when you say"
body="Stay awake for a set time, while plugged in, with the lid closed, or only while your agents are working."
title="Keep your Mac awake"
body="Keep your Mac awake for a set time, while plugged in, with the lid closed, or while your agents are working."
>
<InkStage class="h-full w-full items-center px-3">
<KeepAwakeMock />
@@ -876,7 +875,7 @@ export default function IslandExtras() {
<CollageCell
kicker="Lock Screen"
title="Visible while locked"
title="Agent status on the Lock Screen"
body="The island stays on your Lock Screen, so you can see how many agents are working or waiting without unlocking."
>
<InkStage class="h-full w-full items-center px-3">
@@ -886,8 +885,8 @@ export default function IslandExtras() {
<CollageCell
kicker="Layout"
title="Panels you shape"
body="Drag and resize modules in the mosaic editor, pick a density, and let slot rankings switch with your Focus mode."
title="Arrange your panels"
body="Drag and resize modules, choose how much detail they show, and change compact-view priorities with your Focus mode."
>
<InkStage class="h-full w-full items-center px-3">
<LayoutMock />

View File

@@ -77,6 +77,12 @@ const GLYPHS: Record<string, () => JSX.Element> = {
<path d="M3.5 8a4.5 4.5 0 1 0 1.4-3.3M3.2 2.2v2.8H6" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
),
plus: () => <path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />,
video: () => <path d="M2 4h8v8H2Z m9 3 3-2v6l-3-2Z" fill="currentColor" />,
pin: () => <path d="m6 2 6 6-3 1-2 3-1-2-4 4m2-8 2-2Z" stroke="currentColor" stroke-width="1.2" fill="none" stroke-linejoin="round" />,
eye: () => <><path d="M1 8s2.5-4 7-4 7 4 7 4-2.5 4-7 4-7-4-7-4Z" stroke="currentColor" stroke-width="1.2" fill="none" /><circle cx="8" cy="8" r="2" fill="currentColor" /></>,
plug: () => <path d="M5 1v4m6-4v4M3 5h10v3a5 5 0 0 1-4 4.9V16H7v-3.1A5 5 0 0 1 3 8Z" stroke="currentColor" stroke-width="1.3" fill="none" />,
battery: () => <><rect x="1" y="4" width="12" height="8" rx="1.5" stroke="currentColor" stroke-width="1.2" fill="none" /><path d="M14 6v4M3 6h5v4H3Z" stroke="currentColor" stroke-width="1.2" /></>,
laptop: () => <path d="M3 2.5h10v9H3Z M1 13.5h14" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linejoin="round" />,
refresh: () => (
<path d="M12.5 8A4.5 4.5 0 1 1 11 4.6M11.6 2v2.8H8.8" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
)

View File

@@ -7,21 +7,20 @@ import {
STATUS,
ACCENT,
CALM,
CRITICAL,
CARD_STROKE
} from "./IslandMock";
import { useDemoLoop } from "./useDemoLoop";
import { Glyph, WingCapsule, type GlyphName } from "./ModuleMocks";
import { Glyph, WingCapsule, SessionRowMock, CODEX, type GlyphName } from "./ModuleMocks";
/**
* Live demos for the breakdowns added with the 0.6–0.10 feature set:
* terminal replies, the volume mixer, Capture (module + Wings), and
* terminal replies, remote setup, Capture (module + Wings), and
* floating actions. Same ink chrome and status palette as the originals.
*/
/* ── Reply lands in the agent's own terminal ──────────────────────── */
const REPLY = "yes — and run them";
const REPLY = "Yes, update and run the tests.";
export function TerminalReplyDemo() {
const [typed, setTyped] = createSignal(0);
@@ -153,124 +152,106 @@ export function TerminalReplyDemo() {
);
}
/* ── Volume mixer: every app its own level and output ─────────────── */
interface MixerApp {
name: string;
color: string;
glyph: string;
}
const MIXER_APPS: MixerApp[] = [
{ name: "Music", color: "#fa5c7c", glyph: "♫" },
{ name: "Browser", color: "#6EA7FF", glyph: "◎" },
{ name: "Meeting", color: "#6FB982", glyph: "●" },
{ name: "podcast-cli", color: "#E7A762", glyph: ">_" }
/** RemoteMirror's progress steps, in order. */
const SETUP_STEPS = [
"connecting",
"pushing the hook binary",
"configuring agents",
"checking the host can reach this Mac"
];
export function MixerDemo() {
const [step, setStep] = createSignal(0);
useDemoLoop(9600, [
[0, () => setStep(0)],
[1800, () => setStep(1)],
[3800, () => setStep(2)],
[5800, () => setStep(3)]
export function RemoteHostDemo() {
/* -1 not set up · 0…3 setting up · 4 live */
const [step, setStep] = createSignal(-1);
const [pressed, setPressed] = createSignal(false);
useDemoLoop(11000, [
[0, () => (setStep(-1), setPressed(false))],
[1400, () => setPressed(true)],
[1700, () => (setPressed(false), setStep(0))],
[2700, () => setStep(1)],
[3700, () => setStep(2)],
[4700, () => setStep(3)],
[5800, () => setStep(4)]
]);
const level = (i: number) => {
if (i === 0) return step() >= 3 ? 0.38 : 0.72;
if (i === 1) return 0.55;
if (i === 2) return 0.86;
return 0.46;
};
const muted = (i: number) => i === 1 && step() >= 1;
const route = (i: number) =>
i === 0 && step() >= 2 ? "Headphones" : i === 2 ? "Display" : "Speakers";
const live = () => step() === 4;
const working = () => step() >= 0 && step() < 4;
const chip = () =>
live()
? { color: STATUS.ready, label: "Live" }
: working()
? { color: "rgba(255,255,255,0.55)", label: "Setting up" }
: { color: "rgba(255,255,255,0.35)", label: "Not set up" };
return (
<ModuleCard class="mx-auto w-full max-w-[340px]">
<div class="space-y-2.5 text-left">
<div class="flex items-center">
<span class="text-[11px] font-semibold text-white/50">
Volume mixer
</span>
<span class="ml-auto font-mono text-[10px] text-white/40">
out · Speakers
</span>
</div>
<For each={MIXER_APPS}>
{(app, i) => (
<div class="flex items-center gap-2">
<span
class="flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px] text-[8px] font-bold text-black/70"
style={{ background: app.color }}
>
{app.glyph}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate text-[11px] font-semibold text-white/85">
{app.name}
</span>
<span
class="ml-auto shrink-0 rounded px-1.5 text-[9px] text-white/55 transition-colors duration-500"
style={{
background:
i() === 0 && step() === 2
? `${ACCENT}55`
: "rgba(255,255,255,0.07)"
}}
>
{route(i())}
</span>
</div>
<div
class="relative mt-1 h-[5px] overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{
width: `${level(i()) * 100}%`,
background: muted(i()) ? "rgba(255,255,255,0.18)" : CALM,
transition: "width 0.9s cubic-bezier(0.22,1,0.36,1), background 0.3s"
}}
/>
{/* live signal riding inside the level */}
<div
class="nook-meter absolute top-0 left-0 h-full rounded-full"
style={{
width: `${level(i()) * 100}%`,
background: "rgba(255,255,255,0.35)",
opacity: muted(i()) ? 0 : 1,
"animation-duration": `${0.5 + i() * 0.17}s`,
transition: "width 0.9s cubic-bezier(0.22,1,0.36,1), opacity 0.3s"
}}
/>
</div>
</div>
<span
class="w-4 shrink-0 text-center text-[11px] transition-colors duration-300"
style={{ color: muted(i()) ? CRITICAL : "rgba(255,255,255,0.4)" }}
>
{muted(i()) ? "✕" : "♪"}
</span>
<div class="relative mx-auto w-full max-w-[360px] space-y-3" role="img" aria-label="One-click remote setup preview for an authorized Tailscale host">
{/* the host's card in Settings ▸ Agents ▸ Remote hosts */}
<ModuleCard>
<div class="space-y-2 text-left">
<div class="flex items-center gap-2.5">
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
<p class="truncate text-[11px] text-white/45">tailnet peer</p>
</div>
)}
</For>
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
<p class="text-[10px] text-white/40">
{
[
"every app on its own fader",
"browser muted — the rest keep playing",
"music routed to headphones, meeting stays on the display",
"music down, nothing else moved"
][step()]
}
</p>
<Show when={working()}>
<span class="h-3 w-3 animate-spin rounded-full border-[1.5px] border-white/25 border-t-white/80" />
</Show>
<span class="flex items-center gap-1.5 text-[10.5px] font-medium text-white/85">
<span
class="h-[7px] w-[7px] rounded-full transition-colors duration-300"
style={{ background: chip().color }}
/>
{chip().label}
</span>
<Show
when={live()}
fallback={
<span
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
style={{
background: ACCENT,
opacity: working() ? 0 : 1,
transform: pressed() ? "scale(0.92)" : "scale(1)"
}}
>
Set up
</span>
}
>
<Glyph name="refresh" size={11} class="text-white/50" />
<span class="rounded-md bg-white/10 px-2 py-1 text-[11px] font-semibold text-[#ff6961]">
Remove
</span>
</Show>
</div>
<p class="min-h-8 text-[11px] text-white/50">
{working() ? SETUP_STEPS[step()] : " "}
</p>
</div>
</ModuleCard>
{/* The host name stays visible in the session badge. */}
<div
class="transition-all duration-500"
style={{
opacity: live() ? 1 : 0,
transform: live() ? "translateY(0)" : "translateY(6px)"
}}
>
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Running python bench.py…"
status="running"
agent={CODEX}
host="gpu-rig"
age="now"
/>
</div>
</ModuleCard>
<FakeCursor
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
at={{ x: 88, y: 16 }}
/>
</div>
);
}

View File

@@ -96,7 +96,7 @@ function CompanionDemo() {
{allowed() ? "Allowed ✓" : "Claude Code · the-nook"}
</p>
<p class="text-[9px] leading-snug text-white/60">
{allowed() ? "Back to work." : "Run Bash: swift test"}
{allowed() ? "Agent running." : "Run Bash: swift test"}
</p>
<Show when={!allowed()}>
<div class="mt-1.5 flex gap-1">
@@ -139,7 +139,7 @@ function CompanionDemo() {
>
<p class="text-[8px] font-semibold text-white/50">Codex asks</p>
<p class="mt-0.5 text-[9.5px] leading-snug font-semibold text-white">
Ship it to staging?
Deploy to staging?
</p>
<div class="mt-2 space-y-1">
<For each={["Yes", "Not yet"]}>
@@ -194,19 +194,19 @@ export default function PhoneBeta(props: { brandColor: string }) {
TestFlight beta
</span>
<h2 class="text-text mb-3 text-3xl font-bold tracking-tight">
Your fleet, in your pocket
Use The Nook on iPhone and Apple Watch
</h2>
<p class="text-subtext0 mb-5 leading-relaxed">
The iPhone and Apple Watch companion is in beta. Approvals,
questions, and replies reach you away from the desk, and your
answer goes straight back to the agent.
The companion app is in TestFlight beta. Check your agents,
respond to permission requests, and answer questions when you're
away from your Mac.
</p>
<ul class="mb-8 space-y-2.5">
<For
each={[
"Allow, deny, answer, and reply from iPhone or Apple Watch",
"Connects over an end-to-end encrypted relay, your tailnet, or your LAN",
"Home Screen, Lock Screen, and watch-face widgets for fleet status",
"Agent status widgets for your Home Screen, Lock Screen, and watch face",
"Mute notifications for a while, or while you're active at the Mac"
]}
>
@@ -255,7 +255,7 @@ export default function PhoneBeta(props: { brandColor: string }) {
</form>
<Show when={state() === "sent"}>
<p class="text-subtext1 mt-4 text-sm">
Thanks. Watch your inbox for a TestFlight invite.
Thanks. We'll email you a TestFlight invite.
</p>
</Show>
<Show when={state() === "limited"}>

View File

@@ -0,0 +1,627 @@
import { For, Show, createSignal } from "solid-js";
import { IslandPill, ModuleCard, CALM, WARM } from "./IslandMock";
import { Glyph, type GlyphName } from "./ModuleMocks";
import { useDemoLoop } from "./useDemoLoop";
const PAPER = "#f1ead9";
const BATTERY = "#8dc9b3";
const CALENDAR = "#ff9e42";
/** SettingsSlider: paper fill, dark knob, and a unity notch on app sliders. */
function MixerSlider(props: { level: number; tint?: string; muted?: boolean }) {
const fraction = () => props.level / (props.tint ? 125 : 100);
const position = () => `calc(9px + (100% - 18px) * ${fraction()})`;
return (
<div class="relative h-5 min-w-0 flex-1 rounded-full bg-white/10">
<div
class="absolute inset-y-0 left-0 overflow-hidden rounded-full transition-[width] duration-700"
style={{ width: `calc(${position()} + 9px)`, background: PAPER }}
>
<Show when={props.tint && !props.muted}>
<div
class="nook-meter h-full w-full rounded-full"
style={{ background: props.tint }}
/>
</Show>
</div>
<Show when={props.tint}>
<span
class="absolute top-[7.5px] h-[5px] w-[5px] rounded-full border border-white/55 bg-[#0d0d0f]"
style={{ left: "calc(9px + (100% - 18px) * 0.8 - 2.5px)" }}
/>
</Show>
<span
class="absolute top-px h-[18px] w-[18px] rounded-full border-[1.5px] shadow-sm transition-[left] duration-700"
style={{
left: `calc(${position()} - 9px)`,
background: "#0d0d0f",
"border-color": PAPER
}}
/>
</div>
);
}
function RoutePicker(props: { label: string }) {
return (
<span class="inline-flex max-w-[155px] items-center gap-1 rounded-full bg-white/10 px-2 py-1 text-[11px] text-white/85">
<span class="truncate">{props.label}</span>
<Glyph name="chevron" size={8} />
</span>
);
}
const MIXER_APPS = [
{ name: "Music", color: "#fa5c7c", glyph: "♫" },
{ name: "Browser", color: CALM, glyph: "◎" },
{ name: "Meeting", color: BATTERY, glyph: "▰" }
];
/** VolumeMixerPanelView and MixerAppRow, including the current route controls. */
export function MixerDemo() {
const [step, setStep] = createSignal(0);
useDemoLoop(10800, [
[0, () => setStep(0)],
[2400, () => setStep(1)],
[4800, () => setStep(2)],
[7200, () => setStep(3)]
]);
const muted = (i: number) => i === 1 && step() >= 1;
const volume = (i: number) =>
i === 0 ? (step() >= 3 ? 38 : 100) : i === 1 ? 55 : 86;
return (
<div
role="img"
aria-label="Volume mixer preview with output, system sounds, microphone, and per-app volume, mute and routing controls"
>
<ModuleCard class="mx-auto w-full max-w-[360px]">
<div class="space-y-3 text-left text-white">
<div class="flex items-center gap-2" aria-label="Output volume">
<Glyph name="speaker" class="text-white/50" />
<MixerSlider level={65} />
<span class="w-[34px] text-right font-mono text-[11px] text-white/60">
65%
</span>
</div>
<For
each={[
{
label: "Output",
icon: "speaker",
device: "MacBook Pro Speakers"
},
{
label: "System sounds",
icon: "bell",
device: "MacBook Pro Speakers"
},
{
label: "Microphone",
icon: "mic",
device: "MacBook Pro Microphone"
}
]}
>
{(route) => (
<div class="flex items-center gap-2">
<Glyph name={route.icon} class="text-white/50" />
<span class="flex-1 text-[12px] font-medium">
{route.label}
</span>
<RoutePicker label={route.device} />
</div>
)}
</For>
<div class="h-px bg-white/[0.06]" />
<For each={MIXER_APPS}>
{(app, i) => (
<div class="space-y-1">
<div class="flex items-center gap-2">
<span
class="flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[4px] text-[10px] text-white"
style={{ background: app.color }}
>
{app.glyph}
</span>
<span class="min-w-0 flex-1 truncate text-[12px] font-medium">
{app.name}
</span>
<RoutePicker
label={i() === 0 && step() >= 2 ? "Headphones" : "Default"}
/>
<span aria-label="Pin app" class="text-white/50">
<Glyph name="pin" size={10} />
</span>
<span aria-label="Hide app" class="text-white/50">
<Glyph name="eye" size={10} />
</span>
</div>
<div class="flex items-center gap-2 pl-[26px]">
<MixerSlider
level={volume(i())}
tint={app.color}
muted={muted(i())}
/>
<span class="w-[34px] text-right font-mono text-[11px] text-white/60">
{muted(i()) ? 0 : volume(i())}%
</span>
<Glyph
name={muted(i()) ? "speakerOff" : "speaker"}
size={14}
style={{
color: muted(i()) ? WARM : "rgba(255,255,255,0.5)"
}}
/>
</div>
</div>
)}
</For>
</div>
</ModuleCard>
<p class="mt-3 min-h-8 text-center text-[11px] text-white/55">
{
[
"Adjust each app separately",
"Browser muted",
"Music playing through headphones",
"Music volume lowered to 38%"
][step()]
}
</p>
</div>
);
}
function PowerNode(props: {
label: string;
glyph: GlyphName;
watts: number;
tint: string;
}) {
return (
<div class="flex h-9 items-center gap-[7px]">
<Glyph name={props.glyph} size={18} style={{ color: props.tint }} />
<div>
<p class="text-[9px] text-white/55">{props.label}</p>
<p class="font-mono text-[11px] text-white">
{props.watts.toFixed(1)} W
</p>
</div>
</div>
);
}
/** PowerPanelView: system draw, parallel power circuit, battery bar and controls. */
export function BatteryDemo() {
const [step, setStep] = createSignal(0);
useDemoLoop(14000, [
[0, () => setStep(0)],
[1800, () => setStep(1)],
[3600, () => setStep(2)],
[6400, () => setStep(3)],
[8400, () => setStep(4)],
[10400, () => setStep(5)]
]);
const pct = () => [62, 71, 80, 80, 92, 100][step()];
const topUp = () => step() >= 3;
const held = () => step() === 2;
const batteryWatts = () => (held() || pct() === 100 ? 0 : 26.4);
return (
<div
role="img"
aria-label="Battery preview showing system draw, adapter power split between the Mac and battery, an 80 percent charge limit, and top-up mode"
>
<ModuleCard class="mx-auto w-full max-w-[340px]">
<div class="space-y-3 text-left text-white">
<div>
<div class="flex items-center gap-1.5 font-mono text-[18px]">
<span style={{ color: CALM }}>ϟ</span> 18.2 W
</div>
<p class="text-[9px] font-semibold text-white/55">System draw</p>
</div>
<div class="flex items-center gap-2">
<PowerNode
label="Adapter"
glyph="plug"
watts={18.2 + batteryWatts()}
tint={CALM}
/>
<svg
class="h-[82px] min-w-4 flex-1"
viewBox="0 0 100 82"
preserveAspectRatio="none"
fill="none"
aria-hidden="true"
>
<path
d="M0 41H25C55 41 40 18 70 18H100"
stroke={CALM}
stroke-opacity="0.3"
stroke-width="1.5"
/>
<path
d="M0 41H25C55 41 40 64 70 64H100"
stroke={BATTERY}
stroke-opacity="0.3"
stroke-width="1.5"
/>
<path
class="nook-power-flow"
d="M0 41H25C55 41 40 18 70 18H100"
stroke={CALM}
stroke-width="1.8"
stroke-dasharray="3 18"
/>
<Show when={batteryWatts() > 0}>
<path
class="nook-power-flow"
d="M0 41H25C55 41 40 64 70 64H100"
stroke={BATTERY}
stroke-width="1.8"
stroke-dasharray="3 18"
/>
</Show>
<path d="m96 15 4 3-4 3" stroke={CALM} stroke-width="1.5" />
<path d="m96 61 4 3-4 3" stroke={BATTERY} stroke-width="1.5" />
<Show when={held()}>
<path
d="m81 60 8 8m0-8-8 8"
stroke={BATTERY}
stroke-width="1.8"
/>
</Show>
</svg>
<div class="space-y-2.5">
<PowerNode label="Mac" glyph="laptop" watts={18.2} tint={CALM} />
<PowerNode
label="Battery"
glyph="battery"
watts={batteryWatts()}
tint={BATTERY}
/>
</div>
</div>
<div class="flex items-baseline gap-2">
<span class="flex-1 text-[9px] font-semibold text-white/55 uppercase">
Battery
</span>
<span class="font-mono text-[15px]">{pct()}%</span>
<span class="ml-2 flex items-center gap-1 font-mono text-[10px] text-white/70">
Limit{" "}
<span class="rounded border border-white/15 bg-white/5 px-2 py-0.5 text-[12px] text-white/90">
80
</span>
%
</span>
</div>
<div class="relative flex h-[18px] items-center">
<div class="h-1 w-full overflow-hidden rounded-full bg-white/[0.08]">
<div
class="h-full rounded-full transition-[width] duration-[1600ms]"
style={{ width: `${pct()}%`, background: CALM }}
/>
</div>
<span
class="absolute h-3 w-1 rounded-sm bg-white/85"
style={{ left: "calc(80% - 2px)" }}
/>
</div>
<For
each={[
{
label: "Top-up mode",
note: "Charges past the limit; off on unplug",
on: topUp()
},
{
label: "Overheat protection",
note: topUp()
? "Suspended while Top-up mode is on"
: "Pauses at 95°F",
on: true
}
]}
>
{(control) => (
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<p class="text-[12px] font-medium">{control.label}</p>
<p class="text-[10px] leading-snug text-white/55">
{control.note}
</p>
</div>
<span
class="relative h-4 w-[30px] shrink-0 rounded-full transition-colors"
style={{
background: control.on ? CALM : "rgba(255,255,255,0.15)"
}}
>
<span
class="absolute top-0.5 h-3 w-3 rounded-full bg-white transition-[left]"
style={{ left: control.on ? "16px" : "2px" }}
/>
</span>
</div>
)}
</For>
</div>
</ModuleCard>
<p class="mt-3 min-h-8 text-center text-[11px] text-white/55">
{held()
? "Charging paused at the 80% limit"
: topUp()
? "Top-up mode allows a full charge"
: "Charging with an 80% limit"}
</p>
</div>
);
}
const DAYS = [
{ letter: "M", day: 9, dots: 1 },
{ letter: "T", day: 10, dots: 2 },
{ letter: "W", day: 11, dots: 1 },
{ letter: "T", day: 12, dots: 2 },
{ letter: "F", day: 13, dots: 3 },
{ letter: "S", day: 14, dots: 0 },
{ letter: "S", day: 15, dots: 0 }
];
const EVENTS = [
{
start: "09:30",
end: "09:45",
title: "Standup",
calendar: "Work",
color: CALENDAR,
location: "Video call",
meeting: true
},
{
start: "15:30",
end: "16:00",
title: "Design review",
calendar: "Work",
color: CALENDAR,
location: "4th floor",
meeting: false
}
];
/** CalendarSlotFace's daymark / next-up / on-air states, and CalendarPanelView. */
export function CalendarDemo() {
const [step, setStep] = createSignal(0);
useDemoLoop(16000, [
[0, () => setStep(0)],
[2400, () => setStep(1)],
[4800, () => setStep(2)],
[9000, () => setStep(3)],
[12000, () => setStep(4)]
]);
const onAir = () => step() >= 3;
return (
<div
role="img"
aria-label="Calendar preview showing the date, a meeting countdown, an on-air indicator, and the day's events with a join meeting button"
class="flex min-h-[360px] min-w-0 flex-col items-center gap-4"
>
<div class="flex max-w-full justify-center" style={{ zoom: 0.8 }}>
<IslandPill
right={
<svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
<Show when={step() === 0}>
<rect
x="6"
y="4"
width="28"
height="27"
rx="5"
fill="#ededed"
/>
<path
d="M11 4h18a5 5 0 0 1 5 5v3H6V9a5 5 0 0 1 5-5"
fill={CALENDAR}
/>
<text
x="20"
y="10"
text-anchor="middle"
font-size="5.5"
font-weight="700"
fill="#1f1f1f"
>
THU
</text>
<text
x="20"
y="28"
text-anchor="middle"
font-size="17"
font-weight="700"
fill="#1f1f1f"
>
12
</text>
<circle cx="20" cy="36" r="2.25" fill={CALENDAR} />
</Show>
<Show when={step() === 1 || step() === 2}>
<text
x="20"
y="10"
text-anchor="middle"
font-size="7"
font-weight="700"
fill="white"
opacity="0.65"
>
IN
</text>
<text
x="20"
y="30"
text-anchor="middle"
font-size="20"
font-weight="700"
fill="white"
>
{step() === 1 ? "14m" : "5m"}
</text>
<rect
x="6"
y="34"
width="28"
height="2"
rx="1"
fill="white"
opacity="0.15"
/>
<rect
x="6"
y="34"
width={step() === 1 ? 2 : 19}
height="2"
rx="1"
fill={CALENDAR}
/>
</Show>
<Show when={onAir()}>
<rect
x="1"
y="3"
width="38"
height="22"
rx="5"
fill={CALENDAR}
opacity="0.18"
/>
<circle cx="4.7" cy="14.5" r="2.1" fill={CALENDAR} />
<text
x="22.6"
y="17"
text-anchor="middle"
font-size="7.8"
font-weight="700"
fill={CALENDAR}
>
ON AIR
</text>
<rect
x="1"
y="31"
width="38"
height="5"
rx="2.5"
fill={CALENDAR}
opacity="0.7"
/>
<rect
x="1"
y="31"
width={step() === 4 ? 25 : 8}
height="5"
rx="2.5"
fill="#333"
/>
<rect
x={step() === 4 ? 25 : 8}
y="29"
width="2.6"
height="9"
rx="1.3"
fill="#ededed"
/>
</Show>
</svg>
}
/>
</div>
<div class="w-full max-w-[360px]">
<ModuleCard>
<div class="space-y-2.5 text-left text-white">
<div class="flex items-center">
<div>
<p class="text-[10px] text-white/55">Calendar</p>
<p class="text-[14px] font-semibold">Thu, Nov 12</p>
</div>
<span class="ml-auto rounded-lg bg-white/[0.08] p-[7px] text-white/55">
<Glyph name="plus" />
</span>
</div>
<div class="flex h-[60px] gap-[3px]">
<For each={DAYS}>
{(day) => (
<div
class="flex min-w-0 flex-1 flex-col items-center justify-center gap-[5px] rounded-[10px]"
style={{
background: day.day === 12 ? CALENDAR : "transparent",
color: day.day === 12 ? "#0d0d0f" : "white",
opacity: day.day < 12 ? 0.6 : 1
}}
>
<span class="text-[9px] opacity-60">{day.letter}</span>
<span
class="text-[13px]"
style={{ "font-weight": day.day === 12 ? 700 : 500 }}
>
{day.day}
</span>
<span class="flex h-1 gap-0.5">
<For each={Array.from({ length: day.dots })}>
{() => (
<span
class="h-1 w-1 rounded-full"
style={{ background: CALENDAR }}
/>
)}
</For>
</span>
</div>
)}
</For>
</div>
<For each={EVENTS}>
{(event) => (
<div class="flex items-center gap-2.5 rounded-[10px] bg-white/[0.035] p-2.5">
<div class="w-[40px] shrink-0 text-right font-mono text-[10px]">
<p>{event.start}</p>
<p class="mt-[3px] text-white/55">{event.end}</p>
</div>
<span
class="w-[3px] self-stretch rounded-sm"
style={{ background: event.color }}
/>
<div class="min-w-0 flex-1 space-y-1">
<p class="text-[12px] leading-snug font-semibold">
{event.title}
</p>
<p
class="text-[10px] font-medium"
style={{ color: event.color }}
>
{event.calendar}
</p>
<p class="text-[10px] text-white/55">{event.location}</p>
</div>
<Show when={event.meeting}>
<span
aria-label="Join meeting"
class="rounded-lg bg-white/[0.08] p-2 text-white/65"
>
<Glyph name="video" />
</span>
</Show>
</div>
)}
</For>
</div>
</ModuleCard>
</div>
<p class="min-h-4 text-center text-[11px] text-white/55">
{step() === 0
? "Today's date and calendar events"
: onAir()
? "Meeting in progress"
: "Countdown to your next meeting"}
</p>
</div>
);
}

View File

@@ -67,7 +67,8 @@ describe("generateSitemap", () => {
expect(locs).toContain("https://nessa.freno.me/");
expect(locs).toContain("https://nessa.freno.me/contact");
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) {
expect(loc).not.toContain("://freno.me/");

View File

@@ -57,7 +57,8 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
nessa: [
{ path: "/", changefreq: "weekly", priority: 1.0 },
{ 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: [

View File

@@ -10,6 +10,8 @@ import { describe, expect, it, mock, beforeEach } from "bun:test";
const sentEmails: { to: string; subject: string; html: string }[] = [];
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
/** Every rate-limit key the route derived, in order. */
const rateKeys: string[] = [];
mock.module("~/server/email", () => ({
default: async (to: string, subject: string, html: string) => {
@@ -19,7 +21,10 @@ mock.module("~/server/email", () => ({
}));
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");
@@ -72,6 +77,7 @@ describe("POST /api/the-nook/report", () => {
beforeEach(() => {
sentEmails.length = 0;
tokenAnswers.length = 0;
rateKeys.length = 0;
});
it("emails the owner a report containing the snapshot, escaped", async () => {
@@ -133,10 +139,49 @@ describe("POST /api/the-nook/report", () => {
it("still succeeds when no fingerprint header exists", async () => {
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(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 () => {

View File

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

View File

@@ -1,6 +1,7 @@
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import { SUPPORT_EMAIL } from "./content";
/**
* 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
* 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() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<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 =
"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 =
"/Nessa Exports/Nessa-iOS-Default-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,
PAGE_META
} from "~/routes/nessa/deletion-content";
import { SUPPORT_EMAIL } from "./content";
export default function NessaDeletionPage() {
return (
@@ -54,6 +55,18 @@ export default function NessaDeletionPage() {
removed. No data related to the account is retained in any way.
</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
product={DELETION_PRODUCT_KEY}
cookieName={DELETION_COOKIE_NAME}

View File

@@ -30,6 +30,7 @@ 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 NessaPrivacyPolicy() {
return (
@@ -174,13 +175,27 @@ export default function NessaPrivacyPolicy() {
<span class="-ml-4 pr-2">5.</span> Contact Us
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Reaching Out:</div> If there are any
questions or comments regarding this privacy policy, you can
contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
here
</A>
.
<div class="-ml-6">(a) Data Controller:</div> Nessa is operated by
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{" "}
<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>
</div>
</ol>

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

@@ -59,9 +59,9 @@ export default function NookLanding() {
<PageHead
title="Agent control for your Mac"
ogTitle="The Nook — agent control for your Mac"
description="The Nook — a native macOS utility for coding-agent orchestration, fan and thermal control."
description="Monitor coding agents, respond to permission requests, and manage your Mac\'s cooling, battery, and audio with The Nook."
ogImage={`https://${site().domain}/nook/og.png`}
ogDescription="A one-time-purchase macOS app for coding-agent orchestration, fan control, and thermal insight."
ogDescription="A macOS app for coding agents, fan control, battery charging, and per-app audio. Includes a 14-day trial and a one-time license."
/>
<SubdomainHeader />
@@ -96,11 +96,11 @@ export default function NookLanding() {
The Nook
</div>
<h1 class="text-text mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
Your Mac and Agents, under control
Manage your coding agents and your Mac
</h1>
<p class="text-subtext0 mb-2 max-w-xl text-xl">
Agent orchestration, fan and thermal control in a beautiful native
UI.
Follow your agents, respond to requests, and manage cooling,
charging, and audio from the top of your screen.
</p>
<p class="text-subtext1 mb-8 text-sm">
macOS 14.2+ · 14-day free trial · up to 3 devices
@@ -145,7 +145,7 @@ export default function NookLanding() {
In action
</p>
<h2 class="text-text mb-16 text-center text-3xl font-bold">
Built for the way you actually run agents
What you can do with The Nook
</h2>
<FeatureBreakdowns />
</div>
@@ -164,10 +164,9 @@ export default function NookLanding() {
Try it free for 14 days
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
Orchestrate your coding agents, take control of your fans and
battery, give every app its own volume, capture your screen, and
keep your day in the notch. When the trial ends, unlock everything
with a single one-time payment — no subscription, ever.
The trial includes every module. After 14 days, a one-time
purchase lets you keep using The Nook on up to three of your Macs.
There is no subscription.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
<Button
@@ -187,7 +186,7 @@ export default function NookLanding() {
</a>
</div>
<p class="text-subtext1 mt-6 text-xs">
Beta pricing — full price will be $15. One license covers up to 3 of
The beta price is $10. The regular price will be $15. One license covers up to 3 of
your own Macs.
</p>
</div>
@@ -229,8 +228,7 @@ export default function NookLanding() {
</form>
<Show when={resendSent()}>
<p class="text-subtext1 mt-4 text-center text-sm">
Check your inbox — if a license is registered to that email, your
key is on its way.
If a license is registered to that email, we'll send you the key.
</p>
</Show>
<Show when={resendError()}>

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const root = join(import.meta.dir, "../../..");
const read = (path: string) => readFileSync(join(root, path), "utf8");
const landingCopy = [
"src/routes/nook/index.tsx",
...[
"FeatureCollage",
"FeatureBreakdowns",
"IslandExtras",
"PhoneBeta",
"MoreDemos",
"UtilityDemos"
].map((name) => `src/components/nook/${name}.tsx`)
]
.map(read)
.join("\n");
// Like remote-claims.test.ts, this pins an editorial decision, not a tone score.
describe("Nook landing copy (NOOK-1037)", () => {
for (const phrase of [
"Your fleet, in your pocket",
"Timers that stack",
"Volume and brightness, under the notch",
"The sky, in the slot",
"Storage, measured",
"Wall power, on your terms",
"Fans that answer to you, not the scheduler",
"Your whole fleet, in one pixel strip",
"A hearth that tells the truth",
"A lens that stays asleep",
"Your buttons, orbiting the island",
"Silence it or max it",
"Sleep when you say",
"Panels you shape"
]) {
it(`replaces “${phrase}”`, () => {
expect(landingCopy).not.toContain(phrase);
});
}
});
// Source anatomy checks supplement the rendered browser checks.
const demos = read("src/components/nook/UtilityDemos.tsx");
describe("current utility demo anatomy (NOOK-1037)", () => {
it("shows the three calendar slot states and calendar-colored agenda rows", () => {
for (const text of ["ON AIR", "Work", "Thu, Nov 12", "Join meeting"])
expect(demos).toContain(text);
expect(demos).toMatch(/>\s*IN\s*</);
});
it("shows the power circuit and current battery controls", () => {
for (const text of [
"System draw",
"Adapter",
"Mac",
"Overheat protection",
"Charges past the limit; off on unplug"
])
expect(demos).toContain(text);
expect(demos).not.toContain("the pack stays young longer");
});
it("shows master routing, app percentages, pin and hide controls", () => {
for (const text of [
"System sounds",
"Microphone",
"Output volume",
"Pin app",
"Hide app",
"Default"
])
expect(demos).toContain(text);
});
});

View File

@@ -1,49 +1,32 @@
/**
* Regression test for the removed remote / SSH claims and the removed agent
* count (FRE-323, FRE-359 verdict B).
* Two page contracts live in this file, and they are reconciled on purpose.
*
* FRE-323 took the remote story off the site because it was unverified and
* demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
* installs the nook-hook" flow is app-UI-only, needs a hand-edited
* `~/.ssh/config` for any host not already in ssh config or on the tailnet,
* needs a one-time `ssh-copy-id` because the app runs ssh with
* `BatchMode=yes`, and has no manual fallback for minting the per-host
* credential. FRE-359 verdict B removed the coverage count for the same
* reason: the /agents table lists 13 of the registry's 18 rows.
* NOOK-1038 (2026-11-09, Mike's correction) supersedes FRE-323's blanket remote
* hold: one-click setup is a supported scoped claim for Tailscale peers and
* authorized known hosts, so the Remote cards and the setup demo belong on the
* page. Blanket authorization promises stay out.
*
* 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.
* FRE-997 (2026-11-04) restored the agent count. The page listed thirteen of the
* registry's eighteen rows, so FRE-359 verdict B dropped the count instead of
* correcting it; FRE-997 put all eighteen rows on /agents and made the roster an
* equality check again (`agents.test.ts`), so the count is publishable at 18 and
* the stale counts stay banned.
*
* The copy was not thrown away — it is parked in
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
* it is still there so nobody deletes the parked story as stale. It goes back
* on the site only when the one-click install ships and is recorded.
* The two lines met in the FRE-1695 reconciliation (Gitea was 12 commits ahead,
* GitHub 2). NOOK-1038's file repeated FRE-359's older "no coverage count"
* clause; that clause is superseded by FRE-997's landed correction and is the
* one line dropped here. Everything else from NOOK-1038 is taken verbatim, and
* `marketing/positioning.md` records both decisions side by side (coverage row:
* publishable at 18; remote row: approved, scoped). See `docs/deploy-source.md`.
*
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
*
* Deliberate exceptions, not audited here — both were weighed with the Chief
* of Staff at the go/no-go and left in place on purpose:
* - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip
* it" is a limitation disclosure, not a capability claim.
* - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to
* the removed remote story, but tailnet host lookup does exist. Raised on
* FRE-323 rather than widened into this correction.
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "../../..");
/** Shipped page and component source. Tests and drafts may quote the old copy. */
const AUDITED = [
"src/routes/nook/index.tsx",
"src/routes/nook/agents.tsx",
@@ -53,29 +36,6 @@ const AUDITED = [
"src/components/nook/IslandExtras.tsx"
];
/** Every sentence FRE-323 ordered off the site. */
const BANNED_REMOTE_CLAIMS = [
"Agents on other machines",
"one click installs the nook-hook",
"Any SSH box",
"Works across every hooked agent, local or remote",
"Remote sessions are ordinary rows",
"One-click SSH setup",
"remote machines over SSH",
"this Mac or remote machines"
];
/** FRE-359 verdict B: no stale count, in any form.
*
* "every coding agent" and "Supported agents" left this list with FRE-997:
* the page now lists all eighteen registry rows and `agents.test.ts` asserts
* that equality, so the completeness claim is backed rather than incomplete. */
const BANNED_COVERAGE = [
"all thirteen",
"thirteen coding agents",
"13 agents"
];
/** User-facing copy only: comments are not copy and may name the old wording. */
function copyOf(file: string): string {
return readFileSync(join(ROOT, file), "utf8")
@@ -83,44 +43,83 @@ function copyOf(file: string): string {
.replace(/^\s*\/\/.*$/gm, "");
}
describe("remote claims stay off the site (FRE-323)", () => {
describe("remote marketing reflects the supported setup (NOOK-1038)", () => {
it("fills the collage's missing sixth grid slot with host-labelled remote sessions", () => {
const collage = copyOf("src/components/nook/FeatureCollage.tsx");
// The hero spans two columns; four regular cards fill the other slots.
expect(collage.match(/<CollageCell\b/g)).toHaveLength(4);
expect(collage).toContain('kicker="Remote"');
expect(collage).toContain("One-click setup for Tailscale and authorized known hosts");
expect(collage).toContain('host="build-box"');
expect(collage).toContain('host="gpu-rig"');
});
it("restores the detailed setup section with both discovery sources", () => {
const breakdowns = copyOf("src/components/nook/FeatureBreakdowns.tsx");
expect(breakdowns).toContain('kicker="Remote"');
expect(breakdowns).toContain('title="Set up remote agents in one click"');
expect(breakdowns).toContain("Tailscale peer or an authorized host from your SSH config");
expect(breakdowns).toContain("<RemoteHostDemo />");
});
it("demonstrates setup progressing to Live and a host-labelled session", () => {
const demos = copyOf("src/components/nook/MoreDemos.tsx");
for (const label of [
"connecting",
"pushing the hook binary",
"configuring agents",
"checking the host can reach this Mac",
"Live",
"Set up"
])
expect(demos).toContain(label);
expect(demos).toContain('host="gpu-rig"');
});
for (const file of AUDITED) {
it(`${file} does not restore a removed remote sentence`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_REMOTE_CLAIMS) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
it(`${file} makes no blanket authorization promise`, () => {
const copy = copyOf(file).toLowerCase();
for (const phrase of [
"Any SSH box",
"Works across every hooked agent, local or remote",
"no SSH authorization needed"
])
expect(copy).not.toContain(phrase.toLowerCase());
});
}
it("/agents no longer offers remote sessions", () => {
const copy = copyOf("src/routes/nook/agents.tsx");
expect(copy).not.toContain("remote sessions report into the same grid");
expect(copy).not.toContain("on this Mac or on remote");
});
it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => {
const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8");
for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) {
expect(parked).toContain(phrase);
it("records the current decision without deleting the historical copy", () => {
for (const file of [
"marketing/drafts/remote-ssh/copy.md",
"marketing/drafts/remote-ssh/rationale.md",
"marketing/positioning.md"
]) {
const text = readFileSync(join(ROOT, file), "utf8");
expect(text).toContain("NOOK-1038");
expect(text).toContain("Tailscale and authorized known hosts");
}
expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain(
"FRE-320"
);
expect(
readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8")
).toContain("Any SSH box, one click away");
});
});
describe("the agent count is corrected, not dropped (FRE-997)", () => {
/** FRE-359 verdict B, minus the phrasings FRE-997 re-grounded in the full roster. */
const BANNED_COVERAGE = ["all thirteen", "thirteen coding agents", "13 agents"];
for (const file of AUDITED) {
it(`${file} states no stale count`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_COVERAGE) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
const copy = copyOf(file).toLowerCase();
for (const phrase of BANNED_COVERAGE) expect(copy).not.toContain(phrase);
});
}
it("/agents states the registry's size", () => {
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Supported agents");
});
it("the landing copy states the count the roster actually has", () => {
expect(copyOf("src/components/nook/FeatureBreakdowns.tsx")).toContain("18 agents");
});
});

View File

@@ -14,7 +14,10 @@ import { describe, it, expect } from "bun:test";
import { readdirSync, existsSync, statSync } from "fs";
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[] {
@@ -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 {
// Check for src/routes/<name>.tsx
if (existsSync(`${routesDir}/${routeName}.tsx`)) {
@@ -41,14 +58,12 @@ describe("subdomain routing dispatch invariant", () => {
const routesDir = "./src/routes";
it("every subdomain route has a top-level dispatch route", () => {
const prefixDirs = listRouteFiles(routesDir)
.filter((name) => {
try {
return statSync(`${routesDir}/${name}`).isDirectory();
} catch {
return false;
}
})
// List directory entries before filtering to route files. The previous
// shape filtered with `listRouteFiles` first, which only ever returns
// `.tsx`/`.ts` names, so `statSync(...).isDirectory()` was never true
// and the assertion ran over an empty list — it passed while `/terms`
// had no dispatch file (FRE-1670).
const prefixDirs = listDirectories(routesDir)
// Exclude non-product subdomains
.filter((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 { t } from "~/server/api/utils";
import { logAuditEvent } from "~/server/audit";
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
import { env } from "~/env/server";
import {
AUTH_CONFIG,
@@ -328,39 +329,15 @@ async function cleanupExpiredRateLimits(): Promise<void> {
}
/**
* Get client IP address from request headers.
* Only trusts X-Forwarded-For outside of local development (set by the Vercel
* edge network in production; trusted in tests so the header-parsing path is
* exercised). In local development, uses the socket address to prevent header
* spoofing.
* Get client IP address for a security decision (rate limiting, audit).
*
* Thin alias over `resolveTrustedClientIp` (./trusted-client-ip), which owns
* the trust order: the previous leftmost `x-forwarded-for` read was
* client-spoofable in our append-only proxy topology, and `x-real-ip` was
* read unconditionally — both fixed there (FRE-371).
*/
export function getClientIP(event: H3Event): string {
// In production on Vercel, X-Forwarded-For is set by the edge network
// and cannot be spoofed by clients. In dev, ignore it.
if (env.NODE_ENV !== "development") {
const forwarded = getHeaderValue(event, "x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0].trim();
}
const realIP = getHeaderValue(event, "x-real-ip");
if (realIP) {
return realIP;
}
}
// Fallback: try socket remote address
try {
const nodeReq = event.node.req;
if (nodeReq?.socket?.remoteAddress) {
const addr = nodeReq.socket.remoteAddress;
// Clean up IPv6-mapped IPv4 addresses
return addr.replace(/^::ffff:/, "");
}
} catch {
// socket access failed
}
return "unknown";
return resolveTrustedClientIp(event);
}
/**

View File

@@ -150,29 +150,100 @@ describe("Rate Limiting", () => {
});
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({
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);
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({
headers: { "x-real-ip": "192.168.1.2" }
});
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({
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: {
"x-forwarded-for": "192.168.1.1",
"x-real-ip": "192.168.1.2"
"cf-connecting-ip": "6.6.6.6",
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1",
"x-real-ip": "6.6.6.6"
}
});
const ip = getClientIP(event);
expect(ip).toBe("198.51.100.23");
});
it("a loopback socket peer lets the forwarded headers speak", () => {
const event = createMockEvent({
remoteAddress: "127.0.0.1",
headers: {
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
}
});
@@ -198,13 +269,23 @@ describe("Rate Limiting", () => {
it("should handle IPv6 addresses", () => {
const event = createMockEvent({
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);
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", () => {

View File

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