Compare commits
50 Commits
b41cf83879
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
| df788cb689 | |||
| f371cf6192 | |||
| a8ce226b77 | |||
| 71bb9ec5df | |||
| 4e412ede8e | |||
| 8790cc155e | |||
| 97755042cb | |||
| a0e6ff1044 | |||
| 6fd0f84806 | |||
| c7071b0e32 | |||
| 7fde259e0b | |||
| 25ec84ceb4 | |||
| dd368d21e7 | |||
| 2fd2a4a71a | |||
|
|
1d9287a199 | ||
|
|
39d874a7e2 | ||
|
|
89f56fc78a | ||
|
|
d44133fcb3 | ||
|
|
578ee58f9c | ||
|
|
d2c2e6a4ee | ||
|
|
c718370674 | ||
|
|
210a7e6e75 | ||
|
|
c1c498e37f | ||
| 134f41eac4 | |||
| 881190d1d4 | |||
| f492132bf2 | |||
| a524f23be2 | |||
| 0bf15e528d | |||
| a48326fd1d | |||
| ac63057ef7 | |||
| f6d6d442de | |||
| 14a61fde9b | |||
| b4e68765fe | |||
| dabfeada3b | |||
| 9508aee8e1 | |||
| 1a182e53fe | |||
| 6898186df5 | |||
| 9ff095a82c | |||
| be90b3d068 | |||
| cbe56390f8 | |||
| 26c37c1862 | |||
| 7108f77bf9 | |||
| 687df016a6 | |||
| 92af52dfc9 | |||
| 53f1413966 | |||
| 6f41436591 | |||
| 0b128f6639 | |||
| 37a39b59fe | |||
| ba6b478320 | |||
| 35986239c1 |
@@ -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>"
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration.
|
||||
|
||||
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy` → `/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.)
|
||||
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy` → `/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.) **Every subdomain surface requires both files:** the `src/routes/<prefix>/<name>.tsx` content component AND a matching top-level dispatch file (`src/routes/<name>.tsx` or `src/routes/<name>/index.tsx`) that imports and dispatches to it — without the dispatch file, the page is unreachable on the subdomain. The `src/routes/subdomain-dispatch.test.ts` suite enforces this automatically on every `bun test` run.
|
||||
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
|
||||
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
|
||||
- **Auth:** Host-scoped only — no cookie domain broadening.
|
||||
|
||||
119
docs/deploy-source.md
Normal file
119
docs/deploy-source.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# The deploy line (FRE-1695)
|
||||
|
||||
Written 2026-11-09 by the CTO. Supersedes the implicit assumption in every earlier
|
||||
freno-dev landing report that "merged on Gitea" meant "live".
|
||||
|
||||
## The defect
|
||||
|
||||
Production `freno.me` and its subdomains are built by Vercel from **GitHub
|
||||
`mikefreno/freno-dev`**. Agents merge into **Gitea `Mike/freno-dev`**. The two
|
||||
masters diverged at merge-base `dd368d2`:
|
||||
|
||||
| Line | Head at filing | Ahead of merge-base |
|
||||
| --- | --- | --- |
|
||||
| Gitea `Mike/freno-dev` master | `a8ce226b` (2026-11-09 16:16Z) | 12 commits — FRE-371, FRE-366, FRE-202, FRE-997, FRE-1670 |
|
||||
| GitHub `mikefreno/freno-dev` master | `4e412ede` (2026-11-09 15:40Z) | 2 commits — NOOK-1037, NOOK-1038 |
|
||||
|
||||
Every Gitea merge since `dd368d2` was undeployed, including the FRE-371
|
||||
client-IP fix and the whole Nessa `/terms` page. Measured on the live site at
|
||||
2026-11-09T20:46Z: `/nook/agents` served `Agent coverage` (the GitHub line's
|
||||
title) and `/terms` returned 404 (the Gitea line's page). No agent run can push
|
||||
to GitHub — `/paperclip/.git-credentials` holds `git.freno.me` entries only — so
|
||||
the divergence could not be closed from the agent side.
|
||||
|
||||
## Decision
|
||||
|
||||
**Gitea `Mike/freno-dev` is the canonical repository. GitHub
|
||||
`mikefreno/freno-dev` is a mirror, and it is the tree Vercel builds.**
|
||||
|
||||
One line, one direction: Gitea master → GitHub master → Vercel → live.
|
||||
|
||||
### Why this shape and not the alternatives
|
||||
|
||||
- **Vercel cannot build from Gitea.** Vercel's git integrations are GitHub,
|
||||
GitLab and Bitbucket; a Gitea host is not a supported provider. So "point
|
||||
Vercel at the repo agents push to" is not a setting that exists. Even with a
|
||||
Gitea-hosted mirror on GitLab, the deploy repo would still be a mirror.
|
||||
- **Agents can only push to Gitea.** The per-agent credential is Gitea-only
|
||||
(`git.freno.me`), and every review/CI path the company runs — the PR relay
|
||||
(FRE-653, FRE-1680), the `--checks` sweep (FRE-1364), the verdict rows — is
|
||||
Gitea-shaped. Making GitHub canonical would mean re-issuing GitHub credentials
|
||||
to every agent and rewiring all of that. Keeping Gitea canonical keeps the
|
||||
authoring side exactly as it is.
|
||||
- **Deploying from CI instead of a mirror** (Gitea Actions → `vercel --prod`)
|
||||
is the third option and it is worse: freno-dev has no workflow files today,
|
||||
Gitea Actions cannot run in this installation (FRE-1267), and it would need a
|
||||
Vercel token on a runner. A mirror needs one credential, in one place, and no
|
||||
new CI.
|
||||
|
||||
### Reconcile first, then mirror
|
||||
|
||||
The mirror must fast-forward. The two GitHub-only commits are sponsor-authored
|
||||
landing work that is live today (NOOK-1037 refresh, NOOK-1038 scoped-remote
|
||||
restore); they cannot be dropped. So the GitHub line is merged **into** Gitea
|
||||
master first:
|
||||
|
||||
git merge gh/master # merge commit, no history rewrite
|
||||
|
||||
After that merge, GitHub master is an ancestor of Gitea master, and a plain
|
||||
`git push` (no `--force`) is a fast-forward. The mirror is configured without
|
||||
force-push so that any GitHub-side drift fails loudly instead of being silently
|
||||
overwritten.
|
||||
|
||||
Two conflicts were resolved in the merge, both copy contracts:
|
||||
|
||||
1. `src/components/nook/FeatureBreakdowns.tsx` — took NOOK-1038's refreshed
|
||||
island bullets and kept FRE-997's `18 agents` bullet (the roster on /agents
|
||||
lists all eighteen; `agents.test.ts` asserts equality).
|
||||
2. `src/routes/nook/remote-claims.test.ts` — one file holding both contracts.
|
||||
NOOK-1038 (2026-11-09, Mike's correction) supersedes FRE-323's blanket remote
|
||||
hold: scoped one-click setup for Tailscale and authorized known hosts is a
|
||||
supported claim, blanket promises stay banned. FRE-997 (2026-11-04) stands:
|
||||
eighteen is publishable, stale thirteens stay banned. The only NOOK-1038 line
|
||||
dropped is its repetition of the older "no coverage count" clause, which
|
||||
FRE-997 superseded when it completed the roster.
|
||||
|
||||
## How a merge becomes a deploy
|
||||
|
||||
1. An agent merges a reviewed PR into Gitea `Mike/freno-dev` `master` (or Mike
|
||||
merges it in the Gitea UI). Nothing else changes for the engineer.
|
||||
2. A **Gitea push mirror** on `Mike/freno-dev` pushes `master` to
|
||||
`https://github.com/mikefreno/freno-dev.git` with sync-on-commit enabled.
|
||||
3. Vercel sees the GitHub push and builds production from GitHub `master`.
|
||||
4. `scripts/verify-deploy.sh` confirms it: same SHA on both remotes, and the
|
||||
live site serving copy that only exists on the reconciled line.
|
||||
|
||||
**Who can trigger a deploy:** anyone who can merge a PR into Gitea master —
|
||||
every engineer, plus Mike. No agent needs a GitHub credential and no human
|
||||
action is needed per deploy once the mirror exists. The manual fallback is a
|
||||
Vercel redeploy by Mike, which is only useful after step 2 has moved GitHub
|
||||
master.
|
||||
|
||||
**Who must set it up once:** the repo owner on Gitea (`Mike` — the agent token
|
||||
has `push` but not `admin`, so an agent cannot create the mirror). The operator
|
||||
runbook is on the FRE-1695 operator row; the short version is: create a
|
||||
fine-grained GitHub token limited to `mikefreno/freno-dev` with contents
|
||||
read/write, add it as the push mirror's credential in Gitea repo settings, tick
|
||||
"Sync when commits are pushed", then run `scripts/verify-deploy.sh`.
|
||||
|
||||
Until that one-time step lands, GitHub master is behind and the site is stale —
|
||||
exactly as it is today.
|
||||
|
||||
## Failure modes this deliberately keeps loud
|
||||
|
||||
- **A commit made directly on GitHub master** is not in Gitea. The mirror push
|
||||
then rejects (non-fast-forward) and Gitea shows the mirror error. Do not commit
|
||||
to GitHub `master`; land it in Gitea and let it mirror.
|
||||
- **A merge that is only a Gitea merge** is not live until the mirror runs. Check
|
||||
`scripts/verify-deploy.sh` before calling a site change published.
|
||||
- **The FRE-371 fix's actual exploitability in production** depends on how the
|
||||
edge sets `x-forwarded-for`/`cf-connecting-ip`. That is measured and gated by
|
||||
the Security Engineer, not assumed here.
|
||||
|
||||
## Verifying
|
||||
|
||||
scripts/verify-deploy.sh
|
||||
|
||||
It is read-only and needs no credentials. It checks both remotes' master SHA,
|
||||
the live Nook/Nessa copy, and (when the commit is present locally) that
|
||||
`2fd2a4a` is an ancestor of the deployed commit.
|
||||
@@ -22,3 +22,12 @@ a human reviews and moves it to `approved/` → the human publishes from `approv
|
||||
under their own account → the artifact (or a pointer) lands in `posted/`.
|
||||
|
||||
Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means.
|
||||
|
||||
## The weekly read
|
||||
|
||||
`bun --env-file=.env scripts/weekly-metrics.ts` (or `bun run metrics:weekly`)
|
||||
rewrites `metrics/weekly.md` from the live databases: the staged funnel, the
|
||||
campaign breakdown (`getAnalyticsSummary.topCampaigns`' grouping), and the one
|
||||
stage that moved. It needs `TURSO_DB_URL`, `TURSO_DB_TOKEN`, `NOOK_DB_URL` and
|
||||
`NOOK_DB_TOKEN` — no other production secret. The file is generated; never
|
||||
hand-edit it. The numbers are engineering's; the interpretation is the CMO's.
|
||||
|
||||
64
marketing/drafts/remote-ssh/copy.md
Normal file
64
marketing/drafts/remote-ssh/copy.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Remote SSH copy — historical unqualified wording
|
||||
|
||||
[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring the remote-agent cards and corrected the old blanket restriction. One-click setup works for **Tailscale and authorized known hosts**. The current page describes that scope; this file preserves the old unqualified wording for reference, not for verbatim reuse.
|
||||
|
||||
[gpt-6.1-sol] The earlier FRE-323 removal treated SSH authorization needed by a new host as though it prevented one-click setup for hosts already authorized. Current evidence is `RemoteHostCatalog.discover`, `RemoteHostsController.setUp`, `RemoteMirror.apply`, and the existing `RemoteHostsControllerLiveTests.setUpLeavesItLive` test. No new live-host test run is claimed by this website change.
|
||||
|
||||
## What was removed
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
## Current restoration
|
||||
|
||||
[gpt-6.1-sol] NOOK-1038 restores the collage's Remote tile, host-labelled session examples, and the detailed setup section. The collage says: “One-click setup for Tailscale and authorized known hosts.” The detailed section shows choosing a discovered host, clicking Set up, installation progress, and Live status.
|
||||
|
||||
[gpt-6.1-sol] The historical “Any SSH box” claim, universal agent-coverage bullet, and unqualified setup chip above are not restored. Discovery and an existing authorization path are not the same as granting access to a new machine. This does not limit or deny the working one-click flow for authorized hosts.
|
||||
13
marketing/drafts/remote-ssh/rationale.md
Normal file
13
marketing/drafts/remote-ssh/rationale.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Remote setup marketing decision
|
||||
|
||||
[gpt-6.1-sol] **NOOK-1038 supersedes the blanket hold.** Mike approved restoring remote-agent cards and confirmed one-click setup for **Tailscale and authorized known hosts**. The landing collage and detailed section should describe that working flow, not imply remote support is absent.
|
||||
|
||||
## Why it was removed
|
||||
|
||||
[gpt-6.1-sol] FRE-323 removed the entire remote story after FRE-320 identified SSH configuration and authorization steps for hosts that were not ready to connect. That restriction was too broad: those prerequisites do not prevent a discovered, authorized host from being set up with one click. The historical unqualified copy remains in `copy.md` so the change in scope is explicit.
|
||||
|
||||
## Current evidence and scope
|
||||
|
||||
[gpt-6.1-sol] `RemoteHostCatalog.discover` reads SSH config and Tailscale peers. `RemoteHostsController.setUp` binds the bridge and calls `RemoteMirror.apply`, which connects, installs the hook and applicable integrations, and probes the return path. The existing `RemoteHostsControllerLiveTests.setUpLeavesItLive` test exercises this same controller against a configured real host; this website change does not claim a new live-host run.
|
||||
|
||||
[gpt-6.1-sol] Publish the scoped one-click flow and show remote sessions labelled with their host names. Do not replace it with a “manual setup only” disclaimer. Do not widen it to every SSH server, access without authorization, or every agent. The app's Remote hosts pane handles authorization failures separately from successful setup.
|
||||
@@ -1,4 +1,43 @@
|
||||
# Weekly funnel snapshot
|
||||
|
||||
Generated weekly from the analytics DB + `trials`/`licenses`/`activations`.
|
||||
Format: the funnel numbers, what moved, and the one thing worth acting on.
|
||||
Window: **2026-10-22 18:05:00 → 2026-10-29 18:05:00 UTC** (7 days). Previous: 2026-10-15 18:05:00 → 2026-10-22 18:05:00 UTC.
|
||||
|
||||
Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's.
|
||||
|
||||
## Funnel
|
||||
|
||||
| Stage | This week | Last week | Change |
|
||||
|---|---:|---:|---:|
|
||||
| visit (nook.freno.me page views) | 0 | 0 | 0 |
|
||||
| download click | 0 | 0 | 0 |
|
||||
| DMG served | 0 | 0 | 0 |
|
||||
| trial started | 2 | 0 | +2 |
|
||||
| checkout started (Stripe session) | 0 | 0 | 0 |
|
||||
| purchase (licence issued) | 0 | 0 | 0 |
|
||||
| activation | 0 | 0 | 0 |
|
||||
|
||||
## Campaigns
|
||||
|
||||
Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.topCampaigns` returns.
|
||||
|
||||
| Source | Medium | Campaign | Rows |
|
||||
|---|---|---|---:|
|
||||
| x | — | — | 1 |
|
||||
|
||||
1 of 291 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
|
||||
|
||||
## The one thing that moved
|
||||
|
||||
**trial started: 0 → 2 (+2).**
|
||||
|
||||
## Before acting on these numbers
|
||||
|
||||
- **291 of 291 rows in the window predate the FRE-366 instrumentation.** They carry no host, and their recorded address is the production proxy's loopback (127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. They are excluded from every host-scoped stage above (visits, and the download click/served split), and "unique visitors" over them still collapses to one host. Rows recorded since carry the site they addressed and the visitor's address truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so "unique visitors" counts networks, not machines.
|
||||
- **Download rows are automation-heavy.** Of this window's 137 download rows, 0 are clicks and 0 are served files; the remaining 137 predate the click/served stage label and cannot be split, so they count in neither stage. 112 of all 137 are not browser user agents (curl, Sparkle, link-preview crawlers).
|
||||
- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.
|
||||
|
||||
## Sources
|
||||
|
||||
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
|
||||
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
|
||||
- Generated 2026-10-29T18:05:00Z.
|
||||
|
||||
@@ -4,13 +4,39 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
|
||||
artifact invents its own framing, fix the artifact or fix this file — never both
|
||||
silently diverging.
|
||||
|
||||
Primary source: `~/Code/the-nook/docs/marketing-strategy.md`.
|
||||
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 count
|
||||
correction, against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354],
|
||||
[FRE-997]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
|
||||
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
|
||||
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
|
||||
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
|
||||
no longer exists; the verdicts below are the source of truth it was pointing at.
|
||||
|
||||
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
|
||||
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
|
||||
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
|
||||
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
|
||||
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
|
||||
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
|
||||
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
|
||||
|
||||
## 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,
|
||||
> unblock it in one tap, and keep the machine from overheating while it works.
|
||||
There is no single approved public sentence yet, and saying otherwise is the one
|
||||
mistake this file exists to prevent. What exists is a **launch claim set** — two
|
||||
sentences, both from one 15-second take, recorded as the chosen route by the Chief
|
||||
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
|
||||
|
||||
| Slot | Exact sentence | Status today |
|
||||
|---|---|---|
|
||||
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
|
||||
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
|
||||
|
||||
Working line for internal writing, not for publication:
|
||||
|
||||
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
|
||||
> see what is running, you unblock what is waiting, and the machine stays cool
|
||||
> while it works.
|
||||
|
||||
## The wedge
|
||||
|
||||
@@ -22,15 +48,18 @@ The thing to say over and over, in every channel, in slightly different words:
|
||||
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
||||
Allow, work resumes. Ten seconds, no explanation needed.
|
||||
|
||||
## The bundling reframe
|
||||
## The consolidation reframe
|
||||
|
||||
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
||||
$10 app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||
app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||
|
||||
> One $10 app instead of a notch toy, a fan utility, a battery limiter, a menu-bar
|
||||
> stats strip, and a notification hack. Same one-time purchase, no subscription.
|
||||
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
|
||||
> strip, and a notification hack. One purchase, no subscription.
|
||||
|
||||
Price rises to $15 at 1.0 — dated and honest, not a permanent beta.
|
||||
**No price, no discount and no future price in copy.** The price is $10, it stays
|
||||
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
|
||||
price change is a Chief of Staff + sponsor call; it is never a marketing decision
|
||||
and never a launch asset.
|
||||
|
||||
## Hard rules
|
||||
|
||||
@@ -48,24 +77,51 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
|
||||
community replies written by a human, where quoting what a user said about another
|
||||
tool is normal conversation.
|
||||
|
||||
### 2. A claim requires a test to exist
|
||||
### 2. A claim needs a verdict, not just a test
|
||||
|
||||
Copy that says "12 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.
|
||||
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
|
||||
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
|
||||
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
|
||||
tables below, it does not go in copy.
|
||||
|
||||
### 3. Honest privacy scoping
|
||||
|
||||
Telemetry and the trial email **do** leave the device. The defensible claim is:
|
||||
Telemetry and the licence check **do** leave the device: Sentry runs on **every
|
||||
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
|
||||
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
|
||||
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
|
||||
|
||||
> **No prompt or code content leaves the device.**
|
||||
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
|
||||
- "nothing leaves your Mac"
|
||||
|
||||
Never "no data leaves the device".
|
||||
The one favourable engine sentence — about the **hook and bridge only**, never
|
||||
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
|
||||
service**. Do not write "talk to nothing but your Mac" (too absolute: the
|
||||
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
|
||||
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
|
||||
it to "over SSH" (a tailnet peer dials directly).
|
||||
|
||||
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
|
||||
**held** until the disclosures land: Sentry gated behind a preference or removed,
|
||||
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
|
||||
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
|
||||
false ones is worse than no sentence.
|
||||
|
||||
### 4. Sell what works today
|
||||
|
||||
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.
|
||||
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
|
||||
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
|
||||
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
|
||||
all, and never claim approvals-from-phone as a shipped capability without its own
|
||||
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||
|
||||
[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring remote-agent marketing.
|
||||
One-click setup works for **Tailscale and authorized known hosts**. Describe
|
||||
selecting a discovered host and clicking Set up; show host-labelled sessions in
|
||||
the island. `RemoteHostsController.setUp` runs installation and a return-path
|
||||
probe. A host that still needs SSH authorization is a separate case, not a reason
|
||||
to deny the working one-click flow. `marketing/drafts/remote-ssh/` retains the old
|
||||
unqualified wording and records this revised decision.
|
||||
|
||||
### 5. Humans hold every account
|
||||
|
||||
@@ -73,23 +129,30 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
|
||||
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
||||
nothing posts itself.
|
||||
|
||||
## Approved claims (each backed by code)
|
||||
## Claims: status at 2026-11-03
|
||||
|
||||
| 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 |
|
||||
| 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`) |
|
||||
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||
|
||||
| Claim | Status | Evidence / next step |
|
||||
|---|---|---|
|
||||
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
|
||||
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
|
||||
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
|
||||
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
|
||||
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
|
||||
| Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable. |
|
||||
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
|
||||
| One-click remote setup for Tailscale and authorized known hosts | **Approved by Mike — NOOK-1038** | [gpt-6.1-sol] Restores the scoped flow: host discovery, `RemoteHostsController.setUp`, `RemoteMirror.apply`, and host-labelled sessions. Supersedes the blanket FRE-323 hold, not the requirement for SSH authorization. |
|
||||
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
|
||||
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
|
||||
|
||||
## Voice
|
||||
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives ("twelve agents",
|
||||
"one tap", "$10 once").
|
||||
- The notch/island is the product's home; agents are the product's point.
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives — the only count
|
||||
that ships is eighteen, and it is the registry's own size (see the coverage row
|
||||
above).
|
||||
- The notch is the product's home; agents are the product's point.
|
||||
- Never apologize for being a utility. The utility is the point.
|
||||
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
||||
"game-changer", "seamlessly". Unverifiable superlatives generally.
|
||||
"game-changer", "seamlessly", any unverifiable superlative — and the specific
|
||||
banned sentences in rule 3 and the table above.
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
"test:coverage": "bun test --coverage",
|
||||
"perf": "bun run scripts/perf-test.ts",
|
||||
"perf:compare": "bun run scripts/perf-compare.ts",
|
||||
"metrics:weekly": "bun --env-file=.env scripts/weekly-metrics.ts",
|
||||
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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 +55,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.`
|
||||
);
|
||||
}
|
||||
38
scripts/upload-appcast.ts
Normal file
38
scripts/upload-appcast.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// One-off: upload the backfilled appcast to the download bucket.
|
||||
// Usage: cd ~/Code/freno-dev && bun --env-file=.env /tmp/upload-appcast.ts <file>
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [, , file] = process.argv;
|
||||
if (!file) {
|
||||
console.error("usage: bun --env-file=.env upload-appcast.ts <appcast.xml>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = readFileSync(file);
|
||||
const xml = body.toString("utf8");
|
||||
// Guard: refuse to upload without all three release descriptions present.
|
||||
for (const v of ["0.6.0", "0.6.2", "0.6.3"]) {
|
||||
if (!xml.includes(`sparkle:shortVersionString>${v}<`)) {
|
||||
console.error(`refusing: item for ${v} missing`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: process.env.AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.MY_AWS_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.MY_AWS_SECRET_KEY!
|
||||
}
|
||||
});
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env.VITE_DOWNLOAD_BUCKET_STRING,
|
||||
Key: "api/TheNook/appcast.xml",
|
||||
Body: body,
|
||||
ContentType: "application/xml; charset=utf-8"
|
||||
})
|
||||
);
|
||||
console.log("uploaded", file, "->", "api/TheNook/appcast.xml");
|
||||
87
scripts/verify-deploy.sh
Executable file
87
scripts/verify-deploy.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live probe for the freno-dev deploy path (FRE-1695). Read-only; no credentials.
|
||||
#
|
||||
# Proves, in one run:
|
||||
# 1. Gitea master and GitHub master are the same commit (the mirror converged).
|
||||
# 2. The live site serves copy that only exists on the reconciled line:
|
||||
# NOOK-1038's scoped remote claim (GitHub-only before the merge) *and*
|
||||
# FRE-997's "Supported agents" (Gitea-only before the merge). Both at once
|
||||
# can only come from the merge commit.
|
||||
# 3. The served tree therefore contains FRE-371 (`2fd2a4a`), which is an
|
||||
# ancestor of that merge — checked locally when the commit is in the object
|
||||
# store, so run this from a freno-dev checkout.
|
||||
#
|
||||
# Usage: scripts/verify-deploy.sh [expected-gitea-sha]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
GITEA_REMOTE="${GITEA_REMOTE:-https://git.freno.me/Mike/freno-dev.git}"
|
||||
GITHUB_REMOTE="${GITHUB_REMOTE:-https://github.com/mikefreno/freno-dev.git}"
|
||||
NOOK_HOST="${NOOK_HOST:-https://nook.freno.me}"
|
||||
NESSA_HOST="${NESSA_HOST:-https://nessa.freno.me}"
|
||||
|
||||
fail=0
|
||||
ok() { printf 'ok %s\n' "$1"; }
|
||||
bad() { printf 'FAIL %s\n' "$1"; fail=1; }
|
||||
|
||||
echo "== remotes =="
|
||||
gitea_sha=$(git ls-remote "$GITEA_REMOTE" refs/heads/master | cut -f1)
|
||||
github_sha=$(git ls-remote "$GITHUB_REMOTE" refs/heads/master | cut -f1)
|
||||
printf 'gitea master %s\ngithub master %s\n' "${gitea_sha:-<none>}" "${github_sha:-<none>}"
|
||||
|
||||
[ -n "$gitea_sha" ] && ok "gitea master readable" || bad "gitea master unreadable"
|
||||
if [ -n "$gitea_sha" ] && [ "$gitea_sha" = "$github_sha" ]; then
|
||||
ok "github master is the same commit (mirror converged)"
|
||||
else
|
||||
bad "github master differs from gitea master — the mirror has not run"
|
||||
fi
|
||||
|
||||
if [ -n "${1:-}" ]; then
|
||||
if [ "$1" = "$gitea_sha" ]; then ok "gitea master is the expected commit $1"
|
||||
else bad "gitea master is $gitea_sha, expected $1"; fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== live copy =="
|
||||
page=$(curl -sS "$NOOK_HOST/" 2>/dev/null)
|
||||
case "$page" in
|
||||
*"One-click setup for Tailscale and authorized known hosts"*)
|
||||
ok "nook landing carries NOOK-1038's scoped remote claim" ;;
|
||||
*) bad "nook landing is missing NOOK-1038's scoped remote claim" ;;
|
||||
esac
|
||||
|
||||
agents=$(curl -sS "$NOOK_HOST/agents" 2>/dev/null)
|
||||
case "$agents" in
|
||||
*"Supported agents"*) ok "nook /agents carries FRE-997's restored coverage title" ;;
|
||||
*) bad "nook /agents still serves the pre-FRE-997 title" ;;
|
||||
esac
|
||||
|
||||
terms_code=$(curl -sS -o /tmp/verify-deploy-terms.$$ -w '%{http_code}' "$NESSA_HOST/terms" 2>/dev/null)
|
||||
if [ "$terms_code" = "200" ] && grep -q 'nessa-support@freno.me' /tmp/verify-deploy-terms.$$; then
|
||||
ok "nessa /terms is published and names the support address (FRE-1670)"
|
||||
else
|
||||
bad "nessa /terms returned $terms_code without the support address (FRE-1670 undeployed)"
|
||||
fi
|
||||
rm -f /tmp/verify-deploy-terms.$$
|
||||
|
||||
echo
|
||||
echo "== ancestry =="
|
||||
for commit in 2fd2a4a 7fde259; do
|
||||
if git cat-file -e "${commit}^{commit}" 2>/dev/null; then
|
||||
if git merge-base --is-ancestor "$commit" "$gitea_sha" 2>/dev/null; then
|
||||
ok "$commit is an ancestor of gitea master"
|
||||
else
|
||||
bad "$commit is NOT an ancestor of gitea master"
|
||||
fi
|
||||
else
|
||||
printf 'skip %s not in this clone; run from a freno-dev checkout to check ancestry\n' "$commit"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "PASS — the deploy follows gitea master and the served tree contains FRE-371."
|
||||
else
|
||||
echo "FAIL — see the lines above."
|
||||
fi
|
||||
exit "$fail"
|
||||
159
scripts/weekly-metrics.ts
Normal file
159
scripts/weekly-metrics.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { createClient } from "@libsql/client/web";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import {
|
||||
ensureAnalyticsSchema,
|
||||
getWeeklyReport,
|
||||
renderWeeklyMarkdown,
|
||||
type Connections
|
||||
} from "~/server/weekly-metrics";
|
||||
|
||||
/**
|
||||
* Write `marketing/metrics/weekly.md` from the live analytics and licence DBs.
|
||||
*
|
||||
* bun --env-file=.env scripts/weekly-metrics.ts
|
||||
* bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z
|
||||
*
|
||||
* Required environment (all four; the analytics token also opens the licence
|
||||
* DB, which is why the weekly job can run from a checkout that has no Stripe
|
||||
* or Apple secrets):
|
||||
*
|
||||
* TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics
|
||||
* NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations
|
||||
*
|
||||
* This script deliberately does not import `~/env/server`: that schema demands
|
||||
* every production secret, and the metrics job needs two databases and nothing
|
||||
* else.
|
||||
*
|
||||
* The output path is anchored at the repo root, so running it from any cwd
|
||||
* writes the same file.
|
||||
*/
|
||||
|
||||
interface Options {
|
||||
days: number;
|
||||
now: Date;
|
||||
out: string;
|
||||
stdout: boolean;
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]",
|
||||
"",
|
||||
" --days N window length in days (default 7)",
|
||||
" --now ISO end of the window (default: now)",
|
||||
" --out PATH where to write (default: marketing/metrics/weekly.md)",
|
||||
" --stdout print the report instead of writing a file"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const repoRoot = resolve(import.meta.dir, "..");
|
||||
const options: Options = {
|
||||
days: 7,
|
||||
now: new Date(),
|
||||
out: resolve(repoRoot, "marketing/metrics/weekly.md"),
|
||||
stdout: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!;
|
||||
const value = () => {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`);
|
||||
i += 1;
|
||||
return next;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case "--days": {
|
||||
const days = Number(value());
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
throw new Error(`--days must be a positive number\n\n${usage()}`);
|
||||
}
|
||||
options.days = days;
|
||||
break;
|
||||
}
|
||||
case "--now": {
|
||||
const now = new Date(value());
|
||||
if (Number.isNaN(now.getTime())) {
|
||||
throw new Error(`--now must be an ISO timestamp\n\n${usage()}`);
|
||||
}
|
||||
options.now = now;
|
||||
break;
|
||||
}
|
||||
case "--out":
|
||||
options.out = resolve(value());
|
||||
break;
|
||||
case "--stdout":
|
||||
options.stdout = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
throw new Error(`Unknown argument ${arg}\n\n${usage()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error(
|
||||
`${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` +
|
||||
`NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` +
|
||||
`\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function connect(url: string, token: string) {
|
||||
return createClient({ url, authToken: token });
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
const connections: Connections = {
|
||||
analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")),
|
||||
nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN"))
|
||||
};
|
||||
|
||||
// The app migrates the table on boot, but this job must not depend on a
|
||||
// deploy having happened: add any missing post-hoc columns (stage, host)
|
||||
// through the same idempotent bootstrap path first.
|
||||
await ensureAnalyticsSchema(connections.analytics);
|
||||
|
||||
const report = await getWeeklyReport(connections, {
|
||||
days: options.days,
|
||||
now: options.now
|
||||
});
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
if (options.stdout) {
|
||||
process.stdout.write(markdown);
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(dirname(options.out), { recursive: true });
|
||||
await writeFile(options.out, markdown, "utf8");
|
||||
|
||||
const f = report.counts;
|
||||
console.log(
|
||||
`wrote ${options.out}\n` +
|
||||
`window ${report.window.start} → ${report.window.end}\n` +
|
||||
`visit ${f.visit}, download click ${f.downloadClick}, DMG served ${f.dmgServed}, ` +
|
||||
`trial ${f.trial}, checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` +
|
||||
`mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
120
src/app.css
120
src/app.css
@@ -755,3 +755,123 @@ 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;
|
||||
}
|
||||
|
||||
/* Directional dashes in the power-flow preview. */
|
||||
.nook-power-flow {
|
||||
animation: nook-power-flow 1.6s linear infinite;
|
||||
}
|
||||
@keyframes nook-power-flow {
|
||||
to { stroke-dashoffset: -21; }
|
||||
}
|
||||
|
||||
/* Live signal inside a mixer fader */
|
||||
.nook-meter {
|
||||
transform-origin: left center;
|
||||
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-power-flow,
|
||||
.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 { BatteryDemo, CalendarDemo, MixerDemo } from "./UtilityDemos";
|
||||
import {
|
||||
TerminalReplyDemo,
|
||||
RemoteHostDemo,
|
||||
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%. */
|
||||
@@ -154,111 +168,99 @@ function FleetDemo() {
|
||||
{phase() === "flare"
|
||||
? "Codex needs your input"
|
||||
: phase() === "resolve"
|
||||
? "Answer delivered — agents back to work"
|
||||
: "Fleet running calmly"}
|
||||
? "Reply sent. Agents running."
|
||||
: "Agents running"}
|
||||
</p>
|
||||
<p class="mt-0.5 text-[11px] text-white/50">
|
||||
{phase() === "flare" ? "question · in the island now" : "\u00a0"}
|
||||
{phase() === "flare" ? "Question waiting in the island" : "\u00a0"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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="p-2.5">
|
||||
<div
|
||||
class="grid transition-all duration-500"
|
||||
style={{ "grid-template-rows": asking() ? "1fr" : "0fr", opacity: asking() ? 1 : 0 }}
|
||||
>
|
||||
<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">
|
||||
<p class="px-1 text-[9px] font-semibold tracking-wide text-white/40 uppercase">
|
||||
The Nook
|
||||
</p>
|
||||
<div
|
||||
class="rounded-lg p-2"
|
||||
style={{
|
||||
background: allowed()
|
||||
? "rgba(111,185,130,0.14)"
|
||||
: "rgba(231,167,98,0.14)"
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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,344 +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() {
|
||||
const [level, setLevel] = createSignal(62);
|
||||
const [phase, setPhase] = createSignal<"charge" | "held" | "topup">("charge");
|
||||
onMount(() => {
|
||||
let frame = 0;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const ramp = (target: number, durationMs: number) => {
|
||||
cancelAnimationFrame(frame);
|
||||
const start = level();
|
||||
const startTime = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min((now - startTime) / durationMs, 1);
|
||||
setLevel(start + (target - start) * t);
|
||||
if (t < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
const loop = () => {
|
||||
setLevel(62);
|
||||
setPhase("charge");
|
||||
ramp(80, 2600);
|
||||
timers.push(setTimeout(() => setPhase("held"), 2800));
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
setPhase("topup");
|
||||
ramp(100, 2600);
|
||||
}, 5200)
|
||||
);
|
||||
};
|
||||
loop();
|
||||
const interval = setInterval(loop, 9600);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
timers.forEach(clearTimeout);
|
||||
cancelAnimationFrame(frame);
|
||||
});
|
||||
});
|
||||
|
||||
const pct = () => Math.round(level());
|
||||
const watts = () => (phase() === "held" ? null : pct() >= 100 ? 9 : 38);
|
||||
const subline = () =>
|
||||
phase() === "held"
|
||||
? "charging paused — at the limit"
|
||||
: pct() >= 100
|
||||
? "topped up — re-arms on unplug"
|
||||
: phase() === "topup"
|
||||
? `${watts()} W · top-up past the limit`
|
||||
: `${watts()} W · ${Math.max(1, Math.round((100 - pct()) * 1.5))} min to full`;
|
||||
return (
|
||||
<ModuleCard class="mx-auto w-full max-w-[320px]">
|
||||
<div class="space-y-3 text-left">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold tracking-wide text-white/40 uppercase">
|
||||
Battery
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-md px-2 py-0.5 font-mono text-[10px] font-semibold transition-transform duration-300"
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.75)",
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
transform: phase() === "held" ? "scale(1.12)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
Limit 80%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="font-mono text-[22px] font-semibold text-white">
|
||||
{pct()}%
|
||||
</span>
|
||||
<span class="text-[11px] text-white/50">{subline()}</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative h-[6px] overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={{
|
||||
width: `${pct()}%`,
|
||||
background: pct() > 80 ? WARM : CALM,
|
||||
transition: "background 0.4s"
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
class="absolute top-0 h-full w-px"
|
||||
style={{ left: "80%", background: "rgba(255,255,255,0.5)" }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors duration-500"
|
||||
style={{
|
||||
background:
|
||||
phase() === "topup" ? "rgba(110,167,255,0.10)" : "transparent"
|
||||
}}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-[11px] font-semibold text-white/85">Top-up mode</p>
|
||||
<p class="text-[9px] text-white/40">
|
||||
charge past the limit while plugged in
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="relative inline-flex h-[16px] w-[30px] items-center rounded-full transition-colors duration-300"
|
||||
style={{
|
||||
background:
|
||||
phase() === "topup" ? ACCENT : "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="absolute h-[12px] w-[12px] rounded-full bg-white transition-all duration-300"
|
||||
style={{ left: phase() === "topup" ? "16px" : "2px" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
width="10"
|
||||
height="13"
|
||||
viewBox="0 0 10 13"
|
||||
style={{
|
||||
animation: watts()
|
||||
? "nook-pulse 1.1s ease-in-out infinite"
|
||||
: "none",
|
||||
opacity: watts() ? 1 : 0.3
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M6.2 0 0.8 7h2.6l-1 5.2L7.8 5.4H5l1.2-5.4z"
|
||||
fill="#FFD23F"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[10px] text-white/50">
|
||||
{phase() === "held"
|
||||
? "holding — the pack stays young longer"
|
||||
: phase() === "topup"
|
||||
? "topping up — re-arms when you unplug"
|
||||
: "on the way to the ceiling"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 5: the slot counts down, then opens the day ──────── */
|
||||
|
||||
const WEEK_LETTERS = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
const DAY_EVENTS = [
|
||||
{ time: "09:30–09:45", title: "Standup", loc: "Daily", meeting: true },
|
||||
{ time: "15:30–16:00", title: "Design review", loc: "4th floor", meeting: false }
|
||||
];
|
||||
|
||||
function CalendarDemo() {
|
||||
const [mins, setMins] = createSignal(14);
|
||||
const [today, setToday] = createSignal(new Date());
|
||||
onMount(() => {
|
||||
setToday(new Date());
|
||||
const interval = setInterval(
|
||||
() => setMins((m) => (m <= 0 ? 14 : m - 1)),
|
||||
900
|
||||
);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
const imminent = () => mins() <= 5;
|
||||
const dialDays = () => {
|
||||
const base = today();
|
||||
return [-4, -3, -2, -1, 0, 1, 2, 3, 4].map((off) => {
|
||||
const d = new Date(
|
||||
base.getFullYear(),
|
||||
base.getMonth(),
|
||||
base.getDate() + off
|
||||
);
|
||||
return {
|
||||
letter: WEEK_LETTERS[d.getDay()],
|
||||
num: d.getDate(),
|
||||
today: off === 0,
|
||||
past: off < 0,
|
||||
dots: (d.getDate() * 7 + d.getDay() * 3) % 4
|
||||
};
|
||||
});
|
||||
};
|
||||
return (
|
||||
/* Fixed height: the day panel expands into reserved space, never
|
||||
shifts the page (matters most on mobile's stacked layout). */
|
||||
<div class="flex h-[316px] flex-col items-center gap-4">
|
||||
<IslandPill
|
||||
campfire="running"
|
||||
right={
|
||||
<div class="flex items-center gap-1">
|
||||
<svg width="10" height="8" viewBox="0 0 12 9" class="shrink-0">
|
||||
<path
|
||||
d="M0 2A1.4 1.4 0 0 1 1.4 0.6h5.6A1.4 1.4 0 0 1 8.4 2v5A1.4 1.4 0 0 1 7 8.4H1.4A1.4 1.4 0 0 1 0 7z M12 2.6 9 4.5l3 1.9z"
|
||||
fill="rgba(255,255,255,0.75)"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="whitespace-nowrap font-mono text-[9.5px] font-semibold text-white/90"
|
||||
style={{ color: imminent() ? "#ff9e42" : "rgba(255,255,255,0.9)" }}
|
||||
>
|
||||
{mins() === 0 ? "now" : `${mins()}m`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* the island opening onto the day panel */}
|
||||
<div
|
||||
class="w-full max-w-[380px] overflow-hidden rounded-[14px] bg-black transition-all duration-700"
|
||||
style={{
|
||||
"max-height": imminent() ? "240px" : "0px",
|
||||
opacity: imminent() ? 1 : 0,
|
||||
transform: imminent() ? "translateY(0)" : "translateY(-8px)"
|
||||
}}
|
||||
>
|
||||
<div class="px-4 pt-3 pb-3 text-left">
|
||||
<div class="mb-2 flex items-center">
|
||||
<span class="text-[11px] font-semibold text-white/50">
|
||||
Calendar
|
||||
</span>
|
||||
<span class="ml-auto flex h-4 w-4 items-center justify-center text-[12px] font-semibold text-white/50">
|
||||
+
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-2 flex gap-1">
|
||||
<For each={dialDays()}>
|
||||
{(d) => (
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center gap-[3px]"
|
||||
style={{ opacity: d.today ? 1 : d.past ? 0.55 : 0.8 }}
|
||||
>
|
||||
<span class="text-[8px] font-semibold text-white/45">
|
||||
{d.letter}
|
||||
</span>
|
||||
<span
|
||||
class="flex h-[20px] w-[20px] items-center justify-center rounded-full text-[10px]"
|
||||
style={{
|
||||
background: d.today
|
||||
? "rgb(255,158,66)"
|
||||
: "rgba(255,255,255,0.06)",
|
||||
color: "rgba(255,255,255,0.95)",
|
||||
"font-weight": d.today ? 700 : 500
|
||||
}}
|
||||
>
|
||||
{d.num}
|
||||
</span>
|
||||
<span class="flex h-[4px] gap-[2px]">
|
||||
<For each={Array.from({ length: Math.min(d.dots, 3) })}>
|
||||
{() => (
|
||||
<span
|
||||
class="rounded-full"
|
||||
style={{
|
||||
width: "3.5px",
|
||||
height: "3.5px",
|
||||
background: d.today
|
||||
? "rgb(255,158,66)"
|
||||
: "rgba(255,255,255,0.55)"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class="mb-1 h-px"
|
||||
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||
/>
|
||||
<For each={DAY_EVENTS}>
|
||||
{(ev) => (
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors duration-500"
|
||||
style={{
|
||||
background:
|
||||
ev.meeting && imminent()
|
||||
? "rgba(72,151,178,0.16)"
|
||||
: "transparent"
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p class="font-mono text-[10px] text-white/45">{ev.time}</p>
|
||||
<p class="text-[11px] font-semibold text-white">{ev.title}</p>
|
||||
{ev.loc && <p class="text-[10px] text-white/35">{ev.loc}</p>}
|
||||
</div>
|
||||
{ev.meeting && (
|
||||
<span
|
||||
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white transition-colors duration-500"
|
||||
style={{
|
||||
background: imminent() ? ACCENT : "rgba(255,255,255,0.10)"
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 6: the lens only wakes for a click ──────────────── */
|
||||
|
||||
function CameraDemo() {
|
||||
@@ -826,153 +490,14 @@ function CameraDemo() {
|
||||
</div>
|
||||
<p class="text-[10px] text-white/40">
|
||||
{phase() === "live"
|
||||
? "live — stops the moment you leave the panel"
|
||||
: "inert — the lens stays dark until you click"}
|
||||
? "Camera on while this panel is open"
|
||||
: "Camera off until you click Start"}
|
||||
</p>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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() {
|
||||
@@ -984,11 +509,11 @@ function CampfireDemo() {
|
||||
"error"
|
||||
];
|
||||
const labels = [
|
||||
"fleet running",
|
||||
"pending question",
|
||||
"ready to review",
|
||||
"all quiet",
|
||||
"something failed"
|
||||
"Running",
|
||||
"Question",
|
||||
"Ready",
|
||||
"Idle",
|
||||
"Error"
|
||||
];
|
||||
return (
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
@@ -1016,13 +541,13 @@ export default function FeatureBreakdowns() {
|
||||
<div class="space-y-24">
|
||||
<BreakdownSection
|
||||
kicker="The island"
|
||||
title="Your whole fleet, in one pixel strip"
|
||||
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
||||
title="See which agents need your attention"
|
||||
body="Each square represents an agent session. Its color shows whether the agent is working, finished, or waiting for you. Questions appear in the island so you can respond without switching windows."
|
||||
bullets={[
|
||||
"12 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, and more",
|
||||
"The campfire dances while work runs, simmers when all is calm",
|
||||
"Slot rankings — you choose which data earns the notch",
|
||||
"You don't need a notch — external displays get the same island, floating top-center"
|
||||
"18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
||||
"Connect custom agents using the published integration contract",
|
||||
"Choose which information appears in the compact view",
|
||||
"Works on displays without a notch, centered at the top of the screen"
|
||||
]}
|
||||
>
|
||||
<FleetDemo />
|
||||
@@ -1030,12 +555,12 @@ 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."
|
||||
title="Approve agent requests"
|
||||
body="When an agent needs permission, the island opens with a request card above your sessions. Review the tool and its input, then allow or deny it. You can include a reason when you deny a request."
|
||||
bullets={[
|
||||
"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"
|
||||
"Review permissions above the session list and answer questions in the session row",
|
||||
"Allow for session approves subsequent requests in that session",
|
||||
"Respond from iPhone or Apple Watch with the TestFlight beta",
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
@@ -1043,88 +568,146 @@ export default function FeatureBreakdowns() {
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Cooling"
|
||||
title="Fans that answer to you, not the scheduler"
|
||||
body="Live RPM for every fan, and the curve is yours. Apple's stock curve waits until you're already cooking — swap to Aggressive and it climbs earlier and harder, or slam MAX before the compile."
|
||||
kicker="Terminals"
|
||||
title="Reply to agents and open their terminals"
|
||||
body="Send a reply from the island to the terminal pane running that session. Use Jump to bring its window forward when you want to work in the terminal."
|
||||
bullets={[
|
||||
"Stock, Aggressive, and MAX — swap curves in one click",
|
||||
"MAX and COOL sessions surface right in the pill",
|
||||
"CPU/GPU die temps tier-color before things get loud",
|
||||
"All local: your hardware data never leaves the device"
|
||||
"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="Remote"
|
||||
title="Set up remote agents in one click"
|
||||
body="Choose a Tailscale peer or an authorized host from your SSH config and click Set up. The Nook installs its integrations and checks that the host can reach your Mac."
|
||||
bullets={[
|
||||
"Local and remote sessions in the same island, labelled by host",
|
||||
"Remote integrations follow the agents installed on this Mac",
|
||||
"Direct connections over Tailscale, or an SSH tunnel for other hosts",
|
||||
"Uses the host\'s existing SSH authorization"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<RemoteHostDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="The campfire"
|
||||
title="Agent status in the campfire"
|
||||
body="The pixel-art campfire changes with your agents' activity. Different animations indicate work in progress, a question waiting for you, or an error."
|
||||
bullets={[
|
||||
"Separate animations for idle, running, ready, question, and error states",
|
||||
"Hand-drawn 14×14 sprites, no tweening",
|
||||
"Six chiptunes you can assign to any agent event"
|
||||
]}
|
||||
>
|
||||
<CampfireDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Cooling"
|
||||
title="Monitor and control your Mac's fans"
|
||||
body="Check each fan's speed and choose how cooling responds to load. Keep the stock fan curve, use Aggressive for earlier cooling, or start a MAX session to run the fans at full speed."
|
||||
bullets={[
|
||||
"Switch between Stock, Aggressive, and MAX",
|
||||
"See active MAX and COOL sessions in the compact view",
|
||||
"CPU and GPU temperature readings change color as they rise",
|
||||
"Hardware monitoring runs locally on your Mac"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<FansDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Power"
|
||||
title="Wall power, on your terms"
|
||||
body="Live watts from the adapter to the battery to the chassis — and a charge ceiling you set yourself. On recent macOS The Nook drives the system's own limiter; below that it enforces the limit itself. One slider, 50–100%, any version."
|
||||
title="Set a battery charge limit"
|
||||
body="Set the charge limit on the battery bar, or enter a percentage. The power diagram shows how much power goes to your Mac and battery. Turn on Top-up mode when you need a full charge."
|
||||
bullets={[
|
||||
"One slider, 50–100%, on any macOS version",
|
||||
"Hooks the system limiter where it exists, enforces elsewhere",
|
||||
"Top-up mode charges past, then re-arms on unplug",
|
||||
"Thermal protection pauses charging when the pack runs hot",
|
||||
"Energy flow figure: adapter → battery → system, live watts"
|
||||
"Drag the limit marker or type a percentage; available limits depend on your Mac",
|
||||
"Uses macOS charge limits where available, with helper-based control on supported Macs",
|
||||
"Top-up mode allows charging past the limit and turns off when you unplug",
|
||||
"Overheat protection can pause charging when the battery gets hot",
|
||||
"Separate readings for system draw and battery charging, not a wall-power measurement"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<BatteryDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Your day"
|
||||
title="Meetings come to the notch"
|
||||
body="Today sits in a dial — days behind, days ahead, dots where things happen. The slot counts your next call down, and when it gets close the island opens the day on its own."
|
||||
kicker="Sound"
|
||||
title="Adjust volume and output for each app"
|
||||
body="Set an app's volume without changing the others, mute it, or choose a different output. You can play music through headphones and keep a meeting on the speakers."
|
||||
bullets={[
|
||||
"Next meeting counts down right in the slot",
|
||||
"Near a call, the island opens the day itself",
|
||||
"Meeting links get an Open button in the row",
|
||||
"Real calendars and reminders — no sync service"
|
||||
"Per-app volume, mute, and output routing",
|
||||
"Master output, system sounds, and microphone pickers in one panel",
|
||||
"Includes audio from command-line players"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<MixerDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Capture"
|
||||
title="Take screenshots and record your screen"
|
||||
body="Capture an area, a whole screen, or one app. During a recording, controls beside the island let you pause, stop, and toggle system audio or your microphone."
|
||||
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="Check your calendar and join meetings"
|
||||
body="Browse your calendar from the day strip and open a meeting from its event row. The compact view shows today's date, a countdown before an event, and an ON AIR indicator while a meeting is in progress."
|
||||
bullets={[
|
||||
"Date, countdown, and meeting progress in the compact view",
|
||||
"Choose how early the island opens before a meeting",
|
||||
"Join meetings from the video button beside an event",
|
||||
"Uses your Mac's calendars and reminders"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<CalendarDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Camera"
|
||||
title="A lens that stays asleep"
|
||||
body="A live camera preview in the island for the seconds before a call. It never wakes on its own: permission-gated, inert until you click Start, and dead the moment you leave the panel."
|
||||
title="Preview your camera before a call"
|
||||
body="Check your camera in the island before joining a call. The preview starts only when you click Start camera and stops when you leave the panel."
|
||||
bullets={[
|
||||
"Nothing watches until Start camera gets a click",
|
||||
"Live only while the panel sits open",
|
||||
"Requires camera permission and a click on Start camera",
|
||||
"Preview runs only while the panel is open",
|
||||
"A device picker for every connected camera",
|
||||
"Opt-in module — in no default panel"
|
||||
"Add the camera module to a panel when you want it"
|
||||
]}
|
||||
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="Add shortcuts beside the island"
|
||||
body="Add up to seven actions beside the expanded island. Use them to open apps, run shortcuts, or change settings without opening a panel."
|
||||
bullets={[
|
||||
"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, SessionRowMock, DieLine, CLAUDE, CODEX } 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,58 @@ function FansPanelMock() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Remote agents: two session rows like the expanded agents panel. */
|
||||
function RemoteMock() {
|
||||
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 }}
|
||||
<SessionRowMock
|
||||
title="api · main"
|
||||
prompt="fix the flaky test"
|
||||
state="Running pytest -x…"
|
||||
status="running"
|
||||
agent={CLAUDE}
|
||||
host="build-box"
|
||||
age="4m"
|
||||
/>
|
||||
<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>
|
||||
</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 }}
|
||||
<SessionRowMock
|
||||
title="trainer · fp16"
|
||||
prompt="benchmark fp16"
|
||||
state="Done · replied 1m ago"
|
||||
status="ready"
|
||||
agent={CODEX}
|
||||
host="gpu-rig"
|
||||
age="12m"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/** System gauges: temp die blocks + memory, in module cards. */
|
||||
/* 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="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 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>
|
||||
</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
|
||||
@@ -254,7 +162,7 @@ export default function FeatureCollage() {
|
||||
Why the Nook
|
||||
</p>
|
||||
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||
One glance. Everything your agents are doing.
|
||||
Keep track of your agents while you work
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||
@@ -265,12 +173,12 @@ export default function FeatureCollage() {
|
||||
The island
|
||||
</p>
|
||||
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight">
|
||||
Every agent, one glance
|
||||
Agent status at the top of your screen
|
||||
</h3>
|
||||
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
||||
A pill tucked into your notch — a campfire for your whole fleet
|
||||
on the left, live data on the right. Blue running, green ready,
|
||||
amber needs you.
|
||||
The island sits around your Mac's notch, with agent activity on
|
||||
the left and your choice of information on the right. Blue means
|
||||
running, green means ready, and amber means an agent needs you.
|
||||
</p>
|
||||
</div>
|
||||
<div class="my-10 flex w-full justify-center">
|
||||
@@ -303,19 +211,24 @@ export default function FeatureCollage() {
|
||||
/>
|
||||
</div>
|
||||
<p class="text-subtext0 -mb-1 text-center text-xs">
|
||||
expand for the full panel ↓
|
||||
Open the island to see your sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Approvals */}
|
||||
<CollageCell
|
||||
kicker="Approvals"
|
||||
title="Unblock agents, anywhere"
|
||||
body="Permission prompts 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)."
|
||||
title="Review permission requests"
|
||||
body="Review permission requests above your sessions and choose Allow, Allow for session, or Deny. The iPhone and Apple Watch beta lets you respond away from your Mac."
|
||||
>
|
||||
<InkStage>
|
||||
<div class="w-full max-w-sm px-3">
|
||||
<ApprovalCardMock />
|
||||
<ApprovalCardMock
|
||||
agent={CLAUDE}
|
||||
title="the-nook · bridge"
|
||||
tool="Edit"
|
||||
input="server/routes.ts"
|
||||
/>
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
@@ -323,8 +236,8 @@ export default function FeatureCollage() {
|
||||
{/* Fans */}
|
||||
<CollageCell
|
||||
kicker="Cooling"
|
||||
title="Silence it or max it"
|
||||
body="Read both fans live, then take over the curve completely. Flip to MAX before a compile, whisper-quiet when the fleet idles."
|
||||
title="Fan speeds and cooling modes"
|
||||
body="Monitor each fan's speed and choose a cooling mode. Keep automatic control or start a MAX session before a demanding task."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full max-w-[260px] text-left">
|
||||
@@ -333,24 +246,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."
|
||||
title="CPU and GPU monitoring"
|
||||
body="See CPU and GPU temperatures beside their last 60 seconds of usage. Temperature readings turn amber at 70°C and red at 85°C."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full max-w-[300px] text-left">
|
||||
@@ -358,11 +258,28 @@ export default function FeatureCollage() {
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Remote"
|
||||
title="Agents on other machines"
|
||||
body="One-click setup for Tailscale and authorized known hosts. Remote sessions appear in the island with the host name beside the agent."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full min-w-0 px-3" role="img" aria-label="Remote agent sessions on build-box and gpu-rig">
|
||||
<RemoteMock />
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
</div>
|
||||
|
||||
<p class="text-subtext1 mt-6 text-center text-xs">
|
||||
Plus calendar, reminders, calls, and Now Playing — all in the same
|
||||
island, all one-time purchase.
|
||||
Also includes a volume mixer, screen capture, timers, weather, and more.{" "}
|
||||
<a
|
||||
href="#island-extras"
|
||||
class="underline decoration-dotted hover:opacity-80"
|
||||
>
|
||||
Browse the other modules
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
898
src/components/nook/IslandExtras.tsx
Normal file
898
src/components/nook/IslandExtras.tsx
Normal file
@@ -0,0 +1,898 @@
|
||||
import {
|
||||
For,
|
||||
Index,
|
||||
Show,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type JSX
|
||||
} from "solid-js";
|
||||
import {
|
||||
ModuleCard,
|
||||
STATUS,
|
||||
ACCENT,
|
||||
CALM,
|
||||
WARM,
|
||||
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: "automatically allows received requests", card: "passed", tint: "rgba(111,185,130,0.12)" },
|
||||
{ name: "Verify", note: "received requests wait for approval", card: "held", tint: "rgba(229,72,77,0.12)" }
|
||||
];
|
||||
|
||||
function ApprovalModesMock() {
|
||||
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 = [
|
||||
"18 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">
|
||||
More modules
|
||||
</p>
|
||||
<h2 class="text-text mb-3 text-center text-3xl font-bold">
|
||||
Other tools in the island
|
||||
</h2>
|
||||
<p class="text-subtext0 mx-auto mb-10 max-w-2xl text-center leading-relaxed">
|
||||
Add the modules you need and arrange them in your panels.
|
||||
They're all included with your license.
|
||||
</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="Playback controls and track information"
|
||||
body="See what's playing and control playback. For sources without artwork, including some browsers, the compact view shows an audio waveform."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center">
|
||||
<NowPlayingMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Timers"
|
||||
title="Run several timers at once"
|
||||
body="Keep multiple timers running and check their progress in the island. When a timer finishes, it rings and keeps the island open until you dismiss it."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<TimersMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Weather"
|
||||
title="Weather for your saved places"
|
||||
body="Check hourly and daily forecasts for saved places. The compact view shows the current conditions and temperature."
|
||||
>
|
||||
<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 controls"
|
||||
body="Show volume and brightness changes below the island instead of the system overlay. Adjust supported external displays and use extra brightness on compatible XDR displays."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center">
|
||||
<div class="flex min-h-[110px] w-full justify-center pt-2">
|
||||
<InterceptionMock />
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Approval modes"
|
||||
title="Choose how approvals are handled"
|
||||
body="Auto follows each agent's settings. Yolo automatically allows requests that reach The Nook. Verify waits for your decision on each request it receives."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<ApprovalModesMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Network"
|
||||
title="Network information and speed tests"
|
||||
body="View and copy your public IP, ISP, gateway, and DNS details. Check for VPN routes or run a speed test."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<NetworkMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Disks"
|
||||
title="Disk usage and drive health"
|
||||
body="Check read and write speeds, free space, and SMART health information for supported drives."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<DiskMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Keep Awake"
|
||||
title="Keep your Mac awake"
|
||||
body="Keep your Mac awake for a set time, while plugged in, with the lid closed, or while your agents are working."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<KeepAwakeMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Lock Screen"
|
||||
title="Agent status on the Lock Screen"
|
||||
body="The island stays on your Lock Screen, so you can see how many agents are working or waiting without unlocking."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<LockScreenMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Layout"
|
||||
title="Arrange your panels"
|
||||
body="Drag and resize modules, choose how much detail they show, and change compact-view priorities with your Focus mode."
|
||||
>
|
||||
<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"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
411
src/components/nook/ModuleMocks.tsx
Normal file
411
src/components/nook/ModuleMocks.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
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" />,
|
||||
video: () => <path d="M2 4h8v8H2Z m9 3 3-2v6l-3-2Z" fill="currentColor" />,
|
||||
pin: () => <path d="m6 2 6 6-3 1-2 3-1-2-4 4m2-8 2-2Z" stroke="currentColor" stroke-width="1.2" fill="none" stroke-linejoin="round" />,
|
||||
eye: () => <><path d="M1 8s2.5-4 7-4 7 4 7 4-2.5 4-7 4-7-4-7-4Z" stroke="currentColor" stroke-width="1.2" fill="none" /><circle cx="8" cy="8" r="2" fill="currentColor" /></>,
|
||||
plug: () => <path d="M5 1v4m6-4v4M3 5h10v3a5 5 0 0 1-4 4.9V16H7v-3.1A5 5 0 0 1 3 8Z" stroke="currentColor" stroke-width="1.3" fill="none" />,
|
||||
battery: () => <><rect x="1" y="4" width="12" height="8" rx="1.5" stroke="currentColor" stroke-width="1.2" fill="none" /><path d="M14 6v4M3 6h5v4H3Z" stroke="currentColor" stroke-width="1.2" /></>,
|
||||
laptop: () => <path d="M3 2.5h10v9H3Z M1 13.5h14" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linejoin="round" />,
|
||||
refresh: () => (
|
||||
<path d="M12.5 8A4.5 4.5 0 1 1 11 4.6M11.6 2v2.8H8.8" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
)
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
598
src/components/nook/MoreDemos.tsx
Normal file
598
src/components/nook/MoreDemos.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import { For, Show, createSignal, type JSX } from "solid-js";
|
||||
import {
|
||||
ModuleCard,
|
||||
FakeCursor,
|
||||
IslandPill,
|
||||
AgentDotGrid,
|
||||
STATUS,
|
||||
ACCENT,
|
||||
CALM,
|
||||
CARD_STROKE
|
||||
} from "./IslandMock";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { Glyph, WingCapsule, SessionRowMock, CODEX, type GlyphName } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Live demos for the breakdowns added with the 0.6–0.10 feature set:
|
||||
* terminal replies, remote setup, Capture (module + Wings), and
|
||||
* floating actions. Same ink chrome and status palette as the originals.
|
||||
*/
|
||||
|
||||
/* ── Reply lands in the agent's own terminal ──────────────────────── */
|
||||
|
||||
const REPLY = "Yes, update and run the tests.";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** RemoteMirror's progress steps, in order. */
|
||||
const SETUP_STEPS = [
|
||||
"connecting",
|
||||
"pushing the hook binary",
|
||||
"configuring agents",
|
||||
"checking the host can reach this Mac"
|
||||
];
|
||||
|
||||
export function RemoteHostDemo() {
|
||||
/* -1 not set up · 0…3 setting up · 4 live */
|
||||
const [step, setStep] = createSignal(-1);
|
||||
const [pressed, setPressed] = createSignal(false);
|
||||
useDemoLoop(11000, [
|
||||
[0, () => (setStep(-1), setPressed(false))],
|
||||
[1400, () => setPressed(true)],
|
||||
[1700, () => (setPressed(false), setStep(0))],
|
||||
[2700, () => setStep(1)],
|
||||
[3700, () => setStep(2)],
|
||||
[4700, () => setStep(3)],
|
||||
[5800, () => setStep(4)]
|
||||
]);
|
||||
const live = () => step() === 4;
|
||||
const working = () => step() >= 0 && step() < 4;
|
||||
const chip = () =>
|
||||
live()
|
||||
? { color: STATUS.ready, label: "Live" }
|
||||
: working()
|
||||
? { color: "rgba(255,255,255,0.55)", label: "Setting up" }
|
||||
: { color: "rgba(255,255,255,0.35)", label: "Not set up" };
|
||||
|
||||
return (
|
||||
<div class="relative mx-auto w-full max-w-[360px] space-y-3" role="img" aria-label="One-click remote setup preview for an authorized Tailscale host">
|
||||
{/* the host's card in Settings ▸ Agents ▸ Remote hosts */}
|
||||
<ModuleCard>
|
||||
<div class="space-y-2 text-left">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
|
||||
<p class="truncate text-[11px] text-white/45">tailnet peer</p>
|
||||
</div>
|
||||
<Show when={working()}>
|
||||
<span class="h-3 w-3 animate-spin rounded-full border-[1.5px] border-white/25 border-t-white/80" />
|
||||
</Show>
|
||||
<span class="flex items-center gap-1.5 text-[10.5px] font-medium text-white/85">
|
||||
<span
|
||||
class="h-[7px] w-[7px] rounded-full transition-colors duration-300"
|
||||
style={{ background: chip().color }}
|
||||
/>
|
||||
{chip().label}
|
||||
</span>
|
||||
<Show
|
||||
when={live()}
|
||||
fallback={
|
||||
<span
|
||||
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
|
||||
style={{
|
||||
background: ACCENT,
|
||||
opacity: working() ? 0 : 1,
|
||||
transform: pressed() ? "scale(0.92)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
Set up
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Glyph name="refresh" size={11} class="text-white/50" />
|
||||
<span class="rounded-md bg-white/10 px-2 py-1 text-[11px] font-semibold text-[#ff6961]">
|
||||
Remove
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="min-h-8 text-[11px] text-white/50">
|
||||
{working() ? SETUP_STEPS[step()] : " "}
|
||||
</p>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
|
||||
{/* The host name stays visible in the session badge. */}
|
||||
<div
|
||||
class="transition-all duration-500"
|
||||
style={{
|
||||
opacity: live() ? 1 : 0,
|
||||
transform: live() ? "translateY(0)" : "translateY(6px)"
|
||||
}}
|
||||
>
|
||||
<SessionRowMock
|
||||
title="trainer · fp16"
|
||||
prompt="benchmark fp16"
|
||||
state="Running python bench.py…"
|
||||
status="running"
|
||||
agent={CODEX}
|
||||
host="gpu-rig"
|
||||
age="now"
|
||||
/>
|
||||
</div>
|
||||
<FakeCursor
|
||||
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
|
||||
at={{ x: 88, y: 16 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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() ? "Agent running." : "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">
|
||||
Deploy 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">
|
||||
Use The Nook on iPhone and Apple Watch
|
||||
</h2>
|
||||
<p class="text-subtext0 mb-5 leading-relaxed">
|
||||
The companion app is in TestFlight beta. Check your agents,
|
||||
respond to permission requests, and answer questions when you're
|
||||
away from your Mac.
|
||||
</p>
|
||||
<ul class="mb-8 space-y-2.5">
|
||||
<For
|
||||
each={[
|
||||
"Allow, deny, answer, and reply from iPhone or Apple Watch",
|
||||
"Connects over an end-to-end encrypted relay, your tailnet, or your LAN",
|
||||
"Agent status widgets for your Home Screen, Lock Screen, and watch face",
|
||||
"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. We'll email you 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>
|
||||
);
|
||||
}
|
||||
627
src/components/nook/UtilityDemos.tsx
Normal file
627
src/components/nook/UtilityDemos.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import { IslandPill, ModuleCard, CALM, WARM } from "./IslandMock";
|
||||
import { Glyph, type GlyphName } from "./ModuleMocks";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
|
||||
const PAPER = "#f1ead9";
|
||||
const BATTERY = "#8dc9b3";
|
||||
const CALENDAR = "#ff9e42";
|
||||
|
||||
/** SettingsSlider: paper fill, dark knob, and a unity notch on app sliders. */
|
||||
function MixerSlider(props: { level: number; tint?: string; muted?: boolean }) {
|
||||
const fraction = () => props.level / (props.tint ? 125 : 100);
|
||||
const position = () => `calc(9px + (100% - 18px) * ${fraction()})`;
|
||||
return (
|
||||
<div class="relative h-5 min-w-0 flex-1 rounded-full bg-white/10">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 overflow-hidden rounded-full transition-[width] duration-700"
|
||||
style={{ width: `calc(${position()} + 9px)`, background: PAPER }}
|
||||
>
|
||||
<Show when={props.tint && !props.muted}>
|
||||
<div
|
||||
class="nook-meter h-full w-full rounded-full"
|
||||
style={{ background: props.tint }}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.tint}>
|
||||
<span
|
||||
class="absolute top-[7.5px] h-[5px] w-[5px] rounded-full border border-white/55 bg-[#0d0d0f]"
|
||||
style={{ left: "calc(9px + (100% - 18px) * 0.8 - 2.5px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span
|
||||
class="absolute top-px h-[18px] w-[18px] rounded-full border-[1.5px] shadow-sm transition-[left] duration-700"
|
||||
style={{
|
||||
left: `calc(${position()} - 9px)`,
|
||||
background: "#0d0d0f",
|
||||
"border-color": PAPER
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutePicker(props: { label: string }) {
|
||||
return (
|
||||
<span class="inline-flex max-w-[155px] items-center gap-1 rounded-full bg-white/10 px-2 py-1 text-[11px] text-white/85">
|
||||
<span class="truncate">{props.label}</span>
|
||||
<Glyph name="chevron" size={8} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const MIXER_APPS = [
|
||||
{ name: "Music", color: "#fa5c7c", glyph: "♫" },
|
||||
{ name: "Browser", color: CALM, glyph: "◎" },
|
||||
{ name: "Meeting", color: BATTERY, glyph: "▰" }
|
||||
];
|
||||
|
||||
/** VolumeMixerPanelView and MixerAppRow, including the current route controls. */
|
||||
export function MixerDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
useDemoLoop(10800, [
|
||||
[0, () => setStep(0)],
|
||||
[2400, () => setStep(1)],
|
||||
[4800, () => setStep(2)],
|
||||
[7200, () => setStep(3)]
|
||||
]);
|
||||
const muted = (i: number) => i === 1 && step() >= 1;
|
||||
const volume = (i: number) =>
|
||||
i === 0 ? (step() >= 3 ? 38 : 100) : i === 1 ? 55 : 86;
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Volume mixer preview with output, system sounds, microphone, and per-app volume, mute and routing controls"
|
||||
>
|
||||
<ModuleCard class="mx-auto w-full max-w-[360px]">
|
||||
<div class="space-y-3 text-left text-white">
|
||||
<div class="flex items-center gap-2" aria-label="Output volume">
|
||||
<Glyph name="speaker" class="text-white/50" />
|
||||
<MixerSlider level={65} />
|
||||
<span class="w-[34px] text-right font-mono text-[11px] text-white/60">
|
||||
65%
|
||||
</span>
|
||||
</div>
|
||||
<For
|
||||
each={[
|
||||
{
|
||||
label: "Output",
|
||||
icon: "speaker",
|
||||
device: "MacBook Pro Speakers"
|
||||
},
|
||||
{
|
||||
label: "System sounds",
|
||||
icon: "bell",
|
||||
device: "MacBook Pro Speakers"
|
||||
},
|
||||
{
|
||||
label: "Microphone",
|
||||
icon: "mic",
|
||||
device: "MacBook Pro Microphone"
|
||||
}
|
||||
]}
|
||||
>
|
||||
{(route) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<Glyph name={route.icon} class="text-white/50" />
|
||||
<span class="flex-1 text-[12px] font-medium">
|
||||
{route.label}
|
||||
</span>
|
||||
<RoutePicker label={route.device} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="h-px bg-white/[0.06]" />
|
||||
<For each={MIXER_APPS}>
|
||||
{(app, i) => (
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[4px] text-[10px] text-white"
|
||||
style={{ background: app.color }}
|
||||
>
|
||||
{app.glyph}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] font-medium">
|
||||
{app.name}
|
||||
</span>
|
||||
<RoutePicker
|
||||
label={i() === 0 && step() >= 2 ? "Headphones" : "Default"}
|
||||
/>
|
||||
<span aria-label="Pin app" class="text-white/50">
|
||||
<Glyph name="pin" size={10} />
|
||||
</span>
|
||||
<span aria-label="Hide app" class="text-white/50">
|
||||
<Glyph name="eye" size={10} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-[26px]">
|
||||
<MixerSlider
|
||||
level={volume(i())}
|
||||
tint={app.color}
|
||||
muted={muted(i())}
|
||||
/>
|
||||
<span class="w-[34px] text-right font-mono text-[11px] text-white/60">
|
||||
{muted(i()) ? 0 : volume(i())}%
|
||||
</span>
|
||||
<Glyph
|
||||
name={muted(i()) ? "speakerOff" : "speaker"}
|
||||
size={14}
|
||||
style={{
|
||||
color: muted(i()) ? WARM : "rgba(255,255,255,0.5)"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
<p class="mt-3 min-h-8 text-center text-[11px] text-white/55">
|
||||
{
|
||||
[
|
||||
"Adjust each app separately",
|
||||
"Browser muted",
|
||||
"Music playing through headphones",
|
||||
"Music volume lowered to 38%"
|
||||
][step()]
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PowerNode(props: {
|
||||
label: string;
|
||||
glyph: GlyphName;
|
||||
watts: number;
|
||||
tint: string;
|
||||
}) {
|
||||
return (
|
||||
<div class="flex h-9 items-center gap-[7px]">
|
||||
<Glyph name={props.glyph} size={18} style={{ color: props.tint }} />
|
||||
<div>
|
||||
<p class="text-[9px] text-white/55">{props.label}</p>
|
||||
<p class="font-mono text-[11px] text-white">
|
||||
{props.watts.toFixed(1)} W
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** PowerPanelView: system draw, parallel power circuit, battery bar and controls. */
|
||||
export function BatteryDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
useDemoLoop(14000, [
|
||||
[0, () => setStep(0)],
|
||||
[1800, () => setStep(1)],
|
||||
[3600, () => setStep(2)],
|
||||
[6400, () => setStep(3)],
|
||||
[8400, () => setStep(4)],
|
||||
[10400, () => setStep(5)]
|
||||
]);
|
||||
const pct = () => [62, 71, 80, 80, 92, 100][step()];
|
||||
const topUp = () => step() >= 3;
|
||||
const held = () => step() === 2;
|
||||
const batteryWatts = () => (held() || pct() === 100 ? 0 : 26.4);
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Battery preview showing system draw, adapter power split between the Mac and battery, an 80 percent charge limit, and top-up mode"
|
||||
>
|
||||
<ModuleCard class="mx-auto w-full max-w-[340px]">
|
||||
<div class="space-y-3 text-left text-white">
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 font-mono text-[18px]">
|
||||
<span style={{ color: CALM }}>ϟ</span> 18.2 W
|
||||
</div>
|
||||
<p class="text-[9px] font-semibold text-white/55">System draw</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<PowerNode
|
||||
label="Adapter"
|
||||
glyph="plug"
|
||||
watts={18.2 + batteryWatts()}
|
||||
tint={CALM}
|
||||
/>
|
||||
<svg
|
||||
class="h-[82px] min-w-4 flex-1"
|
||||
viewBox="0 0 100 82"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M0 41H25C55 41 40 18 70 18H100"
|
||||
stroke={CALM}
|
||||
stroke-opacity="0.3"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0 41H25C55 41 40 64 70 64H100"
|
||||
stroke={BATTERY}
|
||||
stroke-opacity="0.3"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
class="nook-power-flow"
|
||||
d="M0 41H25C55 41 40 18 70 18H100"
|
||||
stroke={CALM}
|
||||
stroke-width="1.8"
|
||||
stroke-dasharray="3 18"
|
||||
/>
|
||||
<Show when={batteryWatts() > 0}>
|
||||
<path
|
||||
class="nook-power-flow"
|
||||
d="M0 41H25C55 41 40 64 70 64H100"
|
||||
stroke={BATTERY}
|
||||
stroke-width="1.8"
|
||||
stroke-dasharray="3 18"
|
||||
/>
|
||||
</Show>
|
||||
<path d="m96 15 4 3-4 3" stroke={CALM} stroke-width="1.5" />
|
||||
<path d="m96 61 4 3-4 3" stroke={BATTERY} stroke-width="1.5" />
|
||||
<Show when={held()}>
|
||||
<path
|
||||
d="m81 60 8 8m0-8-8 8"
|
||||
stroke={BATTERY}
|
||||
stroke-width="1.8"
|
||||
/>
|
||||
</Show>
|
||||
</svg>
|
||||
<div class="space-y-2.5">
|
||||
<PowerNode label="Mac" glyph="laptop" watts={18.2} tint={CALM} />
|
||||
<PowerNode
|
||||
label="Battery"
|
||||
glyph="battery"
|
||||
watts={batteryWatts()}
|
||||
tint={BATTERY}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="flex-1 text-[9px] font-semibold text-white/55 uppercase">
|
||||
Battery
|
||||
</span>
|
||||
<span class="font-mono text-[15px]">{pct()}%</span>
|
||||
<span class="ml-2 flex items-center gap-1 font-mono text-[10px] text-white/70">
|
||||
Limit{" "}
|
||||
<span class="rounded border border-white/15 bg-white/5 px-2 py-0.5 text-[12px] text-white/90">
|
||||
80
|
||||
</span>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative flex h-[18px] items-center">
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-white/[0.08]">
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-[1600ms]"
|
||||
style={{ width: `${pct()}%`, background: CALM }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="absolute h-3 w-1 rounded-sm bg-white/85"
|
||||
style={{ left: "calc(80% - 2px)" }}
|
||||
/>
|
||||
</div>
|
||||
<For
|
||||
each={[
|
||||
{
|
||||
label: "Top-up mode",
|
||||
note: "Charges past the limit; off on unplug",
|
||||
on: topUp()
|
||||
},
|
||||
{
|
||||
label: "Overheat protection",
|
||||
note: topUp()
|
||||
? "Suspended while Top-up mode is on"
|
||||
: "Pauses at 95°F",
|
||||
on: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
{(control) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[12px] font-medium">{control.label}</p>
|
||||
<p class="text-[10px] leading-snug text-white/55">
|
||||
{control.note}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="relative h-4 w-[30px] shrink-0 rounded-full transition-colors"
|
||||
style={{
|
||||
background: control.on ? CALM : "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 h-3 w-3 rounded-full bg-white transition-[left]"
|
||||
style={{ left: control.on ? "16px" : "2px" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
<p class="mt-3 min-h-8 text-center text-[11px] text-white/55">
|
||||
{held()
|
||||
? "Charging paused at the 80% limit"
|
||||
: topUp()
|
||||
? "Top-up mode allows a full charge"
|
||||
: "Charging with an 80% limit"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DAYS = [
|
||||
{ letter: "M", day: 9, dots: 1 },
|
||||
{ letter: "T", day: 10, dots: 2 },
|
||||
{ letter: "W", day: 11, dots: 1 },
|
||||
{ letter: "T", day: 12, dots: 2 },
|
||||
{ letter: "F", day: 13, dots: 3 },
|
||||
{ letter: "S", day: 14, dots: 0 },
|
||||
{ letter: "S", day: 15, dots: 0 }
|
||||
];
|
||||
const EVENTS = [
|
||||
{
|
||||
start: "09:30",
|
||||
end: "09:45",
|
||||
title: "Standup",
|
||||
calendar: "Work",
|
||||
color: CALENDAR,
|
||||
location: "Video call",
|
||||
meeting: true
|
||||
},
|
||||
{
|
||||
start: "15:30",
|
||||
end: "16:00",
|
||||
title: "Design review",
|
||||
calendar: "Work",
|
||||
color: CALENDAR,
|
||||
location: "4th floor",
|
||||
meeting: false
|
||||
}
|
||||
];
|
||||
|
||||
/** CalendarSlotFace's daymark / next-up / on-air states, and CalendarPanelView. */
|
||||
export function CalendarDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
useDemoLoop(16000, [
|
||||
[0, () => setStep(0)],
|
||||
[2400, () => setStep(1)],
|
||||
[4800, () => setStep(2)],
|
||||
[9000, () => setStep(3)],
|
||||
[12000, () => setStep(4)]
|
||||
]);
|
||||
const onAir = () => step() >= 3;
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Calendar preview showing the date, a meeting countdown, an on-air indicator, and the day's events with a join meeting button"
|
||||
class="flex min-h-[360px] min-w-0 flex-col items-center gap-4"
|
||||
>
|
||||
<div class="flex max-w-full justify-center" style={{ zoom: 0.8 }}>
|
||||
<IslandPill
|
||||
right={
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<Show when={step() === 0}>
|
||||
<rect
|
||||
x="6"
|
||||
y="4"
|
||||
width="28"
|
||||
height="27"
|
||||
rx="5"
|
||||
fill="#ededed"
|
||||
/>
|
||||
<path
|
||||
d="M11 4h18a5 5 0 0 1 5 5v3H6V9a5 5 0 0 1 5-5"
|
||||
fill={CALENDAR}
|
||||
/>
|
||||
<text
|
||||
x="20"
|
||||
y="10"
|
||||
text-anchor="middle"
|
||||
font-size="5.5"
|
||||
font-weight="700"
|
||||
fill="#1f1f1f"
|
||||
>
|
||||
THU
|
||||
</text>
|
||||
<text
|
||||
x="20"
|
||||
y="28"
|
||||
text-anchor="middle"
|
||||
font-size="17"
|
||||
font-weight="700"
|
||||
fill="#1f1f1f"
|
||||
>
|
||||
12
|
||||
</text>
|
||||
<circle cx="20" cy="36" r="2.25" fill={CALENDAR} />
|
||||
</Show>
|
||||
<Show when={step() === 1 || step() === 2}>
|
||||
<text
|
||||
x="20"
|
||||
y="10"
|
||||
text-anchor="middle"
|
||||
font-size="7"
|
||||
font-weight="700"
|
||||
fill="white"
|
||||
opacity="0.65"
|
||||
>
|
||||
IN
|
||||
</text>
|
||||
<text
|
||||
x="20"
|
||||
y="30"
|
||||
text-anchor="middle"
|
||||
font-size="20"
|
||||
font-weight="700"
|
||||
fill="white"
|
||||
>
|
||||
{step() === 1 ? "14m" : "5m"}
|
||||
</text>
|
||||
<rect
|
||||
x="6"
|
||||
y="34"
|
||||
width="28"
|
||||
height="2"
|
||||
rx="1"
|
||||
fill="white"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<rect
|
||||
x="6"
|
||||
y="34"
|
||||
width={step() === 1 ? 2 : 19}
|
||||
height="2"
|
||||
rx="1"
|
||||
fill={CALENDAR}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={onAir()}>
|
||||
<rect
|
||||
x="1"
|
||||
y="3"
|
||||
width="38"
|
||||
height="22"
|
||||
rx="5"
|
||||
fill={CALENDAR}
|
||||
opacity="0.18"
|
||||
/>
|
||||
<circle cx="4.7" cy="14.5" r="2.1" fill={CALENDAR} />
|
||||
<text
|
||||
x="22.6"
|
||||
y="17"
|
||||
text-anchor="middle"
|
||||
font-size="7.8"
|
||||
font-weight="700"
|
||||
fill={CALENDAR}
|
||||
>
|
||||
ON AIR
|
||||
</text>
|
||||
<rect
|
||||
x="1"
|
||||
y="31"
|
||||
width="38"
|
||||
height="5"
|
||||
rx="2.5"
|
||||
fill={CALENDAR}
|
||||
opacity="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="1"
|
||||
y="31"
|
||||
width={step() === 4 ? 25 : 8}
|
||||
height="5"
|
||||
rx="2.5"
|
||||
fill="#333"
|
||||
/>
|
||||
<rect
|
||||
x={step() === 4 ? 25 : 8}
|
||||
y="29"
|
||||
width="2.6"
|
||||
height="9"
|
||||
rx="1.3"
|
||||
fill="#ededed"
|
||||
/>
|
||||
</Show>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full max-w-[360px]">
|
||||
<ModuleCard>
|
||||
<div class="space-y-2.5 text-left text-white">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-[10px] text-white/55">Calendar</p>
|
||||
<p class="text-[14px] font-semibold">Thu, Nov 12</p>
|
||||
</div>
|
||||
<span class="ml-auto rounded-lg bg-white/[0.08] p-[7px] text-white/55">
|
||||
<Glyph name="plus" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-[60px] gap-[3px]">
|
||||
<For each={DAYS}>
|
||||
{(day) => (
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col items-center justify-center gap-[5px] rounded-[10px]"
|
||||
style={{
|
||||
background: day.day === 12 ? CALENDAR : "transparent",
|
||||
color: day.day === 12 ? "#0d0d0f" : "white",
|
||||
opacity: day.day < 12 ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
<span class="text-[9px] opacity-60">{day.letter}</span>
|
||||
<span
|
||||
class="text-[13px]"
|
||||
style={{ "font-weight": day.day === 12 ? 700 : 500 }}
|
||||
>
|
||||
{day.day}
|
||||
</span>
|
||||
<span class="flex h-1 gap-0.5">
|
||||
<For each={Array.from({ length: day.dots })}>
|
||||
{() => (
|
||||
<span
|
||||
class="h-1 w-1 rounded-full"
|
||||
style={{ background: CALENDAR }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<For each={EVENTS}>
|
||||
{(event) => (
|
||||
<div class="flex items-center gap-2.5 rounded-[10px] bg-white/[0.035] p-2.5">
|
||||
<div class="w-[40px] shrink-0 text-right font-mono text-[10px]">
|
||||
<p>{event.start}</p>
|
||||
<p class="mt-[3px] text-white/55">{event.end}</p>
|
||||
</div>
|
||||
<span
|
||||
class="w-[3px] self-stretch rounded-sm"
|
||||
style={{ background: event.color }}
|
||||
/>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<p class="text-[12px] leading-snug font-semibold">
|
||||
{event.title}
|
||||
</p>
|
||||
<p
|
||||
class="text-[10px] font-medium"
|
||||
style={{ color: event.color }}
|
||||
>
|
||||
{event.calendar}
|
||||
</p>
|
||||
<p class="text-[10px] text-white/55">{event.location}</p>
|
||||
</div>
|
||||
<Show when={event.meeting}>
|
||||
<span
|
||||
aria-label="Join meeting"
|
||||
class="rounded-lg bg-white/[0.08] p-2 text-white/65"
|
||||
>
|
||||
<Glyph name="video" />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
</div>
|
||||
<p class="min-h-4 text-center text-[11px] text-white/55">
|
||||
{step() === 0
|
||||
? "Today's date and calendar events"
|
||||
: onAir()
|
||||
? "Meeting in progress"
|
||||
: "Countdown to your next meeting"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -160,6 +160,8 @@ export interface VisitorAnalytics {
|
||||
ttfb?: number | null;
|
||||
dom_load?: number | null;
|
||||
load_complete?: number | null;
|
||||
stage?: string | null;
|
||||
host?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
22
src/env/server.ts
vendored
22
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),
|
||||
@@ -80,6 +69,12 @@ const serverEnvSchema = z.object({
|
||||
// Bearer secret for the daily lifecycle-email cron. Falls back to
|
||||
// Vercel's CRON_SECRET; unset + no CRON_SECRET = the endpoint denies all.
|
||||
NOOK_LIFECYCLE_SECRET: z.string().min(1).optional(),
|
||||
// Daily VisitorAnalytics retention cron (/api/analytics-retention). Same
|
||||
// shape: falls back to CRON_SECRET, and unset denies every request.
|
||||
ANALYTICS_RETENTION_SECRET: z.string().min(1).optional(),
|
||||
// How long a VisitorAnalytics row lives. Unset (or unusable) = 365 days;
|
||||
// see src/server/analytics-retention.ts before shortening it.
|
||||
ANALYTICS_RETENTION_DAYS: z.string().optional(),
|
||||
CRON_SECRET: z.string().min(1).optional()
|
||||
});
|
||||
|
||||
@@ -183,11 +178,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: {
|
||||
|
||||
@@ -67,7 +67,8 @@ describe("generateSitemap", () => {
|
||||
expect(locs).toContain("https://nessa.freno.me/");
|
||||
expect(locs).toContain("https://nessa.freno.me/contact");
|
||||
expect(locs).toContain("https://nessa.freno.me/privacy");
|
||||
expect(locs.length).toBe(3);
|
||||
expect(locs).toContain("https://nessa.freno.me/terms");
|
||||
expect(locs.length).toBe(4);
|
||||
|
||||
for (const loc of locs) {
|
||||
expect(loc).not.toContain("://freno.me/");
|
||||
|
||||
@@ -57,7 +57,8 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
||||
nessa: [
|
||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 },
|
||||
{ path: "/terms", changefreq: "yearly", priority: 0.4 }
|
||||
],
|
||||
|
||||
lineage: [
|
||||
|
||||
24
src/routes/agents.tsx
Normal file
24
src/routes/agents.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Switch, Match } from "solid-js";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NotFound from "./[...404]";
|
||||
import NookAgentsPage from "./nook/agents";
|
||||
|
||||
/**
|
||||
* Host-aware agents route — `/agents`.
|
||||
*
|
||||
* The supported-agents page is a Nook-subdomain surface; all other sites
|
||||
* fall back to 404. Mirrors the dispatch pattern in `src/routes/index.tsx`
|
||||
* and `src/routes/checkout.tsx` — and like them, this is what makes the
|
||||
* page render at all: production does not apply the vercel.json host
|
||||
* rewrites (SSR sees `/agents`, never `/nook/agents`).
|
||||
*/
|
||||
export default function AgentsPage() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Switch fallback={<NotFound />}>
|
||||
<Match when={site().id === "nook"}>
|
||||
<NookAgentsPage />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
35
src/routes/api/analytics-retention.ts
Normal file
35
src/routes/api/analytics-retention.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { env } from "~/env/server";
|
||||
import { ConnectionFactory } from "~/server/db-connections";
|
||||
import { runRetentionCron } from "~/server/analytics-retention";
|
||||
|
||||
/**
|
||||
* GET /api/analytics-retention — daily Vercel cron.
|
||||
*
|
||||
* Deletes `VisitorAnalytics` rows older than the retention window, so visitor
|
||||
* data has a bound instead of accumulating forever (FRE-369, finding Q4 — the
|
||||
* table's only cleaner used to be a manual admin call, and since FRE-366 the
|
||||
* rows carry a truncated but still real client address).
|
||||
*
|
||||
* Window: `ANALYTICS_RETENTION_DAYS`, defaulting to 365 when unset or unusable
|
||||
* (see `~/server/analytics-retention` for why the default is conservative
|
||||
* rather than the reviewer's 90).
|
||||
*
|
||||
* Auth: `Authorization: Bearer <secret>` where the secret is
|
||||
* `ANALYTICS_RETENTION_SECRET` or Vercel's `CRON_SECRET`. An unconfigured secret
|
||||
* denies every request — an unauthenticated trigger of a bulk delete is the one
|
||||
* outcome this endpoint must never have.
|
||||
*/
|
||||
export async function GET(event: APIEvent) {
|
||||
const result = await runRetentionCron({
|
||||
authorization: event.request.headers.get("authorization"),
|
||||
secret: env.ANALYTICS_RETENTION_SECRET ?? env.CRON_SECRET,
|
||||
configuredDays: env.ANALYTICS_RETENTION_DAYS,
|
||||
conn: ConnectionFactory()
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(result.body), {
|
||||
status: result.status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
@@ -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]!, "served");
|
||||
|
||||
// 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}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
8
src/routes/api/lineage/pvp/purchase.ts
Normal file
8
src/routes/api/lineage/pvp/purchase.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { rest } from "../_lib";
|
||||
|
||||
// POST /api/lineage/pvp/purchase — server-authoritative Blood Token purchase
|
||||
export const POST = (event: APIEvent) => rest(async (caller) => {
|
||||
const input = await event.request.json();
|
||||
return caller.lineage.pvp.purchase(input);
|
||||
}, event);
|
||||
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);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { env } from "~/env/server";
|
||||
import { TURNSTILE_CONFIG } from "~/config";
|
||||
import { verifyTurnstileToken } from "~/server/fetch-utils";
|
||||
import { json, error } from "./_lib";
|
||||
import { trackRequest } from "~/server/track-download";
|
||||
|
||||
/**
|
||||
* POST /api/the-nook/checkout
|
||||
@@ -12,6 +13,14 @@ import { json, error } from "./_lib";
|
||||
* session for the one-time $10 The Nook license. Returns the hosted
|
||||
* checkout URL for the client to redirect to.
|
||||
*
|
||||
* Analytics: a created session is the funnel's "checkout started" stage
|
||||
* (FRE-366). Until then the only trace was the client-side /checkout page
|
||||
* view — a page, not a started checkout. Recorded through the shared
|
||||
* `trackRequest` writer (referrer, campaign tags, country, real client
|
||||
* address, host) under this route's own path, best-effort: a failed
|
||||
* analytics write never blocks a checkout, and an attempt Turnstile or
|
||||
* Stripe rejects is correctly not a started checkout.
|
||||
*
|
||||
* Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no
|
||||
* `stripe` npm dependency added.
|
||||
*/
|
||||
@@ -67,5 +76,14 @@ export async function POST(event: APIEvent) {
|
||||
if (!data.url) {
|
||||
return error("Stripe returned no checkout URL", 502);
|
||||
}
|
||||
|
||||
// The session exists: that is "checkout started". Awaited so the row is
|
||||
// flushed before the response — a buffered write is not guaranteed to
|
||||
// survive the response on serverless.
|
||||
await trackRequest(event, {
|
||||
path: "/api/the-nook/checkout",
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
return json({ checkoutUrl: data.url });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
import { truncateIpAddress } from "~/server/request-client";
|
||||
|
||||
// The route's contracts:
|
||||
// 1. Every click is recorded through the shared analytics buffer with the
|
||||
@@ -55,9 +56,13 @@ mock.module("~/server/analytics", () => ({
|
||||
flushAnalytics: async () => {
|
||||
flushCalls += 1;
|
||||
},
|
||||
// Mirrors the real enrichment contract: deviceType is derived from the UA.
|
||||
// Mirrors the real enrichment contract: deviceType is derived from the UA,
|
||||
// and the address is cut to its prefix by the FRE-369 privacy floor. The
|
||||
// real `enrichAnalyticsEntry` does both, so the mirror has to apply the same
|
||||
// truncation — otherwise this file would prove the door stores a full address.
|
||||
enrichAnalyticsEntry: (entry: Entry) => ({
|
||||
...entry,
|
||||
ipAddress: truncateIpAddress(entry.ipAddress as string | null),
|
||||
deviceType: String(entry.userAgent ?? "").includes("Macintosh")
|
||||
? "desktop"
|
||||
: null
|
||||
@@ -111,8 +116,12 @@ 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",
|
||||
// The door is the click half of the split; the file route writes
|
||||
// "served". And the row names the site it came from (FRE-366).
|
||||
stage: "click",
|
||||
host: "nook.freno.me",
|
||||
referrer: "https://nook.freno.me/"
|
||||
});
|
||||
// Flushed within the request, not left to the batch timer.
|
||||
@@ -137,9 +146,16 @@ describe("GET /api/the-nook/download", () => {
|
||||
expect(logged[2]!.country).toBe("JP");
|
||||
});
|
||||
|
||||
it("falls back to the forwarded address when no request IP is available", async () => {
|
||||
await GET(request({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" }));
|
||||
expect(logged[0]!.ipAddress).toBe("198.51.100.9");
|
||||
it("records the forwarded visitor below the trusted edge, truncated", async () => {
|
||||
// Client → Cloudflare → origin proxy: the sender's spoof is first, the real
|
||||
// client second, the edge last. And the stored value is the /24, never the
|
||||
// address itself (FRE-369).
|
||||
await GET(
|
||||
request({
|
||||
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
|
||||
})
|
||||
);
|
||||
expect(logged[0]!.ipAddress).toBe("203.0.113.0");
|
||||
});
|
||||
|
||||
it("still logs the click when the appcast cannot be read", async () => {
|
||||
|
||||
@@ -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,13 @@ 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` with `stage = "click"`,
|
||||
* distinct from the `stage = "served"` row the file route writes when the
|
||||
* DMG is actually streamed — the split FRE-366 added, alongside the clicks
|
||||
* and served files of every other product — see `~/server/track-download`.
|
||||
*
|
||||
* 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 +27,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", "click");
|
||||
|
||||
let target: string | null = null;
|
||||
try {
|
||||
|
||||
@@ -19,11 +19,15 @@ let trialRows: Record<string, unknown>[] = [];
|
||||
let licenseRows: Record<string, unknown>[] = [];
|
||||
const updates: { sql: string; args?: unknown[] }[] = [];
|
||||
const sent: string[] = [];
|
||||
// When true, conditional-claim UPDATEs report rowsAffected 0 (another
|
||||
// invocation won the claim first).
|
||||
let racingClaim = false;
|
||||
|
||||
const fakeConn = {
|
||||
execute: async (q: { sql: string; args?: unknown[] } | string) => {
|
||||
const { sql, args } =
|
||||
const { sql: rawSql, args } =
|
||||
typeof q === "string" ? { sql: q, args: undefined } : q;
|
||||
const sql = rawSql.trim();
|
||||
if (sql.startsWith("SELECT fingerprint")) {
|
||||
return { rows: trialRows, rowsAffected: 0 };
|
||||
}
|
||||
@@ -37,7 +41,8 @@ const fakeConn = {
|
||||
}
|
||||
if (sql.startsWith("UPDATE")) {
|
||||
updates.push({ sql, args });
|
||||
return { rows: [], rowsAffected: 1 };
|
||||
const claimed = racingClaim ? 0 : 1;
|
||||
return { rows: [], rowsAffected: claimed };
|
||||
}
|
||||
return { rows: [], rowsAffected: 0 };
|
||||
}
|
||||
@@ -154,6 +159,21 @@ describe("GET /api/the-nook/lifecycle", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("claims via conditional update — a lost claim never sends", async () => {
|
||||
// Simulate a racing invocation that already claimed the row: the
|
||||
// conditional UPDATE reports rowsAffected 0, and no email goes out.
|
||||
trialRows = [
|
||||
{ fingerprint: UUID, email: "raced@example.com", started_at: daysAgo(12) }
|
||||
];
|
||||
licenseRows = [];
|
||||
racingClaim = true;
|
||||
const res = await GET(request("Bearer sekrit"));
|
||||
const body = await res.json();
|
||||
expect(body.reminders).toBe(0);
|
||||
expect(sent).toHaveLength(0);
|
||||
racingClaim = false;
|
||||
});
|
||||
|
||||
it("asks for a testimonial at D+3 but not a review until D+14", async () => {
|
||||
licenseRows = [
|
||||
{
|
||||
|
||||
@@ -53,10 +53,18 @@ async function runLifecycle(): Promise<{
|
||||
started_at: string;
|
||||
}[]) {
|
||||
if (ageDays(row.started_at) < 11) continue;
|
||||
await conn.execute({
|
||||
sql: "UPDATE trials SET reminder_sent_at = ? WHERE fingerprint = ?",
|
||||
// Claim-then-send: the conditional UPDATE wins exactly once even when
|
||||
// two invocations race (cron + manual hit) — the loser sees
|
||||
// rowsAffected 0 and never sends. An unconditional mark-then-send let
|
||||
// both invocations pass the SELECT and mail the same user twice.
|
||||
const claim = await conn.execute({
|
||||
sql: `
|
||||
UPDATE trials SET reminder_sent_at = ?
|
||||
WHERE fingerprint = ? AND reminder_sent_at IS NULL
|
||||
`,
|
||||
args: [new Date().toISOString(), row.fingerprint]
|
||||
});
|
||||
if (claim.rowsAffected === 0) continue;
|
||||
// A trial that ended in a purchase gets no buy reminder. The address
|
||||
// comparison is on the raw stored values; both writes lowercase.
|
||||
const owned = await conn.execute({
|
||||
@@ -83,18 +91,26 @@ async function runLifecycle(): Promise<{
|
||||
if (age < 0) continue;
|
||||
|
||||
if (age >= 3 && !row.testimonial_asked_at) {
|
||||
await conn.execute({
|
||||
sql: "UPDATE licenses SET testimonial_asked_at = ? WHERE id = ?",
|
||||
const claim = await conn.execute({
|
||||
sql: `
|
||||
UPDATE licenses SET testimonial_asked_at = ?
|
||||
WHERE id = ? AND testimonial_asked_at IS NULL
|
||||
`,
|
||||
args: [new Date().toISOString(), row.id]
|
||||
});
|
||||
if (claim.rowsAffected === 0) continue;
|
||||
if (await emailTestimonialAsk(row.email)) counts.testimonials++;
|
||||
}
|
||||
|
||||
if (age >= 14 && !row.review_asked_at) {
|
||||
await conn.execute({
|
||||
sql: "UPDATE licenses SET review_asked_at = ? WHERE id = ?",
|
||||
const claim = await conn.execute({
|
||||
sql: `
|
||||
UPDATE licenses SET review_asked_at = ?
|
||||
WHERE id = ? AND review_asked_at IS NULL
|
||||
`,
|
||||
args: [new Date().toISOString(), row.id]
|
||||
});
|
||||
if (claim.rowsAffected === 0) continue;
|
||||
if (await emailReviewAsk(row.email)) counts.reviews++;
|
||||
}
|
||||
}
|
||||
|
||||
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,10 +5,13 @@ 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 }[] = [];
|
||||
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
||||
/** Every rate-limit key the route derived, in order. */
|
||||
const rateKeys: string[] = [];
|
||||
|
||||
mock.module("~/server/email", () => ({
|
||||
default: async (to: string, subject: string, html: string) => {
|
||||
@@ -18,10 +21,16 @@ mock.module("~/server/email", () => ({
|
||||
}));
|
||||
|
||||
mock.module("~/server/bug-report-rate-limit", () => ({
|
||||
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true }
|
||||
takeBugReportToken: (key: string) => {
|
||||
rateKeys.push(key);
|
||||
return tokenAnswers.shift() ?? { allowed: true };
|
||||
}
|
||||
}));
|
||||
|
||||
const { POST } = await import("./report");
|
||||
const { betaRequestPayload } = await import(
|
||||
"~/components/nook/betaRequest"
|
||||
);
|
||||
|
||||
/** A minimal APIEvent double: JSON body plus optional headers. */
|
||||
function request(body: unknown, headers: Record<string, string> = {}) {
|
||||
@@ -68,6 +77,7 @@ describe("POST /api/the-nook/report", () => {
|
||||
beforeEach(() => {
|
||||
sentEmails.length = 0;
|
||||
tokenAnswers.length = 0;
|
||||
rateKeys.length = 0;
|
||||
});
|
||||
|
||||
it("emails the owner a report containing the snapshot, escaped", async () => {
|
||||
@@ -129,9 +139,61 @@ describe("POST /api/the-nook/report", () => {
|
||||
|
||||
it("still succeeds when no fingerprint header exists", async () => {
|
||||
const res = await POST(
|
||||
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" })
|
||||
request(validPayload, {
|
||||
"cf-connecting-ip": "203.0.113.9",
|
||||
"x-forwarded-for": "203.0.113.9, 10.0.0.1"
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(sentEmails).toHaveLength(1);
|
||||
// The quota key is the Cloudflare-recorded client, not a header hop the
|
||||
// sender chose.
|
||||
expect(rateKeys).toEqual(["203.0.113.9"]);
|
||||
});
|
||||
|
||||
it("a rotating leftmost x-forwarded-for does not mint a fresh quota key", async () => {
|
||||
// FRE-371: the old read keyed on the leftmost hop, which the sender
|
||||
// controls outright — one scripted loop with a rotating header had
|
||||
// unlimited reports. With the same Cloudflare-recorded client and a
|
||||
// different spoofed prefix per request, the key must be stable.
|
||||
for (const spoof of ["1.1.1.1", "2.2.2.2", "3.3.3.3"]) {
|
||||
const res = await POST(
|
||||
request(validPayload, {
|
||||
"cf-connecting-ip": "203.0.113.9",
|
||||
"x-forwarded-for": `${spoof}, 203.0.113.9, 10.0.0.1`
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
expect(rateKeys).toEqual([
|
||||
"203.0.113.9",
|
||||
"203.0.113.9",
|
||||
"203.0.113.9"
|
||||
]);
|
||||
});
|
||||
|
||||
it("keys on the rightmost-minus-one hop when Cloudflare's header is absent", async () => {
|
||||
const res = await POST(
|
||||
request(validPayload, {
|
||||
"x-forwarded-for": "6.6.6.6, 203.0.113.9, 10.0.0.1"
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// Never the leftmost (sender-chosen) hop.
|
||||
expect(rateKeys).toEqual(["203.0.113.9"]);
|
||||
});
|
||||
|
||||
it("delivers the landing page's beta request with only the email", async () => {
|
||||
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>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import sendEmail from "~/server/email";
|
||||
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
||||
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
||||
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||
import { json, error } from "./_lib";
|
||||
|
||||
/**
|
||||
@@ -103,10 +104,9 @@ export async function POST(event: APIEvent): Promise<Response> {
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const forwarded = event.request.headers.get("x-forwarded-for");
|
||||
const rateKey =
|
||||
fingerprint ||
|
||||
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
|
||||
// FRE-371: the IP fallback must be the spoof-resistant read — a rotating
|
||||
// leftmost x-forwarded-for used to mint unlimited report quota.
|
||||
const rateKey = fingerprint || resolveTrustedClientIp(event);
|
||||
const token = takeBugReportToken(rateKey);
|
||||
if (!token.allowed) {
|
||||
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
||||
|
||||
23
src/routes/changelog.tsx
Normal file
23
src/routes/changelog.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Switch, Match } from "solid-js";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NotFound from "./[...404]";
|
||||
import NookChangelogPage from "./nook/changelog";
|
||||
|
||||
/**
|
||||
* Host-aware changelog route — `/changelog`.
|
||||
*
|
||||
* The release-notes page is a Nook-subdomain surface; all other sites fall
|
||||
* back to 404. Mirrors the dispatch pattern in `src/routes/index.tsx` and
|
||||
* `src/routes/checkout.tsx` — production does not apply the vercel.json
|
||||
* host rewrites (SSR sees `/changelog`, never `/nook/changelog`).
|
||||
*/
|
||||
export default function ChangelogPage() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Switch fallback={<NotFound />}>
|
||||
<Match when={site().id === "nook"}>
|
||||
<NookChangelogPage />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
23
src/routes/faq.tsx
Normal file
23
src/routes/faq.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Switch, Match } from "solid-js";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NotFound from "./[...404]";
|
||||
import NookFaqPage from "./nook/faq";
|
||||
|
||||
/**
|
||||
* Host-aware FAQ route — `/faq`.
|
||||
*
|
||||
* The FAQ is a Nook-subdomain surface; all other sites fall back to 404.
|
||||
* Mirrors the dispatch pattern in `src/routes/index.tsx` and
|
||||
* `src/routes/checkout.tsx` — production does not apply the vercel.json
|
||||
* host rewrites (SSR sees `/faq`, never `/nook/faq`).
|
||||
*/
|
||||
export default function FaqPage() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Switch fallback={<NotFound />}>
|
||||
<Match when={site().id === "nook"}>
|
||||
<NookFaqPage />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ContactForm } from "~/components/ContactForm";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
/**
|
||||
* Nessa contact page (`nessa.freno.me/contact`).
|
||||
@@ -16,14 +17,27 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
*
|
||||
* Acceptance: `nessa.localhost:3000/contact` renders the contact form with
|
||||
* Nessa branding; submissions email `michael@freno.me` with subject
|
||||
* `[Nessa] Contact Request`.
|
||||
* `[Nessa] Contact Request`. The support address shown by the app is also
|
||||
* published here, so the contact route does not depend on the form alone
|
||||
* (FRE-1670).
|
||||
*/
|
||||
export default function NessaContactPage() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||
<SubdomainHeader />
|
||||
<ContactForm />
|
||||
<ContactForm>
|
||||
<p class="mt-8 text-center text-sm opacity-80">
|
||||
You can also email us directly at{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</ContactForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@ export const TAGLINE = "The fitness app that puts you first." as const;
|
||||
export const SUBTITLE =
|
||||
"Track, train, and connect — without the paywall." as const;
|
||||
|
||||
/**
|
||||
* The support address the Nessa app shows (`SubscriptionView` /
|
||||
* `HelpSupportView`). Every published Nessa document that offers a contact
|
||||
* route names this same address, so a user who leaves the app to read the
|
||||
* terms, privacy policy, contact page, or deletion page can still reach us
|
||||
* (FRE-1670). Keep in step with `nessa-support@freno.me` in the app.
|
||||
*/
|
||||
export const SUPPORT_EMAIL = "nessa-support@freno.me" as const;
|
||||
|
||||
export const ICON_DEFAULT =
|
||||
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
|
||||
export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
DELETION_GRACE_PERIOD_LABEL,
|
||||
PAGE_META
|
||||
} from "~/routes/nessa/deletion-content";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
export default function NessaDeletionPage() {
|
||||
return (
|
||||
@@ -54,6 +55,18 @@ export default function NessaDeletionPage() {
|
||||
removed. No data related to the account is retained in any way.
|
||||
</div>
|
||||
|
||||
<div class="text-text mt-4 w-full justify-center">
|
||||
Prefer email? Send your deletion request from the address on your
|
||||
Nessa account to{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}?subject=Account%20Deletion%20Request`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
; the same {DELETION_GRACE_PERIOD_LABEL} grace period applies.
|
||||
</div>
|
||||
|
||||
<DeletionForm
|
||||
product={DELETION_PRODUCT_KEY}
|
||||
cookieName={DELETION_COOKIE_NAME}
|
||||
|
||||
@@ -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
|
||||
@@ -31,6 +30,7 @@ import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
export default function NessaPrivacyPolicy() {
|
||||
return (
|
||||
@@ -175,15 +175,29 @@ export default function NessaPrivacyPolicy() {
|
||||
<span class="-ml-4 pr-2">5.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
<div class="-ml-6">(a) Reaching Out:</div> If there are any
|
||||
questions or comments regarding this privacy policy, you can
|
||||
<div class="-ml-6">(a) Data Controller:</div> Nessa is operated by
|
||||
FrenoCorp. For the purposes of applicable data-protection law,
|
||||
FrenoCorp is the controller of the personal information described
|
||||
in this policy.
|
||||
<div class="pt-2 pb-2">
|
||||
<div class="-ml-6">(b) Reaching Out:</div> If there are any
|
||||
questions or comments regarding this privacy policy, or you want
|
||||
to access, correct, or delete your personal information, you can
|
||||
contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
here
|
||||
</A>
|
||||
through the contact form
|
||||
</A>{" "}
|
||||
or email{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
|
||||
36
src/routes/nessa/support-contact.test.ts
Normal file
36
src/routes/nessa/support-contact.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Regression guard for the published Nessa support address (FRE-1670).
|
||||
*
|
||||
* The app tells users to mail `nessa-support@freno.me` from the subscription
|
||||
* screen and Help & Support. Before this guard, no published Nessa page named
|
||||
* that address: a user who left the app to read the terms, privacy policy,
|
||||
* contact page, or deletion page found no way to reach support. That is a
|
||||
* privacy-policy contact gap (GDPR/CCPA-style) and an App Review question
|
||||
* waiting to happen.
|
||||
*
|
||||
* The address lives in one place (`SUPPORT_EMAIL`), and every page that
|
||||
* offers a contact route must render it. Reading the route sources is
|
||||
* deliberate: the defect is a page that exists but does not mention the
|
||||
* address, which no runtime assertion over a single page can observe.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readFileSync } from "fs";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
const CONTACT_ROUTE_FILES = [
|
||||
"src/routes/nessa/terms.tsx",
|
||||
"src/routes/nessa/privacy.tsx",
|
||||
"src/routes/nessa/contact.tsx",
|
||||
"src/routes/nessa/deletion.tsx"
|
||||
];
|
||||
|
||||
describe("Nessa published support address", () => {
|
||||
it("is the address the app shows", () => {
|
||||
expect(SUPPORT_EMAIL).toBe("nessa-support@freno.me");
|
||||
});
|
||||
|
||||
it.each(CONTACT_ROUTE_FILES)("%s renders it", (file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
expect(source).toContain("SUPPORT_EMAIL");
|
||||
});
|
||||
});
|
||||
454
src/routes/nessa/terms.tsx
Normal file
454
src/routes/nessa/terms.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Nessa terms of use — `nessa.freno.me/terms`.
|
||||
*
|
||||
* The document behind the `Terms of Service` row in the Nessa app
|
||||
* (`Nessa/Models/AppSettings.swift`, Settings › Legal). Before this page the
|
||||
* row had no Nessa-owned document to resolve to: the app linked another
|
||||
* product's terms, and `nessa.freno.me/terms` was a 404 (FRE-194).
|
||||
*
|
||||
* Nessa sells an auto-renewing subscription through the App Store, so App
|
||||
* Review 3.1.2 requires the app to link a privacy policy **and** terms of
|
||||
* use. Section 5 (subscriptions) and section 6 (Apple acknowledgements) are
|
||||
* the clauses that requirement is read against, so keep them when editing.
|
||||
*
|
||||
* House voice follows `privacy.tsx`: "we" / "Nessa", no legal entity name,
|
||||
* and no governing-law clause — the Nessa pages have never stated either, and
|
||||
* naming an entity or a jurisdiction is a decision to make deliberately, not
|
||||
* to introduce as a side effect of a link fix.
|
||||
*
|
||||
* PageHead is site-aware: only the base title is supplied; the ` | Nessa`
|
||||
* suffix, the `https://nessa.freno.me/terms` canonical and the OG image are
|
||||
* derived automatically. Internal links use public subdomain-relative paths
|
||||
* (`/privacy`, `/deletion`, `/contact`) per nav-config.ts and
|
||||
* page-head-meta.ts.
|
||||
*/
|
||||
import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
export default function NessaTermsOfUse() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Terms of Use"
|
||||
description="Terms of use for Nessa, a fitness app for tracking workouts and training with clubs, challenges, and friends."
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||
<div class="py-4 text-xl">Nessa's Terms of Use</div>
|
||||
<div class="py-2">Last Updated: November 9, 2026</div>
|
||||
<div class="py-2">
|
||||
Welcome to Nessa ('We', 'Us', 'Our').
|
||||
These terms of use govern your access to and use of the Nessa app and
|
||||
its related services. By using Nessa you agree to these terms. Our{" "}
|
||||
<A href="/privacy" class="text-blue hover-underline-animation">
|
||||
privacy policy
|
||||
</A>{" "}
|
||||
explains how we handle your data, and it forms part of these terms.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">1.</span> Acceptance of These Terms
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Agreement:</div> By creating an account,
|
||||
downloading, or otherwise using Nessa you agree to these terms.
|
||||
If you do not agree to them, do not use Nessa.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Eligibility:</div> You must be at least
|
||||
the age of digital consent in your country to use Nessa. If you
|
||||
are using Nessa with a parent's or guardian's
|
||||
account, they are responsible for your compliance with these
|
||||
terms.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Where these terms apply:</div> These
|
||||
terms are between you and us. Where you obtained Nessa through
|
||||
a third party such as the Apple App Store, that
|
||||
party's own terms apply to your relationship with them as
|
||||
well.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">2.</span> The Service
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) What Nessa is:</div> Nessa is a fitness
|
||||
application for iOS, with an Apple Watch companion, that lets
|
||||
you record workouts and routes, review your training history,
|
||||
and take part in community features — clubs, club challenges,
|
||||
posts, comments, and leaderboards. Some features are free and
|
||||
some require a paid subscription (see section 5).
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Changes and availability:</div> We are
|
||||
continually improving Nessa, so features may change, move
|
||||
between tiers, or be withdrawn. We do not guarantee that Nessa
|
||||
will be available at all times, and we may suspend or discontinue
|
||||
part or all of the service. Where a change materially affects a
|
||||
paid feature you have subscribed to, we will tell you on the
|
||||
subscription management page or by contact.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Not medical advice:</div> Nessa is not a
|
||||
medical device and is not intended to diagnose, treat, cure, or
|
||||
prevent any condition. Workout data, heart-rate and other
|
||||
health readings, training plans, and estimates you see in Nessa
|
||||
are for general information and motivation only. Consult a
|
||||
qualified professional before starting or changing a training
|
||||
programme, especially if you have a health condition. Never
|
||||
ignore professional advice, or delay seeking it, because of
|
||||
something you read in Nessa.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Your surroundings:</div> You are
|
||||
responsible for your own safety while using Nessa, including
|
||||
when recording an activity. Follow local laws and do not use
|
||||
Nessa in a way that distracts you from driving or other
|
||||
activities that require your full attention.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">3.</span> Your Account
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Sign-in:</div> Nessa accounts are
|
||||
authenticated through Clerk, our identity provider. You are
|
||||
responsible for keeping the credentials you use to sign in
|
||||
secure, and you are responsible for the activity that happens
|
||||
under your account.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Notify us:</div> Tell us promptly, using
|
||||
the contact details in section 11, if you believe someone else
|
||||
has used or may use your account without your permission.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) One account:</div> Keep to one account,
|
||||
and give us accurate information so we can keep your training
|
||||
history and your place in the community correct. Do not create
|
||||
accounts to evade a moderation decision or to gain a fair
|
||||
advantage in challenges or leaderboards.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">4.</span> Community Content and Acceptable
|
||||
Use
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) You keep your content:</div> You own what
|
||||
you post. By posting to a club, challenge, or other shared area
|
||||
of Nessa you grant us a limited, non-exclusive licence to store,
|
||||
reproduce, and display that content inside Nessa so that the
|
||||
feature you used can work — for example, showing your post to
|
||||
the members of your club. That licence ends when your content is
|
||||
removed from our systems. We handle your content as described in
|
||||
our{" "}
|
||||
<A href="/privacy" class="text-blue hover-underline-animation">
|
||||
privacy policy
|
||||
</A>
|
||||
; we do not sell it.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) What you are responsible for:</div> You
|
||||
are responsible for the content you post and the data you record
|
||||
in Nessa, and for who you share it with. Use the privacy
|
||||
settings on your profile and the privacy rules of the clubs you
|
||||
join before sharing anything you would not want others to see.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Do not:</div> (i) post anything unlawful,
|
||||
harassing, hateful, threatening, or sexually explicit involving
|
||||
minors; (ii) post content you do not have the right to share, or
|
||||
that infringes someone else's intellectual property;
|
||||
(iii) falsify activity data, spoof a location, or otherwise
|
||||
cheat a challenge, leaderboard, or competition; (iv) scrape,
|
||||
bulk-download, or access Nessa through automated means we have
|
||||
not authorised; (v) interfere with, overload, or probe Nessa for
|
||||
vulnerabilities, or attempt to access another user's
|
||||
account or data; (vi) resell, sublicense, or offer Nessa to
|
||||
others as your own service.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Moderation:</div> We may remove or
|
||||
restrict content, disqualify an entry from a challenge, or
|
||||
suspend or close an account that breaks these terms. We are not
|
||||
obliged to pre-monitor content. You can report content or a user
|
||||
to us at any time using the contact details in section 11.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">5.</span> Subscriptions and Payments
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Paid tiers:</div> Nessa offers paid
|
||||
subscription tiers, Nessa Plus and Nessa Pro, which unlock
|
||||
features described in the app. Subscriptions are sold as
|
||||
auto-renewing subscriptions through the Apple App Store and are
|
||||
billed to your Apple Account.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Payment:</div> Payment is charged to your
|
||||
Apple Account at confirmation of purchase. The price of your
|
||||
plan is shown in the app before you buy, and it is the price on
|
||||
your App Store purchase receipt.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Auto-renewal:</div> Unless you cancel at
|
||||
least 24 hours before the end of the current period, your
|
||||
subscription renews automatically. Your account is charged for
|
||||
renewal within 24 hours before the end of the current period, at
|
||||
the price of the plan you selected.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Managing your subscription:</div> You can
|
||||
turn off auto-renewal, or cancel, at any time in your App Store
|
||||
account settings after purchase. Changes made there take effect
|
||||
at the end of the current period, and you keep access to the
|
||||
paid features you have already paid for until then.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(e) Trials:</div> If we offer a free trial,
|
||||
any unused portion of it is forfeited when you purchase a
|
||||
subscription, and the trial converts to a paid auto-renewing
|
||||
subscription unless you cancel it before it ends.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(f) Refunds:</div> Purchases made through the
|
||||
App Store are refunded according to Apple's refund policy
|
||||
and, where applicable law gives you a right to a refund,
|
||||
according to that law. Tell us using the contact details in
|
||||
section 11 and we will do what we can.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(g) Price changes:</div> We may change our
|
||||
prices. A price change does not apply to a period you have
|
||||
already paid for; it applies from your next renewal, and we give
|
||||
notice where the law requires it.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(h) Downgrade:</div> When a subscription
|
||||
lapses or is cancelled you keep the free features, and anything
|
||||
you created that belongs to a paid feature (such as a custom
|
||||
route) is kept but is not editable or usable until you
|
||||
subscribe again.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">6.</span> Apple and Other Third Parties
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Not Apple's terms:</div> These terms
|
||||
are between you and us, not with Apple Inc. Apple is not
|
||||
responsible for Nessa or its content, and your use of Nessa must
|
||||
comply with the Apple App Store Terms of Service.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) No Apple support:</div> Apple has no
|
||||
obligation to furnish maintenance and support services for
|
||||
Nessa. If Nessa fails, you may notify Apple, but Apple has no
|
||||
further support obligation, and support and any warranties
|
||||
relating to Nessa are ours to provide as described in these
|
||||
terms.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Apple is not responsible for claims:</div>{" "}
|
||||
Apple is not responsible for product liability claims,
|
||||
intellectual property infringement claims, or any other claims
|
||||
relating to Nessa or your possession and use of it. We, not
|
||||
Apple, are responsible for addressing any such claims and for
|
||||
any claims that you may have relating to Nessa.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Compliance:</div> You confirm that you
|
||||
are not located in a country subject to an embargo of the United
|
||||
States or listed by the United States government as a
|
||||
terrorist-supporting country, and that you are not listed on any
|
||||
United States government list of prohibited or restricted
|
||||
parties.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(e) Third-party services:</div> Nessa depends
|
||||
on third-party services — Clerk for authentication, Apple
|
||||
HealthKit for health and workout data, Apple's In-App
|
||||
Purchase system for subscriptions, and our own hosting — each
|
||||
governed by its own terms. Where those terms affect how you may
|
||||
use the feature they power, they apply alongside these terms.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">7.</span> Intellectual Property
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Our rights:</div> The Nessa software, its
|
||||
design, text, icons, and branding are owned by us or our
|
||||
licensors and are protected by copyright and other laws. We
|
||||
grant you a personal, non-exclusive, non-transferable, revocable
|
||||
licence to use Nessa for your own personal, non-commercial
|
||||
purposes.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Restrictions:</div> You may not copy,
|
||||
modify, translate, distribute, sell, lease, sublicense, or
|
||||
create derivative works from Nessa, and you may not reverse
|
||||
engineer, decompile, or attempt to obtain its source code,
|
||||
except to the extent applicable law allows this despite a
|
||||
restriction.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">8.</span> Disclaimers and Limitation of
|
||||
Liability
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) As is:</div> Nessa is provided "as
|
||||
is" and "as available" to the fullest extent
|
||||
permitted by applicable law. We do not warrant that Nessa will
|
||||
be uninterrupted, secure, error-free, or that recorded data will
|
||||
be accurate — GPS-derived distance, pace, elevation, and
|
||||
estimates can be wrong, and device and sensor limits apply.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Your data:</div> You are responsible for
|
||||
backing up the data you store on your device. We keep
|
||||
Nessa's service data as described in the{" "}
|
||||
<A href="/privacy" class="text-blue hover-underline-animation">
|
||||
privacy policy
|
||||
</A>{" "}
|
||||
and{" "}
|
||||
<A href="/deletion" class="text-blue hover-underline-animation">
|
||||
deletion page
|
||||
</A>
|
||||
, but no service is a substitute for your own backup.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Liability:</div> To the fullest extent
|
||||
permitted by law, we are not liable for indirect, incidental,
|
||||
special, consequential, or punitive damages, or for lost data,
|
||||
lost profits, or loss of training records, arising out of your
|
||||
use of, or inability to use, Nessa. Where a court would not
|
||||
allow that exclusion, our liability for claims arising from your
|
||||
use of Nessa is limited to the greater of the subscription fees
|
||||
you paid us in the twelve months before the event giving rise to
|
||||
the claim, or the minimum the applicable law requires. Nothing
|
||||
in these terms limits liability that cannot lawfully be limited,
|
||||
including for fraud or for death or personal injury caused by
|
||||
our negligence.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">9.</span> Ending Your Use
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) You can leave:</div> You can stop using
|
||||
Nessa at any time. You can delete your account and the content
|
||||
associated with it through our{" "}
|
||||
<A href="/deletion" class="text-blue hover-underline-animation">
|
||||
account deletion page
|
||||
</A>
|
||||
. Deleting your account does not cancel a subscription that is
|
||||
still in its paid period; cancel that in your App Store account
|
||||
settings as described in section 5.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) We may end access:</div> We may suspend
|
||||
or close your access to Nessa if you break these terms, if we
|
||||
are required to by law, or if we reasonably believe your account
|
||||
has been compromised. Where we can, we will tell you why.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) On termination:</div> Your licence to use
|
||||
Nessa ends, and sections 4(a), 6, 7, 8, 10, and 11 continue to
|
||||
apply.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">10.</span> Changes to These Terms
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Updates:</div> We may update these terms
|
||||
as Nessa changes. The current version is always the one at{" "}
|
||||
<A href="/terms" class="text-blue hover-underline-animation">
|
||||
nessa.freno.me/terms
|
||||
</A>
|
||||
, and the "Last Updated" date at the top of this page
|
||||
shows when it last changed.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Continued use:</div> If you keep using
|
||||
Nessa after a change takes effect, you accept the revised terms.
|
||||
For changes that materially reduce your rights or your paid
|
||||
features, we will give notice in the app or by contact before
|
||||
they take effect.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">11.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
<div class="-ml-6">(a) Reaching Out:</div> If you have any
|
||||
questions or comments about these terms, want to report content,
|
||||
or need help with a subscription, you can contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
through the contact form
|
||||
</A>{" "}
|
||||
or email{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
src/routes/nook/agents.test.ts
Normal file
69
src/routes/nook/agents.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Regression test for the /agents roster and its coverage count.
|
||||
*
|
||||
* The page mirrors the app's registry, `AgentSpec.all` in
|
||||
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
||||
* 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.
|
||||
*
|
||||
* FRE-997 restored the count. The page said thirteen while the registry held
|
||||
* eighteen, so the count was dropped rather than corrected (FRE-359 verdict B);
|
||||
* now that all eighteen rows are on the page, the roster check is an equality
|
||||
* check again and the page states eighteen. A subset check would let a shipped
|
||||
* agent go missing behind a passing suite, which is the NOOK-336 failure.
|
||||
*
|
||||
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
||||
*/
|
||||
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");
|
||||
|
||||
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
|
||||
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
|
||||
const REGISTRY = [
|
||||
"Claude Code",
|
||||
"Codex",
|
||||
"OpenCode",
|
||||
"Pi",
|
||||
"Oh My Pi",
|
||||
"Muse",
|
||||
"Hermes",
|
||||
"OpenClaw",
|
||||
"Z.ai (ZCode)",
|
||||
"Gemini CLI",
|
||||
"Cline",
|
||||
"Qwen Code",
|
||||
"DeepSeek",
|
||||
"Kimi",
|
||||
"GitHub Copilot CLI",
|
||||
"Crush",
|
||||
"Cursor",
|
||||
"Amp"
|
||||
];
|
||||
|
||||
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
||||
|
||||
describe("Nook /agents roster", () => {
|
||||
it("lists every registry agent, once, in registry order", () => {
|
||||
expect(entries).toEqual(REGISTRY);
|
||||
});
|
||||
|
||||
it("states the registry's size", () => {
|
||||
expect(entries.length).toBe(18);
|
||||
expect(COPY).toContain("all eighteen");
|
||||
expect(COPY).toContain("The 18 agents");
|
||||
expect(COPY).toContain("eighteen coding agents");
|
||||
});
|
||||
|
||||
it("carries no stale count", () => {
|
||||
expect(COPY.toLowerCase()).not.toContain("thirteen");
|
||||
expect(COPY).not.toMatch(/\b13 agents\b/);
|
||||
expect(COPY).not.toContain("all thirteen");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* `/agents` on the Nook subdomain — supported agents and their event coverage.
|
||||
* `/agents` on the Nook subdomain — all eighteen agents in the registry and
|
||||
* each row's event coverage.
|
||||
*
|
||||
* The registry below mirrors `AgentSpec.all` in the app
|
||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
|
||||
* installed hook surface (hook/setup.go) or plugin event subscription
|
||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentRegistry.swift) and each
|
||||
* agent's installed hook surface (hook/setup.go) or plugin event subscription
|
||||
* (plugins/*), normalized onto the shared event vocabulary
|
||||
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
||||
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
||||
@@ -27,106 +28,324 @@ 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",
|
||||
"SubagentStart", "SubagentStop", "Notification", "PreToolUse",
|
||||
"PermissionRequest", "PostToolUse", "PostToolUseFailure",
|
||||
"PermissionDenied", "PreCompact"
|
||||
"UserPromptSubmit",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"Stop",
|
||||
"StopFailure",
|
||||
"SessionError",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"Notification",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"PermissionDenied",
|
||||
"PreCompact"
|
||||
],
|
||||
gating: "Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||
gating:
|
||||
"Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||
},
|
||||
{
|
||||
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", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "MessageUpdate", "PermissionRequest", "QuestionAsked"
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"MessageUpdate",
|
||||
"PermissionRequest",
|
||||
"QuestionAsked"
|
||||
],
|
||||
gating: "Full — the plugin answers OpenCode's own permission API when you tap Allow or Deny"
|
||||
gating:
|
||||
"Full — the plugin answers OpenCode's own permission API when you tap Allow or Deny"
|
||||
},
|
||||
{
|
||||
name: "Pi",
|
||||
wiring: "In-process extension (~/.pi/agent/extensions/nook.ts)",
|
||||
wiring: "In-process extension registered in its settings",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "MessageUpdate", "QuestionAsked", "QuestionResolved"
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"AgentStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"MessageUpdate",
|
||||
"QuestionAsked",
|
||||
"QuestionResolved"
|
||||
],
|
||||
gating: "Full when you force verification — Pi alone never asks natively, so the gate engages on the approval tool"
|
||||
gating:
|
||||
"Full when you force verification — Pi alone never asks natively, so the gate engages on the approval tool"
|
||||
},
|
||||
{
|
||||
name: "Oh My Pi",
|
||||
wiring: "In-process extension package (~/.omp/agent/extensions/nook/)",
|
||||
wiring: "In-process extension package",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "MessageUpdate", "QuestionAsked", "QuestionResolved"
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"AgentStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PermissionResolved",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"MessageUpdate",
|
||||
"QuestionAsked",
|
||||
"QuestionResolved"
|
||||
],
|
||||
gating: "Full — honors the agent's own approval tiers, per-tool policies included"
|
||||
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: "Shell-hook entries in the agent's config, consent-gated by allowlist",
|
||||
wiring:
|
||||
"Shell-hook entries in the agent's config, consent-gated by allowlist",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "Stop", "PreToolUse",
|
||||
"PostToolUse", "SubagentStart", "SubagentStop"
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"QuestionAsked"
|
||||
],
|
||||
gating: "Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
gating:
|
||||
"Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "OpenClaw",
|
||||
wiring: "Hook handler package (~/.openclaw/hooks/nook/)",
|
||||
events: ["SessionStart", "UserPromptSubmit", "AgentResponse", "PreCompact", "SessionEnd"],
|
||||
gating: "Observation — strict fire-and-forget; the agent surface has no tool or permission events"
|
||||
wiring: "Hook handler package in its hooks directory",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"PreCompact",
|
||||
"SessionEnd"
|
||||
],
|
||||
gating:
|
||||
"Observation — strict fire-and-forget; the agent surface has no tool or permission events"
|
||||
},
|
||||
{
|
||||
name: "Z.ai (ZCode)",
|
||||
wiring: "Hook entries in ~/.zcode/cli/config.json",
|
||||
wiring: "Hook entries in its CLI config",
|
||||
events: [
|
||||
"SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest",
|
||||
"PostToolUse", "PostToolUseFailure", "Stop"
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop"
|
||||
],
|
||||
gating: "Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||
gating:
|
||||
"Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||
},
|
||||
{
|
||||
name: "Gemini CLI",
|
||||
wiring: "Hook entries in settings.json",
|
||||
events: ["SessionStart", "SessionEnd", "AgentStart", "Stop", "Notification"],
|
||||
gating: "Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
wiring: "Hook entries in its settings",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"AgentStart",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"Notification"
|
||||
],
|
||||
gating:
|
||||
"Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
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", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "PreCompact", "Notification"
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"PreCompact",
|
||||
"Notification"
|
||||
],
|
||||
gating: "Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
gating:
|
||||
"Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "Qwen Code",
|
||||
wiring: "Hook entries in settings.json (same surface as the Claude-style schema)",
|
||||
wiring:
|
||||
"Hook entries in its settings (same surface as the Claude-style schema)",
|
||||
events: [
|
||||
"UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "StopFailure",
|
||||
"SubagentStart", "SubagentStop", "Notification", "PreToolUse",
|
||||
"PermissionRequest", "PostToolUse", "PostToolUseFailure",
|
||||
"PermissionDenied", "PreCompact"
|
||||
"UserPromptSubmit",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"Stop",
|
||||
"StopFailure",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"Notification",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"PermissionDenied",
|
||||
"PreCompact"
|
||||
],
|
||||
gating: "Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||
gating:
|
||||
"Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
wiring: "Standalone hooks.json at ~/.dsh",
|
||||
wiring: "A standalone hooks file its bridge is pointed at",
|
||||
events: [
|
||||
"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop",
|
||||
"SubagentStart", "SubagentStop"
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SubagentStart",
|
||||
"SubagentStop"
|
||||
],
|
||||
gating: "Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
||||
gating:
|
||||
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
wiring: "Hook rules in its TOML config, one per event",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"AgentStart",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PermissionResolved",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"Notification"
|
||||
],
|
||||
gating:
|
||||
"Observation — every event appears as a card; its PreToolUse fires before its own permission check, so the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "GitHub Copilot CLI",
|
||||
wiring: "One hook file of ours in its hooks directory",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"ErrorOccurred",
|
||||
"PreCompact",
|
||||
"Notification"
|
||||
],
|
||||
gating:
|
||||
"Observation — every event appears as a card; its permission request runs before its own policy check, so the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "Crush",
|
||||
wiring:
|
||||
"One entry in its JSONC config, the only event it documents",
|
||||
events: ["PreToolUse"],
|
||||
gating:
|
||||
"Observation — tool calls appear as cards; its own permission prompts decide"
|
||||
},
|
||||
{
|
||||
name: "Cursor",
|
||||
wiring:
|
||||
"Hook entries in its hooks.json, merged with your own. No permission hook, on purpose",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"AfterAgentResponse",
|
||||
"SubagentStop",
|
||||
"PreCompact"
|
||||
],
|
||||
gating:
|
||||
"Observation — a permission hook that stays silent blocks the action here, so Cursor keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "Amp",
|
||||
wiring: "In-process extension package",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError"
|
||||
],
|
||||
gating:
|
||||
"Observation — its plugin API has no permission-wait event, so the agent keeps its own prompting"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -151,8 +370,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 eighteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||
ogTitle="The 18 agents The Nook supports, and what each hook sees"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
@@ -160,7 +379,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 eighteen coding agents in one place — your menu bar and
|
||||
notch show every session that is running, thinking, or blocked, and a
|
||||
card answers permission prompts without leaving your keyboard.
|
||||
</p>
|
||||
@@ -168,16 +387,13 @@ export default function NookAgentsPage() {
|
||||
Each agent reports its own set of events. "Allow / Deny from a card"
|
||||
means the agent hands the decision to The Nook while it waits;
|
||||
"Observe" means every event still appears live, but the agent keeps
|
||||
its own prompting. Agents can run on this Mac or on remote machines
|
||||
over SSH — remote sessions report into the same grid.
|
||||
its own prompting.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<For each={AGENTS}>
|
||||
{(agent) => (
|
||||
<article
|
||||
class="border-overlay0 bg-surface0/40 rounded-2xl border p-5"
|
||||
>
|
||||
<article class="border-overlay0 bg-surface0/40 rounded-2xl border p-5">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 class="text-text text-lg font-semibold">{agent.name}</h2>
|
||||
<span
|
||||
@@ -207,8 +423,7 @@ export default function NookAgentsPage() {
|
||||
|
||||
<p class="text-subtext1 mt-10 max-w-2xl text-sm">
|
||||
Running something else? A custom agent can wire itself in with the
|
||||
same one-line contract the built-in integrations use — see the
|
||||
plugin spec in the app's docs.
|
||||
same one-line contract the built-in integrations use.
|
||||
</p>
|
||||
</main>
|
||||
</>
|
||||
|
||||
80
src/routes/nook/changelog-archive.ts
Normal file
80
src/routes/nook/changelog-archive.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -3,23 +3,62 @@
|
||||
*
|
||||
* 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
|
||||
* the panel. Rows collapse to their header; notes stay in the DOM
|
||||
* (visibility toggled, not unmounted) so crawlers still see every entry.
|
||||
*/
|
||||
import { For, Show } from "solid-js";
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import { query, createAsync } from "@solidjs/router";
|
||||
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";
|
||||
return 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)
|
||||
).map((r) => ({ ...r, build: null }));
|
||||
return [...live, ...archived];
|
||||
}, "nook-changelog-releases");
|
||||
|
||||
export default function NookChangelogPage() {
|
||||
const releases = createAsync(() => getReleases());
|
||||
const releases = createAsync(() => getReleases(), { deferStream: true });
|
||||
// Newest release starts open; every other row starts collapsed. `null`
|
||||
// means "the user hasn't touched the accordion yet"; after any click the
|
||||
// signal holds a version or the NONE sentinel — collapsing the open row
|
||||
// sets NONE, so all sections stay closed instead of snapping back open.
|
||||
const NONE = "__none__";
|
||||
const [openVersion, setOpenVersion] = createSignal<string | null>(null);
|
||||
|
||||
const isOpen = (version: string, index: number) => {
|
||||
const open = openVersion();
|
||||
if (open === null) return index === 0;
|
||||
return open !== NONE && open === version;
|
||||
};
|
||||
|
||||
const toggle = (version: string, index: number) =>
|
||||
setOpenVersion(isOpen(version, index) ? NONE : version);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -35,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
|
||||
@@ -47,29 +85,49 @@ export default function NookChangelogPage() {
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="border-overlay0 space-y-10 border-l pl-6">
|
||||
<div class="border-overlay0 bg-surface0/40 max-h-[70vh] overflow-y-auto rounded-2xl border">
|
||||
<For each={releases()}>
|
||||
{(release) => (
|
||||
<article>
|
||||
<div class="mb-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 class="text-text text-xl font-semibold">
|
||||
{(release, i) => (
|
||||
<article class="border-overlay0 not-last:border-b">
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-base flex w-full items-baseline gap-x-3 gap-y-1 p-5 text-left transition-colors"
|
||||
aria-expanded={isOpen(release.version, i())}
|
||||
onClick={() => toggle(release.version, i())}
|
||||
>
|
||||
<span class="text-subtext1 w-4 shrink-0 font-mono text-sm">
|
||||
{isOpen(release.version, i()) ? "−" : "+"}
|
||||
</span>
|
||||
<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}
|
||||
<Show when={release.date}>
|
||||
<span class="text-subtext1 ml-auto text-xs">
|
||||
{release.date}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={release.date}>
|
||||
<span class="text-subtext1 text-xs">{release.date}</span>
|
||||
</button>
|
||||
{/* Notes are the publisher's own HTML, written to the same
|
||||
S3 appcast the Sparkle updater consumes. Kept mounted
|
||||
and toggled via `hidden` so the content stays crawlable.
|
||||
Releases that shipped before notes were embedded in the
|
||||
appcast carry no description — say so instead of
|
||||
rendering a blank expanse. */}
|
||||
<div
|
||||
classList={{ hidden: !isOpen(release.version, i()) }}
|
||||
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}
|
||||
fallback={
|
||||
<p class="text-subtext1 italic">
|
||||
No notes recorded for this release.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div innerHTML={release.notesHtml} />
|
||||
</Show>
|
||||
</div>
|
||||
{/* Notes are the publisher's own HTML, written to the same
|
||||
S3 appcast the Sparkle updater consumes. */}
|
||||
<div
|
||||
class="text-subtext0 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"
|
||||
innerHTML={release.notesHtml}
|
||||
/>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -29,7 +29,7 @@ const FAQ: FaqEntry[] = [
|
||||
{
|
||||
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 — which is rather the point: the fan control exists because the agents are what heat the machine."
|
||||
"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."
|
||||
},
|
||||
{
|
||||
question: "What permissions does it need?",
|
||||
@@ -37,9 +37,9 @@ const FAQ: FaqEntry[] = [
|
||||
"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?",
|
||||
question: "Why macOS 14.2 or later?",
|
||||
answer:
|
||||
"The interface is built on Apple's current observation framework, which ships with macOS 14 (Sonoma). Supporting older versions would mean rewriting the whole UI layer on a deprecated foundation — time that goes into the app instead. macOS 14 runs on hardware from 2018 onward, which covers the machines that run coding agents comfortably."
|
||||
"The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma)."
|
||||
},
|
||||
{
|
||||
question: "Does it work offline?",
|
||||
@@ -56,11 +56,11 @@ 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="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14.2 requirement, offline use — answered straight."
|
||||
ogTitle="The Nook — FAQ"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<main class="flex min-h-screen flex-col items-center px-[8vw] py-[8vh]">
|
||||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Questions people actually ask
|
||||
</h1>
|
||||
@@ -76,7 +76,7 @@ export default function NookFaqPage() {
|
||||
.
|
||||
</p>
|
||||
|
||||
<div class="max-w-3xl space-y-3">
|
||||
<div class="w-full max-w-3xl space-y-3">
|
||||
<For each={FAQ}>
|
||||
{(entry, i) => (
|
||||
<div class="border-overlay0 bg-surface0/40 rounded-2xl border">
|
||||
|
||||
@@ -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";
|
||||
@@ -57,9 +59,9 @@ export default function NookLanding() {
|
||||
<PageHead
|
||||
title="Agent control for your Mac"
|
||||
ogTitle="The Nook — agent control for your Mac"
|
||||
description="The Nook — a native macOS utility for coding-agent orchestration, fan and thermal control."
|
||||
description="Monitor coding agents, respond to permission requests, and manage your Mac\'s cooling, battery, and audio with The Nook."
|
||||
ogImage={`https://${site().domain}/nook/og.png`}
|
||||
ogDescription="A one-time-purchase macOS app for coding-agent orchestration, fan control, and thermal insight."
|
||||
ogDescription="A macOS app for coding agents, fan control, battery charging, and per-app audio. Includes a 14-day trial and a one-time license."
|
||||
/>
|
||||
|
||||
<SubdomainHeader />
|
||||
@@ -94,14 +96,14 @@ export default function NookLanding() {
|
||||
The Nook
|
||||
</div>
|
||||
<h1 class="text-text mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Your Mac and Agents, under control
|
||||
Manage your coding agents and your Mac
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-2 max-w-xl text-xl">
|
||||
Agent orchestration, fan and thermal control in a beautiful native
|
||||
UI.
|
||||
Follow your agents, respond to requests, and manage cooling,
|
||||
charging, and audio from the top of your screen.
|
||||
</p>
|
||||
<p class="text-subtext1 mb-8 text-sm">
|
||||
macOS 14+ · 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">
|
||||
@@ -143,12 +145,18 @@ export default function NookLanding() {
|
||||
In action
|
||||
</p>
|
||||
<h2 class="text-text mb-16 text-center text-3xl font-bold">
|
||||
Built for the way you actually run agents
|
||||
What you can do with The Nook
|
||||
</h2>
|
||||
<FeatureBreakdowns />
|
||||
</div>
|
||||
</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,9 @@ export default function NookLanding() {
|
||||
Try it free for 14 days
|
||||
</h2>
|
||||
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
|
||||
Orchestrate your coding agents, 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.
|
||||
The trial includes every module. After 14 days, a one-time
|
||||
purchase lets you keep using The Nook on up to three of your Macs.
|
||||
There is no subscription.
|
||||
</p>
|
||||
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
|
||||
<Button
|
||||
@@ -179,7 +186,7 @@ export default function NookLanding() {
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-subtext1 mt-6 text-xs">
|
||||
Beta pricing — full price will be $15. One license covers up to 3 of
|
||||
The beta price is $10. The regular price will be $15. One license covers up to 3 of
|
||||
your own Macs.
|
||||
</p>
|
||||
</div>
|
||||
@@ -221,8 +228,7 @@ export default function NookLanding() {
|
||||
</form>
|
||||
<Show when={resendSent()}>
|
||||
<p class="text-subtext1 mt-4 text-center text-sm">
|
||||
Check your inbox — if a license is registered to that email, your
|
||||
key is on its way.
|
||||
If a license is registered to that email, we'll send you the key.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={resendError()}>
|
||||
|
||||
78
src/routes/nook/landing.test.ts
Normal file
78
src/routes/nook/landing.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = join(import.meta.dir, "../../..");
|
||||
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
||||
const landingCopy = [
|
||||
"src/routes/nook/index.tsx",
|
||||
...[
|
||||
"FeatureCollage",
|
||||
"FeatureBreakdowns",
|
||||
"IslandExtras",
|
||||
"PhoneBeta",
|
||||
"MoreDemos",
|
||||
"UtilityDemos"
|
||||
].map((name) => `src/components/nook/${name}.tsx`)
|
||||
]
|
||||
.map(read)
|
||||
.join("\n");
|
||||
|
||||
// Like remote-claims.test.ts, this pins an editorial decision, not a tone score.
|
||||
describe("Nook landing copy (NOOK-1037)", () => {
|
||||
for (const phrase of [
|
||||
"Your fleet, in your pocket",
|
||||
"Timers that stack",
|
||||
"Volume and brightness, under the notch",
|
||||
"The sky, in the slot",
|
||||
"Storage, measured",
|
||||
"Wall power, on your terms",
|
||||
"Fans that answer to you, not the scheduler",
|
||||
"Your whole fleet, in one pixel strip",
|
||||
"A hearth that tells the truth",
|
||||
"A lens that stays asleep",
|
||||
"Your buttons, orbiting the island",
|
||||
"Silence it or max it",
|
||||
"Sleep when you say",
|
||||
"Panels you shape"
|
||||
]) {
|
||||
it(`replaces “${phrase}”`, () => {
|
||||
expect(landingCopy).not.toContain(phrase);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Source anatomy checks supplement the rendered browser checks.
|
||||
const demos = read("src/components/nook/UtilityDemos.tsx");
|
||||
|
||||
describe("current utility demo anatomy (NOOK-1037)", () => {
|
||||
it("shows the three calendar slot states and calendar-colored agenda rows", () => {
|
||||
for (const text of ["ON AIR", "Work", "Thu, Nov 12", "Join meeting"])
|
||||
expect(demos).toContain(text);
|
||||
expect(demos).toMatch(/>\s*IN\s*</);
|
||||
});
|
||||
|
||||
it("shows the power circuit and current battery controls", () => {
|
||||
for (const text of [
|
||||
"System draw",
|
||||
"Adapter",
|
||||
"Mac",
|
||||
"Overheat protection",
|
||||
"Charges past the limit; off on unplug"
|
||||
])
|
||||
expect(demos).toContain(text);
|
||||
expect(demos).not.toContain("the pack stays young longer");
|
||||
});
|
||||
|
||||
it("shows master routing, app percentages, pin and hide controls", () => {
|
||||
for (const text of [
|
||||
"System sounds",
|
||||
"Microphone",
|
||||
"Output volume",
|
||||
"Pin app",
|
||||
"Hide app",
|
||||
"Default"
|
||||
])
|
||||
expect(demos).toContain(text);
|
||||
});
|
||||
});
|
||||
125
src/routes/nook/remote-claims.test.ts
Normal file
125
src/routes/nook/remote-claims.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Two page contracts live in this file, and they are reconciled on purpose.
|
||||
*
|
||||
* NOOK-1038 (2026-11-09, Mike's correction) supersedes FRE-323's blanket remote
|
||||
* hold: one-click setup is a supported scoped claim for Tailscale peers and
|
||||
* authorized known hosts, so the Remote cards and the setup demo belong on the
|
||||
* page. Blanket authorization promises stay out.
|
||||
*
|
||||
* FRE-997 (2026-11-04) restored the agent count. The page listed thirteen of the
|
||||
* registry's eighteen rows, so FRE-359 verdict B dropped the count instead of
|
||||
* correcting it; FRE-997 put all eighteen rows on /agents and made the roster an
|
||||
* equality check again (`agents.test.ts`), so the count is publishable at 18 and
|
||||
* the stale counts stay banned.
|
||||
*
|
||||
* The two lines met in the FRE-1695 reconciliation (Gitea was 12 commits ahead,
|
||||
* GitHub 2). NOOK-1038's file repeated FRE-359's older "no coverage count"
|
||||
* clause; that clause is superseded by FRE-997's landed correction and is the
|
||||
* one line dropped here. Everything else from NOOK-1038 is taken verbatim, and
|
||||
* `marketing/positioning.md` records both decisions side by side (coverage row:
|
||||
* publishable at 18; remote row: approved, scoped). See `docs/deploy-source.md`.
|
||||
*
|
||||
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
|
||||
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "../../..");
|
||||
const AUDITED = [
|
||||
"src/routes/nook/index.tsx",
|
||||
"src/routes/nook/agents.tsx",
|
||||
"src/routes/nook/faq.tsx",
|
||||
"src/components/nook/FeatureCollage.tsx",
|
||||
"src/components/nook/FeatureBreakdowns.tsx",
|
||||
"src/components/nook/IslandExtras.tsx"
|
||||
];
|
||||
|
||||
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||
function copyOf(file: string): string {
|
||||
return readFileSync(join(ROOT, file), "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
describe("remote marketing reflects the supported setup (NOOK-1038)", () => {
|
||||
it("fills the collage's missing sixth grid slot with host-labelled remote sessions", () => {
|
||||
const collage = copyOf("src/components/nook/FeatureCollage.tsx");
|
||||
// The hero spans two columns; four regular cards fill the other slots.
|
||||
expect(collage.match(/<CollageCell\b/g)).toHaveLength(4);
|
||||
expect(collage).toContain('kicker="Remote"');
|
||||
expect(collage).toContain("One-click setup for Tailscale and authorized known hosts");
|
||||
expect(collage).toContain('host="build-box"');
|
||||
expect(collage).toContain('host="gpu-rig"');
|
||||
});
|
||||
|
||||
it("restores the detailed setup section with both discovery sources", () => {
|
||||
const breakdowns = copyOf("src/components/nook/FeatureBreakdowns.tsx");
|
||||
expect(breakdowns).toContain('kicker="Remote"');
|
||||
expect(breakdowns).toContain('title="Set up remote agents in one click"');
|
||||
expect(breakdowns).toContain("Tailscale peer or an authorized host from your SSH config");
|
||||
expect(breakdowns).toContain("<RemoteHostDemo />");
|
||||
});
|
||||
|
||||
it("demonstrates setup progressing to Live and a host-labelled session", () => {
|
||||
const demos = copyOf("src/components/nook/MoreDemos.tsx");
|
||||
for (const label of [
|
||||
"connecting",
|
||||
"pushing the hook binary",
|
||||
"configuring agents",
|
||||
"checking the host can reach this Mac",
|
||||
"Live",
|
||||
"Set up"
|
||||
])
|
||||
expect(demos).toContain(label);
|
||||
expect(demos).toContain('host="gpu-rig"');
|
||||
});
|
||||
|
||||
for (const file of AUDITED) {
|
||||
it(`${file} makes no blanket authorization promise`, () => {
|
||||
const copy = copyOf(file).toLowerCase();
|
||||
for (const phrase of [
|
||||
"Any SSH box",
|
||||
"Works across every hooked agent, local or remote",
|
||||
"no SSH authorization needed"
|
||||
])
|
||||
expect(copy).not.toContain(phrase.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
it("records the current decision without deleting the historical copy", () => {
|
||||
for (const file of [
|
||||
"marketing/drafts/remote-ssh/copy.md",
|
||||
"marketing/drafts/remote-ssh/rationale.md",
|
||||
"marketing/positioning.md"
|
||||
]) {
|
||||
const text = readFileSync(join(ROOT, file), "utf8");
|
||||
expect(text).toContain("NOOK-1038");
|
||||
expect(text).toContain("Tailscale and authorized known hosts");
|
||||
}
|
||||
expect(
|
||||
readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8")
|
||||
).toContain("Any SSH box, one click away");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the agent count is corrected, not dropped (FRE-997)", () => {
|
||||
/** FRE-359 verdict B, minus the phrasings FRE-997 re-grounded in the full roster. */
|
||||
const BANNED_COVERAGE = ["all thirteen", "thirteen coding agents", "13 agents"];
|
||||
|
||||
for (const file of AUDITED) {
|
||||
it(`${file} states no stale count`, () => {
|
||||
const copy = copyOf(file).toLowerCase();
|
||||
for (const phrase of BANNED_COVERAGE) expect(copy).not.toContain(phrase);
|
||||
});
|
||||
}
|
||||
|
||||
it("/agents states the registry's size", () => {
|
||||
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Supported agents");
|
||||
});
|
||||
|
||||
it("the landing copy states the count the roster actually has", () => {
|
||||
expect(copyOf("src/components/nook/FeatureBreakdowns.tsx")).toContain("18 agents");
|
||||
});
|
||||
});
|
||||
139
src/routes/smarter-remote/privacy.tsx
Normal file
139
src/routes/smarter-remote/privacy.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* A Smarter Remote privacy policy — `freno.me/smarter-remote/privacy`.
|
||||
*
|
||||
* Linked from the iOS app's paywall (App Review 3.1.2). Reflects what the app
|
||||
* actually does: TV control stays on the local network, pairing data stays on
|
||||
* the phone, purchases go through Apple and RevenueCat, and sharing is a QR
|
||||
* code passed between phones.
|
||||
*/
|
||||
import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
|
||||
export default function SmarterRemotePrivacyPolicy() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="A Smarter Remote Privacy Policy"
|
||||
description="Privacy policy for A Smarter Remote, a universal TV remote app that works over your local Wi-Fi network."
|
||||
/>
|
||||
<div class="bg-base">
|
||||
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<div class="py-4 text-xl">A Smarter Remote's Privacy Policy</div>
|
||||
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||
<div class="py-2">
|
||||
Welcome to A Smarter Remote ('We', 'Us',
|
||||
'Our'). The app controls your TVs over your own Wi-Fi
|
||||
network. It has no accounts, no ads, and no analytics. This policy
|
||||
explains the little data it does handle.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Your TVs:</div> The app finds TVs on
|
||||
your local network and stores their names, network addresses,
|
||||
and platform on your phone. Pairing codes and tokens are kept
|
||||
in your phone's Keychain. This data is sent only to the
|
||||
TV itself, over your local network. It never reaches us.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Purchases:</div> Payments are handled
|
||||
by Apple. We use RevenueCat to check whether you own A Smarter
|
||||
Remote Pro. RevenueCat receives an anonymous app user ID, your
|
||||
purchase and subscription history, and basic device and app
|
||||
details such as OS version, app version, and region. It does
|
||||
not receive your name, email, or payment details.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Voice input:</div> If you use voice
|
||||
search, your speech is converted to text by Apple's
|
||||
speech recognition. Apple may process it on its servers. The
|
||||
resulting text is sent only to your TV.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Sharing:</div> If you share a TV with
|
||||
someone, its name, address, and platform are put into a QR
|
||||
code on your screen for them to scan. It goes from one phone
|
||||
to the other and does not pass through our servers.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
Data about your TVs is used only to control them. Purchase
|
||||
records are used only to unlock Pro. We do not use any of it for
|
||||
advertising, and we do not sell it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">3.</span> Local Network Access
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
iOS asks for permission before the app can use your local
|
||||
network. The app needs it to find and control your TVs. If you
|
||||
decline, the app cannot reach your TVs. You can change this in
|
||||
Settings > Privacy & Security > Local Network.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">4.</span> Deleting Your Data
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
Removing a TV in the app deletes its stored details and
|
||||
credentials from your phone. Deleting the app removes all of
|
||||
them. Subscriptions are managed and cancelled in your App Store
|
||||
account settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">5.</span> Children's Privacy
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
The app does not knowingly collect personal information from
|
||||
anyone, including children under 13.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">6.</span> Changes to the Privacy Policy
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
We may update this policy periodically. Any changes are posted on
|
||||
this page, so please review it from time to time.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">7.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
If you have any questions about this policy, you can contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
141
src/routes/smarter-remote/terms.tsx
Normal file
141
src/routes/smarter-remote/terms.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* A Smarter Remote terms of use — `freno.me/smarter-remote/terms`.
|
||||
*
|
||||
* Linked from the iOS app's paywall (App Review 3.1.2). Builds on Apple's
|
||||
* standard EULA and adds the Pro subscription and lifetime purchase terms.
|
||||
*/
|
||||
import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
|
||||
export default function SmarterRemoteTerms() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="A Smarter Remote Terms of Use"
|
||||
description="Terms of use for A Smarter Remote and its Pro subscription."
|
||||
/>
|
||||
<div class="bg-base">
|
||||
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<div class="py-4 text-xl">A Smarter Remote's Terms of Use</div>
|
||||
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||
<div class="py-2">
|
||||
These terms cover your use of the A Smarter Remote app ('the
|
||||
App'). By using the App you agree to them. The App is licensed
|
||||
to you under Apple's{" "}
|
||||
<a
|
||||
href="https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
Licensed Application End User License Agreement
|
||||
</a>
|
||||
, and the terms below add to it.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">1.</span> A Smarter Remote Pro
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) What it includes:</div> The free App
|
||||
controls one TV. Pro removes that limit and unlocks the
|
||||
features marked Pro in the App.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Yearly subscription:</div> Payment is
|
||||
charged to your Apple account when you confirm the purchase,
|
||||
or when any free trial ends. The subscription renews
|
||||
automatically at the same price unless you cancel at least 24
|
||||
hours before the end of the current period. Your account is
|
||||
charged for renewal within 24 hours before the period ends.
|
||||
Any unused part of a free trial is forfeited when you buy a
|
||||
subscription.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Lifetime purchase:</div> A one-time
|
||||
payment that unlocks Pro with no recurring charge.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Managing and refunds:</div> You can
|
||||
manage or cancel a subscription in your App Store account
|
||||
settings. Refunds are handled by Apple under its policies.
|
||||
Restore Purchases in the App re-unlocks Pro on another device
|
||||
signed in to the same Apple account.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">2.</span> Your TVs
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
The App controls TVs through the network features their makers
|
||||
provide. Which features work depends on your TV's model and
|
||||
software, which can change without notice. We do not guarantee
|
||||
that every feature works on every TV. Only control TVs you own
|
||||
or have permission to use.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">3.</span> No Warranty
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
The App is provided 'as is', without warranties of any
|
||||
kind, to the extent the law allows. To the extent the law allows,
|
||||
we are not liable for indirect or consequential damages arising
|
||||
from your use of the App.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">4.</span> Privacy
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
How the App handles data is described in the{" "}
|
||||
<A
|
||||
href="/smarter-remote/privacy"
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
Privacy Policy
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">5.</span> Changes to These Terms
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
We may update these terms periodically. Any changes are posted on
|
||||
this page. Continuing to use the App after a change means you
|
||||
accept the new terms.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">6.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
If you have any questions about these terms, you can contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
src/routes/subdomain-dispatch.test.ts
Normal file
114
src/routes/subdomain-dispatch.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Subdomain routing invariant test.
|
||||
*
|
||||
* Verifies that every subdomain route (src/routes/<prefix>/<name>) has a
|
||||
* corresponding top-level dispatch route (src/routes/<name>.tsx or
|
||||
* src/routes/<name>/index.tsx). This is required because production Vercel
|
||||
* deployments do not apply vercel.json host rewrites — the Nitro Build Output
|
||||
* API config.json replaces them entirely, so host-aware root dispatch is the
|
||||
* only mechanism that serves subdomain pages.
|
||||
*
|
||||
* See docs/subdomain-setup.md and AGENTS.md "Subdomain Routing" for context.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readdirSync, existsSync, statSync } from "fs";
|
||||
|
||||
function isRouteFile(name: string): boolean {
|
||||
// Page routes are components; the `.ts` files beside them are pure content /
|
||||
// metadata modules (content.ts, meta.ts, *-content.ts) with no default
|
||||
// export the router can render, so they are not dispatch targets.
|
||||
return name.endsWith(".tsx");
|
||||
}
|
||||
|
||||
function listRouteFiles(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir).filter(isRouteFile);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function listDirectories(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir).filter((name) => {
|
||||
try {
|
||||
return statSync(`${dir}/${name}`).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function hasDispatchRoute(routeName: string, routesDir: string): boolean {
|
||||
// Check for src/routes/<name>.tsx
|
||||
if (existsSync(`${routesDir}/${routeName}.tsx`)) {
|
||||
return true;
|
||||
}
|
||||
// Check for src/routes/<name>/index.tsx
|
||||
if (existsSync(`${routesDir}/${routeName}/index.tsx`)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("subdomain routing dispatch invariant", () => {
|
||||
const routesDir = "./src/routes";
|
||||
|
||||
it("every subdomain route has a top-level dispatch route", () => {
|
||||
// List directory entries before filtering to route files. The previous
|
||||
// shape filtered with `listRouteFiles` first, which only ever returns
|
||||
// `.tsx`/`.ts` names, so `statSync(...).isDirectory()` was never true
|
||||
// and the assertion ran over an empty list — it passed while `/terms`
|
||||
// had no dispatch file (FRE-1670).
|
||||
const prefixDirs = listDirectories(routesDir)
|
||||
// Exclude non-product subdomains
|
||||
.filter((name) =>
|
||||
["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name)
|
||||
);
|
||||
|
||||
const missing = [];
|
||||
|
||||
for (const prefix of prefixDirs) {
|
||||
const prefixFiles = listRouteFiles(`${routesDir}/${prefix}`);
|
||||
for (const file of prefixFiles) {
|
||||
// Skip test files and non-route files
|
||||
if (file.includes("test") || file.includes("spec")) {
|
||||
continue;
|
||||
}
|
||||
if (!isRouteFile(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine route name (without extension)
|
||||
const routeName = file.replace(/\.(tsx|ts)$/, "");
|
||||
|
||||
// index.tsx maps to the parent directory name
|
||||
const effectiveRoute = routeName === "index" ? prefix : routeName;
|
||||
|
||||
// Skip routes that are intentionally not dispatched to root
|
||||
// (these are subdomain-specific, not shared surfaces)
|
||||
if (
|
||||
effectiveRoute === "content" ||
|
||||
effectiveRoute === "meta" ||
|
||||
effectiveRoute.endsWith("-content") ||
|
||||
effectiveRoute === "password-reset" ||
|
||||
effectiveRoute === "request-password-reset" ||
|
||||
effectiveRoute === "checkout" ||
|
||||
effectiveRoute === "success" ||
|
||||
effectiveRoute === "notifications"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasDispatchRoute(effectiveRoute, routesDir)) {
|
||||
missing.push(`${prefix}/${file} → missing dispatch for /${effectiveRoute}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
30
src/routes/terms.tsx
Normal file
30
src/routes/terms.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Switch, Match } from "solid-js";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NotFound from "./[...404]";
|
||||
import NessaTermsOfUse from "./nessa/terms";
|
||||
|
||||
/**
|
||||
* Host-aware terms-of-use route — `/terms`.
|
||||
*
|
||||
* The public browser path `/terms` is what the Nessa app links from
|
||||
* Settings › Legal and from the subscription paywall
|
||||
* (`AppSettings.termsOfServiceURL`). Production Vercel deployments do not
|
||||
* apply the `vercel.json` host rewrites (the Nitro Build Output API config
|
||||
* supersedes them), so the subdomain page needs this top-level dispatch file
|
||||
* to be reachable — without it `nessa.freno.me/terms` is a 404 even though
|
||||
* `src/routes/nessa/terms.tsx` exists.
|
||||
*
|
||||
* Only Nessa publishes terms of use today. Every other site — and the main
|
||||
* site — falls back to the 404 handler rather than borrowing another
|
||||
* product's legal document.
|
||||
*/
|
||||
export default function TermsPage() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Switch fallback={<NotFound />}>
|
||||
<Match when={site().id === "nessa"}>
|
||||
<NessaTermsOfUse />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
200
src/server/analytics-retention.test.ts
Normal file
200
src/server/analytics-retention.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { VISITOR_ANALYTICS_DDL } from "./analytics-schema";
|
||||
import {
|
||||
DEFAULT_ANALYTICS_RETENTION_DAYS,
|
||||
MAX_ANALYTICS_RETENTION_DAYS,
|
||||
isAuthorizedCron,
|
||||
pruneVisitorAnalytics,
|
||||
resolveRetentionDays,
|
||||
runRetentionCron
|
||||
} from "./analytics-retention";
|
||||
|
||||
// Why a real in-memory SQLite database: retention is a `DELETE` whose whole
|
||||
// job is a date comparison. A stub returning a canned count would pass with a
|
||||
// window that is off by a unit, inverted, or — the failure this file exists for
|
||||
// — never applied at all. Same reasoning as the backfill and schema suites, and
|
||||
// nothing is module-mocked here on purpose: bun's `mock.module` is process-wide.
|
||||
|
||||
let db: Database;
|
||||
|
||||
const conn = {
|
||||
async execute(query: { sql: string; args?: unknown[] }) {
|
||||
const statement = db.query(query.sql);
|
||||
const args = (query.args ?? []) as never[];
|
||||
// `DELETE … RETURNING id` answers with rows, which is how the prune counts
|
||||
// what it removed; bun:sqlite exposes those through `.all()`.
|
||||
if (
|
||||
/^\s*(select|pragma|with)\b/i.test(query.sql) ||
|
||||
/\breturning\b/i.test(query.sql)
|
||||
) {
|
||||
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
|
||||
}
|
||||
return {
|
||||
rows: [] as unknown[],
|
||||
rowsAffected: statement.run(...args).changes
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function insert(id: string, createdAt: string) {
|
||||
db.run(
|
||||
`INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES (?, '/x', 'GET', ?)`,
|
||||
[id, createdAt]
|
||||
);
|
||||
}
|
||||
|
||||
function rowCount(): number {
|
||||
return (db.query(`SELECT COUNT(*) AS n FROM VisitorAnalytics`).get() as {
|
||||
n: number;
|
||||
}).n;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
db.run(VISITOR_ANALYTICS_DDL);
|
||||
});
|
||||
|
||||
describe("resolveRetentionDays", () => {
|
||||
it("takes a usable configured window", () => {
|
||||
expect(resolveRetentionDays("90")).toBe(90);
|
||||
expect(resolveRetentionDays(30)).toBe(30);
|
||||
});
|
||||
|
||||
it("falls back to the default when the value is missing or nonsense", () => {
|
||||
expect(resolveRetentionDays(undefined)).toBe(
|
||||
DEFAULT_ANALYTICS_RETENTION_DAYS
|
||||
);
|
||||
expect(resolveRetentionDays("")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
expect(resolveRetentionDays("0")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
expect(resolveRetentionDays("-5")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
expect(resolveRetentionDays("1.5")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
expect(resolveRetentionDays("all")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS);
|
||||
});
|
||||
|
||||
it("refuses a window so long it means 'never prune'", () => {
|
||||
expect(resolveRetentionDays(String(MAX_ANALYTICS_RETENTION_DAYS + 1))).toBe(
|
||||
DEFAULT_ANALYTICS_RETENTION_DAYS
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneVisitorAnalytics", () => {
|
||||
it("deletes only rows older than the window", async () => {
|
||||
insert("young", "2020-01-01 00:00:00");
|
||||
insert("old", "2019-01-01 00:00:00");
|
||||
// A row exactly at `now - 365 days` boundary in the other direction: the
|
||||
// comparison is `<`, so the recent one survives.
|
||||
db.run(
|
||||
`UPDATE VisitorAnalytics SET created_at = datetime('now', '-10 days') WHERE id = 'young'`
|
||||
);
|
||||
db.run(
|
||||
`UPDATE VisitorAnalytics SET created_at = datetime('now', '-400 days') WHERE id = 'old'`
|
||||
);
|
||||
|
||||
const deleted = await pruneVisitorAnalytics(conn, 365);
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes nothing when every row is inside the window", async () => {
|
||||
insert("a", "x");
|
||||
db.run(
|
||||
`UPDATE VisitorAnalytics SET created_at = datetime('now', '-1 day') WHERE id = 'a'`
|
||||
);
|
||||
|
||||
expect(await pruneVisitorAnalytics(conn, 365)).toBe(0);
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a window it was not given a number for", async () => {
|
||||
insert("a", "2019-01-01 00:00:00");
|
||||
|
||||
await expect(pruneVisitorAnalytics(conn, 0)).rejects.toThrow(/window/);
|
||||
await expect(pruneVisitorAnalytics(conn, -30)).rejects.toThrow(/window/);
|
||||
await expect(pruneVisitorAnalytics(conn, NaN)).rejects.toThrow(/window/);
|
||||
// "0" is not a window either — and must not delete the table.
|
||||
await expect(
|
||||
pruneVisitorAnalytics(conn, "0" as unknown as number)
|
||||
).rejects.toThrow(/window/);
|
||||
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("passes the window as a parameter, not as interpolated SQL", async () => {
|
||||
insert("a", "2019-01-01 00:00:00");
|
||||
const injected = "365); DROP TABLE VisitorAnalytics; --" as unknown as number;
|
||||
|
||||
// Truncating a non-numeric string yields NaN, which the guard refuses — so
|
||||
// the statement never runs and the table is still there.
|
||||
await expect(pruneVisitorAnalytics(conn, injected)).rejects.toThrow(/window/);
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedCron", () => {
|
||||
it("matches the bearer secret and nothing else", () => {
|
||||
expect(isAuthorizedCron("Bearer sekrit", "sekrit")).toBe(true);
|
||||
expect(isAuthorizedCron("bearer SEKRIT".toLowerCase(), "sekrit")).toBe(true);
|
||||
expect(isAuthorizedCron("Bearer nope", "sekrit")).toBe(false);
|
||||
expect(isAuthorizedCron(null, "sekrit")).toBe(false);
|
||||
expect(isAuthorizedCron("Bearer sekrit", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("denies everything when no secret is configured (fail closed)", () => {
|
||||
expect(isAuthorizedCron("Bearer ", undefined)).toBe(false);
|
||||
expect(isAuthorizedCron(undefined, "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRetentionCron", () => {
|
||||
it("denies an unauthenticated trigger before touching the table", async () => {
|
||||
insert("a", "2019-01-01 00:00:00");
|
||||
|
||||
const res = await runRetentionCron({
|
||||
authorization: null,
|
||||
secret: "sekrit",
|
||||
configuredDays: "365",
|
||||
conn
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("prunes on its window and reports what it removed", async () => {
|
||||
db.run(
|
||||
`INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES ('old','/x','GET',datetime('now','-400 days'))`
|
||||
);
|
||||
db.run(
|
||||
`INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES ('new','/x','GET',datetime('now','-1 day'))`
|
||||
);
|
||||
|
||||
const res = await runRetentionCron({
|
||||
authorization: "Bearer sekrit",
|
||||
secret: "sekrit",
|
||||
configuredDays: "365",
|
||||
conn
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, days: 365, deleted: 1 });
|
||||
expect(rowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("says it failed rather than reporting a quiet zero", async () => {
|
||||
db.close(); // the table is gone: the prune's statement must now throw
|
||||
|
||||
const res = await runRetentionCron({
|
||||
authorization: "Bearer sekrit",
|
||||
secret: "sekrit",
|
||||
configuredDays: "365",
|
||||
conn
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.days).toBe(365);
|
||||
expect(res.body.deleted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
129
src/server/analytics-retention.ts
Normal file
129
src/server/analytics-retention.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Scheduled retention for `VisitorAnalytics` (FRE-369, finding Q4).
|
||||
*
|
||||
* The table used to grow without bound: `cleanupOldAnalytics` existed but was
|
||||
* reachable only as a manual tRPC call, and once FRE-366 started recording a
|
||||
* real client address instead of the proxy's loopback, "unbounded" meant
|
||||
* unbounded personal data in a hosted third-party database. Two halves answer
|
||||
* that — the write-time prefix in `./request-client`, and this: a bound on how
|
||||
* long any row exists at all.
|
||||
*
|
||||
* The default (365 days) is deliberately conservative so enabling the schedule
|
||||
* is not itself a destructive event: it deletes nothing on its first run
|
||||
* against a database whose history starts inside the window, and it bounds
|
||||
* growth from then on. Tighten it to the reviewer's suggested 90 by setting
|
||||
* `ANALYTICS_RETENTION_DAYS=90` — that first run *will* delete months of
|
||||
* history that the /analytics dashboard and the weekly report read, which is an
|
||||
* operational decision, not one this file should make silently.
|
||||
*/
|
||||
|
||||
/** Kept when `ANALYTICS_RETENTION_DAYS` is unset or unusable. */
|
||||
export const DEFAULT_ANALYTICS_RETENTION_DAYS = 365;
|
||||
|
||||
/** Anything longer is a misconfiguration, not a policy (≈ 2 years). */
|
||||
export const MAX_ANALYTICS_RETENTION_DAYS = 730;
|
||||
|
||||
/**
|
||||
* The slice of a connection this module needs — the same shape
|
||||
* `weekly-metrics.ts` pins as `MetricsConn`, so a libsql client satisfies it
|
||||
* without a cast (and the in-memory SQLite test adapter satisfies it too).
|
||||
*/
|
||||
export interface AnalyticsConn {
|
||||
execute(query: {
|
||||
sql: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
args?: any[];
|
||||
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The retention window to enforce: a positive integer at most
|
||||
* {@link MAX_ANALYTICS_RETENTION_DAYS}, else the default. Accepts the env
|
||||
* string form and a number, because both callers hand over a different shape.
|
||||
*/
|
||||
export function resolveRetentionDays(
|
||||
configured: string | number | undefined | null
|
||||
): number {
|
||||
const days = typeof configured === "number" ? configured : Number(configured);
|
||||
if (!Number.isInteger(days) || days <= 0) return DEFAULT_ANALYTICS_RETENTION_DAYS;
|
||||
if (days > MAX_ANALYTICS_RETENTION_DAYS) return DEFAULT_ANALYTICS_RETENTION_DAYS;
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete analytics rows older than `olderThanDays` and report how many went.
|
||||
*
|
||||
* The window is a SQL parameter, not interpolated text: `cleanupOldAnalytics`
|
||||
* used to splice the number into the statement, which only ever sat behind a
|
||||
* `z.number()` input on the tRPC side — one unvalidated caller away from
|
||||
* injection. A non-integer is refused here rather than passed on, so neither
|
||||
* path can delete on a window the code did not check.
|
||||
*/
|
||||
export async function pruneVisitorAnalytics(
|
||||
conn: AnalyticsConn,
|
||||
olderThanDays: number
|
||||
): Promise<number> {
|
||||
const days = Math.trunc(Number(olderThanDays));
|
||||
if (!Number.isInteger(days) || days <= 0) {
|
||||
throw new Error(
|
||||
`Refusing to prune VisitorAnalytics on a non-positive window: ${olderThanDays}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `DELETE FROM VisitorAnalytics
|
||||
WHERE created_at < datetime('now', ?)
|
||||
RETURNING id`,
|
||||
args: [`-${days} days`]
|
||||
});
|
||||
|
||||
// Counted from `RETURNING id` rather than `rowsAffected`, keeping the contract
|
||||
// `cleanupOldAnalytics` had: the caller is told how many rows actually went.
|
||||
return result.rows.length;
|
||||
}
|
||||
|
||||
/** Whether a cron may run: Bearer match, and an unset secret denies everything. */
|
||||
export function isAuthorizedCron(
|
||||
authorization: string | null | undefined,
|
||||
secret: string | undefined | null
|
||||
): boolean {
|
||||
if (!secret) return false;
|
||||
const provided = (authorization ?? "").replace(/^Bearer\s+/i, "").trim();
|
||||
return Boolean(provided) && provided === secret;
|
||||
}
|
||||
|
||||
export interface RetentionOutcome {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole cron decision, away from the route: authorise, resolve the window,
|
||||
* prune. Returns the status and body the route should send, so the behaviour is
|
||||
* testable without a database or an environment.
|
||||
*
|
||||
* Fail closed on auth (an unauthenticated trigger deleting real history is the
|
||||
* worst thing this endpoint can do), and 500 rather than a silent zero on a
|
||||
* failed prune — a retention job that quietly stopped pruning is exactly the
|
||||
* state that created this issue.
|
||||
*/
|
||||
export async function runRetentionCron(options: {
|
||||
authorization: string | null | undefined;
|
||||
secret: string | undefined | null;
|
||||
configuredDays: string | number | undefined | null;
|
||||
conn: AnalyticsConn;
|
||||
}): Promise<RetentionOutcome> {
|
||||
if (!isAuthorizedCron(options.authorization, options.secret)) {
|
||||
return { status: 401, body: { error: "Unauthorized" } };
|
||||
}
|
||||
|
||||
const days = resolveRetentionDays(options.configuredDays);
|
||||
|
||||
try {
|
||||
const deleted = await pruneVisitorAnalytics(options.conn, days);
|
||||
return { status: 200, body: { success: true, days, deleted } };
|
||||
} catch (error) {
|
||||
console.error("Analytics retention job failed:", error);
|
||||
return { status: 500, body: { error: "Analytics retention job failed", days } };
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,9 @@ const sampleArgs = [
|
||||
2, // inp
|
||||
3, // ttfb
|
||||
4, // dom_load
|
||||
5 // load_complete
|
||||
5, // load_complete
|
||||
"click", // stage
|
||||
"nook.freno.me" // host
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -69,7 +71,7 @@ describe("VisitorAnalytics schema", () => {
|
||||
}
|
||||
// The reverse direction matters too: a column the code writes but the
|
||||
// DDL omits is the failure this whole file exists to catch.
|
||||
expect(VISITOR_ANALYTICS_COLUMNS.length).toBe(23);
|
||||
expect(VISITOR_ANALYTICS_COLUMNS.length).toBe(25);
|
||||
});
|
||||
|
||||
it("has one placeholder per inserted column", () => {
|
||||
@@ -83,7 +85,7 @@ describe("VisitorAnalytics schema", () => {
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT path, country, utm_source, utm_medium, utm_campaign
|
||||
`SELECT path, country, utm_source, utm_medium, utm_campaign, stage, host
|
||||
FROM VisitorAnalytics`
|
||||
)
|
||||
.get() as Record<string, unknown>;
|
||||
@@ -93,7 +95,9 @@ describe("VisitorAnalytics schema", () => {
|
||||
country: "GB",
|
||||
utm_source: "hn",
|
||||
utm_medium: "post",
|
||||
utm_campaign: "launch"
|
||||
utm_campaign: "launch",
|
||||
stage: "click",
|
||||
host: "nook.freno.me"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export const VISITOR_ANALYTICS_DDL = `
|
||||
utm_source TEXT,
|
||||
utm_medium TEXT,
|
||||
utm_campaign TEXT,
|
||||
stage TEXT,
|
||||
host TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL
|
||||
)
|
||||
`;
|
||||
@@ -79,6 +81,18 @@ export const VISITOR_ANALYTICS_ADDED_COLUMNS = [
|
||||
{
|
||||
name: "utm_campaign",
|
||||
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN utm_campaign TEXT`
|
||||
},
|
||||
{
|
||||
// Which funnel stage a download row is: "click" (a door) or "served"
|
||||
// (the file route). Null on every non-download row (FRE-366).
|
||||
name: "stage",
|
||||
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN stage TEXT`
|
||||
},
|
||||
{
|
||||
// The Host the client addressed, e.g. "nook.freno.me" — the site
|
||||
// dimension the table never had (FRE-366).
|
||||
name: "host",
|
||||
ddl: `ALTER TABLE VisitorAnalytics ADD COLUMN host TEXT`
|
||||
}
|
||||
];
|
||||
|
||||
@@ -110,7 +124,9 @@ export const VISITOR_ANALYTICS_COLUMNS = [
|
||||
"inp",
|
||||
"ttfb",
|
||||
"dom_load",
|
||||
"load_complete"
|
||||
"load_complete",
|
||||
"stage",
|
||||
"host"
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
VISITOR_ANALYTICS_INDEXES,
|
||||
VISITOR_ANALYTICS_INSERT
|
||||
} from "./analytics-schema";
|
||||
import { truncateIpAddress } from "./request-client";
|
||||
import { pruneVisitorAnalytics } from "./analytics-retention";
|
||||
|
||||
/**
|
||||
* Idempotent schema bootstrap, mirroring `nook.ts`. The table had no DDL in
|
||||
@@ -63,6 +66,10 @@ export interface AnalyticsEntry {
|
||||
ttfb?: number | null;
|
||||
domLoad?: number | null;
|
||||
loadComplete?: number | null;
|
||||
/** Funnel stage for download rows ("click" | "served"); null elsewhere. */
|
||||
stage?: string | null;
|
||||
/** Host the client addressed, e.g. "nook.freno.me" — the site dimension. */
|
||||
host?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +130,9 @@ async function flushAnalyticsBuffer(): Promise<void> {
|
||||
entry.inp || null,
|
||||
entry.ttfb || null,
|
||||
entry.domLoad || null,
|
||||
entry.loadComplete || null
|
||||
entry.loadComplete || null,
|
||||
entry.stage || null,
|
||||
entry.host || null
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -257,6 +266,8 @@ export async function queryAnalytics(
|
||||
utm_medium: row.utm_medium as string | null,
|
||||
utm_campaign: row.utm_campaign as string | null,
|
||||
duration_ms: row.duration_ms as number | null,
|
||||
stage: row.stage as string | null,
|
||||
host: row.host as string | null,
|
||||
created_at: row.created_at as string
|
||||
}));
|
||||
}
|
||||
@@ -272,6 +283,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;
|
||||
@@ -304,6 +319,10 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||
});
|
||||
const totalApiCalls = (totalApiCallsResult.rows[0]?.count as number) || 0;
|
||||
|
||||
// Since the FRE-369 privacy floor, `ip_address` holds a truncated prefix
|
||||
// (/24 for v4, /56 for v6), so this counts distinct prefixes — households and
|
||||
// mobile exits, not machines. It was never accurate before that either: every
|
||||
// row held the proxy's loopback, so the count was 1.
|
||||
const uniqueVisitorsResult = await conn.execute({
|
||||
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics
|
||||
WHERE ip_address IS NOT NULL
|
||||
@@ -409,6 +428,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 +457,8 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||
topReferrers,
|
||||
deviceTypes,
|
||||
browsers,
|
||||
topCampaigns
|
||||
topCampaigns,
|
||||
downloads
|
||||
};
|
||||
}
|
||||
|
||||
@@ -596,19 +634,15 @@ export async function getPerformanceStats(days: number = 30): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete visitor analytics older than `olderThanDays`. Reachable from tRPC
|
||||
* (admin) and from the daily retention cron (`/api/analytics/retention`);
|
||||
* the SQL and the input rules live in `./analytics-retention`.
|
||||
*/
|
||||
export async function cleanupOldAnalytics(
|
||||
olderThanDays: number
|
||||
): Promise<number> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `DELETE FROM VisitorAnalytics
|
||||
WHERE created_at < datetime('now', '-${olderThanDays} days')
|
||||
RETURNING id`,
|
||||
args: []
|
||||
});
|
||||
|
||||
return result.rows.length;
|
||||
return pruneVisitorAnalytics(ConnectionFactory(), olderThanDays);
|
||||
}
|
||||
|
||||
function parseUserAgent(userAgent?: string): {
|
||||
@@ -643,6 +677,16 @@ function parseUserAgent(userAgent?: string): {
|
||||
return { deviceType, browser, os };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in what the writer did not supply, and apply the privacy floor.
|
||||
*
|
||||
* Every analytics entry — page views, tRPC context, downloads, checkout —
|
||||
* passes through here, which is why the truncation lives here and not at the
|
||||
* call sites: a future writer cannot forget it, and no caller can persist a
|
||||
* full client address by accident (FRE-369, finding Q4). The audit log
|
||||
* (`./audit.ts`) is a separate, pre-existing surface with its own stakes and is
|
||||
* deliberately untouched by this change.
|
||||
*/
|
||||
export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
|
||||
const { deviceType, browser, os } = parseUserAgent(
|
||||
entry.userAgent || undefined
|
||||
@@ -650,6 +694,7 @@ export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
|
||||
|
||||
return {
|
||||
...entry,
|
||||
ipAddress: truncateIpAddress(entry.ipAddress),
|
||||
deviceType: entry.deviceType || deviceType,
|
||||
browser: entry.browser || browser,
|
||||
os: entry.os || os
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "~/server/analytics";
|
||||
import { ConnectionFactory } from "~/server/database";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { resolveClientIp, requestHost } from "~/server/request-client";
|
||||
import { parseUtm } from "~/server/utm";
|
||||
import { VISITOR_ANALYTICS_INSERT } from "~/server/analytics-schema";
|
||||
|
||||
@@ -98,7 +98,8 @@ export const analyticsRouter = createTRPCRouter({
|
||||
getHeader(req.headers, "referer") ||
|
||||
getHeader(req.headers, "referrer") ||
|
||||
getHeader(ctx.event.request?.headers, "referer");
|
||||
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
|
||||
const ipAddress = resolveClientIp(ctx.event) || undefined;
|
||||
const host = requestHost(ctx.event) || undefined;
|
||||
const enriched = enrichAnalyticsEntry({
|
||||
userId: ctx.userId,
|
||||
path: input.path,
|
||||
@@ -106,6 +107,7 @@ export const analyticsRouter = createTRPCRouter({
|
||||
userAgent,
|
||||
referrer,
|
||||
ipAddress,
|
||||
host,
|
||||
// The client beacon sends `pathname + search`, so the page URL's
|
||||
// own query string is the campaign source; the referrer fills any
|
||||
// gap (e.g. clicks that arrive on a clean path).
|
||||
@@ -145,7 +147,9 @@ export const analyticsRouter = createTRPCRouter({
|
||||
enriched.inp || null,
|
||||
enriched.ttfb || null,
|
||||
enriched.domLoad || null,
|
||||
enriched.loadComplete || null
|
||||
enriched.loadComplete || null,
|
||||
enriched.stage || null,
|
||||
enriched.host || null
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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, "click");
|
||||
}
|
||||
|
||||
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||
|
||||
const credentials = {
|
||||
|
||||
370
src/server/api/routers/lineage/pvp-ledger.test.ts
Normal file
370
src/server/api/routers/lineage/pvp-ledger.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Database } from "bun:sqlite";
|
||||
import {
|
||||
PVP_PURCHASE_COOLDOWN_MS,
|
||||
PVP_PURCHASE_COSTS
|
||||
} from "~/server/pvp-rewards";
|
||||
import {
|
||||
purchaseReward,
|
||||
recordBattleResult,
|
||||
registerPvpCharacter,
|
||||
type PvpLedgerConn,
|
||||
type RegisteredCharacter
|
||||
} from "./pvp-ledger";
|
||||
|
||||
/**
|
||||
* FRE-142 ledger tests: the earn/spend authorization and atomicity decisions
|
||||
* behind `lineage.pvp`, exercised against an in-memory SQLite database
|
||||
* wrapped to the libsql `execute({ sql, args }) -> { rows, rowsAffected }`
|
||||
* contract the router uses (same pattern as nessa-community-authz.test.ts).
|
||||
*
|
||||
* Casts to `any` are fine in test fixtures per the repo conventions where
|
||||
* stub rows are simplified objects.
|
||||
*/
|
||||
|
||||
let db: Database;
|
||||
let conn: PvpLedgerConn;
|
||||
|
||||
function makeConn(): PvpLedgerConn {
|
||||
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 Record<string, unknown>[] };
|
||||
}
|
||||
const result = stmt.run(...(args ?? []));
|
||||
return { rows: [], rowsAffected: Number(result.changes) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function character(
|
||||
overrides: Partial<RegisteredCharacter> = {}
|
||||
): RegisteredCharacter {
|
||||
return {
|
||||
playerClass: "mage",
|
||||
blessing: null,
|
||||
name: "Test Mage",
|
||||
maxHealth: 100,
|
||||
maxSanity: 50,
|
||||
maxMana: 100,
|
||||
baseManaRegen: 5,
|
||||
strength: 10,
|
||||
intelligence: 12,
|
||||
dexterity: 8,
|
||||
resistanceTable: "{}",
|
||||
damageTable: "{}",
|
||||
attackStrings: "[]",
|
||||
knownSpells: "[]",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/** Register a character and seed a token balance in one step. */
|
||||
async function seedRow(
|
||||
linkID: string,
|
||||
ownerUserId: string | null,
|
||||
tokens: number
|
||||
): Promise<void> {
|
||||
await registerPvpCharacter(conn, {
|
||||
character: character(),
|
||||
linkID,
|
||||
ownerUserId,
|
||||
seedTokens: tokens
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
conn = makeConn();
|
||||
});
|
||||
|
||||
describe("registerPvpCharacter", () => {
|
||||
it("creates a bound, seeded row for a new character", async () => {
|
||||
const result = await registerPvpCharacter(conn, {
|
||||
character: character(),
|
||||
linkID: "link-1",
|
||||
ownerUserId: "user-1",
|
||||
seedTokens: 7
|
||||
});
|
||||
expect(result.status).toBe(201);
|
||||
expect(result.pvpTokens).toBe(7);
|
||||
expect(result.winCount).toBe(0);
|
||||
|
||||
const row = db
|
||||
.query(`SELECT pvpTokens, ownerUserId FROM PvP_Characters WHERE linkID = 'link-1'`)
|
||||
.get() as { pvpTokens: number; ownerUserId: string };
|
||||
expect(row.pvpTokens).toBe(7);
|
||||
expect(row.ownerUserId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("seeds a NULL balance exactly once — the server value wins afterwards", async () => {
|
||||
await seedRow("link-1", "user-1", 12);
|
||||
|
||||
// A second register with a *different* claimed balance: the stored 12
|
||||
// is authoritative, so 12 is returned and the claim is ignored.
|
||||
const result = await registerPvpCharacter(conn, {
|
||||
character: character(),
|
||||
linkID: "link-1",
|
||||
ownerUserId: "user-1",
|
||||
seedTokens: 999
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.pvpTokens).toBe(12);
|
||||
});
|
||||
|
||||
it("never rebinds an owned row to another user", async () => {
|
||||
await seedRow("link-1", "user-1", 5);
|
||||
|
||||
await registerPvpCharacter(conn, {
|
||||
character: character(),
|
||||
linkID: "link-1",
|
||||
ownerUserId: "user-2",
|
||||
seedTokens: 5
|
||||
});
|
||||
|
||||
const row = db
|
||||
.query(`SELECT ownerUserId FROM PvP_Characters WHERE linkID = 'link-1'`)
|
||||
.get() as { ownerUserId: string };
|
||||
expect(row.ownerUserId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("binds an unbound legacy row on first authenticated register", async () => {
|
||||
await seedRow("legacy", null, 3);
|
||||
|
||||
await registerPvpCharacter(conn, {
|
||||
character: character(),
|
||||
linkID: "legacy",
|
||||
ownerUserId: "user-1",
|
||||
seedTokens: null
|
||||
});
|
||||
|
||||
const row = db
|
||||
.query(`SELECT ownerUserId, pvpTokens FROM PvP_Characters WHERE linkID = 'legacy'`)
|
||||
.get() as { ownerUserId: string; pvpTokens: number };
|
||||
expect(row.ownerUserId).toBe("user-1");
|
||||
// NULL seed does not overwrite the existing balance.
|
||||
expect(row.pvpTokens).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordBattleResult", () => {
|
||||
it("credits the winner with a formula-based reward and returns the balance", async () => {
|
||||
await seedRow("winner", "user-1", 10);
|
||||
await seedRow("loser", "user-2", 0);
|
||||
|
||||
const before = db
|
||||
.query(`SELECT winCount, lossCount FROM PvP_Characters WHERE linkID = 'loser'`)
|
||||
.get() as { winCount: number; lossCount: number };
|
||||
|
||||
const result = await recordBattleResult(conn, {
|
||||
winnerLinkID: "winner",
|
||||
loserLinkID: "loser",
|
||||
callerUserId: "user-1"
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.credited).toBe(true);
|
||||
// Formula: 1 * winRatio(loser 0-0 → 1.0) * power(≈1.0) * class(1.1
|
||||
// mirror) = 1.1 → clamps/rounds within [1, 5].
|
||||
expect(result.pvpTokens).toBeGreaterThanOrEqual(10 + 1);
|
||||
expect(result.pvpTokens).toBeLessThanOrEqual(10 + 5);
|
||||
|
||||
const tallies = db
|
||||
.query(`SELECT winCount, lossCount FROM PvP_Characters WHERE linkID = 'loser'`)
|
||||
.get() as { winCount: number; lossCount: number };
|
||||
expect(tallies.lossCount).toBe(before.lossCount + 1);
|
||||
expect(tallies.winCount).toBe(before.winCount);
|
||||
});
|
||||
|
||||
it("tallies but credits nothing when the loser reports", async () => {
|
||||
await seedRow("winner", "user-1", 10);
|
||||
await seedRow("loser", "user-2", 0);
|
||||
|
||||
const result = await recordBattleResult(conn, {
|
||||
winnerLinkID: "winner",
|
||||
loserLinkID: "loser",
|
||||
callerUserId: "user-2"
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.credited).toBe(false);
|
||||
|
||||
const balance = db
|
||||
.query(`SELECT pvpTokens FROM PvP_Characters WHERE linkID = 'winner'`)
|
||||
.get() as { pvpTokens: number };
|
||||
expect(balance.pvpTokens).toBe(10);
|
||||
});
|
||||
|
||||
it("rejects a report from a non-participant", async () => {
|
||||
await seedRow("winner", "user-1", 10);
|
||||
await seedRow("loser", "user-2", 0);
|
||||
|
||||
const result = await recordBattleResult(conn, {
|
||||
winnerLinkID: "winner",
|
||||
loserLinkID: "loser",
|
||||
callerUserId: "user-3"
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.reason).toBe("not_participant");
|
||||
|
||||
// Nothing was tallied or credited.
|
||||
const tallies = db
|
||||
.query(`SELECT winCount, lossCount FROM PvP_Characters WHERE linkID = 'winner'`)
|
||||
.get() as { winCount: number; lossCount: number };
|
||||
expect(tallies.winCount).toBe(0);
|
||||
});
|
||||
|
||||
it("treats an unregistered loser as a zero-credit win", async () => {
|
||||
await seedRow("winner", "user-1", 4);
|
||||
|
||||
const result = await recordBattleResult(conn, {
|
||||
winnerLinkID: "winner",
|
||||
loserLinkID: "ghost",
|
||||
callerUserId: "user-1"
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.credited).toBe(false);
|
||||
|
||||
const balance = db
|
||||
.query(`SELECT pvpTokens FROM PvP_Characters WHERE linkID = 'winner'`)
|
||||
.get() as { pvpTokens: number };
|
||||
expect(balance.pvpTokens).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purchaseReward", () => {
|
||||
it("spends atomically and returns the new balance", async () => {
|
||||
await seedRow("link-1", "user-1", PVP_PURCHASE_COSTS[2]);
|
||||
|
||||
const result = await purchaseReward(conn, {
|
||||
linkID: "link-1",
|
||||
callerUserId: "user-1",
|
||||
rewardId: 2,
|
||||
now: 1_000_000
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.pvpTokens).toBe(0);
|
||||
const row = db
|
||||
.query(`SELECT pvpTokens, lastPurchaseAt FROM PvP_Characters WHERE linkID = 'link-1'`)
|
||||
.get() as { pvpTokens: number; lastPurchaseAt: number };
|
||||
expect(row.pvpTokens).toBe(0);
|
||||
expect(row.lastPurchaseAt).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("refuses an unknown reward id without touching the row", async () => {
|
||||
await seedRow("link-1", "user-1", 100);
|
||||
|
||||
const result = await purchaseReward(conn, {
|
||||
linkID: "link-1",
|
||||
callerUserId: "user-1",
|
||||
rewardId: 99
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("unknown_reward");
|
||||
});
|
||||
|
||||
it("refuses purchases on a row the caller does not own", async () => {
|
||||
await seedRow("link-1", "user-1", 100);
|
||||
|
||||
const result = await purchaseReward(conn, {
|
||||
linkID: "link-1",
|
||||
callerUserId: "user-2",
|
||||
rewardId: 5
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("not_found");
|
||||
});
|
||||
|
||||
it("refuses purchases on an unregistered linkID", async () => {
|
||||
const result = await purchaseReward(conn, {
|
||||
linkID: "ghost",
|
||||
callerUserId: "user-1",
|
||||
rewardId: 5
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("not_found");
|
||||
});
|
||||
|
||||
it("refuses an insufficient balance", async () => {
|
||||
await seedRow("link-1", "user-1", 3);
|
||||
|
||||
const result = await purchaseReward(conn, {
|
||||
linkID: "link-1",
|
||||
callerUserId: "user-1",
|
||||
rewardId: 1 // costs 5
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("insufficient_tokens");
|
||||
|
||||
const balance = db
|
||||
.query(`SELECT pvpTokens FROM PvP_Characters WHERE linkID = 'link-1'`)
|
||||
.get() as { pvpTokens: number };
|
||||
expect(balance.pvpTokens).toBe(3);
|
||||
});
|
||||
|
||||
it("refuses a purchase inside the cooldown window, then allows it after", async () => {
|
||||
await seedRow("link-1", "user-1", 1000);
|
||||
|
||||
const first = await purchaseReward(conn, {
|
||||
linkID: "link-1",
|
||||
callerUserId: "user-1",
|
||||
rewardId: 5,
|
||||
now: 1_000_000
|
||||
});
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
const throttled = await purchaseReward(conn, {
|
||||
linkID: "link-1",
|
||||
callerUserId: "user-1",
|
||||
rewardId: 5,
|
||||
now: 1_000_000 + PVP_PURCHASE_COOLDOWN_MS - 1
|
||||
});
|
||||
expect(throttled.ok).toBe(false);
|
||||
expect(throttled.reason).toBe("throttled");
|
||||
|
||||
const allowed = await purchaseReward(conn, {
|
||||
linkID: "link-1",
|
||||
callerUserId: "user-1",
|
||||
rewardId: 5,
|
||||
now: 1_000_000 + PVP_PURCHASE_COOLDOWN_MS
|
||||
});
|
||||
expect(allowed.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a NULL (unseeded) balance as insufficient", async () => {
|
||||
// A row that exists but was never seeded (pre-FRE-142 shape).
|
||||
await seedRow("legacy", null, 0);
|
||||
db.run(`UPDATE PvP_Characters SET pvpTokens = NULL WHERE linkID = 'legacy'`);
|
||||
db.run(`UPDATE PvP_Characters SET ownerUserId = 'user-1' WHERE linkID = 'legacy'`);
|
||||
|
||||
const result = await purchaseReward(conn, {
|
||||
linkID: "legacy",
|
||||
callerUserId: "user-1",
|
||||
rewardId: 1
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("insufficient_tokens");
|
||||
});
|
||||
});
|
||||
395
src/server/api/routers/lineage/pvp-ledger.ts
Normal file
395
src/server/api/routers/lineage/pvp-ledger.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* PvP Blood Token ledger operations (FRE-142) — the authoritative earn/spend
|
||||
* logic behind the `lineage.pvp` router.
|
||||
*
|
||||
* Extracted from the router (mirroring the nessa-community authz pattern)
|
||||
* so the authorization + atomicity decisions can be unit-tested against an
|
||||
* in-memory SQLite database wrapped to the libsql `execute` contract. The
|
||||
* router calls these helpers in the same order, so a pass here guarantees
|
||||
* the decision each endpoint makes before touching data.
|
||||
*
|
||||
* Ledger invariants:
|
||||
* - `pvpTokens` is server-owned. NULL means "not yet seeded"; the first
|
||||
* registerCharacter call seeds it from the client's cached balance
|
||||
* exactly once (one-time migration of pre-FRE-142 balances).
|
||||
* - Earnings happen only in `recordBattleResult`, computed from both
|
||||
* combatants' registered rows via the ported reward formula — never
|
||||
* from client-supplied amounts.
|
||||
* - Spend happens only in `purchaseReward`, as a single conditional
|
||||
* UPDATE (balance + cooldown checked in the WHERE clause) so it is
|
||||
* atomic under concurrency.
|
||||
* - `ownerUserId` (from the verified Lineage JWT) gates every mutation:
|
||||
* a caller can only earn on, and spend from, a row they own.
|
||||
*/
|
||||
|
||||
import { ensurePvpSchema } from "~/server/pvp-schema";
|
||||
import {
|
||||
PVP_PURCHASE_COOLDOWN_MS,
|
||||
PVP_PURCHASE_COSTS,
|
||||
computeWinReward,
|
||||
type PvpCombatantRow
|
||||
} from "~/server/pvp-rewards";
|
||||
|
||||
/** libsql-shaped connection (real client or the bun:sqlite test adapter). */
|
||||
export interface PvpLedgerConn {
|
||||
execute(stmt: {
|
||||
sql: string;
|
||||
args?: (string | number | null)[];
|
||||
}): Promise<{ rows: Record<string, unknown>[]; rowsAffected?: number }>;
|
||||
}
|
||||
|
||||
/** Registered-character fields as persisted on `PvP_Characters`. */
|
||||
export interface RegisteredCharacter {
|
||||
playerClass: string;
|
||||
blessing?: string | null;
|
||||
name: string;
|
||||
maxHealth: number;
|
||||
maxSanity: number;
|
||||
maxMana: number;
|
||||
baseManaRegen: number;
|
||||
strength: number;
|
||||
intelligence: number;
|
||||
dexterity: number;
|
||||
resistanceTable: string;
|
||||
damageTable: string;
|
||||
attackStrings: string;
|
||||
knownSpells: string;
|
||||
}
|
||||
|
||||
function num(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or refresh) the caller's character row and reconcile the ledger.
|
||||
*
|
||||
* - New row: seeded with the caller's cached balance (`seedTokens`) — a
|
||||
* fresh character is 0 — and bound to `ownerUserId` when the caller is
|
||||
* authenticated.
|
||||
* - Existing row: character fields refresh; `ownerUserId` binds only if
|
||||
* still NULL (never rebinds away from another owner); `pvpTokens` seeds
|
||||
* only if still NULL (after that the server value is authoritative and
|
||||
* is returned so the caller can reconcile).
|
||||
*/
|
||||
export async function registerPvpCharacter(
|
||||
conn: PvpLedgerConn,
|
||||
params: {
|
||||
character: RegisteredCharacter;
|
||||
linkID: string;
|
||||
ownerUserId: string | null;
|
||||
seedTokens: number | null;
|
||||
}
|
||||
): Promise<{
|
||||
winCount: number;
|
||||
lossCount: number;
|
||||
tokenRedemptionCount: number;
|
||||
pvpTokens: number;
|
||||
status: number;
|
||||
}> {
|
||||
const { character, linkID, ownerUserId, seedTokens } = params;
|
||||
await ensurePvpSchema(conn);
|
||||
|
||||
const existing = await conn.execute({
|
||||
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
|
||||
args: [linkID]
|
||||
});
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO PvP_Characters (
|
||||
linkID,
|
||||
blessing,
|
||||
playerClass,
|
||||
name,
|
||||
maxHealth,
|
||||
maxSanity,
|
||||
maxMana,
|
||||
baseManaRegen,
|
||||
strength,
|
||||
intelligence,
|
||||
dexterity,
|
||||
resistanceTable,
|
||||
damageTable,
|
||||
attackStrings,
|
||||
knownSpells,
|
||||
pushToken,
|
||||
pushCurrentlyEnabled,
|
||||
pvpTokens,
|
||||
ownerUserId
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
linkID,
|
||||
character.blessing ?? null,
|
||||
character.playerClass,
|
||||
character.name,
|
||||
character.maxHealth,
|
||||
character.maxSanity,
|
||||
character.maxMana,
|
||||
character.baseManaRegen,
|
||||
character.strength,
|
||||
character.intelligence,
|
||||
character.dexterity,
|
||||
character.resistanceTable,
|
||||
character.damageTable,
|
||||
character.attackStrings,
|
||||
character.knownSpells,
|
||||
null,
|
||||
null,
|
||||
seedTokens ?? 0,
|
||||
ownerUserId
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
winCount: 0,
|
||||
lossCount: 0,
|
||||
tokenRedemptionCount: 0,
|
||||
pvpTokens: seedTokens ?? 0,
|
||||
status: 201
|
||||
};
|
||||
}
|
||||
|
||||
const row = existing.rows[0];
|
||||
await conn.execute({
|
||||
sql: `UPDATE PvP_Characters SET
|
||||
playerClass = ?,
|
||||
blessing = ?,
|
||||
name = ?,
|
||||
maxHealth = ?,
|
||||
maxSanity = ?,
|
||||
maxMana = ?,
|
||||
baseManaRegen = ?,
|
||||
strength = ?,
|
||||
intelligence = ?,
|
||||
dexterity = ?,
|
||||
resistanceTable = ?,
|
||||
damageTable = ?,
|
||||
attackStrings = ?,
|
||||
knownSpells = ?,
|
||||
ownerUserId = COALESCE(ownerUserId, ?),
|
||||
pvpTokens = COALESCE(pvpTokens, ?)
|
||||
WHERE linkID = ?`,
|
||||
args: [
|
||||
character.playerClass,
|
||||
character.blessing ?? null,
|
||||
character.name,
|
||||
character.maxHealth,
|
||||
character.maxSanity,
|
||||
character.maxMana,
|
||||
character.baseManaRegen,
|
||||
character.strength,
|
||||
character.intelligence,
|
||||
character.dexterity,
|
||||
character.resistanceTable,
|
||||
character.damageTable,
|
||||
character.attackStrings,
|
||||
character.knownSpells,
|
||||
ownerUserId,
|
||||
seedTokens,
|
||||
linkID
|
||||
]
|
||||
});
|
||||
|
||||
// The post-update balance is authoritative (seeded now if it was NULL).
|
||||
const reconciled = await conn.execute({
|
||||
sql: `SELECT pvpTokens FROM PvP_Characters WHERE linkID = ?`,
|
||||
args: [linkID]
|
||||
});
|
||||
const pvpTokens = num(reconciled.rows[0]?.pvpTokens);
|
||||
|
||||
return {
|
||||
winCount: num(row.winCount),
|
||||
lossCount: num(row.lossCount),
|
||||
tokenRedemptionCount: num(row.tokenRedemptionCount),
|
||||
pvpTokens,
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a battle outcome and credit the winner's Blood Tokens.
|
||||
*
|
||||
* The caller (verified via `callerUserId`) must own either the winner's or
|
||||
* the loser's row — battle results reported by non-participants are
|
||||
* rejected. Only a caller who owns the *winner's* row earns tokens; the
|
||||
* reward is computed server-side from both registered rows, so the
|
||||
* client-supplied linkIDs cannot influence the amount beyond picking the
|
||||
* participants.
|
||||
*/
|
||||
export async function recordBattleResult(
|
||||
conn: PvpLedgerConn,
|
||||
params: {
|
||||
winnerLinkID: string;
|
||||
loserLinkID: string;
|
||||
callerUserId: string;
|
||||
}
|
||||
): Promise<
|
||||
| { ok: true; status: number; pvpTokens: number; credited: boolean }
|
||||
| { ok: false; status: number; reason: "not_participant" }
|
||||
> {
|
||||
const { winnerLinkID, loserLinkID, callerUserId } = params;
|
||||
await ensurePvpSchema(conn);
|
||||
|
||||
const callerRows = await conn.execute({
|
||||
sql: `SELECT linkID FROM PvP_Characters WHERE ownerUserId = ?`,
|
||||
args: [callerUserId]
|
||||
});
|
||||
const callerLinkIDs = new Set(callerRows.rows.map((row) => String(row.linkID)));
|
||||
|
||||
if (!callerLinkIDs.has(winnerLinkID) && !callerLinkIDs.has(loserLinkID)) {
|
||||
return { ok: false, status: 403, reason: "not_participant" };
|
||||
}
|
||||
|
||||
await conn.execute({
|
||||
sql: `
|
||||
UPDATE PvP_Characters
|
||||
SET
|
||||
winCount = winCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END,
|
||||
lossCount = lossCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END
|
||||
WHERE linkID IN (?, ?)
|
||||
`,
|
||||
args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID]
|
||||
});
|
||||
|
||||
const callerIsWinner = callerLinkIDs.has(winnerLinkID);
|
||||
if (!callerIsWinner) {
|
||||
// The loser reports the same outcome for tally purposes; no tokens move.
|
||||
return { ok: true, status: 200, pvpTokens: 0, credited: false };
|
||||
}
|
||||
|
||||
const winnerRes = await conn.execute({
|
||||
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
|
||||
args: [winnerLinkID]
|
||||
});
|
||||
const loserRes = await conn.execute({
|
||||
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
|
||||
args: [loserLinkID]
|
||||
});
|
||||
if (winnerRes.rows.length === 0 || loserRes.rows.length === 0) {
|
||||
// An unregistered combatant cannot be rewarded — but the tallies above
|
||||
// still applied to whatever rows exist, matching pre-FRE-142 behaviour.
|
||||
return { ok: true, status: 200, pvpTokens: 0, credited: false };
|
||||
}
|
||||
|
||||
const winnerRow = winnerRes.rows[0];
|
||||
const loserRow = loserRes.rows[0];
|
||||
const reward = computeWinReward(
|
||||
{
|
||||
playerClass: String(winnerRow.playerClass),
|
||||
strength: num(winnerRow.strength),
|
||||
intelligence: num(winnerRow.intelligence),
|
||||
dexterity: num(winnerRow.dexterity),
|
||||
maxHealth: num(winnerRow.maxHealth),
|
||||
winCount: num(winnerRow.winCount),
|
||||
lossCount: num(winnerRow.lossCount)
|
||||
} satisfies PvpCombatantRow,
|
||||
{
|
||||
playerClass: String(loserRow.playerClass),
|
||||
strength: num(loserRow.strength),
|
||||
intelligence: num(loserRow.intelligence),
|
||||
dexterity: num(loserRow.dexterity),
|
||||
maxHealth: num(loserRow.maxHealth),
|
||||
winCount: num(loserRow.winCount),
|
||||
lossCount: num(loserRow.lossCount)
|
||||
} satisfies PvpCombatantRow
|
||||
);
|
||||
|
||||
await conn.execute({
|
||||
sql: `UPDATE PvP_Characters
|
||||
SET pvpTokens = COALESCE(pvpTokens, 0) + ?
|
||||
WHERE linkID = ?`,
|
||||
args: [reward, winnerLinkID]
|
||||
});
|
||||
|
||||
const balance = await conn.execute({
|
||||
sql: `SELECT pvpTokens FROM PvP_Characters WHERE linkID = ?`,
|
||||
args: [winnerLinkID]
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
pvpTokens: num(balance.rows[0]?.pvpTokens),
|
||||
credited: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend Blood Tokens on a reward atomically.
|
||||
*
|
||||
* The balance check, cooldown check, spend, and cooldown stamp all happen in
|
||||
* one conditional UPDATE — under concurrency exactly one caller can commit
|
||||
* a spend that satisfies the WHERE clause. `reason` explains a refusal:
|
||||
* unknown reward id, no owned row for the linkID, insufficient tokens, or
|
||||
* cooldown in effect.
|
||||
*/
|
||||
export async function purchaseReward(
|
||||
conn: PvpLedgerConn,
|
||||
params: {
|
||||
linkID: string;
|
||||
callerUserId: string;
|
||||
rewardId: number;
|
||||
now?: number;
|
||||
}
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
pvpTokens?: number;
|
||||
reason?: "unknown_reward" | "not_found" | "insufficient_tokens" | "throttled";
|
||||
}> {
|
||||
const { linkID, callerUserId, rewardId } = params;
|
||||
await ensurePvpSchema(conn);
|
||||
|
||||
const cost = PVP_PURCHASE_COSTS[rewardId];
|
||||
if (cost === undefined) {
|
||||
return { ok: false, reason: "unknown_reward" };
|
||||
}
|
||||
|
||||
const now = params.now ?? Date.now();
|
||||
const spend = await conn.execute({
|
||||
sql: `UPDATE PvP_Characters
|
||||
SET pvpTokens = pvpTokens - ?,
|
||||
lastPurchaseAt = ?
|
||||
WHERE linkID = ?
|
||||
AND ownerUserId = ?
|
||||
AND pvpTokens IS NOT NULL
|
||||
AND pvpTokens >= ?
|
||||
AND (lastPurchaseAt IS NULL OR ? - lastPurchaseAt >= ?)`,
|
||||
args: [
|
||||
cost,
|
||||
now,
|
||||
linkID,
|
||||
callerUserId,
|
||||
cost,
|
||||
now,
|
||||
PVP_PURCHASE_COOLDOWN_MS
|
||||
]
|
||||
});
|
||||
|
||||
if ((spend.rowsAffected ?? 0) === 1) {
|
||||
const balance = await conn.execute({
|
||||
sql: `SELECT pvpTokens FROM PvP_Characters WHERE linkID = ?`,
|
||||
args: [linkID]
|
||||
});
|
||||
return { ok: true, pvpTokens: num(balance.rows[0]?.pvpTokens) };
|
||||
}
|
||||
|
||||
// The conditional UPDATE refused — determine why, for observability and
|
||||
// a precise client message.
|
||||
const row = (
|
||||
await conn.execute({
|
||||
sql: `SELECT ownerUserId, pvpTokens, lastPurchaseAt FROM PvP_Characters WHERE linkID = ?`,
|
||||
args: [linkID]
|
||||
})
|
||||
).rows[0];
|
||||
|
||||
if (!row || row.ownerUserId !== callerUserId) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
if (row.pvpTokens === null || row.pvpTokens === undefined) {
|
||||
return { ok: false, reason: "insufficient_tokens" };
|
||||
}
|
||||
if (num(row.pvpTokens) < cost) {
|
||||
return { ok: false, reason: "insufficient_tokens" };
|
||||
}
|
||||
return { ok: false, reason: "throttled" };
|
||||
}
|
||||
@@ -5,7 +5,14 @@ import {
|
||||
} from "../../utils";
|
||||
import { z } from "zod";
|
||||
import { LineageConnectionFactory } from "~/server/utils";
|
||||
import { verifyLineageAuthToken } from "~/server/auth";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import {
|
||||
purchaseReward,
|
||||
recordBattleResult,
|
||||
registerPvpCharacter
|
||||
} from "./pvp-ledger";
|
||||
|
||||
const characterSchema = z.object({
|
||||
playerClass: z.string(),
|
||||
@@ -24,6 +31,25 @@ const characterSchema = z.object({
|
||||
knownSpells: z.string()
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract the caller's Lineage user id from the Authorization bearer.
|
||||
*
|
||||
* FRE-142 / p8-004: the mobile client attaches its Lineage email JWT to all
|
||||
* `/pvp*` requests (`authHeaders()` in PVPStore). Verifying it here binds
|
||||
* every earn/spend mutation to an authenticated owner — anonymous callers
|
||||
* can register (legacy behaviour) but cannot earn or spend.
|
||||
*/
|
||||
async function authenticatedUserId(
|
||||
event: APIEvent
|
||||
): Promise<string | null> {
|
||||
const header = event.request?.headers?.get("authorization");
|
||||
const match = header?.match(/^Bearer\s+(.+)$/i);
|
||||
const token = match?.[1]?.trim();
|
||||
if (!token) return null;
|
||||
const payload = await verifyLineageAuthToken(token);
|
||||
return payload?.userId ?? null;
|
||||
}
|
||||
|
||||
export const lineagePvpRouter = createTRPCRouter({
|
||||
registerCharacter: csrfProtectedProcedure
|
||||
.input(
|
||||
@@ -31,117 +57,25 @@ export const lineagePvpRouter = createTRPCRouter({
|
||||
character: characterSchema,
|
||||
linkID: z.string(),
|
||||
pushToken: z.string().optional(),
|
||||
pushCurrentlyEnabled: z.boolean().optional()
|
||||
pushCurrentlyEnabled: z.boolean().optional(),
|
||||
// The caller's cached local balance — used only to seed the
|
||||
// server-authoritative ledger exactly once (pre-FRE-142 migration).
|
||||
// After that the server value is authoritative and this is ignored.
|
||||
pvpTokens: z.number().int().min(0).nullish()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const { character, linkID, pushToken, pushCurrentlyEnabled } = input;
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { character, linkID, pvpTokens } = input;
|
||||
|
||||
try {
|
||||
const conn = LineageConnectionFactory();
|
||||
const res = await conn.execute({
|
||||
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
|
||||
args: [linkID]
|
||||
});
|
||||
|
||||
if (res.rows.length === 0) {
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO PvP_Characters (
|
||||
const ownerUserId = await authenticatedUserId(ctx.event);
|
||||
return await registerPvpCharacter(conn, {
|
||||
character,
|
||||
linkID,
|
||||
blessing,
|
||||
playerClass,
|
||||
name,
|
||||
maxHealth,
|
||||
maxSanity,
|
||||
maxMana,
|
||||
baseManaRegen,
|
||||
strength,
|
||||
intelligence,
|
||||
dexterity,
|
||||
resistanceTable,
|
||||
damageTable,
|
||||
attackStrings,
|
||||
knownSpells,
|
||||
pushToken,
|
||||
pushCurrentlyEnabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
linkID,
|
||||
character.blessing,
|
||||
character.playerClass,
|
||||
character.name,
|
||||
character.maxHealth,
|
||||
character.maxSanity,
|
||||
character.maxMana,
|
||||
character.baseManaRegen,
|
||||
character.strength,
|
||||
character.intelligence,
|
||||
character.dexterity,
|
||||
character.resistanceTable,
|
||||
character.damageTable,
|
||||
character.attackStrings,
|
||||
character.knownSpells,
|
||||
pushToken,
|
||||
pushCurrentlyEnabled
|
||||
]
|
||||
ownerUserId,
|
||||
seedTokens: pvpTokens ?? null
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
winCount: 0,
|
||||
lossCount: 0,
|
||||
tokenRedemptionCount: 0,
|
||||
status: 201
|
||||
};
|
||||
} else {
|
||||
await conn.execute({
|
||||
sql: `UPDATE PvP_Characters SET
|
||||
playerClass = ?,
|
||||
blessing = ?,
|
||||
name = ?,
|
||||
maxHealth = ?,
|
||||
maxSanity = ?,
|
||||
maxMana = ?,
|
||||
baseManaRegen = ?,
|
||||
strength = ?,
|
||||
intelligence = ?,
|
||||
dexterity = ?,
|
||||
resistanceTable = ?,
|
||||
damageTable = ?,
|
||||
attackStrings = ?,
|
||||
knownSpells = ?,
|
||||
pushToken = ?,
|
||||
pushCurrentlyEnabled = ?
|
||||
WHERE linkID = ?`,
|
||||
args: [
|
||||
character.playerClass,
|
||||
character.blessing,
|
||||
character.name,
|
||||
character.maxHealth,
|
||||
character.maxSanity,
|
||||
character.maxMana,
|
||||
character.baseManaRegen,
|
||||
character.strength,
|
||||
character.intelligence,
|
||||
character.dexterity,
|
||||
character.resistanceTable,
|
||||
character.damageTable,
|
||||
character.attackStrings,
|
||||
character.knownSpells,
|
||||
pushToken,
|
||||
pushCurrentlyEnabled,
|
||||
linkID
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
winCount: res.rows[0].winCount as number,
|
||||
lossCount: res.rows[0].lossCount as number,
|
||||
tokenRedemptionCount: res.rows[0].tokenRedemptionCount as number,
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new TRPCError({
|
||||
@@ -202,33 +136,83 @@ export const lineagePvpRouter = createTRPCRouter({
|
||||
loserLinkID: z.string()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { winnerLinkID, loserLinkID } = input;
|
||||
|
||||
const callerUserId = await authenticatedUserId(ctx.event);
|
||||
if (!callerUserId) {
|
||||
// p8-004: unauthenticated callers can no longer report (and mint)
|
||||
// battle results — earnings are server-authoritative (FRE-142).
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required to report battle results"
|
||||
});
|
||||
}
|
||||
|
||||
const conn = LineageConnectionFactory();
|
||||
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: `
|
||||
UPDATE PvP_Characters
|
||||
SET
|
||||
winCount = winCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END,
|
||||
lossCount = lossCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END
|
||||
WHERE linkID IN (?, ?)
|
||||
`,
|
||||
args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID]
|
||||
const result = await recordBattleResult(conn, {
|
||||
winnerLinkID,
|
||||
loserLinkID,
|
||||
callerUserId
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200
|
||||
};
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Caller is not a participant in this battle"
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof TRPCError) throw e;
|
||||
console.error(e);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to record battle result"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* FRE-142: server-authoritative Blood Token purchase. The cost comes from
|
||||
* the server's own reward table (never the client), the spend is a single
|
||||
* conditional UPDATE (atomic check-and-spend), and the cooldown is stamped
|
||||
* in the row so it survives serverless cold starts. Returns the new
|
||||
* authoritative balance on success; `reason` explains a refusal.
|
||||
*/
|
||||
purchase: csrfProtectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
linkID: z.string().min(1),
|
||||
rewardId: z.number().int()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { linkID, rewardId } = input;
|
||||
|
||||
const callerUserId = await authenticatedUserId(ctx.event);
|
||||
if (!callerUserId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required to purchase rewards"
|
||||
});
|
||||
}
|
||||
|
||||
const conn = LineageConnectionFactory();
|
||||
|
||||
try {
|
||||
return await purchaseReward(conn, {
|
||||
linkID,
|
||||
callerUserId,
|
||||
rewardId
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to purchase reward"
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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
@@ -1,16 +1,13 @@
|
||||
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 { resolveClientIp, requestHost } from "~/server/request-client";
|
||||
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 */
|
||||
@@ -54,45 +51,11 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
getHeader(req.headers, "referer") ||
|
||||
getHeader(req.headers, "referrer") ||
|
||||
getHeader(event.request?.headers, "referer");
|
||||
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Behind the production proxy the direct peer is loopback, so the real
|
||||
// client address comes from the forwarded header the proxy sets — see
|
||||
// `~/server/request-client` for the trust boundary (FRE-366).
|
||||
const ipAddress = resolveClientIp(event) || undefined;
|
||||
const host = requestHost(event) || undefined;
|
||||
|
||||
// Don't log the performance logging endpoint itself to avoid circular tracking
|
||||
if (!path.includes("analytics.logPerformance")) {
|
||||
@@ -103,7 +66,8 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
method,
|
||||
userAgent,
|
||||
referrer,
|
||||
ipAddress
|
||||
ipAddress,
|
||||
host
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -111,8 +75,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
return {
|
||||
event,
|
||||
userId,
|
||||
isAdmin,
|
||||
nessaUserId
|
||||
isAdmin
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,24 +115,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,
|
||||
};
|
||||
}
|
||||
116
src/server/pvp-rewards.test.ts
Normal file
116
src/server/pvp-rewards.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
PVP_PURCHASE_COSTS,
|
||||
clampReward,
|
||||
computeWinReward,
|
||||
winRatioFactor,
|
||||
powerDistanceFactor,
|
||||
classMatchupFactor,
|
||||
type PvpCombatantRow
|
||||
} from "./pvp-rewards";
|
||||
|
||||
// Ported from the lineage client (`entities/__tests__/AIPlayerCharacter.test.ts`
|
||||
// rewardValue blocks). These tests pin the server's copy of the formula to
|
||||
// the same behaviour so the two cannot silently drift apart.
|
||||
|
||||
function combatant(overrides: Partial<PvpCombatantRow> = {}): PvpCombatantRow {
|
||||
return {
|
||||
playerClass: "mage",
|
||||
strength: 10,
|
||||
intelligence: 10,
|
||||
dexterity: 10,
|
||||
maxHealth: 100,
|
||||
winCount: 0,
|
||||
lossCount: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("winRatioFactor", () => {
|
||||
it("returns 1.0 for a combatant with no recorded fights", () => {
|
||||
expect(winRatioFactor(0, 0)).toBe(1.0);
|
||||
});
|
||||
|
||||
it("scales from 0.5 (all losses) to 1.5 (all wins)", () => {
|
||||
expect(winRatioFactor(0, 10)).toBe(0.5);
|
||||
expect(winRatioFactor(5, 5)).toBe(1.0);
|
||||
expect(winRatioFactor(10, 0)).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("powerDistanceFactor", () => {
|
||||
it("returns 1.0 when the winner's power is non-positive", () => {
|
||||
expect(powerDistanceFactor(100, 0)).toBe(1.0);
|
||||
});
|
||||
|
||||
it("clamps to [0.5, 2.0]", () => {
|
||||
expect(powerDistanceFactor(1, 100)).toBe(0.5);
|
||||
expect(powerDistanceFactor(1000, 100)).toBe(2.0);
|
||||
});
|
||||
|
||||
it("is neutral for an even match", () => {
|
||||
expect(powerDistanceFactor(130, 130)).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classMatchupFactor", () => {
|
||||
it("boosts mirror matches by 10%", () => {
|
||||
expect(classMatchupFactor("warrior", "warrior")).toBe(1.1);
|
||||
});
|
||||
|
||||
it("is neutral for cross-class matchups", () => {
|
||||
expect(classMatchupFactor("warrior", "mage")).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampReward", () => {
|
||||
it("clamps to the inclusive [1, 5] range", () => {
|
||||
expect(clampReward(0.2)).toBe(1);
|
||||
expect(clampReward(3.6)).toBe(4);
|
||||
expect(clampReward(99)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeWinReward", () => {
|
||||
it("returns a value within [PVP_REWARD_MIN, PVP_REWARD_MAX]", () => {
|
||||
const reward = computeWinReward(combatant(), combatant());
|
||||
expect(reward).toBeGreaterThanOrEqual(1);
|
||||
expect(reward).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("rewards beating a stronger, winning opponent more than a weak one", () => {
|
||||
const winner = combatant();
|
||||
const strongLoser = combatant({
|
||||
strength: 50,
|
||||
intelligence: 50,
|
||||
dexterity: 50,
|
||||
maxHealth: 400,
|
||||
winCount: 8,
|
||||
lossCount: 2
|
||||
});
|
||||
const weakLoser = combatant({
|
||||
strength: 1,
|
||||
intelligence: 1,
|
||||
dexterity: 1,
|
||||
maxHealth: 10,
|
||||
winCount: 0,
|
||||
lossCount: 9
|
||||
});
|
||||
const strongReward = computeWinReward(winner, strongLoser);
|
||||
const weakReward = computeWinReward(winner, weakLoser);
|
||||
expect(strongReward).toBeGreaterThan(weakReward);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PVP_PURCHASE_COSTS", () => {
|
||||
it("has a cost for every reward id the lineage client can purchase", () => {
|
||||
// Mirror of assets/json/pvpRewards.json in the lineage repo.
|
||||
expect(PVP_PURCHASE_COSTS[1]).toBe(5);
|
||||
expect(PVP_PURCHASE_COSTS[2]).toBe(15);
|
||||
expect(PVP_PURCHASE_COSTS[3]).toBe(20);
|
||||
expect(PVP_PURCHASE_COSTS[4]).toBe(25);
|
||||
expect(PVP_PURCHASE_COSTS[5]).toBe(15);
|
||||
expect(PVP_PURCHASE_COSTS[6]).toBe(40);
|
||||
expect(Object.keys(PVP_PURCHASE_COSTS)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
136
src/server/pvp-rewards.ts
Normal file
136
src/server/pvp-rewards.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Blood Token win-reward calculation (FRE-142), server-side authority.
|
||||
*
|
||||
* Ported from the lineage client (`entities/AIPlayerCharacter.ts`) so the
|
||||
* server credits the same reward a win would have earned client-side. The
|
||||
* client's copy remains for UI display only ("Reward For Winning"); the
|
||||
* server's result is authoritative.
|
||||
*
|
||||
* One deliberate difference: the client computes the *winner's* power from
|
||||
* the live player state; the server uses the winner's registered character
|
||||
* snapshot (refreshed on every arena visit), so the two can drift slightly
|
||||
* within a session — never enough to leave the [1, 5] clamp range in a way
|
||||
* that matters.
|
||||
*/
|
||||
|
||||
/** Minimum reward (in Blood Tokens) a player can earn per PVP win. */
|
||||
export const PVP_REWARD_MIN = 1;
|
||||
|
||||
/** Maximum reward a player can earn per PVP win. */
|
||||
export const PVP_REWARD_MAX = 5;
|
||||
|
||||
/** Base reward before multipliers are applied. */
|
||||
const PVP_REWARD_BASE = 1;
|
||||
|
||||
/** Clamp `value` to the inclusive range [`min`, `max`]. */
|
||||
export function clampReward(
|
||||
value: number,
|
||||
min = PVP_REWARD_MIN,
|
||||
max = PVP_REWARD_MAX
|
||||
): number {
|
||||
return Math.max(min, Math.min(max, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Win-ratio → multiplier factor.
|
||||
*
|
||||
* - 0% win rate → 0.5×
|
||||
* - 50% win rate → 1.0× (neutral)
|
||||
* - 100% win rate → 1.5×
|
||||
*
|
||||
* When the opponent has no recorded fights (total === 0) the factor is 1.0.
|
||||
*/
|
||||
export function winRatioFactor(winCount: number, lossCount: number): number {
|
||||
const total = winCount + lossCount;
|
||||
if (total <= 0) return 1.0;
|
||||
const ratio = winCount / total;
|
||||
return 0.5 + ratio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Power-distance factor: compare the loser's raw combat stats against the
|
||||
* winner's to reward riskier fights. Clamped to [0.5, 2.0].
|
||||
*/
|
||||
export function powerDistanceFactor(
|
||||
loserPower: number,
|
||||
winnerPower: number
|
||||
): number {
|
||||
if (winnerPower <= 0) return 1.0;
|
||||
const ratio = loserPower / winnerPower;
|
||||
return Math.max(0.5, Math.min(2.0, ratio));
|
||||
}
|
||||
|
||||
/** Rough power-level proxy: strength + intelligence + dexterity + maxHealth. */
|
||||
export function computeCombatPower(
|
||||
strength: number,
|
||||
intelligence: number,
|
||||
dexterity: number,
|
||||
maxHealth: number
|
||||
): number {
|
||||
return strength + intelligence + dexterity + maxHealth;
|
||||
}
|
||||
|
||||
/** Server-side purchase rate limit window (ms), mirrors the lineage
|
||||
* client's `PURCHASE_COOLDOWN_MS`. Enforced via the `lastPurchaseAt`
|
||||
* column so it survives serverless cold starts. */
|
||||
export const PVP_PURCHASE_COOLDOWN_MS = 1000;
|
||||
|
||||
/**
|
||||
* Blood Token cost per reward id. Mirrors the lineage client's
|
||||
* `assets/json/pvpRewards.json` — the client's copy is display-only; this
|
||||
* table is authoritative. Costs must never be accepted from the client.
|
||||
*/
|
||||
export const PVP_PURCHASE_COSTS: Record<number, number> = {
|
||||
1: 5, // respec potion
|
||||
2: 15, // tome of knowledge (1 skill point)
|
||||
3: 20, // potion of undeath (age reduction)
|
||||
4: 25, // elixir of renewal (full restore)
|
||||
5: 15, // bag of gold (500 gold)
|
||||
6: 40 // scroll of mastery (3 skill points)
|
||||
};
|
||||
|
||||
/** A mirror match (same class) is slightly harder — 10% reward boost. */
|
||||
export function classMatchupFactor(
|
||||
winnerClass: string,
|
||||
loserClass: string
|
||||
): number {
|
||||
return winnerClass === loserClass ? 1.1 : 1.0;
|
||||
}
|
||||
|
||||
/** The registered-character fields the reward computation reads. */
|
||||
export interface PvpCombatantRow {
|
||||
playerClass: string;
|
||||
strength: number;
|
||||
intelligence: number;
|
||||
dexterity: number;
|
||||
maxHealth: number;
|
||||
winCount: number;
|
||||
lossCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Blood Token reward the winner earns for defeating the loser,
|
||||
* from both combatants' registered rows.
|
||||
*/
|
||||
export function computeWinReward(
|
||||
winner: PvpCombatantRow,
|
||||
loser: PvpCombatantRow
|
||||
): number {
|
||||
const wFactor = winRatioFactor(loser.winCount, loser.lossCount);
|
||||
const loserPower = computeCombatPower(
|
||||
loser.strength,
|
||||
loser.intelligence,
|
||||
loser.dexterity,
|
||||
loser.maxHealth
|
||||
);
|
||||
const winnerPower = computeCombatPower(
|
||||
winner.strength,
|
||||
winner.intelligence,
|
||||
winner.dexterity,
|
||||
winner.maxHealth
|
||||
);
|
||||
const pFactor = powerDistanceFactor(loserPower, winnerPower);
|
||||
const cFactor = classMatchupFactor(winner.playerClass, loser.playerClass);
|
||||
|
||||
return clampReward(PVP_REWARD_BASE * wFactor * pFactor * cFactor);
|
||||
}
|
||||
121
src/server/pvp-schema.test.ts
Normal file
121
src/server/pvp-schema.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, beforeEach } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import {
|
||||
PVP_CHARACTERS_ADDED_COLUMNS,
|
||||
PVP_CHARACTERS_DDL,
|
||||
ensurePvpSchema
|
||||
} from "./pvp-schema";
|
||||
|
||||
// Same rationale as analytics-schema.test.ts: PvP_Characters lived only in
|
||||
// the live Turso database, so nothing proved the DDL, the added columns, and
|
||||
// the router's statements agreed. These tests apply the real schema to a
|
||||
// throwaway in-memory database and exercise the idempotent applier against
|
||||
// both a fresh database and a pre-FRE-142 table shape.
|
||||
|
||||
let db: Database;
|
||||
|
||||
/** Minimal adapter so `ensurePvpSchema` can drive bun:sqlite. */
|
||||
function sqliteConn(db: Database) {
|
||||
return {
|
||||
execute: async (stmt: { sql: string }) => {
|
||||
const sql = stmt.sql.trim().toUpperCase();
|
||||
if (sql.startsWith("SELECT")) {
|
||||
return { rows: db.query(stmt.sql).all() };
|
||||
}
|
||||
db.run(stmt.sql);
|
||||
return { rows: [] };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function columnNames(table: string): string[] {
|
||||
return db
|
||||
.query(`SELECT name FROM pragma_table_info('${table}')`)
|
||||
.all()
|
||||
.map((row) => (row as { name: string }).name);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
});
|
||||
|
||||
describe("PVP_CHARACTERS_DDL", () => {
|
||||
it("creates the table with every column the router touches", () => {
|
||||
db.run(PVP_CHARACTERS_DDL);
|
||||
const columns = columnNames("PvP_Characters");
|
||||
expect(columns).toContain("linkID");
|
||||
expect(columns).toContain("winCount");
|
||||
expect(columns).toContain("lossCount");
|
||||
expect(columns).toContain("tokenRedemptionCount");
|
||||
expect(columns).toContain("pvpTokens");
|
||||
expect(columns).toContain("ownerUserId");
|
||||
expect(columns).toContain("lastPurchaseAt");
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
db.run(PVP_CHARACTERS_DDL);
|
||||
db.run(PVP_CHARACTERS_DDL);
|
||||
expect(columnNames("PvP_Characters").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensurePvpSchema", () => {
|
||||
it("creates the full table on a fresh database", async () => {
|
||||
await ensurePvpSchema(sqliteConn(db));
|
||||
const columns = columnNames("PvP_Characters");
|
||||
for (const column of PVP_CHARACTERS_ADDED_COLUMNS) {
|
||||
expect(columns).toContain(column.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds the FRE-142 columns to a pre-existing legacy table", async () => {
|
||||
// The legacy shape: everything except the three new columns.
|
||||
db.run(`
|
||||
CREATE TABLE PvP_Characters (
|
||||
linkID TEXT PRIMARY KEY,
|
||||
blessing TEXT,
|
||||
playerClass TEXT,
|
||||
name TEXT,
|
||||
maxHealth INTEGER,
|
||||
maxSanity INTEGER,
|
||||
maxMana INTEGER,
|
||||
baseManaRegen INTEGER,
|
||||
strength INTEGER,
|
||||
intelligence INTEGER,
|
||||
dexterity INTEGER,
|
||||
resistanceTable TEXT,
|
||||
damageTable TEXT,
|
||||
attackStrings TEXT,
|
||||
knownSpells TEXT,
|
||||
pushToken TEXT,
|
||||
pushCurrentlyEnabled INTEGER,
|
||||
winCount INTEGER NOT NULL DEFAULT 0,
|
||||
lossCount INTEGER NOT NULL DEFAULT 0,
|
||||
tokenRedemptionCount INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`);
|
||||
expect(columnNames("PvP_Characters")).not.toContain("pvpTokens");
|
||||
|
||||
await ensurePvpSchema(sqliteConn(db));
|
||||
|
||||
const columns = columnNames("PvP_Characters");
|
||||
expect(columns).toContain("pvpTokens");
|
||||
expect(columns).toContain("ownerUserId");
|
||||
expect(columns).toContain("lastPurchaseAt");
|
||||
// Existing data is untouched by the migration.
|
||||
db.run(
|
||||
`INSERT INTO PvP_Characters (linkID) VALUES ('legacy-row')`
|
||||
);
|
||||
const row = db
|
||||
.query(`SELECT pvpTokens, ownerUserId FROM PvP_Characters WHERE linkID = 'legacy-row'`)
|
||||
.get() as { pvpTokens: number | null; ownerUserId: string | null };
|
||||
expect(row.pvpTokens).toBeNull();
|
||||
expect(row.ownerUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("is idempotent on an already-migrated table", async () => {
|
||||
await ensurePvpSchema(sqliteConn(db));
|
||||
await ensurePvpSchema(sqliteConn(db));
|
||||
expect(columnNames("PvP_Characters")).toContain("pvpTokens");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user