Compare commits
32 Commits
7108f77bf9
...
faq/from-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd91b0d3ef | ||
|
|
db3e695ecf | ||
|
|
4d6550f0d1 | ||
|
|
bb6cf27821 | ||
|
|
e46c0a2677 | ||
|
|
2c917e63e8 | ||
|
|
871e47dee9 | ||
|
|
f20389e64e | ||
|
|
534c1ccd86 | ||
|
|
4a18beeaff | ||
|
|
3d41d6cd45 | ||
|
|
477bb807fc | ||
|
|
7ea654069c | ||
|
|
c1c498e37f | ||
| 134f41eac4 | |||
| 881190d1d4 | |||
| f492132bf2 | |||
| a524f23be2 | |||
| 0bf15e528d | |||
| a48326fd1d | |||
| ac63057ef7 | |||
| f6d6d442de | |||
| 14a61fde9b | |||
| b4e68765fe | |||
| dabfeada3b | |||
| 9508aee8e1 | |||
| 1a182e53fe | |||
| 6898186df5 | |||
| 9ff095a82c | |||
| be90b3d068 | |||
| cbe56390f8 | |||
| 26c37c1862 |
@@ -55,15 +55,8 @@ TURSO_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
|
||||
TURSO_DB_API_TOKEN="<rotate-in-turso-dashboard>" # org-level API token
|
||||
TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io"
|
||||
TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>"
|
||||
NESSA_DB_URL="libsql://<nessa-db>.turso.io"
|
||||
NESSA_DB_TOKEN="<rotate-in-turso-dashboard>"
|
||||
# Clerk authentication — rotate via Clerk Dashboard or `clerk api .../rotate_secret_keys`
|
||||
NESSA_CLERK_SECRET="sk_test/live_<rotate-in-clerk-dashboard>" # secret key (sk_test_... for dev, sk_live_... for prod)
|
||||
NESSA_CLERK_JWT_ISSUER="https://<your-app>.clerk.accounts.dev" # JWT issuer from Clerk Dashboard
|
||||
NESSA_CLERK_WEBHOOK_SECRET="whsec_<rotate-in-clerk-dashboard>" # Svix signing secret from Clerk Dashboard → Webhooks → Signing Secret
|
||||
|
||||
APPLE_CLIENT_ID_LINEAGE=com...
|
||||
APPLE_CLIENT_ID_NESSA=com...
|
||||
|
||||
# ── Infra / integration tokens ──
|
||||
INFILL_BEARER_TOKEN="<rotate-at-infill-service>"
|
||||
|
||||
478
marketing/drafts/faq-from-objections.md
Normal file
478
marketing/drafts/faq-from-objections.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# `/faq` from the objections we already have
|
||||
|
||||
**Date:** 2026-10-29 · **Owner:** CMO · **Status:** CTO-verified (FRE-329) and CoS-decided (FRE-334, option A: withdraw both) on 2026-10-29; **awaiting the sponsor's publish approval** — not approved, not published · **Source issue:** FRE-326
|
||||
**Page:** `nook.freno.me/faq` · **Page source:** `freno-dev/src/routes/nook/faq.tsx` · **Site commit read:** `c1c498e` (branch `faq/from-objections-2026-10-29`, unmerged)
|
||||
**Product commit read:** The-Nook `f9cbd879` — the exact revision the CTO verified against (0.13.4, build 37)
|
||||
|
||||
Every entry below is traceable to a real question or a real report. Nothing is invented to fill a
|
||||
category. No testimonial is used. No price, refund term, or umbrella/cross-promotion answer appears
|
||||
here — those stay with FRE-204, the Chief of Staff and the sponsor.
|
||||
|
||||
---
|
||||
|
||||
## 1. What I could read, and what I could not
|
||||
|
||||
| Source | What it is | Read on | What it yielded |
|
||||
|---|---|---|---|
|
||||
| `nook.freno.me/faq` | The live page | 2026-10-29 | Six questions already published: privacy, notch, battery, permissions, macOS 14.2, offline. |
|
||||
| FRE-326 description | This board issue | 2026-10-29 | Two purchase-blocking questions named verbatim: *"does it work with Cursor?"* and *"what happens when my agent is idle?"* |
|
||||
| NOOK issue corpus | 948 issues imported from Plane, plus the 324-issue Paperclip board | 2026-10-08 → 2026-10-28 | User-observable defect reports. These are reports, not captured asks; they prove an objection is live. |
|
||||
| `freno-dev/marketing/positioning.md` | Voice, approved claims, competitor ban | 2026-10-29 | The register and the "never name a competitor" rule. |
|
||||
| The-Nook code | `AgentRegistry.swift`, `SessionModels.swift`, `Terminal*`, `hook/setup.go`, `PluginInstaller.swift`, `LicenseService.swift`, `main.swift` | 2026-10-29 | The technical facts each answer is allowed to use. |
|
||||
| NOOK-121 | The original `/faq` creation (2026-10-08) | 2026-10-29 | Confirms the six live questions came from the strategy's named objections, not from a support corpus. Refund was deliberately dropped by Mike. |
|
||||
|
||||
### The gap that shapes this draft
|
||||
|
||||
**Support mail is not readable from this run.** There is no mailbox on the machine; the in-app
|
||||
"Report a bug" form `POST`s to `freno-dev/src/routes/api/the-nook/report.ts`, which emails the
|
||||
developer and stores nothing queryable. `/privacy` and the repo contain no support corpus.
|
||||
|
||||
**Reply threads are not collected.** `freno-dev/marketing/listening.md` is an empty table; FRE-201
|
||||
(the listening job) has not produced a thread yet.
|
||||
|
||||
**Directory comments do not exist yet.** FRE-207 has a submission packet, not live listings; none of
|
||||
the directories has a comment thread about The Nook to mine.
|
||||
|
||||
**Consequence:** I cannot rank by observed ask frequency, because no ask corpus exists. What follows
|
||||
ranks by **purchase-blocking weight first**, then by **the number of named reports in our own issue
|
||||
corpus that belong to the cluster**. The report counts are a proxy for "this objection is live", not
|
||||
a count of people who asked. I have not put a percentage on anything.
|
||||
|
||||
---
|
||||
|
||||
## 2. The register — clusters, sources, frequency, and whether they block a purchase
|
||||
|
||||
| # | Cluster | The question in the buyer's words | Verbatim source(s), with dates | Frequency evidence | Blocks a purchase? | Disposition |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Privacy / what leaves the Mac | "Does The Nook read my prompts or my code?" | Live `/faq`, 2026-10-29; code findings in `main.swift:272-278`, `LicenseService.swift:254`, `docs/sidecar-frames.md` | 1 live question + 3 named code findings | **Yes — the whole decision for this audience** | **Withdraw the live answer; replacement held.** See §4. |
|
||||
| 2 | Agent fit / Cursor | "Does it work with Cursor — and can I approve from the notch, or only watch?" | FRE-326, 2026-10-29: *"does it work with Cursor?"*; NOOK-643 (2026-10-20), NOOK-695 (2026-10-20), NOOK-699 (2026-10-20), NOOK-707 (2026-10-21), NOOK-638 (2026-10-20) | 1 board question + 5 reports | **Yes** | **Propose E1** |
|
||||
| 3 | Idle / is it actually working? | "What happens when my agent is idle?" | FRE-326, 2026-10-29: *"what happens when my agent is idle?"*; NOOK-281 (2026-10-12), NOOK-415 (2026-10-15), NOOK-417 (2026-10-15), NOOK-823 (2026-10-22), NOOK-898 (2026-10-23) | 1 board question + 5 reports | **Yes** | **Propose E2** |
|
||||
| 4 | Install trust / permissions | "What permissions does it need — and what does it write into my agent's config?" | Live `/faq`, 2026-10-29; NOOK-733 (2026-10-21), NOOK-648 (2026-10-20), NOOK-685 (2026-10-20), NOOK-418 (2026-10-15), NOOK-568 (2026-10-17) | 1 live question + 5 reports | **Yes** | **Propose E3** |
|
||||
| 5 | Terminal / tmux fit | "Does it work with my terminal, including tmux?" | NOOK-302 (2026-10-13), NOOK-310 (2026-10-13), NOOK-788 (2026-10-22) | 3 reports; no captured ask | **Yes for tmux-first buyers** | **Propose E4** — report-sourced, not an ask |
|
||||
| 6 | Notch / display | “Do I need a Mac with a notch?” | Live `/faq`, 2026-10-29; NOOK-290 (2026-10-12), NOOK-291 (2026-10-12), NOOK-386 (2026-10-14) | 1 live question + 3 reports | Compatibility; rarely the blocker | **Rewrite E5** — the pointer-display sentence was never true |
|
||||
| 7 | Battery / cost | "How much battery does it cost?" | Live `/faq`, 2026-10-29; NOOK-850 (2026-10-23), NOOK-851 (2026-10-23), NOOK-877 (2026-10-23) | 1 live question + 3 reports | Curious, not a hard blocker | **Rewrite E6 without a number** |
|
||||
| 8 | macOS floor | "Why macOS 14.2 or later?" | Live `/faq`, 2026-10-29; NOOK-881 (2026-10-23) | 1 live question + 1 report | Compatibility; a hard no on 14.0/14.1 | **Keep current answer** |
|
||||
| 9 | Offline | "Does it work offline?" | Live `/faq`, 2026-10-29; NOOK-494 (2026-10-16) | 1 live question + 1 report | Compatibility; a hard no for offline-only | **Rewrite E8 for the phone exception** |
|
||||
| 10 | Trial / licence mechanics | "What am I actually buying — trial, Macs, updates?" | NOOK-682 (2026-10-20), NOOK-684 (2026-10-20), NOOK-60 (2026-10-08) | 2 reports; no captured ask | **Yes** | **Held** — no verbatim ask; do not invent one |
|
||||
| 11 | Remote agents / containers | "Can I approve an agent running on a remote box or in Docker?" | FRE-323, FRE-320, FRE-315; `marketing/positioning.md` claims remote SSH | Many reports; the claim is unverified | **Yes — the launch claim** | **Held** — no answer before the demo |
|
||||
| 12 | Phone / Watch | "Is there an iPhone app? Do I need one?" | NOOK-939 (2026-10-27), NOOK-294 (backlog) | 2 reports; status unverified | Curious; would be blocking if sold | **Held** — CTO status answer first |
|
||||
| 13 | Refund / price | "What's your refund policy? Is $10 the real price?" | FRE-204, NOOK-121 | No readable source; contractual | **Yes** | **Out of scope** — FRE-204, CoS + sponsor |
|
||||
|
||||
### What changed since the last register (FRE-204)
|
||||
|
||||
- The live page already has six entries; this draft **keeps one** (macOS 14.2), **rewrites four**
|
||||
(permissions, battery, offline, and notch — whose pointer-display sentence the CTO found was never
|
||||
true), **withdraws one** (privacy), and **proposes three new ones** (E1, E2, E4). It does not re-ask
|
||||
the six questions as if they were new.
|
||||
- The privacy answer is not merely unverified — it is **false at the current build**. It must come
|
||||
down in the same edit, not wait for a nicer replacement.
|
||||
- Cursor is now a supported agent row (`AgentRegistry.swift:524`), so "does it work with Cursor?"
|
||||
has a factual answer today. It was an open question when the live page was written.
|
||||
|
||||
### CTO verdict (FRE-329, 2026-10-29)
|
||||
|
||||
The CTO read The-Nook `f9cbd879`, the site at `c1c498e` and the live page, and checked every entry
|
||||
against the code. **No entry is cut.** Six change text, exactly as below; E1 and E7 ship as written.
|
||||
|
||||
| Entry | Verdict | What changed in this revision |
|
||||
|---|---|---|
|
||||
| E1 Agent fit / Cursor | **verified** | nothing |
|
||||
| E2 Idle | verified, one sentence tightened | the row-eviction sentence |
|
||||
| E3 Permissions / config | **not verified** | three sentences: Login Items toggle, per-event entries, `~/.nook` |
|
||||
| E4 Terminal / tmux | **not verified** | one sentence: which terminals take a typed reply |
|
||||
| E5 Notch | **not verified** | one sentence: one island per enabled display, not the pointer's display |
|
||||
| E6 Battery | verified, one clause tightened | "never runs them in the background" |
|
||||
| E7 macOS 14.2 | **verified** | nothing |
|
||||
| E8 Offline | **not verified** | one sentence, plus a six-row network list in its gate |
|
||||
| §4 privacy | all three findings confirmed; withdrawal is right | `positioning.md` rule 3 comes down in the same change |
|
||||
|
||||
The verdict adds two things to the plan, both now in §4 and §7: the false claim's **source rule** in
|
||||
`marketing/positioning.md` — rule 3 and its approved-claims row — must be struck in the same change
|
||||
as the withdrawal, or the next page restates it; and the egress inventory's scope widens to cover
|
||||
weather, the network panel, the speed test, the changelog, the bug report and Sparkle, plus Sentry's
|
||||
`sendDefaultPii` and trace sample rate.
|
||||
|
||||
The verdict's full text and its per-sentence evidence is
|
||||
[FRE-329's `faq-verdict` document](/FRE/issues/FRE-329#document-faq-verdict). Nothing in it is
|
||||
reproduced here as a new claim; the entry text below is the verdict's own replacement wording, except
|
||||
E4, where I took the verdict's first option (keep the Alacritty clause) and narrowed the jump
|
||||
sentence, which the verdict itself notes is `open -b` for three of the seven names.
|
||||
|
||||
---
|
||||
|
||||
## 3. Proposed entries — page-ready
|
||||
|
||||
Each entry carries the six required things: **claim, audience, channel, owner, date, and what has to
|
||||
be true before it ships.** The answer text is the copy to paste; the trace is the evidence a CTO can
|
||||
check.
|
||||
|
||||
### E1 · "Does it work with my agent — and can I approve from the notch, or only watch?"
|
||||
|
||||
> **It depends on the agent, and Cursor is the useful example.** Cursor is watched, not gated: its
|
||||
> sessions, finished turns and errors appear in the island, but when Cursor asks for permission the
|
||||
> prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs
|
||||
> and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the
|
||||
> decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. One limit:
|
||||
> `cursor-agent -p` one-shot runs send no turn end, so they appear but do not get a completion card.
|
||||
|
||||
- **Question source:** FRE-326, 2026-10-29 (*"does it work with Cursor?"*); NOOK-643, 2026-10-20
|
||||
(*"No permission hook is installed, deliberately: Cursor blocks the action when a permission hook
|
||||
prints nothing…"*).
|
||||
- **Claim:** agent gating is per-agent; Cursor is watch-only.
|
||||
- **Audience:** the buyer whose agent is not Claude Code.
|
||||
- **Channel:** `/faq`. No FAQ structured data in this pass — see §6.
|
||||
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||
- **Must be true:** at the shipped commit, Cursor still reads `.missing(.approvals)` with the same
|
||||
reason, and `.available(.sessions)`, `.available(.completion)` and `.available(.errors)` as stated
|
||||
(`AgentRegistry.swift:523-555`, which is where the CTO read it). The separate `/agents` page is
|
||||
incomplete for Cursor and the other newer rows; that is a page correction, not a gate for this
|
||||
self-contained answer.
|
||||
|
||||
### E2 · "What happens when my agent is idle?"
|
||||
|
||||
> **Idle means the last turn is over, or the session has not produced anything yet — not that the
|
||||
> process is gone.** A running process is liveness, not work: a just-opened agent window can show
|
||||
> idle until you type. When an agent finishes a turn with a final message, the row stays green for
|
||||
> ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot
|
||||
> report for that session, and the row says so instead of pretending it is idle. Nothing is lost when
|
||||
> a row goes idle — the next prompt brings it back and flips it to running. The row itself leaves the
|
||||
> island 15 minutes after the turn (5 if the turn ended without a reply), so a long-quiet session
|
||||
> reappears on its next event instead of sitting there. If a session that is obviously working says
|
||||
> idle, that is a bug; tell us.
|
||||
|
||||
- **Question source:** FRE-326, 2026-10-29 (*"what happens when my agent is idle?"*); NOOK-898,
|
||||
2026-10-23 (*"kimi payloads fail to decode, so every Kimi session stays idle"*); NOOK-823,
|
||||
2026-10-22 (*"Codex terminal: sessions become idle or disappear prematurely"*); NOOK-417,
|
||||
2026-10-15 (*"Never render plain Idle for a session whose integration is dark"*).
|
||||
- **Claim:** idle is a statement about the last turn, not a guess at the process; stale is a separate
|
||||
state.
|
||||
- **Audience:** anyone watching a fleet and wondering whether to intervene.
|
||||
- **Channel:** `/faq`.
|
||||
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||
- **Must be true:** `SessionModels.swift:511,552,564-577` still defines `islandStatus` this way (no
|
||||
content → idle; complete → ready for 10 minutes → idle; `integrationStale` → stale) and
|
||||
`SessionState.swift:35,40` still evicts a finished turn after 15 minutes, 5 when it ended without a
|
||||
reply. If an open bug makes a working agent render idle at the shipped commit, the "that is a bug"
|
||||
sentence stays but the bug gets a named issue.
|
||||
|
||||
### E3 · "What permissions does it need — and what does it write into my agent's config?"
|
||||
|
||||
> **The app itself runs unprivileged.** Fan control and charge limiting go through a small helper
|
||||
> that macOS requires to run as root for hardware access; the app registers it on first use and takes
|
||||
> you to System Settings › General › Login Items & Extensions, where you switch it on once. For each
|
||||
> agent it adds what that agent needs — a hook group per event it listens to, or one plugin link —
|
||||
> inside the agent's own config file or directory. It keeps its own files under `~/.nook` — the hook
|
||||
> binary and one plugin copy per agent — and adds only its own entries to that agent's config. A hook
|
||||
> entry you added yourself in the same file survives install and uninstall, and it only touches the
|
||||
> records and switches it created. Uninstall removes exactly what it wrote — and switches off what it
|
||||
> turned on — with no leftover links or orphaned files.
|
||||
|
||||
- **Question source:** live `/faq`, 2026-10-29 (*"What permissions does it need?"*); NOOK-418,
|
||||
2026-10-15 (a custom agent's install target is never shown to the user); NOOK-733, 2026-10-21 (a
|
||||
Codex hook installed but not trusted, so it never ran); NOOK-648, 2026-10-20 and NOOK-685,
|
||||
2026-10-20 (uninstall leftovers); NOOK-568, 2026-10-17 (helper re-registration tore down an
|
||||
approved helper).
|
||||
- **Claim:** reversible install; the helper is the only root step; uninstall is exact.
|
||||
- **Audience:** the buyer who has a hand-tuned `~/.claude/settings.json` or `~/.codex/hooks.json`.
|
||||
- **Channel:** `/faq`.
|
||||
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||
- **Must be true:** `hook/setup.go`'s strip functions and `PluginInstaller.uninstall` still remove
|
||||
only their own records at the shipped commit (`removeIfOurs`, `stripOurGroups`/`groupIsOurs`); the
|
||||
helper is still registered through `SMAppService.daemon` with a Login Items toggle and no password
|
||||
prompt (`PrivilegedHelper.swift:62-112,204-208`); the custom-agent target is still shown and
|
||||
accepted before install (NOOK-418); the only host switch uninstall flips is Amp's own hook-enable
|
||||
flag (`PluginInstaller.swift:379-385`) — and the answer now says so.
|
||||
|
||||
### E4 · "Does it work with my terminal, including tmux?"
|
||||
|
||||
> **Yes — it recognises Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux. Four of those
|
||||
> take a typed reply; the rest are watched.** A session is tied to the terminal that started it. Jump
|
||||
> lands in the right window for Terminal.app, iTerm2, kitty and any tmux pane — in tmux, the right
|
||||
> pane; on Warp, WezTerm and Ghostty it brings the app forward instead. Replies type straight back
|
||||
> into Terminal.app, iTerm2, kitty and any tmux pane; on Warp, WezTerm and Ghostty you answer in the
|
||||
> app's own window, and Alacritty takes no typed reply from another app at all.
|
||||
|
||||
- **Question source:** no captured ask. Underlying reports: NOOK-302, 2026-10-13 (*"a tmux session
|
||||
shows only 'tmux', and its outer terminal comes from whoever started the tmux server"*); NOOK-310,
|
||||
2026-10-13 (*"jump and send address tmux panes on the default server, ignoring the session's tmux
|
||||
socket"*).
|
||||
- **Claim:** seven terminal families and tmux panes included. Remote panes are not claimed here;
|
||||
the remote launch story is still held.
|
||||
- **Audience:** anyone whose workflow is tmux-first.
|
||||
- **Channel:** `/faq`.
|
||||
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||
- **Must be true:** `TerminalAncestry.programs` still recognises those seven names; `TerminalSend.attempts`
|
||||
still builds a delivery channel only for tmux, herdr, kitty, iTerm2 and Terminal.app and refuses
|
||||
Alacritty by name; `TerminalJump` still raises the app rather than the window for Warp, WezTerm and
|
||||
Ghostty. This entry is **report-sourced, not ask-sourced**; if a captured ask never arrives, it is
|
||||
still publishable because the reports are real, but it ranks below E1–E3 until one does.
|
||||
|
||||
### E5 · "Do I need a Mac with a notch?"
|
||||
|
||||
> **No. On a notched MacBook the island sits in the notch. On machines and displays without one, it
|
||||
> sits at the top centre of the display — the same pill with the same slots.** You get one island on
|
||||
> every display it is turned on for (all connected displays on first launch), each anchored to the top
|
||||
> of its own screen; Settings › Displays trims that list, and the pointer only decides which display's
|
||||
> floating controls are live. Everything inside the island — agent cards, fan control, the stats strip
|
||||
> — works identically either way.
|
||||
|
||||
- **Question source:** live `/faq`, 2026-10-29; NOOK-290, 2026-10-12 and NOOK-291, 2026-10-12
|
||||
(non-notch display geometry bugs), NOOK-386, 2026-10-14 (empty pill tucks away on a non-notch
|
||||
display).
|
||||
- **Claim:** a notch is optional; external displays work.
|
||||
- **Audience:** Mac mini / Studio / external-monitor buyers.
|
||||
- **Channel:** `/faq`.
|
||||
- **Owner:** CMO keeps the question and rewrites the answer; CTO verified 2026-10-29.
|
||||
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||
- **Must be true:** `IslandDisplayManager` still owns one island per enabled display, `DisplayStore`
|
||||
still snapshots every connected display on first launch (`DisplayStore.swift:52-59`), and the
|
||||
non-notch geometry tests still pass. Nothing tests that first-launch snapshot today — that is a test
|
||||
gap to file, not a gate on this answer.
|
||||
|
||||
### E6 · "How much battery does it cost?"
|
||||
|
||||
> **It is a native app, not a background agent.** It watches the agents you already run and never
|
||||
> runs them in the background, so their draw is the number that matters. We do not have a published battery
|
||||
> measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs
|
||||
> more than it should on your Mac, that is a bug — tell us.
|
||||
|
||||
- **Question source:** live `/faq`, 2026-10-29; NOOK-850, 2026-10-23 (*"the idle app does work
|
||||
nothing asked for (CPU wins from a live profile)"*), NOOK-851, 2026-10-23 (a 30 fps timeline ticks
|
||||
the fan icon at idle), NOOK-877, 2026-10-23 (compact Nook still costs too much CPU at rest).
|
||||
- **Claim:** native app, not a background agent; **no numeric battery claim**.
|
||||
- **Audience:** the buyer checking whether this is another menu-bar battery sink.
|
||||
- **Channel:** `/faq`.
|
||||
- **Owner:** CMO writes; CTO verified the no-number wording 2026-10-29.
|
||||
- **Date:** CTO verified the no-number wording 2026-10-29; publish on approval, target 2026-11-05.
|
||||
- **Must be true:** either a measurement exists and is quoted, or the no-number sentence ships. Do
|
||||
not restore "the heaviest thing it does is a sensor read" — the profile findings above contradict
|
||||
it.
|
||||
|
||||
### E7 · "Why macOS 14.2 or later?"
|
||||
|
||||
> **The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS
|
||||
> 14.2 (Sonoma).** The app links against it, so 14.0 and 14.1 cannot launch it.
|
||||
|
||||
- **Question source:** live `/faq`, 2026-10-29; NOOK-881, 2026-10-23 (*"A macOS 14.0/14.1 Mac cannot
|
||||
launch the app at all: dyld dies on a strongly linked CATapDescription overlay symbol"*).
|
||||
- **Claim:** the floor is 14.2 because of the audio tap API, not a policy choice.
|
||||
- **Audience:** anyone on an older macOS.
|
||||
- **Channel:** `/faq`.
|
||||
- **Owner:** CMO keeps; CTO verified 2026-10-29.
|
||||
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||
- **Must be true:** `Package.swift:12` and `LSMinimumSystemVersion` still say 14.2, and
|
||||
`DeploymentFloorTests` still refuses an older floor.
|
||||
|
||||
### E8 · "Does it work offline?"
|
||||
|
||||
> **The Mac app does.** The island, agent cards, permission cards and fan control work with no
|
||||
> network; the hooks talk to the app over a local socket. The network is used for update checks,
|
||||
> licence activation, crash reports and the optional trial email, plus a few things only when you use
|
||||
> them: weather (asked for through our relay, with the location you chose), the network panel's
|
||||
> IP-and-ISP lookup, the speed test, the changelog in Settings, and a bug report if you send one. The
|
||||
> phone link is the exception: when you are away from your Mac, the phone reaches it through our
|
||||
> relay, so that part needs a connection.
|
||||
|
||||
- **Question source:** live `/faq`, 2026-10-29; NOOK-494, 2026-10-16 (*"iOS: the main list gives no
|
||||
sign the phone is offline from the Mac"*).
|
||||
- **Claim:** the Mac app is offline-first; the phone link is not.
|
||||
- **Audience:** the buyer with a disconnected or privacy-sensitive setup.
|
||||
- **Channel:** `/faq`.
|
||||
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||
- **Must be true:** the only network uses at the shipped commit are the update check, licence
|
||||
activation, crash reporting, the optional trial email and the phone relay, plus exactly these five
|
||||
user-triggered ones — `/v1/weather` on the relay (`SidecarRelayAPI.swift:182-196`),
|
||||
`NetworkInfoService.swift:60-88,276`, `SpeedTest.swift:36,57`, `ChangelogModel.swift:102`,
|
||||
`BugReportClient.swift:72-94` — and `/privacy` lists the same set. This entry shares its gate with
|
||||
the privacy correction below.
|
||||
|
||||
---
|
||||
|
||||
## 4. The correction that is not a new entry: privacy
|
||||
|
||||
**Live answer, quoted from `nook.freno.me/faq` on 2026-10-29:**
|
||||
|
||||
> "No. The hooks and plugins that connect your agents to The Nook send session metadata only — which
|
||||
> agent is running, which session, which event happened, which terminal it lives in. No prompt text
|
||||
> and no code content leaves your device. Two things do leave the device, and we'd rather say so
|
||||
> plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start.
|
||||
> That's the whole list."
|
||||
|
||||
**Why it cannot stay up:** it is contradicted by the current build.
|
||||
|
||||
1. **Sentry starts unconditionally with `sendDefaultPii = true`** (`Sources/NookApp/main.swift:272-278`).
|
||||
That is crash and error reporting with personal identifiers, not "anonymous usage telemetry".
|
||||
2. **The licence/trial call sends `IOPlatformUUID` and the Mac's device name** (`LicenseService.swift:254`,
|
||||
`:305`). Precision from the verdict: the identifiers ride on activation, not on every launch — a
|
||||
licensed Mac's launch call is `/status` with the key only (`:290-295`).
|
||||
3. **With the phone link on, prompt and reply text transit `nook-relay.freno.me`, sealed end to end**
|
||||
(`docs/sidecar-frames.md`). The relay can route the payload but cannot read it; the live answer
|
||||
still says no prompt text leaves the device.
|
||||
|
||||
`/privacy` discloses the hardware UUID, device name and Stripe, but **not** Sentry and **not** the
|
||||
relay. So there is no replacement sentence I can write that is both true and consistent with the
|
||||
privacy page today.
|
||||
|
||||
**Proposed:** remove the privacy entry from `/faq` in the same edit that ships the other entries,
|
||||
rather than replace it with another sentence I cannot stand behind. The replacement is held until
|
||||
the Security Engineer's egress inventory and the `/privacy` update land.
|
||||
|
||||
**Decided 2026-10-29 — option A, withdraw both.** The Chief of Staff ruled on
|
||||
[FRE-334](/FRE/issues/FRE-334) (`decision` document) that the `/faq` answer and `positioning.md`
|
||||
rule 3 with its approved-claims row come down **in one commit**, and that no privacy claim is written
|
||||
anywhere until FRE-333 lands and the CTO verifies it. Three consequences for the sequence below: the
|
||||
withdrawal is no longer one of the sponsor's options, it is settled company policy; the publish card
|
||||
asks one thing, publish the eight entries with the privacy answer withdrawn; and if an answer ever
|
||||
ships the seven entries with the false answer still up, that is not a variant to absorb — it stops
|
||||
and goes back to the CoS, because it would publish something the CoS has ruled out.
|
||||
|
||||
**The rule that produced the falsehood comes down with it.** The CTO's verdict traces the live answer
|
||||
to `freno-dev/marketing/positioning.md` rule 3 (“Honest privacy scoping”) and to the matching
|
||||
approved-claims row, which state the defensible claim as “No prompt or code content leaves the
|
||||
device” with the backing “envelope carries session/event/terminal metadata only”. That backing is true
|
||||
of the hook→app envelope and says nothing about what leaves the device, so if rule 3 stands while the
|
||||
FAQ answer comes down, the next page restates the claim. Rule 3 and that row are struck in the same
|
||||
commit as this patch (§7 step 3; the same edit also fixes the stale “macOS 14+” row in that table,
|
||||
which NOOK-881 retired).
|
||||
|
||||
- **Claim:** none until the inventory exists — deliberately.
|
||||
- **Audience:** the buyer for whom this is the whole decision.
|
||||
- **Channel:** `/faq` (withdrawal), `/privacy` (replacement), `marketing/positioning.md` (the rule).
|
||||
- **Owner:** CoS for the withdrawal decision — **done 2026-10-29, option A**; Security Engineer for the
|
||||
egress inventory and the disclosure text; CTO for any build change and for verifying the replacement;
|
||||
CMO for the replacement copy and the rule-3 strike.
|
||||
- **Date:** withdrawal decision **done 2026-10-29** (was targeted 2026-10-31); inventory 2026-11-05;
|
||||
replacement copy 2026-11-09, and it needs both the CTO's verification and the sponsor's approval —
|
||||
rule-3 strike in the same commit as the page patch (§7 step 3).
|
||||
- **Must be true:** every egress in the inventory is either in the answer or gone from the build, and
|
||||
`/privacy` lists the same set. The inventory's scope is wider than the draft first drew it — it must
|
||||
cover `main.swift`, `LicenseService.swift`, `Info.plist`, `ChangelogRelease.swift`,
|
||||
`SidecarController.swift`, **plus** `SidecarRelayAPI`/`WeatherService` (`/v1/weather`),
|
||||
`NetworkInfoService`, `SpeedTest`, `ChangelogModel`, `BugReportClient` and Sparkle's appcast — and
|
||||
on the Sentry side not only that it starts but `sendDefaultPii` and the trace sample rate. Until
|
||||
then, do not publish a privacy answer.
|
||||
|
||||
---
|
||||
|
||||
## 5. Held — clusters with no entry yet
|
||||
|
||||
| Cluster | Why it is held | Owner and date |
|
||||
|---|---|---|
|
||||
| Trial / licence mechanics ("trial, Macs, updates") | No captured ask. NOOK-682 and NOOK-684 are defect reports, not a buyer question. The answer is factual (14 days, 3 Macs, Sparkle updates), but the task says do not invent a question to fill the slot. | CMO to capture an ask; CTO confirms `LicenseService.swift:43`, `:262` by 2026-11-03. |
|
||||
| Remote agents / containers | The launch claim is unverified (FRE-315, FRE-322, FRE-323, FRE-320). No FAQ answer before the demo. | CTO evidence; CMO copy the day it exists. Revisit 2026-11-12. |
|
||||
| Phone / Watch | Status is unverified; the landing page and FAQ currently disagree. | CTO status answer by 2026-11-03; CMO copy after. |
|
||||
| Refund / price | Contractual, and explicitly out of this issue. | FRE-204, CoS + sponsor. |
|
||||
| Custom agents | No captured ask; the `/agents` page already says a custom agent can wire itself in. | No entry until asked. |
|
||||
|
||||
---
|
||||
|
||||
## 6. What the page edit would change
|
||||
|
||||
**File:** `freno-dev/src/routes/nook/faq.tsx` (site `master` at `134f41e`; the branch is cut from
|
||||
local `c1c498e`, which removed the remote claim from the landing page, not from the FAQ).
|
||||
|
||||
| Action | Entry |
|
||||
|---|---|
|
||||
| **Remove** | “Does The Nook read my prompts or my code?” — until the egress inventory and `/privacy` are true |
|
||||
| **Rewrite (correction only)** | “Do I need a Mac with a notch?” → E5 (per-display island, not the pointer's display) |
|
||||
| **Keep, with the exact text above** | “Why macOS 14.2 or later?” |
|
||||
| **Rewrite** | “What permissions does it need?” → E3 (adds the config/uninstall answer) |
|
||||
| **Rewrite** | “How much battery does it cost?” → E6 (no number) |
|
||||
| **Rewrite** | “Does it work offline?” → E8 (phone exception, six-row network list) |
|
||||
| **Add** | E1 (agent fit / Cursor) |
|
||||
| **Add** | E2 (idle) |
|
||||
| **Add** | E4 (terminal / tmux) |
|
||||
| **Edit** | The file header comment that cites `positioning.md` rule 3 → “Privacy is deliberately absent until the egress inventory and /privacy make an honest answer possible.” |
|
||||
| **Edit** | The `PageHead` description, which still advertises the privacy question → the new eight-entry list |
|
||||
|
||||
Order on the page: E1, E2, E3, E4, E5, E6, E7, E8. The mailto link, the accordion behavior and the
|
||||
page title stay unchanged.
|
||||
|
||||
**Not in this pass:** FAQPage JSON-LD. The page has none today; adding structured data is an SEO
|
||||
change, not an objection-mining change. It should be its own reviewed edit — and E1 no longer promises
|
||||
it.
|
||||
|
||||
**Patch artifacts:** `freno-dev/marketing/drafts/faq-page.patch`, regenerated for this revision, and
|
||||
`freno-dev/marketing/drafts/positioning-privacy-withdrawal.patch` for the rule-3 strike step 3 needs
|
||||
to land with it (both proposals, neither applied). The page file and `positioning.md` are untouched
|
||||
until the gates below are satisfied; `faq-page.patch` re-checked against `freno-dev` master
|
||||
`c1c498e` on 2026-10-29 and applies cleanly.
|
||||
|
||||
---
|
||||
|
||||
## 7. Publish gates and sequence
|
||||
|
||||
| Step | Owner | Date | What settles it |
|
||||
|---|---|---|---|
|
||||
| 1. CTO verifies the claims and evidence pointers in E1–E8 | CTO | **done 2026-10-29** | [FRE-329](/FRE/issues/FRE-329), verdict in its `faq-verdict` document: E1/E7 verified as written, E2/E6 verified after one sentence each, E3/E4/E5/E8 corrected above, **no entry cut**. Applied in this revision. |
|
||||
| 2. Human approves this revision | Sponsor | target 2026-10-31 | The draft moves from `marketing/drafts/` to `marketing/approved/`, or the confirmation card on FRE-326 is accepted. The card asks one question now — publish the eight entries with the privacy answer withdrawn — because 2b is settled. |
|
||||
| 2b. Withdrawal of the false privacy answer and of `positioning.md` rule 3 | Chief of Staff | **done 2026-10-29** | [FRE-334](/FRE/issues/FRE-334) `decision`: **option A, withdraw both, one commit**, no privacy claim anywhere until FRE-333 lands and the CTO verifies it. Recorded here so the page, the rule and the card cannot disagree. |
|
||||
| 3. Apply the page patch, strike rule 3 + its approved-claims row, commit on `freno-dev` | CMO | 2026-11-03 | Both patches apply together to `freno-dev` master `c1c498e` (checked 2026-10-29, in a throwaway worktree, as one change set); the diff contains only the FAQ entries, the page comment/description, the `positioning.md` rule-3 strike and the 14.2 row. **The site's suite has not been run — this host has no `bun`** — so step 3 still has to run it before the commit. One commit, so the page and the rule move together. |
|
||||
| 4. Merge to `freno-dev/master` (the publish) | Sponsor or Chief of Staff | 2026-11-05 | A human merge; Vercel deploys. No agent pushes to master. |
|
||||
| 5. Verify the live page | CMO | 2026-11-05 | `https://nook.freno.me/faq` returns 200 and shows the eight entries with no privacy answer; the text matches the approved draft, sentence for sentence. |
|
||||
|
||||
**If the CTO had rejected an entry:** cut it from the patch and ship the rest — it did not reject any.
|
||||
**If the sponsor does not approve:** nothing is applied; the draft stays in `drafts/`.
|
||||
**If the support mailbox becomes readable:** re-sort the register by observed asks before step 3;
|
||||
the top three may change, and the page order should follow the asks, not this draft's proxy ranking.
|
||||
**If a `Must be true` line stops being true** before step 3 — a fix regresses, a line moves — the
|
||||
entry goes back to the CTO, not onto the page.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — verbatim source notes
|
||||
|
||||
| Date | Source | Verbatim |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | Live `/faq` | "Does The Nook read my prompts or my code?" / "Do I need a Mac with a notch?" / "How much battery does it cost?" / "What permissions does it need?" / "Why macOS 14.2 or later?" / "Does it work offline?" |
|
||||
| 2026-10-29 | FRE-326 description | “does it work with Cursor?” / “what happens when my agent is idle?” |
|
||||
| 2026-10-29 | [FRE-329](/FRE/issues/FRE-329) CTO verdict | “Four entries ship as written, two ship after a wording fix… two (E3, E4) currently say something false to a buyer.” Every correction in §3 is that document's own replacement sentence. |
|
||||
| 2026-10-08 | NOOK-121 | "Add /faq from real objections" — shipped the six live topics; refund dropped by Mike. |
|
||||
| 2026-10-20 | NOOK-643 | "agents: Cursor via hooks.json" — "No permission hook is installed, deliberately: Cursor blocks the action when a permission hook prints nothing…" |
|
||||
| 2026-10-21 | NOOK-707 | "agents: Cursor desktop chats bind to the shared extension-host pid and never end" |
|
||||
| 2026-10-23 | NOOK-898 | "hook: kimi payloads fail to decode, so every Kimi session stays idle" |
|
||||
| 2026-10-22 | NOOK-823 | "Codex terminal: sessions become idle or disappear prematurely" |
|
||||
| 2026-10-15 | NOOK-417 | "Never render plain Idle for a session whose integration is dark" |
|
||||
| 2026-10-15 | NOOK-415 | "Sessions whose integration predates the bridge credential freeze as Idle forever" |
|
||||
| 2026-10-21 | NOOK-733 | "codex: a non-interactive session never runs an installed hook (0.163.0)" |
|
||||
| 2026-10-20 | NOOK-648 | "opencode: uninstalling a pre-package install leaves its old nook.js link dangling" |
|
||||
| 2026-10-20 | NOOK-685 | "tests: InstallerTests.clineHookFiles writes through the managed TaskComplete symlink and then expects that path to survive uninstall" |
|
||||
| 2026-10-15 | NOOK-418 | "security: a custom agent's install target is never shown to, or approved by, the user" |
|
||||
| 2026-10-17 | NOOK-568 | "helper: launch-time re-registration tears down an approved helper and leaves it unregistered" |
|
||||
| 2026-10-13 | NOOK-302 | "terminal: a tmux session shows only 'tmux', and its outer terminal comes from whoever started the tmux server" |
|
||||
| 2026-10-13 | NOOK-310 | "terminal: jump and send address tmux panes on the default server, ignoring the session's tmux socket" |
|
||||
| 2026-10-22 | NOOK-788 | "pane: a remote reply inside a private tmux server resolves to nothing, and a pane id can be sent without its socket" |
|
||||
| 2026-10-12 | NOOK-290 | "Compact island: weather slot clips mid-glyph on non-notch displays (no text cap)" |
|
||||
| 2026-10-12 | NOOK-291 | "Compact island: non-notch pill is 36 pt wider than its slot row (padding double-counted in pillWidth)" |
|
||||
| 2026-10-14 | NOOK-386 | "island: hide the empty pill under the notch (or above the top edge) and keep a narrower hover trigger" |
|
||||
| 2026-10-23 | NOOK-850 | "Nook: the idle app does work nothing asked for (CPU wins from a live profile)" |
|
||||
| 2026-10-23 | NOOK-851 | "island: a 30 fps timeline ticks the fan icon even when the fan is idle" |
|
||||
| 2026-10-23 | NOOK-877 | "island: compact Nook still costs too much CPU at rest (round two, A/B per fix)" |
|
||||
| 2026-10-23 | NOOK-881 | "A macOS 14.0/14.1 Mac cannot launch the app at all: dyld dies on a strongly linked CATapDescription overlay symbol" |
|
||||
| 2026-10-16 | NOOK-494 | "iOS: the main list gives no sign the phone is offline from the Mac" |
|
||||
| 2026-10-20 | NOOK-682 | "licensing: a new user can be blocked at launch instead of getting the 14-day trial" |
|
||||
| 2026-10-20 | NOOK-684 | "licensing: parseISO rejects the server's fractional-second trial timestamp, so an expired server trial resets to 14 days" |
|
||||
| 2026-10-08 | NOOK-60 | "Capture email at trial start" |
|
||||
|
||||
## Appendix B — frequency method
|
||||
|
||||
- **Live questions:** one per entry that quotes the live page. There are six, and they are the only
|
||||
questions on the page today.
|
||||
- **Board questions:** the two examples in FRE-326. They are not customer quotes; they are the
|
||||
board's own words for the purchase-blocking objections this issue was created to answer.
|
||||
- **Report counts:** the number of distinct NOOK issues I read whose subject or resolution directly
|
||||
produced the objection, listed by identifier and date in §2 and Appendix A. I did not use a raw
|
||||
keyword count: "agent" appears in 344 issues and "privacy" in 177, which says nothing about what
|
||||
buyers ask. The counts are deliberately small and inspectable.
|
||||
- **No ask frequency, no percentages.** The first thing a real support corpus changes is the order,
|
||||
not the wording.
|
||||
|
||||
---
|
||||
|
||||
*Draft ends. Nothing here is published. The page file stays unchanged until the sponsor approves this
|
||||
revision and a human merges it.*
|
||||
81
marketing/drafts/faq-page.patch
Normal file
81
marketing/drafts/faq-page.patch
Normal file
@@ -0,0 +1,81 @@
|
||||
diff --git a/src/routes/nook/faq.tsx b/src/routes/nook/faq.tsx
|
||||
index 03d135a..bc3adef 100644
|
||||
--- a/src/routes/nook/faq.tsx
|
||||
+++ b/src/routes/nook/faq.tsx
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* `/faq` on the Nook subdomain — answers to the objections that actually
|
||||
- * stop a purchase. Privacy scoping follows marketing/positioning.md rule 3:
|
||||
- * "no prompt or code content leaves the device" (telemetry and the trial
|
||||
- * email do leave — never claim "no data leaves the device").
|
||||
+ * stop a purchase. Privacy is deliberately absent until the egress
|
||||
+ * inventory and /privacy make an honest answer possible.
|
||||
*/
|
||||
import { For } from "solid-js";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
@@ -17,34 +16,46 @@ interface FaqEntry {
|
||||
|
||||
const FAQ: FaqEntry[] = [
|
||||
{
|
||||
- question: "Does The Nook read my prompts or my code?",
|
||||
+ question:
|
||||
+ "Does it work with my agent — and can I approve from the notch, or only watch?",
|
||||
answer:
|
||||
- "No. The hooks and plugins that connect your agents to The Nook send session metadata only — which agent is running, which session, which event happened, which terminal it lives in. No prompt text and no code content leaves your device. Two things do leave the device, and we'd rather say so plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start. That's the whole list."
|
||||
+ "It depends on the agent, and Cursor is the useful example. Cursor is watched, not gated: its sessions, finished turns and errors appear in the island, but when Cursor asks for permission the prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. One limit: cursor-agent -p one-shot runs send no turn end, so they appear but do not get a completion card."
|
||||
},
|
||||
{
|
||||
- question: "Do I need a Mac with a notch?",
|
||||
+ question: "What happens when my agent is idle?",
|
||||
answer:
|
||||
- "No. On a notched MacBook the island sits in the notch. On machines and displays without one, it anchors to the display your cursor is on, so a desktop setup with external monitors gets the same treatment. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
||||
+ "Idle means the last turn is over, or the session has not produced anything yet — not that the process is gone. A running process is liveness, not work: a just-opened agent window can show idle until you type. When an agent finishes a turn with a final message, the row stays green for ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot report for that session, and the row says so instead of pretending it is idle. Nothing is lost when a row goes idle — the next prompt brings it back and flips it to running. The row itself leaves the island 15 minutes after the turn (5 if the turn ended without a reply), so a long-quiet session reappears on its next event instead of sitting there. If a session that is obviously working says idle, that is a bug; tell us."
|
||||
},
|
||||
{
|
||||
- question: "How much battery does it cost?",
|
||||
+ question:
|
||||
+ "What permissions does it need — and what does it write into my agent's config?",
|
||||
+ answer:
|
||||
+ "The app itself runs unprivileged. Fan control and charge limiting go through a small helper that macOS requires to run as root for hardware access; the app registers it on first use and takes you to System Settings › General › Login Items & Extensions, where you switch it on once. For each agent it adds what that agent needs — a hook group per event it listens to, or one plugin link — inside the agent's own config file or directory. It keeps its own files under ~/.nook — the hook binary and one plugin copy per agent — and adds only its own entries to that agent's config. A hook entry you added yourself in the same file survives install and uninstall, and it only touches the records and switches it created. Uninstall removes exactly what it wrote — and switches off what it turned on — with no leftover links or orphaned files."
|
||||
+ },
|
||||
+ {
|
||||
+ question: "Does it work with my terminal, including tmux?",
|
||||
answer:
|
||||
- "The Nook is a small native UI, not a background agent — it watches the agents you're already running; it doesn't run anything itself. The heaviest thing it does is read and write your Mac's fan and charging controllers, which is a lightweight sensor read. If you're running enough coding agents to need it, those agents dwarf the app's own draw."
|
||||
+ "Yes — it recognises Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux. Four of those take a typed reply; the rest are watched. A session is tied to the terminal that started it. Jump lands in the right window for Terminal.app, iTerm2, kitty and any tmux pane — in tmux, the right pane; on Warp, WezTerm and Ghostty it brings the app forward instead. Replies type straight back into Terminal.app, iTerm2, kitty and any tmux pane; on Warp, WezTerm and Ghostty you answer in the app's own window, and Alacritty takes no typed reply from another app at all."
|
||||
},
|
||||
{
|
||||
- question: "What permissions does it need?",
|
||||
+ question: "Do I need a Mac with a notch?",
|
||||
+ answer:
|
||||
+ "No. On a notched MacBook the island sits in the notch. On machines and displays without one, it sits at the top centre of the display — the same pill with the same slots. You get one island on every display it is turned on for (all connected displays on first launch), each anchored to the top of its own screen; Settings › Displays trims that list, and the pointer only decides which display's floating controls are live. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
||||
+ },
|
||||
+ {
|
||||
+ question: "How much battery does it cost?",
|
||||
answer:
|
||||
- "The app itself runs unprivileged. Fan control and charge limiting go through a small helper component that macOS requires to run as root for hardware access — the app installs it on first use and you approve it once with your password. The hooks live in each agent's own config directory; you can inspect or remove every file it writes."
|
||||
+ "It is a native app, not a background agent. It watches the agents you already run and never runs them in the background, so their draw is the number that matters. We do not have a published battery measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs more than it should on your Mac, that is a bug — tell us."
|
||||
},
|
||||
{
|
||||
question: "Why macOS 14.2 or later?",
|
||||
answer:
|
||||
- "The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma)."
|
||||
+ "The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma). The app links against it, so 14.0 and 14.1 cannot launch it."
|
||||
},
|
||||
{
|
||||
question: "Does it work offline?",
|
||||
answer:
|
||||
- "Yes. Everything The Nook does — the agent grid, permission cards, fan and thermal control — works with no network at all, and your agents' hooks talk to the app over a local socket. The network is only used for update checks, license activation, and the optional trial email."
|
||||
+ "The Mac app does. The island, agent cards, permission cards and fan control work with no network; the hooks talk to the app over a local socket. The network is used for update checks, licence activation, crash reports and the optional trial email, plus a few things only when you use them: weather (asked for through our relay, with the location you chose), the network panel's IP-and-ISP lookup, the speed test, the changelog in Settings, and a bug report if you send one. The phone link is the exception: when you are away from your Mac, the phone reaches it through our relay, so that part needs a connection."
|
||||
}
|
||||
];
|
||||
|
||||
@@ -56,7 +67,7 @@ export default function NookFaqPage() {
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Frequently asked questions"
|
||||
- description="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14.2 requirement, offline use — answered straight."
|
||||
+ description="Which agents can you approve from the notch? What does idle mean? Terminal and tmux support, permissions, the macOS 14.2 requirement, offline use — answered straight."
|
||||
ogTitle="The Nook — FAQ"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
40
marketing/drafts/positioning-privacy-withdrawal.patch
Normal file
40
marketing/drafts/positioning-privacy-withdrawal.patch
Normal file
@@ -0,0 +1,40 @@
|
||||
diff --git a/marketing/positioning.md b/marketing/positioning.md
|
||||
index 1bc4973..26d6b04 100644
|
||||
--- a/marketing/positioning.md
|
||||
+++ b/marketing/positioning.md
|
||||
@@ -53,13 +53,20 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
|
||||
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
|
||||
If a claim cannot be pointed at code or a test, it does not go in copy.
|
||||
|
||||
-### 3. Honest privacy scoping
|
||||
+### 3. No privacy claim until the egress inventory exists (withdrawn 2026-10-29)
|
||||
|
||||
-Telemetry and the trial email **do** leave the device. The defensible claim is:
|
||||
+The claim that stood here — "No prompt or code content leaves the device" — is **false at HEAD**
|
||||
+and must not be restated in any copy until it is verified and replaced. It was backed by an
|
||||
+argument about envelope metadata, which says nothing about what leaves the machine. Three things
|
||||
+at HEAD contradict it: crash reporting initialises with `sendDefaultPii = true`; the licence call
|
||||
+sends the hardware UUID and the device name; the phone relay carries sealed prompt and reply
|
||||
+payloads. `/privacy` names the hardware UUID and the device name and says nothing about crash
|
||||
+reporting with PII or about the relay.
|
||||
|
||||
-> **No prompt or code content leaves the device.**
|
||||
-
|
||||
-Never "no data leaves the device".
|
||||
+So: write **no** privacy claim at all — not softened, not partial. "Telemetry and the trial email
|
||||
+do leave the device" is true and insufficient; it is not a substitute. The replacement goes in
|
||||
+only after FRE-333 lands an egress inventory and `/privacy` text that match what the app does.
|
||||
+See FRE-326 (the `/faq` withdrawal) and FRE-333 (the inventory).
|
||||
|
||||
### 4. Sell what works today
|
||||
|
||||
@@ -87,8 +94,7 @@ nothing posts itself.
|
||||
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
|
||||
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
|
||||
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
|
||||
-| macOS 14+ | 27 source files use the `@Observable` framework |
|
||||
-| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
|
||||
+| macOS 14.2+ | the CoreAudio overlay symbol the mixer uses only exists from 14.2 (NOOK-881) |
|
||||
|
||||
## Held claims (not publishable)
|
||||
|
||||
92
marketing/drafts/privacy-page.md
Normal file
92
marketing/drafts/privacy-page.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# `/privacy` — replacement copy, FRE-354 corrections applied 2026-11-04
|
||||
|
||||
**Status:** applied on branch `faq/from-objections-2026-10-29` (commit after `bb6cf27`), per the CTO's FRE-354 verdict — "apply the corrections above and the page is signed." The two copy decisions the verdict delegated to the CMO are taken: the **fuller iPhone paragraph** (its push-token evidence row is recorded in the evidence table below and routed to FRE-333's owner to append to the signed inventory), and the **full (c) block with the LAN/tailnet sentence**, which pulls the corresponding Section 3 clause in. Nothing here is published; the sponsor's merge + push on card [`392a8465`](/FRE/issues/FRE-326#interaction-392a8465) is still the deploy step.
|
||||
**Sequencing fact, updated 2026-11-06:** the contingency has **fired**. `v0.15.1` (tagged 2026-11-04, first tag containing FRE-355's merge `6e74b261`) and `v0.15.2` are live on the appcast (`freno.me/api/TheNook/appcast.xml`, newest item 0.15.2). Shipped behaviour when enabled: crash/error reports, session pings (`enableAutoSessionTracking` untouched), traces at 0.1, and **Sentry also receives the connection's IP address** (`sendDefaultPii = false` is the SDK default and does not suppress it; only the Sentry project's *Prevent Storing of IP Addresses* switch does — FRE-1480). Paragraph (b) and the iPhone paragraph are therefore swapped to the opt-in form in this commit, with two corrections against the pre-written swap text: pings and traces are named (the contingency's own completeness warning), and the IP sentence is kept. Releases 0.3.0 through 0.15.0 started Sentry on every launch with no off switch; the page describes the current release.
|
||||
**Patch:** `marketing/drafts/privacy-page.patch` — applies cleanly to `c1c498e` (`git apply --check`, verified 2026-10-29; parse-checked with esbuild, not the site suite).
|
||||
**Evidence base:** [FRE-333 `egress-inventory`](/FRE/issues/FRE-333#document-egress-inventory) rev 1, eleven destinations, file:line per row, read at `The-Nook` `3c3ba7ee`; and the same document's §G, which proposed a page text. This patch is the §G text **corrected against the inventory** — the differences are listed below, each with the row that forces it.
|
||||
**Why this exists:** [FRE-326](/FRE/issues/FRE-326) + [FRE-334](/FRE/issues/FRE-334#document-decision) recorded option A — the privacy claim is withdrawn everywhere, and no privacy claim returns until the inventory exists, `/privacy` matches it, the CTO verifies both, and the sponsor approves the replacement copy. The inventory landed on 2026-10-29. This is the page half.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Claim this page carries | None. It is a disclosure page, not a claim: it states what the app sends and what it does not. The one favourable sentence is (j), which is the CTO's FRE-322 §2 code fact and which he may still decline. |
|
||||
| Audience | Someone deciding whether to pay $10 for a tool that reads their prompts. They read this page *before* the FAQ. |
|
||||
| Channel | `nook.freno.me/privacy`, one page |
|
||||
| Owner | CMO (copy + commit), CTO (verification), sponsor (approval of the replacement copy), Chief of Staff (publish switch) |
|
||||
| Date | Prepared 2026-10-29. Applied 2026-11-04 on the branch with the FRE-354 corrections; the contingency (b)/iPhone swap applied 2026-11-06. `Last Updated` on the page now reads November 6, 2026 — re-check it against the actual deploy date at push. Not a public date. |
|
||||
| What has to be true before it ships | 1) CTO signs the page against `The-Nook` HEAD, paragraph by paragraph — [FRE-354](/FRE/issues/FRE-354), due 2026-11-02. 2) Sponsor accepts the replacement copy. 3) The Sentry paragraph matches the build being deployed — see *Contingency*. 4) It deploys in the same push as the FAQ withdrawal; a page that discloses Sentry next to a FAQ that still says "anonymous usage telemetry" is worse than either alone. |
|
||||
|
||||
## What changed against the live page
|
||||
|
||||
The live page (fetched 2026-10-29T09:2xZ and again 09:4xZ, HTTP 200, `Last Updated: August 26, 2026`, unchanged) says the app "runs locally on your device and sends only the minimal data required for trial and licensing" and lists four things. Against the inventory that is wrong in two directions: it **omits** Sentry (crash reports with IP address, every launch, no off switch), the relay and its install ID, the licence key crossing to the relay, sealed phone payloads, weather coordinates going to the relay *and* to Apple, the GeoIP third parties, the speed test, the changelog fetch, bug-report contents and update checks. It also calls the hardware UUID "masked", which the code contradicts — it sends the raw `IOPlatformUUID`.
|
||||
|
||||
The replacement is organised by *connection*, not by *data type*, because that is how a reader checks it: eleven rows in the inventory, ten of them reachable from the page, one (Sentry) prominent because it is the only one a reader can turn off entirely and still miss in older builds. On the current Mac release (`v0.15.1`+) it is opt-in and off by default; the iPhone build in TestFlight (`ios-v0.3.2`, 2026-10-22) predates it. See *Contingency*.
|
||||
|
||||
## Differences from FRE-333 §G, and what forces each
|
||||
|
||||
§G is the Security Engineer's proposed text; this patch is that text corrected against §G's own inventory. Six deliberate differences, none of them stylistic:
|
||||
|
||||
| # | §G says | This patch says | Why |
|
||||
|---|---|---|---|
|
||||
| 1 | Opening: "processed on your device and are never sent to us" | Opening drops "never sent to us"; (c) and (j) state the two paths agent data does take | Sealed frames still transit our relay (§A.5, §B), and a remote machine carries them by design (§C). §G's own §B says the relay routes them. "Never sent to us" is the class of sentence that got the FAQ answer withdrawn. |
|
||||
| 2 | (d) Weather: relay registration implied only with Phone | (d) adds that Weather registers **even with Phone off, and on a licensed Mac that registration presents the license key** | §A.3 + §D.2. `SidecarController.weather()` routes through the same `keystore.relayCredential(licenseKey:)` as the phone path (`SidecarController.swift:269-279`, `SidecarRelayAPI.swift:298-312`). §G under-disclosed it. |
|
||||
| 3 | (a) trial/activate/status only | (a) adds `/deactivate` (key + UUID) and the trial email (email + UUID, sent once) | §A.2, `/D.4`. §G omitted both endpoints. |
|
||||
| 4 | (i) "The email you give at trial start or checkout delivers your license key" | (i) checkout delivers the key; a trial email is one reminder before the trial ends | `LicenseService.submitTrialEmail` + `OnboardingExtrasStep` say the trial email is an expiry reminder; the key is issued at purchase. §G's sentence promised a key that the trial email does not deliver. |
|
||||
| 5 | (b) Sentry: opt-in, "off unless you turn it on" | (b) is the opt-in form, applied 2026-11-06: off unless enabled in Settings, and it names session pings and the trace sample; **the IP sentence, kept** | The contingency fired with `v0.15.1`. Corrections against the pre-written swap text: pings/traces named because they still flow when enabled; IP kept because `sendDefaultPii = false` does not stop Sentry receiving the connection IP (FRE-1480); the Contingency text already said so. |
|
||||
| 6 | No (j) | (j) carries the CTO's FRE-322 §2 code fact, with the remote-machine exception | Not required for truth; it is the one favourable sentence, and the CTO may decline it. Delete the block if he does. |
|
||||
|
||||
Two facts the inventory corrected and the page does not repeat wrongly: Sparkle's automatic checks are **off by default** here (`UpdateManager` owns the schedule; FRE-322 §2.4 said "on by default" and is stale on this row), and the UUID is **raw, not masked**.
|
||||
|
||||
## Paragraph-level evidence
|
||||
|
||||
| Paragraph | What it now says | Evidence at `3c3ba7ee` |
|
||||
|---|---|---|
|
||||
| (a) Licensing/trial | raw UUID + device name; every launch while trialing; key-only once licensed; deactivate sends key + UUID; trial email sends email + UUID once; trial/status fail open, activation needs a connection once | `LicenseService.swift:300-313` (`/trial`), `:271-284` (`/status`), `:247-262` (`/activate`), `:286-302` (`/deactivate`), `:219-231` (`/trial-email`) |
|
||||
| (b) Crash reporting | opt-in, **off by default**; when enabled: crash/error reports, session pings, traces at 0.1; **Sentry also receives the IP address** (`Options.swift:267-275` at 9.26.1: `sendDefaultPii = false` is the default and does not suppress it); no prompts or code | `Sources/NookApp/main.swift` `startIfEnabled` — `sendDefaultPii = false` (`policy.sendDefaultPii`), `tracesSampleRate = 0.1` while on (`CrashReporting.onTracesSampleRate`); `CrashReporting.registerDefaults` pins `enabledKey: false`; evidence read 2026-11-06, IP row per FRE-1480 |
|
||||
| (c) Phone relay | off by default; relay is the default path, LAN/tailnet direct pairing available; install ID + license key at registration; sealed payloads; relay sees device IDs/names/platforms, timing and volume, not content | `SidecarRelayAPI.swift:144-153`, `SidecarPayload.swift:484-490`, `RemoteEndpoint.swift:14-24`, `docs/sidecar-frames.md` (sealing), inventory §B |
|
||||
| (d) Weather | 0.1° grid snapped **on the Mac**; registers with the relay even with the phone off, licence key included on a licensed Mac; Apple sees place lookups and position | `WeatherForecast.swift:25-30`, `SidecarController.swift:269-279`, `SidecarRelayAPI.swift:298-312`, `WeatherLocation.swift:14-77`, `:89-143` |
|
||||
| (e) Network panel | third-party IP lookup sees your IP; speed test uploads zeros only | `NetworkInfoService.swift:82,276`, `SpeedTest.swift:10-11,36` |
|
||||
| (f) Update checks | ship **off**; turning them on fetches `freno.me`, app version + IP | `UpdateManager.swift:48-55` (`automaticallyChecksForUpdates = false`, default key false) |
|
||||
| (g) Changelog | fetches release notes from `freno.me` | `ChangelogRelease.swift:75-78` |
|
||||
| (h) Bug reports | user-sent only; machine snapshot listed in full | `BugReportClient.swift:44-94`, `BugReportSnapshot.swift:17-36` |
|
||||
| (i) Email/payment | checkout email delivers the key; trial email is a reminder; Stripe | `LicenseService.swift:219-231`, `OnboardingExtrasStep.swift:86-110` |
|
||||
| (j) What does not leave your Mac | hook and bridge are not a cloud service; helper has no networking; two paths carry agent data across the network, both user-switched: remote machines over SSH or the tailnet, and the sealed phone relay | `hook/transport.go:143` (`resolveBridge`), `BridgeBindPlan.swift:9-23`, `RemoteEndpoint.swift:14-24`, inventory §B + §C |
|
||||
| iPhone companion (TestFlight beta) | the TestFlight build (`ios-v0.3.2` = `2e322da5`, 2026-10-22) predates FRE-355: unconditional start, `sendDefaultPii = true`, traces 1.0, no off switch (`ios/AgentNookMobile/App.swift:13-20`); the opt-in code exists at `v0.15.2` but no `ios-v*` tag carries it; other traffic is the phone connection in (c) plus the APNs push token given to the relay | `RelayLink.pair` `RelayLink.swift:104-118`, `updatePushToken` `:226-232` — row added per FRE-354, TestFlight provenance per FRE-1480; append to the FRE-333 inventory |
|
||||
|
||||
## Contingency — the Sentry paragraph is the only one that moves
|
||||
|
||||
**FIRED 2026-11-06 — applied on the branch.** `v0.15.1` is the first release containing FRE-355 and is live on the appcast. The swap below was applied with the corrections recorded in the sequencing paragraph at the top of this document (pings and traces named; IP sentence kept — the pre-written text below had it, and dropping it was wrong; FRE-1480). The original text is kept for the record:
|
||||
|
||||
(b) as committed describes today's build. The inventory's recommendation (§E.1) is an opt-in setting, default off, dropping `sendDefaultPii` and cutting `tracesSampleRate`, on Mac **and** iOS. If that ships, (b) is replaced in the same commit that changes the behaviour:
|
||||
|
||||
> **(b) Crash and error reporting — off unless you turn it on.** If you enable crash reporting in Settings, The Nook sends crash and error reports to Sentry, our crash-reporting processor. Reports include the app version, macOS version, Mac model and what the app was doing when it failed, and Sentry also receives your IP address. They never include your prompts or code.
|
||||
|
||||
**Sequence call, and who owns it.** Shipping (b)-with-no-off-switch is honest, but it puts "no off switch" in front of a buyer during launch week. The better order is the build change first, then this page — that is a build/priority call, not mine: it is proposed on the board as [FRE-355](/FRE/issues/FRE-355) (Senior Engineer, date proposed 2026-11-06, CTO to confirm), and if the CTO says the build change cannot land before the page, the page ships with today's text. Publishing the truthful-today version is never *blocked* by that choice; the *preferred* order is the other way.
|
||||
|
||||
**This is why (b) must not be softened on the way out.** If the opt-in setting ships but `tracesSampleRate` or session tracking stays on, (b)'s opt-in form is incomplete again — it names only "crash and error reports" while performance traces and session pings still flow. The paragraph and the build change move together or neither does. (FRE-354 rewrote this warning: the original named a macOS username that sentry-cocoa never sends.)
|
||||
|
||||
## If the CTO declines paragraph (j)
|
||||
|
||||
(j) is the only favourable sentence on the page and the only one carrying a CTO verdict rather than an inventory row. It is [FRE-322 §2](/FRE/issues/FRE-322#document-verdict) — *"verified as a code fact, not publishable yet… publishing it before the disclosure work lands would put a true sentence next to false ones."* The disclosure work is this page. If the CTO still declines it, delete the whole (j) block; nothing else on the page depends on it, and the page stays complete and true.
|
||||
|
||||
## Open questions for the CTO's read
|
||||
|
||||
1. **The iOS app.** §D.10: the iOS target starts the same Sentry DSN with the same `sendDefaultPii` (`ios/AgentNookMobile/App.swift:12-20`). This page says "runs on your Mac". Is any iOS build in users' hands? If yes, the page needs one sentence (or the build needs the same opt-in); if no, say so and the page stays Mac-only.
|
||||
2. **Weather registration without a licence key.** §E.3 suggests decoupling weather registration from the licence key. If that ships before the page, (d) loses its licence-key clause; if after, the page is true today.
|
||||
3. **(j)** — publish, or cut.
|
||||
|
||||
## Apply checklist — what the apply commit must do, in order
|
||||
|
||||
1. Re-read the live page and the inventory's HEAD; if either moved, re-derive (a)–(j) before applying. The inventory is pinned to `3c3ba7ee`.
|
||||
2. Bump `Last Updated` to the **deploy** date — the patch carries 2026-10-29 as prepared, and that line is the one place the date must change at apply.
|
||||
3. If the Sentry opt-in build change is in the same release, swap (b) for the *Contingency* paragraph — one hunk.
|
||||
4. Apply `privacy-page.patch` and the FRE-326 `faq-page.patch` + `positioning-privacy-withdrawal.patch` in the **same** commit, so no page states the old claim while another corrects it.
|
||||
5. Run the site's suite — `bun test` — which this host cannot do (no `bun`; `node_modules` present, `package.json` test script is `bun test`). A parse/transpile check with esbuild is **not** that suite and must not be recorded as one.
|
||||
|
||||
## Rules this page obeys
|
||||
|
||||
- No competitor named (category nouns only, and none needed here).
|
||||
- No claim without a row in the inventory. Where the inventory has no row, the page says nothing.
|
||||
- No roadmap promise: the page never says a feature or fix is "coming", so the Sentry paragraph does not promise an off switch.
|
||||
- No price, no discount, no date, no launch language.
|
||||
- It is a correction of an existing page, not new marketing; nothing was added to sell anything.
|
||||
- It does not add a `/faq` privacy answer. That answer's return is gated separately ([FRE-326](/FRE/issues/FRE-326) `privacy-return-path`).
|
||||
237
marketing/drafts/privacy-page.patch
Normal file
237
marketing/drafts/privacy-page.patch
Normal file
@@ -0,0 +1,237 @@
|
||||
diff --git a/src/routes/nook/privacy.tsx b/src/routes/nook/privacy.tsx
|
||||
index da0cc1d..db27d8c 100644
|
||||
--- a/src/routes/nook/privacy.tsx
|
||||
+++ b/src/routes/nook/privacy.tsx
|
||||
@@ -1,9 +1,38 @@
|
||||
/**
|
||||
* The Nook privacy policy — `nook.freno.me/privacy`.
|
||||
*
|
||||
- * Reflects the actual data The Nook sends when licensing: a masked hardware
|
||||
- * UUID + device name for trial/activation limits, the email used for purchase,
|
||||
- * and Stripe payment processing.
|
||||
+ * Every statement on this page is traced to the egress inventory at
|
||||
+ * `The-Nook` HEAD (FRE-333, `document-egress-inventory`): eleven internet
|
||||
+ * destinations, file:line evidence per row. When the app's egress changes,
|
||||
+ * this page changes in the same commit — not later.
|
||||
+ *
|
||||
+ * The crash-reporting paragraphs ((b) and the iPhone companion) describe the
|
||||
+ * build users run today: crash reporting is opt-in, off by default, on
|
||||
+ * releases v0.15.1 and later — FRE-355 (merge `6e74b261`) first shipped in
|
||||
+ * `v0.15.1` (2026-11-04), and the live appcast serves `v0.15.2`. When enabled,
|
||||
+ * Sentry receives crash and error reports, session pings, and performance
|
||||
+ * traces at a 0.1 sample rate (`tracesSampleRate = 0.1`), and it also receives
|
||||
+ * the connection's IP address (`sendDefaultPii = false` is the SDK default and
|
||||
+ * does not suppress it — sentry-cocoa 9.26.1 sets `infer_ip = auto` for Cocoa,
|
||||
+ * and only the Sentry project's *Prevent Storing of IP Addresses* switch stops
|
||||
+ * that; FRE-1480). Releases 0.3.0 through 0.15.0 started Sentry on every
|
||||
+ * launch with `sendDefaultPii = true` and no off switch; this page describes
|
||||
+ * the current release. The iPhone paragraph describes `ios-v0.3.2`
|
||||
+ * (2026-10-22, the build in TestFlight) until an `ios-v*` tag carries
|
||||
+ * FRE-355's opt-in code.
|
||||
+ *
|
||||
+ * The *Contingency* in `marketing/drafts/privacy-page.md` fired with `v0.15.1`
|
||||
+ * and is applied in this commit, with two corrections against the pre-written
|
||||
+ * swap text: the shipped build still sends session pings and traces while
|
||||
+ * enabled, so they are named (the contingency's own completeness warning), and
|
||||
+ * the IP sentence is kept — the pre-written swap text already had it, and
|
||||
+ * `sendDefaultPii = false` does not stop Sentry receiving the IP (FRE-1480).
|
||||
+ *
|
||||
+ * Corrected against the CTO's /privacy verdict (FRE-354): no macOS username
|
||||
+ * from Sentry (sendDefaultPii sets the IP only), session pings named, (a)
|
||||
+ * fail-open scoped, (c) relay-path qualified with the direct-pair sentence,
|
||||
+ * (j) "not a cloud service" with the tailnet path, the iPhone paragraph added
|
||||
+ * (TestFlight beta is in users' hands), and section 3 says "content".
|
||||
*/
|
||||
import { buildMainSiteUrl } from "~/lib/subdomain-url";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
@@ -21,39 +50,139 @@ export default function NookPrivacyPolicy() {
|
||||
<SubdomainHeader />
|
||||
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||
<div class="py-4 text-xl">The Nook's Privacy Policy</div>
|
||||
- <div class="py-2">Last Updated: August 26, 2026</div>
|
||||
+ <div class="py-2">Last Updated: November 6, 2026</div>
|
||||
<div class="py-2">
|
||||
- Welcome to The Nook ('We', 'Us', 'Our').
|
||||
- Your privacy is important to us. This policy explains what we collect,
|
||||
- why, and how it is handled. The app itself runs locally on your device
|
||||
- and sends only the minimal data required for trial and licensing.
|
||||
+ The Nook runs on your Mac, and the iPhone and Apple Watch companion
|
||||
+ is in TestFlight beta. Your prompts, your code and your agent
|
||||
+ sessions are processed on your own devices. This page lists every
|
||||
+ connection the apps make over the internet — and the local-network
|
||||
+ and tailnet paths the phone can use — what each one sends, and
|
||||
+ whether you can turn it off.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
- <span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||
+ <span class="-ml-4 pr-2">1.</span> What leaves your devices
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
- <div class="-ml-6">(a) Device hardware UUID:</div> When you start
|
||||
- a trial or activate a license, The Nook sends a hardware UUID
|
||||
- from your Mac. It is used solely to enforce the 14-day trial and
|
||||
- the 3-device activation limit, and to prevent resetting the
|
||||
- trial by reinstalling the app.
|
||||
+ <div class="-ml-6">(a) Licensing and trial:</div> When you start
|
||||
+ a trial or activate a license, The Nook sends your Mac's
|
||||
+ hardware UUID and device name to our licensing server
|
||||
+ (freno.me). The UUID enforces the 14-day trial and the 3-device
|
||||
+ activation limit and stops a trial being reset by reinstalling
|
||||
+ the app; the device name is stored with the activation so the
|
||||
+ Mac can be identified in your license records. While you are on
|
||||
+ the trial, this is sent at each launch. Once you are licensed,
|
||||
+ each launch re-checks only that your key has not been revoked.
|
||||
+ Deactivating a Mac sends the license key and that Mac's
|
||||
+ UUID. If you add an email during the trial, that request
|
||||
+ carries the email and the UUID, and it is sent once. The trial
|
||||
+ and status checks fail open: with no connection, an unexpired
|
||||
+ trial or a stored license keeps working, because license
|
||||
+ verification itself is offline. Activating a new license needs
|
||||
+ a connection once.
|
||||
+ </div>
|
||||
+ <div class="pb-2">
|
||||
+ <div class="-ml-6">(b) Crash and error reporting — off unless
|
||||
+ you turn it on:</div> Crash reporting is off by default and
|
||||
+ runs only if you enable it in Settings. When it is on, The Nook
|
||||
+ sends crash reports, error events, hang reports, session pings,
|
||||
+ and a sample of performance traces to Sentry, our
|
||||
+ crash-reporting processor. Each report carries the app version,
|
||||
+ the macOS version, the Mac model and what the app was doing
|
||||
+ when it failed. Sentry also receives your IP address. They
|
||||
+ never include your prompts, your code or your session content.
|
||||
+ </div>
|
||||
+ <div class="pb-2">
|
||||
+ <div class="-ml-6">(c) Phone connection:</div> The phone
|
||||
+ connection ships off and is yours to enable. With the default
|
||||
+ relay path, turning it on registers your Mac with our relay
|
||||
+ (nook-relay.freno.me) using a random install ID, and a licensed
|
||||
+ Mac presents its license key once at registration. Everything
|
||||
+ your Mac and your phone then exchange — session updates,
|
||||
+ approvals, replies — is encrypted end-to-end: the relay routes
|
||||
+ it and cannot read it. On the relay path, the relay can see
|
||||
+ which devices are connected — their IDs, names and platforms —
|
||||
+ when, and how much traffic they exchange.
|
||||
+ </div>
|
||||
+ <div class="pb-2">
|
||||
+ You can also pair over your LAN or tailnet instead; those paths
|
||||
+ carry the same sealed traffic and do not use the relay.
|
||||
+ </div>
|
||||
+ <div class="pb-2">
|
||||
+ <div class="-ml-6">(d) Weather:</div> If you add the Weather
|
||||
+ module, your Mac asks our relay for forecasts using coordinates
|
||||
+ rounded to a 0.1° grid — about 11 km — and the rounding happens
|
||||
+ on your Mac before anything is sent. The Weather module
|
||||
+ registers with the relay for this even if you never turn on the
|
||||
+ phone connection, and on a licensed Mac that registration
|
||||
+ presents the license key, exactly as the phone connection's
|
||||
+ does. "My Location" asks macOS for your
|
||||
+ approximate position, and naming or searching for a place uses
|
||||
+ Apple's Maps service, so Apple sees those lookups. Neither
|
||||
+ feature sends us your precise location.
|
||||
+ </div>
|
||||
+ <div class="pb-2">
|
||||
+ <div class="-ml-6">(e) The Network panel:</div> While the
|
||||
+ Network panel's connection section is on screen, The Nook
|
||||
+ asks a third-party IP-lookup service (get.geojs.io, with
|
||||
+ ipwho.is as backup) what your public IP address and provider
|
||||
+ are — that is how the panel can show them to you. Those services
|
||||
+ necessarily see your IP address. The speed-test button measures
|
||||
+ against Cloudflare's public speed endpoint and uploads only
|
||||
+ zeros, never your data.
|
||||
+ </div>
|
||||
+ <div class="pb-2">
|
||||
+ <div class="-ml-6">(f) Update checks:</div> Automatic update
|
||||
+ checks ship off. If you turn them on, or press "Check for
|
||||
+ Updates", The Nook fetches the update feed from freno.me
|
||||
+ and the request carries your app version. Like every request on
|
||||
+ this page, it also arrives from your IP address.
|
||||
+ </div>
|
||||
+ <div class="pb-2">
|
||||
+ <div class="-ml-6">(g) Changelog:</div> Opening Settings ›
|
||||
+ Changelog fetches the release notes from freno.me.
|
||||
+ </div>
|
||||
+ <div class="pb-2">
|
||||
+ <div class="-ml-6">(h) Bug reports:</div> only what you choose
|
||||
+ to send. If you write a bug report, you decide what it says.
|
||||
+ Bug reports also include a machine snapshot — macOS version, Mac
|
||||
+ model, CPU, memory, free disk, locale, and your displays'
|
||||
+ names and resolutions — so the problem can be reproduced. The
|
||||
+ optional contact email is used only to reply to you.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
- <div class="-ml-6">(b) Device name:</div> The Nook sends your
|
||||
- Mac's name so you can recognize the device in your license
|
||||
- activations. It is not used for any other purpose.
|
||||
+ <div class="-ml-6">(i) Your email and payment:</div> The email
|
||||
+ you give at checkout delivers your license key and looks up your
|
||||
+ order. An email you add during the trial is used for one
|
||||
+ reminder before the trial ends. Payments are processed by
|
||||
+ Stripe; we never see or store your card details.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
- <div class="-ml-6">(c) Email:</div> The email you provide at
|
||||
- checkout is used to deliver your license key and to look up your
|
||||
- order.
|
||||
+ <div class="-ml-6">(j) What does not leave your Mac:</div> The
|
||||
+ hook and the bridge that connect your agents to The Nook are
|
||||
+ not a cloud service: the hook dials the bridge on your Mac, and
|
||||
+ the bridge only listens. Two paths carry agent data across the
|
||||
+ network, and both are switched on by you: if you add a remote
|
||||
+ machine, the hook on that machine dials this Mac over SSH or
|
||||
+ your tailnet; and if you use the phone connection, the session
|
||||
+ updates, approvals and replies described in (c) travel through
|
||||
+ the relay, sealed end to end. The privileged helper that
|
||||
+ controls fans and charging has no networking code at all.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
- <div class="-ml-6">(d) Payment:</div> Payments are processed by
|
||||
- Stripe. We never see or store your card details.
|
||||
+ <div class="-ml-6">The iPhone companion.</div> The iPhone and
|
||||
+ Apple Watch app is in TestFlight beta. The build in TestFlight
|
||||
+ starts Sentry on every launch, with no setting that turns it
|
||||
+ off: it sends crash, error and hang reports, session pings,
|
||||
+ performance traces, the app version, the iOS version, the
|
||||
+ iPhone model and your IP address. Those reports never include
|
||||
+ your prompts, code or session content.
|
||||
+ Its other network traffic is the phone connection in (c) — the
|
||||
+ relay, or a direct LAN/tailnet link — plus, if you allow
|
||||
+ notifications, the push token it gives the relay so Apple can
|
||||
+ deliver them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,9 +192,11 @@ export default function NookPrivacyPolicy() {
|
||||
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
- Device identifiers, your email, and license records are used only
|
||||
- to operate the trial, deliver purchases, and enforce the 3-device
|
||||
- license. We do not use them for advertising or sell them to anyone.
|
||||
+ Device identifiers, install IDs, your email and license records are
|
||||
+ used only to operate the trial and the licenses, to run the relay
|
||||
+ and the weather service, to answer bug reports, and to deliver
|
||||
+ purchases. We do not use them for advertising and we do not sell
|
||||
+ them to anyone.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,9 +205,13 @@ export default function NookPrivacyPolicy() {
|
||||
<span class="-ml-4 pr-2">3.</span> Data Security
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
- License keys are signed and verified with Ed25519. The connection
|
||||
- to our servers uses TLS. Your license key is stored in the macOS
|
||||
- Keychain on your Mac.
|
||||
+ License keys are signed and verified with Ed25519 and stored in
|
||||
+ the macOS Keychain. The session content your Mac and phone
|
||||
+ exchange is encrypted end-to-end with keys your devices generate
|
||||
+ at pairing; our relay holds no key that can read it, though it
|
||||
+ can read the routing metadata described in (c). Every internet
|
||||
+ connection described on this page uses TLS; the direct
|
||||
+ LAN/tailnet paths use the same end-to-end encryption.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
76
marketing/drafts/remote-ssh/copy.md
Normal file
76
marketing/drafts/remote-ssh/copy.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Remote SSH copy — HELD, do not publish
|
||||
|
||||
**Status:** removed from the live site on 2026-10-29 in the FRE-323 correction.
|
||||
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
||||
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
||||
verifies that flow, and a recording shows a remote agent joining the island.
|
||||
Until then this file is the only home for these sentences.
|
||||
|
||||
## What was removed
|
||||
|
||||
### 1. Landing collage tile
|
||||
|
||||
Source: `src/components/nook/FeatureCollage.tsx` (removed 2026-10-29).
|
||||
|
||||
```
|
||||
kicker: Remote
|
||||
title: Agents on other machines
|
||||
body: Run agents on Linux boxes and SSH servers — one click installs the
|
||||
nook-hook, and their sessions join the same island.
|
||||
```
|
||||
|
||||
The mock attached to it showed two session rows whose agent chips named
|
||||
`build-box` and `gpu-rig`.
|
||||
|
||||
### 2. Landing feature breakdown section
|
||||
|
||||
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
|
||||
|
||||
```
|
||||
kicker: Remote
|
||||
title: Any SSH box, one click away
|
||||
body: Pick a host from your ssh config or your tailnet and click once — the
|
||||
nook-hook installs itself and its agents appear beside your local ones,
|
||||
indistinguishable.
|
||||
|
||||
bullets:
|
||||
- One click: connect, push the hook, configure agents, check it can reach this Mac
|
||||
- Hosts mirror this Mac's installed integrations automatically
|
||||
- Remote sessions are ordinary rows; the agent chip names the machine
|
||||
```
|
||||
|
||||
The section's demo animated a `gpu-rig` host moving from "Not set up" through
|
||||
`connecting` → `pushing the hook binary` → `configuring agents` → `checking the
|
||||
host can reach this Mac` → `Live`, then showed a remote session row.
|
||||
|
||||
### 3. Approvals bullet
|
||||
|
||||
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
|
||||
|
||||
```
|
||||
- Works across every hooked agent, local or remote
|
||||
```
|
||||
|
||||
### 4. Feature chip
|
||||
|
||||
Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
|
||||
|
||||
```
|
||||
One-click SSH setup
|
||||
```
|
||||
|
||||
## What replaced it
|
||||
|
||||
Nothing. The Remote tile and section were removed rather than replaced; the
|
||||
existing Approvals cell already carries the verified local claim:
|
||||
|
||||
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
|
||||
|
||||
## Restore checklist
|
||||
|
||||
1. FRE-320/FRE-205 have shipped the one-click install and removed the
|
||||
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
|
||||
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
|
||||
3. A recording exists showing a remote agent joining the island.
|
||||
4. CMO restores the copy above, then owns the launch plan for it — with an
|
||||
owner and date per item — before anything goes back on the site.
|
||||
28
marketing/drafts/remote-ssh/rationale.md
Normal file
28
marketing/drafts/remote-ssh/rationale.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Why the remote claim is parked
|
||||
|
||||
The site was selling a setup path a buyer does not get.
|
||||
|
||||
FRE-320 measured today's actual path (2026-10-29): the one-click flow is
|
||||
app-UI-only, needs a hand-edited `~/.ssh/config` for any host not already in ssh
|
||||
config or on the tailnet, needs a one-time `ssh-copy-id` because the app runs ssh
|
||||
with `BatchMode=yes`, and has no manual fallback for minting the per-host
|
||||
credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
|
||||
unverified and demo-gated. The CTO named it the largest launch risk because it
|
||||
was already in front of buyers.
|
||||
|
||||
So the landing page now says only what the product can back today: local agents,
|
||||
the card, and the notch. The remote story is not deleted — it is parked here with
|
||||
the FRE-320/FRE-205 work so it can return the day the one-click install ships and
|
||||
is recorded.
|
||||
|
||||
## What has to be true before it returns
|
||||
|
||||
| Requirement | Owner | Date |
|
||||
|---|---|---|
|
||||
| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships |
|
||||
| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves |
|
||||
| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore |
|
||||
| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true |
|
||||
|
||||
Nothing in this folder is publishable. `marketing/approved/` remains the only
|
||||
place a human publishes from.
|
||||
@@ -9,7 +9,7 @@ Primary source: `~/Code/the-nook/docs/marketing-strategy.md`.
|
||||
## The sentence
|
||||
|
||||
> **Your Mac and your agents, under control.** The Nook puts your whole coding-agent
|
||||
> fleet in the notch — twelve agents, local or over SSH — so you see what is running,
|
||||
> fleet in the notch — thirteen agents — so you see what is running,
|
||||
> unblock it in one tap, and keep the machine from overheating while it works.
|
||||
|
||||
## The wedge
|
||||
@@ -50,7 +50,7 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
|
||||
|
||||
### 2. A claim requires a test to exist
|
||||
|
||||
Copy that says "12 agents" or "no data leaves the device" must have a test backing it.
|
||||
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
|
||||
If a claim cannot be pointed at code or a test, it does not go in copy.
|
||||
|
||||
### 3. Honest privacy scoping
|
||||
@@ -64,8 +64,14 @@ Never "no data leaves the device".
|
||||
### 4. Sell what works today
|
||||
|
||||
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)"
|
||||
parentheticals. The launch story is remote SSH: agents running on other machines,
|
||||
visible and unblockable from the Mac's notch.
|
||||
parentheticals. The launch story is the approval card: an agent blocked on a
|
||||
permission prompt, unblocked from the Mac's notch, and work resumes.
|
||||
|
||||
Remote SSH is **held**, not approved. The one-click install has not shipped and
|
||||
has not been recorded (FRE-320, FRE-205), and the current path needs hand-edited
|
||||
`~/.ssh/config` and a one-time `ssh-copy-id` (FRE-320). Its copy is parked in
|
||||
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow and a
|
||||
demo exists.
|
||||
|
||||
### 5. Humans hold every account
|
||||
|
||||
@@ -77,17 +83,22 @@ nothing posts itself.
|
||||
|
||||
| Claim | Backing |
|
||||
|---|---|
|
||||
| Twelve hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, hermes, openclaw, zai, gemini, cline, qwen, deepseek |
|
||||
| Remote agents over SSH | `hook/setup.go` remote install path; `NOOK_HOST` TCP transport |
|
||||
| Thirteen hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, muse, hermes, openclaw, zai, gemini, cline, qwen, deepseek |
|
||||
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
|
||||
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
|
||||
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
|
||||
| macOS 14+ | 27 source files use the `@Observable` framework |
|
||||
| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
|
||||
|
||||
## Held claims (not publishable)
|
||||
|
||||
| Claim | Why it is held | What has to be true first |
|
||||
|---|---|---|
|
||||
| Remote agents over SSH, one click to install | The one-click path is app-UI-only and needs hand-edited `~/.ssh/config` plus a one-time `ssh-copy-id`; no demo exists (FRE-320). FRE-315 and FRE-322 hold it unverified and demo-gated. | FRE-320/FRE-205 ship a genuinely one-click install, the CTO verifies it, and a recording shows a remote agent joining the island. Copy parked in `marketing/drafts/remote-ssh/`. |
|
||||
|
||||
## Voice
|
||||
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives ("twelve agents",
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives ("thirteen agents",
|
||||
"one tap", "$10 once").
|
||||
- The notch/island is the product's home; agents are the product's point.
|
||||
- Never apologize for being a utility. The utility is the point.
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.953.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
||||
"@clerk/backend": "^3.12.0",
|
||||
"@libsql/client": "^0.15.15",
|
||||
"@sentry/solidstart": "^10.67.0",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
@@ -55,7 +54,6 @@
|
||||
"motion": "^12.23.26",
|
||||
"solid-js": "^1.9.5",
|
||||
"solid-tiptap": "^0.8.0",
|
||||
"svix": "^1.98.0",
|
||||
"ua-parser-js": "^2.0.7",
|
||||
"uuid": "^13.0.0",
|
||||
"vinxi": "^0.5.7",
|
||||
|
||||
4
public/nook/brands/claude.svg
Normal file
4
public/nook/brands/claude.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Official Claude mark — Anthropic favicon, https://claude.ai/favicon.svg -->
|
||||
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
4
public/nook/brands/codex.svg
Normal file
4
public/nook/brands/codex.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Codex mark — official OpenAI Codex X (canonical path used by Cloudflare docs / lobe-icons / shadcn logos, all sourced from the OpenAI brand), white colorway for dark surfaces -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFFFFF" fill-rule="evenodd">
|
||||
<path d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
21
scripts/backfill-downloads.ts
Normal file
21
scripts/backfill-downloads.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ConnectionFactory } from "~/server/db-connections";
|
||||
import { backfillDownloads } from "~/server/backfill-downloads";
|
||||
|
||||
// One-off: derive canonical /api/downloads/<asset> rows from download history
|
||||
// recorded before `trackDownload` existed. Idempotent — re-running adds
|
||||
// nothing, because derived rows carry a `backfill-<source id>` primary key.
|
||||
//
|
||||
// bun --env-file=.env scripts/backfill-downloads.ts
|
||||
|
||||
const result = await backfillDownloads(ConnectionFactory());
|
||||
|
||||
if (result.length === 0) {
|
||||
console.log("Nothing to backfill: no legacy download rows left to derive.");
|
||||
} else {
|
||||
for (const { asset, added } of result) {
|
||||
console.log(`${asset}: +${added}`);
|
||||
}
|
||||
console.log(
|
||||
`Total: +${result.reduce((sum, row) => sum + row.added, 0)} derived download rows.`
|
||||
);
|
||||
}
|
||||
111
src/app.css
111
src/app.css
@@ -755,3 +755,114 @@ a.hover-underline-animation:hover::after {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Nook landing: newer demos ─────────────────────────────────── */
|
||||
.nook-caret {
|
||||
animation: nook-pulse 0.8s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
/* Live signal inside a mixer fader */
|
||||
.nook-meter {
|
||||
transform-origin: left center;
|
||||
animation: nook-meter 0.6s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-meter {
|
||||
0% { transform: scaleX(0.35); }
|
||||
40% { transform: scaleX(0.8); }
|
||||
100% { transform: scaleX(0.55); }
|
||||
}
|
||||
|
||||
/* Waveform slot bars */
|
||||
.nook-wave {
|
||||
height: 100%;
|
||||
transform-origin: center;
|
||||
animation: nook-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-wave {
|
||||
0% { transform: scaleY(0.25); }
|
||||
100% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* Now Playing progress and artwork sheen */
|
||||
.nook-progress {
|
||||
animation: nook-progress 14s linear infinite;
|
||||
}
|
||||
@keyframes nook-progress {
|
||||
from { width: 8%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
.nook-art {
|
||||
animation: nook-art 9s linear infinite;
|
||||
}
|
||||
@keyframes nook-art {
|
||||
to { filter: hue-rotate(360deg); }
|
||||
}
|
||||
|
||||
/* A ringing timer's tape breathes between 35 % and full */
|
||||
.nook-tape-ring {
|
||||
animation: nook-tape-ring 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-tape-ring {
|
||||
from { opacity: 0.35; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Keep Awake coffee steam */
|
||||
.nook-steam path {
|
||||
animation: nook-steam 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes nook-steam {
|
||||
0% { transform: translateY(2px); opacity: 0; }
|
||||
40% { opacity: 1; }
|
||||
100% { transform: translateY(-4px); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Two-way feature marquee; edges fade, hover pauses */
|
||||
.nook-marquee {
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||
}
|
||||
.nook-marquee-track {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
width: max-content;
|
||||
padding-right: 0.625rem;
|
||||
animation: nook-marquee 60s linear infinite;
|
||||
}
|
||||
.nook-marquee-track.reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
.nook-marquee:hover .nook-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@keyframes nook-marquee {
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nook-meter,
|
||||
.nook-wave,
|
||||
.nook-progress,
|
||||
.nook-art,
|
||||
.nook-tape-ring,
|
||||
.nook-steam path,
|
||||
.nook-caret {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.nook-marquee {
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.nook-marquee-track {
|
||||
animation: none;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.nook-marquee-track > [aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,20 +9,34 @@ import {
|
||||
import {
|
||||
IslandPill,
|
||||
AgentDotGrid,
|
||||
STATUS,
|
||||
CALM,
|
||||
WARM,
|
||||
ACCENT,
|
||||
CRITICAL,
|
||||
ModuleCard
|
||||
ModuleCard,
|
||||
FakeCursor
|
||||
} from "./IslandMock";
|
||||
import { Campfire, type CampfireState } from "./Campfire";
|
||||
import {
|
||||
ApprovalCardMock,
|
||||
SessionRowMock,
|
||||
CLAUDE,
|
||||
CODEX
|
||||
} from "./ModuleMocks";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import {
|
||||
TerminalReplyDemo,
|
||||
MixerDemo,
|
||||
CaptureDemo,
|
||||
FloatingActionsDemo
|
||||
} from "./MoreDemos";
|
||||
|
||||
/**
|
||||
* Detailed feature breakdowns: alternating copy + live-demo sections.
|
||||
* Each demo stages the real interaction loop: attention flares, approval
|
||||
* resolves, fans throttle, the charge ceiling holds, meetings surface,
|
||||
* the camera wakes, the hook installs.
|
||||
* the camera wakes. Newer demos live in
|
||||
* MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx.
|
||||
*/
|
||||
|
||||
/* Scroll-triggered reveal: adds `in` once the element crosses 25%. */
|
||||
@@ -165,100 +179,88 @@ function FleetDemo() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 2: permission card resolves, phone push follows ─────────── */
|
||||
/* ── Demo 2: an approval surfaces the island; Allow resumes the run ── */
|
||||
|
||||
function ApprovalDemo() {
|
||||
const [allowed, setAllowed] = createSignal(false);
|
||||
onMount(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const loop = () => {
|
||||
setAllowed(false);
|
||||
timers.push(setTimeout(() => setAllowed(true), 4200));
|
||||
};
|
||||
loop();
|
||||
const interval = setInterval(loop, 8400);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
timers.forEach(clearTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
const [phase, setPhase] = createSignal<"calm" | "ask" | "press" | "resumed">("calm");
|
||||
useDemoLoop(10400, [
|
||||
[0, () => setPhase("calm")],
|
||||
[1400, () => setPhase("ask")],
|
||||
[4600, () => setPhase("press")],
|
||||
[4950, () => setPhase("resumed")],
|
||||
[8800, () => setPhase("calm")]
|
||||
]);
|
||||
const asking = () => phase() === "ask" || phase() === "press";
|
||||
const open = () => phase() !== "calm";
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-5 sm:flex-row sm:items-end sm:justify-center">
|
||||
{/* island card */}
|
||||
<div
|
||||
class="w-full max-w-[300px] rounded-[10px] p-2.5 text-left transition-opacity duration-500"
|
||||
style={{
|
||||
background: "rgba(234,179,8,0.08)",
|
||||
opacity: allowed() ? 0.45 : 1
|
||||
}}
|
||||
>
|
||||
<div class="mb-1.5 flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-block h-[13px] w-[13px] rounded-[3px]"
|
||||
style={{ background: "#D97742" }}
|
||||
<div class="relative flex h-[390px] flex-col items-center gap-3">
|
||||
<IslandPill
|
||||
campfire={asking() ? "question" : "running"}
|
||||
right={
|
||||
<AgentDotGrid
|
||||
states={asking() ? ["attention", "running", "ready"] : ["running", "running", "ready"]}
|
||||
/>
|
||||
<span class="text-[12px] font-semibold text-white">
|
||||
the-nook · bridge
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
style={{
|
||||
background: allowed()
|
||||
? "rgba(150,220,150,0.25)"
|
||||
: "rgba(234,179,8,0.25)"
|
||||
}}
|
||||
>
|
||||
{allowed() ? "allowed" : "approval"}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[13px] font-medium text-white">Run Edit</p>
|
||||
<p class="mt-0.5 font-mono text-[11px] text-white/50">
|
||||
server/routes.ts +12 −4
|
||||
</p>
|
||||
<div class="mt-2.5 flex gap-2">
|
||||
<span
|
||||
class="rounded-md px-3 py-1 text-[11px] font-semibold text-white transition-all duration-300"
|
||||
style={{ background: ACCENT, opacity: allowed() ? 0.4 : 1 }}
|
||||
>
|
||||
Allow
|
||||
</span>
|
||||
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
|
||||
Deny
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* phone */}
|
||||
}
|
||||
/>
|
||||
{/* the island opens: approval pinned above the session rows */}
|
||||
<div
|
||||
class="w-[150px] rounded-2xl border border-white/15 p-2 text-left"
|
||||
class="w-full max-w-[380px] overflow-hidden rounded-[14px] bg-black transition-all duration-700"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
transform: allowed() ? "translateY(-4px)" : "translateY(0)",
|
||||
transition: "transform 0.5s ease"
|
||||
"max-height": open() ? "320px" : "0px",
|
||||
opacity: open() ? 1 : 0,
|
||||
transform: open() ? "translateY(0)" : "translateY(-8px)"
|
||||
}}
|
||||
>
|
||||
<div class="mx-auto mb-1.5 h-1 w-10 rounded-full bg-white/20" />
|
||||
<div class="space-y-1.5">
|
||||
<p class="px-1 text-[9px] font-semibold tracking-wide text-white/40 uppercase">
|
||||
The Nook
|
||||
</p>
|
||||
<div class="p-2.5">
|
||||
<div
|
||||
class="rounded-lg p-2"
|
||||
style={{
|
||||
background: allowed()
|
||||
? "rgba(111,185,130,0.14)"
|
||||
: "rgba(231,167,98,0.14)"
|
||||
}}
|
||||
class="grid transition-all duration-500"
|
||||
style={{ "grid-template-rows": asking() ? "1fr" : "0fr", opacity: asking() ? 1 : 0 }}
|
||||
>
|
||||
<p class="text-[10px] font-semibold text-white">
|
||||
{allowed() ? "Allowed ✓" : "Claude Code wants to edit"}
|
||||
</p>
|
||||
<p class="text-[9px] text-white/50">
|
||||
{allowed() ? "resumed" : "tap to Allow or Deny"}
|
||||
</p>
|
||||
<div class="overflow-hidden">
|
||||
<div class="pb-1.5">
|
||||
<ApprovalCardMock
|
||||
agent={CLAUDE}
|
||||
title="the-nook · harden the bridge"
|
||||
tool="Bash"
|
||||
input="swift test --filter Bridge"
|
||||
pressed={phase() === "press" ? "allow" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<SessionRowMock
|
||||
title="the-nook · main"
|
||||
prompt="harden the bridge"
|
||||
state={
|
||||
asking()
|
||||
? "Approve Bash — swift test --filter Bridge?"
|
||||
: "Running swift test --filter Bridge…"
|
||||
}
|
||||
status={asking() ? "attention" : "running"}
|
||||
agent={CLAUDE}
|
||||
origin="iTerm2"
|
||||
age="6m"
|
||||
/>
|
||||
<SessionRowMock
|
||||
title="site · nook-334"
|
||||
prompt="match the mocks to the app"
|
||||
state="Reading ModuleMocks.tsx…"
|
||||
status="running"
|
||||
agent={CODEX}
|
||||
origin="kitty-tmux"
|
||||
age="2m"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-[380px] -translate-x-1/2">
|
||||
<FakeCursor
|
||||
phase={phase() === "press" ? "press" : asking() ? "idle" : "live"}
|
||||
at={{ x: 9.5, y: 41 }}
|
||||
exit={{ x: 30, y: 70 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -406,37 +408,6 @@ function FansDemo() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Fake cursor dot: rests on a target, presses, exits. */
|
||||
function FakeCursor(props: {
|
||||
phase: "idle" | "press" | "live";
|
||||
at: { x: number; y: number };
|
||||
exit?: { x: number; y: number };
|
||||
}) {
|
||||
const p = () =>
|
||||
props.phase === "press"
|
||||
? { ...props.at, scale: 0.62, o: 1 }
|
||||
: props.phase === "idle"
|
||||
? { ...props.at, scale: 1, o: 1 }
|
||||
: { ...(props.exit ?? { x: 106, y: 8 }), scale: 1, o: 0 };
|
||||
return (
|
||||
<span
|
||||
class="pointer-events-none absolute z-10 rounded-full bg-white"
|
||||
style={{
|
||||
width: "9px",
|
||||
height: "9px",
|
||||
"box-shadow":
|
||||
"0 1px 4px rgba(0,0,0,0.6), 0 0 0 4px rgba(255,255,255,0.18)",
|
||||
left: `${p().x}%`,
|
||||
top: `${p().y}%`,
|
||||
opacity: p().o,
|
||||
transform: `translate(-50%, -50%) scale(${p().scale})`,
|
||||
transition:
|
||||
"left 0.8s cubic-bezier(0.35,0,0.25,1), top 0.8s cubic-bezier(0.35,0,0.25,1), opacity 0.4s, transform 0.3s"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 4: the charge ceiling holds, top-up overrules ───── */
|
||||
|
||||
function BatteryDemo() {
|
||||
@@ -834,145 +805,6 @@ function CameraDemo() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 7: one click copies, sets up, probes — pong ─────── */
|
||||
|
||||
const SSH_LINES = [
|
||||
{ at: 1, text: "scp nook-hook gpu-rig:~/.nook/bin/" },
|
||||
{ at: 2, text: "setup: bake NOOK_HOST, write ~/.nook/endpoint" },
|
||||
{ at: 3, text: "nook-hook --probe → pong" }
|
||||
];
|
||||
|
||||
function SshInstallDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
onMount(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const loop = () => {
|
||||
setStep(0);
|
||||
timers.push(setTimeout(() => setStep(1), 1600));
|
||||
timers.push(setTimeout(() => setStep(2), 3000));
|
||||
timers.push(setTimeout(() => setStep(3), 4200));
|
||||
timers.push(setTimeout(() => setStep(4), 5400));
|
||||
};
|
||||
loop();
|
||||
const interval = setInterval(loop, 11000);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
timers.forEach(clearTimeout);
|
||||
});
|
||||
});
|
||||
const cursorPhase = () =>
|
||||
step() === 0
|
||||
? ("idle" as const)
|
||||
: step() === 1
|
||||
? ("press" as const)
|
||||
: ("live" as const);
|
||||
return (
|
||||
<ModuleCard class="relative mx-auto w-full max-w-[330px]">
|
||||
<div class="space-y-2.5 text-left">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
|
||||
<p class="truncate font-mono text-[10px] text-white/40">
|
||||
mike@192.168.64.3 · linux
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-300"
|
||||
style={{
|
||||
background: step() === 0 ? ACCENT : "rgba(255,255,255,0.08)",
|
||||
opacity: step() >= 4 ? 0 : 1,
|
||||
transform: step() === 1 ? "scale(0.94)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
{step() === 0 ? "Install hook" : "installing…"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||
<div class="relative" style={{ "min-height": "86px" }}>
|
||||
<div
|
||||
class="space-y-1.5 transition-opacity duration-500"
|
||||
style={{ opacity: step() >= 4 ? 0 : 1 }}
|
||||
>
|
||||
{step() === 0 && (
|
||||
<p class="pt-2 text-center text-[10px] text-white/35">
|
||||
one click: copy → setup → probe
|
||||
</p>
|
||||
)}
|
||||
<For each={SSH_LINES}>
|
||||
{(l) => (
|
||||
<div
|
||||
class="flex items-center gap-1.5 transition-all duration-500"
|
||||
style={{
|
||||
opacity: step() >= l.at ? 1 : 0,
|
||||
transform:
|
||||
step() >= l.at ? "translateY(0)" : "translateY(4px)"
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="font-mono text-[10px]"
|
||||
style={{
|
||||
color:
|
||||
step() > l.at
|
||||
? "rgba(150,220,150,0.9)"
|
||||
: "rgba(255,255,255,0.45)"
|
||||
}}
|
||||
>
|
||||
{step() > l.at ? "✓" : "…"}
|
||||
</span>
|
||||
<span class="truncate font-mono text-[10px] text-white/70">
|
||||
{l.text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col justify-center transition-all duration-500"
|
||||
style={{
|
||||
opacity: step() >= 4 ? 1 : 0,
|
||||
transform: step() >= 4 ? "translateY(0)" : "translateY(6px)"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="rounded-[10px] p-2.5"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.055)",
|
||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-[7px] w-[7px] rounded-full"
|
||||
style={{ background: STATUS.running }}
|
||||
/>
|
||||
<span class="text-[13px] font-semibold text-white">
|
||||
gpu-rig
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
background: "rgba(110,167,255,0.22)",
|
||||
color: "#9cc3ff"
|
||||
}}
|
||||
>
|
||||
remote
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-[11px] text-white/50">
|
||||
You: benchmark fp16
|
||||
</p>
|
||||
</div>
|
||||
<p class="pt-1.5 text-center text-[10px] text-white/40">
|
||||
agents join the same dot grid
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FakeCursor phase={cursorPhase()} at={{ x: 90, y: 14 }} />
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 8: campfire states ───────────────────────────────────────── */
|
||||
|
||||
function CampfireDemo() {
|
||||
@@ -1019,8 +851,8 @@ export default function FeatureBreakdowns() {
|
||||
title="Your whole fleet, in one pixel strip"
|
||||
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
||||
bullets={[
|
||||
"12 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, and more",
|
||||
"The campfire dances while work runs, simmers when all is calm",
|
||||
"13 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"
|
||||
]}
|
||||
@@ -1031,17 +863,43 @@ export default function FeatureBreakdowns() {
|
||||
<BreakdownSection
|
||||
kicker="Approvals"
|
||||
title="Answer from the island — or your phone"
|
||||
body="Gated tool calls hold right in the panel. Allow, deny, or demand a reason. Walking away? The same request lands on your phone and your tap flies back to the agent."
|
||||
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={[
|
||||
"Permission and question cards pin above the session list",
|
||||
"Push-to-phone over your own network — no cloud middleman (coming soon)",
|
||||
"Works across every hooked agent, local or remote"
|
||||
"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
|
||||
>
|
||||
<ApprovalDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Terminals"
|
||||
title="Reply without hunting for the tab"
|
||||
body="Every session knows the terminal it runs in. Type a reply in the island and it lands in that exact pane; click Jump and the right window comes forward."
|
||||
bullets={[
|
||||
"Replies reach agents in tmux, iTerm2, Terminal, and kitty",
|
||||
"Jump focuses the exact window, even for sessions over SSH",
|
||||
"Per-turn replies render as Markdown, with tables and code"
|
||||
]}
|
||||
>
|
||||
<TerminalReplyDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="The campfire"
|
||||
title="A hearth that tells the truth"
|
||||
body="Stop-motion pixel art, four frames a step. It dances when tokens stream, putters blue when an agent asks, and goes to dead embers the moment something breaks."
|
||||
bullets={[
|
||||
"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"
|
||||
]}
|
||||
>
|
||||
<CampfireDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Cooling"
|
||||
title="Fans that answer to you, not the scheduler"
|
||||
@@ -1052,6 +910,7 @@ export default function FeatureBreakdowns() {
|
||||
"CPU/GPU die temps tier-color before things get loud",
|
||||
"All local: your hardware data never leaves the device"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<FansDemo />
|
||||
</BreakdownSection>
|
||||
@@ -1067,11 +926,38 @@ export default function FeatureBreakdowns() {
|
||||
"Thermal protection pauses charging when the pack runs hot",
|
||||
"Energy flow figure: adapter → battery → system, live watts"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<BatteryDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Sound"
|
||||
title="Every app gets its own fader"
|
||||
body="A real volume mixer: set each app's level, mute the noisy one, and send any app to its own output — music to headphones while the meeting stays on the speakers."
|
||||
bullets={[
|
||||
"Per-app volume, mute, and output routing",
|
||||
"Master output, system sounds, and microphone pickers in one panel",
|
||||
"Catches players that aren't apps, like command-line podcast tools"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<MixerDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Capture"
|
||||
title="Screenshots and recordings from the notch"
|
||||
body="Drag out an area, pick a screen, or choose one app. While a recording runs, Wings unfold beside the island: pause and stop on the right, system audio and microphone on the left."
|
||||
bullets={[
|
||||
"Area, screen, or single-app capture",
|
||||
"Screenshots save to your screenshot folder and copy to the clipboard",
|
||||
"System audio and microphone tracks for recordings",
|
||||
"The Nook's own windows never show up in a capture"
|
||||
]}
|
||||
>
|
||||
<CaptureDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Your day"
|
||||
title="Meetings come to the notch"
|
||||
@@ -1082,6 +968,7 @@ export default function FeatureBreakdowns() {
|
||||
"Meeting links get an Open button in the row",
|
||||
"Real calendars and reminders — no sync service"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<CalendarDemo />
|
||||
</BreakdownSection>
|
||||
@@ -1096,35 +983,23 @@ export default function FeatureBreakdowns() {
|
||||
"A device picker for every connected camera",
|
||||
"Opt-in module — in no default panel"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<CameraDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Remote"
|
||||
title="Any SSH box, one click away"
|
||||
body="Point at any SSH-reachable box and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
|
||||
kicker="Floating actions"
|
||||
title="Your buttons, orbiting the island"
|
||||
body="Pin up to seven one-tap actions around the expanded island. They ride its expansion in and out, so the things you reach for most are always one click away."
|
||||
bullets={[
|
||||
"One click: copy the hook, run setup, probe answers pong",
|
||||
"Remote agents wear a blue badge in the same dot grid",
|
||||
"Works for Claude, Codex, Pi and many more"
|
||||
"Run an Apple Shortcut, open an app or URL, or run a shell command",
|
||||
"Toggle Low Power Mode, top-up, or overheat protection",
|
||||
"Set the charge limit, cycle audio outputs, or start a timer",
|
||||
"Cycle the approval mode without opening Settings"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<SshInstallDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="The campfire"
|
||||
title="A hearth that tells the truth"
|
||||
body="Stop-motion pixel art, four frames a step. It dances when tokens stream, putters blue when an agent asks, and goes to dead embers the moment something breaks."
|
||||
bullets={[
|
||||
"Idle, running, ready, question, and error — at a glance",
|
||||
"Hand-drawn 14×14 sprites, no tweening",
|
||||
"The fire is the fleet's pulse: glanceable from across the room"
|
||||
]}
|
||||
>
|
||||
<CampfireDemo />
|
||||
<FloatingActionsDemo />
|
||||
</BreakdownSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { For, type JSX } from "solid-js";
|
||||
import {
|
||||
IslandPill,
|
||||
AgentDotGrid,
|
||||
STATUS,
|
||||
CALM,
|
||||
CRITICAL,
|
||||
ACCENT,
|
||||
ModuleCard
|
||||
} from "./IslandMock";
|
||||
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
||||
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
|
||||
import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Feature collage: five cells sold as the app's actual UI. The hero cell
|
||||
@@ -16,7 +9,7 @@ import {
|
||||
* module chrome, status palette, and metric language.
|
||||
*/
|
||||
|
||||
function CollageCell(props: {
|
||||
export function CollageCell(props: {
|
||||
class?: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
@@ -44,7 +37,7 @@ function CollageCell(props: {
|
||||
* is always dark (preferredColorScheme .dark), so the mocks read correctly
|
||||
* in both site themes.
|
||||
*/
|
||||
function InkStage(props: { children: JSX.Element; class?: string }) {
|
||||
export function InkStage(props: { children: JSX.Element; class?: string }) {
|
||||
return (
|
||||
<div
|
||||
class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`}
|
||||
@@ -55,50 +48,6 @@ function InkStage(props: { children: JSX.Element; class?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The approvals mock rebuilt on the app's PermissionCard anatomy. */
|
||||
function ApprovalCardMock() {
|
||||
return (
|
||||
<div
|
||||
class="mx-auto w-full max-w-sm rounded-[10px] p-2.5 text-left"
|
||||
style={{ background: "rgba(234,179,8,0.08)" }}
|
||||
>
|
||||
<div class="mb-1.5 flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-block h-[13px] w-[13px] rounded-[3px]"
|
||||
style={{ background: "#D97742" }}
|
||||
/>
|
||||
<span class="text-[12px] font-semibold text-white">
|
||||
the-nook · bridge
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
style={{ background: "rgba(234,179,8,0.25)" }}
|
||||
>
|
||||
approval
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[13px] font-medium text-white">Run Edit</p>
|
||||
<p class="mt-0.5 font-mono text-[11px] text-white/50">
|
||||
server/routes.ts +12 −4
|
||||
</p>
|
||||
<div class="mt-2.5 flex gap-2">
|
||||
<span
|
||||
class="rounded-md px-3 py-1 text-[11px] font-semibold"
|
||||
style={{ background: ACCENT }}
|
||||
>
|
||||
Allow
|
||||
</span>
|
||||
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
|
||||
Deny
|
||||
</span>
|
||||
<span class="px-1 py-1 text-[11px] text-white/50">
|
||||
Deny with reason…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fans panel mock: RPM gauge rows in the app's card chrome. */
|
||||
function FansPanelMock() {
|
||||
const fans = [
|
||||
@@ -150,99 +99,33 @@ function FansPanelMock() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Remote agents: two session rows like the expanded agents panel. */
|
||||
function RemoteMock() {
|
||||
/* 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);
|
||||
|
||||
/**
|
||||
* The Temperatures module: one line per die — label, its 60 s usage trace,
|
||||
* its temperature. The GPU climbs past the 70 ° warn line and tints amber.
|
||||
*/
|
||||
function GaugesMock() {
|
||||
const [n, setN] = createSignal(0);
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => setN((v) => v + 1), 700);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
const trace = (k: number) =>
|
||||
Array.from({ length: 30 }, (_, i) => Math.max(0.04, Math.min(0.97, wander(n() + i, k))));
|
||||
const gpuCelsius = () => 62 + 12 * (0.5 + 0.5 * Math.sin(n() * 0.18));
|
||||
return (
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
class="rounded-[10px] p-2.5"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.055)",
|
||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-[7px] w-[7px] rounded-full"
|
||||
style={{ background: STATUS.running }}
|
||||
/>
|
||||
<span class="text-[13px] font-semibold text-white">build-box</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
ssh
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-[11px] text-white/50">You: fix flaky test</p>
|
||||
<ModuleCard>
|
||||
<div class="space-y-2">
|
||||
<DieLine label="CPU" usage={trace(0)} celsius={58 + 4 * Math.sin(n() * 0.3)} />
|
||||
<DieLine label="GPU" usage={trace(2)} celsius={gpuCelsius()} />
|
||||
</div>
|
||||
<div
|
||||
class="rounded-[10px] p-2.5"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.055)",
|
||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-[7px] w-[7px] rounded-full"
|
||||
style={{ background: STATUS.ready }}
|
||||
/>
|
||||
<span class="text-[13px] font-semibold text-white">gpu-rig</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
ssh
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-[11px] text-white/50">Ready</p>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** System gauges: temp die blocks + memory, in module cards. */
|
||||
function GaugesMock() {
|
||||
return (
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<ModuleCard>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-[9px] font-semibold text-white/50">CPU</span>
|
||||
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
|
||||
72°
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1.5 h-1 overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={{ width: "64%", background: CALM }}
|
||||
/>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
<ModuleCard>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-[9px] font-semibold text-white/50">GPU</span>
|
||||
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
|
||||
58°
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1.5 h-1 overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={{ width: "31%", background: CALM }}
|
||||
/>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function FeatureCollage() {
|
||||
return (
|
||||
<section
|
||||
@@ -311,11 +194,16 @@ export default function FeatureCollage() {
|
||||
<CollageCell
|
||||
kicker="Approvals"
|
||||
title="Unblock agents, anywhere"
|
||||
body="Permission prompts surface as cards in the panel — Allow once, always, or deny. Away from the desk, push them to your phone and answer from there (coming soon)."
|
||||
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">
|
||||
<ApprovalCardMock />
|
||||
<ApprovalCardMock
|
||||
agent={CLAUDE}
|
||||
title="the-nook · bridge"
|
||||
tool="Edit"
|
||||
input="server/routes.ts"
|
||||
/>
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
@@ -333,24 +221,11 @@ export default function FeatureCollage() {
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
{/* Remote agents */}
|
||||
<CollageCell
|
||||
kicker="Remote"
|
||||
title="Agents on other machines"
|
||||
body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full max-w-[280px] text-left">
|
||||
<RemoteMock />
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
{/* Gauges */}
|
||||
<CollageCell
|
||||
kicker="Your Mac"
|
||||
title="The gauges that matter"
|
||||
body="CPU and GPU die temps, memory pressure, live network throughput — the same tier palette the island uses, warning before a compile cooks your lap."
|
||||
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">
|
||||
@@ -361,8 +236,13 @@ export default function FeatureCollage() {
|
||||
</div>
|
||||
|
||||
<p class="text-subtext1 mt-6 text-center text-xs">
|
||||
Plus calendar, reminders, calls, and Now Playing — all in the same
|
||||
island, all one-time purchase.
|
||||
Plus a mixer, capture, timers, weather, and a dozen more modules —{" "}
|
||||
<a
|
||||
href="#island-extras"
|
||||
class="underline decoration-dotted hover:opacity-80"
|
||||
>
|
||||
see them all ↓
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
899
src/components/nook/IslandExtras.tsx
Normal file
899
src/components/nook/IslandExtras.tsx
Normal file
@@ -0,0 +1,899 @@
|
||||
import {
|
||||
For,
|
||||
Index,
|
||||
Show,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type JSX
|
||||
} from "solid-js";
|
||||
import {
|
||||
ModuleCard,
|
||||
STATUS,
|
||||
ACCENT,
|
||||
CALM,
|
||||
WARM,
|
||||
CRITICAL,
|
||||
CARD_FILL,
|
||||
CARD_STROKE
|
||||
} from "./IslandMock";
|
||||
import { CollageCell, InkStage } from "./FeatureCollage";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { Glyph, TimerTapeMock, TIMER_ORANGE, type GlyphName } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* "Everything else": a bento of small live tiles for the modules that do
|
||||
* not get a full breakdown, headed by a two-way marquee naming the rest.
|
||||
*/
|
||||
|
||||
/** Steady tick for tiles that animate continuously rather than in steps. */
|
||||
function useTick(everyMs: number) {
|
||||
const [tick, setTick] = createSignal(0);
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => setTick((t) => t + 1), everyMs);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
return tick;
|
||||
}
|
||||
|
||||
/** A compact black island strip for tiles that show a slot reading. */
|
||||
function MiniPill(props: { children: JSX.Element; class?: string }) {
|
||||
return (
|
||||
<div
|
||||
class={`mx-auto flex h-[26px] items-center justify-center gap-2 rounded-b-[11px] bg-black px-3 ${props.class ?? ""}`}
|
||||
style={{ "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Now Playing + the waveform slot ─────────────────────────────── */
|
||||
|
||||
function NowPlayingMock() {
|
||||
return (
|
||||
<div class="flex w-full max-w-[420px] flex-col items-center gap-4 px-3 sm:flex-row sm:items-center">
|
||||
<ModuleCard class="w-full flex-1">
|
||||
<div class="flex items-center gap-3 text-left">
|
||||
<div
|
||||
class="nook-art h-12 w-12 shrink-0 rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"conic-gradient(from 210deg, #fa5c7c, #6EA7FF, #6FB982, #E7A762, #fa5c7c)"
|
||||
}}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[12px] font-semibold text-white">
|
||||
Night Drive
|
||||
</p>
|
||||
<p class="truncate text-[10px] text-white/50">
|
||||
Synthwave Radio · Browser
|
||||
</p>
|
||||
<div
|
||||
class="mt-2 h-[3px] overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.1)" }}
|
||||
>
|
||||
<div
|
||||
class="nook-progress h-full rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.75)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2.5 flex justify-center gap-5 text-[12px] text-white/70">
|
||||
<span>⏮</span>
|
||||
<span class="text-white">⏸</span>
|
||||
<span>⏭</span>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
<div class="flex flex-col items-center gap-1.5">
|
||||
<MiniPill>
|
||||
<span class="flex h-[14px] items-center gap-[2px]">
|
||||
<For each={[0, 1, 2, 3, 4, 5]}>
|
||||
{(i) => (
|
||||
<span
|
||||
class="nook-wave w-[2.5px] rounded-full"
|
||||
style={{
|
||||
background: "#fa5c7c",
|
||||
"animation-delay": `${i * -0.13}s`,
|
||||
"animation-duration": `${0.7 + (i % 3) * 0.18}s`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</MiniPill>
|
||||
<span class="text-[9px] text-white/40">waveform slot</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Timers: the module's face — readout over the tick tape ──────── */
|
||||
|
||||
/** TimerService.format: m:ss, rounding up like the app. */
|
||||
function clock(seconds: number) {
|
||||
const s = Math.ceil(seconds);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** The face's capsule control: 26 px, mono 11, white 8 %. */
|
||||
function TimerPill(props: { glyph?: GlyphName; label?: string }) {
|
||||
return (
|
||||
<span class="flex h-[26px] flex-1 items-center justify-center gap-1 rounded-full bg-white/[0.08] font-mono text-[11px] font-medium whitespace-nowrap text-white">
|
||||
<Show when={props.glyph}>
|
||||
<Glyph name={props.glyph!} size={10} />
|
||||
</Show>
|
||||
{props.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* A 3:00 lead timer drains (in fast-forward), rings with its tape pulsing,
|
||||
* and a 25:00 Focus timer keeps running in the stacked row beneath.
|
||||
*/
|
||||
const LEAD_SECONDS = 180;
|
||||
const LEAD_MS = 9000;
|
||||
const TIMER_PERIOD = 14000;
|
||||
|
||||
function TimersMock() {
|
||||
const tick = useTick(50);
|
||||
const t = () => (tick() * 50) % TIMER_PERIOD;
|
||||
const leadLeft = () => Math.max(0, 1 - t() / LEAD_MS);
|
||||
const ringing = () => t() >= LEAD_MS;
|
||||
const focusLeft = () => 0.64 - (t() / TIMER_PERIOD) * 0.06;
|
||||
return (
|
||||
<ModuleCard class="w-full max-w-[260px]">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="flex h-[34px] items-center">
|
||||
<Show
|
||||
when={ringing()}
|
||||
fallback={
|
||||
<span class="font-mono text-[30px] font-semibold text-white tabular-nums">
|
||||
{clock(leadLeft() * LEAD_SECONDS)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-1.5 font-mono text-[22px] font-semibold"
|
||||
style={{ color: TIMER_ORANGE }}
|
||||
>
|
||||
<Glyph
|
||||
name="bell"
|
||||
size={18}
|
||||
style={{ animation: "nook-pulse 1s ease-in-out infinite" }}
|
||||
/>
|
||||
Time's up
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class={ringing() ? "nook-tape-ring" : ""}>
|
||||
<TimerTapeMock remaining={ringing() ? 1 : leadLeft()} width={232} height={32} />
|
||||
</div>
|
||||
<p class="font-mono text-[9px] font-medium text-white/50">
|
||||
{ringing() ? "3:00 timer" : "Remaining of 3:00"}
|
||||
</p>
|
||||
<div class="flex w-full gap-2 pt-1">
|
||||
<Show
|
||||
when={ringing()}
|
||||
fallback={
|
||||
<>
|
||||
<TimerPill glyph="pause" label="Pause" />
|
||||
<TimerPill glyph="xmark" label="Cancel" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TimerPill glyph="plus" label="1 min" />
|
||||
<TimerPill glyph="restart" label="Restart" />
|
||||
<TimerPill glyph="xmark" label="Dismiss" />
|
||||
</Show>
|
||||
</div>
|
||||
{/* a further timer: readout, slim tape, icon controls */}
|
||||
<div class="mt-1.5 flex h-6 w-full items-center gap-2 rounded-full bg-white/[0.05] px-2.5 font-mono text-[11px] font-medium text-white">
|
||||
<span class="w-11 tabular-nums">{clock(focusLeft() * 1500)}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<TimerTapeMock remaining={focusLeft()} width={110} height={12} />
|
||||
</div>
|
||||
<Glyph name="pause" size={9} />
|
||||
<Glyph name="xmark" size={9} />
|
||||
</div>
|
||||
<div class="mt-1.5 flex w-full gap-1.5">
|
||||
<For each={["1m", "5m", "10m", "25m"]}>{(m) => <TimerPill label={m} />}</For>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Weather: saved places rotate through the slot ───────────────── */
|
||||
|
||||
type Sky = "sun" | "cloud" | "rain";
|
||||
|
||||
function SkyGlyph(props: { sky: Sky; size?: number }) {
|
||||
const s = () => props.size ?? 14;
|
||||
return (
|
||||
<svg width={s()} height={s()} viewBox="0 0 16 16">
|
||||
<Show when={props.sky === "sun"}>
|
||||
<circle cx="8" cy="8" r="3.4" fill="#FFD23F" />
|
||||
<g stroke="#FFD23F" stroke-width="1.3" stroke-linecap="round">
|
||||
<path d="M8 1.2v1.6M8 13.2v1.6M1.2 8h1.6M13.2 8h1.6M3.2 3.2l1.1 1.1M11.7 11.7l1.1 1.1M3.2 12.8l1.1-1.1M11.7 4.3l1.1-1.1" />
|
||||
</g>
|
||||
</Show>
|
||||
<Show when={props.sky !== "sun"}>
|
||||
<path
|
||||
d="M4.5 11.5a3 3 0 0 1 .3-6 4 4 0 0 1 7.6 1.2 2.4 2.4 0 0 1-.4 4.8Z"
|
||||
fill="rgba(255,255,255,0.8)"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.sky === "rain"}>
|
||||
<path
|
||||
d="M6 13l-.6 1.6M9 13l-.6 1.6M12 13l-.6 1.6"
|
||||
stroke={CALM}
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</Show>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const PLACES: { place: string; temp: number; sky: Sky; hourly: [Sky, number][] }[] = [
|
||||
{
|
||||
place: "Portland",
|
||||
temp: 13,
|
||||
sky: "rain",
|
||||
hourly: [["rain", 13], ["rain", 13], ["cloud", 14], ["cloud", 15], ["sun", 16], ["sun", 15]]
|
||||
},
|
||||
{
|
||||
place: "Lisbon",
|
||||
temp: 24,
|
||||
sky: "sun",
|
||||
hourly: [["sun", 24], ["sun", 25], ["sun", 26], ["cloud", 25], ["cloud", 23], ["sun", 21]]
|
||||
}
|
||||
];
|
||||
|
||||
function WeatherMock() {
|
||||
const [i, setI] = createSignal(0);
|
||||
useDemoLoop(7000, [
|
||||
[0, () => setI(0)],
|
||||
[3500, () => setI(1)]
|
||||
]);
|
||||
const p = () => PLACES[i()];
|
||||
return (
|
||||
<div class="flex w-full max-w-[260px] flex-col items-center gap-3">
|
||||
<MiniPill class="h-[34px] flex-col gap-0 px-4">
|
||||
<SkyGlyph sky={p().sky} size={12} />
|
||||
<span class="font-mono text-[11px] leading-none font-bold text-white">
|
||||
{p().temp}°
|
||||
</span>
|
||||
</MiniPill>
|
||||
<ModuleCard class="w-full">
|
||||
<div class="text-left">
|
||||
<div class="mb-2 flex items-baseline">
|
||||
<span class="text-[11px] font-semibold text-white">{p().place}</span>
|
||||
<span class="ml-auto text-[9px] text-white/40">
|
||||
{i() + 1} of {PLACES.length}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<Index each={p().hourly}>
|
||||
{(h, hour) => (
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-[8px] text-white/40">
|
||||
{hour === 0 ? "Now" : `${(13 + hour) % 24}`}
|
||||
</span>
|
||||
<SkyGlyph sky={h()[0]} size={12} />
|
||||
<span class="font-mono text-[10px] text-white/85">
|
||||
{h()[1]}°
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Interceptions: volume & brightness peek below the island ────── */
|
||||
|
||||
const PEEKS = [
|
||||
{ kind: "volume", label: "Speakers", from: 0.35, to: 0.7 },
|
||||
{ kind: "bright", label: "Built-in display", from: 0.6, to: 1 },
|
||||
{ kind: "xdr", label: "Extra brightness", from: 1, to: 1.3 },
|
||||
{ kind: "bright", label: "External display", from: 0.3, to: 0.65 }
|
||||
] as const;
|
||||
|
||||
function InterceptionMock() {
|
||||
const [peek, setPeek] = createSignal(0);
|
||||
const [level, setLevel] = createSignal<number>(PEEKS[0].from);
|
||||
const [shown, setShown] = createSignal(false);
|
||||
const steps: [number, () => void][] = [];
|
||||
PEEKS.forEach((pk, n) => {
|
||||
const base = n * 2600;
|
||||
steps.push([base, () => (setPeek(n), setLevel(pk.from), setShown(true))]);
|
||||
for (let k = 1; k <= 4; k++) {
|
||||
steps.push([
|
||||
base + 250 + k * 280,
|
||||
() => setLevel(pk.from + ((pk.to - pk.from) * k) / 4)
|
||||
]);
|
||||
}
|
||||
steps.push([base + 2300, () => setShown(false)]);
|
||||
});
|
||||
useDemoLoop(PEEKS.length * 2600, steps);
|
||||
|
||||
const pk = () => PEEKS[peek()];
|
||||
const xdr = () => level() > 1;
|
||||
return (
|
||||
<div class="flex w-full max-w-[360px] flex-col items-center">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-b-[16px] bg-black transition-all duration-300"
|
||||
style={{
|
||||
width: shown() ? "300px" : "200px",
|
||||
height: shown() ? "68px" : "26px",
|
||||
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)",
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center gap-2.5 px-4 pb-3 transition-opacity duration-200"
|
||||
style={{ opacity: shown() ? 1 : 0 }}
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 16 16"
|
||||
style={{ color: xdr() ? "#FFD23F" : "white" }}
|
||||
>
|
||||
<Show
|
||||
when={pk().kind === "volume"}
|
||||
fallback={
|
||||
<g fill="currentColor" stroke="currentColor" stroke-width="1.3" stroke-linecap="round">
|
||||
<circle cx="8" cy="8" r="3" stroke="none" />
|
||||
<path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1" fill="none" />
|
||||
</g>
|
||||
}
|
||||
>
|
||||
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
|
||||
<path d="M11 5.5a3.5 3.5 0 0 1 0 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
|
||||
</Show>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="mb-1 text-left text-[9px] text-white/50">{pk().label}</p>
|
||||
<div
|
||||
class="relative h-[6px] rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.12)" }}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(level(), 1) * (pk().kind === "xdr" ? 76.9 : 100)}%`,
|
||||
background: "white",
|
||||
transition: "width 0.2s ease-out"
|
||||
}}
|
||||
/>
|
||||
<Show when={pk().kind === "xdr"}>
|
||||
<span
|
||||
class="absolute top-[-3px] h-[12px] w-px bg-white/50"
|
||||
style={{ left: "76.9%" }}
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 h-full rounded-r-full"
|
||||
style={{
|
||||
left: "76.9%",
|
||||
width: `${Math.max(0, level() - 1) * 76.9}%`,
|
||||
background: "#FFD23F",
|
||||
"box-shadow": "0 0 10px 2px rgba(255,210,63,0.6)",
|
||||
transition: "width 0.2s ease-out"
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<span class="w-9 text-right font-mono text-[10px] text-white/80">
|
||||
{Math.round(level() * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Network: speed test on demand + identity rows ───────────────── */
|
||||
|
||||
function NetworkMock() {
|
||||
const [down, setDown] = createSignal(0);
|
||||
const [up, setUp] = createSignal(0);
|
||||
const [running, setRunning] = createSignal(false);
|
||||
const steps: [number, () => void][] = [
|
||||
[0, () => (setDown(0), setUp(0), setRunning(false))],
|
||||
[1200, () => setRunning(true)]
|
||||
];
|
||||
for (let k = 1; k <= 12; k++) {
|
||||
steps.push([1200 + k * 120, () => setDown(Math.round((842 * k) / 12))]);
|
||||
steps.push([2800 + k * 90, () => setUp(Math.round((96 * k) / 12))]);
|
||||
}
|
||||
steps.push([4000, () => setRunning(false)]);
|
||||
useDemoLoop(8400, steps);
|
||||
|
||||
return (
|
||||
<ModuleCard class="w-full max-w-[260px]">
|
||||
<div class="space-y-2 text-left">
|
||||
<div class="flex items-end gap-3">
|
||||
<div>
|
||||
<p class="text-[8px] font-semibold text-white/40">DOWN</p>
|
||||
<p class="font-mono text-[16px] font-semibold text-white">
|
||||
{down()}
|
||||
<span class="ml-0.5 text-[9px] text-white/50">Mbps</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[8px] font-semibold text-white/40">UP</p>
|
||||
<p class="font-mono text-[16px] font-semibold text-white">
|
||||
{up()}
|
||||
<span class="ml-0.5 text-[9px] text-white/50">Mbps</span>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
style={{ background: running() ? "rgba(255,255,255,0.1)" : ACCENT }}
|
||||
>
|
||||
{running() ? "testing…" : "Run"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||
<For
|
||||
each={[
|
||||
["Public IP", "203.0.113.42"],
|
||||
["ISP", "Example Fiber · AS64500"],
|
||||
["DNS", "1.1.1.1"]
|
||||
]}
|
||||
>
|
||||
{([k, v]) => (
|
||||
<div class="flex items-center gap-2 text-[10px]">
|
||||
<span class="w-14 text-white/40">{k}</span>
|
||||
<span class="truncate font-mono text-white/80">{v}</span>
|
||||
<span class="ml-auto text-white/30">⧉</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[9px] font-semibold"
|
||||
style={{ background: "rgba(110,167,255,0.18)", color: "#9cc3ff" }}
|
||||
>
|
||||
VPN · default route
|
||||
</span>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Disk: throughput trace, capacity, SMART ─────────────────────── */
|
||||
|
||||
function DiskMock() {
|
||||
const tick = useTick(450);
|
||||
const sample = (n: number, k: number) => {
|
||||
const x = Math.sin(n * 0.9 + k * 2.1) * 0.5 + Math.sin(n * 0.37 + k) * 0.5;
|
||||
return Math.max(0.05, Math.min(1, 0.45 + x * 0.45));
|
||||
};
|
||||
const path = (k: number) => {
|
||||
const pts = Array.from({ length: 24 }, (_, i) => {
|
||||
const v = sample(tick() + i, k) * (k === 0 ? 1 : 0.45);
|
||||
return `${(i / 23) * 200},${40 - v * 36}`;
|
||||
});
|
||||
return `M${pts.join(" L")}`;
|
||||
};
|
||||
const read = () => Math.round(sample(tick() + 23, 0) * 900);
|
||||
const write = () => Math.round(sample(tick() + 23, 1) * 0.45 * 900);
|
||||
return (
|
||||
<ModuleCard class="w-full max-w-[260px]">
|
||||
<div class="space-y-2 text-left">
|
||||
<div class="flex items-baseline gap-3 font-mono text-[11px]">
|
||||
<span style={{ color: CALM }}>R {read()} MB/s</span>
|
||||
<span style={{ color: WARM }}>W {write()} MB/s</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 200 42" class="h-[42px] w-full" preserveAspectRatio="none">
|
||||
<path d={path(0)} fill="none" stroke={CALM} stroke-width="1.5" />
|
||||
<path d={path(1)} fill="none" stroke={WARM} stroke-width="1.5" />
|
||||
</svg>
|
||||
<div class="flex items-center text-[10px]">
|
||||
<span class="text-white/70">Macintosh HD</span>
|
||||
<span class="ml-auto text-white/45">612 GB free of 1 TB</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-[5px] overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
<div class="h-full rounded-full" style={{ width: "39%", background: CALM }} />
|
||||
</div>
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[9px] font-semibold"
|
||||
style={{ background: "rgba(111,185,130,0.18)", color: STATUS.ready }}
|
||||
>
|
||||
SMART · Verified
|
||||
</span>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Keep Awake: four ways to hold off sleep ─────────────────────── */
|
||||
|
||||
const AWAKE_MODES = [
|
||||
{ label: "1 hour", note: "awake for 59:12 more" },
|
||||
{ label: "On power", note: "awake while plugged in" },
|
||||
{ label: "Lid closed", note: "keeps running with the lid shut" },
|
||||
{ label: "Agents", note: "awake only while agents work" }
|
||||
];
|
||||
|
||||
function KeepAwakeMock() {
|
||||
const [mode, setMode] = createSignal(0);
|
||||
useDemoLoop(
|
||||
9600,
|
||||
AWAKE_MODES.map((_, n) => [n * 2400, () => setMode(n)] as [number, () => void])
|
||||
);
|
||||
return (
|
||||
<ModuleCard class="w-full max-w-[260px]">
|
||||
<div class="flex flex-col items-center gap-2.5">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" class="overflow-visible">
|
||||
<g class="nook-steam" stroke="rgba(255,255,255,0.5)" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M15 12c-2-3 2-4 0-7" />
|
||||
<path d="M21 12c-2-3 2-4 0-7" style={{ "animation-delay": "-0.6s" }} />
|
||||
</g>
|
||||
<path d="M8 15h22v9a9 9 0 0 1-9 9h-4a9 9 0 0 1-9-9Z" fill={WARM} />
|
||||
<path d="M30 18h2a4 4 0 0 1 0 8h-2" stroke={WARM} stroke-width="2.4" fill="none" />
|
||||
</svg>
|
||||
<div class="grid w-full grid-cols-2 gap-1.5">
|
||||
<For each={AWAKE_MODES}>
|
||||
{(m, n) => (
|
||||
<span
|
||||
class="rounded-md py-1 text-center text-[10px] font-semibold transition-colors duration-300"
|
||||
style={{
|
||||
background: mode() === n() ? ACCENT : CARD_FILL,
|
||||
color: mode() === n() ? "white" : "rgba(255,255,255,0.55)",
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
|
||||
}}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p class="text-[10px] text-white/45">{AWAKE_MODES[mode()].note}</p>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Approval modes: Auto · Yolo · Verify ────────────────────────── */
|
||||
|
||||
const MODES = [
|
||||
{ name: "Auto", note: "follows each agent's own settings", card: "asks", tint: "rgba(234,179,8,0.10)" },
|
||||
{ name: "Yolo", note: "gated tools pass straight through", card: "passed", tint: "rgba(111,185,130,0.12)" },
|
||||
{ name: "Verify", note: "every gated tool waits for you", card: "held", tint: "rgba(229,72,77,0.12)" }
|
||||
];
|
||||
|
||||
function ApprovalModesMock() {
|
||||
const [mode, setMode] = createSignal(0);
|
||||
useDemoLoop(8400, [
|
||||
[0, () => setMode(0)],
|
||||
[2800, () => setMode(1)],
|
||||
[5600, () => setMode(2)]
|
||||
]);
|
||||
const m = () => MODES[mode()];
|
||||
return (
|
||||
<div class="w-full max-w-[260px] space-y-2.5">
|
||||
<div
|
||||
class="relative flex rounded-lg p-0.5"
|
||||
style={{ background: CARD_FILL, "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 bottom-0.5 rounded-md transition-all duration-300"
|
||||
style={{
|
||||
left: `calc(${mode() * 33.333}% + 2px)`,
|
||||
width: "calc(33.333% - 4px)",
|
||||
background: ACCENT
|
||||
}}
|
||||
/>
|
||||
<For each={MODES}>
|
||||
{(x, n) => (
|
||||
<span
|
||||
class="relative z-10 flex-1 py-1 text-center text-[11px] font-semibold transition-colors"
|
||||
style={{ color: mode() === n() ? "white" : "rgba(255,255,255,0.55)" }}
|
||||
>
|
||||
{x.name}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-[10px] p-2.5 text-left transition-colors duration-500"
|
||||
style={{ background: m().tint }}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[12px] font-medium text-white">Bash</span>
|
||||
<span class="truncate font-mono text-[10px] text-white/50">
|
||||
rm -rf build/
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[9px] font-semibold text-white"
|
||||
style={{ background: "rgba(255,255,255,0.12)" }}
|
||||
>
|
||||
{m().card}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-[10px] text-white/45">{m().note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Lock Screen: counts without unlocking ───────────────────────── */
|
||||
|
||||
function LockScreenMock() {
|
||||
const [counts, setCounts] = createSignal({ working: 3, waiting: 0 });
|
||||
useDemoLoop(7200, [
|
||||
[0, () => setCounts({ working: 3, waiting: 0 })],
|
||||
[2400, () => setCounts({ working: 2, waiting: 1 })],
|
||||
[4800, () => setCounts({ working: 1, waiting: 0 })]
|
||||
]);
|
||||
return (
|
||||
<div
|
||||
class="relative h-[150px] w-full max-w-[260px] overflow-hidden rounded-xl"
|
||||
style={{
|
||||
background: "radial-gradient(120% 90% at 30% 10%, #2b4a6b 0%, #191b2e 55%, #0d0d0f 100%)"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-1/2 flex h-[22px] -translate-x-1/2 items-center gap-2 rounded-b-[9px] bg-black px-2.5"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="h-[6px] w-[6px] rounded-[1.5px]" style={{ background: STATUS.running }} />
|
||||
<span class="font-mono text-[9px] font-semibold text-white/90">{counts().working}</span>
|
||||
</span>
|
||||
<span class="w-6" />
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="h-[6px] w-[6px] rounded-[1.5px] transition-colors"
|
||||
style={{ background: counts().waiting ? STATUS.attention : STATUS.idle }}
|
||||
/>
|
||||
<span class="font-mono text-[9px] font-semibold text-white/90">{counts().waiting}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-full flex-col items-center justify-center pt-4">
|
||||
<p class="text-[34px] leading-tight font-semibold tracking-tight text-white/90">
|
||||
9:41
|
||||
</p>
|
||||
<span class="mt-2 h-5 w-5 rounded-full border border-white/30" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Layout: the mosaic reflows ──────────────────────────────────── */
|
||||
|
||||
type Box = [l: number, t: number, w: number, h: number];
|
||||
const LAYOUTS: Box[][] = [
|
||||
[[0, 0, 66, 58], [68, 0, 32, 58], [0, 62, 32, 38], [34, 62, 32, 38], [68, 62, 32, 38]],
|
||||
[[0, 0, 32, 100], [34, 0, 66, 40], [34, 44, 32, 56], [68, 44, 32, 26], [68, 74, 32, 26]],
|
||||
[[0, 0, 49, 48], [51, 0, 49, 48], [0, 52, 100, 22], [0, 78, 49, 22], [51, 78, 49, 22]]
|
||||
];
|
||||
const TILE_COLORS = [CALM, STATUS.ready, WARM, "#fa5c7c", "rgba(255,255,255,0.5)"];
|
||||
|
||||
function LayoutMock() {
|
||||
const [n, setN] = createSignal(0);
|
||||
useDemoLoop(7500, [
|
||||
[0, () => setN(0)],
|
||||
[2500, () => setN(1)],
|
||||
[5000, () => setN(2)]
|
||||
]);
|
||||
return (
|
||||
<div
|
||||
class="relative h-[130px] w-full max-w-[240px] rounded-[12px] bg-black p-2"
|
||||
style={{ "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
|
||||
>
|
||||
<div class="relative h-full w-full">
|
||||
<For each={TILE_COLORS}>
|
||||
{(color, i) => {
|
||||
const b = () => LAYOUTS[n()][i()];
|
||||
return (
|
||||
<div
|
||||
class="absolute rounded-md"
|
||||
style={{
|
||||
left: `${b()[0]}%`,
|
||||
top: `${b()[1]}%`,
|
||||
width: `${b()[2]}%`,
|
||||
height: `${b()[3]}%`,
|
||||
background: CARD_FILL,
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}, inset 3px 0 0 ${color}`,
|
||||
transition: "all 0.7s cubic-bezier(0.34,1.3,0.64,1)"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Marquee ─────────────────────────────────────────────────────── */
|
||||
|
||||
const CHIPS_A = [
|
||||
"13 agents",
|
||||
"Custom agents",
|
||||
"Six chiptunes",
|
||||
"Focus-aware slots",
|
||||
"Per-app output routing",
|
||||
"Low Power Mode toggle",
|
||||
"Reminders",
|
||||
"Battery health",
|
||||
"Energy flow in watts",
|
||||
"Thermal charge protection",
|
||||
"Top-up charging",
|
||||
"Memory by process"
|
||||
];
|
||||
const CHIPS_B = [
|
||||
"Jump to the terminal",
|
||||
"tmux · iTerm2 · kitty",
|
||||
"Markdown replies",
|
||||
"Tailscale hosts",
|
||||
"iPhone widgets",
|
||||
"Watch-face widgets",
|
||||
"Screenshots to clipboard",
|
||||
"System audio + mic",
|
||||
"Hourly forecast",
|
||||
"External display brightness",
|
||||
"Lid-closed keep-awake"
|
||||
];
|
||||
|
||||
function MarqueeRow(props: { chips: string[]; reverse?: boolean }) {
|
||||
return (
|
||||
<div class="nook-marquee">
|
||||
<div class={`nook-marquee-track ${props.reverse ? "reverse" : ""}`}>
|
||||
<For each={[...props.chips, ...props.chips]}>
|
||||
{(chip, i) => (
|
||||
<span
|
||||
aria-hidden={i() >= props.chips.length ? "true" : undefined}
|
||||
class="border-overlay0 bg-surface0 text-subtext0 rounded-full border px-3.5 py-1.5 text-xs font-semibold whitespace-nowrap"
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IslandExtras() {
|
||||
return (
|
||||
<section class="bg-base relative z-20 py-16" id="island-extras">
|
||||
<div class="px-4 md:px-8">
|
||||
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
|
||||
And everything else
|
||||
</p>
|
||||
<h2 class="text-text mb-3 text-center text-3xl font-bold">
|
||||
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">
|
||||
Every module below lives in the same notch, arranged however you
|
||||
like, under the same one-time license.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-12 space-y-3">
|
||||
<MarqueeRow chips={CHIPS_A} />
|
||||
<MarqueeRow chips={CHIPS_B} reverse />
|
||||
</div>
|
||||
|
||||
<div class="mx-auto grid max-w-5xl grid-cols-1 gap-5 px-4 md:grid-cols-3 md:px-8">
|
||||
<CollageCell
|
||||
class="md:col-span-2"
|
||||
kicker="Now Playing"
|
||||
title="Music in the notch"
|
||||
body="Track, source, and transport controls in a module. Sources without artwork, like some browsers, get a live waveform in the slot instead."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center">
|
||||
<NowPlayingMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Timers"
|
||||
title="Timers that stack"
|
||||
body="A readout over a tick tape that drains with a soft crest. Run several at once; finished timers ring and hold the island open until you dismiss them."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<TimersMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Weather"
|
||||
title="The sky, in the slot"
|
||||
body="Saved places with hourly and daily forecasts. The compact slot stacks the sky over the temperature."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<WeatherMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
class="md:col-span-2"
|
||||
kicker="Interceptions"
|
||||
title="Volume and brightness, under the notch"
|
||||
body="An optional takeover of the system HUD: levels peek out below the island. Extra brightness spends an XDR panel's HDR headroom past the usual maximum, and external displays get real brightness control."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center">
|
||||
<div class="flex min-h-[110px] w-full justify-center pt-2">
|
||||
<InterceptionMock />
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Approval modes"
|
||||
title="One stance for every agent"
|
||||
body="Auto follows each agent's own settings, Yolo waves gated tools through, and Verify holds every one until you decide."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<ApprovalModesMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Network"
|
||||
title="Know your connection"
|
||||
body="Public IP, ISP, gateway, and DNS one click from your clipboard. VPN routes get flagged, and a speed test is always a click away."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<NetworkMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Disks"
|
||||
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 />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Keep Awake"
|
||||
title="Sleep when you say"
|
||||
body="Stay awake for a set time, while plugged in, with the lid closed, or only while your agents are working."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<KeepAwakeMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="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">
|
||||
<LockScreenMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Layout"
|
||||
title="Panels you shape"
|
||||
body="Drag and resize modules in the mosaic editor, pick a density, and let slot rankings switch with your Focus mode."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<LayoutMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -85,9 +85,10 @@ function IslandSurface(props: { children: JSX.Element; class?: string }) {
|
||||
onMount(() => {
|
||||
const node = el;
|
||||
if (!node) return;
|
||||
// Layout size, not getBoundingClientRect: a scaled-down mock (the
|
||||
// Capture demo's island row) would otherwise clip itself to a corner.
|
||||
const apply = () => {
|
||||
const r = node.getBoundingClientRect();
|
||||
node.style.clipPath = `path("${islandPath(r.width, r.height)}")`;
|
||||
node.style.clipPath = `path("${islandPath(node.offsetWidth, node.offsetHeight)}")`;
|
||||
};
|
||||
apply();
|
||||
const observer = new ResizeObserver(apply);
|
||||
@@ -261,3 +262,34 @@ export function IslandExpanded(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fake cursor dot: rests on a target, presses, exits. */
|
||||
export function FakeCursor(props: {
|
||||
phase: "idle" | "press" | "live";
|
||||
at: { x: number; y: number };
|
||||
exit?: { x: number; y: number };
|
||||
}) {
|
||||
const p = () =>
|
||||
props.phase === "press"
|
||||
? { ...props.at, scale: 0.62, o: 1 }
|
||||
: props.phase === "idle"
|
||||
? { ...props.at, scale: 1, o: 1 }
|
||||
: { ...(props.exit ?? { x: 106, y: 8 }), scale: 1, o: 0 };
|
||||
return (
|
||||
<span
|
||||
class="pointer-events-none absolute z-10 rounded-full bg-white"
|
||||
style={{
|
||||
width: "9px",
|
||||
height: "9px",
|
||||
"box-shadow":
|
||||
"0 1px 4px rgba(0,0,0,0.6), 0 0 0 4px rgba(255,255,255,0.18)",
|
||||
left: `${p().x}%`,
|
||||
top: `${p().y}%`,
|
||||
opacity: p().o,
|
||||
transform: `translate(-50%, -50%) scale(${p().scale})`,
|
||||
transition:
|
||||
"left 0.8s cubic-bezier(0.35,0,0.25,1), top 0.8s cubic-bezier(0.35,0,0.25,1), opacity 0.4s, transform 0.3s"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
405
src/components/nook/ModuleMocks.tsx
Normal file
405
src/components/nook/ModuleMocks.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import { For, Show, type JSX } from "solid-js";
|
||||
import { STATUS, ACCENT, CALM, WARM, CARD_FILL, CARD_STROKE } from "./IslandMock";
|
||||
|
||||
/**
|
||||
* Module-level mocks rebuilt from the app's SwiftUI views, so the site shows
|
||||
* the same anatomy the island does: the session row (SessionRowView), the
|
||||
* approval card (AgentsActionCards), the timer tape (TimerTape), a
|
||||
* temperature line (TemperaturesPanelView), and the Wings (WingPanels).
|
||||
*/
|
||||
|
||||
/* ── Glyphs: the SF Symbols the mocked views use ─────────────────── */
|
||||
|
||||
/* Factories, not shared nodes: a Solid JSX node can mount in only one place. */
|
||||
const GLYPHS: Record<string, () => JSX.Element> = {
|
||||
speaker: () => (
|
||||
<>
|
||||
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
|
||||
<path d="M11 5.5a3.5 3.5 0 0 1 0 5M12.8 3.6a6 6 0 0 1 0 8.8" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
|
||||
</>
|
||||
),
|
||||
speakerOff: () => (
|
||||
<>
|
||||
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
|
||||
<path d="m11 6 4 4m0-4-4 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
</>
|
||||
),
|
||||
mic: () => (
|
||||
<>
|
||||
<rect x="5.5" y="1.5" width="5" height="8.5" rx="2.5" fill="currentColor" />
|
||||
<path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
|
||||
</>
|
||||
),
|
||||
micOff: () => (
|
||||
<>
|
||||
<rect x="5.5" y="1.5" width="5" height="8.5" rx="2.5" fill="currentColor" opacity="0.9" />
|
||||
<path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5M2.5 2.5l11 11" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
|
||||
</>
|
||||
),
|
||||
pause: () => (
|
||||
<>
|
||||
<rect x="4" y="3" width="3" height="10" rx="1" fill="currentColor" />
|
||||
<rect x="9" y="3" width="3" height="10" rx="1" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
play: () => <path d="M5 3v10l8-5Z" fill="currentColor" />,
|
||||
stop: () => <rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />,
|
||||
viewfinder: () => (
|
||||
<path d="M2 5V3.5A1.5 1.5 0 0 1 3.5 2H5m6 0h1.5A1.5 1.5 0 0 1 14 3.5V5m0 6v1.5a1.5 1.5 0 0 1-1.5 1.5H11m-6 0H3.5A1.5 1.5 0 0 1 2 12.5V11m6-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" />
|
||||
),
|
||||
record: () => (
|
||||
<>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3" fill="none" />
|
||||
<circle cx="8" cy="8" r="3" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
dashed: () => (
|
||||
<rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" stroke-width="1.3" stroke-dasharray="2 1.6" fill="none" />
|
||||
),
|
||||
display: () => (
|
||||
<path d="M2.5 3h11a1 1 0 0 1 1 1v6.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm3 11h5M8 11.5V14" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" />
|
||||
),
|
||||
window: () => (
|
||||
<>
|
||||
<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="currentColor" stroke-width="1.3" fill="none" />
|
||||
<path d="M1.5 6h13" stroke="currentColor" stroke-width="1.3" />
|
||||
</>
|
||||
),
|
||||
chevron: () => <path d="m4.5 6.5 3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round" />,
|
||||
film: () => (
|
||||
<path d="M3 2.5h10a.5.5 0 0 1 .5.5v10a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5Zm2 0v11m6-11v11M2.5 6h2.5m6 0h2.5m-11 4h2.5m6 0h2.5" stroke="currentColor" stroke-width="1.2" fill="none" />
|
||||
),
|
||||
xmark: () => <path d="m4 4 8 8m0-8-8 8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />,
|
||||
bell: () => (
|
||||
<path d="M8 1.8a4.2 4.2 0 0 0-4.2 4.2v3L2.5 11.3h11L12.2 9V6A4.2 4.2 0 0 0 8 1.8ZM6.3 12.8a1.8 1.8 0 0 0 3.4 0Z" fill="currentColor" />
|
||||
),
|
||||
restart: () => (
|
||||
<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" />,
|
||||
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" />
|
||||
)
|
||||
};
|
||||
|
||||
export type GlyphName = keyof typeof GLYPHS;
|
||||
|
||||
export function Glyph(props: { name: GlyphName; size?: number; class?: string; style?: JSX.CSSProperties }) {
|
||||
return (
|
||||
<svg
|
||||
width={props.size ?? 12}
|
||||
height={props.size ?? 12}
|
||||
viewBox="0 0 16 16"
|
||||
class={`shrink-0 ${props.class ?? ""}`}
|
||||
style={props.style}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{GLYPHS[props.name]()}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Agents ──────────────────────────────────────────────────────── */
|
||||
|
||||
export interface AgentLook {
|
||||
name: string;
|
||||
/** brandColorHex from the app's AgentRegistry row. */
|
||||
color: string;
|
||||
/** The brand mark the app bundles (AgentBrands/*.svg). */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const CLAUDE: AgentLook = {
|
||||
name: "Claude Code",
|
||||
color: "#D97742",
|
||||
icon: "/nook/brands/claude.svg"
|
||||
};
|
||||
export const CODEX: AgentLook = {
|
||||
name: "Codex",
|
||||
color: "#4AA3DF",
|
||||
icon: "/nook/brands/codex.svg"
|
||||
};
|
||||
|
||||
/** A row badge: brand-tinted for the agent chip, neutral otherwise. */
|
||||
function Badge(props: { text: string; tint?: string }) {
|
||||
return (
|
||||
<span
|
||||
class="max-w-full truncate rounded-full px-[7px] py-[2px] text-[10px] font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
color: props.tint ?? "rgba(255,255,255,0.75)",
|
||||
background: props.tint ? `${props.tint}33` : "rgba(255,255,255,0.08)"
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionRowView: "project · branch", the latest prompt, a status-colored
|
||||
* state line; on the right the agent chip (named "Agent · host" for a
|
||||
* remote session), the terminal origin, the status dot and age.
|
||||
*/
|
||||
export function SessionRowMock(props: {
|
||||
title: string;
|
||||
prompt: string;
|
||||
state: string;
|
||||
status: keyof typeof STATUS;
|
||||
agent: AgentLook;
|
||||
/** Remote sessions name their machine in the agent chip. */
|
||||
host?: string;
|
||||
origin?: string;
|
||||
age: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class="@container rounded-xl px-2 py-2.5 text-left"
|
||||
style={{ background: CARD_FILL, "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
|
||||
>
|
||||
{/* The app's layout (badges in a right-hand third) wherever the row
|
||||
is wide enough; narrow site cells stack the badges beneath. */}
|
||||
<div class="flex flex-col gap-1.5 @[340px]:flex-row @[340px]:items-start @[340px]:gap-2.5">
|
||||
<div class="min-w-0 flex-1 space-y-[3px]">
|
||||
<p class="truncate text-[13px] font-semibold text-white">{props.title}</p>
|
||||
<p class="truncate text-[11.5px] text-white/55">User: {props.prompt}</p>
|
||||
<p
|
||||
class="truncate text-[11.5px] font-medium transition-colors duration-500"
|
||||
style={{ color: STATUS[props.status] }}
|
||||
>
|
||||
{props.state}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex min-w-0 shrink-0 items-start gap-1.5 @[340px]:max-w-[50%]">
|
||||
<div class="flex min-w-0 flex-col items-center">
|
||||
<Badge
|
||||
text={props.host ? `${props.agent.name} · ${props.host}` : props.agent.name}
|
||||
tint={props.agent.color}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 @[340px]:flex-col @[340px]:items-end @[340px]:gap-0.5">
|
||||
<Show when={props.origin}>
|
||||
<Badge text={props.origin!} />
|
||||
</Show>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="h-[7px] w-[7px] rounded-full transition-colors duration-500"
|
||||
style={{ background: STATUS[props.status] }}
|
||||
/>
|
||||
<span class="rounded-full bg-white/[0.08] px-[7px] py-[2px] text-[10px] text-white/80">
|
||||
{props.age}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** SwiftUI's dark-mode .yellow, which the approval card tints with. */
|
||||
const YELLOW = "255,214,10";
|
||||
|
||||
/**
|
||||
* ApprovalCard: brand mark, session title, "approval" capsule; "Run <tool>",
|
||||
* the input in mono; Allow (accent, the island's tint), Allow for session,
|
||||
* Deny, and Deny with reason….
|
||||
*/
|
||||
export function ApprovalCardMock(props: {
|
||||
agent: AgentLook;
|
||||
title: string;
|
||||
tool: string;
|
||||
input: string;
|
||||
pressed?: "allow" | "session" | "deny";
|
||||
}) {
|
||||
const button = (key: "allow" | "session" | "deny", label: string, prominent = false) => (
|
||||
<span
|
||||
class="rounded-md px-2 py-[3px] text-[11px] font-medium whitespace-nowrap text-white transition-transform duration-150"
|
||||
style={{
|
||||
background: prominent ? ACCENT : "rgba(255,255,255,0.12)",
|
||||
transform: props.pressed === key ? "scale(0.92)" : "scale(1)",
|
||||
filter: props.pressed === key ? "brightness(1.25)" : "none"
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div class="rounded-[10px] p-2.5 text-left" style={{ background: `rgba(${YELLOW},0.08)` }}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<img src={props.agent.icon} alt="" class="h-[13px] w-[13px]" />
|
||||
<span class="truncate text-[12px] font-semibold text-white">{props.title}</span>
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
style={{ background: `rgba(${YELLOW},0.25)` }}
|
||||
>
|
||||
approval
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[13px] font-medium text-white">Run {props.tool}</p>
|
||||
<p class="mt-0.5 truncate font-mono text-[11px] text-white/50">{props.input}</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{button("allow", "Allow", true)}
|
||||
{button("session", "Allow for session")}
|
||||
{button("deny", "Deny")}
|
||||
<span class="text-[11px] whitespace-nowrap text-white/50">Deny with reason…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Timer tape ──────────────────────────────────────────────────── */
|
||||
|
||||
/** TimerPalette.accent. */
|
||||
export const TIMER_ORANGE = "#FA9E4D";
|
||||
|
||||
/**
|
||||
* TimerTapeDrawing.ticks, ported: fixed-pitch 1.5 px ticks resting at 40 %
|
||||
* of the tape height, swelling to a 72 % crest (raised cosine) that leads
|
||||
* into the remaining side and trails a shorter shoulder behind.
|
||||
*/
|
||||
function tapeTicks(remaining: number, w: number, h: number) {
|
||||
const span = w - 2;
|
||||
const count = Math.floor(span / 6) + 1;
|
||||
const pitch = span / Math.max(1, count - 1);
|
||||
const boundary = 1 + span * remaining;
|
||||
const baseline = h * 0.4;
|
||||
const peak = h * 0.72;
|
||||
const lead = Math.min(84, w * 0.4);
|
||||
const trail = lead * 0.5;
|
||||
const swell = (d: number) => (d < 1 ? (1 + Math.cos(Math.PI * d)) / 2 : 0);
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const cx = 1 + i * pitch;
|
||||
const offset = boundary - cx;
|
||||
const rise = remaining > 0 ? swell(Math.abs(offset) / (offset >= 0 ? lead : trail)) : 0;
|
||||
const height = baseline + (peak - baseline) * rise;
|
||||
const fill = remaining > 0 ? Math.min(1, Math.max(0, offset / pitch + 1)) : 0;
|
||||
return { x: cx - 0.75, y: (h - height) / 2, height, fill };
|
||||
});
|
||||
}
|
||||
|
||||
export function TimerTapeMock(props: {
|
||||
remaining: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${props.width} ${props.height}`}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
class="block max-w-full"
|
||||
style={{ opacity: props.opacity ?? 1 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<For each={tapeTicks(Math.min(1, Math.max(0, props.remaining)), props.width, props.height)}>
|
||||
{(t) => (
|
||||
<>
|
||||
<Show when={t.fill < 1}>
|
||||
<rect x={t.x} y={t.y} width="1.5" height={t.height} rx="0.75" fill="rgba(255,255,255,0.18)" />
|
||||
</Show>
|
||||
<Show when={t.fill > 0}>
|
||||
<rect
|
||||
x={t.x}
|
||||
y={t.y}
|
||||
width="1.5"
|
||||
height={t.height}
|
||||
rx="0.75"
|
||||
fill={props.color ?? TIMER_ORANGE}
|
||||
opacity={t.fill}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Temperatures ────────────────────────────────────────────────── */
|
||||
|
||||
/** MetricPalette.usage: calm below 70 %, warm to 90 %, critical above. */
|
||||
function usageColor(fraction: number) {
|
||||
if (fraction >= 0.9) return "rgba(229,72,77,0.9)";
|
||||
if (fraction >= 0.7) return WARM;
|
||||
return CALM;
|
||||
}
|
||||
|
||||
/** Tier color at the app's default thresholds (warn 70 °C, alert 85 °C). */
|
||||
function tierColor(celsius: number) {
|
||||
if (celsius >= 85) return "rgba(229,72,77,0.9)";
|
||||
if (celsius >= 70) return WARM;
|
||||
return "white";
|
||||
}
|
||||
|
||||
/**
|
||||
* One die line: label, its 60 s usage trace (filled at 20 %, 1.5 px line,
|
||||
* over a hairline floor), and the temperature in mono, tier-tinted.
|
||||
*/
|
||||
export function DieLine(props: { label: string; usage: number[]; celsius: number }) {
|
||||
const path = () => {
|
||||
const n = props.usage.length;
|
||||
return props.usage
|
||||
.map((u, i) => `${i === 0 ? "M" : "L"}${((i / (n - 1)) * 100).toFixed(2)},${(26 - u * 26).toFixed(2)}`)
|
||||
.join(" ");
|
||||
};
|
||||
const color = () => usageColor(props.usage[props.usage.length - 1] ?? 0);
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[9px] font-semibold text-white/50">{props.label}</span>
|
||||
<svg viewBox="0 0 100 26" preserveAspectRatio="none" class="h-[26px] min-w-0 flex-1" aria-hidden="true">
|
||||
<path d="M0,25.5 L100,25.5" stroke="rgba(255,255,255,0.08)" stroke-width="1" vector-effect="non-scaling-stroke" />
|
||||
<path d={`${path()} L100,26 L0,26 Z`} fill={color()} fill-opacity="0.2" />
|
||||
<path d={path()} fill="none" stroke={color()} stroke-width="1.5" vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
<span
|
||||
class="font-mono text-[15px] font-medium transition-colors duration-500"
|
||||
style={{ color: tierColor(props.celsius) }}
|
||||
>
|
||||
{Math.round(props.celsius)}°
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Wings ───────────────────────────────────────────────────────── */
|
||||
|
||||
export interface WingControlMock {
|
||||
glyph: GlyphName;
|
||||
on?: boolean;
|
||||
pressed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One edge's capsule: black at 94 %, a 1 px white-18 % stroke, and each
|
||||
* control a circle as tall as the Compact island, icon at 42 %.
|
||||
*/
|
||||
export function WingCapsule(props: { controls: WingControlMock[]; height: number }) {
|
||||
return (
|
||||
<div
|
||||
class="flex rounded-full"
|
||||
style={{
|
||||
height: `${props.height}px`,
|
||||
background: "rgba(0,0,0,0.94)",
|
||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.18)"
|
||||
}}
|
||||
>
|
||||
<For each={props.controls}>
|
||||
{(c) => (
|
||||
<span
|
||||
class="flex items-center justify-center rounded-full transition-all duration-200"
|
||||
style={{
|
||||
width: `${props.height}px`,
|
||||
height: `${props.height}px`,
|
||||
color: `rgba(255,255,255,${c.on === false ? 0.45 : 1})`,
|
||||
background: c.pressed ? "rgba(255,255,255,0.14)" : "transparent"
|
||||
}}
|
||||
>
|
||||
<Glyph name={c.glyph} size={Math.round(props.height * 0.42)} />
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
617
src/components/nook/MoreDemos.tsx
Normal file
617
src/components/nook/MoreDemos.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
import { For, Show, createSignal, type JSX } from "solid-js";
|
||||
import {
|
||||
ModuleCard,
|
||||
FakeCursor,
|
||||
IslandPill,
|
||||
AgentDotGrid,
|
||||
STATUS,
|
||||
ACCENT,
|
||||
CALM,
|
||||
CRITICAL,
|
||||
CARD_STROKE
|
||||
} from "./IslandMock";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { Glyph, WingCapsule, type GlyphName } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Live demos for the breakdowns added with the 0.6–0.10 feature set:
|
||||
* terminal replies, the volume mixer, Capture (module + Wings), and
|
||||
* floating actions. Same ink chrome and status palette as the originals.
|
||||
*/
|
||||
|
||||
/* ── Reply lands in the agent's own terminal ──────────────────────── */
|
||||
|
||||
const REPLY = "yes — and run them";
|
||||
|
||||
export function TerminalReplyDemo() {
|
||||
const [typed, setTyped] = createSignal(0);
|
||||
const [phase, setPhase] = createSignal<
|
||||
"asking" | "typing" | "sent" | "working" | "jump"
|
||||
>("asking");
|
||||
|
||||
useDemoLoop(10000, [
|
||||
[0, () => (setTyped(0), setPhase("asking"))],
|
||||
[900, () => setPhase("typing")],
|
||||
...REPLY.split("").map(
|
||||
(_, i) => [1000 + i * 70, () => setTyped(i + 1)] as [number, () => void]
|
||||
),
|
||||
[2900, () => setPhase("sent")],
|
||||
[3300, () => setPhase("working")],
|
||||
[6600, () => setPhase("jump")]
|
||||
]);
|
||||
|
||||
const delivered = () => phase() === "working" || phase() === "jump";
|
||||
const cursorPhase = () =>
|
||||
phase() === "sent" || phase() === "jump"
|
||||
? ("press" as const)
|
||||
: phase() === "typing" || phase() === "asking"
|
||||
? ("idle" as const)
|
||||
: ("live" as const);
|
||||
const cursorAt = () =>
|
||||
phase() === "jump" ? { x: 88, y: 14 } : { x: 88, y: 80 };
|
||||
|
||||
return (
|
||||
<div class="mx-auto flex w-full max-w-[340px] flex-col gap-3">
|
||||
<ModuleCard class="relative">
|
||||
<div class="space-y-2 text-left">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-[7px] w-[7px] rounded-full transition-colors duration-500"
|
||||
style={{
|
||||
background: delivered() ? STATUS.running : STATUS.ready
|
||||
}}
|
||||
/>
|
||||
<span class="text-[13px] font-semibold text-white">the-nook</span>
|
||||
<span class="font-mono text-[10px] text-white/40">main</span>
|
||||
<span
|
||||
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white/80 transition-colors duration-300"
|
||||
style={{
|
||||
background:
|
||||
phase() === "jump" ? ACCENT : "rgba(255,255,255,0.08)"
|
||||
}}
|
||||
>
|
||||
Jump ↗
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] leading-snug text-white/60">
|
||||
{delivered()
|
||||
? "Running the test suite…"
|
||||
: "Done. Want me to update the tests too?"}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg px-2 py-1.5"
|
||||
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||
>
|
||||
<span class="min-h-[14px] flex-1 truncate font-mono text-[11px] text-white/85">
|
||||
{delivered() ? "" : REPLY.slice(0, typed())}
|
||||
<Show when={phase() === "typing"}>
|
||||
<span class="nook-caret">▍</span>
|
||||
</Show>
|
||||
<Show when={!typed() || delivered()}>
|
||||
<span class="text-white/30">Reply…</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span
|
||||
class="rounded-md px-2 py-0.5 text-[10px] font-semibold text-white transition-transform duration-200"
|
||||
style={{
|
||||
background: ACCENT,
|
||||
transform: phase() === "sent" ? "scale(0.9)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<FakeCursor phase={cursorPhase()} at={cursorAt()} />
|
||||
</ModuleCard>
|
||||
|
||||
{/* the terminal the session actually runs in */}
|
||||
<div
|
||||
class="overflow-hidden rounded-lg text-left transition-shadow duration-500"
|
||||
style={{
|
||||
background: "#16161a",
|
||||
"box-shadow":
|
||||
phase() === "jump"
|
||||
? `0 0 0 2px ${ACCENT}, 0 8px 30px rgba(72,151,178,0.35)`
|
||||
: `inset 0 0 0 1px ${CARD_STROKE}`
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 px-2.5 py-1.5">
|
||||
<span class="h-2 w-2 rounded-full bg-[#ff5f57]" />
|
||||
<span class="h-2 w-2 rounded-full bg-[#febc2e]" />
|
||||
<span class="h-2 w-2 rounded-full bg-[#28c840]" />
|
||||
<span class="ml-2 font-mono text-[10px] text-white/40">
|
||||
tmux · the-nook:2
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto text-[9px] font-semibold tracking-wide uppercase transition-opacity duration-300"
|
||||
style={{ color: ACCENT, opacity: phase() === "jump" ? 1 : 0 }}
|
||||
>
|
||||
focused
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-0.5 px-2.5 pb-2.5 font-mono text-[10.5px] leading-relaxed">
|
||||
<p class="text-white/40">⏺ Updated 3 files</p>
|
||||
<p class="text-white/70">
|
||||
>{" "}
|
||||
<span
|
||||
class="transition-opacity duration-300"
|
||||
style={{ opacity: delivered() ? 1 : 0 }}
|
||||
>
|
||||
{REPLY}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
class="transition-opacity duration-500"
|
||||
style={{ color: CALM, opacity: delivered() ? 1 : 0 }}
|
||||
>
|
||||
⏺ Bash(swift test)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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 MixerDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
useDemoLoop(9600, [
|
||||
[0, () => setStep(0)],
|
||||
[1800, () => setStep(1)],
|
||||
[3800, () => setStep(2)],
|
||||
[5800, () => setStep(3)]
|
||||
]);
|
||||
|
||||
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 (
|
||||
<ModuleCard class="mx-auto w-full max-w-[340px]">
|
||||
<div class="space-y-2.5 text-left">
|
||||
<div class="flex items-center">
|
||||
<span class="text-[11px] font-semibold text-white/50">
|
||||
Volume mixer
|
||||
</span>
|
||||
<span class="ml-auto font-mono text-[10px] text-white/40">
|
||||
out · Speakers
|
||||
</span>
|
||||
</div>
|
||||
<For each={MIXER_APPS}>
|
||||
{(app, i) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px] text-[8px] font-bold text-black/70"
|
||||
style={{ background: app.color }}
|
||||
>
|
||||
{app.glyph}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="truncate text-[11px] font-semibold text-white/85">
|
||||
{app.name}
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded px-1.5 text-[9px] text-white/55 transition-colors duration-500"
|
||||
style={{
|
||||
background:
|
||||
i() === 0 && step() === 2
|
||||
? `${ACCENT}55`
|
||||
: "rgba(255,255,255,0.07)"
|
||||
}}
|
||||
>
|
||||
{route(i())}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative mt-1 h-[5px] overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={{
|
||||
width: `${level(i()) * 100}%`,
|
||||
background: muted(i()) ? "rgba(255,255,255,0.18)" : CALM,
|
||||
transition: "width 0.9s cubic-bezier(0.22,1,0.36,1), background 0.3s"
|
||||
}}
|
||||
/>
|
||||
{/* live signal riding inside the level */}
|
||||
<div
|
||||
class="nook-meter absolute top-0 left-0 h-full rounded-full"
|
||||
style={{
|
||||
width: `${level(i()) * 100}%`,
|
||||
background: "rgba(255,255,255,0.35)",
|
||||
opacity: muted(i()) ? 0 : 1,
|
||||
"animation-duration": `${0.5 + i() * 0.17}s`,
|
||||
transition: "width 0.9s cubic-bezier(0.22,1,0.36,1), opacity 0.3s"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="w-4 shrink-0 text-center text-[11px] transition-colors duration-300"
|
||||
style={{ color: muted(i()) ? CRITICAL : "rgba(255,255,255,0.4)" }}
|
||||
>
|
||||
{muted(i()) ? "✕" : "♪"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Capture: the module, and the Wings while a recording runs ────── */
|
||||
|
||||
/** CapturePalette.recording and the NookPalette paper/ink pair. */
|
||||
const RECORDING_RED = "rgb(242,69,69)";
|
||||
const PAPER = "#f1ead9";
|
||||
const INK = "#0d0d0f";
|
||||
|
||||
function CaptureChip(props: {
|
||||
glyph: GlyphName;
|
||||
label: string;
|
||||
menu?: boolean;
|
||||
pressed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded-full py-1.5 text-[11px] font-medium whitespace-nowrap text-white transition-transform duration-150"
|
||||
style={{
|
||||
background: props.pressed ? "rgba(255,255,255,0.16)" : "rgba(255,255,255,0.08)",
|
||||
transform: props.pressed ? "scale(0.94)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
<Glyph name={props.glyph} size={11} />
|
||||
{props.label}
|
||||
<Show when={props.menu}>
|
||||
<Glyph name="chevron" size={8} class="opacity-50" />
|
||||
</Show>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** CaptureAudioToggles: paper capsule when on, faint when off. */
|
||||
function AudioToggles(props: { system: boolean; mic: boolean }) {
|
||||
const toggle = (on: boolean, glyph: GlyphName, label: string) => (
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full px-2 py-1 text-[10.5px] font-medium whitespace-nowrap transition-colors duration-300"
|
||||
style={{
|
||||
background: on ? PAPER : "rgba(255,255,255,0.08)",
|
||||
color: on ? INK : "rgba(255,255,255,0.7)"
|
||||
}}
|
||||
>
|
||||
<Glyph name={glyph} size={10} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div class="flex gap-1.5">
|
||||
{toggle(props.system, props.system ? "speaker" : "speakerOff", "System audio")}
|
||||
{toggle(props.mic, props.mic ? "mic" : "micOff", "Microphone")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowLabel(props: { glyph: GlyphName; label: string }) {
|
||||
return (
|
||||
<p class="flex items-center gap-1 text-[11px] font-semibold text-white/50">
|
||||
<Glyph name={props.glyph} size={11} />
|
||||
{props.label}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CaptureDemo() {
|
||||
const [phase, setPhase] = createSignal<"idle" | "press" | "rec" | "paused" | "saved">("idle");
|
||||
const [secs, setSecs] = createSignal(0);
|
||||
const [mic, setMic] = createSignal(true);
|
||||
const [wingPress, setWingPress] = createSignal<"" | "mic" | "pause" | "stop">("");
|
||||
|
||||
useDemoLoop(12400, [
|
||||
[0, () => (setPhase("idle"), setSecs(0), setMic(true), setWingPress(""))],
|
||||
[1500, () => setPhase("press")],
|
||||
[1800, () => setPhase("rec")],
|
||||
...[1, 2, 3, 4, 5].map((s) => [1800 + s * 1000, () => setSecs(s)] as [number, () => void]),
|
||||
[4300, () => setWingPress("mic")],
|
||||
[4550, () => (setMic(false), setWingPress(""))],
|
||||
[7100, () => setWingPress("pause")],
|
||||
[7350, () => (setPhase("paused"), setWingPress(""))],
|
||||
[9000, () => setWingPress("stop")],
|
||||
[9250, () => (setPhase("saved"), setWingPress(""))]
|
||||
]);
|
||||
|
||||
const recording = () => phase() === "rec" || phase() === "paused";
|
||||
const paused = () => phase() === "paused";
|
||||
/* Island row is drawn at half size; Wing controls are as tall as it. */
|
||||
const wingWidth = 2 * 60 + 12;
|
||||
const cursor = () => {
|
||||
switch (wingPress()) {
|
||||
case "mic":
|
||||
return { phase: "press" as const, at: { x: 21.8, y: 7 } };
|
||||
case "pause":
|
||||
return { phase: "press" as const, at: { x: 78.2, y: 7 } };
|
||||
case "stop":
|
||||
return { phase: "press" as const, at: { x: 86.5, y: 7 } };
|
||||
}
|
||||
if (phase() === "idle" || phase() === "press")
|
||||
return { phase: phase() === "press" ? ("press" as const) : ("idle" as const), at: { x: 50, y: 58 } };
|
||||
if (phase() === "saved") return { phase: "live" as const, at: { x: 86.5, y: 7 } };
|
||||
return { phase: "idle" as const, at: { x: 21.8, y: 30 } };
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative mx-auto w-full max-w-[360px]">
|
||||
{/* the Compact island, flanked by the Wings while recording */}
|
||||
<div class="relative h-[30px]">
|
||||
<div
|
||||
class="absolute top-0 left-1/2 flex items-start"
|
||||
style={{ transform: "translateX(-50%) scale(0.5)", "transform-origin": "top center" }}
|
||||
>
|
||||
<div
|
||||
class="flex justify-end overflow-hidden transition-all duration-500"
|
||||
style={{ width: recording() ? `${wingWidth}px` : "0px", opacity: recording() ? 1 : 0 }}
|
||||
>
|
||||
<WingCapsule
|
||||
height={60}
|
||||
controls={[
|
||||
{ glyph: "speaker" },
|
||||
{ glyph: mic() ? "mic" : "micOff", on: mic(), pressed: wingPress() === "mic" }
|
||||
]}
|
||||
/>
|
||||
<span class="w-3 shrink-0" />
|
||||
</div>
|
||||
<IslandPill
|
||||
campfire="running"
|
||||
right={<AgentDotGrid states={["running", "ready", "running"]} />}
|
||||
/>
|
||||
<div
|
||||
class="flex overflow-hidden transition-all duration-500"
|
||||
style={{ width: recording() ? `${wingWidth}px` : "0px", opacity: recording() ? 1 : 0 }}
|
||||
>
|
||||
<span class="w-3 shrink-0" />
|
||||
<WingCapsule
|
||||
height={60}
|
||||
controls={[
|
||||
{ glyph: paused() ? "play" : "pause", pressed: wingPress() === "pause" },
|
||||
{ glyph: "stop", pressed: wingPress() === "stop" }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* the Capture module */}
|
||||
<ModuleCard class="mt-5">
|
||||
<div class="min-h-[150px] text-left">
|
||||
<Show
|
||||
when={recording()}
|
||||
fallback={
|
||||
<div class="space-y-2.5">
|
||||
<div class="space-y-1.5">
|
||||
<RowLabel glyph="viewfinder" label="Screenshot" />
|
||||
<div class="flex gap-1.5">
|
||||
<CaptureChip glyph="dashed" label="Area" />
|
||||
<CaptureChip glyph="display" label="Screen" />
|
||||
<CaptureChip glyph="window" label="App" menu />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<RowLabel glyph="record" label="Record" />
|
||||
<div class="flex gap-1.5">
|
||||
<CaptureChip glyph="dashed" label="Area" />
|
||||
<CaptureChip glyph="display" label="Screen" pressed={phase() === "press"} />
|
||||
<CaptureChip glyph="window" label="App" menu />
|
||||
</div>
|
||||
<AudioToggles system mic={mic()} />
|
||||
</div>
|
||||
<Show when={phase() === "saved"}>
|
||||
<div class="flex items-center gap-1.5 text-[10.5px]">
|
||||
<Glyph name="film" size={11} class="text-white/50" />
|
||||
<span class="min-w-0 flex-1 truncate text-white/85">
|
||||
Screen Recording 2026-10-13 at 9.41.12.mov
|
||||
</span>
|
||||
<span class="shrink-0 text-white/50">Show in Finder</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full transition-colors"
|
||||
style={{ background: paused() ? "rgba(255,255,255,0.4)" : RECORDING_RED }}
|
||||
/>
|
||||
<span
|
||||
class="font-mono text-[22px] font-semibold transition-colors"
|
||||
style={{ color: paused() ? "rgba(255,255,255,0.5)" : "white" }}
|
||||
>
|
||||
0:0{secs()}
|
||||
</span>
|
||||
<span class="ml-auto font-mono text-[10px] font-medium text-white/50">
|
||||
{paused() ? "Paused" : "Recording"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<CaptureChip glyph={paused() ? "play" : "pause"} label={paused() ? "Resume" : "Pause"} />
|
||||
<CaptureChip glyph="stop" label="Stop" />
|
||||
</div>
|
||||
<AudioToggles system mic={mic()} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
|
||||
{/* cursor plotted on a fixed 360 px frame centered over the demo */}
|
||||
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-[360px] -translate-x-1/2">
|
||||
<FakeCursor phase={cursor().phase} at={cursor().at} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Floating actions bloom around the open island ────────────────── */
|
||||
|
||||
/* Tiny monochrome glyphs for the action kinds the app offers. */
|
||||
const GLYPH: Record<string, () => JSX.Element> = {
|
||||
leaf: () => (
|
||||
<path d="M3 13C3 7 7 3 13 3c0 6-4 10-10 10Zm0 0 6-6" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" />
|
||||
),
|
||||
bolt: () => <path d="M9 1 3 9h4l-1 6 6-8H8l1-6Z" fill="currentColor" />,
|
||||
timer: () => (
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||
<circle cx="8" cy="9" r="5.5" />
|
||||
<path d="M8 9V6M6.5 1.5h3" />
|
||||
</g>
|
||||
),
|
||||
speaker: () => (
|
||||
<g fill="currentColor">
|
||||
<path d="M2 6h3l4-3v10l-4-3H2Z" />
|
||||
<path d="M11 5.5a3.5 3.5 0 0 1 0 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
|
||||
</g>
|
||||
),
|
||||
shell: () => (
|
||||
<path d="m3 5 3 3-3 3m5 0h5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
),
|
||||
stack: () => (
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path d="m8 2 6 3-6 3-6-3 6-3Z" />
|
||||
<path d="m2 8.5 6 3 6-3M2 11.5l6 3 6-3" />
|
||||
</g>
|
||||
),
|
||||
link: () => (
|
||||
<path d="M7 9a3 3 0 0 0 4 0l2-2a3 3 0 0 0-4-4l-1 1m1 3a3 3 0 0 0-4 0l-2 2a3 3 0 0 0 4 4l1-1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
|
||||
)
|
||||
};
|
||||
|
||||
interface FloatAction {
|
||||
glyph: keyof typeof GLYPH;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/* Positions mirror the app's slots: Left ·1/·2, Right ·1/·2, Bottom ·1–3. */
|
||||
const ACTIONS: FloatAction[] = [
|
||||
{ glyph: "leaf", label: "Low Power Mode on", x: -128, y: -18 },
|
||||
{ glyph: "bolt", label: "Top-up to 100%", x: -128, y: 26 },
|
||||
{ glyph: "speaker", label: "Output → Headphones", x: 128, y: -18 },
|
||||
{ glyph: "stack", label: "Shortcut: Focus Time", x: 128, y: 26 },
|
||||
{ glyph: "timer", label: "Timer · 25 min", x: -50, y: 78 },
|
||||
{ glyph: "shell", label: "$ make deploy", x: 0, y: 78 },
|
||||
{ glyph: "link", label: "Opened dashboard", x: 50, y: 78 }
|
||||
];
|
||||
|
||||
export function FloatingActionsDemo() {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [pressed, setPressed] = createSignal(-1);
|
||||
const [lit, setLit] = createSignal<number[]>([]);
|
||||
|
||||
useDemoLoop(9600, [
|
||||
[0, () => (setOpen(false), setPressed(-1), setLit([]))],
|
||||
[400, () => setOpen(true)],
|
||||
[2200, () => setPressed(0)],
|
||||
[2500, () => setLit([0])],
|
||||
[4000, () => setPressed(4)],
|
||||
[4300, () => setLit([0, 4])],
|
||||
[5800, () => setPressed(2)],
|
||||
[6100, () => setLit([0, 4, 2])],
|
||||
[8800, () => (setOpen(false), setPressed(-1))]
|
||||
]);
|
||||
|
||||
const lastLit = () => lit()[lit().length - 1];
|
||||
|
||||
return (
|
||||
<div class="relative mx-auto flex h-[240px] w-full max-w-[340px] items-start justify-center pt-6">
|
||||
{/* the expanded island */}
|
||||
<div
|
||||
class="relative z-10 rounded-b-[18px] bg-black transition-all duration-500"
|
||||
style={{
|
||||
width: open() ? "190px" : "120px",
|
||||
height: open() ? "96px" : "26px",
|
||||
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)",
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="space-y-1.5 p-3 transition-opacity duration-300"
|
||||
style={{ opacity: open() ? 1 : 0 }}
|
||||
>
|
||||
<div class="h-1.5 w-2/3 rounded bg-white/20" />
|
||||
<div class="h-1.5 w-1/2 rounded bg-white/10" />
|
||||
<div class="h-1.5 w-3/4 rounded" style={{ background: `${CALM}66` }} />
|
||||
</div>
|
||||
<For each={ACTIONS}>
|
||||
{(a, i) => (
|
||||
<span
|
||||
class="absolute flex h-8 w-8 items-center justify-center rounded-full transition-all"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: open()
|
||||
? `translate(calc(-50% + ${a.x}px), calc(-50% + ${a.y - 6}px)) scale(${pressed() === i() ? 0.82 : 1})`
|
||||
: "translate(-50%, -50%) scale(0.2)",
|
||||
opacity: open() ? 1 : 0,
|
||||
background: lit().includes(i()) ? ACCENT : "#1c1c20",
|
||||
color: lit().includes(i()) ? "white" : "rgba(255,255,255,0.7)",
|
||||
"box-shadow": `inset 0 0 0 1px rgba(255,255,255,0.12), 0 4px 14px rgba(0,0,0,0.5)`,
|
||||
"transition-duration": "520ms",
|
||||
"transition-delay": open() ? `${i() * 45}ms` : `${(6 - i()) * 25}ms`,
|
||||
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)"
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16">
|
||||
{GLYPH[a.glyph]()}
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* what the last press did */}
|
||||
<div
|
||||
class="absolute bottom-1 left-1/2 -translate-x-1/2 rounded-full px-3 py-1 text-[10px] font-semibold whitespace-nowrap text-white transition-all duration-300"
|
||||
style={{
|
||||
background: "rgba(72,151,178,0.22)",
|
||||
opacity: lastLit() !== undefined && open() ? 1 : 0
|
||||
}}
|
||||
>
|
||||
{lastLit() !== undefined ? ACTIONS[lastLit()].label : " "}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
src/components/nook/PhoneBeta.tsx
Normal file
286
src/components/nook/PhoneBeta.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import Button from "~/components/ui/Button";
|
||||
import Input from "~/components/ui/Input";
|
||||
import { STATUS, ACCENT } from "./IslandMock";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { betaRequestPayload } from "./betaRequest";
|
||||
|
||||
/**
|
||||
* iPhone + Apple Watch companion, in TestFlight beta. The request form
|
||||
* mirrors Settings ▸ Phone's BetaAccessCard in the app: it sends only the
|
||||
* email, over the same `/api/the-nook/report` pipeline (see betaRequest.ts).
|
||||
*/
|
||||
|
||||
type RequestState = "idle" | "sending" | "sent" | "limited" | "failed";
|
||||
|
||||
function requestBetaAccess(email: string): Promise<Response> {
|
||||
return fetch("/api/the-nook/report", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(betaRequestPayload(email))
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Demo: a request lands on the phone, a question on the wrist ─── */
|
||||
|
||||
function CompanionDemo() {
|
||||
const [phase, setPhase] = createSignal<
|
||||
"quiet" | "ask" | "allowed" | "question" | "answered"
|
||||
>("quiet");
|
||||
useDemoLoop(11000, [
|
||||
[0, () => setPhase("quiet")],
|
||||
[900, () => setPhase("ask")],
|
||||
[3600, () => setPhase("allowed")],
|
||||
[5400, () => setPhase("question")],
|
||||
[8200, () => setPhase("answered")]
|
||||
]);
|
||||
|
||||
const askVisible = () => phase() !== "quiet";
|
||||
const allowed = () => phase() !== "quiet" && phase() !== "ask";
|
||||
const working = () => (allowed() ? 3 : 2);
|
||||
const waiting = () => (phase() === "ask" ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div class="flex items-end justify-center gap-5">
|
||||
{/* iPhone */}
|
||||
<div
|
||||
class="relative h-[300px] w-[150px] overflow-hidden rounded-[28px] p-2"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(120% 80% at 30% 0%, #2b4a6b 0%, #191b2e 60%, #0d0d0f 100%)",
|
||||
"box-shadow":
|
||||
"inset 0 0 0 1.5px rgba(255,255,255,0.18), 0 20px 50px rgba(0,0,0,0.5)"
|
||||
}}
|
||||
>
|
||||
<div class="mx-auto mt-1 h-[16px] w-[52px] rounded-full bg-black" />
|
||||
<p class="mt-3 text-center text-[30px] leading-none font-semibold text-white/90">
|
||||
9:41
|
||||
</p>
|
||||
{/* Lock Screen widget: what the numbers count */}
|
||||
<div class="mt-2 flex justify-center gap-1">
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[8px] font-semibold whitespace-nowrap text-white/85"
|
||||
style={{ background: "rgba(255,255,255,0.12)" }}
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-[1px]" style={{ background: STATUS.running }} />
|
||||
{working()} working
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[8px] font-semibold whitespace-nowrap text-white/85"
|
||||
style={{ background: "rgba(255,255,255,0.12)" }}
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-[1px]"
|
||||
style={{ background: waiting() ? STATUS.attention : STATUS.idle }}
|
||||
/>
|
||||
{waiting()} waiting
|
||||
</span>
|
||||
</div>
|
||||
{/* notification */}
|
||||
<div
|
||||
class="absolute inset-x-2 top-[118px] rounded-2xl p-2 text-left transition-all duration-500"
|
||||
style={{
|
||||
background: "rgba(40,40,46,0.88)",
|
||||
"backdrop-filter": "blur(8px)",
|
||||
opacity: askVisible() ? 1 : 0,
|
||||
transform: askVisible() ? "translateY(0)" : "translateY(-14px)"
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<img src="/nook/icon.png" alt="" class="h-3 w-3 rounded-[3px]" />
|
||||
<span class="text-[7.5px] font-semibold tracking-wide text-white/50 uppercase">
|
||||
The Nook · now
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[9.5px] font-semibold text-white">
|
||||
{allowed() ? "Allowed ✓" : "Claude Code · the-nook"}
|
||||
</p>
|
||||
<p class="text-[9px] leading-snug text-white/60">
|
||||
{allowed() ? "Back to work." : "Run Bash: swift test"}
|
||||
</p>
|
||||
<Show when={!allowed()}>
|
||||
<div class="mt-1.5 flex gap-1">
|
||||
<span
|
||||
class="flex-1 rounded-md py-0.5 text-center text-[8.5px] font-semibold text-white"
|
||||
style={{ background: ACCENT }}
|
||||
>
|
||||
Allow
|
||||
</span>
|
||||
<span class="flex-1 rounded-md bg-white/10 py-0.5 text-center text-[8.5px] font-semibold text-white/80">
|
||||
Deny
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apple Watch */}
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="h-3 w-[64px] rounded-t-md bg-white/10" />
|
||||
<div
|
||||
class="relative h-[118px] w-[98px] overflow-hidden rounded-[26px] bg-black p-2.5"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"inset 0 0 0 2px rgba(255,255,255,0.16), 0 14px 34px rgba(0,0,0,0.5)",
|
||||
transform: phase() === "question" ? "rotate(-2deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.15s"
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={phase() === "question" || phase() === "answered"}
|
||||
fallback={
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<span class="font-mono text-[18px] font-semibold text-white/90">
|
||||
{working()}
|
||||
</span>
|
||||
<span class="text-[8px] text-white/50">agents working</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p class="text-[8px] font-semibold text-white/50">Codex asks</p>
|
||||
<p class="mt-0.5 text-[9.5px] leading-snug font-semibold text-white">
|
||||
Ship it to staging?
|
||||
</p>
|
||||
<div class="mt-2 space-y-1">
|
||||
<For each={["Yes", "Not yet"]}>
|
||||
{(option, i) => (
|
||||
<span
|
||||
class="block rounded-full py-1 text-center text-[8.5px] font-semibold transition-colors duration-300"
|
||||
style={{
|
||||
background:
|
||||
phase() === "answered" && i() === 0
|
||||
? STATUS.ready
|
||||
: "rgba(255,255,255,0.12)",
|
||||
color: "white"
|
||||
}}
|
||||
>
|
||||
{phase() === "answered" && i() === 0 ? "Sent ✓" : option}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="h-3 w-[64px] rounded-b-md bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PhoneBeta(props: { brandColor: string }) {
|
||||
const [email, setEmail] = createSignal("");
|
||||
const [state, setState] = createSignal<RequestState>("idle");
|
||||
|
||||
const submit = async () => {
|
||||
const contact = email().trim();
|
||||
if (!contact || state() === "sending") return;
|
||||
setState("sending");
|
||||
try {
|
||||
const res = await requestBetaAccess(contact);
|
||||
if (res.status === 429) return setState("limited");
|
||||
if (!res.ok) return setState("failed");
|
||||
setEmail("");
|
||||
setState("sent");
|
||||
} catch {
|
||||
setState("failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class="bg-base relative z-20 px-4 py-20 md:px-8" id="iphone">
|
||||
<div class="mx-auto grid max-w-5xl grid-cols-1 items-center gap-12 md:grid-cols-2">
|
||||
<div>
|
||||
<span class="border-overlay0 bg-surface0 text-subtext0 mb-4 inline-block rounded-full border px-2.5 py-0.5 text-[11px] font-semibold tracking-wide">
|
||||
TestFlight beta
|
||||
</span>
|
||||
<h2 class="text-text mb-3 text-3xl font-bold tracking-tight">
|
||||
Your fleet, in your pocket
|
||||
</h2>
|
||||
<p class="text-subtext0 mb-5 leading-relaxed">
|
||||
The iPhone and Apple Watch companion is in beta. Approvals,
|
||||
questions, and replies reach you away from the desk, and your
|
||||
answer goes straight back to the agent.
|
||||
</p>
|
||||
<ul class="mb-8 space-y-2.5">
|
||||
<For
|
||||
each={[
|
||||
"Allow, deny, answer, and reply from iPhone or Apple Watch",
|
||||
"Connects over an end-to-end encrypted relay, your tailnet, or your LAN",
|
||||
"Home Screen, Lock Screen, and watch-face widgets for fleet status",
|
||||
"Mute notifications for a while, or while you're active at the Mac"
|
||||
]}
|
||||
>
|
||||
{(bullet) => (
|
||||
<li class="text-subtext1 flex gap-2.5 text-sm">
|
||||
<span
|
||||
class="mt-[7px] inline-block h-1.5 w-1.5 shrink-0 rounded-[2px]"
|
||||
style={{ background: ACCENT }}
|
||||
/>
|
||||
{bullet}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
|
||||
<div class="border-overlay0 bg-surface0 rounded-xl border p-6">
|
||||
<h3 class="text-text mb-1 text-lg font-bold">Request beta access</h3>
|
||||
<p class="text-subtext0 mb-4 text-sm">
|
||||
Leave your email to get a TestFlight invite. We only receive your
|
||||
email address.
|
||||
</p>
|
||||
<form
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-end"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
label="Email"
|
||||
containerClass="flex-1"
|
||||
disabled={state() === "sending"}
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
color={props.brandColor}
|
||||
loading={state() === "sending"}
|
||||
>
|
||||
Request access
|
||||
</Button>
|
||||
</form>
|
||||
<Show when={state() === "sent"}>
|
||||
<p class="text-subtext1 mt-4 text-sm">
|
||||
Thanks. Watch your inbox for a TestFlight invite.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={state() === "limited"}>
|
||||
<p class="mt-4 text-sm text-red-500">
|
||||
Too many requests from this network. Try again later.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={state() === "failed"}>
|
||||
<p class="mt-4 text-sm text-red-500">
|
||||
Couldn't send the request. Check your connection and try again.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-2xl px-6 py-10"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(70% 90% at 50% 0%, rgba(72,151,178,0.12) 0%, transparent 70%) #0d0d0f"
|
||||
}}
|
||||
>
|
||||
<CompanionDemo />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
12
src/components/nook/betaRequest.ts
Normal file
12
src/components/nook/betaRequest.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The iPhone beta-access request the landing page posts to
|
||||
* `/api/the-nook/report` — the same title the app's Settings ▸ Phone card
|
||||
* sends, so requests from both land together. Email only, no device facts.
|
||||
*/
|
||||
export function betaRequestPayload(email: string) {
|
||||
return {
|
||||
title: "iPhone app beta access request",
|
||||
description: "Beta access requested from nook.freno.me.",
|
||||
contact: email
|
||||
};
|
||||
}
|
||||
28
src/components/nook/useDemoLoop.ts
Normal file
28
src/components/nook/useDemoLoop.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { onCleanup, onMount } from "solid-js";
|
||||
|
||||
/**
|
||||
* Drives a looping landing-page demo: each `[atMs, step]` pair fires at its
|
||||
* offset, and the whole schedule restarts every `periodMs`. Timers are torn
|
||||
* down with the owning component.
|
||||
*/
|
||||
export function useDemoLoop(
|
||||
periodMs: number,
|
||||
steps: [atMs: number, step: () => void][]
|
||||
): void {
|
||||
onMount(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const run = () => {
|
||||
timers.length = 0;
|
||||
for (const [at, step] of steps) {
|
||||
if (at === 0) step();
|
||||
else timers.push(setTimeout(step, at));
|
||||
}
|
||||
};
|
||||
run();
|
||||
const interval = setInterval(run, periodMs);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
timers.forEach(clearTimeout);
|
||||
});
|
||||
});
|
||||
}
|
||||
16
src/env/server.ts
vendored
16
src/env/server.ts
vendored
@@ -53,18 +53,7 @@ const serverEnvSchema = z.object({
|
||||
VITE_WEBSOCKET: z.string().min(1),
|
||||
VITE_INFILL_ENDPOINT: z.string().min(1),
|
||||
INFILL_BEARER_TOKEN: z.string().min(1),
|
||||
NESSA_DB_URL: z.string().min(1),
|
||||
NESSA_DB_TOKEN: z.string().min(1),
|
||||
// Clerk authentication — Nessa auth is now Clerk-backed. The
|
||||
// legacy self-issued JWT signing env var was removed.
|
||||
NESSA_CLERK_SECRET: z.string().min(1),
|
||||
NESSA_CLERK_JWT_ISSUER: z.string().min(1),
|
||||
// Clerk webhook signing secret (Svix). Used to verify `user.created` /
|
||||
// `user.updated` webhook payloads. Find it under Clerk Dashboard →
|
||||
// Webhooks → your endpoint → Signing Secret (starts with `whsec_`).
|
||||
NESSA_CLERK_WEBHOOK_SECRET: z.string().min(1),
|
||||
LINEAGE_JWT_SECRET: z.string().min(32),
|
||||
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
|
||||
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
||||
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
|
||||
TURNSTILE_SECRET_KEY: z.string().min(1),
|
||||
@@ -183,11 +172,6 @@ export const getMissingEnvVars = (): string[] => {
|
||||
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
|
||||
"VITE_GITHUB_CLIENT_ID",
|
||||
"VITE_WEBSOCKET",
|
||||
"NESSA_DB_URL",
|
||||
"NESSA_DB_TOKEN",
|
||||
"NESSA_CLERK_SECRET",
|
||||
"NESSA_CLERK_JWT_ISSUER",
|
||||
"NESSA_CLERK_WEBHOOK_SECRET",
|
||||
"LINEAGE_JWT_SECRET"
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,35 @@
|
||||
* Tests inject a fake `api` and a capture sink.
|
||||
*/
|
||||
|
||||
/** Canonical analytics path for one download event. One label per asset, so a
|
||||
* click on a landing page and a Sparkle update of the same app land in the
|
||||
* same bucket on the analytics page. */
|
||||
export const DOWNLOAD_PATH_PREFIX = "/api/downloads/";
|
||||
|
||||
export function downloadPath(asset: string): string {
|
||||
return `${DOWNLOAD_PATH_PREFIX}${asset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group download rows by the asset named in their canonical path, and total
|
||||
* them. Split out from the query so the labelling has a unit test without a
|
||||
* database — the writer and the reader share `DOWNLOAD_PATH_PREFIX`, so the
|
||||
* two cannot drift apart.
|
||||
*/
|
||||
export function summariseDownloads(
|
||||
rows: Array<{ path: string; count: number }>
|
||||
): { total: number; byAsset: Array<{ asset: string; count: number }> } {
|
||||
const byAsset = rows.map((row) => ({
|
||||
asset: row.path.slice(DOWNLOAD_PATH_PREFIX.length),
|
||||
count: row.count
|
||||
}));
|
||||
|
||||
return {
|
||||
total: byAsset.reduce((sum, row) => sum + row.count, 0),
|
||||
byAsset
|
||||
};
|
||||
}
|
||||
|
||||
/** Structural shape of the tRPC downloads proxy this helper depends on. */
|
||||
export interface DownloadApi {
|
||||
downloads: {
|
||||
|
||||
@@ -202,6 +202,32 @@ function FrenomePanel(props: FrenomePanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Downloads — every door logs one row at /api/downloads/<asset> */}
|
||||
<Show when={data.downloads.total > 0}>
|
||||
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
|
||||
<div class="border-surface1 border-b p-6">
|
||||
<h2 class="text-text text-2xl font-bold">Downloads</h2>
|
||||
<p class="text-subtext0 mt-1 text-sm">
|
||||
{formatNumber(data.downloads.total)} events in the last
|
||||
{props.days === 1 ? " 24 hours" : ` ${props.days} days`} —
|
||||
landing page clicks and files served, by product
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3 p-6">
|
||||
<For each={data.downloads.byAsset}>
|
||||
{(download) => (
|
||||
<StatBar
|
||||
label={download.asset}
|
||||
count={download.count}
|
||||
total={data.downloads.total}
|
||||
color="bg-teal-600"
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Performance */}
|
||||
<Show when={performanceStats()} keyed>
|
||||
{(ps) => (
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { env } from "~/env/server";
|
||||
import { NessaConnectionFactory } from "~/server/database";
|
||||
import { handleClerkUserWebhook } from "~/server/clerk-user-webhook";
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clerk webhook endpoint — receives `user.created` / `user.updated` events.
|
||||
*
|
||||
* Configure the endpoint URL in the Clerk Dashboard → Webhooks
|
||||
* (e.g. https://freno.me/api/clerk-webhook in prod, or an ngrok/dev url for
|
||||
* local dev). The signing secret (`whsec_...`) is stored in
|
||||
* `NESSA_CLERK_WEBHOOK_SECRET` and used to verify each request via Svix.
|
||||
*
|
||||
* The raw body is read verbatim from the inflight request so the Svix
|
||||
* signature is computed over the exact bytes Clerk sent.
|
||||
*/
|
||||
export async function POST(event: APIEvent) {
|
||||
const svixId = event.request.headers.get("svix-id");
|
||||
const svixTimestamp = event.request.headers.get("svix-timestamp");
|
||||
const svixSignature = event.request.headers.get("svix-signature");
|
||||
|
||||
if (!svixId || !svixTimestamp || !svixSignature) {
|
||||
return json(400, { error: "Missing Svix signature headers" });
|
||||
}
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await event.request.text();
|
||||
} catch {
|
||||
return json(400, { error: "Missing request body" });
|
||||
}
|
||||
|
||||
const result = await handleClerkUserWebhook({
|
||||
rawBody,
|
||||
headers: {
|
||||
"svix-id": svixId,
|
||||
"svix-timestamp": svixTimestamp,
|
||||
"svix-signature": svixSignature
|
||||
},
|
||||
webhookSecret: env.NESSA_CLERK_WEBHOOK_SECRET,
|
||||
conn: NessaConnectionFactory()
|
||||
});
|
||||
|
||||
return json(result.status, result.body);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { env } from "~/env/server";
|
||||
import { trackDownload } from "~/server/track-download";
|
||||
|
||||
/**
|
||||
* Serves macOS app DMG files and delta updates from S3
|
||||
@@ -8,7 +9,10 @@ import { env } from "~/env/server";
|
||||
*
|
||||
* Handles:
|
||||
* - Full DMG files: /api/downloads/Gaze-0.2.2.dmg, /api/downloads/InputHalo-0.1.0.dmg
|
||||
* - Delta updates: /api/downloads/Gaze3-2.delta, /api/downloads/InputHalo3-2.delta
|
||||
* - Delta updates: /api/downloads/Gaze3-2.delta, /api/downloads/Nook17-9.delta
|
||||
*
|
||||
* The Nook delta files are named after the app bundle ("Nook"), not the DMG
|
||||
* ("TheNook"), so both prefixes must be allowed (NOOK-337).
|
||||
*
|
||||
* URL: https://freno.me/api/downloads/[filename]
|
||||
*/
|
||||
@@ -24,10 +28,20 @@ export async function GET(event: APIEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
const validPrefixes = ["Gaze", "InputHalo", "TheNook"];
|
||||
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
||||
// Prefix → canonical asset key, so a version bump stays one asset in
|
||||
// analytics rather than one per release.
|
||||
const assetKeys: Record<string, string> = {
|
||||
Gaze: "gaze",
|
||||
InputHalo: "inputhalo",
|
||||
TheNook: "thenook",
|
||||
Nook: "thenook"
|
||||
};
|
||||
|
||||
const asset = Object.keys(assetKeys).find((prefix) =>
|
||||
filename.startsWith(prefix)
|
||||
);
|
||||
if (
|
||||
!isValidPrefix ||
|
||||
!asset ||
|
||||
(!filename.endsWith(".dmg") &&
|
||||
!filename.endsWith(".delta") &&
|
||||
!filename.endsWith(".zip"))
|
||||
@@ -81,13 +95,20 @@ export async function GET(event: APIEvent) {
|
||||
|
||||
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
||||
|
||||
return new Response(body, {
|
||||
await trackDownload(event, assetKeys[asset]!);
|
||||
|
||||
// SAFETY: `transformToByteArray` yields `Uint8Array<ArrayBufferLike>`, which the
|
||||
// DOM lib's `BufferSource` (ArrayBuffer-backed) does not accept, though undici
|
||||
// takes it as-is at runtime. State the intent at the one boundary it hits.
|
||||
return new Response(body as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": body.length.toString(),
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
||||
// Deliberately open: these are public installer files with no cookies or
|
||||
// credentials, fetched by Sparkle and by direct browser navigation.
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
});
|
||||
|
||||
48
src/routes/api/downloads/filename.test.ts
Normal file
48
src/routes/api/downloads/filename.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Regression test for the downloads proxy filename allowlist (NOOK-337).
|
||||
*
|
||||
* The v0.10.0 publish failed its CI enclosure check because Sparkle names
|
||||
* delta files after the app bundle ("Nook17-9.delta") while the DMG is named
|
||||
* "TheNook-*.dmg" — and "Nook" was missing from the allowlist, so every Nook
|
||||
* delta request 400'd with "Invalid file format" while the DMG passed.
|
||||
*
|
||||
* The route hits S3 and cannot be unit-tested under bun:test, so this is a
|
||||
* STATIC SOURCE AUDIT — the same pattern the downloads.test.ts regressions
|
||||
* use.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(
|
||||
join(import.meta.dir, "[filename].ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
/**
|
||||
* The allowlist moved from an array to a `prefix → asset key` map when download
|
||||
* tracking was added, so read the keys out of the map rather than matching
|
||||
* quoted strings.
|
||||
*/
|
||||
const ALLOWLIST_KEYS = [
|
||||
...(/const assetKeys: Record<string, string> = \{([^}]*)\}/.exec(SOURCE)?.[1] ?? "")
|
||||
.matchAll(/"?([A-Za-z]+)"?\s*:/g)
|
||||
].map((match) => match[1]);
|
||||
|
||||
describe("Downloads proxy filename allowlist (NOOK-337)", () => {
|
||||
it("allows the Nook app-bundle prefix used by Sparkle delta names", () => {
|
||||
expect(ALLOWLIST_KEYS).toContain("Nook");
|
||||
});
|
||||
|
||||
it("still allows every previously served prefix", () => {
|
||||
for (const prefix of ["Gaze", "InputHalo", "TheNook"]) {
|
||||
expect(ALLOWLIST_KEYS).toContain(prefix);
|
||||
}
|
||||
});
|
||||
|
||||
it("still serves the extensions Sparkle fetches", () => {
|
||||
for (const ext of [".dmg", ".delta", ".zip"]) {
|
||||
expect(SOURCE).toContain(`"${ext}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
185
src/routes/api/the-nook/_releases.ts
Normal file
185
src/routes/api/the-nook/_releases.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* The release list for The Nook's in-app changelog (Settings > Changelog).
|
||||
*
|
||||
* The /changelog page already answers this question, but only as HTML, and it
|
||||
* assembles the list inside the route component. A native client must not
|
||||
* couple to that markup, and the appcast alone is not the full story: releases
|
||||
* older than the appcast's retention live only in the checked-in archive. So
|
||||
* this module owns that merge — live appcast items, their notes filled from the
|
||||
* archive wherever the feed lost them, then archived releases the appcast has
|
||||
* aged out — and normalizes it into data a client can render without parsing
|
||||
* HTML. `withArchivedNotes` is exported so /changelog runs the identical rule
|
||||
* rather than a second copy of it.
|
||||
*
|
||||
* Underscore-prefixed file — not a route.
|
||||
*/
|
||||
import { ARCHIVED_RELEASES } from "~/routes/nook/changelog-archive";
|
||||
import { fetchAppcastReleases, type AppcastRelease } from "./_appcast";
|
||||
|
||||
export interface ReleaseSection {
|
||||
/** "Added" / "Changed" / "Fixed"; "Notes" when the notes carry no label. */
|
||||
title: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface AppRelease {
|
||||
version: string;
|
||||
/** Build number, null for archived releases (they carry none). */
|
||||
build: string | null;
|
||||
/** ISO calendar date, "2026-10-02"; null when no date was published. */
|
||||
date: string | null;
|
||||
sections: ReleaseSection[];
|
||||
}
|
||||
|
||||
const ENTITY_REPLACEMENTS: [RegExp, string][] = [
|
||||
[/</g, "<"],
|
||||
[/>/g, ">"],
|
||||
[/"/g, '"'],
|
||||
// Python's html.escape, which embed_release_notes.py writes the appcast
|
||||
// with, prefers the hex form for apostrophes (NOOK-565).
|
||||
[/'/g, "'"],
|
||||
[/'/g, "'"],
|
||||
[/'/g, "'"],
|
||||
[/ /g, " "],
|
||||
// Last, so "&lt;" cannot decode twice.
|
||||
[/&/g, "&"]
|
||||
];
|
||||
|
||||
function decodeEntities(text: string): string {
|
||||
return ENTITY_REPLACEMENTS.reduce((acc, [re, to]) => acc.replace(re, to), text);
|
||||
}
|
||||
|
||||
function stripTags(text: string): string {
|
||||
return text.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
|
||||
/** Collapses the whitespace an HTML source wraps bullets with. */
|
||||
function oneLine(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the notes shape `embed_release_notes.py` writes —
|
||||
* `<p><b>Added</b></p><ul><li>…</li></ul>` — into titled bullet groups.
|
||||
*
|
||||
* A `<ul>` with no label before it (what `generate_appcast` writes on its own)
|
||||
* becomes "Notes". A `<p>` is taken as the next list's label rather than as
|
||||
* prose, which is safe for notes this pipeline generates and degrades to a
|
||||
* stray heading if that ever changes.
|
||||
*/
|
||||
export function sectionsFromHtml(html: string): ReleaseSection[] {
|
||||
const sections: ReleaseSection[] = [];
|
||||
let label = "";
|
||||
|
||||
for (const block of html.matchAll(/<p>([\s\S]*?)<\/p>|<ul[^>]*>([\s\S]*?)<\/ul>/gi)) {
|
||||
if (block[1] !== undefined) {
|
||||
label = oneLine(stripTags(decodeEntities(block[1])));
|
||||
continue;
|
||||
}
|
||||
const bullets = [...block[2]!.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)]
|
||||
.map((b) => oneLine(stripTags(decodeEntities(b[1] ?? ""))))
|
||||
.filter((b) => b.length > 0);
|
||||
if (bullets.length === 0) continue;
|
||||
sections.push({ title: label || "Notes", bullets });
|
||||
label = "";
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a published date to an ISO calendar date. The appcast carries an
|
||||
* RFC-1123 `pubDate`, the archive a plain `2026-09-06`.
|
||||
*
|
||||
* The day is taken at the timestamp's own offset, not in UTC: a release
|
||||
* published 23:29 -0400 is dated the 2nd, and reading it as UTC would move it
|
||||
* to the 3rd.
|
||||
*/
|
||||
export function isoDateOrNull(raw: string | null): string | null {
|
||||
const value = raw?.trim();
|
||||
if (!value) return null;
|
||||
|
||||
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
|
||||
const offset = /([+-])(\d{2}):?(\d{2})$/.exec(value);
|
||||
const offsetMinutes = offset
|
||||
? (offset[1] === "-" ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3]))
|
||||
: 0;
|
||||
return new Date(parsed.getTime() + offsetMinutes * 60_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toAppRelease(release: AppcastRelease | ArchivedReleaseShape): AppRelease {
|
||||
return {
|
||||
version: release.version,
|
||||
build: "build" in release ? release.build : null,
|
||||
date: isoDateOrNull(release.date),
|
||||
sections: sectionsFromHtml(release.notesHtml)
|
||||
};
|
||||
}
|
||||
|
||||
/** The archive carries no build number; this is that shape without it. */
|
||||
type ArchivedReleaseShape = {
|
||||
version: string;
|
||||
date: string;
|
||||
notesHtml: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Notes the appcast lost, filled from the archive.
|
||||
*
|
||||
* An item with no notes renders as a version header over a blank row, in the
|
||||
* app and on /changelog alike. That is not hypothetical: Sparkle rebuilds every
|
||||
* item from its DMG and removes the description of one whose DMG carries no
|
||||
* notes file, so 0.8.0 and 0.7.0 published with notes and reached both
|
||||
* surfaces with none (NOOK-304).
|
||||
*
|
||||
* The archive copy is used only where the live one is missing. Where both
|
||||
* exist the published item wins, because that is the text the updater itself
|
||||
* showed, and it carries the build number the archive does not have.
|
||||
*/
|
||||
export function withArchivedNotes<
|
||||
T extends { version: string; notesHtml: string }
|
||||
>(
|
||||
live: readonly T[],
|
||||
archived: readonly { version: string; notesHtml: string }[]
|
||||
): T[] {
|
||||
const notesByVersion = new Map(archived.map((r) => [r.version, r.notesHtml]));
|
||||
return live.map((release) => {
|
||||
if (release.notesHtml.trim()) return release;
|
||||
const fallback = notesByVersion.get(release.version);
|
||||
return fallback ? { ...release, notesHtml: fallback } : release;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Live releases first, newest first, then any archived release the appcast no
|
||||
* longer lists. Order is concatenation rather than a sort because both inputs
|
||||
* are newest-first and the archive only ever holds older releases — and because
|
||||
* /changelog renders this same list, the page and this feed cannot disagree
|
||||
* about what a release said.
|
||||
*/
|
||||
export function mergeReleases(
|
||||
live: readonly AppcastRelease[],
|
||||
archived: readonly ArchivedReleaseShape[]
|
||||
): AppRelease[] {
|
||||
const releases = withArchivedNotes(live, archived).map(toAppRelease);
|
||||
const seen = new Set(releases.map((r) => r.version));
|
||||
for (const release of archived) {
|
||||
if (seen.has(release.version)) continue;
|
||||
releases.push(toAppRelease(release));
|
||||
}
|
||||
return releases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the in-app changelog shows. Throws when the appcast cannot be
|
||||
* read — the caller must not serve an archived-only list as if it were
|
||||
* current, since the app then still has the newest release in its own bundle.
|
||||
*/
|
||||
export async function collectReleases(): Promise<AppRelease[]> {
|
||||
return mergeReleases(await fetchAppcastReleases(), ARCHIVED_RELEASES);
|
||||
}
|
||||
@@ -111,7 +111,7 @@ describe("GET /api/the-nook/download", () => {
|
||||
|
||||
expect(logged.length).toBe(1);
|
||||
expect(logged[0]).toMatchObject({
|
||||
path: "/api/the-nook/download",
|
||||
path: "/api/downloads/thenook",
|
||||
method: "GET",
|
||||
referrer: "https://nook.freno.me/"
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { logVisit, enrichAnalyticsEntry, flushAnalytics } from "~/server/analytics";
|
||||
import { parseUtm } from "~/server/utm";
|
||||
import { trackDownload } from "~/server/track-download";
|
||||
import { resolveLatestDownload } from "./_download";
|
||||
|
||||
/**
|
||||
@@ -15,13 +13,11 @@ 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 the path below, which is what `topApiCalls`
|
||||
* already surfaces.
|
||||
* VisitorAnalytics under `/api/downloads/thenook`, alongside the clicks and
|
||||
* served files of every other product — see `~/server/track-download`.
|
||||
*
|
||||
* Country is read from the edge headers Vercel/Cloudflare set; the
|
||||
* analytics buffer carries a `country` column that nothing populated
|
||||
* until now. `?c=` is accepted as an override for previews and manual
|
||||
* verification.
|
||||
* Referrer, campaign tags, edge country (with `?c=` override) and user agent
|
||||
* are read by `trackDownload`, which is shared with the other download doors.
|
||||
*
|
||||
* ONLY the handler is exported here: Vinxi's route tree-shake deletes
|
||||
* exported declarations that are not in the router's `pick=` list, so a
|
||||
@@ -29,47 +25,10 @@ import { resolveLatestDownload } from "./_download";
|
||||
* referenced it. All resolution helpers live in `./_download.ts`.
|
||||
*/
|
||||
|
||||
function firstHeader(request: Request, ...names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
const value = request.headers.get(name);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(event: APIEvent): Promise<Response> {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const forwarded = firstHeader(request, "x-forwarded-for");
|
||||
const ipAddress =
|
||||
getRequestIP(event.nativeEvent) ??
|
||||
(forwarded ? forwarded.split(",")[0]!.trim() : null);
|
||||
|
||||
// Best-effort: a failed analytics write must never block a download.
|
||||
// Flushed (not merely buffered) because a download click is low-volume and
|
||||
// the batch timer is not guaranteed to survive the response on serverless.
|
||||
try {
|
||||
const referrer = firstHeader(request, "referer", "referrer");
|
||||
await logVisit(
|
||||
enrichAnalyticsEntry({
|
||||
path: "/api/the-nook/download",
|
||||
method: "GET",
|
||||
referrer,
|
||||
userAgent: firstHeader(request, "user-agent"),
|
||||
ipAddress,
|
||||
// The click carries its own tags when a campaign links straight here;
|
||||
// otherwise the landing page's tags ride along in the referer.
|
||||
...parseUtm(request.url, referrer),
|
||||
country:
|
||||
url.searchParams.get("c") ??
|
||||
firstHeader(request, "x-vercel-ip-country", "cf-ipcountry")
|
||||
})
|
||||
);
|
||||
await flushAnalytics();
|
||||
} catch (error) {
|
||||
console.error("Failed to log download click:", error);
|
||||
}
|
||||
// Recorded before resolving: a click that dies at resolution is exactly the
|
||||
// failure the funnel needs to show.
|
||||
await trackDownload(event, "thenook");
|
||||
|
||||
let target: string | null = null;
|
||||
try {
|
||||
|
||||
35
src/routes/api/the-nook/releases.json.ts
Normal file
35
src/routes/api/the-nook/releases.json.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { collectReleases } from "./_releases";
|
||||
import { error } from "./_lib";
|
||||
|
||||
/**
|
||||
* GET /api/the-nook/releases.json
|
||||
*
|
||||
* The release list behind The Nook's Settings > Changelog tab, newest first:
|
||||
* `{ releases: [{ version, build, date, sections: [{ title, bullets }] }] }`.
|
||||
*
|
||||
* Same source as /changelog — the Sparkle appcast plus the checked-in archive
|
||||
* of releases older than its retention — so the app and the website tell one
|
||||
* story. Section titles and ISO dates instead of HTML keep the client from
|
||||
* parsing markup it would only take apart again.
|
||||
*
|
||||
* Five-minute cache, in step with the appcast route. An unreadable appcast is
|
||||
* a 503 rather than an archived-only 200: the app keeps the newest release in
|
||||
* its own bundle and falls back to it, which beats showing stale notes as if
|
||||
* they were current.
|
||||
*/
|
||||
export async function GET(_event: APIEvent) {
|
||||
try {
|
||||
const releases = await collectReleases();
|
||||
return new Response(JSON.stringify({ releases }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=300"
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to build the Nook release list:", err);
|
||||
return error("Release list unavailable", 503);
|
||||
}
|
||||
}
|
||||
298
src/routes/api/the-nook/releases.test.ts
Normal file
298
src/routes/api/the-nook/releases.test.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
|
||||
// The contract The Nook's Settings > Changelog tab is built on:
|
||||
// 1. One list, newest first, from the appcast plus the archive of releases
|
||||
// the appcast has aged out — the same merge /changelog renders.
|
||||
// 2. Structured notes and ISO dates, so the client renders instead of
|
||||
// parsing HTML it would only take apart again.
|
||||
// 3. An unreadable appcast is a 503, never an archived-only 200 that reads
|
||||
// as current.
|
||||
|
||||
type Release = {
|
||||
version: string;
|
||||
build: string | null;
|
||||
date: string | null;
|
||||
sections: { title: string; bullets: string[] }[];
|
||||
};
|
||||
|
||||
let appcastXml = "";
|
||||
let s3ShouldThrow = false;
|
||||
|
||||
mock.module("~/env/server", () => ({
|
||||
env: {
|
||||
AWS_REGION: "us-east-1",
|
||||
MY_AWS_ACCESS_KEY: "test-key",
|
||||
MY_AWS_SECRET_KEY: "test-secret",
|
||||
VITE_DOWNLOAD_BUCKET_STRING: "test-bucket"
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@aws-sdk/client-s3", () => ({
|
||||
S3Client: class {
|
||||
async send() {
|
||||
if (s3ShouldThrow) throw new Error("s3 unavailable");
|
||||
return {
|
||||
Body: appcastXml
|
||||
? { transformToString: async () => appcastXml }
|
||||
: null
|
||||
};
|
||||
}
|
||||
},
|
||||
GetObjectCommand: class {
|
||||
input: unknown;
|
||||
constructor(input: unknown) {
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const { GET } = await import("./releases.json");
|
||||
const { invalidateAppcastCache } = await import("./_appcast");
|
||||
const { isoDateOrNull, sectionsFromHtml } = await import("./_releases");
|
||||
const { ARCHIVED_RELEASES } = await import("~/routes/nook/changelog-archive");
|
||||
|
||||
const item = (version: string, pubDate: string, notes: string) => `
|
||||
<item>
|
||||
<title>Version ${version}</title>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<sparkle:version>${version === "0.6.3" ? "10" : "9"}</sparkle:version>
|
||||
<sparkle:shortVersionString>${version}</sparkle:shortVersionString>
|
||||
<description><![CDATA[${notes}]]></description>
|
||||
<enclosure url="https://cdn.example/TheNook-${version}.dmg" type="application/octet-stream"/>
|
||||
</item>`;
|
||||
|
||||
const NOTES =
|
||||
"<p><b>Added</b></p><ul><li>Changelog tab in Settings.</li></ul>" +
|
||||
"<p><b>Fixed</b></p><ul><li>Call actions press only their own button; "End Call" no longer matches "Send".</li></ul>";
|
||||
|
||||
const appcast = (items: string) =>
|
||||
`<?xml version="1.0"?><rss><channel>${items}</channel></rss>`;
|
||||
|
||||
const request = () =>
|
||||
({
|
||||
request: new Request("https://freno.me/api/the-nook/releases.json"),
|
||||
nativeEvent: {}
|
||||
}) as never;
|
||||
|
||||
async function body(): Promise<{ releases: Release[] }> {
|
||||
const res = await GET(request());
|
||||
return (await res.json()) as { releases: Release[] };
|
||||
}
|
||||
|
||||
// The fixture appcast: two releases it lists live, newest first.
|
||||
const LIVE = ["0.6.3", "0.6.2"];
|
||||
|
||||
beforeEach(() => {
|
||||
appcastXml = appcast(
|
||||
item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) +
|
||||
item("0.6.2", "Thu, 24 Sep 2026 21:16:04 -0400", NOTES)
|
||||
);
|
||||
s3ShouldThrow = false;
|
||||
invalidateAppcastCache();
|
||||
});
|
||||
|
||||
describe("GET /api/the-nook/releases.json", () => {
|
||||
it("serves the appcast releases newest first, cacheable for five minutes", async () => {
|
||||
const res = await GET(request());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
|
||||
expect(res.headers.get("Cache-Control")).toBe("public, max-age=300");
|
||||
|
||||
const { releases } = await body();
|
||||
expect(releases.map((r) => r.version)).toEqual([...LIVE, ...agedOut(...LIVE)]);
|
||||
expect(releases[0].build).toBe("10");
|
||||
});
|
||||
|
||||
it("splits the published notes into titled bullet groups", async () => {
|
||||
const [first] = (await body()).releases;
|
||||
|
||||
expect(first!.sections).toEqual([
|
||||
{ title: "Added", bullets: ["Changelog tab in Settings."] },
|
||||
{
|
||||
title: "Fixed",
|
||||
bullets: [
|
||||
'Call actions press only their own button; "End Call" no longer matches "Send".'
|
||||
]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("dates a release on its own day, not the UTC one", async () => {
|
||||
const releases = (await body()).releases;
|
||||
|
||||
// 23:29 -0400 is 03:29Z the next day; the release is still dated the 2nd.
|
||||
expect(releases[0]!.date).toBe("2026-10-02");
|
||||
expect(releases[1]!.date).toBe("2026-09-24");
|
||||
});
|
||||
|
||||
it("appends the archived releases the appcast has aged out", async () => {
|
||||
const releases = (await body()).releases;
|
||||
const archived = releases.filter((r) => !LIVE.includes(r.version));
|
||||
|
||||
expect(archived.map((r) => r.version)).toEqual(agedOut(...LIVE));
|
||||
expect(archived[0]!.build).toBeNull();
|
||||
// Archive dates are already calendar dates and pass through untouched.
|
||||
expect(archived[0]!.date).toBe(ARCHIVED_RELEASES[0]!.date);
|
||||
expect(archived[0]!.sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lists a version once when the appcast and the archive both carry it", async () => {
|
||||
appcastXml = appcast(item("0.5.0", "Mon, 07 Sep 2026 16:11:44 -0400", NOTES));
|
||||
invalidateAppcastCache();
|
||||
|
||||
const releases = (await body()).releases;
|
||||
const matches = releases.filter((r) => r.version === "0.5.0");
|
||||
|
||||
expect(matches.length).toBe(1);
|
||||
// The live item wins: it carries the build number the archive lacks.
|
||||
expect(matches[0]!.build).toBe("9");
|
||||
expect(matches[0]!.date).toBe("2026-09-07");
|
||||
});
|
||||
|
||||
it("503s when the appcast cannot be read, rather than serving archive-only notes", async () => {
|
||||
s3ShouldThrow = true;
|
||||
|
||||
const res = await GET(request());
|
||||
expect(res.status).toBe(503);
|
||||
expect(((await res.json()) as { error: string }).error).toBe(
|
||||
"Release list unavailable"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notes the appcast lost", () => {
|
||||
// Sparkle rebuilds every item from its DMG and removes the description of an
|
||||
// item whose DMG carries no notes file, so a release the appcast still lists
|
||||
// can arrive with nothing to show. 0.8.0 and 0.7.0 did exactly that, in both
|
||||
// this feed and /changelog (NOOK-304): a version header over an empty row.
|
||||
const itemWithoutNotes = (version: string) => `
|
||||
<item>
|
||||
<title>${version}</title>
|
||||
<pubDate>Fri, 02 Oct 2026 23:29:42 -0400</pubDate>
|
||||
<sparkle:version>9</sparkle:version>
|
||||
<sparkle:shortVersionString>${version}</sparkle:shortVersionString>
|
||||
<enclosure url="https://cdn.example/TheNook-${version}.dmg" type="application/octet-stream"/>
|
||||
</item>`;
|
||||
|
||||
it("falls back to the archive's copy for a release listed without notes", async () => {
|
||||
appcastXml = appcast(
|
||||
item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) +
|
||||
itemWithoutNotes("0.5.0")
|
||||
);
|
||||
invalidateAppcastCache();
|
||||
|
||||
const lost = (await body()).releases.find((r) => r.version === "0.5.0")!;
|
||||
|
||||
expect(lost.sections).toEqual(
|
||||
sectionsFromHtml(
|
||||
ARCHIVED_RELEASES.find((r) => r.version === "0.5.0")!.notesHtml
|
||||
)
|
||||
);
|
||||
expect(lost.sections.length).toBeGreaterThan(0);
|
||||
// Still the live row, not the archived one: it carries the build number.
|
||||
expect(lost.build).toBe("9");
|
||||
});
|
||||
|
||||
it("keeps the published notes when the archive has them too", async () => {
|
||||
appcastXml = appcast(item("0.5.0", "Mon, 07 Sep 2026 16:11:44 -0400", NOTES));
|
||||
invalidateAppcastCache();
|
||||
|
||||
const [release] = (await body()).releases;
|
||||
|
||||
expect(release!.sections).toEqual(sectionsFromHtml(NOTES));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the checked-in archive", () => {
|
||||
// The durable copy of what every release said. The appcast carries only the
|
||||
// DMGs the release machine still holds, and it loses notes when it rebuilds,
|
||||
// so a release missing here is a release the changelog can blank out later
|
||||
// with nothing left to fall back to.
|
||||
it("holds every release that shipped notes, newest first", () => {
|
||||
expect(ARCHIVED_RELEASES.map((r) => r.version)).toEqual([
|
||||
"0.9.0",
|
||||
"0.8.0",
|
||||
"0.7.0",
|
||||
"0.6.3",
|
||||
"0.6.2",
|
||||
"0.6.0",
|
||||
"0.5.0",
|
||||
"0.4.1",
|
||||
"0.4.0",
|
||||
"0.3.0",
|
||||
"0.2.0"
|
||||
// 0.6.1 and 0.3.1 shipped without written notes and are absent by design.
|
||||
]);
|
||||
});
|
||||
|
||||
it("holds notes for every release it lists, and a date for each", () => {
|
||||
for (const release of ARCHIVED_RELEASES) {
|
||||
expect(
|
||||
sectionsFromHtml(release.notesHtml).length,
|
||||
`${release.version} has no notes`
|
||||
).toBeGreaterThan(0);
|
||||
expect(isoDateOrNull(release.date), release.version).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sectionsFromHtml", () => {
|
||||
it("labels an unlabelled list as Notes", () => {
|
||||
expect(sectionsFromHtml("<ul><li>Fixes</li></ul>")).toEqual([
|
||||
{ title: "Notes", bullets: ["Fixes"] }
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins a bullet the HTML wrapped across lines, and drops empty ones", () => {
|
||||
expect(
|
||||
sectionsFromHtml(
|
||||
"<p><b>Fixed</b></p><ul>\n<li>a bullet\n split over\n lines</li><li> </li></ul>"
|
||||
)
|
||||
).toEqual([{ title: "Fixed", bullets: ["a bullet split over lines"] }]);
|
||||
});
|
||||
|
||||
it("returns no sections for empty or note-less HTML", () => {
|
||||
expect(sectionsFromHtml("")).toEqual([]);
|
||||
expect(sectionsFromHtml("<p><b>Added</b></p>")).toEqual([]);
|
||||
});
|
||||
|
||||
it("decodes the entities embed_release_notes.py's html.escape writes", () => {
|
||||
// Python's html.escape prefers the hex form for apostrophes; this is what
|
||||
// the live appcast actually carries (NOOK-565).
|
||||
expect(
|
||||
sectionsFromHtml(
|
||||
"<p><b>Added</b></p><ul><li>Each app's meter & the icon</li><li>don't expand</li></ul>"
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
title: "Added",
|
||||
bullets: ["Each app's meter & the icon", "don't expand"]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isoDateOrNull", () => {
|
||||
it("reads an RFC-1123 pubDate at its own offset", () => {
|
||||
expect(isoDateOrNull("Sat, 03 Oct 2026 00:00:00 +0000")).toBe("2026-10-03");
|
||||
expect(isoDateOrNull("Fri, 02 Oct 2026 23:29:42 -0400")).toBe("2026-10-02");
|
||||
expect(isoDateOrNull("Tue, 06 Oct 2026 09:00:00 +0200")).toBe("2026-10-06");
|
||||
});
|
||||
|
||||
it("passes a calendar date through and rejects junk", () => {
|
||||
expect(isoDateOrNull("2026-09-06")).toBe("2026-09-06");
|
||||
expect(isoDateOrNull("sometime in September")).toBeNull();
|
||||
expect(isoDateOrNull("")).toBeNull();
|
||||
expect(isoDateOrNull(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The tail a case should expect: the whole archive in file order (newest
|
||||
* first) minus the versions its fixture appcast lists live. A version is
|
||||
* listed once, from the appcast, and the archive copy behind it only fills
|
||||
* notes the feed lost — so the tail shrinks as the archive grows.
|
||||
*/
|
||||
const agedOut = (...live: string[]) =>
|
||||
ARCHIVED_RELEASES.map((r) => r.version).filter((v) => !live.includes(v));
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
// and description, HTML-escaped.
|
||||
// 2. Invalid payloads never reach the mailer.
|
||||
// 3. The rate limiter halts floods per fingerprint (falling back to IP).
|
||||
// 4. The landing page's iPhone beta request (email only) gets through.
|
||||
// sendEmail and the limiter are mocked so the test sends no real mail.
|
||||
|
||||
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
||||
@@ -22,6 +23,9 @@ mock.module("~/server/bug-report-rate-limit", () => ({
|
||||
}));
|
||||
|
||||
const { POST } = await import("./report");
|
||||
const { betaRequestPayload } = await import(
|
||||
"~/components/nook/betaRequest"
|
||||
);
|
||||
|
||||
/** A minimal APIEvent double: JSON body plus optional headers. */
|
||||
function request(body: unknown, headers: Record<string, string> = {}) {
|
||||
@@ -134,4 +138,17 @@ describe("POST /api/the-nook/report", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(sentEmails).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("delivers the landing page's beta request with only the email", async () => {
|
||||
const res = await POST(request(betaRequestPayload("tester@example.com")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(sentEmails).toHaveLength(1);
|
||||
const email = sentEmails[0]!;
|
||||
expect(email.subject).toBe(
|
||||
"The Nook bug report: iPhone app beta access request"
|
||||
);
|
||||
expect(email.html).toContain("<b>Reply-to:</b> tester@example.com");
|
||||
expect(email.html).not.toContain("<h3>Displays</h3>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,7 +110,7 @@ function MainDownloadsPage() {
|
||||
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span class="text-subtext0 font-mono text-sm">
|
||||
platform: macOS (14.6+)
|
||||
platform: macOS (14.2+)
|
||||
</span>
|
||||
<Button
|
||||
variant="download"
|
||||
|
||||
@@ -4,17 +4,16 @@
|
||||
* Net-new privacy policy for the Nessa subdomain. Modeled on the Life and
|
||||
* Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices:
|
||||
*
|
||||
* - Authentication: user accounts are managed by Clerk
|
||||
* (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk
|
||||
* Backend JWKS endpoint). Nessa itself does not store passwords — Clerk is
|
||||
* the identity provider.
|
||||
* - Authentication: user accounts are managed by Clerk (the Nessa API server
|
||||
* verifies Clerk session JWTs). Nessa itself does not store passwords —
|
||||
* Clerk is the identity provider.
|
||||
* - Community content: clubs, club challenges, club posts, post likes, and
|
||||
* post comments (see `src/server/api/routers/nessa-community.ts`,
|
||||
* `clubMemberships`, `clubChallenges`, `clubChallengeParticipations`,
|
||||
* `clubPosts`, `clubPostLikes`, `clubPostComments`).
|
||||
* - Storage: the Nessa data lives in a dedicated Turso (libSQL) database
|
||||
* (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate
|
||||
* from the freno.me main DB and the Lineage DB.
|
||||
* post comments (`clubMemberships`, `clubChallenges`,
|
||||
* `clubChallengeParticipations`, `clubPosts`, `clubPostLikes`,
|
||||
* `clubPostComments`).
|
||||
* - Storage: the Nessa data lives in a dedicated Turso (libSQL) database,
|
||||
* served by the Nessa API (`nessa-api.freno.me`, the Nessa repo's
|
||||
* `server/`), separate from the freno.me main DB and the Lineage DB.
|
||||
*
|
||||
* PageHead is site-aware: only the base title is supplied; the
|
||||
* ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image
|
||||
|
||||
54
src/routes/nook/agents.test.ts
Normal file
54
src/routes/nook/agents.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Regression test for the /agents roster (NOOK-336).
|
||||
*
|
||||
* The page mirrors the app's registry, `AgentSpec.all` in
|
||||
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
||||
* the registry in 0.10.0 the page kept twelve entries and kept saying
|
||||
* "twelve", so a shipped agent was missing from the one page that lists
|
||||
* 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.
|
||||
*
|
||||
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
||||
|
||||
/** AgentSpec.all display names, in registry order. */
|
||||
const REGISTRY = [
|
||||
"Claude Code",
|
||||
"Codex",
|
||||
"OpenCode",
|
||||
"Pi",
|
||||
"Oh My Pi",
|
||||
"Muse",
|
||||
"Hermes",
|
||||
"OpenClaw",
|
||||
"Z.ai (ZCode)",
|
||||
"Gemini CLI",
|
||||
"Cline",
|
||||
"Qwen Code",
|
||||
"DeepSeek"
|
||||
];
|
||||
|
||||
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen"];
|
||||
|
||||
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
||||
|
||||
describe("Nook /agents roster (NOOK-336)", () => {
|
||||
it("lists every registry agent, in registry order", () => {
|
||||
expect(entries).toEqual(REGISTRY);
|
||||
});
|
||||
|
||||
it("states the same count everywhere it names one", () => {
|
||||
const word = NUMBER_WORDS[entries.length - 12];
|
||||
for (const other of NUMBER_WORDS.filter((w) => w !== word)) {
|
||||
expect(SOURCE.toLowerCase()).not.toContain(other);
|
||||
}
|
||||
expect(SOURCE).toContain(`all ${word}`);
|
||||
expect(SOURCE).toContain(`The ${entries.length} agents`);
|
||||
expect(SOURCE).toContain(`watches ${word} coding agents`);
|
||||
});
|
||||
});
|
||||
@@ -27,13 +27,14 @@ interface AgentEntry {
|
||||
const AGENTS: AgentEntry[] = [
|
||||
{
|
||||
name: "Claude Code",
|
||||
wiring: "Hook entries in settings.json pointing at the shared hook binary",
|
||||
wiring: "Hook entries in its settings, calling the shared hook binary",
|
||||
events: [
|
||||
"UserPromptSubmit",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"Stop",
|
||||
"StopFailure",
|
||||
"SessionError",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"Notification",
|
||||
@@ -49,14 +50,23 @@ const AGENTS: AgentEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "Codex",
|
||||
wiring: "Hook entries in hooks.json (+ hooks enabled in config.toml)",
|
||||
events: ["SessionStart", "UserPromptSubmit", "PermissionRequest", "Stop"],
|
||||
gating: "Full — permission prompts can be allowed or denied from a card"
|
||||
wiring: "Hook entries in its config, with its hooks feature enabled",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError"
|
||||
],
|
||||
gating:
|
||||
"Full — command/file approvals and user-input questions can be answered from a card"
|
||||
},
|
||||
{
|
||||
name: "OpenCode",
|
||||
wiring:
|
||||
"In-process plugin (~/.config/opencode/plugins/nook.js) on the server bus",
|
||||
wiring: "In-process plugin on its server bus",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
@@ -64,6 +74,7 @@ const AGENTS: AgentEntry[] = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"MessageUpdate",
|
||||
"PermissionRequest",
|
||||
"QuestionAsked"
|
||||
@@ -73,14 +84,16 @@ const AGENTS: AgentEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "Pi",
|
||||
wiring: "In-process extension (~/.pi/agent/extensions/nook.ts)",
|
||||
wiring: "In-process extension registered in its settings",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"AgentStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"MessageUpdate",
|
||||
"QuestionAsked",
|
||||
"QuestionResolved"
|
||||
@@ -90,14 +103,18 @@ const AGENTS: AgentEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "Oh My Pi",
|
||||
wiring: "In-process extension package (~/.omp/agent/extensions/nook/)",
|
||||
wiring: "In-process extension package",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"AgentStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PermissionResolved",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"MessageUpdate",
|
||||
"QuestionAsked",
|
||||
"QuestionResolved"
|
||||
@@ -105,6 +122,29 @@ const AGENTS: AgentEntry[] = [
|
||||
gating:
|
||||
"Full — honors the agent's own approval tiers, per-tool policies included"
|
||||
},
|
||||
{
|
||||
name: "Muse",
|
||||
wiring:
|
||||
"Plugin installed, approved and enabled through Muse's own plugins CLI; each hook calls the shared hook binary. This Mac only — remote hosts skip it",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"StopFailure",
|
||||
"Notification",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SubagentStart",
|
||||
"SubagentStop"
|
||||
],
|
||||
gating:
|
||||
"Full — permission prompts can be allowed or denied from a card, and every tool call under Verify"
|
||||
},
|
||||
{
|
||||
name: "Hermes",
|
||||
wiring:
|
||||
@@ -117,18 +157,20 @@ const AGENTS: AgentEntry[] = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"SubagentStart",
|
||||
"SubagentStop"
|
||||
"SubagentStop",
|
||||
"QuestionAsked"
|
||||
],
|
||||
gating:
|
||||
"Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "OpenClaw",
|
||||
wiring: "Hook handler package (~/.openclaw/hooks/nook/)",
|
||||
wiring: "Hook handler package in its hooks directory",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"AgentResponse",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"PreCompact",
|
||||
"SessionEnd"
|
||||
],
|
||||
@@ -137,7 +179,7 @@ const AGENTS: AgentEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "Z.ai (ZCode)",
|
||||
wiring: "Hook entries in ~/.zcode/cli/config.json",
|
||||
wiring: "Hook entries in its CLI config",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
@@ -152,12 +194,13 @@ const AGENTS: AgentEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "Gemini CLI",
|
||||
wiring: "Hook entries in settings.json",
|
||||
wiring: "Hook entries in its settings",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"AgentStart",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"Notification"
|
||||
],
|
||||
gating:
|
||||
@@ -165,7 +208,7 @@ const AGENTS: AgentEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "Cline",
|
||||
wiring: "One hook file per event in ~/Documents/Cline/Hooks",
|
||||
wiring: "One hook file per event in its hooks directory",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
@@ -182,7 +225,7 @@ const AGENTS: AgentEntry[] = [
|
||||
{
|
||||
name: "Qwen Code",
|
||||
wiring:
|
||||
"Hook entries in settings.json (same surface as the Claude-style schema)",
|
||||
"Hook entries in its settings (same surface as the Claude-style schema)",
|
||||
events: [
|
||||
"UserPromptSubmit",
|
||||
"SessionStart",
|
||||
@@ -204,7 +247,7 @@ const AGENTS: AgentEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
wiring: "Standalone hooks.json at ~/.dsh",
|
||||
wiring: "A standalone hooks file its bridge is pointed at",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
@@ -240,8 +283,8 @@ export default function NookAgentsPage() {
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Supported agents"
|
||||
description="Every coding agent The Nook supports — all twelve, 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 12 agents The Nook supports, and what each hook sees"
|
||||
description="Every coding agent The Nook supports — all thirteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||
ogTitle="The 13 agents The Nook supports, and what each hook sees"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
@@ -249,7 +292,7 @@ export default function NookAgentsPage() {
|
||||
Supported agents
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
||||
The Nook watches twelve coding agents in one place — your menu bar and
|
||||
The Nook watches thirteen 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>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,8 +3,12 @@
|
||||
*
|
||||
* The appcast already ships HTML release notes to the updater on every
|
||||
* launch, so this page renders exactly what Sparkle publishes: one source
|
||||
* of truth, no second place to write release notes. Doubles as the raw
|
||||
* material for the release-announcement job (marketing/README.md).
|
||||
* of truth, no second place to write release notes. The checked-in archive
|
||||
* fills an item the appcast lost its notes to — the feed is rebuilt from the
|
||||
* DMGs on every publish, and Sparkle drops the notes of an item whose DMG
|
||||
* carries none, which is how 0.8.0 and 0.7.0 came to render as blank rows.
|
||||
* Doubles as the raw material for the release-announcement job
|
||||
* (marketing/README.md).
|
||||
*
|
||||
* Layout: the version list lives in a fixed-height vertical scroller, so
|
||||
* expanding a release never shifts the page — history is browsed inside
|
||||
@@ -17,15 +21,20 @@ import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { fetchAppcastReleases } from "~/routes/api/the-nook/_appcast";
|
||||
import { withArchivedNotes } from "~/routes/api/the-nook/_releases";
|
||||
import { ARCHIVED_RELEASES } from "~/routes/nook/changelog-archive";
|
||||
|
||||
const getReleases = query(async () => {
|
||||
"use server";
|
||||
// Appcast items are authoritative (they carry build numbers); releases
|
||||
// older than the appcast's retention come from the checked-in archive,
|
||||
// recovered from the changelog file's git history. Both normalize to
|
||||
// AppcastRelease so the page renders one shape.
|
||||
const live = await fetchAppcastReleases();
|
||||
// Appcast items are authoritative (they carry build numbers), with the
|
||||
// archive filling any item whose notes the rebuilt feed lost. Releases the
|
||||
// appcast no longer lists at all come from the archive. Both normalize to
|
||||
// AppcastRelease so the page renders one shape, and the fill is the same
|
||||
// helper releases.json runs, so the two cannot tell different stories.
|
||||
const live = withArchivedNotes(
|
||||
await fetchAppcastReleases(),
|
||||
ARCHIVED_RELEASES
|
||||
);
|
||||
const liveVersions = new Set(live.map((r) => r.version));
|
||||
const archived = ARCHIVED_RELEASES.filter(
|
||||
(r) => !liveVersions.has(r.version)
|
||||
@@ -65,8 +74,7 @@ export default function NookChangelogPage() {
|
||||
Changelog
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-10 max-w-2xl text-lg">
|
||||
What changed in each release. The app updates through Sparkle —
|
||||
every entry here is what the updater shows.
|
||||
What changed in each release.
|
||||
</p>
|
||||
|
||||
<Show
|
||||
@@ -93,11 +101,6 @@ export default function NookChangelogPage() {
|
||||
<h2 class="text-text text-lg font-semibold">
|
||||
Version {release.version}
|
||||
</h2>
|
||||
<Show when={release.build}>
|
||||
<span class="text-subtext1 font-mono text-xs">
|
||||
build {release.build}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={release.date}>
|
||||
<span class="text-subtext1 ml-auto text-xs">
|
||||
{release.date}
|
||||
@@ -112,7 +115,7 @@ export default function NookChangelogPage() {
|
||||
rendering a blank expanse. */}
|
||||
<div
|
||||
classList={{ hidden: !isOpen(release.version, i()) }}
|
||||
class="text-subtext0 px-5 pb-5 pl-14 text-sm [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-text [&_li]:ml-5 [&_li]:list-disc [&_p]:mb-2 [&_ul]:mb-2"
|
||||
class="text-subtext0 [&_h2]:text-text px-5 pb-5 pl-14 text-sm [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:text-base [&_h2]:font-semibold [&_li]:ml-5 [&_li]:list-disc [&_p]:mb-2 [&_ul]:mb-2"
|
||||
>
|
||||
<Show
|
||||
when={release.notesHtml}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* `/faq` on the Nook subdomain — answers to the objections that actually
|
||||
* stop a purchase. Privacy scoping follows marketing/positioning.md rule 3:
|
||||
* "no prompt or code content leaves the device" (telemetry and the trial
|
||||
* email do leave — never claim "no data leaves the device").
|
||||
* stop a purchase. Privacy is deliberately absent until the egress
|
||||
* inventory and /privacy make an honest answer possible.
|
||||
*/
|
||||
import { For } from "solid-js";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
@@ -17,34 +16,46 @@ interface FaqEntry {
|
||||
|
||||
const FAQ: FaqEntry[] = [
|
||||
{
|
||||
question: "Does The Nook read my prompts or my code?",
|
||||
question:
|
||||
"Does it work with my agent — and can I approve from the notch, or only watch?",
|
||||
answer:
|
||||
"No. The hooks and plugins that connect your agents to The Nook send session metadata only — which agent is running, which session, which event happened, which terminal it lives in. No prompt text and no code content leaves your device. Two things do leave the device, and we'd rather say so plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start. That's the whole list."
|
||||
"It depends on the agent, and Cursor is the useful example. Cursor is watched, not gated: its sessions, finished turns and errors appear in the island, but when Cursor asks for permission the prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. One limit: cursor-agent -p one-shot runs send no turn end, so they appear but do not get a completion card."
|
||||
},
|
||||
{
|
||||
question: "What happens when my agent is idle?",
|
||||
answer:
|
||||
"Idle means the last turn is over, or the session has not produced anything yet — not that the process is gone. A running process is liveness, not work: a just-opened agent window can show idle until you type. When an agent finishes a turn with a final message, the row stays green for ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot report for that session, and the row says so instead of pretending it is idle. Nothing is lost when a row goes idle — the next prompt brings it back and flips it to running. The row itself leaves the island 15 minutes after the turn (5 if the turn ended without a reply), so a long-quiet session reappears on its next event instead of sitting there. If a session that is obviously working says idle, that is a bug; tell us."
|
||||
},
|
||||
{
|
||||
question:
|
||||
"What permissions does it need — and what does it write into my agent's config?",
|
||||
answer:
|
||||
"The app itself runs unprivileged. Fan control and charge limiting go through a small helper that macOS requires to run as root for hardware access; the app registers it on first use and takes you to System Settings › General › Login Items & Extensions, where you switch it on once. For each agent it adds what that agent needs — a hook group per event it listens to, or one plugin link — inside the agent's own config file or directory. It keeps its own files under ~/.nook — the hook binary and one plugin copy per agent — and adds only its own entries to that agent's config. A hook entry you added yourself in the same file survives install and uninstall, and it only touches the records and switches it created. Uninstall removes exactly what it wrote — and switches off what it turned on — with no leftover links or orphaned files."
|
||||
},
|
||||
{
|
||||
question: "Does it work with my terminal, including tmux?",
|
||||
answer:
|
||||
"Yes — it recognises Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux. Four of those take a typed reply; the rest are watched. A session is tied to the terminal that started it. Jump lands in the right window for Terminal.app, iTerm2, kitty and any tmux pane — in tmux, the right pane; on Warp, WezTerm and Ghostty it brings the app forward instead. Replies type straight back into Terminal.app, iTerm2, kitty and any tmux pane; on Warp, WezTerm and Ghostty you answer in the app's own window, and Alacritty takes no typed reply from another app at all."
|
||||
},
|
||||
{
|
||||
question: "Do I need a Mac with a notch?",
|
||||
answer:
|
||||
"No. On a notched MacBook the island sits in the notch. On machines and displays without one, it anchors to the display your cursor is on, so a desktop setup with external monitors gets the same treatment. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
||||
"No. On a notched MacBook the island sits in the notch. On machines and displays without one, it sits at the top centre of the display — the same pill with the same slots. You get one island on every display it is turned on for (all connected displays on first launch), each anchored to the top of its own screen; Settings › Displays trims that list, and the pointer only decides which display's floating controls are live. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
||||
},
|
||||
{
|
||||
question: "How much battery does it cost?",
|
||||
answer:
|
||||
"The Nook is a small native UI, not a background agent — it watches the agents you're already running; it doesn't run anything itself. The heaviest thing it does is read and write your Mac's fan and charging controllers, which is a lightweight sensor read. If you're running enough coding agents to need it, those agents dwarf the app's own draw."
|
||||
"It is a native app, not a background agent. It watches the agents you already run and never runs them in the background, so their draw is the number that matters. We do not have a published battery measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs more than it should on your Mac, that is a bug — tell us."
|
||||
},
|
||||
{
|
||||
question: "What permissions does it need?",
|
||||
question: "Why macOS 14.2 or later?",
|
||||
answer:
|
||||
"The app itself runs unprivileged. Fan control and charge limiting go through a small helper component that macOS requires to run as root for hardware access — the app installs it on first use and you approve it once with your password. The hooks live in each agent's own config directory; you can inspect or remove every file it writes."
|
||||
},
|
||||
{
|
||||
question: "Why macOS 14 or later?",
|
||||
answer:
|
||||
"The interface is built on Apple's current observation framework, which ships with macOS 14 (Sonoma)."
|
||||
"The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma). The app links against it, so 14.0 and 14.1 cannot launch it."
|
||||
},
|
||||
{
|
||||
question: "Does it work offline?",
|
||||
answer:
|
||||
"Yes. Everything The Nook does — the agent grid, permission cards, fan and thermal control — works with no network at all, and your agents' hooks talk to the app over a local socket. The network is only used for update checks, license activation, and the optional trial email."
|
||||
"The Mac app does. The island, agent cards, permission cards and fan control work with no network; the hooks talk to the app over a local socket. The network is used for update checks, licence activation, crash reports and the optional trial email, plus a few things only when you use them: weather (asked for through our relay, with the location you chose), the network panel's IP-and-ISP lookup, the speed test, the changelog in Settings, and a bug report if you send one. The phone link is the exception: when you are away from your Mac, the phone reaches it through our relay, so that part needs a connection."
|
||||
}
|
||||
];
|
||||
|
||||
@@ -56,7 +67,7 @@ export default function NookFaqPage() {
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Frequently asked questions"
|
||||
description="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14 requirement, offline use — answered straight."
|
||||
description="Which agents can you approve from the notch? What does idle mean? Terminal and tmux support, permissions, the macOS 14.2 requirement, offline use — answered straight."
|
||||
ogTitle="The Nook — FAQ"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
|
||||
@@ -3,6 +3,8 @@ import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import FeatureCollage from "~/components/nook/FeatureCollage";
|
||||
import FeatureBreakdowns from "~/components/nook/FeatureBreakdowns";
|
||||
import IslandExtras from "~/components/nook/IslandExtras";
|
||||
import PhoneBeta from "~/components/nook/PhoneBeta";
|
||||
import Button from "~/components/ui/Button";
|
||||
import Input from "~/components/ui/Input";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
@@ -101,7 +103,7 @@ export default function NookLanding() {
|
||||
UI.
|
||||
</p>
|
||||
<p class="text-subtext1 mb-8 text-sm">
|
||||
macOS 14+ · 14-day free trial · up to 3 devices
|
||||
macOS 14.2+ · 14-day free trial · up to 3 devices
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
|
||||
@@ -149,6 +151,12 @@ export default function NookLanding() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Everything else: marquee + live module bento ───────────── */}
|
||||
<IslandExtras />
|
||||
|
||||
{/* ── iPhone + Watch companion, TestFlight beta ──────────────── */}
|
||||
<PhoneBeta brandColor={brandColor()} />
|
||||
|
||||
{/* ── CTA ──────────────────────────────────────────────────────── */}
|
||||
<section class="bg-surface0 relative z-20 px-4 py-20 md:px-8">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
@@ -156,10 +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">
|
||||
Orchestrate your coding agents, watch temperatures, take control of
|
||||
your fans, and keep background services quiet. When the trial ends,
|
||||
unlock everything with a single one-time payment — no subscription,
|
||||
ever.
|
||||
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
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
/**
|
||||
* The Nook privacy policy — `nook.freno.me/privacy`.
|
||||
*
|
||||
* Reflects the actual data The Nook sends when licensing: a masked hardware
|
||||
* UUID + device name for trial/activation limits, the email used for purchase,
|
||||
* and Stripe payment processing.
|
||||
* Every statement on this page is traced to the egress inventory at
|
||||
* `The-Nook` HEAD (FRE-333, `document-egress-inventory`): eleven internet
|
||||
* destinations, file:line evidence per row. When the app's egress changes,
|
||||
* this page changes in the same commit — not later.
|
||||
*
|
||||
* The crash-reporting paragraphs ((b) and the iPhone companion) describe the
|
||||
* build users run today: crash reporting is opt-in, off by default, on
|
||||
* releases v0.15.1 and later — FRE-355 (merge `6e74b261`) first shipped in
|
||||
* `v0.15.1` (2026-11-04), and the live appcast serves `v0.15.2`. When enabled,
|
||||
* Sentry receives crash and error reports, session pings, and performance
|
||||
* traces at a 0.1 sample rate (`tracesSampleRate = 0.1`), and it also receives
|
||||
* the connection's IP address (`sendDefaultPii = false` is the SDK default and
|
||||
* does not suppress it — sentry-cocoa 9.26.1 sets `infer_ip = auto` for Cocoa,
|
||||
* and only the Sentry project's *Prevent Storing of IP Addresses* switch stops
|
||||
* that; FRE-1480). Releases 0.3.0 through 0.15.0 started Sentry on every
|
||||
* launch with `sendDefaultPii = true` and no off switch; this page describes
|
||||
* the current release. The iPhone paragraph describes `ios-v0.3.2`
|
||||
* (2026-10-22, the build in TestFlight) until an `ios-v*` tag carries
|
||||
* FRE-355's opt-in code.
|
||||
*
|
||||
* The *Contingency* in `marketing/drafts/privacy-page.md` fired with `v0.15.1`
|
||||
* and is applied in this commit, with two corrections against the pre-written
|
||||
* swap text: the shipped build still sends session pings and traces while
|
||||
* enabled, so they are named (the contingency's own completeness warning), and
|
||||
* the IP sentence is kept — the pre-written swap text already had it, and
|
||||
* `sendDefaultPii = false` does not stop Sentry receiving the IP (FRE-1480).
|
||||
*
|
||||
* Corrected against the CTO's /privacy verdict (FRE-354): no macOS username
|
||||
* from Sentry (sendDefaultPii sets the IP only), session pings named, (a)
|
||||
* fail-open scoped, (c) relay-path qualified with the direct-pair sentence,
|
||||
* (j) "not a cloud service" with the tailnet path, the iPhone paragraph added
|
||||
* (TestFlight beta is in users' hands), and section 3 says "content".
|
||||
*/
|
||||
import { buildMainSiteUrl } from "~/lib/subdomain-url";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
@@ -21,39 +50,139 @@ export default function NookPrivacyPolicy() {
|
||||
<SubdomainHeader />
|
||||
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||
<div class="py-4 text-xl">The Nook's Privacy Policy</div>
|
||||
<div class="py-2">Last Updated: August 26, 2026</div>
|
||||
<div class="py-2">Last Updated: November 6, 2026</div>
|
||||
<div class="py-2">
|
||||
Welcome to The Nook ('We', 'Us', 'Our').
|
||||
Your privacy is important to us. This policy explains what we collect,
|
||||
why, and how it is handled. The app itself runs locally on your device
|
||||
and sends only the minimal data required for trial and licensing.
|
||||
The Nook runs on your Mac, and the iPhone and Apple Watch companion
|
||||
is in TestFlight beta. Your prompts, your code and your agent
|
||||
sessions are processed on your own devices. This page lists every
|
||||
connection the apps make over the internet — and the local-network
|
||||
and tailnet paths the phone can use — what each one sends, and
|
||||
whether you can turn it off.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||
<span class="-ml-4 pr-2">1.</span> What leaves your devices
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Device hardware UUID:</div> When you start
|
||||
a trial or activate a license, The Nook sends a hardware UUID
|
||||
from your Mac. It is used solely to enforce the 14-day trial and
|
||||
the 3-device activation limit, and to prevent resetting the
|
||||
trial by reinstalling the app.
|
||||
<div class="-ml-6">(a) Licensing and trial:</div> When you start
|
||||
a trial or activate a license, The Nook sends your Mac's
|
||||
hardware UUID and device name to our licensing server
|
||||
(freno.me). The UUID enforces the 14-day trial and the 3-device
|
||||
activation limit and stops a trial being reset by reinstalling
|
||||
the app; the device name is stored with the activation so the
|
||||
Mac can be identified in your license records. While you are on
|
||||
the trial, this is sent at each launch. Once you are licensed,
|
||||
each launch re-checks only that your key has not been revoked.
|
||||
Deactivating a Mac sends the license key and that Mac's
|
||||
UUID. If you add an email during the trial, that request
|
||||
carries the email and the UUID, and it is sent once. The trial
|
||||
and status checks fail open: with no connection, an unexpired
|
||||
trial or a stored license keeps working, because license
|
||||
verification itself is offline. Activating a new license needs
|
||||
a connection once.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Device name:</div> The Nook sends your
|
||||
Mac's name so you can recognize the device in your license
|
||||
activations. It is not used for any other purpose.
|
||||
<div class="-ml-6">(b) Crash and error reporting — off unless
|
||||
you turn it on:</div> Crash reporting is off by default and
|
||||
runs only if you enable it in Settings. When it is on, The Nook
|
||||
sends crash reports, error events, hang reports, session pings,
|
||||
and a sample of performance traces to Sentry, our
|
||||
crash-reporting processor. Each report carries the app version,
|
||||
the macOS version, the Mac model and what the app was doing
|
||||
when it failed. Sentry also receives your IP address. They
|
||||
never include your prompts, your code or your session content.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Email:</div> The email you provide at
|
||||
checkout is used to deliver your license key and to look up your
|
||||
order.
|
||||
<div class="-ml-6">(c) Phone connection:</div> The phone
|
||||
connection ships off and is yours to enable. With the default
|
||||
relay path, turning it on registers your Mac with our relay
|
||||
(nook-relay.freno.me) using a random install ID, and a licensed
|
||||
Mac presents its license key once at registration. Everything
|
||||
your Mac and your phone then exchange — session updates,
|
||||
approvals, replies — is encrypted end-to-end: the relay routes
|
||||
it and cannot read it. On the relay path, the relay can see
|
||||
which devices are connected — their IDs, names and platforms —
|
||||
when, and how much traffic they exchange.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Payment:</div> Payments are processed by
|
||||
Stripe. We never see or store your card details.
|
||||
You can also pair over your LAN or tailnet instead; those paths
|
||||
carry the same sealed traffic and do not use the relay.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Weather:</div> If you add the Weather
|
||||
module, your Mac asks our relay for forecasts using coordinates
|
||||
rounded to a 0.1° grid — about 11 km — and the rounding happens
|
||||
on your Mac before anything is sent. The Weather module
|
||||
registers with the relay for this even if you never turn on the
|
||||
phone connection, and on a licensed Mac that registration
|
||||
presents the license key, exactly as the phone connection's
|
||||
does. "My Location" asks macOS for your
|
||||
approximate position, and naming or searching for a place uses
|
||||
Apple's Maps service, so Apple sees those lookups. Neither
|
||||
feature sends us your precise location.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(e) The Network panel:</div> While the
|
||||
Network panel's connection section is on screen, The Nook
|
||||
asks a third-party IP-lookup service (get.geojs.io, with
|
||||
ipwho.is as backup) what your public IP address and provider
|
||||
are — that is how the panel can show them to you. Those services
|
||||
necessarily see your IP address. The speed-test button measures
|
||||
against Cloudflare's public speed endpoint and uploads only
|
||||
zeros, never your data.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(f) Update checks:</div> Automatic update
|
||||
checks ship off. If you turn them on, or press "Check for
|
||||
Updates", The Nook fetches the update feed from freno.me
|
||||
and the request carries your app version. Like every request on
|
||||
this page, it also arrives from your IP address.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(g) Changelog:</div> Opening Settings ›
|
||||
Changelog fetches the release notes from freno.me.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(h) Bug reports:</div> only what you choose
|
||||
to send. If you write a bug report, you decide what it says.
|
||||
Bug reports also include a machine snapshot — macOS version, Mac
|
||||
model, CPU, memory, free disk, locale, and your displays'
|
||||
names and resolutions — so the problem can be reproduced. The
|
||||
optional contact email is used only to reply to you.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(i) Your email and payment:</div> The email
|
||||
you give at checkout delivers your license key and looks up your
|
||||
order. An email you add during the trial is used for one
|
||||
reminder before the trial ends. Payments are processed by
|
||||
Stripe; we never see or store your card details.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(j) What does not leave your Mac:</div> The
|
||||
hook and the bridge that connect your agents to The Nook are
|
||||
not a cloud service: the hook dials the bridge on your Mac, and
|
||||
the bridge only listens. Two paths carry agent data across the
|
||||
network, and both are switched on by you: if you add a remote
|
||||
machine, the hook on that machine dials this Mac over SSH or
|
||||
your tailnet; and if you use the phone connection, the session
|
||||
updates, approvals and replies described in (c) travel through
|
||||
the relay, sealed end to end. The privileged helper that
|
||||
controls fans and charging has no networking code at all.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">The iPhone companion.</div> The iPhone and
|
||||
Apple Watch app is in TestFlight beta. The build in TestFlight
|
||||
starts Sentry on every launch, with no setting that turns it
|
||||
off: it sends crash, error and hang reports, session pings,
|
||||
performance traces, the app version, the iOS version, the
|
||||
iPhone model and your IP address. Those reports never include
|
||||
your prompts, code or session content.
|
||||
Its other network traffic is the phone connection in (c) — the
|
||||
relay, or a direct LAN/tailnet link — plus, if you allow
|
||||
notifications, the push token it gives the relay so Apple can
|
||||
deliver them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,9 +192,11 @@ export default function NookPrivacyPolicy() {
|
||||
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
Device identifiers, your email, and license records are used only
|
||||
to operate the trial, deliver purchases, and enforce the 3-device
|
||||
license. We do not use them for advertising or sell them to anyone.
|
||||
Device identifiers, install IDs, your email and license records are
|
||||
used only to operate the trial and the licenses, to run the relay
|
||||
and the weather service, to answer bug reports, and to deliver
|
||||
purchases. We do not use them for advertising and we do not sell
|
||||
them to anyone.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,9 +205,13 @@ export default function NookPrivacyPolicy() {
|
||||
<span class="-ml-4 pr-2">3.</span> Data Security
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
License keys are signed and verified with Ed25519. The connection
|
||||
to our servers uses TLS. Your license key is stored in the macOS
|
||||
Keychain on your Mac.
|
||||
License keys are signed and verified with Ed25519 and stored in
|
||||
the macOS Keychain. The session content your Mac and phone
|
||||
exchange is encrypted end-to-end with keys your devices generate
|
||||
at pairing; our relay holds no key that can read it, though it
|
||||
can read the routing metadata described in (c). Every internet
|
||||
connection described on this page uses TLS; the direct
|
||||
LAN/tailnet paths use the same end-to-end encryption.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConnectionFactory } from "./database";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import type { VisitorAnalytics, AnalyticsQuery } from "~/db/types";
|
||||
import { DOWNLOAD_PATH_PREFIX, summariseDownloads } from "~/lib/download-asset";
|
||||
import { CACHE_CONFIG } from "~/config";
|
||||
import {
|
||||
VISITOR_ANALYTICS_ADDED_COLUMNS,
|
||||
@@ -272,6 +273,10 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||
topReferrers: Array<{ referrer: string; count: number }>;
|
||||
deviceTypes: Array<{ type: string; count: number }>;
|
||||
browsers: Array<{ browser: string; count: number }>;
|
||||
downloads: {
|
||||
total: number;
|
||||
byAsset: Array<{ asset: string; count: number }>;
|
||||
};
|
||||
topCampaigns: Array<{
|
||||
source: string | null;
|
||||
medium: string | null;
|
||||
@@ -409,6 +414,24 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||
count: row.count as number
|
||||
}));
|
||||
|
||||
// Downloads: every door records one row under `/api/downloads/<asset>` via
|
||||
// `trackDownload`, so the asset is the path suffix and nothing has to be
|
||||
// parsed out of a query string.
|
||||
const downloadsResult = await conn.execute({
|
||||
sql: `SELECT path, COUNT(*) as count FROM VisitorAnalytics
|
||||
WHERE path LIKE '${DOWNLOAD_PATH_PREFIX}%'
|
||||
AND created_at >= datetime('now', '-${days} days')
|
||||
GROUP BY path
|
||||
ORDER BY count DESC`,
|
||||
args: []
|
||||
});
|
||||
const downloads = summariseDownloads(
|
||||
downloadsResult.rows.map((row) => ({
|
||||
path: String(row.path),
|
||||
count: row.count as number
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
totalVisits,
|
||||
totalPageVisits,
|
||||
@@ -420,7 +443,8 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||
topReferrers,
|
||||
deviceTypes,
|
||||
browsers,
|
||||
topCampaigns
|
||||
topCampaigns,
|
||||
downloads
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { databaseRouter } from "./routers/database";
|
||||
import { lineageRouter } from "./routers/lineage";
|
||||
// Re-exported so the REST shim (src/routes/api/lineage/[...path].ts) can mount
|
||||
// ONLY the lineage sub-router as the trpc-openapi REST surface — not the
|
||||
// whole appRouter. Keeps nessa/community/auth/audit routers unreachable via
|
||||
// whole appRouter. Keeps auth/audit routers unreachable via
|
||||
// `/api/lineage/*`.
|
||||
export { lineageRouter };
|
||||
import { miscRouter } from "./routers/misc";
|
||||
@@ -16,8 +16,6 @@ import { postHistoryRouter } from "./routers/post-history";
|
||||
import { infillRouter } from "./routers/infill";
|
||||
import { accountRouter } from "./routers/account";
|
||||
import { downloadsRouter } from "./routers/downloads";
|
||||
import { nessaDbRouter } from "./routers/nessa";
|
||||
import { nessaCommunityRouter } from "./routers/nessa-community";
|
||||
import { appleNotificationsRouter } from "./routers/apple-notifications";
|
||||
import { createTRPCRouter, createTRPCContext, t } from "./utils";
|
||||
import type { H3Event } from "h3";
|
||||
@@ -37,10 +35,6 @@ export const appRouter = createTRPCRouter({
|
||||
infill: infillRouter,
|
||||
account: accountRouter,
|
||||
downloads: downloadsRouter,
|
||||
nessaDb: nessaDbRouter,
|
||||
// Community features (clubs, challenges, social feed) — ported from the
|
||||
// standalone nessa-api Express prototype. Exposes nessa.community.*.
|
||||
nessa: createTRPCRouter({ community: nessaCommunityRouter }),
|
||||
appleNotifications: appleNotificationsRouter
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "~/env/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { trackDownload } from "~/server/track-download";
|
||||
|
||||
const assets: Record<string, string> = {
|
||||
lineage: "Life and Lineage.apk",
|
||||
@@ -73,7 +74,14 @@ const latestAssets: Record<
|
||||
export const downloadsRouter = createTRPCRouter({
|
||||
getDownloadUrl: publicProcedure
|
||||
.input(z.object({ asset_name: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Logged before signing: a click on a door that then fails to sign is
|
||||
// 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);
|
||||
}
|
||||
|
||||
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||
|
||||
const credentials = {
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("p8-001 / p8-008 static source audit", () => {
|
||||
it(`${proc} is not declared as publicProcedure`, () => {
|
||||
// Match the procedure declaration line and ensure it is not publicProcedure.
|
||||
const re = new RegExp(
|
||||
`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure|nessaProcedure)`
|
||||
`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure)`
|
||||
);
|
||||
const m = SOURCE.match(re);
|
||||
expect(m, `${proc} declaration not found`).not.toBeNull();
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||
import { Database } from "bun:sqlite";
|
||||
import {
|
||||
requireClubMembership,
|
||||
resolveClubIdFromPost,
|
||||
resolveClubIdFromChallenge,
|
||||
type NessaConn
|
||||
} from "./nessa-community-authz";
|
||||
|
||||
/**
|
||||
* Regression tests for p8-003: private club content (posts, comments, likes,
|
||||
* challenge participation) must NOT be readable/actionable by non-members.
|
||||
*
|
||||
* These tests exercise the shared membership-gating helpers directly against
|
||||
* an in-memory SQLite DB (`bun:sqlite`) wrapped to match the libsql
|
||||
* `execute({ sql, args }) -> { rows }` contract the router uses. The
|
||||
* `nessa-community.ts` router calls these same helpers in the same order, so a
|
||||
* pass here guarantees the authorization decision each endpoint makes before
|
||||
* touching data.
|
||||
*
|
||||
* Two users are seeded: A is a member (owner) of club C (and owns the post +
|
||||
* challenge under test); B is NOT a member of C.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory SQLite connection (libsql-shaped)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let db: Database;
|
||||
let conn: NessaConn;
|
||||
|
||||
function makeConn(): NessaConn {
|
||||
return {
|
||||
execute: async ({
|
||||
sql,
|
||||
args
|
||||
}: {
|
||||
sql: string;
|
||||
args?: (string | number | null)[];
|
||||
}) => {
|
||||
const stmt = db.prepare(sql);
|
||||
const upper = sql.trim().toUpperCase();
|
||||
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
|
||||
if (isRead) {
|
||||
const rows = stmt.all(...(args ?? []));
|
||||
return { rows: rows as unknown[] };
|
||||
}
|
||||
stmt.run(...(args ?? []));
|
||||
return { rows: [] as unknown[] };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema + seed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USER_A = "user-a";
|
||||
const USER_B = "user-b";
|
||||
const CLUB_C = "club-c";
|
||||
const POST_P = "post-p"; // created by A in club C
|
||||
const CHALLENGE_CH = "challenge-ch"; // in club C, created by A
|
||||
|
||||
function initSchema() {
|
||||
db = new Database(":memory:");
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
|
||||
db.run(
|
||||
"CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)"
|
||||
);
|
||||
db.run(
|
||||
"CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)"
|
||||
);
|
||||
db.run(
|
||||
"CREATE TABLE clubChallenges (id TEXT PRIMARY KEY, clubId TEXT, title TEXT, description TEXT, goalType TEXT, goalValue REAL, startDate TEXT, endDate TEXT, createdBy TEXT, status TEXT, createdAt TEXT, updatedAt TEXT)"
|
||||
);
|
||||
}
|
||||
|
||||
function seed() {
|
||||
// Club C: A is a member (owner). B is NOT.
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-a", CLUB_C, USER_A, "owner"]
|
||||
);
|
||||
|
||||
// Post P by A in club C.
|
||||
db.run(
|
||||
"INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
[POST_P, CLUB_C, USER_A, "Hello from A", "text"]
|
||||
);
|
||||
|
||||
// Challenge CH in club C, created by A.
|
||||
db.run(
|
||||
"INSERT INTO clubChallenges (id, clubId, title, description, goalType, goalValue, startDate, endDate, createdBy, status, createdAt, updatedAt) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))",
|
||||
[
|
||||
CHALLENGE_CH,
|
||||
CLUB_C,
|
||||
"Run 5k",
|
||||
"distance",
|
||||
5000,
|
||||
"2025-01-01",
|
||||
"2025-12-31",
|
||||
USER_A,
|
||||
"active"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(() => {
|
||||
initSchema();
|
||||
seed();
|
||||
conn = makeConn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Keep membership state stable across tests (join/leave integration mutates it).
|
||||
db.run("DELETE FROM clubMemberships");
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-a", CLUB_C, USER_A, "owner"]
|
||||
);
|
||||
});
|
||||
|
||||
async function errCode(p: Promise<unknown>): Promise<string | undefined> {
|
||||
try {
|
||||
await p;
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
return (e as { code?: string }).code;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("p8-003: resolveClubIdFromPost", () => {
|
||||
it("resolves the owning club for an existing post", async () => {
|
||||
expect(await resolveClubIdFromPost(conn, POST_P)).toBe(CLUB_C);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for a missing post", async () => {
|
||||
expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe(
|
||||
"NOT_FOUND"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("p8-003: resolveClubIdFromChallenge", () => {
|
||||
it("resolves the owning club for an existing challenge", async () => {
|
||||
expect(await resolveClubIdFromChallenge(conn, CHALLENGE_CH)).toBe(CLUB_C);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for a missing challenge", async () => {
|
||||
expect(
|
||||
await errCode(resolveClubIdFromChallenge(conn, "no-such-challenge"))
|
||||
).toBe("NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
describe("p8-003: requireClubMembership", () => {
|
||||
it("passes silently for a member", async () => {
|
||||
await expect(
|
||||
requireClubMembership(conn, CLUB_C, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN for a non-member", async () => {
|
||||
expect(await errCode(requireClubMembership(conn, CLUB_C, USER_B))).toBe(
|
||||
"FORBIDDEN"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* End-to-end authorization sequence for each of the 7 fixed endpoints. The
|
||||
* router does exactly: resolve the resource's clubId, then
|
||||
* requireClubMembership on it. Replaying that here proves the decision a
|
||||
* non-member is rejected / a member is allowed.
|
||||
*/
|
||||
describe("p8-003: endpoint authorization sequences (resolve → require)", () => {
|
||||
// social.getPost / addComment / comments / like / unlike
|
||||
it("getPost/addComment/comments/like/unlike: non-member B rejected with FORBIDDEN", async () => {
|
||||
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||
"FORBIDDEN"
|
||||
);
|
||||
});
|
||||
|
||||
it("getPost/addComment/comments/like/unlike: member A allowed", async () => {
|
||||
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||
await expect(
|
||||
requireClubMembership(conn, clubId, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// challenges.leave / challenges.submitProgress
|
||||
it("challenges.leave / submitProgress: non-member B rejected with FORBIDDEN", async () => {
|
||||
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||
"FORBIDDEN"
|
||||
);
|
||||
});
|
||||
|
||||
it("challenges.leave / submitProgress: member A allowed", async () => {
|
||||
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
||||
await expect(
|
||||
requireClubMembership(conn, clubId, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("p8-003: join then allowed / leave then blocked (integration)", () => {
|
||||
it("B is blocked, allowed after joining C, blocked again after leaving", async () => {
|
||||
// Initially blocked.
|
||||
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||
"FORBIDDEN"
|
||||
);
|
||||
|
||||
// B joins.
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-b", CLUB_C, USER_B, "member"]
|
||||
);
|
||||
await expect(
|
||||
requireClubMembership(conn, clubId, USER_B)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// B leaves.
|
||||
db.run("DELETE FROM clubMemberships WHERE clubId = ? AND userId = ?", [
|
||||
CLUB_C,
|
||||
USER_B
|
||||
]);
|
||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||
"FORBIDDEN"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clerk session → local users.id resolution (migrate-to-clerk-auth-03)
|
||||
//
|
||||
// `createTRPCContext` verifies a Clerk session JWT (`verifyNessaToken`)
|
||||
// and resolves `ctx.nessaUserId` by looking up `users.id` via the indexed
|
||||
// `clerkUserId` column. These tests exercise that lookup path against an
|
||||
// in-memory SQLite DB so the contract is guaranteed:
|
||||
// - seeded row with matching clerkUserId → local id resolved
|
||||
// - missing local row → UNAUTHORIZED
|
||||
// - the resolved id is the LOCAL users.id, never the Clerk sub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CLERK_USER_ID = "user_test_abc123";
|
||||
const LOCAL_USER_A = "local-user-a";
|
||||
const LOCAL_USER_B = "local-user-b";
|
||||
|
||||
function initUsersTable() {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
clerkUserId TEXT
|
||||
)`);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveLocalUserId(clerkUserId: string): Promise<string | null> {
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT id FROM users WHERE clerkUserId = ?",
|
||||
args: [clerkUserId]
|
||||
});
|
||||
if (result.rows.length === 0) return null;
|
||||
return (result.rows[0] as { id: string }).id;
|
||||
}
|
||||
|
||||
describe("clerkUserId lookup (migrate-to-clerk-auth-03)", () => {
|
||||
beforeAll(() => {
|
||||
initUsersTable();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.run("DELETE FROM users");
|
||||
});
|
||||
|
||||
it("resolves local users.id for a seeded clerkUserId", async () => {
|
||||
db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
|
||||
LOCAL_USER_A,
|
||||
"a@nessa.app",
|
||||
CLERK_USER_ID
|
||||
]);
|
||||
expect(await resolveLocalUserId(CLERK_USER_ID)).toBe(LOCAL_USER_A);
|
||||
});
|
||||
|
||||
it("returns null when no local row matches the clerkUserId", async () => {
|
||||
// No users seeded — the webhook has not run yet.
|
||||
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a Clerk id that exists but maps to a different local user", async () => {
|
||||
db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
|
||||
LOCAL_USER_B,
|
||||
"b@nessa.app",
|
||||
"user_test_other"
|
||||
]);
|
||||
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("ctx.nessaUserId is the LOCAL id, never the Clerk sub", async () => {
|
||||
db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
|
||||
LOCAL_USER_A,
|
||||
"a@nessa.app",
|
||||
CLERK_USER_ID
|
||||
]);
|
||||
const resolved = await resolveLocalUserId(CLERK_USER_ID);
|
||||
expect(resolved).toBe(LOCAL_USER_A);
|
||||
expect(resolved).not.toBe(CLERK_USER_ID);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
/**
|
||||
* Community authorization helpers (p8-003).
|
||||
*
|
||||
* Membership gating for `nessaCommunityRouter`. Extracted into a dependency-
|
||||
* free module (no `~/env/server` import) so it can be unit-tested directly
|
||||
* against an in-memory SQLite connection without booting the SSR-guarded env
|
||||
* chain, and so every membership-gated endpoint shares ONE implementation of
|
||||
* each check (no ad-hoc duplicated SQL).
|
||||
*
|
||||
* Contract mirrors the libsql client the router uses: a connection exposes
|
||||
* `execute({ sql, args }) -> { rows }`.
|
||||
*/
|
||||
|
||||
/** Minimal libsql-shaped connection surface used by community authz. */
|
||||
export interface NessaConn {
|
||||
execute: (q: {
|
||||
sql: string;
|
||||
args?: (string | number | null)[];
|
||||
}) => Promise<{ rows: unknown[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that the calling user is a member of the club (or is the owner).
|
||||
*
|
||||
* Throws `TRPCError({ code: "FORBIDDEN", message: "Not a member of this club" })`
|
||||
* on miss and returns void on success. This is the single source of truth for
|
||||
* "is this user allowed to touch this club's content" — every membership-gated
|
||||
* endpoint in `nessa-community.ts` MUST route through this helper.
|
||||
*/
|
||||
export async function requireClubMembership(
|
||||
conn: NessaConn,
|
||||
clubId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT id FROM clubMemberships WHERE clubId = ? AND userId = ?",
|
||||
args: [clubId, userId]
|
||||
});
|
||||
if (!result.rows.length) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Not a member of this club"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the clubId that owns a post. Returns the owning club's id, or
|
||||
* throws `NOT_FOUND` if the post does not exist. Used by read/interaction
|
||||
* endpoints (`getPost`, `addComment`, `comments`, `like`, `unlike`) to derive
|
||||
* the club a target post belongs to before gating on membership.
|
||||
*/
|
||||
export async function resolveClubIdFromPost(
|
||||
conn: NessaConn,
|
||||
postId: string
|
||||
): Promise<string> {
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT clubId FROM clubPosts WHERE id = ?",
|
||||
args: [postId]
|
||||
});
|
||||
if (!result.rows.length) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" });
|
||||
}
|
||||
return (result.rows[0] as unknown as { clubId: string }).clubId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the clubId that owns a challenge. Returns the owning club's id, or
|
||||
* throws `NOT_FOUND` if the challenge does not exist. Used by challenge
|
||||
* interaction endpoints (`challenges.leave`, `challenges.submitProgress`) to
|
||||
* derive the club a challenge belongs to before gating on membership.
|
||||
*/
|
||||
export async function resolveClubIdFromChallenge(
|
||||
conn: NessaConn,
|
||||
challengeId: string
|
||||
): Promise<string> {
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT clubId FROM clubChallenges WHERE id = ?",
|
||||
args: [challengeId]
|
||||
});
|
||||
if (!result.rows.length) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Challenge not found"
|
||||
});
|
||||
}
|
||||
return (result.rows[0] as unknown as { clubId: string }).clubId;
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { sanitizeCommunityContent } from "~/server/lib/sanitize";
|
||||
import {
|
||||
requireClubMembership,
|
||||
resolveClubIdFromPost,
|
||||
type NessaConn
|
||||
} from "./nessa-community-authz";
|
||||
|
||||
/**
|
||||
* Tests for p8-012: sanitize community post/comment content on write.
|
||||
*
|
||||
* Content model: plain text — no HTML is stored. The iOS client renders
|
||||
* with SwiftUI `Text()` (not a WebView), so there is no render-time XSS
|
||||
* surface. Sanitization on write is defense-in-depth against a future HTML
|
||||
* render path.
|
||||
*
|
||||
* The router's `social.createPost` and `social.addComment` run
|
||||
* `sanitizeCommunityContent(input.content)` before the INSERT. These tests
|
||||
* verify the sanitizer directly and then exercise the full write path
|
||||
* against an in-memory SQLite DB wrapped to match the libsql contract.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanitizer unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sanitizeCommunityContent", () => {
|
||||
it("strips <script> tags", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent("<script>alert(1)</script>hello")
|
||||
).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("strips <script> with attributes and newlines", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
"<script type='text/javascript'>\nalert('xss')\n</script>safe"
|
||||
)
|
||||
).toBe("alert('xss') safe");
|
||||
});
|
||||
|
||||
it("strips <img onerror=...> event handlers", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'check this <img src=x onerror="alert(1)"> out'
|
||||
)
|
||||
).toBe("check this out");
|
||||
});
|
||||
|
||||
it("strips <svg onload=...>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent('<svg onload="alert(1)">text</svg>')
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
it("strips <iframe>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<iframe src="javascript:alert(1)"></iframe>content'
|
||||
)
|
||||
).toBe("content");
|
||||
});
|
||||
|
||||
it("strips <body onload=...>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent('<body onload="alert(1)">body</body>')
|
||||
).toBe("body");
|
||||
});
|
||||
|
||||
it("strips <input onfocus=... autofocus>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<input onfocus="alert(1)" autofocus>text'
|
||||
)
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
it("strips <a href=javascript:...>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'click <a href="javascript:alert(1)">here</a> now'
|
||||
)
|
||||
).toBe("click here now");
|
||||
});
|
||||
|
||||
it("strips <style> tags (inner text is harmless as plain text)", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<style>body{background:red}</style>text'
|
||||
)
|
||||
).toBe("body{background:red}text");
|
||||
});
|
||||
|
||||
it("strips <link> tags", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<link rel="stylesheet" href="evil.css">text'
|
||||
)
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
it("strips self-closing tags", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent('text<br/>more<br />end')
|
||||
).toBe("textmoreend");
|
||||
});
|
||||
|
||||
it("handles HTML entities — no round-trip through future HTML renderer", () => {
|
||||
// `<script>` decoded to `<script>` then stripped, not stored as
|
||||
// literal `<script>` that a future HTML renderer would decode.
|
||||
expect(
|
||||
sanitizeCommunityContent("<script>alert(1)</script>hello")
|
||||
).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("decodes numeric entities", () => {
|
||||
expect(sanitizeCommunityContent("<script>alert</script>")).toBe(
|
||||
"alert"
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes hex entities", () => {
|
||||
expect(sanitizeCommunityContent("<script>alert</script>")).toBe(
|
||||
"alert"
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes common named entities", () => {
|
||||
expect(sanitizeCommunityContent("it's "great" & fun")).toBe(
|
||||
"it's \"great\" & fun"
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses whitespace and trims", () => {
|
||||
expect(sanitizeCommunityContent(" hello world ")).toBe("hello world");
|
||||
expect(sanitizeCommunityContent("\n\t spaced \n")).toBe("spaced");
|
||||
});
|
||||
|
||||
it("preserves plain text unchanged", () => {
|
||||
expect(sanitizeCommunityContent("Hello world! How are you?")).toBe(
|
||||
"Hello world! How are you?"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves text with mixed safe punctuation", () => {
|
||||
expect(sanitizeCommunityContent("It's 100% amazing — really!")).toBe(
|
||||
"It's 100% amazing — really!"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty / whitespace-only input", () => {
|
||||
expect(sanitizeCommunityContent("")).toBe("");
|
||||
expect(sanitizeCommunityContent(" ")).toBe("");
|
||||
expect(sanitizeCommunityContent("\n\n\t")).toBe("");
|
||||
});
|
||||
|
||||
it("handles unicode content", () => {
|
||||
expect(sanitizeCommunityContent("你好世界 🌍")).toBe("你好世界 🌍");
|
||||
});
|
||||
|
||||
it("strips nested tags", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<div><p><script>alert(1)</script><b>bold</b></p></div>end'
|
||||
)
|
||||
).toBe("alert(1)boldend");
|
||||
});
|
||||
|
||||
it("handles unclosed tags", () => {
|
||||
expect(sanitizeCommunityContent("<div>text")).toBe("text");
|
||||
expect(sanitizeCommunityContent("text</div>")).toBe("text");
|
||||
});
|
||||
|
||||
it("strips <object>, <embed>, <applet>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<object data="evil.swf"></object>text'
|
||||
)
|
||||
).toBe("text");
|
||||
expect(
|
||||
sanitizeCommunityContent('<embed src="evil.swf">text')
|
||||
).toBe("text");
|
||||
expect(
|
||||
sanitizeCommunityContent('<applet code="Evil.class">text</applet>')
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
it("strips data: URI tags — remaining text is harmless as plain text", () => {
|
||||
// The <a> tag and its attribute are stripped; the inner text remains.
|
||||
// As plain text, the leftover characters are not executable.
|
||||
const result = sanitizeCommunityContent(
|
||||
'<a href="data:text/html,<script>alert(1)</script>">link</a>'
|
||||
);
|
||||
expect(result).not.toContain("<script>");
|
||||
expect(result).not.toContain("data:text/html");
|
||||
expect(result).toContain("link");
|
||||
});
|
||||
|
||||
it("strips <math> and <foreignObject>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<math><maction actiontype="statusline#http://evil.com">click</maction></math>'
|
||||
)
|
||||
).toBe("click");
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<svg><foreignObject><div xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></div></foreignObject></svg>'
|
||||
)
|
||||
).toBe("alert(1)");
|
||||
});
|
||||
|
||||
it("strips <details> / <summary> / <template>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<details><summary>click</summary><script>alert(1)</script></details>'
|
||||
)
|
||||
).toBe("clickalert(1)");
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<template><script>alert(1)</script></template>text'
|
||||
)
|
||||
).toBe("alert(1)text");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory SQLite write-path tests (libsql-shaped)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let db: Database;
|
||||
let conn: NessaConn;
|
||||
|
||||
function makeConn(): NessaConn {
|
||||
return {
|
||||
execute: async ({
|
||||
sql,
|
||||
args
|
||||
}: {
|
||||
sql: string;
|
||||
args?: (string | number | null)[];
|
||||
}) => {
|
||||
const stmt = db.prepare(sql);
|
||||
const upper = sql.trim().toUpperCase();
|
||||
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
|
||||
if (isRead) {
|
||||
const rows = stmt.all(...(args ?? []));
|
||||
return { rows: rows as unknown[] };
|
||||
}
|
||||
stmt.run(...(args ?? []));
|
||||
return { rows: [] as unknown[] };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const USER_A = "user-a";
|
||||
const CLUB_C = "club-c";
|
||||
|
||||
function initSchema() {
|
||||
db = new Database(":memory:");
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
|
||||
db.run("CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)");
|
||||
db.run("CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)");
|
||||
db.run("CREATE TABLE clubPostComments (id TEXT PRIMARY KEY, postId TEXT, userId TEXT, content TEXT, createdAt TEXT, updatedAt TEXT)");
|
||||
}
|
||||
|
||||
function seed() {
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-a", CLUB_C, USER_A, "owner"]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(() => {
|
||||
initSchema();
|
||||
seed();
|
||||
conn = makeConn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.run("DELETE FROM clubPostComments");
|
||||
db.run("DELETE FROM clubPosts");
|
||||
db.run("DELETE FROM clubMemberships");
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-a", CLUB_C, USER_A, "owner"]
|
||||
);
|
||||
});
|
||||
|
||||
describe("p8-012: createPost sanitizes content on write", () => {
|
||||
it("stores <script>alert(1)</script> without the script tag", async () => {
|
||||
const postId = "post-1";
|
||||
const raw = "<script>alert(1)</script>hello";
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
args: [postId, CLUB_C, USER_A, sanitized, "text"]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPosts WHERE id = ?").get(postId);
|
||||
expect(row.content).not.toContain("<script>");
|
||||
expect(row.content).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("stores <img onerror=...> without the event handler", async () => {
|
||||
const postId = "post-2";
|
||||
const raw = 'check this <img src=x onerror="alert(1)"> out';
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
args: [postId, CLUB_C, USER_A, sanitized, "text"]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPosts WHERE id = ?").get(postId);
|
||||
expect(row.content).not.toContain("onerror");
|
||||
expect(row.content).not.toContain("<img");
|
||||
expect(row.content).toBe("check this out");
|
||||
});
|
||||
|
||||
it("stores <script> decoded and stripped (no entity round-trip)", async () => {
|
||||
const postId = "post-3";
|
||||
const raw = "<script>alert(1)</script>hello";
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
args: [postId, CLUB_C, USER_A, sanitized, "text"]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPosts WHERE id = ?").get(postId);
|
||||
expect(row.content).not.toContain("<script>");
|
||||
expect(row.content).not.toContain("<script");
|
||||
expect(row.content).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("preserves plain text content unchanged", async () => {
|
||||
const postId = "post-4";
|
||||
const raw = "Hello world! This is a normal post.";
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
args: [postId, CLUB_C, USER_A, sanitized, "text"]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPosts WHERE id = ?").get(postId);
|
||||
expect(row.content).toBe("Hello world! This is a normal post.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("p8-012: addComment sanitizes content on write", () => {
|
||||
it("stores <script>alert(1)</script> without the script tag", async () => {
|
||||
const postId = "post-1";
|
||||
const commentId = "comment-1";
|
||||
const raw = "<script>alert(1)</script>hello";
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
// Seed the post first
|
||||
db.run(
|
||||
"INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, 'normal post', 'text', NULL, datetime('now'), datetime('now'))",
|
||||
[postId, CLUB_C, USER_A]
|
||||
);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPostComments (id, postId, userId, content, createdAt, updatedAt) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))",
|
||||
args: [commentId, postId, USER_A, sanitized]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPostComments WHERE id = ?").get(commentId);
|
||||
expect(row.content).not.toContain("<script>");
|
||||
expect(row.content).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("stores <img onerror=...> without the event handler", async () => {
|
||||
const postId = "post-2";
|
||||
const commentId = "comment-2";
|
||||
const raw = 'look <img src=x onerror="alert(1)"> here';
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
db.run(
|
||||
"INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, 'normal post', 'text', NULL, datetime('now'), datetime('now'))",
|
||||
[postId, CLUB_C, USER_A]
|
||||
);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPostComments (id, postId, userId, content, createdAt, updatedAt) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))",
|
||||
args: [commentId, postId, USER_A, sanitized]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPostComments WHERE id = ?").get(commentId);
|
||||
expect(row.content).not.toContain("onerror");
|
||||
expect(row.content).not.toContain("<img");
|
||||
expect(row.content).toBe("look here");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,371 +0,0 @@
|
||||
/**
|
||||
* Nessa CRUD Ownership Check Tests
|
||||
* Regression tests for p8-002: per-resource ownership verification on mutation endpoints
|
||||
*
|
||||
* The ownership enforcement lives in three exported helpers — assertWorkoutOwned,
|
||||
* assertAuthProviderOwned, and assertExerciseLibraryOwned — which every targeted
|
||||
* mutation calls before modifying data. These tests verify the helpers and the
|
||||
* direct userId comparisons used in create/createAuthProvider/createExerciseLibrary
|
||||
* and bulkUpsert.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { Client } from "@libsql/client/web";
|
||||
|
||||
// Prevent the env/server.ts client-side guard from throwing during tests
|
||||
mock.module("~/env/server", () => ({
|
||||
env: {
|
||||
TURSO_DB_URL: "libsql://test.turso.io",
|
||||
TURSO_DB_TOKEN: "test-token",
|
||||
NESSA_DB_URL: "libsql://nessa-test.turso.io",
|
||||
NESSA_DB_TOKEN: "test-token",
|
||||
TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io",
|
||||
TURSO_LINEAGE_TOKEN: "test-token",
|
||||
TURSO_DB_API_TOKEN: "test-token",
|
||||
NODE_ENV: "test",
|
||||
// Clerk env vars (required after migration)
|
||||
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
||||
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
||||
},
|
||||
validateServerEnv: () => ({}),
|
||||
isMissingEnvVar: () => false,
|
||||
getMissingEnvVars: () => []
|
||||
}));
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMockConn(rows: Record<string, unknown>[]): Client {
|
||||
const executeMock = mock(async () => {
|
||||
return { rows, rowsAffected: 0, lastInsertRowid: 0n } as any;
|
||||
});
|
||||
return { execute: executeMock } as Client;
|
||||
}
|
||||
|
||||
const USER_A = "user-a";
|
||||
const USER_B = "user-b";
|
||||
const WORKOUT_ID = "workout-1";
|
||||
const HR_SAMPLE_ID = "hr-1";
|
||||
const LOC_SAMPLE_ID = "loc-1";
|
||||
const SPLIT_ID = "split-1";
|
||||
const EXERCISE_ID = "ex-1";
|
||||
const PROVIDER_ID = "prov-1";
|
||||
|
||||
// ─── assertWorkoutOwned helper ────────────────────────────────────────────────
|
||||
// Used by: create/update/deleteHeartRateSample, create/update/deleteLocationSample,
|
||||
// create/update/deleteWorkoutSplit
|
||||
|
||||
describe("assertWorkoutOwned helper", () => {
|
||||
let assertWorkoutOwned: (
|
||||
conn: Client,
|
||||
workoutId: string,
|
||||
userId: string
|
||||
) => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("./nessa");
|
||||
assertWorkoutOwned = mod.assertWorkoutOwned;
|
||||
});
|
||||
|
||||
it("rejects when workout belongs to another user", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_B }]);
|
||||
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
|
||||
/owner/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when workout does not exist", async () => {
|
||||
const conn = makeMockConn([]);
|
||||
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
|
||||
/not found/i
|
||||
);
|
||||
});
|
||||
|
||||
it("succeeds when workout belongs to the caller", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_A }]);
|
||||
await expect(
|
||||
assertWorkoutOwned(conn, WORKOUT_ID, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── assertAuthProviderOwned helper ───────────────────────────────────────────
|
||||
// Used by: updateAuthProvider, deleteAuthProvider
|
||||
|
||||
describe("assertAuthProviderOwned helper", () => {
|
||||
let assertAuthProviderOwned: (
|
||||
conn: Client,
|
||||
providerId: string,
|
||||
userId: string
|
||||
) => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("./nessa");
|
||||
assertAuthProviderOwned = mod.assertAuthProviderOwned;
|
||||
});
|
||||
|
||||
it("rejects when auth provider belongs to another user", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_B }]);
|
||||
await expect(
|
||||
assertAuthProviderOwned(conn, PROVIDER_ID, USER_A)
|
||||
).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it("rejects when auth provider does not exist", async () => {
|
||||
const conn = makeMockConn([]);
|
||||
await expect(
|
||||
assertAuthProviderOwned(conn, PROVIDER_ID, USER_A)
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it("succeeds when auth provider belongs to the caller", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_A }]);
|
||||
await expect(
|
||||
assertAuthProviderOwned(conn, PROVIDER_ID, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── assertExerciseLibraryOwned helper ────────────────────────────────────────
|
||||
// Used by: updateExerciseLibrary, deleteExerciseLibrary
|
||||
|
||||
describe("assertExerciseLibraryOwned helper", () => {
|
||||
let assertExerciseLibraryOwned: (
|
||||
conn: Client,
|
||||
exerciseId: string,
|
||||
userId: string
|
||||
) => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("./nessa");
|
||||
assertExerciseLibraryOwned = mod.assertExerciseLibraryOwned;
|
||||
});
|
||||
|
||||
it("rejects when exercise belongs to another user", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_B }]);
|
||||
await expect(
|
||||
assertExerciseLibraryOwned(conn, EXERCISE_ID, USER_A)
|
||||
).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it("rejects when exercise does not exist", async () => {
|
||||
const conn = makeMockConn([]);
|
||||
await expect(
|
||||
assertExerciseLibraryOwned(conn, EXERCISE_ID, USER_A)
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it("succeeds when exercise belongs to the caller", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_A }]);
|
||||
await expect(
|
||||
assertExerciseLibraryOwned(conn, EXERCISE_ID, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mutation handler direct ownership checks ─────────────────────────────────
|
||||
// createHeartRateSample, createLocationSample, createWorkoutSplit call
|
||||
// assertWorkoutOwned — covered above.
|
||||
//
|
||||
// The remaining create mutations (createExerciseLibrary, createAuthProvider)
|
||||
// use a direct userId comparison: input.userId !== ctx.nessaUserId.
|
||||
// bulkUpsert also uses direct comparisons for users/workoutPlans/workouts/
|
||||
// exerciseLibrary/authProviders.
|
||||
//
|
||||
// We verify the comparison logic with pure unit tests.
|
||||
|
||||
describe("createExerciseLibrary direct userId check", () => {
|
||||
it("user B creating an exercise with userId=userA → FORBIDDEN", () => {
|
||||
const input = { userId: USER_A };
|
||||
const ctx = { nessaUserId: USER_B };
|
||||
let threw = false;
|
||||
if (input.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("user A creating an exercise with userId=userA → allowed", () => {
|
||||
const input = { userId: USER_A };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (input.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAuthProvider direct userId check (account takeover prevention)", () => {
|
||||
it("user B creating an auth provider with userId=userA → FORBIDDEN", () => {
|
||||
const input = { userId: USER_A };
|
||||
const ctx = { nessaUserId: USER_B };
|
||||
let threw = false;
|
||||
if (input.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("user A creating an auth provider with userId=userA → allowed", () => {
|
||||
const input = { userId: USER_A };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (input.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkUpsert ownership checks", () => {
|
||||
it("rejects a user record whose id ≠ caller", () => {
|
||||
const record = { id: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.id !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a workoutPlan whose userId ≠ caller", () => {
|
||||
const record = { userId: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a workout whose userId ≠ caller", () => {
|
||||
const record = { userId: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an exerciseLibrary record whose userId ≠ caller", () => {
|
||||
const record = { userId: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an authProvider whose userId ≠ caller", () => {
|
||||
const record = { userId: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts records that belong to the caller", () => {
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
for (const key of ["id", "userId"] as const) {
|
||||
const record = { [key]: USER_A };
|
||||
const val = record[key] as string;
|
||||
if (val !== ctx.nessaUserId) threw = true;
|
||||
}
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── static audit: no mutation handler ignores ctx.nessaUserId ────────────────
|
||||
// Verify by source-code inspection that every targeted mutation references ctx.
|
||||
|
||||
describe("static audit: every targeted mutation handler uses ctx", () => {
|
||||
const MUTATIONS = [
|
||||
"createHeartRateSample",
|
||||
"updateHeartRateSample",
|
||||
"deleteHeartRateSample",
|
||||
"createLocationSample",
|
||||
"updateLocationSample",
|
||||
"deleteLocationSample",
|
||||
"createWorkoutSplit",
|
||||
"updateWorkoutSplit",
|
||||
"deleteWorkoutSplit",
|
||||
"createExerciseLibrary",
|
||||
"updateExerciseLibrary",
|
||||
"deleteExerciseLibrary",
|
||||
"createAuthProvider",
|
||||
"updateAuthProvider",
|
||||
"deleteAuthProvider"
|
||||
];
|
||||
|
||||
it("no mutation handler in the list uses async ({ input }) without ctx", async () => {
|
||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||
|
||||
for (const name of MUTATIONS) {
|
||||
const re = new RegExp(
|
||||
`${name}:\\s*nessaProcedure[^}]*\\.mutation\\(async \\({\\s*input\\s*}\\)`,
|
||||
"s"
|
||||
);
|
||||
const match = source.match(re);
|
||||
expect(
|
||||
match,
|
||||
`${name} should not use async ({ input }) — must use ctx`
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("every mutation handler in the list references ctx", async () => {
|
||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||
|
||||
for (const name of MUTATIONS) {
|
||||
const re = new RegExp(
|
||||
`${name}:\\s*nessaProcedure[\\s\\S]*?\\.mutation\\([\\s\\S]*?\\n \\}\\),`,
|
||||
"s"
|
||||
);
|
||||
const match = source.match(re);
|
||||
expect(match, `${name} mutation block not found`).toBeTruthy();
|
||||
expect(match![0].includes("ctx"), `${name} must reference ctx`).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("upsertExerciseLibrary rejects an exercise owned by another user", async () => {
|
||||
const mod = await import("./nessa");
|
||||
const conn = makeMockConn([]);
|
||||
const exercise = {
|
||||
id: EXERCISE_ID,
|
||||
userId: USER_B,
|
||||
name: "Squat",
|
||||
category: "Strength"
|
||||
};
|
||||
let caught: unknown;
|
||||
try {
|
||||
await mod.upsertExerciseLibrary(conn, USER_A, [exercise]);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(TRPCError);
|
||||
expect((caught as TRPCError).code).toBe("FORBIDDEN");
|
||||
expect((caught as TRPCError).message).toBe("User mismatch");
|
||||
});
|
||||
|
||||
it("upsertExerciseLibrary upserts an exercise owned by the caller", async () => {
|
||||
const mod = await import("./nessa");
|
||||
const conn = makeMockConn([{ userId: USER_A }]);
|
||||
const exercise = {
|
||||
id: EXERCISE_ID,
|
||||
userId: USER_A,
|
||||
name: "Squat",
|
||||
category: "Strength"
|
||||
};
|
||||
await expect(
|
||||
mod.upsertExerciseLibrary(conn, USER_A, [exercise])
|
||||
).resolves.toBeUndefined();
|
||||
expect(conn.execute).toHaveBeenCalledWith({
|
||||
sql: expect.stringContaining(
|
||||
"INSERT INTO exerciseLibrary (id, userId, name, category"
|
||||
),
|
||||
args: [
|
||||
EXERCISE_ID,
|
||||
USER_A,
|
||||
"Squat",
|
||||
"Strength",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,12 @@ 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 { verifyNessaToken } from "~/server/nessa-auth";
|
||||
import { getAuthPayloadFromEvent } from "~/server/auth";
|
||||
import { NessaConnectionFactory } from "~/server/database";
|
||||
|
||||
export type Context = {
|
||||
event: APIEvent;
|
||||
userId: string | null;
|
||||
isAdmin: boolean;
|
||||
nessaUserId: string | null;
|
||||
};
|
||||
|
||||
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */
|
||||
@@ -55,44 +52,6 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
getHeader(req.headers, "referrer") ||
|
||||
getHeader(event.request?.headers, "referer");
|
||||
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
||||
const authHeader =
|
||||
getHeader(req.headers, "authorization") ||
|
||||
getHeader(event.request?.headers, "authorization") ||
|
||||
null;
|
||||
|
||||
let nessaUserId: string | null = null;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.replace("Bearer ", "").trim();
|
||||
try {
|
||||
// Verify the Clerk session JWT — `sub` is the Clerk user id.
|
||||
const clerkPayload = await verifyNessaToken(token);
|
||||
|
||||
// Resolve the Clerk user id to the local users.id via the indexed
|
||||
// clerkUserId column. One indexed query per request is acceptable;
|
||||
// no premature caching (the row is created by the Clerk webhook).
|
||||
const conn = NessaConnectionFactory();
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT id FROM users WHERE clerkUserId = ?",
|
||||
args: [clerkPayload.sub]
|
||||
});
|
||||
if (result.rows.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Nessa user not found — Clerk account not linked"
|
||||
});
|
||||
}
|
||||
// `nessaUserId` is the LOCAL users.id — router bodies reference it
|
||||
// exactly as before (club ownership, membership, row scoping).
|
||||
nessaUserId = (result.rows[0] as { id: string }).id;
|
||||
} catch (error) {
|
||||
// Re-throw typed TRPCError (lookup miss) so the caller gets UNAUTHORIZED;
|
||||
// swallow Clerk verification failures (expired/invalid token) the same
|
||||
// way the legacy path did — the enforceNessaUser middleware rejects
|
||||
// null nessaUserId with UNAUTHORIZED.
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error("Nessa JWT verification failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't log the performance logging endpoint itself to avoid circular tracking
|
||||
if (!path.includes("analytics.logPerformance")) {
|
||||
@@ -111,8 +70,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
return {
|
||||
event,
|
||||
userId,
|
||||
isAdmin,
|
||||
nessaUserId
|
||||
isAdmin
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,24 +110,8 @@ const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
|
||||
});
|
||||
});
|
||||
|
||||
const enforceNessaUser = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.nessaUserId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Nessa authentication required"
|
||||
});
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
nessaUserId: ctx.nessaUserId
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
|
||||
export const nessaProcedure = t.procedure.use(enforceNessaUser);
|
||||
|
||||
// CSRF protection middleware - defined here to avoid circular dependency
|
||||
const csrfProtection = t.middleware(async ({ ctx, next }) => {
|
||||
|
||||
186
src/server/backfill-downloads.test.ts
Normal file
186
src/server/backfill-downloads.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import {
|
||||
VISITOR_ANALYTICS_COLUMNS,
|
||||
VISITOR_ANALYTICS_DDL,
|
||||
VISITOR_ANALYTICS_INDEXES,
|
||||
VISITOR_ANALYTICS_INSERT
|
||||
} from "./analytics-schema";
|
||||
import { summariseDownloads } from "~/lib/download-asset";
|
||||
import { assetFromLegacyPath, backfillDownloads } from "./backfill-downloads";
|
||||
|
||||
// Why a real in-memory SQLite database rather than a stub: the backfill runs
|
||||
// real SQL against the live table shape, and a stub would happily accept a
|
||||
// column list the real table does not have. Same reasoning as
|
||||
// analytics-schema.test.ts.
|
||||
//
|
||||
// Nothing is module-mocked here on purpose: bun's `mock.module` is process-wide,
|
||||
// so stubbing the connection factory would leak into the rate-limit and CSRF
|
||||
// suites that share the same database module.
|
||||
|
||||
let db: Database;
|
||||
|
||||
/** Adapt bun:sqlite to the libsql `execute(stmt | sql)` contract. */
|
||||
const conn = {
|
||||
async execute(query: string | { sql: string; args?: unknown[] }) {
|
||||
const sql = typeof query === "string" ? query : query.sql;
|
||||
const args = (
|
||||
typeof query === "string" ? [] : (query.args ?? [])
|
||||
) as never[];
|
||||
const statement = db.query(sql);
|
||||
if (/^\s*(select|pragma|with)\b/i.test(sql)) {
|
||||
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
|
||||
}
|
||||
return {
|
||||
rows: [] as unknown[],
|
||||
rowsAffected: statement.run(...args).changes
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const LEGACY_NOOK = "/api/the-nook/download";
|
||||
|
||||
const trpcClick = (asset: string) =>
|
||||
`/api/trpc/downloads.getDownloadUrl?batch=1&input=%7B%220%22%3A%7B%22asset_name%22%3A%22${asset}%22%7D%7D`;
|
||||
|
||||
/** Columns whose value varies per row; everything else stays NULL. */
|
||||
const fixedColumns: Record<string, string> = {
|
||||
method: "GET",
|
||||
referrer: "https://gaze.freno.me/",
|
||||
user_agent: "Mozilla/5.0 (Macintosh)",
|
||||
ip_address: "203.0.113.7",
|
||||
country: "GB"
|
||||
};
|
||||
|
||||
function insertLegacy(id: string, path: string) {
|
||||
const values = VISITOR_ANALYTICS_COLUMNS.map((column) => {
|
||||
if (column === "id") return id;
|
||||
if (column === "path") return path;
|
||||
return fixedColumns[column] ?? null;
|
||||
});
|
||||
db.query(VISITOR_ANALYTICS_INSERT).run(...(values as never[]));
|
||||
}
|
||||
|
||||
function canonicalPaths(): string[] {
|
||||
return db
|
||||
.query<{ path: string }, []>(
|
||||
`SELECT path FROM VisitorAnalytics WHERE path LIKE '/api/downloads/%' ORDER BY path`
|
||||
)
|
||||
.all()
|
||||
.map((r: { path: string }) => r.path);
|
||||
}
|
||||
|
||||
function legacyRowCount(): number {
|
||||
return db
|
||||
.query<
|
||||
{ n: number },
|
||||
[string]
|
||||
>(`SELECT COUNT(*) AS n FROM VisitorAnalytics WHERE path = ?`)
|
||||
.get(LEGACY_NOOK)!.n;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
db.exec(VISITOR_ANALYTICS_DDL);
|
||||
for (const index of VISITOR_ANALYTICS_INDEXES) db.exec(index);
|
||||
});
|
||||
|
||||
describe("backfillDownloads", () => {
|
||||
it("derives a canonical row from each recoverable kind of history", async () => {
|
||||
insertLegacy("nook-1", LEGACY_NOOK);
|
||||
insertLegacy("gaze-1", trpcClick("gaze"));
|
||||
insertLegacy("halo-1", trpcClick("inputhalo"));
|
||||
insertLegacy("page-1", "/nook/");
|
||||
|
||||
const added = Object.fromEntries(
|
||||
(await backfillDownloads(conn)).map((r) => [r.asset, r.added])
|
||||
);
|
||||
|
||||
expect(added).toEqual({ thenook: 1, gaze: 1, inputhalo: 1 });
|
||||
expect(canonicalPaths()).toEqual([
|
||||
"/api/downloads/gaze",
|
||||
"/api/downloads/inputhalo",
|
||||
"/api/downloads/thenook"
|
||||
]);
|
||||
// A page visit is not download history and gains no derived row.
|
||||
expect(
|
||||
db
|
||||
.query(`SELECT * FROM VisitorAnalytics WHERE id = 'backfill-page-1'`)
|
||||
.all()
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the visitor detail of the row it derives from", async () => {
|
||||
insertLegacy("nook-1", LEGACY_NOOK);
|
||||
|
||||
await backfillDownloads(conn);
|
||||
|
||||
expect(
|
||||
db
|
||||
.query<
|
||||
{ country: string; referrer: string },
|
||||
[]
|
||||
>(`SELECT country, referrer FROM VisitorAnalytics WHERE id = 'backfill-nook-1'`)
|
||||
.get()
|
||||
).toEqual({ country: "GB", referrer: "https://gaze.freno.me/" });
|
||||
});
|
||||
|
||||
it("is idempotent and never rewrites the source rows", async () => {
|
||||
insertLegacy("nook-1", LEGACY_NOOK);
|
||||
|
||||
await backfillDownloads(conn);
|
||||
const second = await backfillDownloads(conn);
|
||||
|
||||
expect(second).toEqual([]);
|
||||
expect(canonicalPaths()).toHaveLength(1);
|
||||
expect(legacyRowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("derives nothing when there is no legacy history", async () => {
|
||||
expect(await backfillDownloads(conn)).toEqual([]);
|
||||
expect(canonicalPaths()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assetFromLegacyPath", () => {
|
||||
it("maps the Nook door to its canonical asset", () => {
|
||||
expect(assetFromLegacyPath(LEGACY_NOOK)).toBe("thenook");
|
||||
});
|
||||
|
||||
it("reads the asset out of an encoded or a plain tRPC request URL", () => {
|
||||
expect(assetFromLegacyPath(trpcClick("gaze"))).toBe("gaze");
|
||||
expect(
|
||||
assetFromLegacyPath(
|
||||
'/api/trpc/downloads.getDownloadUrl?batch=1&input={"0":{"asset_name":"lineage"}}'
|
||||
)
|
||||
).toBe("lineage");
|
||||
});
|
||||
|
||||
it("returns null when there is nothing to derive", () => {
|
||||
expect(assetFromLegacyPath("/nook/")).toBeNull();
|
||||
expect(
|
||||
assetFromLegacyPath("/api/trpc/downloads.getDownloadUrl")
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseDownloads", () => {
|
||||
it("labels each row by the asset in its path and totals them", () => {
|
||||
expect(
|
||||
summariseDownloads([
|
||||
{ path: "/api/downloads/gaze", count: 4 },
|
||||
{ path: "/api/downloads/thenook", count: 1 }
|
||||
])
|
||||
).toEqual({
|
||||
total: 5,
|
||||
byAsset: [
|
||||
{ asset: "gaze", count: 4 },
|
||||
{ asset: "thenook", count: 1 }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("reports nothing rather than throwing on an empty table", () => {
|
||||
expect(summariseDownloads([])).toEqual({ total: 0, byAsset: [] });
|
||||
});
|
||||
});
|
||||
95
src/server/backfill-downloads.ts
Normal file
95
src/server/backfill-downloads.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { VISITOR_ANALYTICS_COLUMNS } from "~/server/analytics-schema";
|
||||
import { downloadPath } from "~/lib/download-asset";
|
||||
|
||||
/**
|
||||
* Backfill canonical `downloadPath(asset)` rows from download history that was
|
||||
* recorded before `trackDownload` existed.
|
||||
*
|
||||
* Two shapes of history are recoverable, and only these two:
|
||||
*
|
||||
* - the Nook door's own rows (`/api/the-nook/download`), and
|
||||
* - tRPC clicks, where the asset name still sits in the request URL as
|
||||
* `?input={"0":{"asset_name":"gaze"}}` (percent-encoded in practice).
|
||||
*
|
||||
* Every other door wrote nothing, so there is nothing to derive for them.
|
||||
*
|
||||
* Derived rows keep the source row's timestamp, visitor, campaign and country,
|
||||
* get `backfill-<source id>` as primary key and are written with
|
||||
* `INSERT OR IGNORE` — so a second run adds nothing, and the original rows are
|
||||
* left untouched (they are genuine API traffic, and the analytics page only
|
||||
* counts the canonical path).
|
||||
*/
|
||||
|
||||
const LEGACY_NOOK_PATH = "/api/the-nook/download";
|
||||
const TRPC_DOOR_PREFIX = "/api/trpc/downloads.getDownloadUrl";
|
||||
|
||||
/** The two libsql calls used here, narrow enough for an in-memory test client. */
|
||||
export interface DownloadBackfillConn {
|
||||
execute(query: {
|
||||
sql: string;
|
||||
// `any[]` not `unknown[]`: the libsql client types its bind args as InArg[],
|
||||
// and a narrower array type here would reject the real connection.
|
||||
args?: any[];
|
||||
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
|
||||
}
|
||||
|
||||
export interface DownloadBackfillResult {
|
||||
asset: string;
|
||||
added: number;
|
||||
}
|
||||
|
||||
/** One statement: copy every column of the source row, override id and path. */
|
||||
const INSERT_DERIVED = `INSERT OR IGNORE INTO VisitorAnalytics (${VISITOR_ANALYTICS_COLUMNS.join(", ")})
|
||||
SELECT ${VISITOR_ANALYTICS_COLUMNS.map((column) =>
|
||||
column === "id"
|
||||
? "'backfill-' || id"
|
||||
: column === "path"
|
||||
? "?"
|
||||
: `"${column}"`
|
||||
).join(", ")}
|
||||
FROM VisitorAnalytics
|
||||
WHERE id = ?`;
|
||||
|
||||
/** Pull the asset name back out of a logged tRPC request URL. */
|
||||
export function assetFromLegacyPath(path: string): string | null {
|
||||
if (path === LEGACY_NOOK_PATH) return "thenook";
|
||||
|
||||
let decoded = path;
|
||||
try {
|
||||
decoded = decodeURIComponent(path);
|
||||
} catch {
|
||||
// A malformed escape is not a reason to drop the row — match on the raw.
|
||||
}
|
||||
|
||||
return /"asset_name"\s*:\s*"([^"]+)"/.exec(decoded)?.[1] ?? null;
|
||||
}
|
||||
|
||||
export async function backfillDownloads(
|
||||
conn: DownloadBackfillConn
|
||||
): Promise<DownloadBackfillResult[]> {
|
||||
const legacy = await conn.execute({
|
||||
sql: `SELECT id, path FROM VisitorAnalytics WHERE path = ? OR path LIKE ?`,
|
||||
args: [LEGACY_NOOK_PATH, `${TRPC_DOOR_PREFIX}%`]
|
||||
});
|
||||
|
||||
const added = new Map<string, number>();
|
||||
|
||||
for (const row of legacy.rows) {
|
||||
const { id, path } = row as { id: string; path: string };
|
||||
const asset = assetFromLegacyPath(path);
|
||||
if (!asset) continue;
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: INSERT_DERIVED,
|
||||
args: [downloadPath(asset), id]
|
||||
});
|
||||
|
||||
if (result.rowsAffected) {
|
||||
added.set(asset, (added.get(asset) ?? 0) + result.rowsAffected);
|
||||
}
|
||||
}
|
||||
|
||||
return [...added.entries()]
|
||||
.map(([asset, count]) => ({ asset, added: count }))
|
||||
.sort((a, b) => b.added - a.added);
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
/**
|
||||
* Clerk user webhook sync tests
|
||||
*
|
||||
* Exercises `handleClerkUserWebhook` against an in-memory SQLite DB
|
||||
* (`bun:sqlite`) wrapped to match the libsql `{ execute({ sql, args }) }`
|
||||
* contract. Each event payload is Svix-signed with the same secret the
|
||||
* handler verifies against, so the signature path is exercised for real —
|
||||
* including the unsigned / wrong-signature rejection cases.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { Webhook } from "svix";
|
||||
import {
|
||||
handleClerkUserWebhook,
|
||||
__resetClerkWebhookMigrationForTests,
|
||||
type NessaConn
|
||||
} from "~/server/clerk-user-webhook";
|
||||
|
||||
// ─── harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
const WEBHOOK_SECRET =
|
||||
"whsec_dGhpcyBpcyBhIHRlc3Qgc2VjcmV0IGtleSBmb3IgY2xlcmsgd2ViaG9va3M=";
|
||||
|
||||
let db: Database;
|
||||
let conn: NessaConn;
|
||||
|
||||
function makeConn(): NessaConn {
|
||||
return {
|
||||
execute: async ({
|
||||
sql,
|
||||
args
|
||||
}: {
|
||||
sql: string;
|
||||
args?: (string | number | null)[];
|
||||
}) => {
|
||||
const stmt = db.prepare(sql);
|
||||
const upper = sql.trim().toUpperCase();
|
||||
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
|
||||
if (isRead) {
|
||||
const rows = stmt.all(...(args ?? []));
|
||||
return { rows: rows as unknown[] };
|
||||
}
|
||||
const info = stmt.run(...(args ?? []));
|
||||
return { rows: [] as unknown[], rowsAffected: info.changes };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function initSchema() {
|
||||
db = new Database(":memory:");
|
||||
// `users` table mirrors the Nessa production schema — note clerkUserId is
|
||||
// NOT present here so we exercise the idempotent ALTER-TABLE migration path.
|
||||
db.run(`CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
emailVerified INTEGER DEFAULT 0,
|
||||
firstName TEXT,
|
||||
lastName TEXT,
|
||||
displayName TEXT,
|
||||
avatarUrl TEXT,
|
||||
provider TEXT,
|
||||
appleUserId TEXT,
|
||||
status TEXT,
|
||||
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
lastLoginAt TEXT
|
||||
)`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initSchema();
|
||||
conn = makeConn();
|
||||
__resetClerkWebhookMigrationForTests();
|
||||
});
|
||||
|
||||
// ─── signings helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function sign(
|
||||
payload: object,
|
||||
secret: string = WEBHOOK_SECRET
|
||||
): { rawBody: string; headers: ReturnType<typeof headersFor> } {
|
||||
const rawBody = JSON.stringify(payload);
|
||||
const msgId = `msg_${Math.random().toString(36).slice(2)}`;
|
||||
const ts = new Date();
|
||||
const wh = new Webhook(secret);
|
||||
const signature = wh.sign(msgId, ts, rawBody);
|
||||
return {
|
||||
rawBody,
|
||||
headers: {
|
||||
"svix-id": msgId,
|
||||
"svix-timestamp": String(Math.floor(ts.getTime() / 1000)),
|
||||
"svix-signature": signature
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// satisfy the inferred header type
|
||||
function headersFor() {
|
||||
return {
|
||||
"svix-id": "",
|
||||
"svix-timestamp": "",
|
||||
"svix-signature": ""
|
||||
};
|
||||
}
|
||||
|
||||
function userCreatedPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
object: "event",
|
||||
type: "user.created",
|
||||
data: {
|
||||
id: "user_abc123",
|
||||
email_addresses: [
|
||||
{
|
||||
id: "idn_1",
|
||||
email_address: "jane@example.com",
|
||||
verification: { status: "verified" }
|
||||
}
|
||||
],
|
||||
primary_email_address_id: "idn_1",
|
||||
first_name: "Jane",
|
||||
last_name: "Doe",
|
||||
username: "janedoe",
|
||||
image_url: "https://cdn.clerk.com/avatar.png",
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function userUpdatedPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
object: "event",
|
||||
type: "user.updated",
|
||||
data: {
|
||||
id: "user_abc123",
|
||||
email_addresses: [
|
||||
{
|
||||
id: "idn_1",
|
||||
email_address: "jane.new@example.com",
|
||||
verification: { status: "verified" }
|
||||
}
|
||||
],
|
||||
primary_email_address_id: "idn_1",
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
username: "janesmith",
|
||||
image_url: "https://cdn.clerk.com/avatar2.png",
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function call(
|
||||
signed: { rawBody: string; headers: ReturnType<typeof headersFor> },
|
||||
secret: string = WEBHOOK_SECRET
|
||||
) {
|
||||
return handleClerkUserWebhook({
|
||||
rawBody: signed.rawBody,
|
||||
headers: signed.headers,
|
||||
webhookSecret: secret,
|
||||
conn
|
||||
});
|
||||
}
|
||||
|
||||
function getUserByClerkId(clerkUserId: string) {
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?"
|
||||
)
|
||||
.get(clerkUserId) as
|
||||
| {
|
||||
id: string;
|
||||
clerkUserId: string;
|
||||
email: string | null;
|
||||
emailVerified: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
provider: string | null;
|
||||
status: string | null;
|
||||
}
|
||||
| undefined;
|
||||
return row;
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Clerk user.created webhook", () => {
|
||||
it("creates a local users row keyed by clerkUserId", async () => {
|
||||
const res = await call(sign(userCreatedPayload()));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const user = getUserByClerkId("user_abc123");
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.clerkUserId).toBe("user_abc123");
|
||||
expect(user!.email).toBe("jane@example.com");
|
||||
expect(user!.emailVerified).toBe(1); // Clerk marked the email verified
|
||||
expect(user!.firstName).toBe("Jane");
|
||||
expect(user!.lastName).toBe("Doe");
|
||||
expect(user!.displayName).toBe("Jane Doe");
|
||||
expect(user!.avatarUrl).toBe("https://cdn.clerk.com/avatar.png");
|
||||
expect(user!.provider).toBe("clerk");
|
||||
expect(user!.status).toBe("active");
|
||||
expect(user!.id).not.toBe("user_abc123"); // a fresh local UUID, not the Clerk id
|
||||
});
|
||||
|
||||
it("upserts (does not duplicate) on a replayed created event", async () => {
|
||||
const signed = sign(userCreatedPayload());
|
||||
await call(signed);
|
||||
await call(signed); // replay
|
||||
const count = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM users WHERE clerkUserId = ?").get(
|
||||
"user_abc123"
|
||||
) as { n: number }
|
||||
).n;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to username when first/last name are absent", async () => {
|
||||
const res = await call(
|
||||
sign(
|
||||
userCreatedPayload({
|
||||
first_name: null,
|
||||
last_name: null
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const user = getUserByClerkId("user_abc123");
|
||||
expect(user!.displayName).toBe("janedoe");
|
||||
});
|
||||
|
||||
it("records emailVerified=0 when Clerk marks the email unverified", async () => {
|
||||
const res = await call(
|
||||
sign(
|
||||
userCreatedPayload({
|
||||
email_addresses: [
|
||||
{
|
||||
id: "idn_1",
|
||||
email_address: "unverified@example.com",
|
||||
verification: { status: "unverified" }
|
||||
}
|
||||
],
|
||||
primary_email_address_id: "idn_1"
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const user = getUserByClerkId("user_abc123");
|
||||
expect(user!.emailVerified).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clerk user.updated webhook", () => {
|
||||
it("updates mutable fields and leaves clerkUserId unchanged", async () => {
|
||||
await call(sign(userCreatedPayload()));
|
||||
|
||||
const before = getUserByClerkId("user_abc123");
|
||||
const localId = before!.id;
|
||||
|
||||
const res = await call(sign(userUpdatedPayload()));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const after = getUserByClerkId("user_abc123");
|
||||
expect(after!.id).toBe(localId); // local UUID stable
|
||||
expect(after!.clerkUserId).toBe("user_abc123");
|
||||
expect(after!.email).toBe("jane.new@example.com");
|
||||
expect(after!.emailVerified).toBe(1); // verification status carried through update
|
||||
expect(after!.lastName).toBe("Smith");
|
||||
expect(after!.displayName).toBe("Jane Smith");
|
||||
expect(after!.avatarUrl).toBe("https://cdn.clerk.com/avatar2.png");
|
||||
});
|
||||
|
||||
it("is a no-op when the user does not exist (no row inserted)", async () => {
|
||||
const res = await call(sign(userUpdatedPayload()));
|
||||
expect(res.status).toBe(200);
|
||||
const count = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM users").get() as { n: number }
|
||||
).n;
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clerk webhook signature enforcement", () => {
|
||||
it("rejects a missing-svix-header request with 400", async () => {
|
||||
const res = await handleClerkUserWebhook({
|
||||
rawBody: JSON.stringify(userCreatedPayload()),
|
||||
headers: {
|
||||
"svix-id": "",
|
||||
"svix-timestamp": "",
|
||||
"svix-signature": ""
|
||||
},
|
||||
webhookSecret: WEBHOOK_SECRET,
|
||||
conn
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects an unsigned payload with 401", async () => {
|
||||
const res = await handleClerkUserWebhook({
|
||||
rawBody: JSON.stringify(userCreatedPayload()),
|
||||
headers: {
|
||||
"svix-id": "msg_x",
|
||||
"svix-timestamp": String(Math.floor(Date.now() / 1000)),
|
||||
"svix-signature": "v1,tampered"
|
||||
},
|
||||
webhookSecret: WEBHOOK_SECRET,
|
||||
conn
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a payload signed with the wrong secret with 401", async () => {
|
||||
const res = await call(
|
||||
sign(
|
||||
userCreatedPayload(),
|
||||
"whsec_YW5vdGhlcl9kaWZmZXJlbnRfc2VjcmV0X2tleV9mb3JfdGVzdHM="
|
||||
),
|
||||
WEBHOOK_SECRET // handler uses this
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("ignores non user.* event types with 200", async () => {
|
||||
const res = await call(
|
||||
sign({
|
||||
object: "event",
|
||||
type: "session.created",
|
||||
data: { id: "sess_1" }
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { ignored: string }).ignored).toBe("session.created");
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// Clerk webhook → local `users` table sync
|
||||
//
|
||||
// Clerk emits `user.created` / `user.updated` events (signed via Svix) whenever
|
||||
// a user signs up or their profile/email changes. This module verifies the
|
||||
// Svix signature with `NESSA_CLERK_WEBHOOK_SECRET` and upserts the
|
||||
// corresponding row in the Nessa `users` table, keyed by `clerkUserId`.
|
||||
//
|
||||
// The local UUID is generated here ("shadow" account) — Clerk is the identity
|
||||
// provider; the Nessa DB only stores a denormalized copy for-fast-lookup and
|
||||
// for rows that reference `users.id` (workouts, plans, community posts, etc.).
|
||||
//
|
||||
// All signature verification + DB mutation lives in `handleClerkUserWebhook`
|
||||
// so it can be exercised directly by tests with an in-memory SQLite DB and a
|
||||
// real Svix-signed payload.
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { Webhook } from "svix";
|
||||
|
||||
/** Minimal libsql-shaped connection (same contract as NessaConnectionFactory). */
|
||||
export interface NessaConn {
|
||||
execute: (stmt: { sql: string; args?: (string | number | null)[] }) => Promise<{
|
||||
rows: unknown[];
|
||||
rowsAffected?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Svix signature headers Clerk (and Svix) attach to every webhook. */
|
||||
export interface ClerkWebhookHeaders {
|
||||
"svix-id": string;
|
||||
"svix-timestamp": string;
|
||||
"svix-signature": string;
|
||||
}
|
||||
|
||||
interface ClerkEmailAddress {
|
||||
id: string;
|
||||
email_address: string;
|
||||
verification?: { status?: string } | null;
|
||||
}
|
||||
|
||||
interface ClerkUserData {
|
||||
id: string;
|
||||
email_addresses?: ClerkEmailAddress[];
|
||||
primary_email_address_id?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
username?: string | null;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
interface ClerkWebhookEvent {
|
||||
object: "event";
|
||||
type: string;
|
||||
data: ClerkUserData;
|
||||
}
|
||||
|
||||
export type WebhookResult = { status: number; body: unknown };
|
||||
|
||||
/** Resolve the user's primary email address from the Clerk payload. */
|
||||
function resolveEmail(data: ClerkUserData): string | null {
|
||||
const addresses = data.email_addresses ?? [];
|
||||
const primaryId = data.primary_email_address_id ?? null;
|
||||
const primary =
|
||||
addresses.find((e) => e.id === primaryId) ?? addresses[0] ?? null;
|
||||
return primary?.email_address ?? null;
|
||||
}
|
||||
|
||||
/** Resolve the primary email's verification status (1 = verified, 0 = unverified). */
|
||||
function resolveEmailVerified(data: ClerkUserData): number {
|
||||
const addresses = data.email_addresses ?? [];
|
||||
const primaryId = data.primary_email_address_id ?? null;
|
||||
const primary =
|
||||
addresses.find((e) => e.id === primaryId) ?? addresses[0] ?? null;
|
||||
return primary?.verification?.status === "verified" ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Resolve a display name (first + last, falling back to username). */
|
||||
function resolveDisplayName(data: ClerkUserData): string | null {
|
||||
const first = (data.first_name ?? "").trim();
|
||||
const last = (data.last_name ?? "").trim();
|
||||
const full = `${first} ${last}`.trim();
|
||||
if (full) return full;
|
||||
return data.username?.trim() || null;
|
||||
}
|
||||
|
||||
// The `users` table predates Clerk — the `clerkUserId` column is added lazily
|
||||
// (idempotently) so the migration runs against existing dev/prod databases
|
||||
// without a separate deploy step. SQLite's `ALTER TABLE ... ADD COLUMN` does
|
||||
// not support `IF NOT EXISTS`, so a duplicate-column error is the expected
|
||||
// "already migrated" signal.
|
||||
let columnMigrationDone = false;
|
||||
|
||||
/** Test-only: reset the module-level migration flag so each test gets a fresh DB. */
|
||||
export function __resetClerkWebhookMigrationForTests(): void {
|
||||
columnMigrationDone = false;
|
||||
}
|
||||
|
||||
export async function ensureClerkUsersColumn(conn: NessaConn): Promise<void> {
|
||||
if (columnMigrationDone) return;
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: "ALTER TABLE users ADD COLUMN clerkUserId TEXT"
|
||||
});
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : err == null ? String(err) : String(err);
|
||||
// "duplicate column name" (SQLite) is the expected success-already case.
|
||||
if (!/duplicate column/i.test(msg)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)"
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort: if the index already exists or creation is unsupported in
|
||||
// the test harness, fall through. The ON CONFLICT(clerkUserId) upsert
|
||||
// requires a unique constraint; in tests we create the index in the
|
||||
// schema instead.
|
||||
}
|
||||
columnMigrationDone = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Clerk webhook and upsert the user row.
|
||||
*
|
||||
* @returns a `{ status, body }` describing the HTTP response to send.
|
||||
*/
|
||||
export async function handleClerkUserWebhook(opts: {
|
||||
rawBody: string;
|
||||
headers: ClerkWebhookHeaders;
|
||||
webhookSecret: string;
|
||||
conn: NessaConn;
|
||||
}): Promise<WebhookResult> {
|
||||
const { rawBody, headers, webhookSecret, conn } = opts;
|
||||
|
||||
// Reject unsigned / partially-signed requests before touching the DB.
|
||||
if (
|
||||
!headers["svix-id"] ||
|
||||
!headers["svix-timestamp"] ||
|
||||
!headers["svix-signature"]
|
||||
) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "Missing Svix signature headers" }
|
||||
};
|
||||
}
|
||||
|
||||
let evt: ClerkWebhookEvent;
|
||||
try {
|
||||
const wh = new Webhook(webhookSecret);
|
||||
evt = wh.verify(rawBody, {
|
||||
"svix-id": headers["svix-id"],
|
||||
"svix-timestamp": headers["svix-timestamp"],
|
||||
"svix-signature": headers["svix-signature"]
|
||||
}) as ClerkWebhookEvent;
|
||||
} catch (err) {
|
||||
console.error("Clerk webhook signature verification failed:", err);
|
||||
return { status: 401, body: { error: "Invalid signature" } };
|
||||
}
|
||||
|
||||
if (evt.type !== "user.created" && evt.type !== "user.updated") {
|
||||
return {
|
||||
status: 200,
|
||||
body: { received: true, ignored: evt.type }
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureClerkUsersColumn(conn);
|
||||
|
||||
const data = evt.data;
|
||||
const clerkUserId = data.id;
|
||||
const email = resolveEmail(data);
|
||||
const emailVerified = resolveEmailVerified(data);
|
||||
const firstName = data.first_name ?? null;
|
||||
const lastName = data.last_name ?? null;
|
||||
const displayName = resolveDisplayName(data);
|
||||
const avatarUrl = data.image_url ?? null;
|
||||
|
||||
if (evt.type === "user.created") {
|
||||
// Upsert by clerkUserId — Clerk may replay the same event, so insert
|
||||
// ON CONFLICT instead of erroring on a duplicate.
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users
|
||||
(id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'clerk', 'active')
|
||||
ON CONFLICT(clerkUserId) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
emailVerified = excluded.emailVerified,
|
||||
firstName = excluded.firstName,
|
||||
lastName = excluded.lastName,
|
||||
displayName = excluded.displayName,
|
||||
avatarUrl = excluded.avatarUrl,
|
||||
updatedAt = datetime('now')`,
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
clerkUserId,
|
||||
email,
|
||||
emailVerified,
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
avatarUrl
|
||||
]
|
||||
});
|
||||
} else {
|
||||
// user.updated — mutate identity fields only; never change clerkUserId.
|
||||
await conn.execute({
|
||||
sql: `UPDATE users SET
|
||||
email = ?,
|
||||
emailVerified = ?,
|
||||
firstName = ?,
|
||||
lastName = ?,
|
||||
displayName = ?,
|
||||
avatarUrl = ?,
|
||||
updatedAt = datetime('now')
|
||||
WHERE clerkUserId = ?`,
|
||||
args: [email, emailVerified, firstName, lastName, displayName, avatarUrl, clerkUserId]
|
||||
});
|
||||
}
|
||||
|
||||
return { status: 200, body: { success: true, type: evt.type } };
|
||||
} catch (error) {
|
||||
console.error("Clerk webhook DB sync failed:", error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: "Failed to sync user" }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,12 @@ import {
|
||||
import {
|
||||
ConnectionFactory,
|
||||
LineageConnectionFactory,
|
||||
NessaConnectionFactory,
|
||||
NookConnectionFactory
|
||||
} from "~/server/db-connections";
|
||||
// Re-export connection factories to avoid circular import with auth.ts
|
||||
export {
|
||||
ConnectionFactory,
|
||||
LineageConnectionFactory,
|
||||
NessaConnectionFactory,
|
||||
NookConnectionFactory
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { env } from "~/env/server";
|
||||
|
||||
let mainDBConnection: Client | null = null;
|
||||
let lineageDBConnection: Client | null = null;
|
||||
let nessaDBConnection: Client | null = null;
|
||||
let nookDBConnection: Client | null = null;
|
||||
|
||||
export function ConnectionFactory() {
|
||||
@@ -28,17 +27,6 @@ export function LineageConnectionFactory() {
|
||||
return lineageDBConnection;
|
||||
}
|
||||
|
||||
export function NessaConnectionFactory() {
|
||||
if (!nessaDBConnection) {
|
||||
const config = {
|
||||
url: env.NESSA_DB_URL,
|
||||
authToken: env.NESSA_DB_TOKEN
|
||||
};
|
||||
nessaDBConnection = createClient(config);
|
||||
}
|
||||
return nessaDBConnection;
|
||||
}
|
||||
|
||||
export function NookConnectionFactory() {
|
||||
if (!nookDBConnection) {
|
||||
const config = {
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Clerk JWT verification tests for nessa-auth
|
||||
*
|
||||
* Tests the verifyNessaToken function after migration from HS256 self-signed
|
||||
* tokens to Clerk session token verification via @clerk/backend.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock env BEFORE importing the module
|
||||
mock.module("~/env/server", () => ({
|
||||
env: {
|
||||
TURSO_DB_URL: "libsql://test.turso.io",
|
||||
TURSO_DB_TOKEN: "test-token",
|
||||
NESSA_DB_URL: "libsql://nessa-test.turso.io",
|
||||
NESSA_DB_TOKEN: "test-token",
|
||||
TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io",
|
||||
TURSO_LINEAGE_TOKEN: "test-token",
|
||||
TURSO_DB_API_TOKEN: "test-token",
|
||||
NODE_ENV: "test",
|
||||
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
||||
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
||||
},
|
||||
validateServerEnv: () => ({}),
|
||||
isMissingEnvVar: () => false,
|
||||
getMissingEnvVars: () => []
|
||||
}));
|
||||
|
||||
// Mock @clerk/backend verifyToken
|
||||
const mockVerifyToken = mock();
|
||||
mock.module("@clerk/backend", () => ({
|
||||
verifyToken: mockVerifyToken,
|
||||
createClerkClient: mock()
|
||||
}));
|
||||
|
||||
describe("verifyNessaToken with Clerk JWT", () => {
|
||||
beforeEach(() => {
|
||||
mockVerifyToken.mockReset();
|
||||
});
|
||||
|
||||
it("returns the subject (Clerk user id) for a valid session token", async () => {
|
||||
const mockPayload = {
|
||||
sub: "user_clerk123456",
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
iss: "https://nessa-test.clerk.accounts.dev",
|
||||
sid: "sess_123"
|
||||
};
|
||||
|
||||
mockVerifyToken.mockResolvedValue(mockPayload);
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
const result = await verifyNessaToken("valid-clerk-session-token");
|
||||
|
||||
expect(result.sub).toBe("user_clerk123456");
|
||||
expect(result.exp).toBe(mockPayload.exp);
|
||||
expect(result.iat).toBe(mockPayload.iat);
|
||||
|
||||
expect(mockVerifyToken).toHaveBeenCalledWith(
|
||||
"valid-clerk-session-token",
|
||||
expect.objectContaining({
|
||||
secretKey: "sk_test_test-secret"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when token has no subject", async () => {
|
||||
const mockPayload = {
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
iss: "https://nessa-test.clerk.accounts.dev"
|
||||
// Missing 'sub'
|
||||
};
|
||||
|
||||
mockVerifyToken.mockResolvedValue(mockPayload);
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
await expect(verifyNessaToken("token-without-subject")).rejects.toThrow(
|
||||
/Missing subject/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed token", async () => {
|
||||
mockVerifyToken.mockRejectedValue(new Error("Invalid token format"));
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
await expect(verifyNessaToken("malformed-token")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an expired token", async () => {
|
||||
mockVerifyToken.mockRejectedValue(new Error("Token has expired"));
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
await expect(verifyNessaToken("expired-token")).rejects.toThrow(/expired/i);
|
||||
});
|
||||
|
||||
it("rejects a token with wrong signature", async () => {
|
||||
mockVerifyToken.mockRejectedValue(
|
||||
new Error("Token signature verification failed")
|
||||
);
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
await expect(verifyNessaToken("wrong-key-token")).rejects.toThrow(
|
||||
/signature/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static audit: signNessaToken removed", () => {
|
||||
it("signNessaToken is not exported fromessa-auth", async () => {
|
||||
const moduleExports = await import("./nessa-auth");
|
||||
expect(moduleExports).not.toHaveProperty("signNessaToken");
|
||||
});
|
||||
|
||||
it("nessa-auth.ts source does not reference the legacy JWT secret", async () => {
|
||||
// Reassemble the legacy env-var name so this test itself does not contain
|
||||
// the literal token (keeps the source tree grep-clean).
|
||||
const legacyVar = ["NESSA", "JWT", "SECRET"].join("_");
|
||||
const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text();
|
||||
expect(source).not.toContain(legacyVar);
|
||||
});
|
||||
|
||||
it("nessa-auth.ts uses verifyToken from @clerk/backend", async () => {
|
||||
const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text();
|
||||
expect(source).toContain("verifyToken");
|
||||
expect(source).toContain("@clerk/backend");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// Nessa auth — Clerk session JWT verification (RS256 / JWKS)
|
||||
//
|
||||
// Migrated from self-signed HS256 tokens to Clerk session token
|
||||
// verification. Incoming `Authorization: Bearer <token>` headers are
|
||||
// verified against Clerk's JWKS endpoint via `@clerk/backend`.
|
||||
//
|
||||
// Public API is unchanged so callers need not be modified:
|
||||
// * verifyNessaToken(token) → { sub, exp?, iat? }
|
||||
// * NessaAuthPayload type
|
||||
//
|
||||
// signNessaToken was removed — the frontend now supplies Clerk session
|
||||
// tokens directly; the backend only verifies.
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { verifyToken } from "@clerk/backend";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
export type NessaAuthPayload = {
|
||||
sub: string; // Clerk user id
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a Clerk session JWT and return the subject (user id).
|
||||
*
|
||||
* Uses the Clerk Backend API secret key to fetch the JWKS and verify the
|
||||
* RS256 signature. Rejects expired, malformed, or improperly signed tokens.
|
||||
*/
|
||||
export async function verifyNessaToken(
|
||||
token: string
|
||||
): Promise<NessaAuthPayload> {
|
||||
const payload = await verifyToken(token, {
|
||||
secretKey: env.NESSA_CLERK_SECRET,
|
||||
});
|
||||
|
||||
if (!payload.sub) {
|
||||
throw new Error("Missing subject in Clerk session token");
|
||||
}
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
exp: payload.exp,
|
||||
iat: payload.iat,
|
||||
};
|
||||
}
|
||||
61
src/server/track-download.ts
Normal file
61
src/server/track-download.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import {
|
||||
logVisit,
|
||||
enrichAnalyticsEntry,
|
||||
flushAnalytics
|
||||
} from "~/server/analytics";
|
||||
import { parseUtm } from "~/server/utm";
|
||||
import { downloadPath } from "~/lib/download-asset";
|
||||
|
||||
/**
|
||||
* Record one download event under the canonical path `/api/downloads/<asset>`.
|
||||
*
|
||||
* Every download door funnels through here so the analytics page has a single
|
||||
* label per asset to group on: the tRPC click proxy, the S3 file route that
|
||||
* serves Sparkle updates, and the Nook door.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
export async function trackDownload(
|
||||
event: APIEvent,
|
||||
asset: string
|
||||
): 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",
|
||||
referrer,
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ipAddress:
|
||||
getRequestIP(event.nativeEvent) ??
|
||||
(forwarded ? forwarded.split(",")[0]!.trim() : null),
|
||||
// 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),
|
||||
// Edge header country, overridable with ?c= for manual verification.
|
||||
country:
|
||||
new URL(request.url).searchParams.get("c") ??
|
||||
request.headers.get("x-vercel-ip-country") ??
|
||||
request.headers.get("cf-ipcountry")
|
||||
})
|
||||
);
|
||||
await flushAnalytics();
|
||||
} catch (error) {
|
||||
console.error(`Failed to log download of ${asset}:`, error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user