forked from Mike/freno-dev
Compare commits
16 Commits
FRE-1670-g
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12a063aa9 | ||
|
|
0137703341 | ||
|
|
4dac2e2928 | ||
|
|
680563e3f9 | ||
|
|
c5fb98af8d | ||
|
|
77e692273e | ||
|
|
a58b25501a | ||
| a0e6ff1044 | |||
| 6fd0f84806 | |||
| c7071b0e32 | |||
| 7fde259e0b | |||
| 25ec84ceb4 | |||
| 2fd2a4a71a | |||
|
|
1d9287a199 | ||
|
|
39d874a7e2 | ||
|
|
89f56fc78a |
@@ -22,3 +22,12 @@ a human reviews and moves it to `approved/` → the human publishes from `approv
|
||||
under their own account → the artifact (or a pointer) lands in `posted/`.
|
||||
|
||||
Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means.
|
||||
|
||||
## The weekly read
|
||||
|
||||
`bun --env-file=.env scripts/weekly-metrics.ts` (or `bun run metrics:weekly`)
|
||||
rewrites `metrics/weekly.md` from the live databases: the staged funnel, the
|
||||
campaign breakdown (`getAnalyticsSummary.topCampaigns`' grouping), and the one
|
||||
stage that moved. It needs `TURSO_DB_URL`, `TURSO_DB_TOKEN`, `NOOK_DB_URL` and
|
||||
`NOOK_DB_TOKEN` — no other production secret. The file is generated; never
|
||||
hand-edit it. The numbers are engineering's; the interpretation is the CMO's.
|
||||
|
||||
92
marketing/approved/agent-done-signal/copy.md
Normal file
92
marketing/approved/agent-done-signal/copy.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# How do you know when your coding agent is done?
|
||||
|
||||
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 fact-check
|
||||
> card, verdict: "Approve both — move to approved/"). Not published; a human
|
||||
> places it on nook.freno.me from `approved/`.
|
||||
> This page never names a product of ours and never names a competitor.
|
||||
> Internal target: first page of the FRE-199 pair, Thu 2026-11-06.
|
||||
> CTO review 2026-11-05: changes requested and applied — the `osascript`
|
||||
> example is attributed to the hooks guide, not the hooks reference; pair-1
|
||||
> citations standardised to code.claude.com.
|
||||
|
||||
Often, you don't — not from the terminal alone. A coding agent's turn can end in
|
||||
silence: the prompt returns, the cursor blinks, and a finished turn looks exactly
|
||||
like one that stalled, is still running, or is waiting on you. The fix is to stop
|
||||
trusting silence and wire the two moments that matter — *the turn ended* and *the
|
||||
agent needs you* — to something you will actually see.
|
||||
|
||||
## Why silence is the failure mode
|
||||
|
||||
A terminal coding agent ends a turn by printing a prompt. If you are in another
|
||||
window, another room, or running several agents at once, nothing in that output
|
||||
distinguishes "finished" from "blocked on a permission prompt" from "dead". The
|
||||
task being done and you finding out are two different events, and the second one
|
||||
has no default signal.
|
||||
|
||||
Practitioners keep rediscovering this. One operator running a self-hosted queue
|
||||
of autonomous agent sessions described sessions that "finished their work but
|
||||
never sent the done signal, and held their slots for hours" — their fix was to
|
||||
derive "done" from git state plus an idle transcript instead of trusting the
|
||||
agent's own signal. A public service post about a code-review plugin described
|
||||
the mirror image: a green checkmark and "No findings" when the review had not
|
||||
run at all. An empty result and a successful result rendered identically.
|
||||
|
||||
That is the trap in one sentence: **zero looks exactly like success.**
|
||||
|
||||
## What the turn-ending signal actually is
|
||||
|
||||
Claude Code gives you two hooks that bracket the moments worth surfacing:
|
||||
|
||||
- **`Stop`** fires when the agent finishes responding — the end of a turn.
|
||||
- **`Notification`** fires when Claude Code sends a notification. A permission
|
||||
prompt is one of these: the hook's payload carries a `notification_type` of
|
||||
`permission_prompt` and a message like "Claude needs your permission".
|
||||
- **`StopFailure`** fires instead of `Stop` when the turn ends on an API error —
|
||||
rate limits, authentication problems, and the like.
|
||||
|
||||
All three can run a command of your choosing. The hooks reference documents the
|
||||
events; the hooks guide includes a desktop-notification example built on
|
||||
macOS's `osascript`.
|
||||
|
||||
## What to check or do
|
||||
|
||||
1. **Wire turn-end to a notification you will actually notice.** A `Stop` hook
|
||||
that calls `osascript -e 'display notification …'` (or your terminal's
|
||||
notification escape) turns the silent prompt-return into a visible event.
|
||||
A terminal bell is not this — it is mute the moment you switch windows.
|
||||
2. **Route permission waits separately from completions.** The `Notification`
|
||||
hook's payload tells you which kind it is. A permission wait means the agent
|
||||
is *blocked on you*; treating it as "done" hides the one state that costs the
|
||||
most time.
|
||||
3. **Don't trust the exit signal alone.** Check the artifacts: `git status`,
|
||||
`git log`, whether the transcript has gone idle. The queue operator's rule
|
||||
generalises — a "done" that has to name what it is waiting on is a "done"
|
||||
you can act on.
|
||||
4. **Surface the failure path too.** A turn that ends on an API error fires
|
||||
`StopFailure`, not `Stop` — an alert wired to the wrong one misses exactly
|
||||
the runs that need attention.
|
||||
|
||||
One next step: take the agent command you run most often and give its end-of-turn
|
||||
a notification you would notice from across the room. That single wire closes the
|
||||
most expensive silence in the workflow.
|
||||
|
||||
---
|
||||
|
||||
## Fact-check table (not part of the page)
|
||||
|
||||
Every technical claim above, with its primary source. Claims about Claude Code's
|
||||
behaviour come from its own documentation, re-read 2026-11-05.
|
||||
|
||||
| # | Claim in the page | Source |
|
||||
|---|---|---|
|
||||
| 1 | `Stop` fires when the agent finishes responding (end of a turn); hook cadence is "per turn: UserPromptSubmit, Stop, StopFailure" | Claude Code hooks reference — https://code.claude.com/docs/en/hooks |
|
||||
| 2 | `Notification` fires "when Claude Code sends a notification"; example payload carries `notification_type: "permission_prompt"`, message "Claude needs your permission" | same hooks reference (Notification event section) |
|
||||
| 3 | `StopFailure` "runs instead of Stop when the turn ends due to an API error" (rate limits, authentication) | same hooks reference (StopFailure section) |
|
||||
| 4 | Hooks run a command of your choosing; a desktop-notification example using `osascript` is in the hooks guide | Claude Code hooks guide — https://code.claude.com/docs/en/hooks-guide (desktop-notification example) |
|
||||
| 5 | Operator anecdote: sessions that finished work but never signalled done, holding slots for hours; fix = derive done from git + idle transcript | r/ClaudeCode thread 1xjb3j0 ("Are you running a self-hosted queue of autonomous Claude Code sessions?", 2026-10-31), quoted in `drafts/listening.md` Scan 4 addendum. Quoted as one operator's described practice, not a measurement. |
|
||||
| 6 | PSA anecdote: code-review plugin showed green check + "No findings" without running | r/ClaudeCode 1xmnz2c (2026-11-03), logged in `drafts/listening.md` Scan 6. |
|
||||
| 7 | "Zero looks exactly like success" | Verbatim buyer phrasing, three independent instances (1xm09fs, 1xmnz2c, plus HN repeats) in `drafts/listening.md` Scans 5–8. Used as the problem's name, attributed to no one. |
|
||||
|
||||
**Ban compliance:** no product of ours named · no competitor named · no
|
||||
superlatives · no benchmark numbers · no invented testimonials (anecdotes are
|
||||
real threads, described without handles) · no price · no roadmap dates.
|
||||
72
marketing/approved/agent-done-signal/rationale.md
Normal file
72
marketing/approved/agent-done-signal/rationale.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Rationale — "How do you know when your coding agent is done?"
|
||||
|
||||
FRE-199 item 1, page 1 of the first pair. Drafted 2026-11-05 by CMO.
|
||||
|
||||
## The phrase
|
||||
|
||||
Primary: **"how do you know when your coding agent is done"** (variants: "agent
|
||||
finished silently", "claude code waiting for permission" overlaps on the
|
||||
blocked-half; this page owns the *done*-half).
|
||||
|
||||
Chosen by the FRE-199 selection rule: strongest buying intent we can answer
|
||||
honestly without naming a product. It is on the standing phrase list
|
||||
(`drafts/listening.md` §The phrase list: "how do I know when my agent is done").
|
||||
|
||||
## Why this phrase won the slot
|
||||
|
||||
- **Recurrence with independence.** The "zero looks exactly like success"
|
||||
pattern appears in at least four separate sources across Scans 5–9: 1xm09fs,
|
||||
the code-review PSA (1xmnz2c), and two HN commenters (fhub, sadilek), plus
|
||||
the queue operator's done-signal workaround (1xjb3j0). Four independent
|
||||
formulations of one pain in seven days is the strongest recurrence the
|
||||
backlog shows.
|
||||
- **Buying intent.** The people describing it are running agents unattended
|
||||
right now and are losing time to the gap — they are past curiosity.
|
||||
- **Answerable honestly, generically.** The mechanism (Stop / Notification /
|
||||
StopFailure hooks) is documented in the agent's own reference; every claim in
|
||||
the page traces to it. Nothing about our product is needed to answer it.
|
||||
- **Wedge-aligned.** "An agent blocked is the most expensive kind of idle" —
|
||||
the done-half of that sentence (idle you never learn about) is this page.
|
||||
|
||||
## Evidence trail
|
||||
|
||||
| Thread | Date | What it showed |
|
||||
|---|---|---|
|
||||
| r/ClaudeCode 1xjb3j0 — "self-hosted queue of autonomous Claude Code sessions" | 2026-10-31 | Sessions "finished their work but never sent the done signal, and held their slots for hours"; operator derives done from git + idle transcript; "a waiting one has to name what it's waiting on" |
|
||||
| r/ClaudeCode 1xm09fs | 2026-11-02 | First "zero looks exactly like success" instance (verbatim buyer phrasing) |
|
||||
| r/ClaudeCode 1xmnz2c — code-review plugin PSA | 2026-11-03 | Green check + "No findings" when the review never ran; third independent instance |
|
||||
| HN comments (fhub, sadilek) | 2026-11-04/05 | Human-attention concurrency ceiling quantified twice more |
|
||||
|
||||
All rows live in `drafts/listening.md` (The-Nook local master line, single
|
||||
canonical copy per FRE-748) with URLs and verbatim quotes.
|
||||
|
||||
## Gates before this page ships
|
||||
|
||||
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
|
||||
Freno). Page moves `pending/` → `approved/` on his read, per the marketing
|
||||
tree's hard rule.
|
||||
2. **CTO eyes on the agent-behaviour claims** — all four are sourced to the
|
||||
Claude Code hooks reference re-read 2026-11-05, but anything asserted about
|
||||
an agent's behaviour is CTO-verifyable, and the FRE-199 plan routes it to
|
||||
him.
|
||||
3. **Publish path:** `pending/` → `approved/` → a human places it on
|
||||
nook.freno.me (blog route). Nothing posts itself.
|
||||
|
||||
## Ban compliance (FRE-199 + positioning.md rule 1)
|
||||
|
||||
- The Nook: not named. The notch, the island, the companion: not mentioned.
|
||||
- Competitors (notch apps, agent monitors, Remote Control–style tools): not
|
||||
named. Remote Control is not mentioned either — the page answers the done-half
|
||||
without touching the away-from-desk half.
|
||||
- Claude Code is named only as the subject whose documented behaviour the page
|
||||
explains, with its own docs as the source — the same status the listening
|
||||
phrase list already gives it. It is the agent, not a competitor.
|
||||
- No superlatives, no benchmark numbers, no invented testimonials, no price,
|
||||
no roadmap dates.
|
||||
|
||||
## Cadence note
|
||||
|
||||
This page and `agent-waiting-on-permission/` are the first pair. The queue
|
||||
behind them (multiple-agents tracking, invisible-permission cluster) holds well
|
||||
above the three-candidate floor, so the two-a-week cadence holds without
|
||||
inventing phrases.
|
||||
113
marketing/approved/agent-parallel-tracking/copy.md
Normal file
113
marketing/approved/agent-parallel-tracking/copy.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# How do you keep track of multiple coding agents?
|
||||
|
||||
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 pair 2
|
||||
> fact-check card, verdict: "Approve both — move to approved/"). Not published;
|
||||
> a human places it on nook.freno.me from `approved/`. Internal target:
|
||||
> Mon 2026-11-09, met four days early. This page never names a product of ours
|
||||
> and never names a competitor.
|
||||
|
||||
The honest answer: not by watching them. Run three agents and the watching
|
||||
already fails — the session you are not reading is the one that finished, or
|
||||
the one sitting on a question, and both states look identical from the outside.
|
||||
The fix is not more windows; it is giving each agent a way to announce its own
|
||||
state, so checking becomes something that happens to you instead of something
|
||||
you have to remember.
|
||||
|
||||
## Why attention is the limit
|
||||
|
||||
Parallel agents are cheap to start and expensive to supervise. Every running
|
||||
session can stop and wait for you — a permission prompt, a question, a finished
|
||||
turn — and from your side of the screen those are invisible until you go look.
|
||||
Operators who run agents at scale keep describing the same wall in different
|
||||
words. One practitioner running dozens of parallel threads a day put it as the
|
||||
limit moving "from tokens to my attention"; another, asked how many sessions
|
||||
they could run, got the answer from their own comment section: not compute —
|
||||
you. A developer who ran agents unattended for a month summarised the failure
|
||||
as silence: "the six hours of silence was the real problem, not the dialog
|
||||
itself." And the most common collision is the simplest — two agents waiting on
|
||||
you while you are deep in the third, and one of them idle so long you lost the
|
||||
thread of what you asked it to do.
|
||||
|
||||
None of that is a speed problem. It is a visibility problem: *which* of your
|
||||
agents needs you, and *how do you find out without touring every window?*
|
||||
|
||||
## What a per-agent signal actually is
|
||||
|
||||
A terminal coding agent has no shared dashboard by default — each session is
|
||||
its own process, its own window. But its state changes are events, and events
|
||||
can be wired outward. Claude Code documents the pieces:
|
||||
|
||||
- **Every state you care about fires an event.** `Stop` fires when Claude
|
||||
finishes responding — the end of a turn. `Notification` fires when Claude
|
||||
Code sends a notification, and the notification's type tells you which kind
|
||||
of waiting it is: a `permission_prompt`, an `idle_prompt`, `agent_needs_input`,
|
||||
or `agent_completed`. `SessionStart` and `SessionEnd` bracket the whole
|
||||
session.
|
||||
- **The event names the session it came from.** Every hook payload carries a
|
||||
`session_id`, so one handler can serve every window and still know which
|
||||
agent fired.
|
||||
- **A hook is just a command you choose.** The events are configured in the
|
||||
settings files and run whatever command you point them at — append to a log
|
||||
file, update a status line, send a desktop notification that names the
|
||||
project.
|
||||
- **The status line can carry per-session state.** Claude Code's status line
|
||||
is a customizable bar driven by a script you write; it receives session data
|
||||
on every render, and the docs name "work across multiple sessions and need
|
||||
to distinguish them" as a reason it exists.
|
||||
|
||||
The pattern in all four: stop touring windows and make each agent's
|
||||
running / waiting / finished state observable where you already are.
|
||||
|
||||
## What to check or do
|
||||
|
||||
1. **Name the states before naming the tools.** For your workflow the states
|
||||
are usually three — running, waiting on you, finished — and the expensive
|
||||
one is always "waiting on you". A tracking setup that cannot distinguish a
|
||||
permission wait from a completion will quietly hide the state that costs
|
||||
the most.
|
||||
2. **Wire the waiting events first.** A `Notification` hook matched on
|
||||
`permission_prompt` and `agent_needs_input` catches the blocked cases; the
|
||||
payload's `session_id` lets one handler label each alert with the project
|
||||
it came from.
|
||||
3. **Route completions separately.** `Stop` means the turn ended — not that
|
||||
the work was right, and not that nothing is blocked elsewhere. Treating
|
||||
"finished" and "needs you" as one notification is how a waiting agent gets
|
||||
buried under a pile of done messages.
|
||||
4. **Make the state visible without switching windows.** A per-session status
|
||||
line, a log file the hook appends to, or a desktop notification that names
|
||||
the project all work — the test is whether you can answer "does anything
|
||||
need me?" without leaving the window you are in.
|
||||
5. **Expect the ceiling to be yours.** Operators consistently find the
|
||||
bottleneck before the machines do. The number of agents you can run is the
|
||||
number whose waiting state reaches you reliably — not the number your
|
||||
terminal can host.
|
||||
|
||||
One next step: run two agents side by side and wire only their permission-wait
|
||||
events to a notification that names the project. The first time the second
|
||||
agent's prompt reaches you before you happen to check its window is the whole
|
||||
argument.
|
||||
|
||||
---
|
||||
|
||||
## Fact-check table (not part of the page)
|
||||
|
||||
Every technical claim above, with its primary source. Claims about Claude
|
||||
Code's behaviour come from its own documentation, re-read 2026-11-05.
|
||||
|
||||
| # | Claim in the page | Source |
|
||||
|---|---|---|
|
||||
| 1 | `Stop` fires when Claude finishes responding; `SessionStart`/`SessionEnd` are per-session events | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (hook events table; "per session: SessionStart and SessionEnd; per turn: UserPromptSubmit, Stop, and StopFailure") |
|
||||
| 2 | `Notification` fires when Claude Code sends a notification; matcher supports notification types including `permission_prompt`, `idle_prompt`, `agent_needs_input`, `agent_completed` | same hooks reference (matcher table: "Notification | notification type") |
|
||||
| 3 | Every hook payload carries `session_id`, the current session identifier | same hooks reference (Common input fields: `session_id` — "Current session identifier") |
|
||||
| 4 | Hooks are configured in the settings files and run a command of your choosing | same hooks reference (configuration and command-hook examples) |
|
||||
| 5 | The status line is a customizable bar driven by a script, receives JSON session data, and the docs name "work across multiple sessions and need to distinguish them" as a use | Claude Code status line doc — https://code.claude.com/docs/en/statusline |
|
||||
| 6 | "The limit moved from tokens to my attention" / "fifty a day add up to something I could feel by Friday" | r/ClaudeCode 1xh5cum (2026-10-27, "Four months of 50+ parallel agent threads a day"), logged in `drafts/listening.md` Scan 3. Practitioner's own account, not a measurement. |
|
||||
| 7 | "How many sessions before you become the bottleneck" — the thread went to compute; the attention half stayed on the table | r/ClaudeCode 1xdjb2g (2026-10-23, 72 comments), logged in `drafts/listening.md` Scan 1. |
|
||||
| 8 | "The six hours of silence was the real problem, not the dialog itself" | r/ClaudeCode 1xipsk0 (2026-10-29, "~40 Claude Code agents unattended for a month"), quoted by the OP in-thread, logged in `drafts/listening.md` Scan 2. |
|
||||
| 9 | Two agents waiting while the human is in the third; idle so long the thread of the task was lost; "most of my idle time came from agents waiting on a permission prompt while I was in another window" | r/ClaudeAI 1xmzp2n (2026-11-03, "How do you keep track of multiple coding agents running in parallel?"), two independent commenters, logged in `drafts/listening.md` Scan 9. |
|
||||
| 10 | "You're the bottleneck agents were meant to eliminate" — approvals routing through one person | r/ClaudeCode 1xk0ghj (2026-10-31, "multiple agents working on multiple projects"), commenter's words, logged in `drafts/listening.md` Scan 4. |
|
||||
|
||||
**Ban compliance:** no product of ours named · no competitor named (herdr,
|
||||
tmux, dashboards: none named — the page answers generically) · no handles · no
|
||||
superlatives · no benchmark numbers · no invented testimonials (anecdotes are
|
||||
real threads, described without handles) · no price · no roadmap dates.
|
||||
71
marketing/approved/agent-parallel-tracking/rationale.md
Normal file
71
marketing/approved/agent-parallel-tracking/rationale.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Rationale — "How do you keep track of multiple coding agents?"
|
||||
|
||||
FRE-199 item 1, page 1 of the second pair. Drafted 2026-11-05 by CMO (internal
|
||||
target Mon 2026-11-09, beaten by four days — the backlog held, so drafting
|
||||
early cost nothing).
|
||||
|
||||
## The phrase
|
||||
|
||||
Primary: **"how do you keep track of multiple coding agents running in
|
||||
parallel"** (variants: "how many sessions before you become the bottleneck",
|
||||
"which agent is waiting", "agent tracking dashboard").
|
||||
|
||||
## Why this phrase won the slot
|
||||
|
||||
- **Strongest recurrence in the backlog.** Seven independent threads across
|
||||
nine scans state the multi-agent attention problem in their own words — more
|
||||
than any other cluster after the pair-1 phrases. It is the natural next page:
|
||||
the pair-1 pages answer *one* agent's silence (done-signal) and *one*
|
||||
permission wait (waiting-on-permission); this is the fleet-scale version of
|
||||
the same pain, and the buyers are already at fleet scale.
|
||||
- **Buying intent.** Every quoting operator is running multiple agents *now*
|
||||
and losing time to supervision, not exploring.
|
||||
- **Answerable honestly, generically.** The mechanism (per-session hook events
|
||||
with `session_id`, typed notifications, the status line) is documented in the
|
||||
agent's own reference; every claim traces to it. No product needed.
|
||||
- **Distinct from pair 1.** Done-signal owns "one agent, is it finished";
|
||||
waiting-on-permission owns "one agent, why is it blocked". This page owns
|
||||
"several agents, which one needs me" — the phrase, the evidence, and the
|
||||
mechanism (typed Notification matchers, `session_id`, status line) do not
|
||||
overlap pair 1's.
|
||||
|
||||
## Evidence trail
|
||||
|
||||
| Thread | Date | What it showed |
|
||||
|---|---|---|
|
||||
| r/ClaudeAI 1xmzp2n — "How do you keep track of multiple coding agents running in parallel?" | 2026-11-03 | The exact question as a fresh thread: "How do you notice when one is waiting, done, or stuck?"; two commenters independently: agents "sat waiting on a permission prompt while I was deep in the third", idle "long enough that I lost the thread of what I had asked it to do" |
|
||||
| r/ClaudeCode 1xh5cum — "Four months of 50+ parallel agent threads a day" | 2026-10-27 | "The limit moved from tokens to my attention"; "fifty a day add up to something I could feel by Friday" |
|
||||
| r/ClaudeCode 1xdjb2g — "How many sessions can you run before you become the bottleneck?" | 2026-10-23 | 72 comments; the thread went to compute and left the attention half open |
|
||||
| r/ClaudeCode 1xipsk0 — "~40 Claude Code agents unattended for a month" | 2026-10-29 | "The six hours of silence was the real problem, not the dialog itself" |
|
||||
| r/ClaudeCode 1xk0ghj — "multiple agents working on multiple projects" | 2026-10-31 | "If every decision routes through you, you're the bottleneck agents were meant to eliminate" |
|
||||
| Hacker News 50298436 (herdr launch) | 2026-10-30 | A 30-year tmux user's stated switch reason: "visual indication which agent is working and which is waiting for an input" — used in rationale only; the page does not name any tool |
|
||||
|
||||
All rows live in `drafts/listening.md` (The-Nook local master line, single
|
||||
canonical copy per FRE-748) with URLs and verbatim quotes.
|
||||
|
||||
## Gates before this page ships
|
||||
|
||||
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
|
||||
Freno). Page moves `drafts/` → `pending/` → `approved/` on his read, per
|
||||
the marketing tree's hard rule.
|
||||
2. **CTO eyes on the agent-behaviour claims** — five doc-sourced claims
|
||||
(hooks reference ×4, statusline doc ×1), all re-read 2026-11-05. Routed to
|
||||
the CTO per the FRE-199 plan.
|
||||
3. **Publish path:** `approved/` → a human places it on nook.freno.me.
|
||||
Nothing posts itself.
|
||||
|
||||
## Ban compliance (FRE-199 + positioning.md rule 1)
|
||||
|
||||
- The Nook: not named. The notch, the island, the companion: not mentioned.
|
||||
- Competitors: not named. The herdr evidence is rationale-only; the page's
|
||||
"what to check" recommends no tool at all — states and wiring, not products.
|
||||
- Claude Code is named only as the subject whose documented behaviour the page
|
||||
explains, with its own docs as the source — same status as pair 1.
|
||||
- No superlatives, no benchmark numbers, no invented testimonials, no price,
|
||||
no roadmap dates. Anecdotes are real threads, described without handles.
|
||||
|
||||
## Cadence note
|
||||
|
||||
Second pair. Queue behind it: the run-without-asking cluster (same pair), the
|
||||
fan-control phrase, and the invisible-permission variants — above the
|
||||
three-candidate floor. Two a week holds.
|
||||
102
marketing/approved/agent-run-without-asking/copy.md
Normal file
102
marketing/approved/agent-run-without-asking/copy.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Should you let your coding agent run without asking permission?
|
||||
|
||||
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 pair 2
|
||||
> fact-check card, verdict: "Approve both — move to approved/"). Not published;
|
||||
> a human places it on nook.freno.me from `approved/`. Internal target:
|
||||
> Mon 2026-11-09, met four days early. This page never names a product of ours
|
||||
> and never names a competitor.
|
||||
> CTO review 2026-11-05: changes requested and applied — the `Bash(git commit *)`
|
||||
> allowed / `Bash(git push *)` denied example removed (the docs' own
|
||||
> "What a Bash rule doesn't match" table shows a Bash rule does not stop every
|
||||
> invocation); the precedence claim stands.
|
||||
|
||||
You can — and the honest answer is that "should" is not one decision but two:
|
||||
what you are letting it do, and what happens when it does the wrong thing.
|
||||
Every operator who runs agents unattended converges on the same split — a free
|
||||
hand on what git can undo, a confirmation on what it can't — and the agent's
|
||||
own permission modes exist to draw that line somewhere you choose, not
|
||||
somewhere a flag chooses for you.
|
||||
|
||||
## The mechanism: a spectrum, not a switch
|
||||
|
||||
Claude Code's permission modes form a ladder from "ask for everything" to "ask
|
||||
for almost nothing", and each rung has a documented meaning:
|
||||
|
||||
- **`default`** prompts on the first use of each tool.
|
||||
- **`acceptEdits`** auto-accepts file edits and common filesystem commands
|
||||
(`mkdir`, `touch`, `mv`, `cp`) inside the working directory.
|
||||
- **`plan`** reads files and runs read-only shell commands but does not edit
|
||||
your source files — the agent explores, you approve the plan.
|
||||
- **`auto`** runs without routine prompts; before actions such as shell
|
||||
commands and network requests run, a background classifier checks that they
|
||||
align with your request.
|
||||
- **`bypassPermissions`** skips permission prompts, except for actions no mode
|
||||
auto-approves.
|
||||
|
||||
Two documented facts should shape where you stop on that ladder. First, the
|
||||
docs' own warning on the top rung: in `bypassPermissions` mode Claude Code
|
||||
skips prompts — including for writes to protected paths such as `.git` — and
|
||||
the mode belongs "in isolated environments like containers or VMs where Claude
|
||||
Code can't cause damage". Second, enforcement is real, not advisory: the docs
|
||||
state plainly that "permission rules are enforced by Claude Code, not by the
|
||||
model" — a `CLAUDE.md` instruction asking nicely for permission changes
|
||||
nothing.
|
||||
|
||||
## What to check or do
|
||||
|
||||
1. **Decide by blast radius, not by annoyance.** The question "how do I get
|
||||
fewer prompts?" tempts a jump to the top of the ladder. The better
|
||||
question: which of this agent's actions would be *undoable* if wrong? Edits
|
||||
inside a git worktree are; `rm -rf` on the wrong path, a pushed commit, and
|
||||
a message sent to a third party are not.
|
||||
2. **Move down the ladder one rung at a time.** `acceptEdits` in a worktree
|
||||
covers most of the prompt volume with the undo still in reach. If you
|
||||
consider `auto` or `bypassPermissions`, the docs' own container-or-VM
|
||||
condition is the bar: isolation first, autonomy second.
|
||||
3. **Write the rules instead of trusting the mode.** Rules are evaluated in
|
||||
order — deny, then ask, then allow — and the first match wins, so a broad
|
||||
deny holds even against a narrower allow. (What a Bash rule cannot do is
|
||||
match every way the same binary gets invoked — see the next step.)
|
||||
4. **Know what the rules do not cover.** A Bash rule matches the command text
|
||||
Claude writes, and the docs are explicit that this "isn't a security
|
||||
boundary around the program" — the same binary invoked by another path is
|
||||
not matched. For filesystem and network enforcement that does not depend
|
||||
on command text, the docs point at sandboxing.
|
||||
5. **Use hooks as the programmable layer.** A `PreToolUse` hook sees every
|
||||
tool call before it runs and can return a `deny` decision — the reference
|
||||
builds one that blocks `rm -rf`. A silent hook does not approve anything;
|
||||
the call just continues through the normal permission flow.
|
||||
6. **Leave the failure path a way to reach you.** An unattended run that hits
|
||||
a prompt nobody sees stops silently — that is the failure mode of unattended
|
||||
work, not of permissions. Whatever mode you pick, wire the
|
||||
"waiting on you" event somewhere you will actually notice.
|
||||
|
||||
One next step: write down the three actions your agent takes that you could
|
||||
not undo, and put them in `deny` or `ask` rules today. Whatever mode you run
|
||||
above them, those three now require a human — which is the whole point of the
|
||||
ladder.
|
||||
|
||||
---
|
||||
|
||||
## Fact-check table (not part of the page)
|
||||
|
||||
Every technical claim above, with its primary source. Claims about Claude
|
||||
Code's behaviour come from its own documentation, re-read 2026-11-05.
|
||||
|
||||
| # | Claim in the page | Source |
|
||||
|---|---|---|
|
||||
| 1 | Mode meanings: `default` prompts on first use; `acceptEdits` auto-accepts file edits and common filesystem commands (`mkdir`, `touch`, `mv`, `cp`) in the working directory; `plan` reads files and runs read-only shell commands but doesn't edit source; `auto` runs without routine prompts with a background classifier checking alignment; `bypassPermissions` skips prompts except actions no mode auto-approves | Claude Code permissions doc — https://code.claude.com/docs/en/permissions (Permission modes table) |
|
||||
| 2 | Docs' warning: in `bypassPermissions` mode Claude Code skips prompts including for writes to protected paths such as `.git`; "Only use this mode in isolated environments like containers or VMs where Claude Code can't cause damage" | same permissions doc (warning under the modes table) |
|
||||
| 3 | "Permission rules are enforced by Claude Code, not by the model" | same permissions doc (note under Manage permissions) |
|
||||
| 4 | Rules evaluated in order deny → ask → allow; first match wins; a broad deny holds against a narrower allow | same permissions doc (Manage permissions) |
|
||||
| 5 | A Bash rule matches the command text Claude writes and "isn't a security boundary around the program" (`Bash(curl *)` doesn't stop `/usr/bin/curl …`); for filesystem/network enforcement independent of command text the docs point at sandboxing | same permissions doc (What a Bash rule doesn't match) |
|
||||
| 6 | A `PreToolUse` hook fires before a tool call executes, can block it, and can return a `permissionDecision` of `"deny"`; the reference builds a hook that blocks `rm -rf`; a silent hook does not approve — the call continues through the normal permission flow | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (PreToolUse decision control walkthrough) |
|
||||
| 7 | Undo-split practice ("free hand on what git can undo, confirmation on what it can't") | r/ClaudeCode 1xkoz06 (2026-10-31), commenter's described practice, logged in `drafts/listening.md` Scan 3 addendum; also r/ClaudeCode 1xexhwf commenters (VM, worktrees, blast-radius split). Described as operator practice, not a rule. |
|
||||
| 8 | An unattended run that hits a prompt nobody sees stops silently | r/ClaudeCode 1xipsk0 (2026-10-29), commenter: "A run started from launchd or a background service can hit a prompt that no one will ever see", logged in `drafts/listening.md` Scan 2. |
|
||||
| 9 | The trust asymmetry: comfortable with a model writing code, "considerably less comfortable giving an autonomous agent broad access to my development environment" | r/ClaudeCode 1xk8imc (2026-10-31, "Has anyone built a safe, autonomous coding workflow…?", 43 comments), OP's words, logged in `drafts/listening.md` Scan 4. |
|
||||
| 10 | The operator's own safety menu: "committing before every run? containers? `--dangerously-skip-permissions` and hoping?" | r/ClaudeAI 1xk277d (2026-10-31, "How often has a coding agent done something you had to undo?"), OP's words, logged in `drafts/listening.md` Scan 4. Quoted as the question's shape, not as guidance. |
|
||||
|
||||
**Ban compliance:** no product of ours named · no competitor named · no
|
||||
handles · no superlatives · no benchmark numbers · no invented testimonials ·
|
||||
no price · no roadmap dates. The docs' container/VM warning is quoted as the
|
||||
agent's own documentation, not as our policy.
|
||||
75
marketing/approved/agent-run-without-asking/rationale.md
Normal file
75
marketing/approved/agent-run-without-asking/rationale.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Rationale — "Should you let your coding agent run without asking permission?"
|
||||
|
||||
FRE-199 item 1, page 2 of the second pair. Drafted 2026-11-05 by CMO (internal
|
||||
target Mon 2026-11-09, beaten by four days).
|
||||
|
||||
## The phrase
|
||||
|
||||
Primary: **"do you let your coding agent run without asking permission"**
|
||||
(variants: "safe autonomous coding workflow", "approve every step or skip
|
||||
permissions", "is dangerously-skip-permissions safe").
|
||||
|
||||
On the standing phrase list (`drafts/listening.md` §The phrase list: "do you
|
||||
let claude code run without asking").
|
||||
|
||||
## Why this phrase won the slot
|
||||
|
||||
- **Recurrence with independence.** Four separate threads state the autonomy
|
||||
question from four angles: the direct one (1xexhwf, 24 comments), the trust
|
||||
asymmetry (1xk8imc, 43 comments), the safety menu (1xk277d), and the
|
||||
remove-permissions ask (1xfe6eh — Desktop, recorded, not answered). The
|
||||
cluster has run continuously since Scan 1.
|
||||
- **Buying intent.** These are people configuring or rejecting autonomy right
|
||||
now — the objection vocabulary came straight out of this cluster
|
||||
("--dangerously-skip-permissions and hoping").
|
||||
- **Answerable honestly, generically.** The mode ladder, the docs' own
|
||||
container/VM warning, the enforcement note, and `PreToolUse` deny are all
|
||||
documented; every claim traces to the agent's docs. No product needed.
|
||||
- **Distinct from pair 1's permission page.** `agent-waiting-on-permission`
|
||||
answers "why is it *blocked* and why didn't I know" — the visibility of a
|
||||
single stuck prompt. This page answers "should I *grant* autonomy and how do
|
||||
I bound it" — the mode ladder, rule precedence, sandboxing, and hooks as
|
||||
enforcement. Shared vocabulary (modes, rules) exists because the docs are
|
||||
one system; the question, the claims, and the advice do not overlap.
|
||||
|
||||
## Evidence trail
|
||||
|
||||
| Thread | Date | What it showed |
|
||||
|---|---|---|
|
||||
| r/ClaudeCode 1xexhwf — "Do you let Claude Code run without asking, or approve every step?" | 2026-10-25 | The direct question, 24 comments; the thread became a safety argument (VM, worktrees, empty-variable `rm -rf`) — that argument *is* the page's subject |
|
||||
| r/ClaudeCode 1xk8imc — "Has anyone built a safe, autonomous coding workflow…?" | 2026-10-31 | "Comfortable having another model work on code. Considerably less comfortable giving an autonomous agent broad access to my development environment" — the trust asymmetry, 43 comments |
|
||||
| r/ClaudeAI 1xk277d — "How often has a coding agent done something you had to undo?" | 2026-10-31 | "What you do to stay safe today: committing before every run? containers? `--dangerously-skip-permissions` and hoping?" — the operator's own menu |
|
||||
| r/ClaudeCode 1xipsk0 — "~40 agents unattended for a month" | 2026-10-29 | "A run started from launchd or a background service can hit a prompt that no one will ever see" — the unattended failure path |
|
||||
| r/ClaudeCode 1xkoz06 | 2026-10-31 | The undo-split practice (also cited on pair 1; real thread, described without handles) |
|
||||
| r/ClaudeAI 1xfe6eh — "How can I remove Claude's need to ask me for permission…" | 2026-10-30 | Recorded, not used: Claude Desktop computer-use, not Code — a mention there would be noise. Shows the phrase's reach |
|
||||
|
||||
All rows live in `drafts/listening.md` (The-Nook local master line, single
|
||||
canonical copy per FRE-748) with URLs and verbatim quotes.
|
||||
|
||||
## Gates before this page ships
|
||||
|
||||
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
|
||||
Freno). Page moves `drafts/` → `pending/` → `approved/` on his read.
|
||||
2. **CTO eyes on the agent-behaviour claims** — six doc-sourced claims
|
||||
(permissions doc ×5, hooks reference ×1), all re-read 2026-11-05. Routed to
|
||||
the CTO per the FRE-199 plan.
|
||||
3. **Publish path:** `approved/` → a human places it on nook.freno.me.
|
||||
Nothing posts itself.
|
||||
|
||||
## Ban compliance (FRE-199 + positioning.md rule 1)
|
||||
|
||||
- The Nook: not named. The notch, the island, the companion: not mentioned.
|
||||
- Competitors: not named. Sandboxing and hooks are described as the agent's
|
||||
own documented mechanisms, not as anyone's product.
|
||||
- Claude Code is named only as the subject whose documented behaviour the page
|
||||
explains, with its own docs as the source — same status as pair 1.
|
||||
- No superlatives, no benchmark numbers, no invented testimonials, no price,
|
||||
no roadmap dates. Anecdotes are real threads, described without handles.
|
||||
|
||||
## Cadence note
|
||||
|
||||
Second pair. Queue behind it: the fan-control phrase ("mac fan control
|
||||
claude", standing phrase list), the invisible-permission variants
|
||||
(dontallow/TCC cluster), and the notification-reliability thread (1xm6frp,
|
||||
held back for overlap with done-signal). Above the three-candidate floor; two
|
||||
a week holds.
|
||||
86
marketing/approved/agent-waiting-on-permission/copy.md
Normal file
86
marketing/approved/agent-waiting-on-permission/copy.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Why is my coding agent sitting on a permission prompt?
|
||||
|
||||
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 fact-check
|
||||
> card, verdict: "Approve both — move to approved/"). Not published; a human
|
||||
> places it on nook.freno.me from `approved/`.
|
||||
> This page never names a product of ours and never names a competitor.
|
||||
> Internal target: second page of the FRE-199 pair, Fri 2026-11-07.
|
||||
> CTO review 2026-11-05: changes requested and applied — rule precedence
|
||||
> corrected to deny / ask / allow (body previously read allow / ask / deny,
|
||||
> contradicting the fact-check table and the source); pair-1 citations
|
||||
> standardised to code.claude.com.
|
||||
|
||||
Because it tried to do something its permission settings don't allow, and the
|
||||
default is to wait — indefinitely and quietly — for you. The prompt is not the
|
||||
expensive part; the hour you spent not knowing it was waiting is. The way out is
|
||||
not to approve faster, it's to make the prompts rare and the waiting visible.
|
||||
|
||||
## The mechanism
|
||||
|
||||
Claude Code's permission system decides, per tool call, whether to run, refuse,
|
||||
or stop and ask. In its `default` mode, it prompts for permission on the first
|
||||
use of each tool. Modes are set with `defaultMode` in the settings files, and a
|
||||
session's rules come from deny / ask / allow lists evaluated in that order —
|
||||
the first match wins.
|
||||
|
||||
You can see the whole rule set at any time with `/permissions`, which lists
|
||||
every permission rule and the settings file each one came from. Rules can name
|
||||
a specific use — `Bash(npm run build)` matches exactly that command — or a
|
||||
wildcard, like `Bash(git log *)`.
|
||||
|
||||
So the prompt you came back to an hour later is not a malfunction. It is the
|
||||
system working as configured, on a call your rules never covered — and the
|
||||
"waiting" state being invisible is what turned ten seconds of approval into a
|
||||
lost hour.
|
||||
|
||||
## What to check or do
|
||||
|
||||
1. **Read the prompt before automating it away.** It names the tool and the
|
||||
input. The right response to "it keeps asking" is usually a rule, not a
|
||||
wider mode.
|
||||
2. **Allowlist the undoable, prompt on the irreversible.** Reads, tests, and
|
||||
status commands are safe to allow — `git` is the undo for edits. Anything
|
||||
that reaches another person or another system — network calls, deletes,
|
||||
deploys — is worth keeping on ask. Rule syntax is in the permission docs;
|
||||
`/permissions` shows what you already have.
|
||||
3. **Consider `acceptEdits` before anything broader.** It auto-accepts file
|
||||
edits and common filesystem commands inside the working directory. That is
|
||||
usually the difference between ten prompts a session and two — without
|
||||
handing over network or delete.
|
||||
4. **Bound the blast radius instead of removing the gate.** If you do widen
|
||||
what runs unattended, do it where a mistake is recoverable: a worktree per
|
||||
task, commits pushed early, nothing irreversible in reach. Operators who run
|
||||
agents unattended describe exactly this split — free hand on what git can
|
||||
undo, confirmation on what it can't.
|
||||
5. **Make the wait visible.** Claude Code fires a `Notification` hook when it
|
||||
sends a notification, and a permission prompt is one — the payload carries a
|
||||
`notification_type` of `permission_prompt`. A hook on that event, wired to a
|
||||
desktop notification you'd see from another window, turns the silent wait
|
||||
back into a ten-second approval.
|
||||
|
||||
One next step: run `/permissions` today and add allow rules for the three
|
||||
commands you find yourself approving most. The prompts that remain will be the
|
||||
ones that deserve a read.
|
||||
|
||||
---
|
||||
|
||||
## Fact-check table (not part of the page)
|
||||
|
||||
Every technical claim above, with its primary source. Claims about Claude
|
||||
Code's behaviour come from its own documentation, re-read 2026-11-05.
|
||||
|
||||
| # | Claim in the page | Source |
|
||||
|---|---|---|
|
||||
| 1 | In `default` mode Claude Code "prompts for permission on first use of each tool" | Claude Code permissions doc — https://code.claude.com/docs/en/permissions (Permission modes table) |
|
||||
| 2 | `defaultMode` in settings files sets the default mode for new sessions | same permissions doc (Permission modes section) |
|
||||
| 3 | Rules are evaluated deny → ask → allow, first match wins | same permissions doc (Permission rule syntax) |
|
||||
| 4 | `/permissions` lists all permission rules and the settings.json each comes from | same permissions doc (Manage permissions) |
|
||||
| 5 | `Bash(npm run build)` matches the exact command; `Bash(git log *)` wildcard form | same permissions doc (Use specifiers for fine-grained control) |
|
||||
| 6 | `acceptEdits` "automatically accepts file edits and common filesystem commands such as mkdir, touch, mv, cp for paths in the working directory" | same permissions doc (Permission modes table) |
|
||||
| 7 | A `Notification` hook fires when Claude Code sends a notification; permission prompts carry `notification_type: "permission_prompt"` and a message like "Claude needs your permission" | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (Notification event) |
|
||||
| 8 | Undo-split practice ("free hand on what git can undo, confirmation on what it can't") | r/ClaudeCode 1xkoz06 (2026-10-31, u/tomm3hshipp thread), commenter's described practice, logged in `drafts/listening.md` Scan 3 addendum. Described as one operator's approach, not a rule. |
|
||||
| 9 | The 55-minutes-lost framing | r/ClaudeAI 1xhbc71 (2026-10-28): "coming back an hour later to find it's been sitting on a permission prompt for 55 minutes". Used as the problem's shape, not quoted as a person. |
|
||||
|
||||
**Ban compliance:** no product of ours named · no competitor named · no
|
||||
superlatives · no benchmark numbers · no invented testimonials · no price · no
|
||||
roadmap dates.
|
||||
77
marketing/approved/agent-waiting-on-permission/rationale.md
Normal file
77
marketing/approved/agent-waiting-on-permission/rationale.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Rationale — "Why is my coding agent sitting on a permission prompt?"
|
||||
|
||||
FRE-199 item 1, page 2 of the first pair. Drafted 2026-11-05 by CMO.
|
||||
|
||||
## The phrase
|
||||
|
||||
Primary: **"claude code waiting for permission"** and the away-from-desk variant
|
||||
("approvals when you're away from the computer"). First phrase on the standing
|
||||
list (`drafts/listening.md` §The phrase list).
|
||||
|
||||
## Why this phrase won the slot
|
||||
|
||||
- **The strongest single thread in the backlog.** r/ClaudeAI 1xhbc71 (2026-10-28,
|
||||
47 comments and moving) asks the buying question outright: kicked off a long
|
||||
task, came back an hour later, agent had been sitting on a permission prompt
|
||||
for 55 minutes. The thread's own answers (remote control, tmux+SSH,
|
||||
skip-permissions, allowlists) prove demand; none of them makes the wait
|
||||
visible at the machine — that is the honest gap this page answers without
|
||||
naming anyone.
|
||||
- **The week's objection vocabulary.** Scans 1–9 keep returning "the model keeps
|
||||
handing control back" and "stops and waits for me" — permission friction is
|
||||
the category's core complaint, not a niche one.
|
||||
- **Recurring independent accounts.** The invisible-permission theme has three
|
||||
independent accounts in one week (1xksutq — gh used without a visible prompt;
|
||||
1xkejsp's Linux panel; the scheduled-tasks silent-stall finding), so the
|
||||
search demand is not one loud thread.
|
||||
- **Wedge-aligned.** This is the literal wedge sentence's territory — "an agent
|
||||
blocked on a permission prompt is the most expensive kind of idle" — answered
|
||||
with the agent's own documented mechanism.
|
||||
|
||||
## Deliberate scoping
|
||||
|
||||
This page owns the **blocked-half** of the approval problem (why prompts happen,
|
||||
how to make them rare, how to make the wait visible at the machine). The
|
||||
**away-half** (controlling a remote agent from elsewhere) is a different phrase,
|
||||
a different page, and one where the honest answer is dominated by the agent
|
||||
vendor's own remote feature — the page deliberately does not go there
|
||||
(`drafts/listening.md` Scan 1 category intel #1).
|
||||
|
||||
## Evidence trail
|
||||
|
||||
| Thread | Date | What it showed |
|
||||
|---|---|---|
|
||||
| r/ClaudeAI 1xhbc71 — "How do you handle Claude Code approvals when you're away from your computer?" | 2026-10-28 | 55-minute wait on a permission prompt; 47 comments of demand; "what's still annoying about your setup?" |
|
||||
| r/ClaudeCode 1xkoz06 — "How permissive are you with AI and for how long?" | 2026-10-31 | The undo-split: "file edits, tests and branches get a free hand, since git is the undo. Anything that reaches another person … stays on confirm" |
|
||||
| r/ClaudeCode 1xksutq — gh used without visible prompt | 2026-11-01 | Third independent invisible-permission account of the week |
|
||||
| r/ClaudeCode 1xkejsp — Linux panel app | 2026-10-31 | Per-session "Needs You" differentiation shipping elsewhere; the need is real and cross-platform |
|
||||
|
||||
All rows live in `drafts/listening.md` with URLs and verbatim quotes.
|
||||
|
||||
## Gates before this page ships
|
||||
|
||||
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
|
||||
Freno); page moves `pending/` → `approved/` on his read.
|
||||
2. **CTO eyes on the agent-behaviour claims** — all eight doc-sourced claims
|
||||
re-read 2026-11-05; the FRE-199 plan routes anything asserted about an
|
||||
agent's behaviour to the CTO.
|
||||
3. **Publish path:** `pending/` → `approved/` → a human places it on
|
||||
nook.freno.me (blog route). Nothing posts itself.
|
||||
|
||||
## Ban compliance (FRE-199 + positioning.md rule 1)
|
||||
|
||||
- The Nook: not named; notch/island/companion not mentioned.
|
||||
- Competitors: not named — including the agent vendor's remote feature, which
|
||||
the page never needs to mention because it stays on the at-the-machine half.
|
||||
- Claude Code is named only as the subject whose documented permission system
|
||||
the page explains, with its own docs as the source — same status the
|
||||
listening phrase list already gives it.
|
||||
- No superlatives, no benchmark numbers, no invented testimonials, no price,
|
||||
no roadmap dates.
|
||||
|
||||
## Cadence note
|
||||
|
||||
Second page of the first pair. Queue behind it: multi-agent tracking ("How do
|
||||
you keep track of multiple coding agents running in parallel?", 1xm6frp,
|
||||
2026-11-03) and the invisible-permission cluster — the queue holds above the
|
||||
three-candidate floor, so the two-a-week cadence holds.
|
||||
91
marketing/briefs/registry-wave-2.md
Normal file
91
marketing/briefs/registry-wave-2.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Registry wave 2 — brief and copy (FRE-199 item 3)
|
||||
|
||||
**Owner:** CMO prepares · sponsor submits · **Copy ready 2026-11-05** (target was
|
||||
Fri 2026-11-13) · **Submit:** week of 2026-11-16, after the wave-1 readback.
|
||||
**Status:** `briefs/` — nothing here has been posted; humans hold every account;
|
||||
no agent authenticates, posts or sends.
|
||||
|
||||
## What wave 2 is, and what it waits for
|
||||
|
||||
Wave 1 is FRE-207's packet (`The-Nook` worktree `drafts/directory-submissions.md`
|
||||
+ `drafts/click-list.md`): 8 targets, CTO claim verdict applied 2026-11-01
|
||||
(C1–C5 signed, C7 refused → relay sentence), sponsor-owned inputs G2 (price
|
||||
line), G3 (`@freno.me` mailbox), G4 (screenshots). **Wave-2 submission waits on
|
||||
the wave-1 readback** — what actually got submitted, what got listed, what was
|
||||
rejected and why — so targets can be re-ranked on evidence instead of guesswork.
|
||||
The readback lives on the FRE-207 issue; this brief is ready either way.
|
||||
|
||||
**Copy rule inherited from wave 1:** paste the wave-1 blocks, do not improvise.
|
||||
Only claims signed by the CTO verdict ship:
|
||||
|
||||
- C1 — works with 18 coding agents (compatibility fact, not a competitor mention).
|
||||
- C2 — answer an agent's approval from your Mac, iPhone or Watch.
|
||||
- C3 — sessions keep arriving while the Mac app is closed (widgets; fleet status only).
|
||||
- C4 — macOS 14.2+, universal.
|
||||
- C5 — iPhone/iPad and Apple Watch companions are **TestFlight beta**; no App Store link anywhere.
|
||||
- C7 — "no cloud service of its own" is refused; block D ships the relay sentence.
|
||||
- **Price line (G2):** "Free 14-day trial" only — no number — until CoS + sponsor
|
||||
clear the "$10 one-time licence" line for public listings (FRE-204 holds $10 in-app;
|
||||
the listing text is a separate publish decision).
|
||||
- Banned: superlatives, user counts, testimonials, competitor names.
|
||||
|
||||
## Targets — fetched live 2026-11-05, marked per the wave-1 legend
|
||||
|
||||
| # | Target | What it is | How it ships | Fit | Verified |
|
||||
|---|---|---|---|---|---|
|
||||
| W2-1 | `iCHAIT/awesome-macOS` (19.4k ★) | Curated macOS app list; PR-based | PR adding one line to the relevant section from `contributing.md`; copy: block A + block B compressed | High — category fit, persistent link | ✅ repo + contributing rules read 2026-11-05 |
|
||||
| W2-2 | 1000.tools | Curated tool directory, self-serve categories (AI, developer tools, productivity) | Category submission form; copy: block A + B | Medium-high | ✅ fetched 2026-11-05 (categories listed on page) |
|
||||
| W2-3 | uneed.best | Daily product launch site | Launch submission (account needed, held by a human) | Medium — launch-format, needs assets | ✅ fetched 2026-11-05 |
|
||||
| W2-4 | devhunt.org | Daily dev-tool launches | Launch submission | Medium | ✅ fetched 2026-11-05 |
|
||||
| W2-5 | Product Hunt | Launch site | Maker/hunter flow, on-screen (403 to plain fetch — read live before filing) | Low-medium this wave; big-audience launch usually wants FRE-203's launch-day coordination | ⚠️ bot-walled; fields read on screen |
|
||||
|
||||
Re-rank after the wave-1 readback: if directory listings (wave-1 §1/§3–5)
|
||||
produced traffic or referrers, wave 2 leads with W2-1/W2-2; if nothing was
|
||||
submittable, wave 2 waits until G3/G4 clear rather than submitting half-copy.
|
||||
|
||||
## Copy — paste-ready, inheriting the signed claims only
|
||||
|
||||
**One-liner (≤60):** `Your coding agents, at the edge of the screen` (43 chars — wave-1 block A).
|
||||
|
||||
**Short description (~250 chars):** wave-1 block B verbatim; if a field forces a
|
||||
price, "Free 14-day trial" and no number (G2).
|
||||
|
||||
**awesome-macOS PR line (one row, section: menu-bar / status-bar apps or the
|
||||
section the maintainers route notch apps to — read the README section headers
|
||||
before filing):**
|
||||
|
||||
```
|
||||
- [The Nook](https://nook.freno.me/) - Puts every coding-agent session at the
|
||||
edge of your screen and lets you answer approvals from your Mac, iPhone or
|
||||
Watch. Works with 18 coding agents. [![Open-Source Software][OSS Icon]] — not
|
||||
open source; omit the badge row, keep the description.
|
||||
```
|
||||
|
||||
(The badge syntax is copied from the list's own rows; The Nook is not open
|
||||
source, so the OSS badge is **omitted** — the line above shows the shape only.
|
||||
No TestFlight mention needed in a one-row listing; add "Companions for iPhone
|
||||
and Apple Watch (TestFlight beta)" only if the PR reviewer asks for platform
|
||||
detail — C5 wording.)
|
||||
|
||||
**Launch-site fields (W2-3/W2-4):** wave-1 blocks A, B, C (widgets — fleet
|
||||
status only), D (relay sentence, not C7's refused wording). Gallery needs G4's
|
||||
three real screenshots.
|
||||
|
||||
## Gates before anything here is submitted
|
||||
|
||||
| Gate | Owner | State |
|
||||
|---|---|---|
|
||||
| Wave-1 readback recorded (what submitted/listed/rejected) | sponsor → FRE-207 issue, carried to FRE-199 | owed before submission week |
|
||||
| G2 price line for public listings | CoS + sponsor | pending (copy ships without a number either way) |
|
||||
| G3 `hello@freno.me` mailbox | sponsor | pending |
|
||||
| G4 three real screenshots + logo PNG | sponsor or CTO | pending — blocks every upload field |
|
||||
| Claim sign-off | CTO | **cleared 2026-10-31** for C1–C5; C7 ships as the relay sentence |
|
||||
|
||||
## Provenance
|
||||
|
||||
- Targets fetched 2026-11-05: uneed.best, devhunt.org, 1000.tools (HTTP 200,
|
||||
content read); `iCHAIT/awesome-macOS` via GitHub API (19,441 stars,
|
||||
contributing.md linked from README); producthunt.com returns 403 to a plain
|
||||
fetch — marked ⚠️, read on screen before filing.
|
||||
- All claims inherited from FRE-207's CTO verdict of 2026-10-31 — no new
|
||||
claims introduced by this brief.
|
||||
96
marketing/drafts/agent-keeps-asking/copy.md
Normal file
96
marketing/drafts/agent-keeps-asking/copy.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Why does your coding agent keep stopping to ask you questions?
|
||||
|
||||
> **Status: draft — awaiting sponsor fact-check** (FRE-199 pair 3, internal target
|
||||
> Thu 2026-11-12). Not publishable from `drafts/`.
|
||||
> This page never names a product of ours and never names a competitor.
|
||||
|
||||
Most of the stopping is not the model being chatty — it is the permission system
|
||||
doing what it was told. In the default mode, the agent prompts before the first
|
||||
use of each tool; separately, its turn ends every time it finishes responding,
|
||||
and ending a turn with a question is finishing a turn. You cannot and should not
|
||||
eliminate every ask — but you can decide, once, which asks deserve an
|
||||
interruption and let the routine ones pass without you.
|
||||
|
||||
## Where the asks come from
|
||||
|
||||
**Permission gates.** In the default mode the agent "prompts for permission on
|
||||
first use of each tool" — so every new tool, and often every new command shape,
|
||||
costs one prompt. The configuration decides who answers:
|
||||
|
||||
- **`acceptEdits`** automatically accepts file edits and common filesystem
|
||||
commands (like `mkdir`, `touch`, `mv`, `cp`) for paths in your working
|
||||
directory — the single biggest ask category for most coding sessions, gone.
|
||||
- **Permission rules** do the fine work: `deny` rules block a tool outright,
|
||||
`allow` rules pass it silently, `ask` rules force the prompt. Order matters:
|
||||
rules are evaluated deny, then ask, then allow — the first match wins, and an
|
||||
allow rule cannot carve an exception out of a deny.
|
||||
- **Modes set the floor.** `defaultMode` in your settings picks where every new
|
||||
session starts. At the far end, `bypassPermissions` skips prompts entirely —
|
||||
the docs' own warning is that it belongs only in isolated environments like
|
||||
containers or VMs.
|
||||
|
||||
One fact worth internalising: permission rules are enforced by the coding
|
||||
agent's harness, not by the model. A line in your prompt asking it to "not ask"
|
||||
changes nothing about what it is allowed to do — the configuration is the only
|
||||
lever.
|
||||
|
||||
**Turn ends.** A `Stop` hook fires when the agent finishes responding — every
|
||||
turn, including the turns that end in a question. From the terminal's side of
|
||||
the glass, "it finished", "it's asking me something" and "it's waiting on a
|
||||
permission prompt" are the same silence. That is why the asking *feels*
|
||||
endless: each ask costs a context switch, and nothing distinguishes the asks
|
||||
from the completions.
|
||||
|
||||
Practitioners describe the pattern in their own words. A developer who measured
|
||||
why their sessions kept stopping found that the agent "just kept handing
|
||||
control back" even with a permissive mode already on — the model's own habit of
|
||||
ending turns to stay in the loop, on top of the permission asks. An operator
|
||||
running a team of agents with per-agent autonomy levels put the other side:
|
||||
"if every decision routes through you, you're the bottleneck agents were meant
|
||||
to eliminate."
|
||||
|
||||
## What to check or do
|
||||
|
||||
1. **Sort the asks by kind before touching anything.** File edits in your
|
||||
working directory: `acceptEdits` removes them wholesale. The same safe
|
||||
command every session: an `allow` rule. The commands that must never run: a
|
||||
`deny` rule. What is left is the set of questions only you can answer.
|
||||
2. **Write the rules in the right order.** Deny, then ask, then allow; first
|
||||
match wins. A broad deny stays broad — narrowing an allow elsewhere does not
|
||||
soften it.
|
||||
3. **Keep the genuinely ambiguous asks.** A mode that asks less is not a mode
|
||||
that asks smarter. `bypassPermissions` in a throwaway container is a
|
||||
documented pattern for unattended runs; using it on your daily machine is
|
||||
the docs' own caution, not a recommendation.
|
||||
4. **Make every stop visible.** Because every stop ends a turn, wire the
|
||||
turn-end and notification moments (the `Stop` and `Notification` hooks) so
|
||||
an ask reaches you where you actually are — and a permission wait is
|
||||
distinguishable from a finished job.
|
||||
|
||||
One next step: run one session with the asks tallied by kind — edits, known
|
||||
commands, everything else. The first two usually turn out to be rules you
|
||||
hadn't written yet; the third is the work.
|
||||
|
||||
---
|
||||
|
||||
## Fact-check table (not part of the page)
|
||||
|
||||
Every technical claim above, with its primary source. Claims about the
|
||||
permission system and hooks were verified against the live docs on 2026-11-05.
|
||||
|
||||
| # | Claim in the page | Source |
|
||||
|---|---|---|
|
||||
| 1 | Default mode "prompts for permission on first use of each tool" (labeled Manual; `manual` alias) | Claude Code permissions doc — https://code.claude.com/docs/en/permissions (permission modes table; fetched 2026-11-05) |
|
||||
| 2 | `acceptEdits` "automatically accepts file edits and common filesystem commands such as mkdir, touch, mv, and cp for paths in the working directory or additionalDirectories" | same page (permission modes table) |
|
||||
| 3 | Rules are evaluated in order deny, then ask, then allow; "the first match in that order determines the outcome"; "an allow rule can't carve an exception out of a deny rule" | same page (rule precedence section) |
|
||||
| 4 | `bypassPermissions` "skips permission prompts"; docs' caution: "Only use this mode in isolated environments like containers or VMs where Claude Code can't cause damage" | same page (permission modes table) |
|
||||
| 5 | `defaultMode` in settings files sets the default permission mode for new sessions | same page ("To change the default permission mode for new sessions, set defaultMode in your settings files") |
|
||||
| 6 | "Permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or CLAUDE.md shape what Claude tries to do, but they don't change what Claude Code allows." | same page (verbatim) |
|
||||
| 7 | `Stop` fires when the agent finishes responding; hook cadence is per turn | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (verified 2026-11-05 in FRE-199 round 1; unchanged) |
|
||||
| 8 | Anecdote: "it just kept handing control back"; sessions kept stopping with a permissive mode on | r/ClaudeCode thread 1xlfqmo ("I measured why my Claude Code sessions kept stopping…", 2026-11-01, u/belovoj), logged in `drafts/listening.md` Scan 5 #1. Quoted as one developer's described experience; the thread's own measurement figures are deliberately **not** cited. |
|
||||
| 9 | Anecdote: "if every decision routes through you, you're the bottleneck agents were meant to eliminate"; per-agent autonomy levels 0–5 | r/ClaudeCode thread 1xk0ghj ("multiple agents working on multiple projects", 2026-10-31, quote by u/iceydee), logged in `drafts/listening.md` Scan 4 #2. One operator's described practice. |
|
||||
|
||||
**Ban compliance:** no product of ours named · no competitor named · no
|
||||
superlatives · no benchmark numbers (the 1xlfqmo poster's measurement figures
|
||||
are deliberately not cited) · no invented testimonials (anecdotes are real
|
||||
threads, described without handles) · no price · no roadmap dates.
|
||||
52
marketing/drafts/agent-keeps-asking/rationale.md
Normal file
52
marketing/drafts/agent-keeps-asking/rationale.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Rationale — "Why does your coding agent keep stopping to ask you questions?"
|
||||
|
||||
FRE-199 item 1, page 2 of the third pair. Drafted 2026-11-06 by CMO (internal
|
||||
target Thu 2026-11-12 — six days early).
|
||||
|
||||
## The phrase
|
||||
|
||||
Primary: **"why does my coding agent keep stopping to ask me questions"**
|
||||
(variants: "agent keeps handing control back", "claude code keeps asking for
|
||||
permission"). The H1 carries the generic form; "handing control back" is the
|
||||
backlog's own added phrase (Scan 5's phrase-list finding).
|
||||
|
||||
## Why this phrase won the slot
|
||||
|
||||
- **Backlog evidence.** r/ClaudeCode 1xlfqmo (2026-11-01, Scan 5 #1): "Auto mode
|
||||
was already on, so permissions weren't the issue… it just kept handing
|
||||
control back"; "the model was forcably trying to keep me in the loop." The
|
||||
phrase-list finding from the same scan added "handing control back" as a
|
||||
standing query. Corroborated by 1xlxw0q (turns ending with "nothing yet") and
|
||||
the autonomy-ladder operator (Scan 4 #2, thread 1xk0ghj: "if every decision
|
||||
routes through you, you're the bottleneck agents were meant to eliminate").
|
||||
- **Shortlist position (c)** in the plan's pair-3 shortlist, recorded at
|
||||
revision 5 — the page extends the done-signal cluster (pair 1) with the
|
||||
handback half. The distinction is kept deliberately: *agent-done-signal*
|
||||
owns "how do I know it's done"; this page owns "why does it keep stopping on
|
||||
me". Different question, different searcher.
|
||||
- **Answerable honestly from verified sources only.** Every mechanism claim
|
||||
re-reads to the permissions doc and hooks reference — the same pages and the
|
||||
same claims the CTO verified in FRE-199 rounds 1–2 (mode ladder, precedence,
|
||||
acceptEdits scope, enforced-by-harness sentence, Stop cadence). Nothing new
|
||||
is asserted about agent behaviour.
|
||||
|
||||
## What the page deliberately does not do
|
||||
|
||||
- **Does not cite the 1xlfqmo poster's measurement figures** (the 28-of-73
|
||||
sample is the poster's own 18-day data, and the backlog row carries an
|
||||
explicit do-not-cite flag). The anecdote is quoted as experience, not
|
||||
measurement, and the fact-check table says so.
|
||||
- Does not promise the asks can be eliminated. The honest answer is that the
|
||||
asking is partly the permission system working as configured and partly the
|
||||
agent ending turns to stay in the loop — the page sorts which is which.
|
||||
- Does not touch environment contention (page 1 of this pair owns that half).
|
||||
|
||||
## Evidence trail
|
||||
|
||||
| Thread | Date | What it showed |
|
||||
|---|---|---|
|
||||
| r/ClaudeCode 1xlfqmo — "I measured why my Claude Code sessions kept stopping" | 2026-11-01 | "It just kept handing control back" with a permissive mode on; "forcibly trying to keep me in the loop" — the model-behaviour half |
|
||||
| r/ClaudeCode 1xk0ghj — "multiple agents working on multiple projects" (quote by u/iceydee) | 2026-10-31 | "If every decision routes through you, you're the bottleneck agents were meant to eliminate"; per-agent autonomy levels 0–5 — the configuration half |
|
||||
| r/ClaudeAI 1xlxw0q — "2,300 hours waiting" | 2026-11-02 | Turns ending in a one-word answer reload context each time — the per-ask cost, corroborating |
|
||||
|
||||
All rows live in `drafts/listening.md` (The-Nook local master line).
|
||||
72
marketing/drafts/agent-worktree-contention/copy.md
Normal file
72
marketing/drafts/agent-worktree-contention/copy.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# How do you manage worktrees when your agents end up waiting on each other?
|
||||
|
||||
> **Status: draft — awaiting sponsor fact-check** (FRE-199 pair 3, internal target
|
||||
> Thu 2026-11-12). Not publishable from `drafts/`.
|
||||
> This page never names a product of ours and never names a competitor.
|
||||
|
||||
Usually, the worktrees are not what your agents are waiting on — the environment
|
||||
they share is. Two agents can sit in two worktrees of the same repo and still
|
||||
queue up behind one dev server, one test database, one `docker compose` stack.
|
||||
The fix that scales is to multiply the environment per worktree, not to
|
||||
orchestrate the waiting.
|
||||
|
||||
## Why agents queue when the code doesn't
|
||||
|
||||
Git supports parallel work out of the box: `git worktree add <path> <branch>`
|
||||
creates a second working directory linked to the same repository — shared
|
||||
history, but its own HEAD, its own index, its own checked-out branch. Git even
|
||||
enforces the ownership half for you: it refuses to check out a branch into a
|
||||
second worktree while the first one holds it.
|
||||
|
||||
What git does not duplicate is everything the repo only *points at*: the dev
|
||||
server bound to port 3000, the database named after the project directory, the
|
||||
compose stack whose project name defaults to the folder you launched it from.
|
||||
Two agents in two worktrees that both bring up the same stack are two agents
|
||||
contending for one environment — and the queue looks like a worktree problem
|
||||
when it is an environment problem.
|
||||
|
||||
Practitioners who hit this converge on the same verdict. One, in a thread
|
||||
asking this exact question: the queue "comes from having one test env, so I'd
|
||||
multiply the env instead of orchestrating the waiting."
|
||||
|
||||
## What to check or do
|
||||
|
||||
1. **Give every worktree its own copy of the stack.** Docker Compose exists for
|
||||
exactly this: it uses a *project name* to isolate environments from each
|
||||
other, and names "multiple copies of a single environment, useful for
|
||||
running stable copies for each feature branch" as a use case. Set a unique
|
||||
one per worktree — the `-p` flag has top precedence, or use `name:` in the
|
||||
compose file — and the port and container-name collisions disappear.
|
||||
2. **Anything that cannot be multiplied, serialise on purpose.** One migration
|
||||
runner, one deploy target, one flaky hardware-in-the-loop device: put it
|
||||
behind a single serial step rather than letting two agents race it. A queue
|
||||
you chose is a queue you can reason about.
|
||||
3. **One branch, one worktree.** Git already refuses to check out the same
|
||||
branch in two worktrees; keep it that way. Two agents on one branch is a
|
||||
merge conflict waiting for both of them to finish.
|
||||
4. **Remember a fresh worktree starts unconfigured.** Env files, generated
|
||||
config and installed dependencies live in the worktree, not the repo — a new
|
||||
worktree is a new environment to set up, which is exactly why the per-worktree
|
||||
copy should be scripted, not hand-arranged.
|
||||
|
||||
One next step: find the resource your agents contend for most often — usually
|
||||
the test stack — and give it a per-worktree project name. The waiting usually
|
||||
resolves before any queueing tool is needed.
|
||||
|
||||
---
|
||||
|
||||
## Fact-check table (not part of the page)
|
||||
|
||||
Every technical claim above, with its primary source. Claims about git and
|
||||
Docker Compose were verified against the live docs on 2026-11-05.
|
||||
|
||||
| # | Claim in the page | Source |
|
||||
|---|---|---|
|
||||
| 1 | `git worktree add <path> [<commit-ish>]` creates a worktree "linked to the current repository, sharing everything except per-worktree files such as HEAD, index, etc." | git worktree docs — https://git-scm.com/docs/git-worktree (COMMANDS › add; fetched 2026-11-05) |
|
||||
| 2 | `add` "refuses to create a new worktree when <commit-ish> is a branch name and is already checked out by another worktree" | same page (OPTIONS › `--force`) |
|
||||
| 3 | Compose "uses a project name to isolate environments from each other"; "multiple copies of a single environment, useful for running stable copies for each feature branch" is a named use case; precedence: `-p` flag > `COMPOSE_PROJECT_NAME` > `name:` attribute > directory basename | Docker Compose docs, "Specify a project name" — https://docs.docker.com/compose/how-tos/project-name/ (fetched 2026-11-05) |
|
||||
| 4 | Anecdote: "The queue comes from having one test env, so I'd multiply the env instead of orchestrating the waiting" | r/ClaudeCode thread 1xlz2bs ("How do you manage worktrees when your agents end up waiting on each other?", 2026-11-02, u/Roland_culait / u/First-Painter7431), logged in `drafts/listening.md` Scan 6 #4. Quoted as one commenter's described practice, not a measurement. |
|
||||
|
||||
**Ban compliance:** no product of ours named · no competitor named · no
|
||||
superlatives · no benchmark numbers · no invented testimonials (the anecdote is
|
||||
a real thread, described without handles) · no price · no roadmap dates.
|
||||
46
marketing/drafts/agent-worktree-contention/rationale.md
Normal file
46
marketing/drafts/agent-worktree-contention/rationale.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Rationale — "How do you manage worktrees when your agents end up waiting on each other?"
|
||||
|
||||
FRE-199 item 1, page 1 of the third pair. Drafted 2026-11-06 by CMO (internal
|
||||
target Thu 2026-11-12 — six days early).
|
||||
|
||||
## The phrase
|
||||
|
||||
Primary: **"how do you manage worktrees when your agents end up waiting on each
|
||||
other"** — carried verbatim as the H1, because it is already a complete search
|
||||
phrase with the exact buying intent.
|
||||
|
||||
## Why this phrase won the slot
|
||||
|
||||
- **Backlog evidence.** r/ClaudeCode 1xlz2bs (2026-11-02, ~15 h old at scan time,
|
||||
Scan 6 #4). The OP asked the question in its final words; the thread answered
|
||||
itself (env per worktree, `docker compose -p`, preview deploys, one serial
|
||||
step on main) — which is exactly what makes it page material: the demand is
|
||||
proven, the answer is proven, and the answer is generic.
|
||||
- **Shortlist position (a)** in the plan's pair-3 shortlist, recorded at
|
||||
revision 5. Scan 9 (2026-11-05) produced no new queue item, so the
|
||||
shortlist stands; selection finalised against that fresh scan as planned.
|
||||
- **Answerable honestly and generically.** Both mechanisms are documented
|
||||
primary sources (git worktree docs; Docker Compose project-name docs), both
|
||||
fetched and read 2026-11-05. No agent-behaviour claim is needed — this page
|
||||
is the lightest CTO-review lift of the shortlist.
|
||||
- **Wedge-adjacent without a mention.** "Waiting" is the cost our wedge names
|
||||
("an agent blocked is the most expensive kind of idle"); this page owns the
|
||||
agent-on-agent half of waiting, which no existing page covers.
|
||||
|
||||
## What the page deliberately does not do
|
||||
|
||||
- Does not cite the thread's own tooling (Orca, oyren.ai) or any workflow
|
||||
product — category nouns only.
|
||||
- Does not invent a measurement. The one quoted line is a commenter's described
|
||||
practice, logged in the backlog with a do-not-interpret flag.
|
||||
- Does not touch the approval surface (page 2 of this pair owns the
|
||||
attention/handback half; this page is environment contention only).
|
||||
|
||||
## Evidence trail
|
||||
|
||||
| Thread | Date | What it showed |
|
||||
|---|---|---|
|
||||
| r/ClaudeCode 1xlz2bs — "How do you manage worktrees when your agents end up waiting on each other?" | 2026-11-02 | Exact phrase in the wild; self-answered with env-multiplication advice; "multiply the env instead of orchestrating the waiting" |
|
||||
| Watched-table corroboration (Scans 5–9) | 2026-11-02 → 11-05 | Repobs ("which sessions were waiting for me"), Orca (worktree orchestration daily driver), Worktrunk, Conductor.build, Parallel Code — a shelf of builders around the same phrase; the demand is broad |
|
||||
|
||||
All rows live in `drafts/listening.md` (The-Nook local master line).
|
||||
96
marketing/drafts/newsletter-postmortem-pitch/copy.md
Normal file
96
marketing/drafts/newsletter-postmortem-pitch/copy.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Newsletter pitch — post-mortem shape (narrowed; no conversion figure)
|
||||
|
||||
> **Status: draft — narrowed 2026-11-06 per the CTO's reopening verdict on
|
||||
> FRE-199.** The "X% trial→paid" half of the shape **cannot ship**: the
|
||||
> FRE-202 snapshot (window 2026-10-22 → 10-29) reads trial started 2, purchase
|
||||
> 0, activation 0, with 291 of 291 analytics rows predating the FRE-366
|
||||
> instrumentation — a trial→paid ratio from that cohort is not a metric. The
|
||||
> ratio comes from FRE-197's mature Day 28–35 cohort read when it lands.
|
||||
> **No figure in this file is a measurement.** Send gates at the bottom.
|
||||
|
||||
## Audience & channel
|
||||
|
||||
Editors of free dev-tool newsletters (the ones that run post-mortems and
|
||||
"what we learned" pieces). Direct email, one editor at a time, personalised.
|
||||
No paid placements at any budget without a CoS + sponsor decision.
|
||||
|
||||
## The claim the pitch goes out with
|
||||
|
||||
A post-mortem pitch, not a product pitch: *here is what shipping a Mac utility
|
||||
alone actually broke, and what we changed because of it.* The product is the
|
||||
setting; the breakage and the fix are the story. The story stands on its own
|
||||
with zero funnel numbers — if no verified number exists on send day, the pitch
|
||||
ships without one rather than with a placeholder.
|
||||
|
||||
## The pitch (shape — names and dates personalised; nothing else is a placeholder)
|
||||
|
||||
**Subject:** What shipping a Mac utility alone broke (post-mortem)
|
||||
|
||||
Hi <editor>,
|
||||
|
||||
We build The Nook, a $10 Mac utility that surfaces coding-agent approvals at
|
||||
the notch. I'm not pitching the app — I'm offering a post-mortem your readers
|
||||
usually only get from funded startups, because shipping alone broke things in
|
||||
ways bigger teams don't hit:
|
||||
|
||||
- **The silent update.** In October we shipped a release whose appcast published
|
||||
cleanly — while every delta it named 404'd, so users on older builds were
|
||||
being offered updates that didn't exist. The publish path deleted files
|
||||
before the feed that named them was live. Nothing alerted; the feed just
|
||||
lied quietly. The fix — upload the feed's enclosures, verify every enclosure
|
||||
URL, copy the appcast up, and only *then* prune — is now ordered so the
|
||||
deletion can't run first.
|
||||
- **The green suite.** Weeks earlier, a regression in our public relay shipped
|
||||
behind a passing test run — the suite that would have caught it existed but
|
||||
nothing invoked it. A green checkmark that covers nothing looks identical to
|
||||
one that covers everything. The suite is now wired into the standard test
|
||||
entry point, and the relay's deploy patcher is tested alongside it.
|
||||
- **What we're not going to quote you.** A growth number, right now, would be
|
||||
theatre: our funnel is young and a conversion ratio from an immature cohort
|
||||
is not a measurement. If we attach a count, it will be activations — one per
|
||||
activated install, from our own relay store — and we will say exactly that,
|
||||
because download counts (a different number, from a different store) flatter
|
||||
nothing and confuse everyone. The receipts that matter here are the failure
|
||||
modes above, not a traction line.
|
||||
|
||||
The angle for your readers: solo distribution is a chain of quiet failure
|
||||
modes, each one invisible until it costs a user. We kept the receipts.
|
||||
|
||||
Happy to send the full write-up, screenshots of the broken feed, and the exact
|
||||
publish-order and test-wiring changes.
|
||||
|
||||
— <name>, FrenoCorp
|
||||
|
||||
## Where a number could come from, and what disqualifies it
|
||||
|
||||
- **Trial→paid ratio: cannot ship today.** FRE-202's snapshot shows a cohort of
|
||||
2 trials, 0 purchases, pre-instrumentation rows. The ratio waits for
|
||||
FRE-197's mature Day 28–35 read. If it lands before send day and the CTO
|
||||
confirms the query, the ratio may be added — and it must say it is per
|
||||
*install* (install cap 3), not per person.
|
||||
- **Activations: the only count eligible to ship.** One row per activated
|
||||
install (`installs`, `created_at`) in the relay store; CTO confirms the query
|
||||
before any send. The pitch names it as activations, never as downloads.
|
||||
- **Downloads (S3 side) are never quoted.** Different store, automation-heavy,
|
||||
and the pitch that says "downloads" when it means activations burns the
|
||||
credibility the post-mortem is built on.
|
||||
- No number is estimated to fill any gap, ever.
|
||||
|
||||
## Gates before anything is sent
|
||||
|
||||
1. Any count quoted is activations, from a CTO-confirmed query — owner: CTO.
|
||||
2. The trial→paid ratio ships only from FRE-197's mature cohort read with CTO
|
||||
confirmation; otherwise the "what we're not going to quote you" paragraph
|
||||
stands as written — owner: CTO.
|
||||
3. Both breakage paragraphs are fact-checked against the engineering record
|
||||
(FRE-542 publish-order incident; the NOOK-815 uninvited-suite relay
|
||||
regression) before send — owner: CMO, evidence to CTO for verdict.
|
||||
4. Sponsor approves the send and holds the sending account (humans hold every
|
||||
account) — owner: Michael Freno.
|
||||
|
||||
## Target dates (internal)
|
||||
|
||||
- Draft with placeholders: **done 2026-11-05**; narrowed to the no-figure shape
|
||||
**2026-11-06** (CTO direction).
|
||||
- Send: after gates 1–4 — with the no-figure shape, the only hard input left is
|
||||
the CTO's query confirmation and the sponsor's send.
|
||||
73
marketing/drafts/newsletter-postmortem-pitch/rationale.md
Normal file
73
marketing/drafts/newsletter-postmortem-pitch/rationale.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Rationale — newsletter post-mortem pitch (shape)
|
||||
|
||||
FRE-199 item 2. Drafted 2026-11-05 by CMO (plan date was 2026-11-06; FRE-201
|
||||
landed today, which was the only gate on drafting the shape).
|
||||
|
||||
## Why draft now
|
||||
|
||||
The Chief of Staff's sequencing had the pitch drafted when FRE-201 landed. It
|
||||
landed 2026-11-05 (row closed `done` 18:45Z; the daily scan routine
|
||||
`2553b201` has been producing the backlog since 2026-10-30). The figures were
|
||||
never gated on FRE-201 — only the shape was — so the shape ships now and the
|
||||
figures stay placeholders behind their own gate.
|
||||
|
||||
## Why a post-mortem and not a product pitch
|
||||
|
||||
The issue text's own thesis: "1,900 downloads, 6% trial→paid, here's what
|
||||
broke" outperforms a product pitch. Editors of free dev-tool newsletters run
|
||||
post-mortems; a solo shop shipping a Mac utility and keeping receipts is a
|
||||
story they can run without us asking for a review.
|
||||
|
||||
## The breakage is real, not invented
|
||||
|
||||
The lead paragraph is the 0.13.4 delta incident (FRE-542): the appcast stayed
|
||||
at 0.13.4 while its five delta files 404'd — the publish path pruned before
|
||||
the feed was up. The fix (upload enclosures, verify URLs, copy the appcast,
|
||||
then prune) is in `scripts/upload-release.sh` and is engineering-verified by
|
||||
the FRE-542 record.
|
||||
|
||||
**Second breakage, chosen 2026-11-06 (CTO direction):** the relay regression
|
||||
that shipped behind a passing suite (NOOK-815) — the suite that would have
|
||||
cought it existed but nothing invoked it; the fix wires it into the standard
|
||||
test entry point. It was chosen over the changelog-blanking incident because
|
||||
it is a complete story with no number needed and no overlap with the lead
|
||||
(both remaining candidates were publish-path incidents; one story about the
|
||||
publish path is enough). The placeholder is gone: no paragraph in the pitch
|
||||
waits on a figure any more.
|
||||
|
||||
## Narrowed 2026-11-06 — what the numbers can and cannot support
|
||||
|
||||
The CTO's reopening verdict on FRE-199 confirmed **no figure for the pitch
|
||||
today**: FRE-202's snapshot (trial started 2, purchase 0, activation 0; 291 of
|
||||
291 rows predate the FRE-366 instrumentation) cannot yield a trial→paid ratio —
|
||||
that waits for FRE-197's mature Day 28–35 cohort read. So the pitch narrowed:
|
||||
|
||||
- the "X% trial→paid" placeholder is deleted, not held;
|
||||
- if any count ships, it is **activations** (relay store, one row per activated
|
||||
install), named as activations — downloads (S3 side) are never quoted;
|
||||
- a new paragraph — "what we're not going to quote you" — turns the missing
|
||||
number into part of the post-mortem's credibility instead of a gap;
|
||||
- with the no-figure shape, the only inputs left before send are the CTO's
|
||||
query confirmation (if a count is attached) and the sponsor's send.
|
||||
|
||||
## Hard rules honoured
|
||||
|
||||
- No figure in the file is a measurement; nothing is estimated to fill a gap.
|
||||
- No conversion figure ships — the ratio is FRE-197's, gated and CTO-confirmed.
|
||||
- Any count that ships is activations, named as activations; downloads are
|
||||
never quoted.
|
||||
- The two caveats (per-install conversion with install cap 3; downloads ≠
|
||||
activations) are written to travel inside the pitch, not a footnote.
|
||||
- Paid placements: out, per the issue text and the plan (TLDR ≈ $3k, no
|
||||
verifiable CPC). No placement is bought without CoS + sponsor.
|
||||
- Price appears once ($10, what the app actually charges) to identify the
|
||||
product — no discount, no countdown, no price-change copy (FRE-204 holds).
|
||||
|
||||
## Gates before send
|
||||
|
||||
1. Any count quoted is activations, from a CTO-confirmed query — CTO.
|
||||
2. Trial→paid ratio ships only from FRE-197's mature read, CTO-confirmed;
|
||||
otherwise the no-figure paragraph stands — CTO.
|
||||
3. Both breakage paragraphs fact-checked against the engineering record
|
||||
(FRE-542; NOOK-815) — CMO drafts, CTO verifies.
|
||||
4. Sponsor approves the send; a human holds the account and sends — sponsor.
|
||||
@@ -1,8 +1,13 @@
|
||||
# Remote SSH copy — historical unqualified wording
|
||||
# Remote SSH copy — HELD, do not publish
|
||||
|
||||
[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.
|
||||
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
|
||||
correction, and **not yet off the live site** — the deploy is blocked on a write
|
||||
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
|
||||
serves every sentence below until that push or a Vercel deploy lands.
|
||||
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
||||
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
||||
verifies that flow, and a recording shows a remote agent joining the island.
|
||||
Until then this file is the only home for these sentences.
|
||||
|
||||
## What was removed
|
||||
|
||||
@@ -57,8 +62,18 @@ Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
|
||||
One-click SSH setup
|
||||
```
|
||||
|
||||
## Current restoration
|
||||
## What replaced it
|
||||
|
||||
[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.
|
||||
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] 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.
|
||||
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
|
||||
|
||||
## Restore checklist
|
||||
|
||||
1. FRE-320/FRE-205 have shipped the one-click install and removed the
|
||||
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
|
||||
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
|
||||
3. A recording exists showing a remote agent joining the island.
|
||||
4. CMO restores the copy above, then owns the launch plan for it — with an
|
||||
owner and date per item — before anything goes back on the site.
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
# Remote setup marketing decision
|
||||
# Why the remote claim is parked
|
||||
|
||||
[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.
|
||||
The site was selling a setup path a buyer does not get.
|
||||
|
||||
## Why it was removed
|
||||
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.
|
||||
|
||||
[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.
|
||||
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.
|
||||
|
||||
## Current evidence and scope
|
||||
## What has to be true before it returns
|
||||
|
||||
[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.
|
||||
| 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] 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.
|
||||
Nothing in this folder is publishable. `marketing/approved/` remains the only
|
||||
place a human publishes from.
|
||||
|
||||
@@ -1,4 +1,43 @@
|
||||
# Weekly funnel snapshot
|
||||
|
||||
Generated weekly from the analytics DB + `trials`/`licenses`/`activations`.
|
||||
Format: the funnel numbers, what moved, and the one thing worth acting on.
|
||||
Window: **2026-10-22 18:05:00 → 2026-10-29 18:05:00 UTC** (7 days). Previous: 2026-10-15 18:05:00 → 2026-10-22 18:05:00 UTC.
|
||||
|
||||
Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's.
|
||||
|
||||
## Funnel
|
||||
|
||||
| Stage | This week | Last week | Change |
|
||||
|---|---:|---:|---:|
|
||||
| visit (nook.freno.me page views) | 0 | 0 | 0 |
|
||||
| download click | 0 | 0 | 0 |
|
||||
| DMG served | 0 | 0 | 0 |
|
||||
| trial started | 2 | 0 | +2 |
|
||||
| checkout started (Stripe session) | 0 | 0 | 0 |
|
||||
| purchase (licence issued) | 0 | 0 | 0 |
|
||||
| activation | 0 | 0 | 0 |
|
||||
|
||||
## Campaigns
|
||||
|
||||
Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.topCampaigns` returns.
|
||||
|
||||
| Source | Medium | Campaign | Rows |
|
||||
|---|---|---|---:|
|
||||
| x | — | — | 1 |
|
||||
|
||||
1 of 291 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
|
||||
|
||||
## The one thing that moved
|
||||
|
||||
**trial started: 0 → 2 (+2).**
|
||||
|
||||
## Before acting on these numbers
|
||||
|
||||
- **291 of 291 rows in the window predate the FRE-366 instrumentation.** They carry no host, and their recorded address is the production proxy's loopback (127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. They are excluded from every host-scoped stage above (visits, and the download click/served split), and "unique visitors" over them still collapses to one host. Rows recorded since carry the site they addressed and the visitor's address truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so "unique visitors" counts networks, not machines.
|
||||
- **Download rows are automation-heavy.** Of this window's 137 download rows, 0 are clicks and 0 are served files; the remaining 137 predate the click/served stage label and cannot be split, so they count in neither stage. 112 of all 137 are not browser user agents (curl, Sparkle, link-preview crawlers).
|
||||
- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.
|
||||
|
||||
## Sources
|
||||
|
||||
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
|
||||
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
|
||||
- Generated 2026-10-29T18:05:00Z.
|
||||
|
||||
@@ -4,8 +4,9 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
|
||||
artifact invents its own framing, fix the artifact or fix this file — never both
|
||||
silently diverging.
|
||||
|
||||
**Reconciled 2026-10-29** against the CTO's verdicts ([FRE-315], [FRE-322],
|
||||
[FRE-354]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
|
||||
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 count
|
||||
correction, against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354],
|
||||
[FRE-997]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
|
||||
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
|
||||
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
|
||||
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
|
||||
@@ -17,6 +18,7 @@ no longer exists; the verdicts below are the source of truth it was pointing at.
|
||||
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
|
||||
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
|
||||
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
|
||||
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
|
||||
|
||||
## The sentence
|
||||
|
||||
@@ -113,13 +115,11 @@ one thing forbidden: say "TestFlight beta" when the phone exists in a sentence a
|
||||
all, and never claim approvals-from-phone as a shipped capability without its own
|
||||
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||
|
||||
[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.
|
||||
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.
|
||||
|
||||
### 5. Humans hold every account
|
||||
|
||||
@@ -127,7 +127,7 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
|
||||
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
||||
nothing posts itself.
|
||||
|
||||
## Claims: status at 2026-10-29
|
||||
## Claims: status at 2026-11-03
|
||||
|
||||
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||
|
||||
@@ -138,16 +138,17 @@ Each row names the evidence, not a feeling. Re-read the verdict before using a r
|
||||
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
|
||||
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
|
||||
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
|
||||
| Agent coverage numbers ("thirteen agents", "8 agents", "all your agents") | **Excluded — verdict B, count removed** | [FRE-359] verdict B (2026-10-29): the page's 13 rows are the registry's first 13 of 18, so no number is publishable and `every` / `all` / `the agents The Nook supports` are banned. Removed from `agents.tsx`, the landing island section and the marquee. [FRE-322] §1 matrix for the sets. |
|
||||
| Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
|
||||
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
|
||||
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
|
||||
|
||||
## Voice
|
||||
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives — but no coverage
|
||||
counts (see the excluded row above).
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives — the only count
|
||||
that ships is eighteen, and it is the registry's own size (see the coverage row
|
||||
above).
|
||||
- The notch is the product's home; agents are the product's point.
|
||||
- Never apologize for being a utility. The utility is the point.
|
||||
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test:coverage": "bun test --coverage",
|
||||
"perf": "bun run scripts/perf-test.ts",
|
||||
"perf:compare": "bun run scripts/perf-compare.ts",
|
||||
"metrics:weekly": "bun --env-file=.env scripts/weekly-metrics.ts",
|
||||
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
159
scripts/weekly-metrics.ts
Normal file
159
scripts/weekly-metrics.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { createClient } from "@libsql/client/web";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import {
|
||||
ensureAnalyticsSchema,
|
||||
getWeeklyReport,
|
||||
renderWeeklyMarkdown,
|
||||
type Connections
|
||||
} from "~/server/weekly-metrics";
|
||||
|
||||
/**
|
||||
* Write `marketing/metrics/weekly.md` from the live analytics and licence DBs.
|
||||
*
|
||||
* bun --env-file=.env scripts/weekly-metrics.ts
|
||||
* bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z
|
||||
*
|
||||
* Required environment (all four; the analytics token also opens the licence
|
||||
* DB, which is why the weekly job can run from a checkout that has no Stripe
|
||||
* or Apple secrets):
|
||||
*
|
||||
* TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics
|
||||
* NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations
|
||||
*
|
||||
* This script deliberately does not import `~/env/server`: that schema demands
|
||||
* every production secret, and the metrics job needs two databases and nothing
|
||||
* else.
|
||||
*
|
||||
* The output path is anchored at the repo root, so running it from any cwd
|
||||
* writes the same file.
|
||||
*/
|
||||
|
||||
interface Options {
|
||||
days: number;
|
||||
now: Date;
|
||||
out: string;
|
||||
stdout: boolean;
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]",
|
||||
"",
|
||||
" --days N window length in days (default 7)",
|
||||
" --now ISO end of the window (default: now)",
|
||||
" --out PATH where to write (default: marketing/metrics/weekly.md)",
|
||||
" --stdout print the report instead of writing a file"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const repoRoot = resolve(import.meta.dir, "..");
|
||||
const options: Options = {
|
||||
days: 7,
|
||||
now: new Date(),
|
||||
out: resolve(repoRoot, "marketing/metrics/weekly.md"),
|
||||
stdout: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!;
|
||||
const value = () => {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`);
|
||||
i += 1;
|
||||
return next;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case "--days": {
|
||||
const days = Number(value());
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
throw new Error(`--days must be a positive number\n\n${usage()}`);
|
||||
}
|
||||
options.days = days;
|
||||
break;
|
||||
}
|
||||
case "--now": {
|
||||
const now = new Date(value());
|
||||
if (Number.isNaN(now.getTime())) {
|
||||
throw new Error(`--now must be an ISO timestamp\n\n${usage()}`);
|
||||
}
|
||||
options.now = now;
|
||||
break;
|
||||
}
|
||||
case "--out":
|
||||
options.out = resolve(value());
|
||||
break;
|
||||
case "--stdout":
|
||||
options.stdout = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
throw new Error(`Unknown argument ${arg}\n\n${usage()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error(
|
||||
`${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` +
|
||||
`NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` +
|
||||
`\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function connect(url: string, token: string) {
|
||||
return createClient({ url, authToken: token });
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
const connections: Connections = {
|
||||
analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")),
|
||||
nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN"))
|
||||
};
|
||||
|
||||
// The app migrates the table on boot, but this job must not depend on a
|
||||
// deploy having happened: add any missing post-hoc columns (stage, host)
|
||||
// through the same idempotent bootstrap path first.
|
||||
await ensureAnalyticsSchema(connections.analytics);
|
||||
|
||||
const report = await getWeeklyReport(connections, {
|
||||
days: options.days,
|
||||
now: options.now
|
||||
});
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
if (options.stdout) {
|
||||
process.stdout.write(markdown);
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(dirname(options.out), { recursive: true });
|
||||
await writeFile(options.out, markdown, "utf8");
|
||||
|
||||
const f = report.counts;
|
||||
console.log(
|
||||
`wrote ${options.out}\n` +
|
||||
`window ${report.window.start} → ${report.window.end}\n` +
|
||||
`visit ${f.visit}, download click ${f.downloadClick}, DMG served ${f.dmgServed}, ` +
|
||||
`trial ${f.trial}, checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` +
|
||||
`mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -761,14 +761,6 @@ 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;
|
||||
@@ -848,7 +840,6 @@ a.hover-underline-animation:hover::after {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nook-power-flow,
|
||||
.nook-meter,
|
||||
.nook-wave,
|
||||
.nook-progress,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IslandPill,
|
||||
AgentDotGrid,
|
||||
CALM,
|
||||
WARM,
|
||||
ACCENT,
|
||||
CRITICAL,
|
||||
ModuleCard,
|
||||
@@ -23,10 +24,9 @@ import {
|
||||
CODEX
|
||||
} from "./ModuleMocks";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { BatteryDemo, CalendarDemo, MixerDemo } from "./UtilityDemos";
|
||||
import {
|
||||
TerminalReplyDemo,
|
||||
RemoteHostDemo,
|
||||
MixerDemo,
|
||||
CaptureDemo,
|
||||
FloatingActionsDemo
|
||||
} from "./MoreDemos";
|
||||
@@ -168,11 +168,11 @@ function FleetDemo() {
|
||||
{phase() === "flare"
|
||||
? "Codex needs your input"
|
||||
: phase() === "resolve"
|
||||
? "Reply sent. Agents running."
|
||||
: "Agents running"}
|
||||
? "Answer delivered — agents back to work"
|
||||
: "Fleet running calmly"}
|
||||
</p>
|
||||
<p class="mt-0.5 text-[11px] text-white/50">
|
||||
{phase() === "flare" ? "Question waiting in the island" : "\u00a0"}
|
||||
{phase() === "flare" ? "question · in the island now" : "\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,6 +408,313 @@ 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() {
|
||||
@@ -490,8 +797,8 @@ function CameraDemo() {
|
||||
</div>
|
||||
<p class="text-[10px] text-white/40">
|
||||
{phase() === "live"
|
||||
? "Camera on while this panel is open"
|
||||
: "Camera off until you click Start"}
|
||||
? "live — stops the moment you leave the panel"
|
||||
: "inert — the lens stays dark until you click"}
|
||||
</p>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
@@ -509,11 +816,11 @@ function CampfireDemo() {
|
||||
"error"
|
||||
];
|
||||
const labels = [
|
||||
"Running",
|
||||
"Question",
|
||||
"Ready",
|
||||
"Idle",
|
||||
"Error"
|
||||
"fleet running",
|
||||
"pending question",
|
||||
"ready to review",
|
||||
"all quiet",
|
||||
"something failed"
|
||||
];
|
||||
return (
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
@@ -541,13 +848,13 @@ export default function FeatureBreakdowns() {
|
||||
<div class="space-y-24">
|
||||
<BreakdownSection
|
||||
kicker="The island"
|
||||
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."
|
||||
title="Your whole fleet, in one pixel strip"
|
||||
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
||||
bullets={[
|
||||
"Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, Cursor and more",
|
||||
"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"
|
||||
"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"
|
||||
]}
|
||||
>
|
||||
<FleetDemo />
|
||||
@@ -555,12 +862,12 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Approvals"
|
||||
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."
|
||||
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."
|
||||
bullets={[
|
||||
"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",
|
||||
"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",
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
@@ -569,8 +876,8 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Terminals"
|
||||
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."
|
||||
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."
|
||||
bullets={[
|
||||
"Replies reach agents in tmux, iTerm2, Terminal, and kitty",
|
||||
"Jump focuses the exact window, even for sessions over SSH",
|
||||
@@ -580,27 +887,12 @@ export default function FeatureBreakdowns() {
|
||||
<TerminalReplyDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
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={[
|
||||
"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."
|
||||
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."
|
||||
bullets={[
|
||||
"Separate animations for idle, running, ready, question, and error states",
|
||||
"Idle, running, ready, question, and error — at a glance",
|
||||
"Hand-drawn 14×14 sprites, no tweening",
|
||||
"Six chiptunes you can assign to any agent event"
|
||||
]}
|
||||
@@ -610,13 +902,13 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Cooling"
|
||||
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."
|
||||
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."
|
||||
bullets={[
|
||||
"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"
|
||||
"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"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
@@ -625,14 +917,14 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Power"
|
||||
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."
|
||||
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."
|
||||
bullets={[
|
||||
"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"
|
||||
"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"
|
||||
]}
|
||||
>
|
||||
<BatteryDemo />
|
||||
@@ -640,12 +932,12 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Sound"
|
||||
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."
|
||||
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."
|
||||
bullets={[
|
||||
"Per-app volume, mute, and output routing",
|
||||
"Master output, system sounds, and microphone pickers in one panel",
|
||||
"Includes audio from command-line players"
|
||||
"Catches players that aren't apps, like command-line podcast tools"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
@@ -654,8 +946,8 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Capture"
|
||||
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."
|
||||
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."
|
||||
bullets={[
|
||||
"Area, screen, or single-app capture",
|
||||
"Screenshots save to your screenshot folder and copy to the clipboard",
|
||||
@@ -668,13 +960,13 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Your day"
|
||||
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."
|
||||
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."
|
||||
bullets={[
|
||||
"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"
|
||||
"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"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
@@ -683,13 +975,13 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Camera"
|
||||
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."
|
||||
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."
|
||||
bullets={[
|
||||
"Requires camera permission and a click on Start camera",
|
||||
"Preview runs only while the panel is open",
|
||||
"Nothing watches until Start camera gets a click",
|
||||
"Live only while the panel sits open",
|
||||
"A device picker for every connected camera",
|
||||
"Add the camera module to a panel when you want it"
|
||||
"Opt-in module — in no default panel"
|
||||
]}
|
||||
>
|
||||
<CameraDemo />
|
||||
@@ -697,8 +989,8 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Floating actions"
|
||||
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."
|
||||
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."
|
||||
bullets={[
|
||||
"Run an Apple Shortcut, open an app or URL, or run a shell command",
|
||||
"Toggle Low Power Mode, top-up, or overheat protection",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
||||
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
|
||||
import { ApprovalCardMock, SessionRowMock, DieLine, CLAUDE, CODEX } from "./ModuleMocks";
|
||||
import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Feature collage: five cells sold as the app's actual UI. The hero cell
|
||||
@@ -99,31 +99,6 @@ 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);
|
||||
@@ -162,7 +137,7 @@ export default function FeatureCollage() {
|
||||
Why the Nook
|
||||
</p>
|
||||
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||
Keep track of your agents while you work
|
||||
One glance. Everything your agents are doing.
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||
@@ -173,12 +148,12 @@ export default function FeatureCollage() {
|
||||
The island
|
||||
</p>
|
||||
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight">
|
||||
Agent status at the top of your screen
|
||||
Every agent, one glance
|
||||
</h3>
|
||||
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
||||
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.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="my-10 flex w-full justify-center">
|
||||
@@ -211,15 +186,15 @@ export default function FeatureCollage() {
|
||||
/>
|
||||
</div>
|
||||
<p class="text-subtext0 -mb-1 text-center text-xs">
|
||||
Open the island to see your sessions
|
||||
expand for the full panel ↓
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Approvals */}
|
||||
<CollageCell
|
||||
kicker="Approvals"
|
||||
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."
|
||||
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)."
|
||||
>
|
||||
<InkStage>
|
||||
<div class="w-full max-w-sm px-3">
|
||||
@@ -236,8 +211,8 @@ export default function FeatureCollage() {
|
||||
{/* Fans */}
|
||||
<CollageCell
|
||||
kicker="Cooling"
|
||||
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."
|
||||
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."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full max-w-[260px] text-left">
|
||||
@@ -249,8 +224,8 @@ export default function FeatureCollage() {
|
||||
{/* Gauges */}
|
||||
<CollageCell
|
||||
kicker="Your Mac"
|
||||
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."
|
||||
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."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full max-w-[300px] text-left">
|
||||
@@ -258,27 +233,15 @@ 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">
|
||||
Also includes a volume mixer, screen capture, timers, weather, and more.{" "}
|
||||
Plus a mixer, capture, timers, weather, and a dozen more modules —{" "}
|
||||
<a
|
||||
href="#island-extras"
|
||||
class="underline decoration-dotted hover:opacity-80"
|
||||
>
|
||||
Browse the other modules
|
||||
see them all ↓
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ACCENT,
|
||||
CALM,
|
||||
WARM,
|
||||
CRITICAL,
|
||||
CARD_FILL,
|
||||
CARD_STROKE
|
||||
} from "./IslandMock";
|
||||
@@ -570,8 +571,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: "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)" }
|
||||
{ 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)" }
|
||||
];
|
||||
|
||||
function ApprovalModesMock() {
|
||||
@@ -721,7 +722,7 @@ function LayoutMock() {
|
||||
/* ── Marquee ─────────────────────────────────────────────────────── */
|
||||
|
||||
const CHIPS_A = [
|
||||
"Agents wired in",
|
||||
"18 agents",
|
||||
"Custom agents",
|
||||
"Six chiptunes",
|
||||
"Focus-aware slots",
|
||||
@@ -772,14 +773,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">
|
||||
More modules
|
||||
And everything else
|
||||
</p>
|
||||
<h2 class="text-text mb-3 text-center text-3xl font-bold">
|
||||
Other tools in the island
|
||||
One island instead of a menu bar full of utilities
|
||||
</h2>
|
||||
<p class="text-subtext0 mx-auto mb-10 max-w-2xl text-center leading-relaxed">
|
||||
Add the modules you need and arrange them in your panels.
|
||||
They're all included with your license.
|
||||
Every module below lives in the same notch, arranged however you
|
||||
like, under the same one-time license.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -792,8 +793,8 @@ export default function IslandExtras() {
|
||||
<CollageCell
|
||||
class="md:col-span-2"
|
||||
kicker="Now Playing"
|
||||
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."
|
||||
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."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center">
|
||||
<NowPlayingMock />
|
||||
@@ -802,8 +803,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Timers"
|
||||
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."
|
||||
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."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<TimersMock />
|
||||
@@ -812,8 +813,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Weather"
|
||||
title="Weather for your saved places"
|
||||
body="Check hourly and daily forecasts for saved places. The compact view shows the current conditions and temperature."
|
||||
title="The sky, in the slot"
|
||||
body="Saved places with hourly and daily forecasts. The compact slot stacks the sky over the temperature."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<WeatherMock />
|
||||
@@ -823,8 +824,8 @@ export default function IslandExtras() {
|
||||
<CollageCell
|
||||
class="md:col-span-2"
|
||||
kicker="Interceptions"
|
||||
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."
|
||||
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."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center">
|
||||
<div class="flex min-h-[110px] w-full justify-center pt-2">
|
||||
@@ -835,8 +836,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Approval modes"
|
||||
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."
|
||||
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."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<ApprovalModesMock />
|
||||
@@ -845,8 +846,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Network"
|
||||
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."
|
||||
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."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<NetworkMock />
|
||||
@@ -855,8 +856,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Disks"
|
||||
title="Disk usage and drive health"
|
||||
body="Check read and write speeds, free space, and SMART health information for supported drives."
|
||||
title="Storage, measured"
|
||||
body="Live read and write throughput, capacity that matches Finder, and SMART health for your drives."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<DiskMock />
|
||||
@@ -865,8 +866,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Keep Awake"
|
||||
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."
|
||||
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."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<KeepAwakeMock />
|
||||
@@ -875,7 +876,7 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Lock Screen"
|
||||
title="Agent status on the Lock Screen"
|
||||
title="Visible while locked"
|
||||
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">
|
||||
@@ -885,8 +886,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Layout"
|
||||
title="Arrange your panels"
|
||||
body="Drag and resize modules, choose how much detail they show, and change compact-view priorities with your Focus mode."
|
||||
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."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<LayoutMock />
|
||||
|
||||
@@ -77,12 +77,6 @@ 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" />
|
||||
)
|
||||
|
||||
@@ -7,20 +7,21 @@ import {
|
||||
STATUS,
|
||||
ACCENT,
|
||||
CALM,
|
||||
CRITICAL,
|
||||
CARD_STROKE
|
||||
} from "./IslandMock";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { Glyph, WingCapsule, SessionRowMock, CODEX, type GlyphName } from "./ModuleMocks";
|
||||
import { Glyph, WingCapsule, type GlyphName } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Live demos for the breakdowns added with the 0.6–0.10 feature set:
|
||||
* terminal replies, remote setup, Capture (module + Wings), and
|
||||
* terminal replies, the volume mixer, 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, update and run the tests.";
|
||||
const REPLY = "yes — and run them";
|
||||
|
||||
export function TerminalReplyDemo() {
|
||||
const [typed, setTyped] = createSignal(0);
|
||||
@@ -152,106 +153,124 @@ export function TerminalReplyDemo() {
|
||||
);
|
||||
}
|
||||
|
||||
/** RemoteMirror's progress steps, in order. */
|
||||
const SETUP_STEPS = [
|
||||
"connecting",
|
||||
"pushing the hook binary",
|
||||
"configuring agents",
|
||||
"checking the host can reach this Mac"
|
||||
/* ── 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: ">_" }
|
||||
];
|
||||
|
||||
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)]
|
||||
export function MixerDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
useDemoLoop(9600, [
|
||||
[0, () => setStep(0)],
|
||||
[1800, () => setStep(1)],
|
||||
[3800, () => setStep(2)],
|
||||
[5800, () => setStep(3)]
|
||||
]);
|
||||
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" };
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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}
|
||||
<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>
|
||||
<Show
|
||||
when={live()}
|
||||
fallback={
|
||||
<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="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
|
||||
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: ACCENT,
|
||||
opacity: working() ? 0 : 1,
|
||||
transform: pressed() ? "scale(0.92)" : "scale(1)"
|
||||
background:
|
||||
i() === 0 && step() === 2
|
||||
? `${ACCENT}55`
|
||||
: "rgba(255,255,255,0.07)"
|
||||
}}
|
||||
>
|
||||
Set up
|
||||
{route(i())}
|
||||
</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()] : " "}
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
<FakeCursor
|
||||
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
|
||||
at={{ x: 88, y: 16 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ function CompanionDemo() {
|
||||
{allowed() ? "Allowed ✓" : "Claude Code · the-nook"}
|
||||
</p>
|
||||
<p class="text-[9px] leading-snug text-white/60">
|
||||
{allowed() ? "Agent running." : "Run Bash: swift test"}
|
||||
{allowed() ? "Back to work." : "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">
|
||||
Deploy to staging?
|
||||
Ship it 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">
|
||||
Use The Nook on iPhone and Apple Watch
|
||||
Your fleet, in your pocket
|
||||
</h2>
|
||||
<p class="text-subtext0 mb-5 leading-relaxed">
|
||||
The companion app is in TestFlight beta. Check your agents,
|
||||
respond to permission requests, and answer questions when you're
|
||||
away from your Mac.
|
||||
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.
|
||||
</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",
|
||||
"Agent status widgets for your Home Screen, Lock Screen, and watch face",
|
||||
"Home Screen, Lock Screen, and watch-face widgets for fleet status",
|
||||
"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. We'll email you a TestFlight invite.
|
||||
Thanks. Watch your inbox for a TestFlight invite.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={state() === "limited"}>
|
||||
|
||||
@@ -1,627 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -160,6 +160,8 @@ export interface VisitorAnalytics {
|
||||
ttfb?: number | null;
|
||||
dom_load?: number | null;
|
||||
load_complete?: number | null;
|
||||
stage?: string | null;
|
||||
host?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
6
src/env/server.ts
vendored
6
src/env/server.ts
vendored
@@ -69,6 +69,12 @@ const serverEnvSchema = z.object({
|
||||
// Bearer secret for the daily lifecycle-email cron. Falls back to
|
||||
// Vercel's CRON_SECRET; unset + no CRON_SECRET = the endpoint denies all.
|
||||
NOOK_LIFECYCLE_SECRET: z.string().min(1).optional(),
|
||||
// Daily VisitorAnalytics retention cron (/api/analytics-retention). Same
|
||||
// shape: falls back to CRON_SECRET, and unset denies every request.
|
||||
ANALYTICS_RETENTION_SECRET: z.string().min(1).optional(),
|
||||
// How long a VisitorAnalytics row lives. Unset (or unusable) = 365 days;
|
||||
// see src/server/analytics-retention.ts before shortening it.
|
||||
ANALYTICS_RETENTION_DAYS: z.string().optional(),
|
||||
CRON_SECRET: z.string().min(1).optional()
|
||||
});
|
||||
|
||||
|
||||
@@ -67,8 +67,7 @@ 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).toContain("https://nessa.freno.me/terms");
|
||||
expect(locs.length).toBe(4);
|
||||
expect(locs.length).toBe(3);
|
||||
|
||||
for (const loc of locs) {
|
||||
expect(loc).not.toContain("://freno.me/");
|
||||
|
||||
@@ -57,8 +57,7 @@ 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: "/terms", changefreq: "yearly", priority: 0.4 }
|
||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
||||
],
|
||||
|
||||
lineage: [
|
||||
|
||||
35
src/routes/api/analytics-retention.ts
Normal file
35
src/routes/api/analytics-retention.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { env } from "~/env/server";
|
||||
import { ConnectionFactory } from "~/server/db-connections";
|
||||
import { runRetentionCron } from "~/server/analytics-retention";
|
||||
|
||||
/**
|
||||
* GET /api/analytics-retention — daily Vercel cron.
|
||||
*
|
||||
* Deletes `VisitorAnalytics` rows older than the retention window, so visitor
|
||||
* data has a bound instead of accumulating forever (FRE-369, finding Q4 — the
|
||||
* table's only cleaner used to be a manual admin call, and since FRE-366 the
|
||||
* rows carry a truncated but still real client address).
|
||||
*
|
||||
* Window: `ANALYTICS_RETENTION_DAYS`, defaulting to 365 when unset or unusable
|
||||
* (see `~/server/analytics-retention` for why the default is conservative
|
||||
* rather than the reviewer's 90).
|
||||
*
|
||||
* Auth: `Authorization: Bearer <secret>` where the secret is
|
||||
* `ANALYTICS_RETENTION_SECRET` or Vercel's `CRON_SECRET`. An unconfigured secret
|
||||
* denies every request — an unauthenticated trigger of a bulk delete is the one
|
||||
* outcome this endpoint must never have.
|
||||
*/
|
||||
export async function GET(event: APIEvent) {
|
||||
const result = await runRetentionCron({
|
||||
authorization: event.request.headers.get("authorization"),
|
||||
secret: env.ANALYTICS_RETENTION_SECRET ?? env.CRON_SECRET,
|
||||
configuredDays: env.ANALYTICS_RETENTION_DAYS,
|
||||
conn: ConnectionFactory()
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(result.body), {
|
||||
status: result.status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export async function GET(event: APIEvent) {
|
||||
|
||||
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
||||
|
||||
await trackDownload(event, assetKeys[asset]!);
|
||||
await trackDownload(event, assetKeys[asset]!, "served");
|
||||
|
||||
// SAFETY: `transformToByteArray` yields `Uint8Array<ArrayBufferLike>`, which the
|
||||
// DOM lib's `BufferSource` (ArrayBuffer-backed) does not accept, though undici
|
||||
|
||||
@@ -3,6 +3,7 @@ import { env } from "~/env/server";
|
||||
import { TURNSTILE_CONFIG } from "~/config";
|
||||
import { verifyTurnstileToken } from "~/server/fetch-utils";
|
||||
import { json, error } from "./_lib";
|
||||
import { trackRequest } from "~/server/track-download";
|
||||
|
||||
/**
|
||||
* POST /api/the-nook/checkout
|
||||
@@ -12,6 +13,14 @@ import { json, error } from "./_lib";
|
||||
* session for the one-time $10 The Nook license. Returns the hosted
|
||||
* checkout URL for the client to redirect to.
|
||||
*
|
||||
* Analytics: a created session is the funnel's "checkout started" stage
|
||||
* (FRE-366). Until then the only trace was the client-side /checkout page
|
||||
* view — a page, not a started checkout. Recorded through the shared
|
||||
* `trackRequest` writer (referrer, campaign tags, country, real client
|
||||
* address, host) under this route's own path, best-effort: a failed
|
||||
* analytics write never blocks a checkout, and an attempt Turnstile or
|
||||
* Stripe rejects is correctly not a started checkout.
|
||||
*
|
||||
* Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no
|
||||
* `stripe` npm dependency added.
|
||||
*/
|
||||
@@ -67,5 +76,14 @@ export async function POST(event: APIEvent) {
|
||||
if (!data.url) {
|
||||
return error("Stripe returned no checkout URL", 502);
|
||||
}
|
||||
|
||||
// The session exists: that is "checkout started". Awaited so the row is
|
||||
// flushed before the response — a buffered write is not guaranteed to
|
||||
// survive the response on serverless.
|
||||
await trackRequest(event, {
|
||||
path: "/api/the-nook/checkout",
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
return json({ checkoutUrl: data.url });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
import { truncateIpAddress } from "~/server/request-client";
|
||||
|
||||
// The route's contracts:
|
||||
// 1. Every click is recorded through the shared analytics buffer with the
|
||||
@@ -55,9 +56,13 @@ mock.module("~/server/analytics", () => ({
|
||||
flushAnalytics: async () => {
|
||||
flushCalls += 1;
|
||||
},
|
||||
// Mirrors the real enrichment contract: deviceType is derived from the UA.
|
||||
// Mirrors the real enrichment contract: deviceType is derived from the UA,
|
||||
// and the address is cut to its prefix by the FRE-369 privacy floor. The
|
||||
// real `enrichAnalyticsEntry` does both, so the mirror has to apply the same
|
||||
// truncation — otherwise this file would prove the door stores a full address.
|
||||
enrichAnalyticsEntry: (entry: Entry) => ({
|
||||
...entry,
|
||||
ipAddress: truncateIpAddress(entry.ipAddress as string | null),
|
||||
deviceType: String(entry.userAgent ?? "").includes("Macintosh")
|
||||
? "desktop"
|
||||
: null
|
||||
@@ -113,6 +118,10 @@ describe("GET /api/the-nook/download", () => {
|
||||
expect(logged[0]).toMatchObject({
|
||||
path: "/api/downloads/thenook",
|
||||
method: "GET",
|
||||
// The door is the click half of the split; the file route writes
|
||||
// "served". And the row names the site it came from (FRE-366).
|
||||
stage: "click",
|
||||
host: "nook.freno.me",
|
||||
referrer: "https://nook.freno.me/"
|
||||
});
|
||||
// Flushed within the request, not left to the batch timer.
|
||||
@@ -137,9 +146,16 @@ describe("GET /api/the-nook/download", () => {
|
||||
expect(logged[2]!.country).toBe("JP");
|
||||
});
|
||||
|
||||
it("falls back to the forwarded address when no request IP is available", async () => {
|
||||
await GET(request({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" }));
|
||||
expect(logged[0]!.ipAddress).toBe("198.51.100.9");
|
||||
it("records the forwarded visitor below the trusted edge, truncated", async () => {
|
||||
// Client → Cloudflare → origin proxy: the sender's spoof is first, the real
|
||||
// client second, the edge last. And the stored value is the /24, never the
|
||||
// address itself (FRE-369).
|
||||
await GET(
|
||||
request({
|
||||
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
|
||||
})
|
||||
);
|
||||
expect(logged[0]!.ipAddress).toBe("203.0.113.0");
|
||||
});
|
||||
|
||||
it("still logs the click when the appcast cannot be read", async () => {
|
||||
|
||||
@@ -13,8 +13,10 @@ import { resolveLatestDownload } from "./_download";
|
||||
* Why a redirect and not a direct S3 link: the appcast route and the DMG
|
||||
* route never logged anything, so step 2 of the funnel (download click →
|
||||
* DMG served) was invisible. Every click now lands one row in
|
||||
* VisitorAnalytics under `/api/downloads/thenook`, alongside the clicks and
|
||||
* served files of every other product — see `~/server/track-download`.
|
||||
* VisitorAnalytics under `/api/downloads/thenook` with `stage = "click"`,
|
||||
* distinct from the `stage = "served"` row the file route writes when the
|
||||
* DMG is actually streamed — the split FRE-366 added, alongside the clicks
|
||||
* and served files of every other product — see `~/server/track-download`.
|
||||
*
|
||||
* Referrer, campaign tags, edge country (with `?c=` override) and user agent
|
||||
* are read by `trackDownload`, which is shared with the other download doors.
|
||||
@@ -28,7 +30,7 @@ import { resolveLatestDownload } from "./_download";
|
||||
export async function GET(event: APIEvent): Promise<Response> {
|
||||
// Recorded before resolving: a click that dies at resolution is exactly the
|
||||
// failure the funnel needs to show.
|
||||
await trackDownload(event, "thenook");
|
||||
await trackDownload(event, "thenook", "click");
|
||||
|
||||
let target: string | null = null;
|
||||
try {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" }), {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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`).
|
||||
@@ -17,27 +16,14 @@ import { SUPPORT_EMAIL } from "./content";
|
||||
*
|
||||
* Acceptance: `nessa.localhost:3000/contact` renders the contact form with
|
||||
* Nessa branding; submissions email `michael@freno.me` with subject
|
||||
* `[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).
|
||||
* `[Nessa] Contact Request`.
|
||||
*/
|
||||
export default function NessaContactPage() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||
<SubdomainHeader />
|
||||
<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>
|
||||
<ContactForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,15 +14,6 @@ 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;
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
DELETION_GRACE_PERIOD_LABEL,
|
||||
PAGE_META
|
||||
} from "~/routes/nessa/deletion-content";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
export default function NessaDeletionPage() {
|
||||
return (
|
||||
@@ -55,18 +54,6 @@ 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}
|
||||
|
||||
@@ -30,7 +30,6 @@ 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 (
|
||||
@@ -175,29 +174,15 @@ 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) 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
|
||||
<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">
|
||||
through the contact form
|
||||
</A>{" "}
|
||||
or email{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -1,454 +0,0 @@
|
||||
/**
|
||||
* 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's Terms of Use</div>
|
||||
<div class="py-2">Last Updated: November 9, 2026</div>
|
||||
<div class="py-2">
|
||||
Welcome to Nessa ('We', 'Us', 'Our').
|
||||
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's or guardian'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'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'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'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'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'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'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 "as
|
||||
is" and "as available" 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'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 "Last Updated" 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Regression test for the /agents roster (NOOK-336) and its coverage wording
|
||||
* (FRE-359).
|
||||
* Regression test for the /agents roster and its coverage count.
|
||||
*
|
||||
* The page mirrors the app's registry, `AgentSpec.all` in
|
||||
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
||||
@@ -9,11 +8,11 @@
|
||||
* them. The registry lives in another repo, so the expected roster is
|
||||
* restated here; when an agent is added there, add it here and on the page.
|
||||
*
|
||||
* FRE-359 verdict B: the page no longer states a count or claims coverage
|
||||
* ("all thirteen", "every coding agent The Nook supports"). The page still
|
||||
* lists 13 of the registry's 18 rows — the table is hand-mirrored and its
|
||||
* regeneration is its own ticket — so the roster check here is a subset
|
||||
* check in registry order, not an equality check.
|
||||
* FRE-997 restored the count. The page said thirteen while the registry held
|
||||
* eighteen, so the count was dropped rather than corrected (FRE-359 verdict B);
|
||||
* now that all eighteen rows are on the page, the roster check is an equality
|
||||
* check again and the page states eighteen. A subset check would let a shipped
|
||||
* agent go missing behind a passing suite, which is the NOOK-336 failure.
|
||||
*
|
||||
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
||||
*/
|
||||
@@ -48,51 +47,23 @@ const REGISTRY = [
|
||||
"Amp"
|
||||
];
|
||||
|
||||
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen"];
|
||||
|
||||
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
||||
|
||||
/** The page's rows are a subset of the registry, in registry order. */
|
||||
function subsetInOrder(page: string[], registry: string[]): boolean {
|
||||
let cursor = 0;
|
||||
for (const name of page) {
|
||||
const at = registry.indexOf(name, cursor);
|
||||
if (at === -1) return false;
|
||||
cursor = at + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe("Nook /agents roster (NOOK-336)", () => {
|
||||
it("lists only registry agents, in registry order", () => {
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
expect(subsetInOrder(entries, REGISTRY)).toBe(true);
|
||||
describe("Nook /agents roster", () => {
|
||||
it("lists every registry agent, once, in registry order", () => {
|
||||
expect(entries).toEqual(REGISTRY);
|
||||
});
|
||||
|
||||
it("lists each agent at most once", () => {
|
||||
expect(new Set(entries).size).toBe(entries.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nook /agents coverage wording (FRE-359)", () => {
|
||||
it("states no agent count anywhere on the page", () => {
|
||||
for (const word of NUMBER_WORDS) {
|
||||
expect(COPY.toLowerCase()).not.toContain(word);
|
||||
}
|
||||
// A bare numeral count ("13 agents", "The 13") is just as precise and just
|
||||
// as unverified; the page must not state one in any form.
|
||||
expect(COPY).not.toMatch(/\b\d{1,2} agents\b/);
|
||||
expect(COPY).not.toMatch(/\ball \d{1,2}\b/);
|
||||
});
|
||||
|
||||
it("makes no completeness claim over the agent set", () => {
|
||||
for (const banned of [
|
||||
"every coding agent",
|
||||
"the agents the nook supports",
|
||||
"agents the nook supports",
|
||||
"supported agents"
|
||||
]) {
|
||||
expect(COPY.toLowerCase()).not.toContain(banned);
|
||||
}
|
||||
it("states the registry's size", () => {
|
||||
expect(entries.length).toBe(18);
|
||||
expect(COPY).toContain("all eighteen");
|
||||
expect(COPY).toContain("The 18 agents");
|
||||
expect(COPY).toContain("eighteen coding agents");
|
||||
});
|
||||
|
||||
it("carries no stale count", () => {
|
||||
expect(COPY.toLowerCase()).not.toContain("thirteen");
|
||||
expect(COPY).not.toMatch(/\b13 agents\b/);
|
||||
expect(COPY).not.toContain("all thirteen");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* `/agents` on the Nook subdomain — the agent roster and each row's event
|
||||
* coverage. It states no agent count and claims no completeness over the
|
||||
* registry (FRE-359 verdict B).
|
||||
* `/agents` on the Nook subdomain — all eighteen agents in the registry and
|
||||
* each row's event coverage.
|
||||
*
|
||||
* The registry below mirrors `AgentSpec.all` in the app
|
||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
|
||||
* installed hook surface (hook/setup.go) or plugin event subscription
|
||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentRegistry.swift) and each
|
||||
* agent's installed hook surface (hook/setup.go) or plugin event subscription
|
||||
* (plugins/*), normalized onto the shared event vocabulary
|
||||
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
||||
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
||||
@@ -261,6 +260,92 @@ const AGENTS: AgentEntry[] = [
|
||||
],
|
||||
gating:
|
||||
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
wiring: "Hook rules in its TOML config, one per event",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"AgentStart",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PermissionResolved",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"Notification"
|
||||
],
|
||||
gating:
|
||||
"Observation — every event appears as a card; its PreToolUse fires before its own permission check, so the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "GitHub Copilot CLI",
|
||||
wiring: "One hook file of ours in its hooks directory",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"ErrorOccurred",
|
||||
"PreCompact",
|
||||
"Notification"
|
||||
],
|
||||
gating:
|
||||
"Observation — every event appears as a card; its permission request runs before its own policy check, so the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "Crush",
|
||||
wiring:
|
||||
"One entry in its JSONC config, the only event it documents",
|
||||
events: ["PreToolUse"],
|
||||
gating:
|
||||
"Observation — tool calls appear as cards; its own permission prompts decide"
|
||||
},
|
||||
{
|
||||
name: "Cursor",
|
||||
wiring:
|
||||
"Hook entries in its hooks.json, merged with your own. No permission hook, on purpose",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"AfterAgentResponse",
|
||||
"SubagentStop",
|
||||
"PreCompact"
|
||||
],
|
||||
gating:
|
||||
"Observation — a permission hook that stays silent blocks the action here, so Cursor keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "Amp",
|
||||
wiring: "In-process extension package",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError"
|
||||
],
|
||||
gating:
|
||||
"Observation — its plugin API has no permission-wait event, so the agent keeps its own prompting"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -284,19 +369,19 @@ export default function NookAgentsPage() {
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Agent coverage"
|
||||
description="The coding agents The Nook connects to, with the events each hook reports and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||
ogTitle="The coding agents The Nook connects to, and what each hook sees"
|
||||
title="Supported agents"
|
||||
description="Every coding agent The Nook supports — all eighteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||
ogTitle="The 18 agents The Nook supports, and what each hook sees"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Agent coverage
|
||||
Supported agents
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
||||
The Nook watches coding agents in one place — your menu bar and notch
|
||||
show sessions that are running, thinking, or blocked, and a card can
|
||||
answer a permission prompt without leaving your keyboard.
|
||||
The Nook watches eighteen coding agents in one place — your menu bar and
|
||||
notch show every session that is running, thinking, or blocked, and a
|
||||
card answers permission prompts without leaving your keyboard.
|
||||
</p>
|
||||
<p class="text-subtext1 mb-10 max-w-2xl text-sm">
|
||||
Each agent reports its own set of events. "Allow / Deny from a card"
|
||||
|
||||
@@ -59,9 +59,9 @@ export default function NookLanding() {
|
||||
<PageHead
|
||||
title="Agent control for your Mac"
|
||||
ogTitle="The Nook — agent control for your Mac"
|
||||
description="Monitor coding agents, respond to permission requests, and manage your Mac\'s cooling, battery, and audio with The Nook."
|
||||
description="The Nook — a native macOS utility for coding-agent orchestration, fan and thermal control."
|
||||
ogImage={`https://${site().domain}/nook/og.png`}
|
||||
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."
|
||||
ogDescription="A one-time-purchase macOS app for coding-agent orchestration, fan control, and thermal insight."
|
||||
/>
|
||||
|
||||
<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">
|
||||
Manage your coding agents and your Mac
|
||||
Your Mac and Agents, under control
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-2 max-w-xl text-xl">
|
||||
Follow your agents, respond to requests, and manage cooling,
|
||||
charging, and audio from the top of your screen.
|
||||
Agent orchestration, fan and thermal control in a beautiful native
|
||||
UI.
|
||||
</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">
|
||||
What you can do with The Nook
|
||||
Built for the way you actually run agents
|
||||
</h2>
|
||||
<FeatureBreakdowns />
|
||||
</div>
|
||||
@@ -164,9 +164,10 @@ export default function NookLanding() {
|
||||
Try it free for 14 days
|
||||
</h2>
|
||||
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
|
||||
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.
|
||||
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.
|
||||
</p>
|
||||
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
|
||||
<Button
|
||||
@@ -186,7 +187,7 @@ export default function NookLanding() {
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-subtext1 mt-6 text-xs">
|
||||
The beta price is $10. The regular price will be $15. One license covers up to 3 of
|
||||
Beta pricing — full price will be $15. One license covers up to 3 of
|
||||
your own Macs.
|
||||
</p>
|
||||
</div>
|
||||
@@ -228,7 +229,8 @@ export default function NookLanding() {
|
||||
</form>
|
||||
<Show when={resendSent()}>
|
||||
<p class="text-subtext1 mt-4 text-center text-sm">
|
||||
If a license is registered to that email, we'll send you the key.
|
||||
Check your inbox — if a license is registered to that email, your
|
||||
key is on its way.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={resendError()}>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,49 @@
|
||||
/**
|
||||
* NOOK-1038 restores remote marketing for Tailscale and authorized known hosts,
|
||||
* per Mike's correction. FRE-323's blanket removal is no longer the contract;
|
||||
* one-click setup is a supported scoped claim, not a promise for any SSH box.
|
||||
* FRE-359's separate decision against agent coverage counts is unchanged.
|
||||
* Static source audit, like agents.test.ts; browser checks cover rendered layout.
|
||||
* Regression test for the removed remote / SSH claims and the removed agent
|
||||
* count (FRE-323, FRE-359 verdict B).
|
||||
*
|
||||
* FRE-323 took the remote story off the site because it was unverified and
|
||||
* demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
|
||||
* installs the nook-hook" flow is app-UI-only, needs a hand-edited
|
||||
* `~/.ssh/config` for any host not already in ssh config or on the tailnet,
|
||||
* needs a one-time `ssh-copy-id` because the app runs ssh with
|
||||
* `BatchMode=yes`, and has no manual fallback for minting the per-host
|
||||
* credential. FRE-359 verdict B removed the coverage count for the same
|
||||
* reason: the /agents table lists 13 of the registry's 18 rows.
|
||||
*
|
||||
* FRE-997 corrects the second half without touching the first. The count was
|
||||
* stale, not unverifiable: `AgentSpec.all` holds eighteen, /agents now lists
|
||||
* all eighteen and `agents.test.ts` asserts that roster as an equality again.
|
||||
* So the two completeness phrasings FRE-359 banned ("every coding agent",
|
||||
* "Supported agents") no longer rest on a 13-of-18 gap — the ban existed
|
||||
* because the page claimed the set and listed two thirds of it. They are out
|
||||
* of `BANNED_COVERAGE` on this branch for that reason alone; the stale counts
|
||||
* stay banned, and every FRE-323 remote sentence stays banned, because this
|
||||
* branch still carries the FRE-323 site state.
|
||||
*
|
||||
* The copy was not thrown away — it is parked in
|
||||
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
|
||||
* it is still there so nobody deletes the parked story as stale. It goes back
|
||||
* on the site only when the one-click install ships and is recorded.
|
||||
*
|
||||
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
|
||||
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
|
||||
*
|
||||
* Deliberate exceptions, not audited here — both were weighed with the Chief
|
||||
* of Staff at the go/no-go and left in place on purpose:
|
||||
* - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip
|
||||
* it" is a limitation disclosure, not a capability claim.
|
||||
* - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to
|
||||
* the removed remote story, but tailnet host lookup does exist. Raised on
|
||||
* FRE-323 rather than widened into this correction.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "../../..");
|
||||
|
||||
/** Shipped page and component source. Tests and drafts may quote the old copy. */
|
||||
const AUDITED = [
|
||||
"src/routes/nook/index.tsx",
|
||||
"src/routes/nook/agents.tsx",
|
||||
@@ -19,93 +53,74 @@ 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")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
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"');
|
||||
});
|
||||
|
||||
describe("remote claims stay off the site (FRE-323)", () => {
|
||||
for (const file of AUDITED) {
|
||||
it(`${file} makes no blanket authorization or agent-coverage 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("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/copy.md"), "utf8")
|
||||
).toContain("Any SSH box, one click away");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent coverage count stays off the page (FRE-359 verdict B)", () => {
|
||||
for (const file of AUDITED) {
|
||||
it(`${file} states no coverage count`, () => {
|
||||
it(`${file} does not restore a removed remote sentence`, () => {
|
||||
const copy = copyOf(file);
|
||||
for (const phrase of [
|
||||
"all thirteen",
|
||||
"thirteen coding agents",
|
||||
"13 agents",
|
||||
"every coding agent",
|
||||
"Supported agents"
|
||||
])
|
||||
for (const phrase of BANNED_REMOTE_CLAIMS) {
|
||||
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("/agents titles the table Agent coverage", () => {
|
||||
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Agent coverage");
|
||||
it("/agents no longer offers remote sessions", () => {
|
||||
const copy = copyOf("src/routes/nook/agents.tsx");
|
||||
expect(copy).not.toContain("remote sessions report into the same grid");
|
||||
expect(copy).not.toContain("on this Mac or on remote");
|
||||
});
|
||||
|
||||
it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => {
|
||||
const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8");
|
||||
for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) {
|
||||
expect(parked).toContain(phrase);
|
||||
}
|
||||
expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain(
|
||||
"FRE-320"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the agent count is corrected, not dropped (FRE-997)", () => {
|
||||
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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("/agents states the registry's size", () => {
|
||||
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Supported agents");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,7 @@ import { describe, it, expect } from "bun:test";
|
||||
import { readdirSync, existsSync, statSync } from "fs";
|
||||
|
||||
function isRouteFile(name: string): boolean {
|
||||
// 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");
|
||||
return name.endsWith(".tsx") || name.endsWith(".ts");
|
||||
}
|
||||
|
||||
function listRouteFiles(dir: string): string[] {
|
||||
@@ -28,20 +25,6 @@ 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`)) {
|
||||
@@ -58,12 +41,14 @@ describe("subdomain routing dispatch invariant", () => {
|
||||
const routesDir = "./src/routes";
|
||||
|
||||
it("every subdomain route has a top-level dispatch route", () => {
|
||||
// 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)
|
||||
const prefixDirs = listRouteFiles(routesDir)
|
||||
.filter((name) => {
|
||||
try {
|
||||
return statSync(`${routesDir}/${name}`).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
// Exclude non-product subdomains
|
||||
.filter((name) =>
|
||||
["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
200
src/server/analytics-retention.test.ts
Normal file
200
src/server/analytics-retention.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { VISITOR_ANALYTICS_DDL } from "./analytics-schema";
|
||||
import {
|
||||
DEFAULT_ANALYTICS_RETENTION_DAYS,
|
||||
MAX_ANALYTICS_RETENTION_DAYS,
|
||||
isAuthorizedCron,
|
||||
pruneVisitorAnalytics,
|
||||
resolveRetentionDays,
|
||||
runRetentionCron
|
||||
} from "./analytics-retention";
|
||||
|
||||
// Why a real in-memory SQLite database: retention is a `DELETE` whose whole
|
||||
// job is a date comparison. A stub returning a canned count would pass with a
|
||||
// window that is off by a unit, inverted, or — the failure this file exists for
|
||||
// — never applied at all. Same reasoning as the backfill and schema suites, and
|
||||
// nothing is module-mocked here on purpose: bun's `mock.module` is process-wide.
|
||||
|
||||
let db: Database;
|
||||
|
||||
const conn = {
|
||||
async execute(query: { sql: string; args?: unknown[] }) {
|
||||
const statement = db.query(query.sql);
|
||||
const args = (query.args ?? []) as never[];
|
||||
// `DELETE … RETURNING id` answers with rows, which is how the prune counts
|
||||
// what it removed; bun:sqlite exposes those through `.all()`.
|
||||
if (
|
||||
/^\s*(select|pragma|with)\b/i.test(query.sql) ||
|
||||
/\breturning\b/i.test(query.sql)
|
||||
) {
|
||||
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
|
||||
}
|
||||
return {
|
||||
rows: [] as unknown[],
|
||||
rowsAffected: statement.run(...args).changes
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function insert(id: string, createdAt: string) {
|
||||
db.run(
|
||||
`INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES (?, '/x', 'GET', ?)`,
|
||||
[id, createdAt]
|
||||
);
|
||||
}
|
||||
|
||||
function rowCount(): number {
|
||||
return (db.query(`SELECT COUNT(*) AS n FROM VisitorAnalytics`).get() as {
|
||||
n: number;
|
||||
}).n;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
db.run(VISITOR_ANALYTICS_DDL);
|
||||
});
|
||||
|
||||
describe("resolveRetentionDays", () => {
|
||||
it("takes a usable configured window", () => {
|
||||
expect(resolveRetentionDays("90")).toBe(90);
|
||||
expect(resolveRetentionDays(30)).toBe(30);
|
||||
});
|
||||
|
||||
it("falls back to the default when the value is missing or nonsense", () => {
|
||||
expect(resolveRetentionDays(undefined)).toBe(
|
||||
DEFAULT_ANALYTICS_RETENTION_DAYS
|
||||
);
|
||||
expect(resolveRetentionDays("")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
expect(resolveRetentionDays("0")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
expect(resolveRetentionDays("-5")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
expect(resolveRetentionDays("1.5")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
expect(resolveRetentionDays("all")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
});
|
||||
|
||||
it("refuses a window so long it means 'never prune'", () => {
|
||||
expect(resolveRetentionDays(String(MAX_ANALYTICS_RETENTION_DAYS + 1))).toBe(
|
||||
DEFAULT_ANALYTICS_RETENTION_DAYS
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneVisitorAnalytics", () => {
|
||||
it("deletes only rows older than the window", async () => {
|
||||
insert("young", "2020-01-01 00:00:00");
|
||||
insert("old", "2019-01-01 00:00:00");
|
||||
// A row exactly at `now - 365 days` boundary in the other direction: the
|
||||
// comparison is `<`, so the recent one survives.
|
||||
db.run(
|
||||
`UPDATE VisitorAnalytics SET created_at = datetime('now', '-10 days') WHERE id = 'young'`
|
||||
);
|
||||
db.run(
|
||||
`UPDATE VisitorAnalytics SET created_at = datetime('now', '-400 days') WHERE id = 'old'`
|
||||
);
|
||||
|
||||
const deleted = await pruneVisitorAnalytics(conn, 365);
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes nothing when every row is inside the window", async () => {
|
||||
insert("a", "x");
|
||||
db.run(
|
||||
`UPDATE VisitorAnalytics SET created_at = datetime('now', '-1 day') WHERE id = 'a'`
|
||||
);
|
||||
|
||||
expect(await pruneVisitorAnalytics(conn, 365)).toBe(0);
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a window it was not given a number for", async () => {
|
||||
insert("a", "2019-01-01 00:00:00");
|
||||
|
||||
await expect(pruneVisitorAnalytics(conn, 0)).rejects.toThrow(/window/);
|
||||
await expect(pruneVisitorAnalytics(conn, -30)).rejects.toThrow(/window/);
|
||||
await expect(pruneVisitorAnalytics(conn, NaN)).rejects.toThrow(/window/);
|
||||
// "0" is not a window either — and must not delete the table.
|
||||
await expect(
|
||||
pruneVisitorAnalytics(conn, "0" as unknown as number)
|
||||
).rejects.toThrow(/window/);
|
||||
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("passes the window as a parameter, not as interpolated SQL", async () => {
|
||||
insert("a", "2019-01-01 00:00:00");
|
||||
const injected = "365); DROP TABLE VisitorAnalytics; --" as unknown as number;
|
||||
|
||||
// Truncating a non-numeric string yields NaN, which the guard refuses — so
|
||||
// the statement never runs and the table is still there.
|
||||
await expect(pruneVisitorAnalytics(conn, injected)).rejects.toThrow(/window/);
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedCron", () => {
|
||||
it("matches the bearer secret and nothing else", () => {
|
||||
expect(isAuthorizedCron("Bearer sekrit", "sekrit")).toBe(true);
|
||||
expect(isAuthorizedCron("bearer SEKRIT".toLowerCase(), "sekrit")).toBe(true);
|
||||
expect(isAuthorizedCron("Bearer nope", "sekrit")).toBe(false);
|
||||
expect(isAuthorizedCron(null, "sekrit")).toBe(false);
|
||||
expect(isAuthorizedCron("Bearer sekrit", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("denies everything when no secret is configured (fail closed)", () => {
|
||||
expect(isAuthorizedCron("Bearer ", undefined)).toBe(false);
|
||||
expect(isAuthorizedCron(undefined, "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRetentionCron", () => {
|
||||
it("denies an unauthenticated trigger before touching the table", async () => {
|
||||
insert("a", "2019-01-01 00:00:00");
|
||||
|
||||
const res = await runRetentionCron({
|
||||
authorization: null,
|
||||
secret: "sekrit",
|
||||
configuredDays: "365",
|
||||
conn
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("prunes on its window and reports what it removed", async () => {
|
||||
db.run(
|
||||
`INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES ('old','/x','GET',datetime('now','-400 days'))`
|
||||
);
|
||||
db.run(
|
||||
`INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES ('new','/x','GET',datetime('now','-1 day'))`
|
||||
);
|
||||
|
||||
const res = await runRetentionCron({
|
||||
authorization: "Bearer sekrit",
|
||||
secret: "sekrit",
|
||||
configuredDays: "365",
|
||||
conn
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, days: 365, deleted: 1 });
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("says it failed rather than reporting a quiet zero", async () => {
|
||||
db.close(); // the table is gone: the prune's statement must now throw
|
||||
|
||||
const res = await runRetentionCron({
|
||||
authorization: "Bearer sekrit",
|
||||
secret: "sekrit",
|
||||
configuredDays: "365",
|
||||
conn
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.days).toBe(365);
|
||||
expect(res.body.deleted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
129
src/server/analytics-retention.ts
Normal file
129
src/server/analytics-retention.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Scheduled retention for `VisitorAnalytics` (FRE-369, finding Q4).
|
||||
*
|
||||
* The table used to grow without bound: `cleanupOldAnalytics` existed but was
|
||||
* reachable only as a manual tRPC call, and once FRE-366 started recording a
|
||||
* real client address instead of the proxy's loopback, "unbounded" meant
|
||||
* unbounded personal data in a hosted third-party database. Two halves answer
|
||||
* that — the write-time prefix in `./request-client`, and this: a bound on how
|
||||
* long any row exists at all.
|
||||
*
|
||||
* The default (365 days) is deliberately conservative so enabling the schedule
|
||||
* is not itself a destructive event: it deletes nothing on its first run
|
||||
* against a database whose history starts inside the window, and it bounds
|
||||
* growth from then on. Tighten it to the reviewer's suggested 90 by setting
|
||||
* `ANALYTICS_RETENTION_DAYS=90` — that first run *will* delete months of
|
||||
* history that the /analytics dashboard and the weekly report read, which is an
|
||||
* operational decision, not one this file should make silently.
|
||||
*/
|
||||
|
||||
/** Kept when `ANALYTICS_RETENTION_DAYS` is unset or unusable. */
|
||||
export const DEFAULT_ANALYTICS_RETENTION_DAYS = 365;
|
||||
|
||||
/** Anything longer is a misconfiguration, not a policy (≈ 2 years). */
|
||||
export const MAX_ANALYTICS_RETENTION_DAYS = 730;
|
||||
|
||||
/**
|
||||
* The slice of a connection this module needs — the same shape
|
||||
* `weekly-metrics.ts` pins as `MetricsConn`, so a libsql client satisfies it
|
||||
* without a cast (and the in-memory SQLite test adapter satisfies it too).
|
||||
*/
|
||||
export interface AnalyticsConn {
|
||||
execute(query: {
|
||||
sql: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
args?: any[];
|
||||
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The retention window to enforce: a positive integer at most
|
||||
* {@link MAX_ANALYTICS_RETENTION_DAYS}, else the default. Accepts the env
|
||||
* string form and a number, because both callers hand over a different shape.
|
||||
*/
|
||||
export function resolveRetentionDays(
|
||||
configured: string | number | undefined | null
|
||||
): number {
|
||||
const days = typeof configured === "number" ? configured : Number(configured);
|
||||
if (!Number.isInteger(days) || days <= 0) return DEFAULT_ANALYTICS_RETENTION_DAYS;
|
||||
if (days > MAX_ANALYTICS_RETENTION_DAYS) return DEFAULT_ANALYTICS_RETENTION_DAYS;
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete analytics rows older than `olderThanDays` and report how many went.
|
||||
*
|
||||
* The window is a SQL parameter, not interpolated text: `cleanupOldAnalytics`
|
||||
* used to splice the number into the statement, which only ever sat behind a
|
||||
* `z.number()` input on the tRPC side — one unvalidated caller away from
|
||||
* injection. A non-integer is refused here rather than passed on, so neither
|
||||
* path can delete on a window the code did not check.
|
||||
*/
|
||||
export async function pruneVisitorAnalytics(
|
||||
conn: AnalyticsConn,
|
||||
olderThanDays: number
|
||||
): Promise<number> {
|
||||
const days = Math.trunc(Number(olderThanDays));
|
||||
if (!Number.isInteger(days) || days <= 0) {
|
||||
throw new Error(
|
||||
`Refusing to prune VisitorAnalytics on a non-positive window: ${olderThanDays}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `DELETE FROM VisitorAnalytics
|
||||
WHERE created_at < datetime('now', ?)
|
||||
RETURNING id`,
|
||||
args: [`-${days} days`]
|
||||
});
|
||||
|
||||
// Counted from `RETURNING id` rather than `rowsAffected`, keeping the contract
|
||||
// `cleanupOldAnalytics` had: the caller is told how many rows actually went.
|
||||
return result.rows.length;
|
||||
}
|
||||
|
||||
/** Whether a cron may run: Bearer match, and an unset secret denies everything. */
|
||||
export function isAuthorizedCron(
|
||||
authorization: string | null | undefined,
|
||||
secret: string | undefined | null
|
||||
): boolean {
|
||||
if (!secret) return false;
|
||||
const provided = (authorization ?? "").replace(/^Bearer\s+/i, "").trim();
|
||||
return Boolean(provided) && provided === secret;
|
||||
}
|
||||
|
||||
export interface RetentionOutcome {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole cron decision, away from the route: authorise, resolve the window,
|
||||
* prune. Returns the status and body the route should send, so the behaviour is
|
||||
* testable without a database or an environment.
|
||||
*
|
||||
* Fail closed on auth (an unauthenticated trigger deleting real history is the
|
||||
* worst thing this endpoint can do), and 500 rather than a silent zero on a
|
||||
* failed prune — a retention job that quietly stopped pruning is exactly the
|
||||
* state that created this issue.
|
||||
*/
|
||||
export async function runRetentionCron(options: {
|
||||
authorization: string | null | undefined;
|
||||
secret: string | undefined | null;
|
||||
configuredDays: string | number | undefined | null;
|
||||
conn: AnalyticsConn;
|
||||
}): Promise<RetentionOutcome> {
|
||||
if (!isAuthorizedCron(options.authorization, options.secret)) {
|
||||
return { status: 401, body: { error: "Unauthorized" } };
|
||||
}
|
||||
|
||||
const days = resolveRetentionDays(options.configuredDays);
|
||||
|
||||
try {
|
||||
const deleted = await pruneVisitorAnalytics(options.conn, days);
|
||||
return { status: 200, body: { success: true, days, deleted } };
|
||||
} catch (error) {
|
||||
console.error("Analytics retention job failed:", error);
|
||||
return { status: 500, body: { error: "Analytics retention job failed", days } };
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,9 @@ const sampleArgs = [
|
||||
2, // inp
|
||||
3, // ttfb
|
||||
4, // dom_load
|
||||
5 // load_complete
|
||||
5, // load_complete
|
||||
"click", // stage
|
||||
"nook.freno.me" // host
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -69,7 +71,7 @@ describe("VisitorAnalytics schema", () => {
|
||||
}
|
||||
// The reverse direction matters too: a column the code writes but the
|
||||
// DDL omits is the failure this whole file exists to catch.
|
||||
expect(VISITOR_ANALYTICS_COLUMNS.length).toBe(23);
|
||||
expect(VISITOR_ANALYTICS_COLUMNS.length).toBe(25);
|
||||
});
|
||||
|
||||
it("has one placeholder per inserted column", () => {
|
||||
@@ -83,7 +85,7 @@ describe("VisitorAnalytics schema", () => {
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT path, country, utm_source, utm_medium, utm_campaign
|
||||
`SELECT path, country, utm_source, utm_medium, utm_campaign, stage, host
|
||||
FROM VisitorAnalytics`
|
||||
)
|
||||
.get() as Record<string, unknown>;
|
||||
@@ -93,7 +95,9 @@ describe("VisitorAnalytics schema", () => {
|
||||
country: "GB",
|
||||
utm_source: "hn",
|
||||
utm_medium: "post",
|
||||
utm_campaign: "launch"
|
||||
utm_campaign: "launch",
|
||||
stage: "click",
|
||||
host: "nook.freno.me"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export const VISITOR_ANALYTICS_DDL = `
|
||||
utm_source TEXT,
|
||||
utm_medium TEXT,
|
||||
utm_campaign TEXT,
|
||||
stage TEXT,
|
||||
host TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL
|
||||
)
|
||||
`;
|
||||
@@ -79,6 +81,18 @@ export const VISITOR_ANALYTICS_ADDED_COLUMNS = [
|
||||
{
|
||||
name: "utm_campaign",
|
||||
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN utm_campaign TEXT`
|
||||
},
|
||||
{
|
||||
// Which funnel stage a download row is: "click" (a door) or "served"
|
||||
// (the file route). Null on every non-download row (FRE-366).
|
||||
name: "stage",
|
||||
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN stage TEXT`
|
||||
},
|
||||
{
|
||||
// The Host the client addressed, e.g. "nook.freno.me" — the site
|
||||
// dimension the table never had (FRE-366).
|
||||
name: "host",
|
||||
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN host TEXT`
|
||||
}
|
||||
];
|
||||
|
||||
@@ -110,7 +124,9 @@ export const VISITOR_ANALYTICS_COLUMNS = [
|
||||
"inp",
|
||||
"ttfb",
|
||||
"dom_load",
|
||||
"load_complete"
|
||||
"load_complete",
|
||||
"stage",
|
||||
"host"
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
VISITOR_ANALYTICS_INDEXES,
|
||||
VISITOR_ANALYTICS_INSERT
|
||||
} from "./analytics-schema";
|
||||
import { truncateIpAddress } from "./request-client";
|
||||
import { pruneVisitorAnalytics } from "./analytics-retention";
|
||||
|
||||
/**
|
||||
* Idempotent schema bootstrap, mirroring `nook.ts`. The table had no DDL in
|
||||
@@ -64,6 +66,10 @@ export interface AnalyticsEntry {
|
||||
ttfb?: number | null;
|
||||
domLoad?: number | null;
|
||||
loadComplete?: number | null;
|
||||
/** Funnel stage for download rows ("click" | "served"); null elsewhere. */
|
||||
stage?: string | null;
|
||||
/** Host the client addressed, e.g. "nook.freno.me" — the site dimension. */
|
||||
host?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +130,9 @@ async function flushAnalyticsBuffer(): Promise<void> {
|
||||
entry.inp || null,
|
||||
entry.ttfb || null,
|
||||
entry.domLoad || null,
|
||||
entry.loadComplete || null
|
||||
entry.loadComplete || null,
|
||||
entry.stage || null,
|
||||
entry.host || null
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -258,6 +266,8 @@ export async function queryAnalytics(
|
||||
utm_medium: row.utm_medium as string | null,
|
||||
utm_campaign: row.utm_campaign as string | null,
|
||||
duration_ms: row.duration_ms as number | null,
|
||||
stage: row.stage as string | null,
|
||||
host: row.host as string | null,
|
||||
created_at: row.created_at as string
|
||||
}));
|
||||
}
|
||||
@@ -309,6 +319,10 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||
});
|
||||
const totalApiCalls = (totalApiCallsResult.rows[0]?.count as number) || 0;
|
||||
|
||||
// Since the FRE-369 privacy floor, `ip_address` holds a truncated prefix
|
||||
// (/24 for v4, /56 for v6), so this counts distinct prefixes — households and
|
||||
// mobile exits, not machines. It was never accurate before that either: every
|
||||
// row held the proxy's loopback, so the count was 1.
|
||||
const uniqueVisitorsResult = await conn.execute({
|
||||
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics
|
||||
WHERE ip_address IS NOT NULL
|
||||
@@ -620,19 +634,15 @@ export async function getPerformanceStats(days: number = 30): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete visitor analytics older than `olderThanDays`. Reachable from tRPC
|
||||
* (admin) and from the daily retention cron (`/api/analytics/retention`);
|
||||
* the SQL and the input rules live in `./analytics-retention`.
|
||||
*/
|
||||
export async function cleanupOldAnalytics(
|
||||
olderThanDays: number
|
||||
): Promise<number> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `DELETE FROM VisitorAnalytics
|
||||
WHERE created_at < datetime('now', '-${olderThanDays} days')
|
||||
RETURNING id`,
|
||||
args: []
|
||||
});
|
||||
|
||||
return result.rows.length;
|
||||
return pruneVisitorAnalytics(ConnectionFactory(), olderThanDays);
|
||||
}
|
||||
|
||||
function parseUserAgent(userAgent?: string): {
|
||||
@@ -667,6 +677,16 @@ function parseUserAgent(userAgent?: string): {
|
||||
return { deviceType, browser, os };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in what the writer did not supply, and apply the privacy floor.
|
||||
*
|
||||
* Every analytics entry — page views, tRPC context, downloads, checkout —
|
||||
* passes through here, which is why the truncation lives here and not at the
|
||||
* call sites: a future writer cannot forget it, and no caller can persist a
|
||||
* full client address by accident (FRE-369, finding Q4). The audit log
|
||||
* (`./audit.ts`) is a separate, pre-existing surface with its own stakes and is
|
||||
* deliberately untouched by this change.
|
||||
*/
|
||||
export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
|
||||
const { deviceType, browser, os } = parseUserAgent(
|
||||
entry.userAgent || undefined
|
||||
@@ -674,6 +694,7 @@ export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
|
||||
|
||||
return {
|
||||
...entry,
|
||||
ipAddress: truncateIpAddress(entry.ipAddress),
|
||||
deviceType: entry.deviceType || deviceType,
|
||||
browser: entry.browser || browser,
|
||||
os: entry.os || os
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "~/server/analytics";
|
||||
import { ConnectionFactory } from "~/server/database";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { resolveClientIp, requestHost } from "~/server/request-client";
|
||||
import { parseUtm } from "~/server/utm";
|
||||
import { VISITOR_ANALYTICS_INSERT } from "~/server/analytics-schema";
|
||||
|
||||
@@ -98,7 +98,8 @@ export const analyticsRouter = createTRPCRouter({
|
||||
getHeader(req.headers, "referer") ||
|
||||
getHeader(req.headers, "referrer") ||
|
||||
getHeader(ctx.event.request?.headers, "referer");
|
||||
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
|
||||
const ipAddress = resolveClientIp(ctx.event) || undefined;
|
||||
const host = requestHost(ctx.event) || undefined;
|
||||
const enriched = enrichAnalyticsEntry({
|
||||
userId: ctx.userId,
|
||||
path: input.path,
|
||||
@@ -106,6 +107,7 @@ export const analyticsRouter = createTRPCRouter({
|
||||
userAgent,
|
||||
referrer,
|
||||
ipAddress,
|
||||
host,
|
||||
// The client beacon sends `pathname + search`, so the page URL's
|
||||
// own query string is the campaign source; the referrer fills any
|
||||
// gap (e.g. clicks that arrive on a clean path).
|
||||
@@ -145,7 +147,9 @@ export const analyticsRouter = createTRPCRouter({
|
||||
enriched.inp || null,
|
||||
enriched.ttfb || null,
|
||||
enriched.domLoad || null,
|
||||
enriched.loadComplete || null
|
||||
enriched.loadComplete || null,
|
||||
enriched.stage || null,
|
||||
enriched.host || null
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export const downloadsRouter = createTRPCRouter({
|
||||
// exactly the event the funnel needs to show. Unknown asset names are
|
||||
// skipped rather than recorded as a download that never happened.
|
||||
if (assets[input.asset_name] || latestAssets[input.asset_name]) {
|
||||
await trackDownload(ctx.event, input.asset_name);
|
||||
await trackDownload(ctx.event, input.asset_name, "click");
|
||||
}
|
||||
|
||||
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { resolveClientIp, requestHost } from "~/server/request-client";
|
||||
import { getAuthPayloadFromEvent } from "~/server/auth";
|
||||
|
||||
export type Context = {
|
||||
@@ -51,7 +51,11 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
getHeader(req.headers, "referer") ||
|
||||
getHeader(req.headers, "referrer") ||
|
||||
getHeader(event.request?.headers, "referer");
|
||||
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
||||
// Behind the production proxy the direct peer is loopback, so the real
|
||||
// client address comes from the forwarded header the proxy sets — see
|
||||
// `~/server/request-client` for the trust boundary (FRE-366).
|
||||
const ipAddress = resolveClientIp(event) || undefined;
|
||||
const host = requestHost(event) || undefined;
|
||||
|
||||
// Don't log the performance logging endpoint itself to avoid circular tracking
|
||||
if (!path.includes("analytics.logPerformance")) {
|
||||
@@ -62,7 +66,8 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
method,
|
||||
userAgent,
|
||||
referrer,
|
||||
ipAddress
|
||||
ipAddress,
|
||||
host
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
203
src/server/request-client.test.ts
Normal file
203
src/server/request-client.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
// The trust boundary of FRE-366, as corrected by the FRE-369 security review.
|
||||
// Two things must stay true here:
|
||||
// 1. The address we record is the visitor, not whoever typed a header first.
|
||||
// Cloudflare and the origin proxy both *append* to x-forwarded-for, so the
|
||||
// leftmost hop is the client's own invention — `cf-connecting-ip` (which
|
||||
// Cloudflare overwrites) comes first, then the hop below the trusted edge.
|
||||
// 2. What reaches the database is a prefix, never a full address
|
||||
// (`truncateIpAddress`), because a visitor row is not worth a precise
|
||||
// location.
|
||||
// A regression on (1) re-records whatever an attacker claims; on (2) it stores
|
||||
// personal data we promised not to.
|
||||
|
||||
let directIp: string | null = null;
|
||||
mock.module("vinxi/http", () => ({
|
||||
getRequestIP: () => directIp
|
||||
}));
|
||||
|
||||
const {
|
||||
resolveClientIp,
|
||||
isLoopbackAddress,
|
||||
isIpAddress,
|
||||
truncateIpAddress,
|
||||
normaliseHost,
|
||||
requestHost
|
||||
} = await import("./request-client");
|
||||
|
||||
function event(
|
||||
headers: Record<string, string> = {},
|
||||
url = "https://nook.freno.me/downloads"
|
||||
) {
|
||||
return {
|
||||
request: new Request(url, { headers }),
|
||||
nativeEvent: {}
|
||||
} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
directIp = null;
|
||||
});
|
||||
|
||||
describe("resolveClientIp", () => {
|
||||
it("prefers Cloudflare's connecting address over any forwarded header", () => {
|
||||
directIp = "127.0.0.1";
|
||||
expect(
|
||||
resolveClientIp(
|
||||
event({
|
||||
"cf-connecting-ip": "203.0.113.7",
|
||||
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("takes the hop below the trusted edge — never the leftmost one", () => {
|
||||
// client → Cloudflare → same-host origin proxy: the sender's spoof is
|
||||
// first, the real client second, the edge last.
|
||||
directIp = "::1";
|
||||
expect(
|
||||
resolveClientIp(
|
||||
event({
|
||||
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("refuses a single-hop forwarded header: nothing above it to discount", () => {
|
||||
directIp = "127.0.0.1";
|
||||
expect(
|
||||
resolveClientIp(event({ "x-forwarded-for": "198.51.100.9" }))
|
||||
).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("ignores a forwarded header from a real peer — it is spoofable", () => {
|
||||
directIp = "203.0.113.7";
|
||||
expect(
|
||||
resolveClientIp(
|
||||
event({
|
||||
"cf-connecting-ip": "198.51.100.9",
|
||||
"x-forwarded-for": "198.51.100.9, 10.0.0.1"
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("ignores a header value that is not an address", () => {
|
||||
directIp = "127.0.0.1";
|
||||
expect(
|
||||
resolveClientIp(
|
||||
event({
|
||||
"cf-connecting-ip": "not-an-address",
|
||||
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
|
||||
directIp = "127.0.0.1";
|
||||
expect(
|
||||
resolveClientIp(event({ "cf-connecting-ip": "evil.example.com" }))
|
||||
).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("keeps the loopback peer when the proxy set nothing usable", () => {
|
||||
directIp = "::ffff:127.0.0.1";
|
||||
expect(resolveClientIp(event())).toBe("::ffff:127.0.0.1");
|
||||
});
|
||||
|
||||
it("records nothing rather than inventing an address", () => {
|
||||
expect(resolveClientIp(event())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoopbackAddress", () => {
|
||||
it("covers the v4 range and every spelling of v6 loopback", () => {
|
||||
expect(isLoopbackAddress("127.0.0.1")).toBe(true);
|
||||
expect(isLoopbackAddress("127.0.0.53")).toBe(true);
|
||||
expect(isLoopbackAddress("::1")).toBe(true);
|
||||
expect(isLoopbackAddress("0:0:0:0:0:0:0:1")).toBe(true);
|
||||
expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isLoopbackAddress("::ffff:127.9.9.9")).toBe(true);
|
||||
});
|
||||
|
||||
it("is not loopback for a real peer, or for text it cannot parse", () => {
|
||||
expect(isLoopbackAddress("203.0.113.7")).toBe(false);
|
||||
expect(isLoopbackAddress("10.0.0.1")).toBe(false);
|
||||
expect(isLoopbackAddress("localhost")).toBe(false);
|
||||
expect(isLoopbackAddress("127.0.53")).toBe(false); // not a complete address
|
||||
expect(isLoopbackAddress("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIpAddress", () => {
|
||||
it("accepts a dotted quad and an IPv6 literal", () => {
|
||||
expect(isIpAddress("198.51.100.9")).toBe(true);
|
||||
expect(isIpAddress("2001:db8::1")).toBe(true);
|
||||
expect(isIpAddress("::ffff:198.51.100.9")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects anything a proxy could not have meant", () => {
|
||||
expect(isIpAddress("999.1.1.1")).toBe(false);
|
||||
expect(isIpAddress("1.2.3")).toBe(false);
|
||||
expect(isIpAddress("2001:db8:::1")).toBe(false);
|
||||
expect(isIpAddress("evil.example.com")).toBe(false);
|
||||
expect(isIpAddress("")).toBe(false);
|
||||
expect(isIpAddress(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateIpAddress", () => {
|
||||
it("cuts IPv4 to its /24", () => {
|
||||
expect(truncateIpAddress("198.51.100.9")).toBe("198.51.100.0");
|
||||
expect(truncateIpAddress("127.0.0.1")).toBe("127.0.0.0");
|
||||
});
|
||||
|
||||
it("cuts IPv6 to its /56, uncompressed and stable", () => {
|
||||
expect(
|
||||
truncateIpAddress("2001:db8:85a3:1234:5678:9abc:defa:1234")
|
||||
).toBe("2001:db8:85a3:1200:0:0:0:0");
|
||||
// The compressed spelling of the same address truncates to the same string,
|
||||
// so `COUNT(DISTINCT ip_address)` cannot be split by how a proxy rendered it.
|
||||
expect(truncateIpAddress("2001:db8:85a3:1234::")).toBe(
|
||||
"2001:db8:85a3:1200:0:0:0:0"
|
||||
);
|
||||
expect(truncateIpAddress("::1")).toBe("0:0:0:0:0:0:0:0");
|
||||
});
|
||||
|
||||
it("treats an IPv4-mapped v6 address as the v4 it is", () => {
|
||||
expect(truncateIpAddress("::ffff:198.51.100.9")).toBe("198.51.100.0");
|
||||
});
|
||||
|
||||
it("drops what it cannot parse rather than storing it", () => {
|
||||
expect(truncateIpAddress("203.0.113.999")).toBeNull();
|
||||
expect(truncateIpAddress("<script>alert(1)</script>")).toBeNull();
|
||||
expect(truncateIpAddress("")).toBeNull();
|
||||
expect(truncateIpAddress(null)).toBeNull();
|
||||
expect(truncateIpAddress(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normaliseHost", () => {
|
||||
it("lowercases and strips the port so the weekly host scope can match", () => {
|
||||
expect(normaliseHost("Nook.Freno.ME:443")).toBe("nook.freno.me");
|
||||
expect(normaliseHost("nook.freno.me.")).toBe("nook.freno.me");
|
||||
expect(normaliseHost("[::1]:3000")).toBe("::1");
|
||||
expect(normaliseHost(" ")).toBeNull();
|
||||
expect(normaliseHost(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestHost", () => {
|
||||
it("reads the host the client addressed from the request URL", () => {
|
||||
expect(requestHost(event())).toBe("nook.freno.me");
|
||||
expect(requestHost(event({}, "https://freno.me/"))).toBe("freno.me");
|
||||
});
|
||||
|
||||
it("normalises a host that carries a port", () => {
|
||||
expect(requestHost(event({}, "https://nook.freno.me:443/downloads"))).toBe(
|
||||
"nook.freno.me"
|
||||
);
|
||||
});
|
||||
});
|
||||
282
src/server/request-client.ts
Normal file
282
src/server/request-client.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { hostFromEventLike } from "./site-context-server";
|
||||
|
||||
/**
|
||||
* The client address and the site a request addressed, for the analytics
|
||||
* writers (FRE-366).
|
||||
*
|
||||
* Until this existed every writer called `getRequestIP` and only looked at
|
||||
* `x-forwarded-for` when it returned null — so behind the production proxy the
|
||||
* recorded address was the proxy itself, and all 12,035 addressed rows in
|
||||
* VisitorAnalytics carried 127.0.0.1 / ::1.
|
||||
*
|
||||
* STANDING RULE (FRE-369, the security review of that change): nothing here is
|
||||
* a security signal. The address is a *best guess at a visitor*, not an
|
||||
* authenticated identity — do not import it into auth, rate limiting, abuse
|
||||
* control, fraud checks, or anything that grants or denies access. A caller
|
||||
* that needs a security decision must re-derive trust for its own stakes; the
|
||||
* blast radius of a wrong answer here is a poisoned analytics row, and that is
|
||||
* the only reason the shortcuts below are acceptable.
|
||||
*
|
||||
* Trust boundary, stated plainly: `x-forwarded-for` is client-controlled text.
|
||||
* It is believed only when the direct peer is a loopback address — the
|
||||
* production shape, where a same-host proxy (nginx/caddy) terminates TLS and
|
||||
* sets the header itself. Anywhere else the direct peer IS the client (dev,
|
||||
* direct hit) and a forwarded header from it is ignored as spoofable.
|
||||
*
|
||||
* Which hop is the visitor (FRE-369 finding Q1): the request arrives as
|
||||
* `client → Cloudflare → same-host origin proxy → app`, and both proxies
|
||||
* *append*. So `x-forwarded-for` reaches us as
|
||||
* `<client-supplied spoof…>, <true client>, <Cloudflare edge>` and the leftmost
|
||||
* hop is exactly the one the sender chose — the original FRE-366 read took it
|
||||
* and was wrong. Order of preference:
|
||||
*
|
||||
* 1. `cf-connecting-ip` — Cloudflare *overwrites* this header, so a
|
||||
* client-supplied value never survives the edge. Authoritative.
|
||||
* 2. `x-forwarded-for`, counted from the right: drop the rightmost hop (the
|
||||
* Cloudflare edge address the origin proxy appended) and take the one
|
||||
* before it. Never the leftmost.
|
||||
* 3. the direct peer, as before.
|
||||
*
|
||||
* h3's own `getRequestIP(event, { xForwardedFor: true })` also takes the
|
||||
* leftmost hop, so it is not a fix for this and must not be swapped in.
|
||||
*
|
||||
* Residual caveat, deliberately not papered over: step 2 assumes the pinned
|
||||
* topology (one trusted proxy above us, i.e. Cloudflare). Behind a *single*
|
||||
* forwarding proxy that does not set `cf-connecting-ip`, a client that sent its
|
||||
* own `x-forwarded-for` sits at the hop we read. That is why the value is only
|
||||
* ever stored truncated (see `truncateIpAddress`) and why it stays out of every
|
||||
* security path. Deploy-side precondition this repo cannot verify: the origin
|
||||
* must be reachable only from Cloudflare (origin firewall / authenticated
|
||||
* origin pull). If it is directly reachable, an attacker can set every header
|
||||
* in this file and no read here 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;
|
||||
|
||||
function header(event: APIEvent, name: string): string | null {
|
||||
try {
|
||||
return event.request?.headers?.get?.(name) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The visitor's address, resolved through the trust boundary above.
|
||||
*
|
||||
* Never store the result of this directly: `enrichAnalyticsEntry` runs every
|
||||
* analytics entry through {@link truncateIpAddress} so a full address cannot
|
||||
* reach the database even from a writer that forgets. Callers that need the
|
||||
* un-truncated value must justify it in review.
|
||||
*/
|
||||
export function resolveClientIp(event: APIEvent): string | null {
|
||||
const direct = getRequestIP(event.nativeEvent);
|
||||
|
||||
// A real peer is its own client: believed, and no forwarded header from it
|
||||
// is (it would be the peer lying about who it is).
|
||||
if (direct && !isLoopbackAddress(direct)) return direct;
|
||||
|
||||
// 1. Cloudflare's own record of the connecting address.
|
||||
const connecting = cleanHop(header(event, CF_CONNECTING_IP));
|
||||
if (isIpAddress(connecting)) return connecting;
|
||||
|
||||
// 2. Rightmost-minus-the-trusted-hop, never leftmost.
|
||||
const hops = (header(event, X_FORWARDED_FOR) ?? "")
|
||||
.split(",")
|
||||
.map(cleanHop)
|
||||
.filter(Boolean);
|
||||
const index = hops.length - 1 - FORWARDED_HOPS_TO_DISCOUNT;
|
||||
if (index >= 0) {
|
||||
const candidate = hops[index]!;
|
||||
if (isIpAddress(candidate)) return candidate;
|
||||
}
|
||||
|
||||
// 3. Nothing trustworthy: record what we actually saw, even though it is the
|
||||
// proxy. `readTraffic` reports these rows rather than hiding them.
|
||||
return direct ?? null;
|
||||
}
|
||||
|
||||
function cleanHop(value: string | null | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Addresses: parse, validate, truncate ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The privacy floor the security review required before this ships (FRE-369,
|
||||
* finding Q4): VisitorAnalytics must never hold a full client address. IPv4 is
|
||||
* cut to /24, IPv6 to /56 — the prefix that identifies a household and that a
|
||||
* rotating "privacy" IPv6 address does not actually change. Funnel stages count
|
||||
* rows, and `uniqueVisitors` survives as a count of distinct prefixes, so
|
||||
* nothing measured here needs the last octet.
|
||||
*
|
||||
* An address that does not parse is dropped rather than stored: an unparseable
|
||||
* string is client text we cannot reason about, and keeping it would smuggle
|
||||
* the very granularity this function exists to remove.
|
||||
*/
|
||||
export function truncateIpAddress(ip: string | null | undefined): string | null {
|
||||
const value = cleanHop(ip);
|
||||
if (!value) return null;
|
||||
|
||||
const bytes = addressBytes(value);
|
||||
if (!bytes) return null;
|
||||
|
||||
const mapped = bytes.length === 16 ? ipv4FromBytes(bytes) : null;
|
||||
if (bytes.length === 4 || mapped) {
|
||||
const octets = (mapped ?? value).split(".").map(Number) as number[];
|
||||
return `${octets[0]}.${octets[1]}.${octets[2]}.0`;
|
||||
}
|
||||
|
||||
// /56 = keep the first 7 bytes, zero the rest. Rendered uncompressed and
|
||||
// lowercase so one client always truncates to one stored string.
|
||||
const kept = bytes.map((_, i) => (i >= 7 ? 0 : _));
|
||||
const groups: string[] = [];
|
||||
for (let i = 0; i < 16; i += 2) {
|
||||
groups.push((((kept[i]! << 8) | kept[i + 1]!) >>> 0).toString(16));
|
||||
}
|
||||
return groups.join(":");
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The site the request addressed — the `Host` the client asked for, normalised
|
||||
* to `nook.freno.me`. Recorded on every analytics row so a weekly read can
|
||||
* scope to one site; without it `/` on nook.freno.me is the same row as `/` on
|
||||
* freno.me. Thin alias over the site-context extractor so the analytics writers
|
||||
* share one host-parsing implementation with routing.
|
||||
*
|
||||
* Normalised (lowercased, port stripped) because the weekly read scopes with
|
||||
* `host = ?`: `nook.freno.me:443` or `Nook.Freno.ME` would otherwise store a
|
||||
* value that silently never matches the scope (FRE-369, finding Q3 — no
|
||||
* injection risk, but a correctness one worth fixing at write time).
|
||||
*/
|
||||
export function requestHost(event: APIEvent): string | null {
|
||||
return normaliseHost(hostFromEventLike(event)) ?? normaliseHost(urlHost(event));
|
||||
}
|
||||
|
||||
function urlHost(event: APIEvent): string | null {
|
||||
// `host` is a forbidden header and some server shapes drop it from
|
||||
// `event.request.headers`; the request URL is absolute on the server and
|
||||
// carries the same host the client addressed.
|
||||
try {
|
||||
const url = event.request?.url;
|
||||
return url ? new URL(url).host || null : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowercase, drop the port and any trailing dot; null when nothing is left. */
|
||||
export function normaliseHost(host: string | null | undefined): string | null {
|
||||
const raw = cleanHop(host).toLowerCase();
|
||||
if (!raw) return null;
|
||||
|
||||
// A bracketed IPv6 literal keeps its brackets' contents (`[::1]:3000` →
|
||||
// `::1`); anything else drops a trailing `:port`.
|
||||
const closing = raw.indexOf("]");
|
||||
const withoutPort = raw.startsWith("[")
|
||||
? closing > 0
|
||||
? raw.slice(1, closing)
|
||||
: raw.slice(1)
|
||||
: raw.replace(/:\d+$/, "");
|
||||
|
||||
const trimmed = withoutPort.replace(/\.$/, "");
|
||||
return trimmed || null;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
@@ -38,9 +38,10 @@ export interface ServerSiteEvent {
|
||||
* the raw Node request via `event.nativeEvent.node.req.headers`. We try
|
||||
* both so this works for API route handlers (`APIEvent`), the SSR document
|
||||
* handler (`PageEvent`), and tRPC procedures operating on the underlying
|
||||
* `H3Event`.
|
||||
* `H3Event`. Also used by the analytics writers to record the site dimension
|
||||
* (`host` column) on every row (FRE-366).
|
||||
*/
|
||||
function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
|
||||
export function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
|
||||
// 1) Web FetchEvent / APIEvent / PageEvent → standard `Request` headers.
|
||||
try {
|
||||
const req = (event as { request?: Request } | null)?.request;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import {
|
||||
logVisit,
|
||||
enrichAnalyticsEntry,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
} from "~/server/analytics";
|
||||
import { parseUtm } from "~/server/utm";
|
||||
import { downloadPath } from "~/lib/download-asset";
|
||||
import { resolveClientIp, requestHost } from "~/server/request-client";
|
||||
|
||||
/**
|
||||
* Record one download event under the canonical path `/api/downloads/<asset>`.
|
||||
@@ -15,35 +15,75 @@ import { downloadPath } from "~/lib/download-asset";
|
||||
* label per asset to group on: the tRPC click proxy, the S3 file route that
|
||||
* serves Sparkle updates, and the Nook door.
|
||||
*
|
||||
* The `stage` is what separates the doors within one asset label: "click"
|
||||
* for a door the visitor chose (landing button, tRPC proxy), "served" for
|
||||
* the file route that actually streams the DMG. Until FRE-366 both wrote the
|
||||
* same unlabelled row, so "download click → DMG served" could not be split —
|
||||
* one served click wrote two identical rows. `summariseDownloads()` and the
|
||||
* /analytics downloads block still group on the path alone; the stage is a
|
||||
* separate column precisely so that grouping cannot change.
|
||||
*
|
||||
* Best-effort by design — a failed analytics write must never block a
|
||||
* download. Flushed rather than merely buffered: a download is low-volume and
|
||||
* the 30s batch timer is not guaranteed to survive the response on serverless.
|
||||
*/
|
||||
export async function trackDownload(
|
||||
event: APIEvent,
|
||||
asset: string,
|
||||
stage: DownloadStage
|
||||
): Promise<void> {
|
||||
await trackRequest(event, {
|
||||
path: downloadPath(asset),
|
||||
method: "GET",
|
||||
stage
|
||||
});
|
||||
}
|
||||
|
||||
export type DownloadStage = "click" | "served";
|
||||
|
||||
export interface TrackedRequest {
|
||||
path: string;
|
||||
method: string;
|
||||
/** Funnel stage label; only download rows carry one today. */
|
||||
stage?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one server-side request event with the shared visitor dimensions:
|
||||
* referrer, campaign tags, edge country, real client address and host.
|
||||
*
|
||||
* This is the one writer every measured door goes through — download doors
|
||||
* via `trackDownload`, the checkout door directly — so the dimensions can
|
||||
* never drift between doors. Same best-effort, flushed-immediately contract
|
||||
* as `trackDownload`.
|
||||
*
|
||||
* `event.request` is expected to be a Fetch `Request` (true for Vinxi route
|
||||
* handlers and browser tRPC calls). A server-side `createCaller` hands over a
|
||||
* Node request, whose headers have no `get` — that throw is swallowed below,
|
||||
* so such a click is not recorded. No server-side caller downloads today.
|
||||
* so such an event is not recorded. No server-side caller downloads today.
|
||||
*/
|
||||
export async function trackDownload(
|
||||
export async function trackRequest(
|
||||
event: APIEvent,
|
||||
asset: string
|
||||
tracked: TrackedRequest
|
||||
): Promise<void> {
|
||||
const { request } = event;
|
||||
|
||||
try {
|
||||
const referrer =
|
||||
request.headers.get("referer") ?? request.headers.get("referrer");
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
|
||||
await logVisit(
|
||||
enrichAnalyticsEntry({
|
||||
path: downloadPath(asset),
|
||||
method: "GET",
|
||||
path: tracked.path,
|
||||
method: tracked.method,
|
||||
stage: tracked.stage ?? null,
|
||||
referrer,
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ipAddress:
|
||||
getRequestIP(event.nativeEvent) ??
|
||||
(forwarded ? forwarded.split(",")[0]!.trim() : null),
|
||||
// Behind the production proxy the direct peer is loopback; the real
|
||||
// client address comes from the forwarded header the proxy sets —
|
||||
// see `~/server/request-client` for the trust boundary.
|
||||
ipAddress: resolveClientIp(event),
|
||||
host: requestHost(event),
|
||||
// The click carries its own tags when a campaign links straight at the
|
||||
// door; otherwise the landing page's tags ride along in the referer.
|
||||
...parseUtm(request.url, referrer),
|
||||
@@ -56,6 +96,6 @@ export async function trackDownload(
|
||||
);
|
||||
await flushAnalytics();
|
||||
} catch (error) {
|
||||
console.error(`Failed to log download of ${asset}:`, error);
|
||||
console.error(`Failed to log ${tracked.method} ${tracked.path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
171
src/server/trusted-client-ip.test.ts
Normal file
171
src/server/trusted-client-ip.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
resolveTrustedClientIp,
|
||||
isIpAddress,
|
||||
isLoopbackAddress,
|
||||
UNKNOWN_CLIENT_IP,
|
||||
type ClientIpEventLike
|
||||
} from "./trusted-client-ip";
|
||||
|
||||
/**
|
||||
* Unit coverage for the security-path client-IP read (FRE-371). The caller
|
||||
* suites (rate-limit.test.ts, report.test.ts) pin the behaviour through
|
||||
* getClientIP and the route; this file pins the helper's own edges: the peer
|
||||
* forms a real socket can present, the APIEvent wrapper, and failing closed.
|
||||
*/
|
||||
function event(opts: {
|
||||
headers?: Record<string, string>;
|
||||
remoteAddress?: string;
|
||||
wrapped?: boolean;
|
||||
}): ClientIpEventLike {
|
||||
const inner: ClientIpEventLike = {
|
||||
...(opts.remoteAddress
|
||||
? { node: { req: { socket: { remoteAddress: opts.remoteAddress } } } }
|
||||
: {}),
|
||||
request: { headers: new Headers(opts.headers ?? {}) }
|
||||
};
|
||||
// An APIEvent wraps its H3Event as `nativeEvent` (routes/api/* shape).
|
||||
return opts.wrapped ? { nativeEvent: inner } : inner;
|
||||
}
|
||||
|
||||
describe("resolveTrustedClientIp", () => {
|
||||
it("trust order: non-loopback peer, then cf-connecting-ip, then rightmost-minus-one x-forwarded-for", () => {
|
||||
// Peer wins.
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({
|
||||
remoteAddress: "198.51.100.23",
|
||||
headers: {
|
||||
"cf-connecting-ip": "203.0.113.7",
|
||||
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe("198.51.100.23");
|
||||
|
||||
// Loopback peer → Cloudflare's record.
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({
|
||||
remoteAddress: "127.0.0.1",
|
||||
headers: {
|
||||
"cf-connecting-ip": "203.0.113.7",
|
||||
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
|
||||
// No CF header → the hop below the trusted proxy.
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({
|
||||
remoteAddress: "127.0.0.1",
|
||||
headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1" }
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("reads the wrapped H3Event of an APIEvent-shaped input", () => {
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({
|
||||
wrapped: true,
|
||||
remoteAddress: "127.0.0.1",
|
||||
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("never returns a truncated address", () => {
|
||||
// The full /128 is the bucket key; a truncated one would pool attackers
|
||||
// with their neighbours.
|
||||
const ip = resolveTrustedClientIp(
|
||||
event({
|
||||
remoteAddress: "127.0.0.1",
|
||||
headers: { "cf-connecting-ip": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" }
|
||||
})
|
||||
);
|
||||
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
||||
});
|
||||
|
||||
it("fails closed to the stricter unknown key", () => {
|
||||
// One hop = the sender's own invention; a non-address hop; nothing at all.
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({ headers: { "x-forwarded-for": "6.6.6.6" } })
|
||||
)
|
||||
).toBe(UNKNOWN_CLIENT_IP);
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({ headers: { "cf-connecting-ip": "not-an-ip" } })
|
||||
)
|
||||
).toBe(UNKNOWN_CLIENT_IP);
|
||||
expect(resolveTrustedClientIp(event({}))).toBe(UNKNOWN_CLIENT_IP);
|
||||
});
|
||||
|
||||
it("recognises the loopback forms a real peer presents", () => {
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({
|
||||
// IPv6-mapped IPv4 loopback from a node socket.
|
||||
remoteAddress: "::ffff:127.0.0.1",
|
||||
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({
|
||||
remoteAddress: "::1",
|
||||
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
// A non-loopback peer that merely looks odd is still the client.
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({
|
||||
remoteAddress: "::ffff:203.0.113.7",
|
||||
headers: { "cf-connecting-ip": "6.6.6.6" }
|
||||
})
|
||||
)
|
||||
).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("never believes x-real-ip", () => {
|
||||
expect(
|
||||
resolveTrustedClientIp(
|
||||
event({ headers: { "x-real-ip": "203.0.113.7" } })
|
||||
)
|
||||
).toBe(UNKNOWN_CLIENT_IP);
|
||||
});
|
||||
});
|
||||
|
||||
describe("address helpers", () => {
|
||||
it("isIpAddress", () => {
|
||||
expect(isIpAddress("203.0.113.7")).toBe(true);
|
||||
expect(isIpAddress("::1")).toBe(true);
|
||||
expect(isIpAddress("::ffff:203.0.113.7")).toBe(true);
|
||||
expect(isIpAddress("2001:db8::1")).toBe(true);
|
||||
expect(isIpAddress("not-an-ip")).toBe(false);
|
||||
expect(isIpAddress("256.1.1.1")).toBe(false);
|
||||
expect(isIpAddress("")).toBe(false);
|
||||
expect(isIpAddress(null)).toBe(false);
|
||||
expect(isIpAddress(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("isLoopbackAddress", () => {
|
||||
expect(isLoopbackAddress("127.0.0.1")).toBe(true);
|
||||
expect(isLoopbackAddress("127.255.0.3")).toBe(true);
|
||||
expect(isLoopbackAddress("::1")).toBe(true);
|
||||
expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isLoopbackAddress("203.0.113.7")).toBe(false);
|
||||
expect(isLoopbackAddress("::2")).toBe(false);
|
||||
// Unparseable input is not loopback — fails closed.
|
||||
expect(isLoopbackAddress("garbage")).toBe(false);
|
||||
});
|
||||
});
|
||||
250
src/server/trusted-client-ip.ts
Normal file
250
src/server/trusted-client-ip.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* The client address, resolved for security decisions (FRE-371).
|
||||
*
|
||||
* This is the ONLY client-IP read allowed to feed a security decision — rate
|
||||
* limiting, abuse quotas, account lockout, audit trails. It exists separately
|
||||
* from `request-client.ts`'s `resolveClientIp` (the analytics read) on
|
||||
* purpose:
|
||||
*
|
||||
* - `resolveClientIp` carries a standing rule (FRE-369 review, 1d9287a):
|
||||
* it never feeds a security decision. Its blast radius is a poisoned
|
||||
* analytics row; ours is granting or denying access.
|
||||
* - Rate limiting needs the UN-truncated address; the analytics module only
|
||||
* ever hands out truncated prefixes.
|
||||
* - `request-client.ts` ships on FRE-366's branch, which is under review;
|
||||
* a security fix must not merge-depend on an analytics diff.
|
||||
*
|
||||
* The address-parsing primitives below are therefore a deliberate port of the
|
||||
* reviewed ones, not an import. If the two ever drift, this file's version is
|
||||
* the one security paths follow.
|
||||
*
|
||||
* WHY the old read was spoofable: `security.ts`'s `getClientIP` took the
|
||||
* LEFTMOST `x-forwarded-for` hop, believing the header "cannot be spoofed".
|
||||
* In the pinned topology — client → Cloudflare → same-host origin proxy →
|
||||
* app — both proxies *append*, so the header arrives as
|
||||
* `<client-supplied spoof…>, <true client>, <Cloudflare edge>` and the
|
||||
* leftmost hop is exactly the one the sender chose. Rotating
|
||||
* `X-Forwarded-For: <random>` per request minted a fresh rate-limit bucket
|
||||
* and unlimited bug-report quota. `x-real-ip` was read unconditionally too;
|
||||
* nothing in this topology overwrites it, so it is equally client text and is
|
||||
* no longer read at all.
|
||||
*
|
||||
* Trust order (matches the convention FRE-366 landed after its security
|
||||
* review, re-derived here for security stakes):
|
||||
*
|
||||
* 1. The direct socket peer, when it is a real routable address: it IS the
|
||||
* client (dev, direct hit), and any forwarded header from it is the
|
||||
* client describing itself — ignored.
|
||||
* 2. `cf-connecting-ip` — reachable only when the peer is the loopback
|
||||
* origin proxy (or unreadable, e.g. a synthetic event in tests).
|
||||
* Cloudflare *overwrites* this header at the edge, so a client-supplied
|
||||
* value never survives. Must parse as an address.
|
||||
* 3. `x-forwarded-for` counted from the RIGHT: drop the rightmost hop (the
|
||||
* Cloudflare edge address the origin proxy appended) and take the hop
|
||||
* before it. Never the leftmost. Must parse as an address.
|
||||
* 4. "unknown" — callers give unknown-IP keys stricter shared limits, so
|
||||
* an unresolvable client fails toward a tighter bucket, never a looser
|
||||
* one.
|
||||
*
|
||||
* Deploy-side precondition this code cannot verify (same as the analytics
|
||||
* read): the origin must be reachable only through Cloudflare and the
|
||||
* same-host proxy. If the app is directly internet-reachable, a client can
|
||||
* set every header read here and no in-app read can recover the truth.
|
||||
*/
|
||||
|
||||
/** Cloudflare's "who actually connected" header: overwritten at the edge. */
|
||||
const CF_CONNECTING_IP = "cf-connecting-ip";
|
||||
const X_FORWARDED_FOR = "x-forwarded-for";
|
||||
|
||||
/**
|
||||
* Hops discounted from the right of `x-forwarded-for` before the value is
|
||||
* taken: the Cloudflare edge address the origin proxy appended. Pinned to 1
|
||||
* because there is exactly one trusted proxy between Cloudflare and the app;
|
||||
* raise it only if the topology gains a trusted hop.
|
||||
*/
|
||||
const FORWARDED_HOPS_TO_DISCOUNT = 1;
|
||||
|
||||
/** Returned when no trustworthy address exists. Callers rate-limit it harder. */
|
||||
export const UNKNOWN_CLIENT_IP = "unknown";
|
||||
|
||||
/**
|
||||
* The shapes this module reads: an H3Event (`security.ts` callers) or a
|
||||
* SolidStart APIEvent (`routes/api/*`), which wraps its H3Event as
|
||||
* `nativeEvent`. Every field is optional because test doubles are minimal;
|
||||
* anything unreadable simply falls through to the next trust step.
|
||||
*/
|
||||
export interface ClientIpEventLike {
|
||||
request?: { headers?: unknown };
|
||||
headers?: unknown;
|
||||
node?: {
|
||||
req?: {
|
||||
headers?: Record<string, unknown>;
|
||||
socket?: { remoteAddress?: string };
|
||||
};
|
||||
};
|
||||
nativeEvent?: ClientIpEventLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* The client address for a security decision, per the trust order above.
|
||||
* Never truncated: rate-limit buckets are per-address, and truncating would
|
||||
* let an attacker share a bucket with the innocents behind their /24.
|
||||
*/
|
||||
export function resolveTrustedClientIp(event: ClientIpEventLike): string {
|
||||
// An APIEvent's socket and (authoritative) headers live on the wrapped
|
||||
// H3Event; a bare APIEvent-shaped double without one is read as-is.
|
||||
const source = event.nativeEvent ?? event;
|
||||
|
||||
const peer = directPeer(source);
|
||||
if (peer && isIpAddress(peer) && !isLoopbackAddress(peer)) return peer;
|
||||
|
||||
// The peer is the loopback origin proxy (or unreadable): forwarded headers
|
||||
// may be believed, in the order above.
|
||||
const connecting = cleanHop(headerValue(source, CF_CONNECTING_IP));
|
||||
if (isIpAddress(connecting)) return connecting;
|
||||
|
||||
const hops = (headerValue(source, X_FORWARDED_FOR) ?? "")
|
||||
.split(",")
|
||||
.map(cleanHop)
|
||||
.filter(Boolean);
|
||||
const index = hops.length - 1 - FORWARDED_HOPS_TO_DISCOUNT;
|
||||
if (index >= 0 && isIpAddress(hops[index])) return hops[index]!;
|
||||
|
||||
return UNKNOWN_CLIENT_IP;
|
||||
}
|
||||
|
||||
/** The socket peer, IPv6-mapped IPv4 flattened (`::ffff:1.2.3.4` → `1.2.3.4`). */
|
||||
function directPeer(event: ClientIpEventLike): string | null {
|
||||
try {
|
||||
const addr = event.node?.req?.socket?.remoteAddress;
|
||||
return addr ? addr.replace(/^::ffff:/, "") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Header read tolerant of every event shape in play: Fetch-style
|
||||
* `request.headers.get`, an h3 `event.headers` (Headers or plain object), and
|
||||
* the raw node request headers. Header names are case-insensitive per HTTP;
|
||||
* the plain-object fallbacks lowercase to match how node and the test doubles
|
||||
* store them.
|
||||
*/
|
||||
function headerValue(event: ClientIpEventLike, name: string): string | null {
|
||||
try {
|
||||
const fromHeaders = (h: unknown): string | null => {
|
||||
if (!h) return null;
|
||||
if (typeof (h as Headers).get === "function") {
|
||||
return (h as Headers).get(name) ?? null;
|
||||
}
|
||||
if (typeof h === "object") {
|
||||
const record = h as Record<string, unknown>;
|
||||
const value = record[name] ?? record[name.toLowerCase()];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
fromHeaders(event.request?.headers) ??
|
||||
fromHeaders(event.headers) ??
|
||||
fromHeaders(event.node?.req?.headers)
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanHop(value: string | null | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
// ── Addresses: parse and validate ───────────────────────────────────────────
|
||||
// Ported from the reviewed `request-client.ts` (FRE-366 @ 1d9287a); see the
|
||||
// header comment for why this is a copy and not an import.
|
||||
|
||||
/** Any address this file understands: a dotted quad or an IPv6 literal. */
|
||||
export function isIpAddress(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && addressBytes(value) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an address is the loopback the production proxy presents.
|
||||
*
|
||||
* Parses rather than string-matches, so the forms a real peer can present in —
|
||||
* `::ffff:127.0.0.53`, `0:0:0:0:0:0:0:1`, an IPv4-mapped literal — are all
|
||||
* recognised. An address this does not parse is *not* loopback, which fails
|
||||
* closed: forwarded headers from an unrecognised peer are ignored rather than
|
||||
* believed (a non-loopback peer short-circuits before headers are read).
|
||||
*/
|
||||
export function isLoopbackAddress(ip: string): boolean {
|
||||
const bytes = addressBytes(ip);
|
||||
if (!bytes) return false;
|
||||
if (bytes.length === 4) return bytes[0] === 127;
|
||||
|
||||
// IPv6 loopback ::1 — and ::ffff:127/8, which is loopback too.
|
||||
const mapped = ipv4FromBytes(bytes);
|
||||
if (mapped) return isLoopbackAddress(mapped);
|
||||
const tail = bytes[15]!;
|
||||
return bytes.slice(0, 15).every((byte) => byte === 0) && tail === 1;
|
||||
}
|
||||
|
||||
function isIpv4(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
if (parts.length !== 4) return false;
|
||||
return parts.every(
|
||||
(part) => /^\d{1,3}$/.test(part) && Number(part) <= 255 && !/^0\d/.test(part)
|
||||
);
|
||||
}
|
||||
|
||||
/** 4 bytes for IPv4, 16 for IPv6 (including `::ffff:a.b.c.d`), else null. */
|
||||
function addressBytes(value: string): number[] | null {
|
||||
const raw = value.trim().replace(/^\[|\]$/g, "").split("%")[0]!;
|
||||
if (isIpv4(raw)) return raw.split(".").map(Number);
|
||||
if (!raw.includes(":")) return null;
|
||||
|
||||
const halves = raw.split("::");
|
||||
if (halves.length > 2) return null;
|
||||
const compressed = halves.length === 2;
|
||||
|
||||
const head = groupsToBytes(halves[0] ?? "");
|
||||
const tail = groupsToBytes(halves[1] ?? "");
|
||||
if (!head || !tail) return null;
|
||||
|
||||
const total = head.length + tail.length;
|
||||
if (compressed) {
|
||||
if (total > 8) return null;
|
||||
return [...head, ...new Array(8 - total).fill(0), ...tail].flatMap(split16);
|
||||
}
|
||||
if (total !== 8) return null;
|
||||
return [...head, ...tail].flatMap(split16);
|
||||
}
|
||||
|
||||
/** One side of `::` as 16-bit groups; a trailing `1.2.3.4` counts as two. */
|
||||
function groupsToBytes(side: string): number[] | null {
|
||||
if (side === "") return [];
|
||||
const out: number[] = [];
|
||||
for (const part of side.split(":")) {
|
||||
if (/^[0-9a-fA-F]{1,4}$/.test(part)) {
|
||||
out.push(parseInt(part, 16));
|
||||
} else if (isIpv4(part)) {
|
||||
const o = part.split(".").map(Number);
|
||||
out.push((o[0]! << 8) | o[1]!, (o[2]! << 8) | o[3]!);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function split16(group: number): number[] {
|
||||
return [(group >> 8) & 0xff, group & 0xff];
|
||||
}
|
||||
|
||||
/** The embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`), else null. */
|
||||
function ipv4FromBytes(bytes: number[]): string | null {
|
||||
const prefix = bytes.slice(0, 10).every((byte) => byte === 0);
|
||||
const marker = bytes[10] === 0xff && bytes[11] === 0xff;
|
||||
if (!prefix || !marker) return null;
|
||||
return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`;
|
||||
}
|
||||
520
src/server/weekly-metrics.test.ts
Normal file
520
src/server/weekly-metrics.test.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import {
|
||||
VISITOR_ANALYTICS_ADDED_COLUMNS,
|
||||
VISITOR_ANALYTICS_COLUMNS,
|
||||
VISITOR_ANALYTICS_DDL,
|
||||
VISITOR_ANALYTICS_INSERT
|
||||
} from "./analytics-schema";
|
||||
import {
|
||||
NOOK_CHECKOUT_PATH,
|
||||
NOOK_DOWNLOAD_PATH,
|
||||
NOOK_HOST,
|
||||
ensureAnalyticsSchema,
|
||||
getWeeklyReport,
|
||||
pickMover,
|
||||
readCampaigns,
|
||||
readFunnel,
|
||||
readTraffic,
|
||||
renderWeeklyMarkdown,
|
||||
windowsFor,
|
||||
type Connections,
|
||||
type FunnelCounts,
|
||||
type MetricsConn
|
||||
} from "./weekly-metrics";
|
||||
|
||||
// Same reasoning as the backfill suite: the queries run real SQL, so an
|
||||
// in-memory SQLite shaped like production is the only honest stand-in. A stub
|
||||
// that returned canned rows would pass with a column or a label the live table
|
||||
// does not have.
|
||||
|
||||
const NOOK_DDL = [
|
||||
`CREATE TABLE trials (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
started_at TEXT NOT NULL,
|
||||
email TEXT
|
||||
)`,
|
||||
`CREATE TABLE licenses (
|
||||
id TEXT PRIMARY KEY,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
stripe_session_id TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE activations (
|
||||
id TEXT PRIMARY KEY,
|
||||
license_id TEXT NOT NULL,
|
||||
device_fingerprint TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL,
|
||||
activated_at TEXT NOT NULL,
|
||||
deactivated_at TEXT
|
||||
)`
|
||||
];
|
||||
|
||||
function adapter(db: Database): MetricsConn {
|
||||
return {
|
||||
async execute(query) {
|
||||
const args = (query.args ?? []) as never[];
|
||||
const statement = db.query(query.sql);
|
||||
if (/^\s*(select|pragma|with)\b/i.test(query.sql)) {
|
||||
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
|
||||
}
|
||||
return {
|
||||
rows: [] as unknown[],
|
||||
rowsAffected: statement.run(...args).changes
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let analyticsDb: Database;
|
||||
let nookDb: Database;
|
||||
let conns: Connections;
|
||||
|
||||
const NOW = new Date("2026-10-29T16:00:00Z");
|
||||
|
||||
// A row as the post-FRE-366 writers record it: real client address, the host
|
||||
// it addressed, and a stage label where the row is a download.
|
||||
const row = (
|
||||
overrides: Record<string, unknown>
|
||||
): Record<string, unknown> => ({
|
||||
id: crypto.randomUUID(),
|
||||
user_id: null,
|
||||
path: "/",
|
||||
method: "GET",
|
||||
referrer: null,
|
||||
user_agent: "Mozilla/5.0 (Macintosh)",
|
||||
// What the writers store since the FRE-369 privacy floor: a /24 (or /56 for
|
||||
// IPv6), never the address itself.
|
||||
ip_address: "203.0.113.0",
|
||||
country: "GB",
|
||||
device_type: "desktop",
|
||||
browser: "safari",
|
||||
os: "macos",
|
||||
utm_source: null,
|
||||
utm_medium: null,
|
||||
utm_campaign: null,
|
||||
duration_ms: null,
|
||||
fcp: null,
|
||||
lcp: null,
|
||||
cls: null,
|
||||
fid: null,
|
||||
inp: null,
|
||||
ttfb: null,
|
||||
dom_load: null,
|
||||
load_complete: null,
|
||||
stage: null,
|
||||
host: NOOK_HOST,
|
||||
...overrides
|
||||
});
|
||||
|
||||
function insertAnalytics(overrides: Record<string, unknown>): void {
|
||||
const values = row(overrides);
|
||||
analyticsDb
|
||||
.prepare(VISITOR_ANALYTICS_INSERT)
|
||||
.run(...(VISITOR_ANALYTICS_COLUMNS.map((column) => values[column]) as never[]));
|
||||
|
||||
// `created_at` is not in VISITOR_ANALYTICS_COLUMNS (it defaults to now), so
|
||||
// the window each row belongs to has to be set after the insert.
|
||||
if (values.created_at) {
|
||||
analyticsDb
|
||||
.prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`)
|
||||
.run(values.created_at as string, values.id as string);
|
||||
}
|
||||
}
|
||||
|
||||
// Inside the current window for NOW - 7 days: 2026-10-22 16:00 → 2026-10-29 16:00.
|
||||
const THIS_WEEK = "2026-10-27 10:00:00";
|
||||
// Inside the previous window: 2026-10-15 16:00 → 2026-10-22 16:00.
|
||||
const LAST_WEEK = "2026-10-18 10:00:00";
|
||||
|
||||
beforeEach(() => {
|
||||
analyticsDb = new Database(":memory:");
|
||||
analyticsDb.run(VISITOR_ANALYTICS_DDL);
|
||||
nookDb = new Database(":memory:");
|
||||
for (const ddl of NOOK_DDL) nookDb.run(ddl);
|
||||
conns = { analytics: adapter(analyticsDb), nook: adapter(nookDb) };
|
||||
});
|
||||
|
||||
describe("windowsFor", () => {
|
||||
it("puts the previous window directly before the current one", () => {
|
||||
const windows = windowsFor(NOW, 7);
|
||||
expect(windows.current).toEqual({
|
||||
start: "2026-10-22 16:00:00",
|
||||
end: "2026-10-29 16:00:00"
|
||||
});
|
||||
expect(windows.previous).toEqual({
|
||||
start: "2026-10-15 16:00:00",
|
||||
end: "2026-10-22 16:00:00"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFunnel", () => {
|
||||
it("counts each stage by its own label inside its window", async () => {
|
||||
// Page views on the Nook host count; other sites, API calls, legacy
|
||||
// rows with no host, and the other window do not.
|
||||
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/downloads", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/", host: "freno.me", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/", host: null, created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/api/trpc/anything", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/", created_at: LAST_WEEK });
|
||||
// The old checkout proxy: a /checkout page view is a visit, not a
|
||||
// started checkout.
|
||||
insertAnalytics({ path: "/checkout", created_at: THIS_WEEK });
|
||||
|
||||
// The download label splits by stage; a row with no stage predates the
|
||||
// split and counts in neither.
|
||||
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "click", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "served", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "served", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: null, created_at: THIS_WEEK });
|
||||
|
||||
// A created Stripe session, recorded by the checkout route itself.
|
||||
insertAnalytics({
|
||||
path: NOOK_CHECKOUT_PATH,
|
||||
method: "POST",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
|
||||
// The licence DB stores ISO-8601 with milliseconds.
|
||||
nookDb
|
||||
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
|
||||
.run("fp-1", "2026-10-28T20:28:46.806Z");
|
||||
nookDb
|
||||
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
|
||||
.run("fp-old", "2026-08-26T19:26:12.791Z");
|
||||
nookDb
|
||||
.prepare(
|
||||
`INSERT INTO licenses (id, key, email, stripe_session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run("lic-1", "NOOK-1", "buyer@example.com", "cs_1", "2026-10-25T12:00:00.000Z");
|
||||
nookDb
|
||||
.prepare(
|
||||
`INSERT INTO activations (id, license_id, device_fingerprint, device_name, activated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run("act-1", "lic-1", "device-1", "Hermes", "2026-10-25T12:05:00.000Z");
|
||||
|
||||
const window = windowsFor(NOW, 7).current;
|
||||
const funnel = await readFunnel(conns, window);
|
||||
|
||||
expect(funnel).toEqual({
|
||||
visit: 3, // "/", "/downloads" and the "/checkout" page view on the Nook host
|
||||
downloadClick: 1,
|
||||
dmgServed: 2,
|
||||
trial: 1,
|
||||
checkout: 1,
|
||||
purchase: 1,
|
||||
activation: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("reports zeros against an empty database", async () => {
|
||||
const window = windowsFor(NOW, 7).current;
|
||||
expect(await readFunnel(conns, window)).toEqual({
|
||||
visit: 0,
|
||||
downloadClick: 0,
|
||||
dmgServed: 0,
|
||||
trial: 0,
|
||||
checkout: 0,
|
||||
purchase: 0,
|
||||
activation: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readTraffic", () => {
|
||||
it("separates legacy rows, live loopback and automation from clean traffic", async () => {
|
||||
// Post-instrumentation rows: host set. Two of them still loopback — in the
|
||||
// truncated shapes the privacy floor writes — which is the shape the report
|
||||
// must flag as "the fix is not live".
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
stage: "click",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
stage: "served",
|
||||
ip_address: "127.0.0.0",
|
||||
user_agent: "The Nook/0.13.4 Sparkle/2.9.6",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
ip_address: "0:0:0:0:0:0:0:0",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||
|
||||
// Pre-instrumentation rows: no host, proxy's address — legacy, never
|
||||
// counted as "the fix is not live".
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
stage: null,
|
||||
host: null,
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "curl/8.7.1",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
host: null,
|
||||
ip_address: "::1",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
|
||||
const traffic = await readTraffic(conns, windowsFor(NOW, 7).current);
|
||||
|
||||
expect(traffic).toEqual({
|
||||
analyticsRows: 6,
|
||||
hostRows: 4,
|
||||
legacyRows: 2,
|
||||
hostLoopbackRows: 2,
|
||||
downloadRows: 3,
|
||||
downloadClickRows: 1,
|
||||
downloadServedRows: 1,
|
||||
downloadUnstagedRows: 1,
|
||||
downloadNonBrowserRows: 2
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCampaigns", () => {
|
||||
it("groups tagged rows, excludes untagged, and orders by volume", async () => {
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
utm_source: "hn",
|
||||
utm_medium: "post",
|
||||
utm_campaign: "launch",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
utm_source: "hn",
|
||||
utm_medium: "post",
|
||||
utm_campaign: "launch",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
utm_source: "r_macapps",
|
||||
utm_campaign: "week2",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||
|
||||
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
|
||||
|
||||
expect(campaigns).toEqual([
|
||||
{ source: "hn", medium: "post", campaign: "launch", count: 2 },
|
||||
{ source: "r_macapps", medium: null, campaign: "week2", count: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps the list at ten rows", async () => {
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
utm_source: `source-${i}`,
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
}
|
||||
|
||||
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
|
||||
expect(campaigns.length).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureAnalyticsSchema", () => {
|
||||
it("adds the post-hoc columns to a table that predates them, idempotently", async () => {
|
||||
// The table as production held it before any post-hoc column existed.
|
||||
// (`created_at` is not in the insert column list — it defaults DB-side —
|
||||
// so the simulated table has to name it explicitly.)
|
||||
const oldDb = new Database(":memory:");
|
||||
const withoutAdded = [
|
||||
...VISITOR_ANALYTICS_COLUMNS.filter(
|
||||
(c) => !VISITOR_ANALYTICS_ADDED_COLUMNS.some((a) => a.name === c)
|
||||
),
|
||||
"created_at"
|
||||
];
|
||||
oldDb.run(
|
||||
`CREATE TABLE VisitorAnalytics (${withoutAdded.map((c) => `${c} TEXT`).join(", ")})`
|
||||
);
|
||||
const oldConn = adapter(oldDb);
|
||||
|
||||
await ensureAnalyticsSchema(oldConn);
|
||||
await ensureAnalyticsSchema(oldConn); // a second run is a no-op
|
||||
|
||||
// The writers' full INSERT — stage and host included — now works.
|
||||
const values = row({ stage: "click", path: NOOK_DOWNLOAD_PATH });
|
||||
expect(() =>
|
||||
oldDb
|
||||
.prepare(VISITOR_ANALYTICS_INSERT)
|
||||
.run(
|
||||
...(VISITOR_ANALYTICS_COLUMNS.map((c) => values[c]) as never[])
|
||||
)
|
||||
).not.toThrow();
|
||||
oldDb
|
||||
.prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`)
|
||||
.run(THIS_WEEK, values.id as string);
|
||||
|
||||
// And the funnel reads its stage split from the migrated table.
|
||||
const funnel = await readFunnel(
|
||||
{ analytics: oldConn, nook: conns.nook },
|
||||
windowsFor(NOW, 7).current
|
||||
);
|
||||
expect(funnel.downloadClick).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickMover", () => {
|
||||
const base: FunnelCounts = {
|
||||
visit: 0,
|
||||
downloadClick: 0,
|
||||
dmgServed: 0,
|
||||
trial: 0,
|
||||
checkout: 0,
|
||||
purchase: 0,
|
||||
activation: 0
|
||||
};
|
||||
|
||||
it("picks the stage with the largest absolute change", () => {
|
||||
const mover = pickMover(
|
||||
{ ...base, visit: 61, dmgServed: 156, trial: 2 },
|
||||
{ ...base, visit: 56 }
|
||||
);
|
||||
expect(mover).toEqual({
|
||||
key: "dmgServed",
|
||||
label: "DMG served",
|
||||
now: 156,
|
||||
before: 0,
|
||||
delta: 156
|
||||
});
|
||||
});
|
||||
|
||||
it("breaks ties toward the earlier stage", () => {
|
||||
const mover = pickMover(
|
||||
{ ...base, visit: 5, downloadClick: 5 },
|
||||
{ ...base, visit: 0, downloadClick: 0 }
|
||||
);
|
||||
expect(mover?.key).toBe("visit");
|
||||
});
|
||||
|
||||
it("returns null when nothing moved", () => {
|
||||
expect(pickMover({ ...base, visit: 3 }, { ...base, visit: 3 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWeeklyReport / renderWeeklyMarkdown", () => {
|
||||
it("renders seven distinct stages with no merged-label or proxy caveats", async () => {
|
||||
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "click", created_at: THIS_WEEK });
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
stage: "served",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
stage: "served",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({ path: NOOK_CHECKOUT_PATH, method: "POST", created_at: THIS_WEEK });
|
||||
nookDb
|
||||
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
|
||||
.run("fp-1", "2026-10-28T20:28:46.806Z");
|
||||
|
||||
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
expect(markdown).toContain(
|
||||
"Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days)"
|
||||
);
|
||||
expect(markdown).toContain(`| visit (${NOOK_HOST} page views) | 1 | 0 | +1 |`);
|
||||
expect(markdown).toContain("| download click | 1 | 0 | +1 |");
|
||||
expect(markdown).toContain("| DMG served | 2 | 0 | +2 |");
|
||||
expect(markdown).toContain("| checkout started (Stripe session) | 1 | 0 | +1 |");
|
||||
|
||||
const order = [
|
||||
`visit (${NOOK_HOST} page views)`,
|
||||
"download click",
|
||||
"DMG served",
|
||||
"trial started",
|
||||
"checkout started (Stripe session)",
|
||||
"purchase (licence issued)",
|
||||
"activation"
|
||||
];
|
||||
for (let i = 1; i < order.length; i += 1) {
|
||||
expect(markdown.indexOf(order[i - 1]!)).toBeLessThan(
|
||||
markdown.indexOf(order[i]!)
|
||||
);
|
||||
}
|
||||
|
||||
// The caveats this issue retired stay retired.
|
||||
expect(markdown).not.toContain("one label");
|
||||
expect(markdown).not.toContain("not recorded");
|
||||
expect(markdown).not.toContain("no host dimension");
|
||||
expect(markdown).not.toContain("†");
|
||||
|
||||
expect(report.mover?.key).toBe("dmgServed");
|
||||
expect(markdown).toContain("**DMG served: 0 → 2 (+2).**");
|
||||
});
|
||||
|
||||
it("states what pre-instrumentation rows cannot say, computed from the window", async () => {
|
||||
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||
// Legacy rows: no host, proxy's address, no stage label.
|
||||
insertAnalytics({ path: "/", host: null, ip_address: "127.0.0.1", created_at: THIS_WEEK });
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
stage: null,
|
||||
host: null,
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "curl/8.7.1",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
|
||||
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
expect(markdown).toContain(
|
||||
"2 of 3 rows in the window predate the FRE-366 instrumentation"
|
||||
);
|
||||
expect(markdown).toContain("predate the click/served stage label");
|
||||
// Legacy loopback is not "the fix is not live".
|
||||
expect(markdown).not.toContain("still carry a loopback address");
|
||||
});
|
||||
|
||||
it("flags loopback only when rows recorded since the instrumentation carry it", async () => {
|
||||
// Both spellings a live writer can produce: the raw proxy address, and the
|
||||
// /24 the privacy floor stores instead of it.
|
||||
insertAnalytics({ path: "/", ip_address: "127.0.0.1", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/", ip_address: "127.0.53.0", created_at: THIS_WEEK });
|
||||
|
||||
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
expect(markdown).toContain(
|
||||
"2 rows recorded since the instrumentation still carry a loopback address"
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing moved rather than inventing a winner", async () => {
|
||||
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
expect(report.mover).toBeNull();
|
||||
expect(markdown).toContain("_Nothing moved: every stage held its previous-week value._");
|
||||
expect(markdown).toContain("_No tagged traffic in the window._");
|
||||
});
|
||||
|
||||
it("rejects a window that is not a positive number of days", async () => {
|
||||
await expect(getWeeklyReport(conns, { now: NOW, days: 0 })).rejects.toThrow(
|
||||
"days must be a positive number"
|
||||
);
|
||||
});
|
||||
});
|
||||
647
src/server/weekly-metrics.ts
Normal file
647
src/server/weekly-metrics.ts
Normal file
@@ -0,0 +1,647 @@
|
||||
/**
|
||||
* The weekly funnel reading: `marketing/metrics/weekly.md`'s data half.
|
||||
*
|
||||
* The funnel the marketing cluster agreed on is
|
||||
*
|
||||
* visit → download click → DMG served → trial started → checkout started
|
||||
* → purchase → activation
|
||||
*
|
||||
* and this module reads it from the two live databases, plus the campaign
|
||||
* dimension the /analytics page already exposes through
|
||||
* `getAnalyticsSummary.topCampaigns`.
|
||||
*
|
||||
* Two properties matter more than the numbers:
|
||||
*
|
||||
* - It takes both connections as arguments. `~/server/analytics` imports the
|
||||
* full server env (Stripe keys, Apple secret, licence key …); the weekly
|
||||
* job only needs the two databases, and must be runnable from a checkout
|
||||
* whose `.env` carries nothing else. The SQL here is the same shape as the
|
||||
* analytics summaries, so the weekly read and the dashboard cannot drift
|
||||
* into different label sets.
|
||||
* - Every stage is measured by its own label. FRE-366 gave the funnel that:
|
||||
* the download door and the DMG file route record distinct `stage` values
|
||||
* (`click` / `served`), a created Stripe checkout session records its own
|
||||
* row, and every row carries the real client address and the host it
|
||||
* addressed, so the visit stage scopes to the Nook. Rows that predate
|
||||
* that instrumentation are stated next to the numbers, never silently
|
||||
* counted (see `renderWeeklyMarkdown`).
|
||||
*
|
||||
* No `~/env`, no `~/server/database`: the caller owns the connections, the
|
||||
* tests own an in-memory SQLite, and the script owns the libsql clients.
|
||||
*/
|
||||
|
||||
import { DOWNLOAD_PATH_PREFIX } from "~/lib/download-asset";
|
||||
import {
|
||||
VISITOR_ANALYTICS_ADDED_COLUMNS,
|
||||
VISITOR_ANALYTICS_DDL
|
||||
} from "~/server/analytics-schema";
|
||||
|
||||
/** The one asset key the Nook lands under — `downloadPath("thenook")`. */
|
||||
export const NOOK_DOWNLOAD_PATH = `${DOWNLOAD_PATH_PREFIX}thenook`;
|
||||
|
||||
/** The host the visit stage scopes to. Recorded per row since FRE-366. */
|
||||
export const NOOK_HOST = "nook.freno.me";
|
||||
|
||||
/** The path a created Stripe checkout session records (FRE-366). */
|
||||
export const NOOK_CHECKOUT_PATH = "/api/the-nook/checkout";
|
||||
|
||||
/** An `execute`-shaped connection: the libsql client and the test adapter both fit. */
|
||||
export interface MetricsConn {
|
||||
execute(query: {
|
||||
sql: string;
|
||||
args?: any[];
|
||||
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
|
||||
}
|
||||
|
||||
export interface Connections {
|
||||
/** freno.me Turso DB — VisitorAnalytics. */
|
||||
analytics: MetricsConn;
|
||||
/** the-nook Turso DB — trials, licenses, activations. */
|
||||
nook: MetricsConn;
|
||||
}
|
||||
|
||||
export type StageKey =
|
||||
| "visit"
|
||||
| "downloadClick"
|
||||
| "dmgServed"
|
||||
| "trial"
|
||||
| "checkout"
|
||||
| "purchase"
|
||||
| "activation";
|
||||
|
||||
export interface StageDef {
|
||||
key: StageKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seven agreed stages, each with its own label in the data:
|
||||
* `downloadClick` and `dmgServed` split what used to be one merged download
|
||||
* label (the `stage` column, FRE-366), and `checkout` reads created Stripe
|
||||
* sessions instead of the /checkout page-view proxy.
|
||||
*/
|
||||
export const STAGES: StageDef[] = [
|
||||
{ key: "visit", label: `visit (${NOOK_HOST} page views)` },
|
||||
{ key: "downloadClick", label: "download click" },
|
||||
{ key: "dmgServed", label: "DMG served" },
|
||||
{ key: "trial", label: "trial started" },
|
||||
{ key: "checkout", label: "checkout started (Stripe session)" },
|
||||
{ key: "purchase", label: "purchase (licence issued)" },
|
||||
{ key: "activation", label: "activation" }
|
||||
];
|
||||
|
||||
export interface FunnelCounts {
|
||||
visit: number;
|
||||
downloadClick: number;
|
||||
dmgServed: number;
|
||||
trial: number;
|
||||
checkout: number;
|
||||
purchase: number;
|
||||
activation: number;
|
||||
}
|
||||
|
||||
export interface CampaignRow {
|
||||
source: string | null;
|
||||
medium: string | null;
|
||||
campaign: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Traffic-shape facts the report has to state next to the funnel. */
|
||||
export interface WindowTraffic {
|
||||
analyticsRows: number;
|
||||
/** Rows carrying a host — written since the FRE-366 instrumentation. */
|
||||
hostRows: number;
|
||||
/** Rows with no host — pre-FRE-366; their address is the proxy's loopback. */
|
||||
legacyRows: number;
|
||||
/** Post-instrumentation rows still recording loopback: the fix is not live. */
|
||||
hostLoopbackRows: number;
|
||||
downloadRows: number;
|
||||
downloadClickRows: number;
|
||||
downloadServedRows: number;
|
||||
/** Download rows with no stage label — they predate FRE-366 and cannot be split. */
|
||||
downloadUnstagedRows: number;
|
||||
downloadNonBrowserRows: number;
|
||||
}
|
||||
|
||||
export interface Mover {
|
||||
key: StageKey;
|
||||
label: string;
|
||||
now: number;
|
||||
before: number;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface WeeklyReport {
|
||||
window: { start: string; end: string; days: number };
|
||||
previous: { start: string; end: string };
|
||||
counts: FunnelCounts;
|
||||
previousCounts: FunnelCounts;
|
||||
campaigns: CampaignRow[];
|
||||
taggedRows: number;
|
||||
traffic: WindowTraffic;
|
||||
mover: Mover | null;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recorded address that is not a visitor, in every spelling the column can
|
||||
* hold: the proxy the request came through (`127.0.0.1`, `::1`, an IPv4-mapped
|
||||
* form) *and* the prefixes the FRE-369 privacy floor writes instead of them
|
||||
* (`127.0.0.0`, all-zero IPv6). Whole `127/8` because truncation keeps the first
|
||||
* three octets, so `127.0.53.1` and `127.0.53.0` are the same non-visitor.
|
||||
*/
|
||||
const LOOPBACK_SQL = `(ip_address LIKE '127.%'
|
||||
OR ip_address LIKE '::ffff:127.%'
|
||||
OR ip_address IN ('::1', '0:0:0:0:0:0:0:1', '0:0:0:0:0:0:0:0'))`;
|
||||
|
||||
/** SQLite's `datetime()` string, UTC: `YYYY-MM-DD HH:MM:SS`. */
|
||||
export function sqlTime(date: Date): string {
|
||||
return date.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export interface Windows {
|
||||
current: { start: string; end: string };
|
||||
previous: { start: string; end: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The current window is `[now - days, now)` and the previous one sits directly
|
||||
* before it, so a weekly read always compares like with like. Boundaries are
|
||||
* passed as SQL parameters rather than `datetime('now', …)` so the file a run
|
||||
* writes is reproducible and the two queries cannot straddle a second.
|
||||
*/
|
||||
export function windowsFor(now: Date, days: number): Windows {
|
||||
const span = days * 24 * 60 * 60 * 1000;
|
||||
const end = now;
|
||||
const start = new Date(end.getTime() - span);
|
||||
const previousEnd = start;
|
||||
const previousStart = new Date(previousEnd.getTime() - span);
|
||||
|
||||
return {
|
||||
current: { start: sqlTime(start), end: sqlTime(end) },
|
||||
previous: { start: sqlTime(previousStart), end: sqlTime(previousEnd) }
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "bigint") return Number(value);
|
||||
if (typeof value === "string") return Number(value) || 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function scalar(
|
||||
conn: MetricsConn,
|
||||
sql: string,
|
||||
args: any[]
|
||||
): Promise<number> {
|
||||
const result = await conn.execute({ sql, args });
|
||||
const row = result.rows[0] as Record<string, unknown> | undefined;
|
||||
if (!row) return 0;
|
||||
return toNumber(Object.values(row)[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the analytics table up to the schema the readers expect, through the
|
||||
* same idempotent bootstrap path the app server uses (`analytics.ts`): the
|
||||
* DDL is a no-op against the live table and each post-hoc column is ALTERed
|
||||
* in only when absent. The weekly job runs against databases the app may not
|
||||
* have migrated yet — a fresh checkout, a restored backup, or production
|
||||
* before the next deploy — and must render its seven stages regardless.
|
||||
*/
|
||||
export async function ensureAnalyticsSchema(conn: MetricsConn): Promise<void> {
|
||||
await conn.execute({ sql: VISITOR_ANALYTICS_DDL });
|
||||
|
||||
const info = await conn.execute({
|
||||
sql: `PRAGMA table_info(VisitorAnalytics)`
|
||||
});
|
||||
const present = new Set(info.rows.map((row) => String((row as any).name)));
|
||||
for (const column of VISITOR_ANALYTICS_ADDED_COLUMNS) {
|
||||
if (!present.has(column.name)) {
|
||||
await conn.execute({ sql: column.ddl });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function countInWindow(
|
||||
conn: MetricsConn,
|
||||
sql: string,
|
||||
start: string,
|
||||
end: string
|
||||
): Promise<number> {
|
||||
return scalar(conn, sql, [start, end]);
|
||||
}
|
||||
|
||||
/** One window's funnel. Two databases, one call. */
|
||||
export async function readFunnel(
|
||||
conns: Connections,
|
||||
window: { start: string; end: string }
|
||||
): Promise<FunnelCounts> {
|
||||
const { analytics, nook } = conns;
|
||||
const { start, end } = window;
|
||||
|
||||
// Scoped to the Nook host (FRE-366): before the host column existed, a
|
||||
// page view of `/` on nook.freno.me was the same row as freno.me's root.
|
||||
// Pre-instrumentation rows carry no host and are excluded — the traffic
|
||||
// section states how many.
|
||||
const visit = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND path NOT LIKE '/api/%'
|
||||
AND host = ?`,
|
||||
[start, end, NOOK_HOST]
|
||||
);
|
||||
|
||||
const downloadClick = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND path = ? AND stage = 'click'`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
const dmgServed = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND path = ? AND stage = 'served'`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
// Created Stripe checkout sessions, recorded by the checkout route itself
|
||||
// (FRE-366) — not the /checkout page view that used to proxy for them.
|
||||
const checkout = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND path = ?`,
|
||||
[start, end, NOOK_CHECKOUT_PATH]
|
||||
);
|
||||
|
||||
// The licence DB stores ISO-8601 (`2026-10-28T20:28:46.806Z`); the analytics
|
||||
// DB stores `YYYY-MM-DD HH:MM:SS`. `datetime()` normalises both sides so a
|
||||
// boundary seconds apart cannot include or drop a row on string comparison.
|
||||
const trial = await scalar(
|
||||
nook,
|
||||
`SELECT COUNT(*) AS n FROM trials
|
||||
WHERE datetime(started_at) >= datetime(?) AND datetime(started_at) < datetime(?)`,
|
||||
[start, end]
|
||||
);
|
||||
|
||||
const purchase = await scalar(
|
||||
nook,
|
||||
`SELECT COUNT(*) AS n FROM licenses
|
||||
WHERE datetime(created_at) >= datetime(?) AND datetime(created_at) < datetime(?)`,
|
||||
[start, end]
|
||||
);
|
||||
|
||||
const activation = await scalar(
|
||||
nook,
|
||||
`SELECT COUNT(*) AS n FROM activations
|
||||
WHERE datetime(activated_at) >= datetime(?) AND datetime(activated_at) < datetime(?)`,
|
||||
[start, end]
|
||||
);
|
||||
|
||||
return {
|
||||
visit,
|
||||
downloadClick,
|
||||
dmgServed,
|
||||
trial,
|
||||
checkout,
|
||||
purchase,
|
||||
activation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traffic shape for one window: how much of it predates the FRE-366
|
||||
* instrumentation (no host, proxy's address), whether rows recorded since
|
||||
* still see loopback (which would mean the forwarded-header fix is not
|
||||
* live), and how the download label splits. Without these facts the numbers
|
||||
* read as demand, which they are not.
|
||||
*/
|
||||
export async function readTraffic(
|
||||
conns: Connections,
|
||||
window: { start: string; end: string }
|
||||
): Promise<WindowTraffic> {
|
||||
const { analytics } = conns;
|
||||
const { start, end } = window;
|
||||
|
||||
const analyticsRows = await countInWindow(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?`,
|
||||
start,
|
||||
end
|
||||
);
|
||||
|
||||
const hostRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND host IS NOT NULL`,
|
||||
[start, end]
|
||||
);
|
||||
|
||||
const legacyRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND host IS NULL`,
|
||||
[start, end]
|
||||
);
|
||||
|
||||
const hostLoopbackRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND host IS NOT NULL
|
||||
AND ${LOOPBACK_SQL}`,
|
||||
[start, end]
|
||||
);
|
||||
|
||||
const downloadRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ? AND path = ?`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
const downloadClickRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ? AND path = ? AND stage = 'click'`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
const downloadServedRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ? AND path = ? AND stage = 'served'`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
const downloadUnstagedRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ? AND path = ? AND stage IS NULL`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
// Anything whose UA is not a browser engine: curl, Sparkle's updater,
|
||||
// link-preview crawlers. A real install click arrives as `Mozilla/5.0 …`.
|
||||
const downloadNonBrowserRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ? AND path = ?
|
||||
AND (user_agent IS NULL OR user_agent NOT LIKE 'Mozilla/%')`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
return {
|
||||
analyticsRows,
|
||||
hostRows,
|
||||
legacyRows,
|
||||
hostLoopbackRows,
|
||||
downloadRows,
|
||||
downloadClickRows,
|
||||
downloadServedRows,
|
||||
downloadUnstagedRows,
|
||||
downloadNonBrowserRows
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The campaign dimension, grouped exactly as `getAnalyticsSummary.topCampaigns`
|
||||
* groups it: top 10 by volume, untagged rows excluded (the untagged bucket is
|
||||
* a different question and would otherwise swallow the list).
|
||||
*/
|
||||
export async function readCampaigns(
|
||||
analytics: MetricsConn,
|
||||
window: { start: string; end: string }
|
||||
): Promise<CampaignRow[]> {
|
||||
const result = await analytics.execute({
|
||||
sql: `SELECT utm_source, utm_medium, utm_campaign, COUNT(*) AS count
|
||||
FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND (utm_source IS NOT NULL OR utm_medium IS NOT NULL OR utm_campaign IS NOT NULL)
|
||||
GROUP BY utm_source, utm_medium, utm_campaign
|
||||
ORDER BY count DESC
|
||||
LIMIT 10`,
|
||||
args: [window.start, window.end]
|
||||
});
|
||||
|
||||
return result.rows.map((raw) => {
|
||||
const row = raw as Record<string, unknown>;
|
||||
return {
|
||||
source: (row.utm_source as string | null) ?? null,
|
||||
medium: (row.utm_medium as string | null) ?? null,
|
||||
campaign: (row.utm_campaign as string | null) ?? null,
|
||||
count: toNumber(row.count)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "The one thing that moved": the stage with the largest absolute change
|
||||
* against the previous window. Ties go to the earlier stage, so a week when
|
||||
* nothing moved returns null rather than a made-up winner.
|
||||
*/
|
||||
export function pickMover(
|
||||
current: FunnelCounts,
|
||||
previous: FunnelCounts
|
||||
): Mover | null {
|
||||
let best: Mover | null = null;
|
||||
|
||||
for (const stage of STAGES) {
|
||||
const delta = current[stage.key] - previous[stage.key];
|
||||
if (delta === 0) continue;
|
||||
if (!best || Math.abs(delta) > Math.abs(best.delta)) {
|
||||
best = {
|
||||
key: stage.key,
|
||||
label: stage.label,
|
||||
now: current[stage.key],
|
||||
before: previous[stage.key],
|
||||
delta
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface ReportOptions {
|
||||
/** End of the current window; defaults to the wall clock. */
|
||||
now?: Date;
|
||||
/** Window length in days; defaults to 7. */
|
||||
days?: number;
|
||||
}
|
||||
|
||||
/** Read everything `.md` needs, in one call. */
|
||||
export async function getWeeklyReport(
|
||||
conns: Connections,
|
||||
options: ReportOptions = {}
|
||||
): Promise<WeeklyReport> {
|
||||
const days = options.days ?? 7;
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
throw new Error(`days must be a positive number, got ${days}`);
|
||||
}
|
||||
|
||||
const now = options.now ?? new Date();
|
||||
const windows = windowsFor(now, days);
|
||||
|
||||
const [counts, previousCounts, traffic, campaigns] = await Promise.all([
|
||||
readFunnel(conns, windows.current),
|
||||
readFunnel(conns, windows.previous),
|
||||
readTraffic(conns, windows.current),
|
||||
readCampaigns(conns.analytics, windows.current)
|
||||
]);
|
||||
|
||||
return {
|
||||
window: { ...windows.current, days },
|
||||
previous: windows.previous,
|
||||
counts,
|
||||
previousCounts,
|
||||
campaigns,
|
||||
taggedRows: campaigns.reduce((sum, row) => sum + row.count, 0),
|
||||
traffic,
|
||||
mover: pickMover(counts, previousCounts),
|
||||
generatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z")
|
||||
};
|
||||
}
|
||||
|
||||
function cell(value: string | null): string {
|
||||
return value === null || value === "" ? "—" : value;
|
||||
}
|
||||
|
||||
function signed(value: number): string {
|
||||
if (value === 0) return "0";
|
||||
return value > 0 ? `+${value}` : `−${Math.abs(value)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the weekly file. Interpretation lives in the CMO's read; this renderer
|
||||
* only states what the data is, what moved, and what the numbers cannot say.
|
||||
* The Gaps section is not boilerplate: each bullet is computed from the window
|
||||
* it describes, so it empties itself if the underlying hole is ever fixed.
|
||||
*/
|
||||
export function renderWeeklyMarkdown(report: WeeklyReport): string {
|
||||
const { counts, previousCounts, traffic } = report;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("# Weekly funnel snapshot");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Window: **${report.window.start} → ${report.window.end} UTC** (${report.window.days} days). ` +
|
||||
`Previous: ${report.previous.start} → ${report.previous.end} UTC.`
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated " +
|
||||
"weekly, never hand-edited. The analysis is the CMO's."
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Funnel");
|
||||
lines.push("");
|
||||
lines.push("| Stage | This week | Last week | Change |");
|
||||
lines.push("|---|---:|---:|---:|");
|
||||
for (const stage of STAGES) {
|
||||
const now = counts[stage.key];
|
||||
const before = previousCounts[stage.key];
|
||||
lines.push(`| ${stage.label} | ${now} | ${before} | ${signed(now - before)} |`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Campaigns");
|
||||
lines.push("");
|
||||
if (report.campaigns.length === 0) {
|
||||
lines.push("_No tagged traffic in the window._");
|
||||
} else {
|
||||
lines.push(
|
||||
"Top 10 by volume, untagged excluded — the same grouping " +
|
||||
"`getAnalyticsSummary.topCampaigns` returns."
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("| Source | Medium | Campaign | Rows |");
|
||||
lines.push("|---|---|---|---:|");
|
||||
for (const row of report.campaigns) {
|
||||
lines.push(
|
||||
`| ${cell(row.source)} | ${cell(row.medium)} | ${cell(row.campaign)} | ${row.count} |`
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`${report.taggedRows} of ${traffic.analyticsRows} rows in the window carry ` +
|
||||
`campaign tags; the rest are untagged and therefore unattributed.`
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## The one thing that moved");
|
||||
lines.push("");
|
||||
if (!report.mover) {
|
||||
lines.push("_Nothing moved: every stage held its previous-week value._");
|
||||
} else {
|
||||
const mover = report.mover;
|
||||
lines.push(
|
||||
`**${mover.label}: ${mover.before} → ${mover.now} (${signed(mover.delta)}).**`
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Before acting on these numbers");
|
||||
lines.push("");
|
||||
|
||||
if (traffic.legacyRows > 0) {
|
||||
lines.push(
|
||||
`- **${traffic.legacyRows} of ${traffic.analyticsRows} rows in the window predate the FRE-366 instrumentation.** ` +
|
||||
`They carry no host, and their recorded address is the production proxy's loopback ` +
|
||||
`(127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. ` +
|
||||
`They are excluded from every host-scoped stage above (visits, and the download ` +
|
||||
`click/served split), and "unique visitors" over them still collapses to one host. ` +
|
||||
`Rows recorded since carry the site they addressed and the visitor's address ` +
|
||||
`truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so ` +
|
||||
`"unique visitors" counts networks, not machines.`
|
||||
);
|
||||
}
|
||||
|
||||
if (traffic.hostLoopbackRows > 0) {
|
||||
lines.push(
|
||||
`- **${traffic.hostLoopbackRows} rows recorded since the instrumentation still carry a ` +
|
||||
`loopback address** — the forwarded-header fix is not taking effect in production ` +
|
||||
`(no \`cf-connecting-ip\`, and no usable \`x-forwarded-for\` hop). Do not trust ` +
|
||||
`per-channel reach until this is zero.`
|
||||
);
|
||||
}
|
||||
|
||||
if (traffic.downloadRows > 0) {
|
||||
lines.push(
|
||||
`- **Download rows are automation-heavy.** Of this window's ${traffic.downloadRows} ` +
|
||||
`download rows, ${traffic.downloadClickRows} are clicks and ` +
|
||||
`${traffic.downloadServedRows} are served files` +
|
||||
(traffic.downloadUnstagedRows > 0
|
||||
? `; the remaining ${traffic.downloadUnstagedRows} predate the click/served stage ` +
|
||||
`label and cannot be split, so they count in neither stage`
|
||||
: "") +
|
||||
`. ${traffic.downloadNonBrowserRows} of all ${traffic.downloadRows} are not browser ` +
|
||||
`user agents (curl, Sparkle, link-preview crawlers).`
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature ` +
|
||||
`Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.`
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## Sources");
|
||||
lines.push("");
|
||||
lines.push("- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.");
|
||||
lines.push("- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.");
|
||||
lines.push(`- Generated ${report.generatedAt}.`);
|
||||
lines.push("");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
}
|
||||
],
|
||||
"crons": [
|
||||
{ "path": "/api/analytics-retention", "schedule": "0 12 * * *" },
|
||||
{ "path": "/api/the-nook/lifecycle", "schedule": "0 13 * * *" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user