Compare commits
4 Commits
44bf21a868
...
b41cf83879
| Author | SHA1 | Date | |
|---|---|---|---|
| b41cf83879 | |||
| f66fd43f62 | |||
| 421fb946fc | |||
| dc959d971b |
10
.env.example
10
.env.example
@@ -83,3 +83,13 @@ NOOK_LICENSE_PRIVATE_KEY="<generate-license-keypair>" # base64 PKCS8
|
||||
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
|
||||
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
||||
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"
|
||||
|
||||
# ── The Nook lifecycle emails ──
|
||||
# Daily cron (vercel.json → /api/the-nook/lifecycle) requires a Bearer secret.
|
||||
# Set CRON_SECRET and Vercel attaches it automatically to scheduled runs.
|
||||
# Generate: openssl rand -base64 64 | tr -d '\n'
|
||||
CRON_SECRET="<openssl-rand-base64>"
|
||||
# Optional override for the secret the endpoint accepts (falls back to CRON_SECRET).
|
||||
NOOK_LIFECYCLE_SECRET=""
|
||||
# Optional Brevo list id trial-start emails are added to (the lifecycle audience).
|
||||
NOOK_TRIAL_LIST_ID=""
|
||||
|
||||
24
marketing/README.md
Normal file
24
marketing/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Marketing tree
|
||||
|
||||
One voice, one place. Every later artifact draws on `positioning.md` — do not
|
||||
invent framing per-asset.
|
||||
|
||||
```
|
||||
positioning.md # source of truth: wedge, voice, banned phrasings, approved claims
|
||||
calendar.json # scheduled pushes: date, channel, status
|
||||
briefs/<channel>.md # per-channel rules, format, limits, what "good" looks like
|
||||
drafts/<id>/ # agent output: copy.md, assets/, rationale.md
|
||||
pending/ approved/ posted/ # human gate by directory move
|
||||
metrics/weekly.md # funnel snapshot, generated
|
||||
listening.md # buying-intent threads + drafted replies
|
||||
```
|
||||
|
||||
## Hard rule
|
||||
|
||||
**`approved/` is the only thing a human ever publishes from. Nothing posts itself.**
|
||||
|
||||
Workflow: agent drafts into `drafts/<id>/` → moves to `pending/` when complete →
|
||||
a human reviews and moves it to `approved/` → the human publishes from `approved/`
|
||||
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.
|
||||
1
marketing/approved/.gitkeep
Normal file
1
marketing/approved/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# approved
|
||||
1
marketing/briefs/.gitkeep
Normal file
1
marketing/briefs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# briefs
|
||||
4
marketing/calendar.json
Normal file
4
marketing/calendar.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$comment": "Scheduled pushes: date, channel, status. Statuses: draft | pending | approved | posted. Move nothing to posted without the artifact living in approved/ first — see positioning.md rule 5.",
|
||||
"pushes": []
|
||||
}
|
||||
1
marketing/drafts/.gitkeep
Normal file
1
marketing/drafts/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# drafts
|
||||
15
marketing/listening.md
Normal file
15
marketing/listening.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Listening — buying-intent threads
|
||||
|
||||
Agent-found threads where someone is describing a problem The Nook solves, with a
|
||||
drafted helpful-first reply. Replies are drafted here, reviewed, and posted **by a
|
||||
human** with a single disclosed mention — helpful first, product second.
|
||||
|
||||
Rules:
|
||||
|
||||
- Real threads only — no invented scenarios.
|
||||
- The drafted reply must stand alone as a helpful answer if the mention is removed.
|
||||
- One disclosed mention per reply, maximum.
|
||||
- Never post from this file; replies move through `approved/`.
|
||||
|
||||
| Date | Source | Thread | Problem signal | Drafted reply |
|
||||
|---|---|---|---|---|
|
||||
4
marketing/metrics/weekly.md
Normal file
4
marketing/metrics/weekly.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# 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.
|
||||
1
marketing/pending/.gitkeep
Normal file
1
marketing/pending/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# pending
|
||||
95
marketing/positioning.md
Normal file
95
marketing/positioning.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# The Nook — Positioning (source of truth)
|
||||
|
||||
Every drafted asset draws its voice, claims, and constraints from this file. If an
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
|
||||
## The wedge
|
||||
|
||||
The thing to say over and over, in every channel, in slightly different words:
|
||||
|
||||
> **An agent blocked on a permission prompt is the most expensive kind of idle.**
|
||||
> You pay for the model, you pay for the wait, and you get neither.
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
> 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.
|
||||
|
||||
Price rises to $15 at 1.0 — dated and honest, not a permanent beta.
|
||||
|
||||
## Hard rules
|
||||
|
||||
### 1. Category nouns, never product nouns
|
||||
|
||||
`freno-dev/AGENTS.md` forbids naming any competitor in user-facing copy — landing
|
||||
pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all of it.
|
||||
|
||||
- Use **category nouns**: "notch app", "menu-bar monitor", "fan control utility",
|
||||
"battery charge limiter", "agent notification hack". These carry the same search
|
||||
intent and are fully compliant.
|
||||
- Compare against **the status quo, not a competitor**: "vs. a terminal bell you
|
||||
don't hear", "vs. four utilities in your menu bar", "vs. finding out an hour later."
|
||||
- The rule does not reach App Store–external review replies, direct email, and
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### 3. Honest privacy scoping
|
||||
|
||||
Telemetry and the trial email **do** leave the device. The defensible claim is:
|
||||
|
||||
> **No prompt or code content leaves the device.**
|
||||
|
||||
Never "no data leaves the device".
|
||||
|
||||
### 4. Sell what works today
|
||||
|
||||
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)"
|
||||
parentheticals. The launch story is remote SSH: agents running on other machines,
|
||||
visible and unblockable from the Mac's notch.
|
||||
|
||||
### 5. Humans hold every account
|
||||
|
||||
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)
|
||||
|
||||
| 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`) |
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
1
marketing/posted/.gitkeep
Normal file
1
marketing/posted/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# posted
|
||||
9
src/env/server.ts
vendored
9
src/env/server.ts
vendored
@@ -73,7 +73,14 @@ const serverEnvSchema = z.object({
|
||||
NOOK_LICENSE_PRIVATE_KEY: z.string().min(1),
|
||||
NOOK_STRIPE_SK: z.string().min(1),
|
||||
NOOK_STRIPE_WEBHOOK_SECRET: z.string().min(1),
|
||||
NOOK_STRIPE_PRICE_ID: z.string().min(1)
|
||||
NOOK_STRIPE_PRICE_ID: z.string().min(1),
|
||||
// Brevo list the trial-start emails land in (the lifecycle mail's
|
||||
// audience). Optional: capture still stores the address when unset.
|
||||
NOOK_TRIAL_LIST_ID: z.coerce.number().int().positive().optional(),
|
||||
// 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(),
|
||||
CRON_SECRET: z.string().min(1).optional()
|
||||
});
|
||||
|
||||
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||
|
||||
@@ -120,6 +120,9 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
||||
],
|
||||
nook: [
|
||||
{ label: "Home", href: "/", icon: "home" },
|
||||
{ label: "Agents", href: "/agents", icon: "home" },
|
||||
{ label: "Changelog", href: "/changelog", icon: "home" },
|
||||
{ label: "FAQ", href: "/faq", icon: "home" },
|
||||
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -83,6 +83,9 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
||||
nook: [
|
||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||
{ path: "/checkout", changefreq: "monthly", priority: 0.5 },
|
||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 },
|
||||
{ path: "/agents", changefreq: "weekly", priority: 0.9 },
|
||||
{ path: "/changelog", changefreq: "daily", priority: 0.7 },
|
||||
{ path: "/faq", changefreq: "monthly", priority: 0.8 }
|
||||
]
|
||||
};
|
||||
|
||||
114
src/routes/api/the-nook/_appcast.ts
Normal file
114
src/routes/api/the-nook/_appcast.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
/**
|
||||
* Appcast reading for The Nook, shared by the download resolver and the
|
||||
* /changelog page. The same route-module tree-shake rule applies as in
|
||||
* `_download.ts`: helpers live in an underscore-prefixed module because
|
||||
* route files may only export HTTP handlers.
|
||||
*/
|
||||
|
||||
const APPCAST_KEY = "api/TheNook/appcast.xml";
|
||||
|
||||
/** Kept in step with the appcast route's own 5-minute cache. */
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface AppcastRelease {
|
||||
/** Human version, e.g. "1.2.0" — from sparkle:shortVersionString. */
|
||||
version: string;
|
||||
/** Build number from sparkle:version, when present. */
|
||||
build: string | null;
|
||||
/** Release date as published (already human-formatted in the appcast). */
|
||||
date: string | null;
|
||||
/** HTML release notes exactly as the Sparkle updater receives them. */
|
||||
notesHtml: string;
|
||||
}
|
||||
|
||||
function extract(tag: string, xml: string): string | null {
|
||||
const match = xml.match(
|
||||
new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, "i")
|
||||
);
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function decodeCdata(value: string): string {
|
||||
const cdata = value.match(/^<!\[CDATA\[([\s\S]*)\]\]>$/);
|
||||
const inner = (cdata ? cdata[1] : value).trim();
|
||||
if (cdata) return inner;
|
||||
// Non-CDATA text is XML-escaped; descriptions arrive as escaped HTML.
|
||||
return inner
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
/** Strips the conventional "Version " prefix from an appcast item title. */
|
||||
function versionFromTitle(title: string): string {
|
||||
return title.replace(/^version\s+/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Sparkle appcast into release entries, newest first (the appcast's
|
||||
* own order). Empty or unparseable input yields an empty list — the page
|
||||
* renders its empty state rather than an error.
|
||||
*/
|
||||
export function parseAppcastItems(xml: string): AppcastRelease[] {
|
||||
const releases: AppcastRelease[] = [];
|
||||
for (const match of xml.matchAll(/<item\b[^>]*>([\s\S]*?)<\/item>/gi)) {
|
||||
const item = match[1];
|
||||
if (!item) continue;
|
||||
const short = decodeCdata(extract("sparkle:shortVersionString", item) ?? "");
|
||||
const title = versionFromTitle(decodeCdata(extract("title", item) ?? ""));
|
||||
const version = short || title;
|
||||
if (!version) continue;
|
||||
releases.push({
|
||||
version,
|
||||
build: decodeCdata(extract("sparkle:version", item) ?? "") || null,
|
||||
date: decodeCdata(extract("pubDate", item) ?? "") || null,
|
||||
notesHtml: decodeCdata(extract("description", item) ?? "")
|
||||
});
|
||||
}
|
||||
return releases;
|
||||
}
|
||||
|
||||
let cached: { xml: string; at: number } | null = null;
|
||||
|
||||
/** Drops the cached appcast so the next read hits S3 again. */
|
||||
export function invalidateAppcastCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/** Reads (and briefly caches) the raw appcast XML from the download bucket. */
|
||||
export async function fetchAppcastXml(): Promise<string | null> {
|
||||
if (cached && Date.now() - cached.at < TTL_MS) {
|
||||
return cached.xml;
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: env.AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
}
|
||||
});
|
||||
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: env.VITE_DOWNLOAD_BUCKET_STRING,
|
||||
Key: APPCAST_KEY
|
||||
})
|
||||
);
|
||||
if (!response.Body) return null;
|
||||
|
||||
const xml = await response.Body.transformToString();
|
||||
cached = { xml, at: Date.now() };
|
||||
return xml;
|
||||
}
|
||||
|
||||
/** The releases behind /changelog, or an empty list when the appcast is unreadable. */
|
||||
export async function fetchAppcastReleases(): Promise<AppcastRelease[]> {
|
||||
const xml = await fetchAppcastXml();
|
||||
return xml ? parseAppcastItems(xml) : [];
|
||||
}
|
||||
64
src/routes/api/the-nook/appcast.test.ts
Normal file
64
src/routes/api/the-nook/appcast.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseAppcastItems } from "./_appcast";
|
||||
|
||||
// The /changelog page renders exactly what the Sparkle updater receives, so
|
||||
// the parser's contract is the appcast's contract: newest-first items, a
|
||||
// shortVersionString title with the raw <title> as fallback, CDATA and
|
||||
// plain-encoded descriptions both accepted, garbage in → empty list out.
|
||||
|
||||
const item = (over: Record<string, string>) => `
|
||||
<item>
|
||||
<title>Version ${over.title ?? "1.0.0"}</title>
|
||||
${over.short ? `<sparkle:shortVersionString>${over.short}</sparkle:shortVersionString>` : ""}
|
||||
${over.build ? `<sparkle:version>${over.build}</sparkle:version>` : ""}
|
||||
${over.date ? `<pubDate>${over.date}</pubDate>` : ""}
|
||||
${over.desc ?? `<description><![CDATA[<ul><li>Fixes</li></ul>]]></description>`}
|
||||
<enclosure url="https://example.com/Nook-1.0.0.dmg" type="application/x-apple-diskimage"/>
|
||||
</item>`;
|
||||
|
||||
describe("parseAppcastItems", () => {
|
||||
it("parses newest-first items with version, build, date and CDATA notes", () => {
|
||||
const xml = `<rss><channel>
|
||||
${item({ title: "1.2.0", short: "1.2.0", build: "42", date: "Sat, 03 Oct 2026 12:00:00 +0000" })}
|
||||
${item({ title: "1.1.0", short: "1.1.0", build: "41", date: "Fri, 02 Oct 2026 12:00:00 +0000" })}
|
||||
</channel></rss>`;
|
||||
|
||||
const releases = parseAppcastItems(xml);
|
||||
expect(releases.length).toBe(2);
|
||||
expect(releases[0].version).toBe("1.2.0");
|
||||
expect(releases[0].build).toBe("42");
|
||||
expect(releases[0].date).toContain("03 Oct 2026");
|
||||
expect(releases[0].notesHtml).toBe("<ul><li>Fixes</li></ul>");
|
||||
expect(releases[1].version).toBe("1.1.0");
|
||||
});
|
||||
|
||||
it("falls back to the plain title when shortVersionString is absent", () => {
|
||||
const xml = `<rss><channel>${item({ title: "1.0.0" })}</channel></rss>`;
|
||||
const releases = parseAppcastItems(xml);
|
||||
expect(releases[0].version).toBe("1.0.0");
|
||||
expect(releases[0].build).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts non-CDATA escaped-HTML descriptions", () => {
|
||||
const xml = `<rss><channel>
|
||||
${item({ desc: "<description><p>Hello</p></description>" })}
|
||||
</channel></rss>`;
|
||||
expect(parseAppcastItems(xml)[0].notesHtml).toBe("<p>Hello</p>");
|
||||
});
|
||||
|
||||
it("returns [] for empty, non-XML, or item-less input", () => {
|
||||
expect(parseAppcastItems("")).toEqual([]);
|
||||
expect(parseAppcastItems("<html>not an appcast</html>")).toEqual([]);
|
||||
expect(parseAppcastItems("<rss><channel></channel></rss>")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips items with no version signal at all", () => {
|
||||
const xml = `<rss><channel>
|
||||
<item><description><![CDATA[<p>orphan</p>]]></description></item>
|
||||
${item({ title: "1.0.0" })}
|
||||
</channel></rss>`;
|
||||
const releases = parseAppcastItems(xml);
|
||||
expect(releases.length).toBe(1);
|
||||
expect(releases[0].version).toBe("1.0.0");
|
||||
});
|
||||
});
|
||||
210
src/routes/api/the-nook/lifecycle.test.ts
Normal file
210
src/routes/api/the-nook/lifecycle.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
|
||||
// The lifecycle job's contracts:
|
||||
// 1. Auth is Bearer-only; an unconfigured secret denies everything (fail
|
||||
// closed) — an unauthenticated trigger would re-email real users.
|
||||
// 2. Trials younger than 11 days get nothing; at ≥ 11 days the reminder is
|
||||
// sent and the row is marked, so a re-run never double-sends.
|
||||
// 3. A trial address that already bought a license is skipped (marked, no
|
||||
// buy reminder).
|
||||
// 4. Licenses get the testimonial ask at ≥ 3 days and the review ask at
|
||||
// ≥ 14 days, each once.
|
||||
// DB + email sends are mocked; the route module is imported after mocks.
|
||||
|
||||
const UUID = "a1b2c3d4-e5f6-4a1b-8c2d-1234567890ab";
|
||||
const NOW = Date.now();
|
||||
const daysAgo = (n: number) => new Date(NOW - n * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
let trialRows: Record<string, unknown>[] = [];
|
||||
let licenseRows: Record<string, unknown>[] = [];
|
||||
const updates: { sql: string; args?: unknown[] }[] = [];
|
||||
const sent: string[] = [];
|
||||
|
||||
const fakeConn = {
|
||||
execute: async (q: { sql: string; args?: unknown[] } | string) => {
|
||||
const { sql, args } =
|
||||
typeof q === "string" ? { sql: q, args: undefined } : q;
|
||||
if (sql.startsWith("SELECT fingerprint")) {
|
||||
return { rows: trialRows, rowsAffected: 0 };
|
||||
}
|
||||
if (sql.startsWith("SELECT id, email")) {
|
||||
return { rows: licenseRows, rowsAffected: 0 };
|
||||
}
|
||||
if (sql.startsWith("SELECT 1 FROM licenses")) {
|
||||
const email = args?.[0];
|
||||
const owned = licenseRows.some((r) => r.email === email);
|
||||
return { rows: owned ? [{ 1: 1 }] : [], rowsAffected: 0 };
|
||||
}
|
||||
if (sql.startsWith("UPDATE")) {
|
||||
updates.push({ sql, args });
|
||||
return { rows: [], rowsAffected: 1 };
|
||||
}
|
||||
return { rows: [], rowsAffected: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
mock.module("~/server/db-connections", () => ({
|
||||
NookConnectionFactory: () => fakeConn
|
||||
}));
|
||||
|
||||
mock.module("~/server/nook", () => ({
|
||||
// Full export surface: bun's mock registry is shared across test files in
|
||||
// one run, and a partial mock here would starve resend-license.test.ts.
|
||||
nookSchemaBootstrap: Promise.resolve(),
|
||||
emailLicenseKey: async () => {},
|
||||
issueLicense: async () => ({ key: "", id: "" }),
|
||||
grantLicense: async () => ({ key: "", id: "" }),
|
||||
setTrialEmail: async (fingerprint: string, email: string) => {
|
||||
updates.push({ sql: "setTrialEmail", args: [fingerprint, email] });
|
||||
return true;
|
||||
},
|
||||
verifyLicenseKey: () => true
|
||||
}));
|
||||
|
||||
mock.module("~/server/nook-lifecycle", () => ({
|
||||
emailTrialReminder: async (to: string) => {
|
||||
sent.push(`reminder:${to}`);
|
||||
return true;
|
||||
},
|
||||
emailTestimonialAsk: async (to: string) => {
|
||||
sent.push(`testimonial:${to}`);
|
||||
return true;
|
||||
},
|
||||
emailReviewAsk: async (to: string) => {
|
||||
sent.push(`review:${to}`);
|
||||
return true;
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("~/env/server", () => ({
|
||||
env: {
|
||||
NOOK_LIFECYCLE_SECRET: "sekrit",
|
||||
SENDINBLUE_KEY: "test-sendinblue",
|
||||
NOOK_LICENSE_PRIVATE_KEY: "test",
|
||||
NOOK_STRIPE_WEBHOOK_SECRET: "test",
|
||||
AWS_REGION: "us-east-1",
|
||||
MY_AWS_ACCESS_KEY: "test-key",
|
||||
MY_AWS_SECRET_KEY: "test-secret",
|
||||
VITE_DOWNLOAD_BUCKET_STRING: "test-bucket"
|
||||
}
|
||||
}));
|
||||
|
||||
const { GET } = await import("./lifecycle");
|
||||
|
||||
function request(auth?: string): any {
|
||||
return {
|
||||
request: { headers: new Headers(auth ? { authorization: auth } : {}) }
|
||||
};
|
||||
}
|
||||
|
||||
describe("GET /api/the-nook/lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
trialRows = [];
|
||||
licenseRows = [];
|
||||
updates.length = 0;
|
||||
sent.length = 0;
|
||||
});
|
||||
|
||||
it("denies missing, wrong, and unconfigured secrets", async () => {
|
||||
const noHeader = await GET(request());
|
||||
expect(noHeader.status).toBe(401);
|
||||
const wrong = await GET(request("Bearer nope"));
|
||||
expect(wrong.status).toBe(401);
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends the D+11 reminder to an aged trial and marks it", async () => {
|
||||
trialRows = [
|
||||
{ fingerprint: UUID, email: "trial@example.com", started_at: daysAgo(11) }
|
||||
];
|
||||
const res = await GET(request("Bearer sekrit"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.reminders).toBe(1);
|
||||
expect(sent).toEqual(["reminder:trial@example.com"]);
|
||||
expect(
|
||||
updates.some((u) => u.sql.includes("reminder_sent_at"))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("skips young trials entirely", async () => {
|
||||
trialRows = [
|
||||
{ fingerprint: UUID, email: "young@example.com", started_at: daysAgo(3) }
|
||||
];
|
||||
const res = await GET(request("Bearer sekrit"));
|
||||
const body = await res.json();
|
||||
expect(body.reminders).toBe(0);
|
||||
expect(sent).toHaveLength(0);
|
||||
expect(
|
||||
updates.some((u) => u.sql.includes("reminder_sent_at"))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("marks but does not remind a trial that already bought", async () => {
|
||||
trialRows = [
|
||||
{ fingerprint: UUID, email: "bought@example.com", started_at: daysAgo(12) }
|
||||
];
|
||||
licenseRows = [{ id: "l1", email: "bought@example.com", created_at: daysAgo(1) }];
|
||||
const res = await GET(request("Bearer sekrit"));
|
||||
const body = await res.json();
|
||||
expect(body.reminders).toBe(0);
|
||||
expect(sent).toHaveLength(0);
|
||||
expect(
|
||||
updates.some((u) => u.sql.includes("reminder_sent_at"))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("asks for a testimonial at D+3 but not a review until D+14", async () => {
|
||||
licenseRows = [
|
||||
{
|
||||
id: "l1",
|
||||
email: "buyer@example.com",
|
||||
created_at: daysAgo(5),
|
||||
testimonial_asked_at: null,
|
||||
review_asked_at: null
|
||||
}
|
||||
];
|
||||
const res = await GET(request("Bearer sekrit"));
|
||||
const body = await res.json();
|
||||
expect(body.testimonials).toBe(1);
|
||||
expect(body.reviews).toBe(0);
|
||||
expect(sent).toEqual(["testimonial:buyer@example.com"]);
|
||||
});
|
||||
|
||||
it("sends both asks for an old license and never twice", async () => {
|
||||
licenseRows = [
|
||||
{
|
||||
id: "l1",
|
||||
email: "buyer@example.com",
|
||||
created_at: daysAgo(20),
|
||||
testimonial_asked_at: null,
|
||||
review_asked_at: null
|
||||
}
|
||||
];
|
||||
const res = await GET(request("Bearer sekrit"));
|
||||
const body = await res.json();
|
||||
expect(body.testimonials).toBe(1);
|
||||
expect(body.reviews).toBe(1);
|
||||
expect(sent.sort()).toEqual([
|
||||
"review:buyer@example.com",
|
||||
"testimonial:buyer@example.com"
|
||||
]);
|
||||
|
||||
// Second run: both columns are now set — nothing re-sends.
|
||||
sent.length = 0;
|
||||
licenseRows = [
|
||||
{
|
||||
id: "l1",
|
||||
email: "buyer@example.com",
|
||||
created_at: daysAgo(20),
|
||||
testimonial_asked_at: daysAgo(1),
|
||||
review_asked_at: daysAgo(1)
|
||||
}
|
||||
];
|
||||
const res2 = await GET(request("Bearer sekrit"));
|
||||
const body2 = await res2.json();
|
||||
expect(body2.testimonials).toBe(0);
|
||||
expect(body2.reviews).toBe(0);
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
120
src/routes/api/the-nook/lifecycle.ts
Normal file
120
src/routes/api/the-nook/lifecycle.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { env } from "~/env/server";
|
||||
import { NookConnectionFactory } from "~/server/db-connections";
|
||||
import { nookSchemaBootstrap } from "~/server/nook";
|
||||
import {
|
||||
emailTrialReminder,
|
||||
emailTestimonialAsk,
|
||||
emailReviewAsk
|
||||
} from "~/server/nook-lifecycle";
|
||||
import { json } from "./_lib";
|
||||
|
||||
/**
|
||||
* GET /api/the-nook/lifecycle — daily Vercel cron.
|
||||
*
|
||||
* Runs the scheduled half of the two lifecycle emails:
|
||||
* - trials with an email and no reminder yet, aged ≥ 11 days → D+11 expiry
|
||||
* reminder (skipped when that same address already bought a license)
|
||||
* - licenses aged ≥ 3 days → testimonial ask
|
||||
* - licenses aged ≥ 14 days → review ask
|
||||
*
|
||||
* Each row is marked *before* its send is attempted (best-effort, logged on
|
||||
* failure), so a retry never double-sends; the purchase thank-you itself is
|
||||
* sent from the Stripe webhook at purchase time. Auth: `Authorization:
|
||||
* Bearer <secret>` where the secret is NOOK_LIFECYCLE_SECRET or Vercel's
|
||||
* CRON_SECRET — an unauthenticated trigger could re-email real users, so an
|
||||
* unconfigured secret fails closed.
|
||||
*/
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function ageDays(iso: string): number {
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? (Date.now() - t) / DAY_MS : -1;
|
||||
}
|
||||
|
||||
async function runLifecycle(): Promise<{
|
||||
reminders: number;
|
||||
testimonials: number;
|
||||
reviews: number;
|
||||
}> {
|
||||
await nookSchemaBootstrap;
|
||||
const conn = NookConnectionFactory();
|
||||
const counts = { reminders: 0, testimonials: 0, reviews: 0 };
|
||||
|
||||
// ── Trial D+11 reminder ────────────────────────────────────────────────
|
||||
const trials = await conn.execute(
|
||||
`SELECT fingerprint, email, started_at FROM trials
|
||||
WHERE email IS NOT NULL AND reminder_sent_at IS NULL`
|
||||
);
|
||||
for (const row of trials.rows as {
|
||||
fingerprint: string;
|
||||
email: string;
|
||||
started_at: string;
|
||||
}[]) {
|
||||
if (ageDays(row.started_at) < 11) continue;
|
||||
await conn.execute({
|
||||
sql: "UPDATE trials SET reminder_sent_at = ? WHERE fingerprint = ?",
|
||||
args: [new Date().toISOString(), row.fingerprint]
|
||||
});
|
||||
// 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({
|
||||
sql: "SELECT 1 FROM licenses WHERE lower(email) = lower(?) LIMIT 1",
|
||||
args: [row.email]
|
||||
});
|
||||
if (owned.rows.length > 0) continue;
|
||||
if (await emailTrialReminder(row.email)) counts.reminders++;
|
||||
}
|
||||
|
||||
// ── Purchase D+3 testimonial ask / D+14 review ask ────────────────────
|
||||
const licenses = await conn.execute(
|
||||
`SELECT id, email, created_at, testimonial_asked_at, review_asked_at
|
||||
FROM licenses WHERE revoked = 0`
|
||||
);
|
||||
for (const row of licenses.rows as {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
testimonial_asked_at: string | null;
|
||||
review_asked_at: string | null;
|
||||
}[]) {
|
||||
const age = ageDays(row.created_at);
|
||||
if (age < 0) continue;
|
||||
|
||||
if (age >= 3 && !row.testimonial_asked_at) {
|
||||
await conn.execute({
|
||||
sql: "UPDATE licenses SET testimonial_asked_at = ? WHERE id = ?",
|
||||
args: [new Date().toISOString(), row.id]
|
||||
});
|
||||
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 = ?",
|
||||
args: [new Date().toISOString(), row.id]
|
||||
});
|
||||
if (await emailReviewAsk(row.email)) counts.reviews++;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
export async function GET(event: APIEvent) {
|
||||
const auth = event.request.headers.get("authorization") ?? "";
|
||||
const provided = auth.replace(/^Bearer\s+/i, "").trim();
|
||||
const secret = env.NOOK_LIFECYCLE_SECRET ?? env.CRON_SECRET;
|
||||
if (!secret || !provided || provided !== secret) {
|
||||
return json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const counts = await runLifecycle();
|
||||
return json({ success: true, ...counts });
|
||||
} catch (error) {
|
||||
console.error("Lifecycle job failed:", error);
|
||||
return json({ error: "Lifecycle job failed" }, 500);
|
||||
}
|
||||
}
|
||||
77
src/routes/api/the-nook/trial-email.test.ts
Normal file
77
src/routes/api/the-nook/trial-email.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
|
||||
// The route's contracts:
|
||||
// 1. Both fingerprint (UUID) and email are validated — bad input never
|
||||
// reaches the store.
|
||||
// 2. The email is normalized (trimmed, lowercased) before storage.
|
||||
// 3. The response is `{ success: true }` regardless of whether a trial row
|
||||
// already existed — the client learns nothing it doesn't know.
|
||||
// NookConnectionFactory and the nook helper module are mocked so the test
|
||||
// needs no real DB, env, or outbound email.
|
||||
|
||||
const UUID = "a1b2c3d4-e5f6-4a1b-8c2d-1234567890ab";
|
||||
const calls: { sql: string; args?: unknown[] }[] = [];
|
||||
|
||||
const fakeConn = {
|
||||
execute: async (q: { sql: string; args?: unknown[] }) => {
|
||||
calls.push(q);
|
||||
return { rows: [], rowsAffected: 1 };
|
||||
}
|
||||
};
|
||||
|
||||
mock.module("~/server/db-connections", () => ({
|
||||
NookConnectionFactory: () => fakeConn
|
||||
}));
|
||||
|
||||
mock.module("~/server/nook", () => ({
|
||||
// Full export surface: bun's mock registry is shared across test files in
|
||||
// one run, and a partial mock here would starve resend-license.test.ts.
|
||||
nookSchemaBootstrap: Promise.resolve(),
|
||||
emailLicenseKey: async () => {},
|
||||
issueLicense: async () => ({ key: "", id: "" }),
|
||||
grantLicense: async () => ({ key: "", id: "" }),
|
||||
setTrialEmail: async (fingerprint: string, email: string) => {
|
||||
calls.push({ sql: "setTrialEmail", args: [fingerprint, email] });
|
||||
return true;
|
||||
},
|
||||
verifyLicenseKey: () => true
|
||||
}));
|
||||
|
||||
const { POST } = await import("./trial-email");
|
||||
|
||||
function request(body: unknown): any {
|
||||
return { request: { json: async () => body } };
|
||||
}
|
||||
|
||||
describe("POST /api/the-nook/trial-email", () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
it("rejects an invalid fingerprint", async () => {
|
||||
const res = await POST(
|
||||
request({ fingerprint: "nope", email: "a@b.co" })
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: "Invalid fingerprint" });
|
||||
});
|
||||
|
||||
it("rejects an invalid email", async () => {
|
||||
const res = await POST(
|
||||
request({ fingerprint: UUID, email: "not-an-email" })
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: "Invalid email" });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("normalizes the email and stores it against the fingerprint", async () => {
|
||||
const res = await POST(
|
||||
request({ fingerprint: UUID, email: " USER@Example.COM " })
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ success: true });
|
||||
const stored = calls.find((c) => c.sql === "setTrialEmail");
|
||||
expect(stored?.args).toEqual([UUID, "user@example.com"]);
|
||||
});
|
||||
});
|
||||
36
src/routes/api/the-nook/trial-email.ts
Normal file
36
src/routes/api/the-nook/trial-email.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { NookConnectionFactory } from "~/server/db-connections";
|
||||
import { nookSchemaBootstrap, setTrialEmail } from "~/server/nook";
|
||||
import { json, error, isUuid } from "./_lib";
|
||||
|
||||
/**
|
||||
* POST /api/the-nook/trial-email
|
||||
* Body: { fingerprint, email }
|
||||
*
|
||||
* Attaches the email a user gave at trial start (the optional first-launch
|
||||
* prompt) to their `trials` row and adds it to the Brevo trial list. The
|
||||
* response is the same whether the trial row existed or not, so the client
|
||||
* learns nothing it doesn't already know.
|
||||
*/
|
||||
export async function POST(event: APIEvent) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await event.request.json();
|
||||
} catch {
|
||||
return error("Invalid JSON", 400);
|
||||
}
|
||||
const b = (body ?? {}) as Record<string, unknown>;
|
||||
const fingerprint = b.fingerprint;
|
||||
if (!isUuid(fingerprint)) {
|
||||
return error("Invalid fingerprint", 400);
|
||||
}
|
||||
const email =
|
||||
typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
|
||||
return error("Invalid email", 400);
|
||||
}
|
||||
|
||||
await nookSchemaBootstrap;
|
||||
const stored = await setTrialEmail(fingerprint, email);
|
||||
return json({ success: stored });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { APIEvent } from "@solidjs/start/server";
|
||||
import { env } from "~/env/server";
|
||||
import { NookConnectionFactory } from "~/server/db-connections";
|
||||
import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
|
||||
import { emailThankYou } from "~/server/nook-lifecycle";
|
||||
import { json } from "../_lib";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
@@ -92,6 +93,9 @@ export async function POST(event: APIEvent) {
|
||||
try {
|
||||
const { key } = await issueLicense(email, sessionId);
|
||||
await emailLicenseKey(email, key);
|
||||
// Purchase thank-you (the D+3 testimonial and D+14 review asks are
|
||||
// scheduled from the daily lifecycle job, not here).
|
||||
await emailThankYou(email);
|
||||
} catch (error) {
|
||||
// UNIQUE stripe_session_id conflict from a racing duplicate delivery.
|
||||
console.error("Failed to issue The Nook license (webhook):", error);
|
||||
|
||||
@@ -21,6 +21,8 @@ import { api } from "~/lib/api";
|
||||
import CustomScrollbar from "~/components/CustomScrollbar";
|
||||
import "../post.css";
|
||||
import { Post } from "~/db/types";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NotFound from "../../[...404]";
|
||||
|
||||
const getPostByTitle = query(
|
||||
async (
|
||||
@@ -262,6 +264,7 @@ const getPostByTitle = query(
|
||||
);
|
||||
|
||||
export default function PostPage() {
|
||||
const site = useSite();
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -297,6 +300,12 @@ export default function PostPage() {
|
||||
return str.includes('data-type="mermaid"');
|
||||
};
|
||||
|
||||
// Host-dispatch guard: blog posts live on the apex domain only (see the
|
||||
// guard in blog/index.tsx for the dev-server rationale).
|
||||
if (site().id !== "main") {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={data()}
|
||||
|
||||
@@ -9,6 +9,8 @@ import PostSorting from "~/components/blog/PostSorting";
|
||||
import PublishStatusToggle from "~/components/blog/PublishStatusToggle";
|
||||
import { TerminalSplash } from "~/components/TerminalSplash";
|
||||
import { CACHE_CONFIG } from "~/config";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NotFound from "../[...404]";
|
||||
|
||||
const getPosts = query(async () => {
|
||||
"use server";
|
||||
@@ -77,6 +79,7 @@ const getPosts = query(async () => {
|
||||
}, "posts");
|
||||
|
||||
export default function BlogIndex() {
|
||||
const site = useSite();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const sort = () => searchParams.sort || "newest";
|
||||
@@ -89,6 +92,15 @@ export default function BlogIndex() {
|
||||
|
||||
const data = createAsync(() => getPosts(), { deferStream: true });
|
||||
|
||||
// Host-dispatch guard: /blog is the personal blog on the apex domain only.
|
||||
// Production rewrites already send `<sub>.freno.me/blog` → `/<sub>/blog`,
|
||||
// which has no route — but in dev the vinxi server ignores vercel.json and
|
||||
// would render the personal blog on a product subdomain, splitting the
|
||||
// brand and crawl equity. Render the shared 404 instead.
|
||||
if (site().id !== "main") {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||
|
||||
216
src/routes/nook/agents.tsx
Normal file
216
src/routes/nook/agents.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* `/agents` on the Nook subdomain — supported agents and their 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
|
||||
* (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
|
||||
* claims.
|
||||
*/
|
||||
import { For } from "solid-js";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
|
||||
interface AgentEntry {
|
||||
name: string;
|
||||
/** How The Nook connects to this agent. */
|
||||
wiring: string;
|
||||
/** Events the installed hook/plugin reports, on the shared vocabulary. */
|
||||
events: string[];
|
||||
/** What the agent can be asked to do from a Nook card. */
|
||||
gating: string;
|
||||
}
|
||||
|
||||
const AGENTS: AgentEntry[] = [
|
||||
{
|
||||
name: "Claude Code",
|
||||
wiring: "Hook entries in settings.json pointing at the shared hook binary",
|
||||
events: [
|
||||
"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"
|
||||
},
|
||||
{
|
||||
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"
|
||||
},
|
||||
{
|
||||
name: "OpenCode",
|
||||
wiring: "In-process plugin (~/.config/opencode/plugins/nook.js) on the server bus",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "MessageUpdate", "PermissionRequest", "QuestionAsked"
|
||||
],
|
||||
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)",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "MessageUpdate", "QuestionAsked", "QuestionResolved"
|
||||
],
|
||||
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/)",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "MessageUpdate", "QuestionAsked", "QuestionResolved"
|
||||
],
|
||||
gating: "Full — honors the agent's own approval tiers, per-tool policies included"
|
||||
},
|
||||
{
|
||||
name: "Hermes",
|
||||
wiring: "Shell-hook entries in the agent's config, consent-gated by allowlist",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "Stop", "PreToolUse",
|
||||
"PostToolUse", "SubagentStart", "SubagentStop"
|
||||
],
|
||||
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"
|
||||
},
|
||||
{
|
||||
name: "Z.ai (ZCode)",
|
||||
wiring: "Hook entries in ~/.zcode/cli/config.json",
|
||||
events: [
|
||||
"SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest",
|
||||
"PostToolUse", "PostToolUseFailure", "Stop"
|
||||
],
|
||||
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"
|
||||
},
|
||||
{
|
||||
name: "Cline",
|
||||
wiring: "One hook file per event in ~/Documents/Cline/Hooks",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "PreCompact", "Notification"
|
||||
],
|
||||
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)",
|
||||
events: [
|
||||
"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"
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
wiring: "Standalone hooks.json at ~/.dsh",
|
||||
events: [
|
||||
"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop",
|
||||
"SubagentStart", "SubagentStop"
|
||||
],
|
||||
gating: "Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
||||
}
|
||||
];
|
||||
|
||||
const GATING_BADGE: Record<string, { label: string; class: string }> = {
|
||||
full: {
|
||||
label: "Allow / Deny from a card",
|
||||
class: "text-green border-green/40 bg-green/10"
|
||||
},
|
||||
observation: {
|
||||
label: "Observe",
|
||||
class: "text-blue border-blue/40 bg-blue/10"
|
||||
}
|
||||
};
|
||||
|
||||
function gatingKey(entry: AgentEntry): "full" | "observation" {
|
||||
return entry.gating.startsWith("Full") ? "full" : "observation";
|
||||
}
|
||||
|
||||
export default function NookAgentsPage() {
|
||||
return (
|
||||
<>
|
||||
<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"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
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
|
||||
notch show every session that is running, thinking, or blocked, and a
|
||||
card answers permission prompts without leaving your keyboard.
|
||||
</p>
|
||||
<p class="text-subtext1 mb-10 max-w-2xl text-sm">
|
||||
Each agent reports its own set of events. "Allow / Deny from a card"
|
||||
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.
|
||||
</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"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 class="text-text text-lg font-semibold">{agent.name}</h2>
|
||||
<span
|
||||
class={`shrink-0 rounded-full border px-3 py-1 text-xs font-semibold ${GATING_BADGE[gatingKey(agent)].class}`}
|
||||
>
|
||||
{GATING_BADGE[gatingKey(agent)].label}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-subtext0 mb-3 text-sm">{agent.wiring}</p>
|
||||
<p class="text-subtext1 mb-1 text-xs font-semibold tracking-wide uppercase">
|
||||
Events reported
|
||||
</p>
|
||||
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||
<For each={agent.events}>
|
||||
{(event) => (
|
||||
<code class="border-overlay0 bg-base text-subtext0 rounded-md border px-2 py-0.5 font-mono text-xs">
|
||||
{event}
|
||||
</code>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p class="text-subtext0 text-sm">{agent.gating}</p>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
src/routes/nook/changelog.tsx
Normal file
81
src/routes/nook/changelog.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* `/changelog` on the Nook subdomain — rendered from the Sparkle appcast.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
import { For, Show } 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";
|
||||
|
||||
const getReleases = query(async () => {
|
||||
"use server";
|
||||
return fetchAppcastReleases();
|
||||
}, "nook-changelog-releases");
|
||||
|
||||
export default function NookChangelogPage() {
|
||||
const releases = createAsync(() => getReleases());
|
||||
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Changelog"
|
||||
description="Every release of The Nook, newest first — agent orchestration, fan and thermal control for macOS."
|
||||
ogTitle="The Nook changelog"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<Show
|
||||
when={(releases()?.length ?? 0) > 0}
|
||||
fallback={
|
||||
<p class="text-subtext0 text-sm">
|
||||
No releases published yet — check back soon.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="border-overlay0 space-y-10 border-l pl-6">
|
||||
<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">
|
||||
Version {release.version}
|
||||
</h2>
|
||||
<Show when={release.build}>
|
||||
<span class="text-subtext1 font-mono text-xs">
|
||||
build {release.build}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={release.date}>
|
||||
<span class="text-subtext1 text-xs">{release.date}</span>
|
||||
</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>
|
||||
</div>
|
||||
</Show>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
src/routes/nook/faq.tsx
Normal file
106
src/routes/nook/faq.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* `/faq` on the Nook subdomain — answers to the objections that actually
|
||||
* stop a purchase. Privacy scoping follows marketing/positioning.md rule 3:
|
||||
* "no prompt or code content leaves the device" (telemetry and the trial
|
||||
* email do leave — never claim "no data leaves the device").
|
||||
*/
|
||||
import { For } from "solid-js";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
|
||||
interface FaqEntry {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
const FAQ: FaqEntry[] = [
|
||||
{
|
||||
question: "Does The Nook read my prompts or my code?",
|
||||
answer:
|
||||
"No. The hooks and plugins that connect your agents to The Nook send session metadata only — which agent is running, which session, which event happened, which terminal it lives in. No prompt text and no code content leaves your device. Two things do leave the device, and we'd rather say so plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start. That's the whole list."
|
||||
},
|
||||
{
|
||||
question: "Do I need a Mac with a notch?",
|
||||
answer:
|
||||
"No. On a notched MacBook the island sits in the notch. On machines and displays without one, it anchors to the display your cursor is on, so a desktop setup with external monitors gets the same treatment. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
||||
},
|
||||
{
|
||||
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."
|
||||
},
|
||||
{
|
||||
question: "What permissions does it need?",
|
||||
answer:
|
||||
"The app itself runs unprivileged. Fan control and charge limiting go through a small helper component that macOS requires to run as root for hardware access — the app installs it on first use and you approve it once with your password. The hooks live in each agent's own config directory; you can inspect or remove every file it writes."
|
||||
},
|
||||
{
|
||||
question: "Why macOS 14 or later?",
|
||||
answer:
|
||||
"The interface is built on Apple's current observation framework, which ships with macOS 14 (Sonoma). 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."
|
||||
},
|
||||
{
|
||||
question: "Does it work offline?",
|
||||
answer:
|
||||
"Yes. Everything The Nook does — the agent grid, permission cards, fan and thermal control — works with no network at all, and your agents' hooks talk to the app over a local socket. The network is only used for update checks, license activation, and the optional trial email."
|
||||
}
|
||||
];
|
||||
|
||||
export default function NookFaqPage() {
|
||||
const [open, setOpen] = createSignal<number | null>(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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."
|
||||
ogTitle="The Nook — FAQ"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Questions people actually ask
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-10 max-w-2xl text-lg">
|
||||
The things that decide a purchase, answered without hedging. If
|
||||
something here doesn't match your experience, that's a bug —{" "}
|
||||
<a
|
||||
class="underline underline-offset-2 hover:opacity-80"
|
||||
href="mailto:support@freno.me"
|
||||
>
|
||||
tell us
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div class="max-w-3xl space-y-3">
|
||||
<For each={FAQ}>
|
||||
{(entry, i) => (
|
||||
<div class="border-overlay0 bg-surface0/40 rounded-2xl border">
|
||||
<button
|
||||
type="button"
|
||||
class="text-text flex w-full items-center justify-between gap-4 p-5 text-left text-base font-semibold"
|
||||
aria-expanded={open() === i()}
|
||||
onClick={() => setOpen(open() === i() ? null : i())}
|
||||
>
|
||||
{entry.question}
|
||||
<span class="text-subtext1 shrink-0 font-mono text-sm">
|
||||
{open() === i() ? "−" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
<Show when={open() === i()}>
|
||||
<p class="text-subtext0 px-5 pb-5 text-sm leading-relaxed">
|
||||
{entry.answer}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -55,10 +55,10 @@ export default function NookLanding() {
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Home"
|
||||
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."
|
||||
ogImage={`https://${site().domain}/nook/og.png`}
|
||||
ogTitle="The Nook — native macOS agent orchestration"
|
||||
ogDescription="A one-time-purchase macOS app for coding-agent orchestration, fan control, and thermal insight."
|
||||
/>
|
||||
|
||||
|
||||
108
src/server/nook-lifecycle.ts
Normal file
108
src/server/nook-lifecycle.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { env } from "~/env/server";
|
||||
|
||||
/**
|
||||
* The two lifecycle emails (plus their scheduled follow-ups) for The Nook —
|
||||
* the highest-ROI-per-hour writing in the whole plan, per
|
||||
* the-nook/docs/marketing-strategy.md §4.4:
|
||||
*
|
||||
* (a) trial D+11 expiry reminder with a buy link (scheduled, see
|
||||
* /api/the-nook/lifecycle)
|
||||
* (b) purchase thank-you, sent at purchase, promising a testimonial ask at
|
||||
* D+3 and a review ask at D+14 (also scheduled from the same job)
|
||||
*
|
||||
* All sends are best-effort: failures are logged, never thrown — an email
|
||||
* outage must never fail a purchase webhook or a cron run. Copy follows
|
||||
* marketing/positioning.md: category nouns, no competitor names, honest
|
||||
* privacy scoping.
|
||||
*/
|
||||
|
||||
const SENDER = { name: "The Nook", email: "support@freno.me" };
|
||||
const CHECKOUT_URL = "https://nook.freno.me/checkout";
|
||||
|
||||
/** Sends one plain-text email via the Brevo SMTP API. Best-effort. */
|
||||
async function send(to: string, subject: string, textContent: string): Promise<boolean> {
|
||||
if (!env.SENDINBLUE_KEY) {
|
||||
console.error("Lifecycle email skipped — SENDINBLUE_KEY unset:", subject);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("https://api.brevo.com/v3/smtp/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key": env.SENDINBLUE_KEY,
|
||||
"content-type": "application/json",
|
||||
accept: "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sender: SENDER,
|
||||
replyTo: SENDER,
|
||||
to: [{ email: to }],
|
||||
subject,
|
||||
textContent
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Lifecycle email "${subject}" to ${to}: HTTP ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Lifecycle email "${subject}" to ${to} failed:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** (a) Trial D+11: the expiry reminder with a buy link. */
|
||||
export function emailTrialReminder(to: string): Promise<boolean> {
|
||||
return send(
|
||||
to,
|
||||
"Your Nook trial ends in 3 days",
|
||||
`Hi,\n\nYour Nook trial ends in about three days.\n\n` +
|
||||
`If it's been keeping your agents unblocked and your Mac cool, you can ` +
|
||||
`keep it with a one-time purchase — no subscription:\n\n${CHECKOUT_URL}\n\n` +
|
||||
`If it's not for you, no action needed — the trial just ends and nothing ` +
|
||||
`is charged. Either way, thanks for trying it.\n\n` +
|
||||
`— Mike, The Nook`
|
||||
);
|
||||
}
|
||||
|
||||
/** (b) Purchase thank-you, sent immediately at purchase. */
|
||||
export function emailThankYou(to: string): Promise<boolean> {
|
||||
return send(
|
||||
to,
|
||||
"Welcome to The Nook",
|
||||
`Hi,\n\nThank you for buying The Nook — your license key just arrived in a ` +
|
||||
`separate email.\n\n` +
|
||||
`Two small things to expect from me, nothing more: in a few days I'll ` +
|
||||
`ask how it's going, and later I'll ask if you'd share a line about it. ` +
|
||||
`That's the whole newsletter.\n\n` +
|
||||
`If anything is off — activation, a hook that won't fire, anything — ` +
|
||||
`just reply to this email.\n\n— Mike, The Nook`
|
||||
);
|
||||
}
|
||||
|
||||
/** (b1) Purchase D+3: the testimonial ask. */
|
||||
export function emailTestimonialAsk(to: string): Promise<boolean> {
|
||||
return send(
|
||||
to,
|
||||
"How is The Nook going for you?",
|
||||
`Hi,\n\nYou've had The Nook for a few days now. How is it going?\n\n` +
|
||||
`If it's earned its place, reply with a line or two about what it does ` +
|
||||
`for you — with your permission, and only with it, I'd love to share ` +
|
||||
`your words on the site.\n\n` +
|
||||
`If something is in the way, reply with that instead: it's a faster fix ` +
|
||||
`than you'd think.\n\n— Mike, The Nook`
|
||||
);
|
||||
}
|
||||
|
||||
/** (b2) Purchase D+14: the review ask. */
|
||||
export function emailReviewAsk(to: string): Promise<boolean> {
|
||||
return send(
|
||||
to,
|
||||
"Two weeks in — would you share a line?",
|
||||
`Hi,\n\nTwo weeks with The Nook. If it's doing its job, would you share a ` +
|
||||
`sentence about it? A reply is fine — with your name (or handle) and ` +
|
||||
`what setup you run, it helps other people with a dozen agents decide.\n\n` +
|
||||
`Thank you either way.\n\n— Mike, The Nook`
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,9 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
||||
stripe_session_id TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
max_devices INTEGER NOT NULL DEFAULT 3
|
||||
max_devices INTEGER NOT NULL DEFAULT 3,
|
||||
testimonial_asked_at TEXT,
|
||||
review_asked_at TEXT
|
||||
)
|
||||
`);
|
||||
const licenseCols = await conn.execute(`PRAGMA table_info(licenses)`);
|
||||
@@ -46,6 +48,18 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
||||
`ALTER TABLE licenses ADD COLUMN max_devices INTEGER NOT NULL DEFAULT 3`
|
||||
);
|
||||
}
|
||||
const licenseColsAll = await conn.execute(`PRAGMA table_info(licenses)`);
|
||||
const licenseNames = new Set(
|
||||
licenseColsAll.rows.map((r) => (r as { name?: string }).name)
|
||||
);
|
||||
if (!licenseNames.has("testimonial_asked_at")) {
|
||||
await conn.execute(
|
||||
`ALTER TABLE licenses ADD COLUMN testimonial_asked_at TEXT`
|
||||
);
|
||||
}
|
||||
if (!licenseNames.has("review_asked_at")) {
|
||||
await conn.execute(`ALTER TABLE licenses ADD COLUMN review_asked_at TEXT`);
|
||||
}
|
||||
await conn.execute(`
|
||||
CREATE TABLE IF NOT EXISTS activations (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -59,9 +73,27 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
||||
await conn.execute(`
|
||||
CREATE TABLE IF NOT EXISTS trials (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
started_at TEXT NOT NULL
|
||||
started_at TEXT NOT NULL,
|
||||
email TEXT
|
||||
)
|
||||
`);
|
||||
// Pre-existing installs created `trials` without the email column.
|
||||
const trialCols = await conn.execute(`PRAGMA table_info(trials)`);
|
||||
const hasEmail = trialCols.rows.some(
|
||||
(r) => (r as { name?: string }).name === "email"
|
||||
);
|
||||
if (!hasEmail) {
|
||||
await conn.execute(`ALTER TABLE trials ADD COLUMN email TEXT`);
|
||||
}
|
||||
const trialColsAll = await conn.execute(`PRAGMA table_info(trials)`);
|
||||
const trialNames = new Set(
|
||||
trialColsAll.rows.map((r) => (r as { name?: string }).name)
|
||||
);
|
||||
if (!trialNames.has("reminder_sent_at")) {
|
||||
await conn.execute(
|
||||
`ALTER TABLE trials ADD COLUMN reminder_sent_at TEXT`
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
function privateKeyObject() {
|
||||
@@ -214,6 +246,57 @@ export async function grantLicense(
|
||||
return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records (or replaces) the email a user gave at trial start, and adds it to
|
||||
* the trial-list in Brevo so the lifecycle mail has an audience. Best-effort
|
||||
* on both counts: a failed Brevo call is logged, never thrown — the address
|
||||
* is already durably in `trials`.
|
||||
*
|
||||
* Returns true when a `trials` row now carries the email.
|
||||
*/
|
||||
export async function setTrialEmail(
|
||||
fingerprint: string,
|
||||
email: string
|
||||
): Promise<boolean> {
|
||||
const conn = NookConnectionFactory();
|
||||
const res = await conn.execute({
|
||||
sql: `
|
||||
INSERT INTO trials (fingerprint, started_at, email)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(fingerprint) DO UPDATE SET email = excluded.email
|
||||
`,
|
||||
args: [fingerprint, new Date().toISOString(), email]
|
||||
});
|
||||
await addTrialEmailToList(email);
|
||||
return res.rowsAffected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an address to the Brevo trial list (the same API family
|
||||
* `emailLicenseKey` speaks). No-op when NOOK_TRIAL_LIST_ID is not configured.
|
||||
*/
|
||||
async function addTrialEmailToList(email: string): Promise<void> {
|
||||
const listId = env.NOOK_TRIAL_LIST_ID;
|
||||
if (!listId) return;
|
||||
try {
|
||||
await fetch("https://api.brevo.com/v3/contacts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key": env.SENDINBLUE_KEY,
|
||||
"content-type": "application/json",
|
||||
accept: "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
updateEnabled: true,
|
||||
listIds: [listId]
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add trial email to Brevo list:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a license key to the buyer. Best-effort — failures are logged, never
|
||||
* thrown, so the caller (checkout webhook / resend) isn't interrupted.
|
||||
|
||||
@@ -55,5 +55,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"crons": [
|
||||
{ "path": "/api/the-nook/lifecycle", "schedule": "0 13 * * *" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user