From c307a7588055a061d63cb47b3d1940654cff912a Mon Sep 17 00:00:00 2001 From: tonym Date: Sun, 16 Aug 2026 18:27:38 -0500 Subject: [PATCH] feat: platform AI names behind Sign in with OTM (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OTM SSO: /api/auth/otm-sso verifies the control-plane ticket (vendored verifier), in-memory jti claim, mints an HMAC cookie (src/lib/session.ts); /api/auth/session + /api/auth/signout. No accounts, no DB. - /api/ai-names: server-side call to the platform's metered gateway (ANTHROPIC_BASE_URL + per-app gateway token), 401 without a session, ~20 req/h per OTM account. Prompt/parser shared with the BYO-key path via src/lib/ai-prompt.ts. - Finder: 'Sign in with OTM for AI names' link → 'AI names' toggle when signed in; own key still overrides. Pairs with platform 0.116.0 (needsAnthropic + needsAuthSecret on names). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG --- CLAUDE.md | 43 ++++++++--- README.md | 19 ++++- package.json | 2 +- src/app/api/ai-names/route.ts | 122 ++++++++++++++++++++++++++++++ src/app/api/auth/otm-sso/route.ts | 85 +++++++++++++++++++++ src/app/api/auth/session/route.ts | 19 +++++ src/app/api/auth/signout/route.ts | 10 +++ src/app/globals.css | 24 ++++++ src/app/settings/page.tsx | 3 +- src/components/Finder.tsx | 76 +++++++++++++++++-- src/lib/ai-prompt.ts | 44 +++++++++++ src/lib/ai.ts | 46 ++--------- src/lib/otm.ts | 29 +++++++ src/lib/platform-ai.ts | 13 ++++ src/lib/session.ts | 96 +++++++++++++++++++++++ src/lib/sso-ticket.ts | 85 +++++++++++++++++++++ src/lib/version.ts | 10 ++- 17 files changed, 666 insertions(+), 60 deletions(-) create mode 100644 src/app/api/ai-names/route.ts create mode 100644 src/app/api/auth/otm-sso/route.ts create mode 100644 src/app/api/auth/session/route.ts create mode 100644 src/app/api/auth/signout/route.ts create mode 100644 src/lib/ai-prompt.ts create mode 100644 src/lib/otm.ts create mode 100644 src/lib/platform-ai.ts create mode 100644 src/lib/session.ts create mode 100644 src/lib/sso-ticket.ts diff --git a/CLAUDE.md b/CLAUDE.md index 981d898..d302670 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Free business-name generator with live domain availability, at **names.poweredbyotm.com**. A first-party **Powered by OTM** app. - **Stack:** Next.js 15 (App Router, `output: "standalone"`), React 18, no DB, - no auth, public. Generation runs **client-side**; availability is a server + no local accounts, public. Sign in with OTM (SSO) exists only to gate AI. Generation runs **client-side**; availability is a server route (browsers can't do DNS, and RDAP servers send no CORS headers). - **Repo:** `tonym/names` on `git.poweredbyotm.com`. - **Deploy:** platform MCP — `build_app("names")` then `deploy_app("names")`. @@ -15,8 +15,9 @@ Free business-name generator with live domain availability, at ## Deploy Registered in `platform/apps/otm-admin/lib/first-party-apps.ts` as -`publicAccess: true`, `needsDb: false`, `needsAuthSecret: false`, -`needsSuggestions: true`, `uploads: "/app/public/uploads"`. +`publicAccess: true`, `needsDb: false`, `needsAuthSecret: true` (only for +`OTM_SSO_SECRET`/`AUTH_SECRET` — see below), `needsSuggestions: true`, +`needsAnthropic: true`, `uploads: "/app/public/uploads"`. Adding this app to the registry required an otm-admin release (0.113.0) — and because the auto-deploy webhook is **gated on green CI**, `names` does not @@ -94,14 +95,36 @@ snapshots the last-seen status per domain; the Finder keeps snapshots fresh from live lookups and **Re-check** re-queries through the normal availability route. No server state, on purpose. -## Keys stay in the browser +## AI — two paths, one brief -Optional AI names use the **visitor's own** Anthropic/Gemini key from -`localStorage` (`names.aiKey`), called browser-direct — same pattern as `rpo`'s -Google Vision key, for the same reason: the platform has no per-app env tool, -and a free public tool must not hold a metered credential. The -`anthropic-dangerous-direct-browser-access` header is correct *here* and would be -wrong in a product that owns the key. +`src/lib/ai-prompt.ts` holds the prompt + parser both paths share; change the +brief there, never in one path. + +1. **Platform gateway (default, needs OTM sign-in).** The platform registers + this app `needsAnthropic` and injects `ANTHROPIC_BASE_URL` (its metered + gateway) + `ANTHROPIC_API_KEY` (this app's *gateway token* `otmapp_names_…`, + NOT an sk-ant key — see `platform/apps/otm-admin/lib/first-party-ai.ts`). + `app/api/ai-names` calls it server-side, **only with a session cookie**, plus + ~20 requests/hour per OTM account. Usage is metered against the "Powered By + OTM" customer, so the platform's monthly cap on `/settings/ai` is the hard + stop. **The login gate is what makes it safe to hold a metered credential on + a free public tool — do not add an anonymous path to `/api/ai-names`.** +2. **Bring your own key (override).** `localStorage` `names.aiKey`, called + browser-direct — same pattern as `rpo`'s Google Vision key. The + `anthropic-dangerous-direct-browser-access` header is correct *here* and + would be wrong in a product that owns the key. When a key is saved, the UI + shows only this path. + +## Sign in with OTM — no accounts here + +`src/lib/otm.ts` (`app:names` audience, bounce URLs) → OTM's `/sso/authorize` +→ `app/api/auth/otm-sso` verifies the 60s ticket (`src/lib/sso-ticket.ts`, +vendored verbatim from `@otm/account-panel` — keep in sync) and mints our own +HMAC cookie (`src/lib/session.ts`, `names.session`, 12h, signed with +`AUTH_SECRET`). jti replay guard is an in-memory Map — fine for one container +and 60s tickets. No user rows, no DB. `/api/auth/session` tells the UI who's +signed in and whether platform AI is wired (`platformAi`); if the env is +missing the feature is simply absent. ## Registrar links diff --git a/README.md b/README.md index 8593116..c501c02 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,20 @@ straight back: `diversify()` caps each strategy at ~28% of the returned list. Without it the top 20 came back as eighteen coinages — scoring alone clumps badly. +## AI names + +Sign in with a free OTM account (the link sits next to the generate button) and +an **AI names** toggle appears: Claude-generated ideas are merged in ahead of the +built-in list, checked for availability like everything else. The call goes +`browser → /api/ai-names → OTM's metered Anthropic gateway → Anthropic`; the app +holds a per-app gateway token, never a real Anthropic key, and the route refuses +anonymous requests and caps each account at roughly 20 requests an hour. The +platform's monthly AI cap is the backstop. + +Sign-in is OTM SSO: `/sso/authorize?aud=app:names` on the control plane hands +back a 60-second single-use ticket to `/api/auth/otm-sso`, which mints a small +HMAC-signed cookie (`names.session`). There are no user accounts in this app. + ## Bring-your-own AI key Optional. `/settings` stores an Anthropic or Gemini key in `localStorage` @@ -97,7 +111,10 @@ npm run typecheck npm run build ``` -No env vars are needed to run it. Tailwind is present **only** to style the +No env vars are needed to run it. Sign-in and platform AI light up only when the +platform injects `AUTH_SECRET`, `OTM_SSO_SECRET`, `ANTHROPIC_BASE_URL` and +`ANTHROPIC_API_KEY`; without them `/api/auth/session` reports `platformAi:false` +and the UI hides the feature. Tailwind is present **only** to style the shared `@otm/account-panel` widgets (the bottom-right feedback chat-pop) — the app's own UI is handwritten CSS, so `preflight` is off and the config scans `node_modules/@otm/account-panel/src`. The app is always dark, so `(); + +function take(key: string): boolean { + const now = Date.now(); + const b = buckets.get(key) ?? { tokens: RATE_CAPACITY, at: now }; + b.tokens = Math.min(RATE_CAPACITY, b.tokens + ((now - b.at) / 1000) * RATE_REFILL_PER_SEC); + b.at = now; + if (b.tokens < 1) { + buckets.set(key, b); + return false; + } + b.tokens -= 1; + if (buckets.size > 10000) buckets.clear(); + buckets.set(key, b); + return true; +} + +interface Body { + keywords?: unknown; + style?: unknown; + count?: unknown; +} + +export async function POST(req: NextRequest) { + if (!platformAiEnabled()) { + return NextResponse.json({ error: "AI names are not enabled on this deployment." }, { status: 503 }); + } + const session = readSession(req); + if (!session) { + return NextResponse.json({ error: "Sign in with OTM to use AI names." }, { status: 401 }); + } + if (!take(session.sub)) { + return NextResponse.json( + { error: "You've used this hour's AI allowance — the built-in generator is unlimited." }, + { status: 429 }, + ); + } + + let body: Body; + try { + body = (await req.json()) as Body; + } catch { + return NextResponse.json({ error: "Body must be JSON." }, { status: 400 }); + } + const keywords = Array.isArray(body.keywords) + ? body.keywords + .filter((k): k is string => typeof k === "string") + .map((k) => k.trim().toLowerCase().slice(0, 40)) + .filter(Boolean) + .slice(0, 12) + : []; + const style = typeof body.style === "string" ? body.style.slice(0, 20) : "balanced"; + const count = Math.max(4, Math.min(MAX_COUNT, typeof body.count === "number" ? Math.floor(body.count) : 16)); + + const base = process.env.ANTHROPIC_BASE_URL!.replace(/\/+$/, ""); + let upstream: Response; + try { + upstream = await fetch(`${base}/v1/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": process.env.ANTHROPIC_API_KEY!, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: MODEL, + max_tokens: 1024, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: userPrompt(keywords.length ? keywords : ["a new business"], style, count) }], + }), + signal: AbortSignal.timeout(30_000), + }); + } catch { + return NextResponse.json({ error: "Could not reach the AI service." }, { status: 502 }); + } + + if (!upstream.ok) { + // The gateway speaks Anthropic-shaped errors; surface the cap/rate cases + // readably and hide the rest behind a generic message. + const err = (await upstream.json().catch(() => ({}))) as { error?: { type?: string; message?: string } }; + const type = err.error?.type; + if (upstream.status === 429 || type === "rate_limit_error") { + return NextResponse.json( + { error: "AI names are paused for now (usage cap). The built-in generator still works." }, + { status: 429 }, + ); + } + return NextResponse.json({ error: `AI service returned ${upstream.status}.` }, { status: 502 }); + } + + const data = (await upstream.json()) as { content?: Array<{ type: string; text?: string }> }; + const text = (data.content ?? []).map((b) => (b.type === "text" ? (b.text ?? "") : "")).join(""); + return NextResponse.json({ names: [...new Set(parseNames(text))] }); +} diff --git a/src/app/api/auth/otm-sso/route.ts b/src/app/api/auth/otm-sso/route.ts new file mode 100644 index 0000000..a4ef046 --- /dev/null +++ b/src/app/api/auth/otm-sso/route.ts @@ -0,0 +1,85 @@ +// "Sign in with OTM" — the callback the control plane redirects to. +// +// The visitor hits admin.poweredbyotm.com/sso/authorize?aud=app:names, OTM +// authenticates them (any verified account — this app is publicAccess) and 302s +// here with a 60s single-use HS256 ticket. We verify it (src/lib/sso-ticket.ts), +// claim its jti so a copied link can't be replayed, and mint our own signed +// cookie (src/lib/session.ts). Every failure path redirects to /?signin=… ; +// nothing here may 500. +// +// The jti claim is in-memory: tickets live 60s and this app runs as one +// container, so a Map with a short TTL is a complete replay guard for the only +// window that matters. It does not survive a restart — a ticket minted before +// the restart is at most 60s old, and the attacker would need both the link +// and a restart in that minute. + +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { verifySsoTicket } from "@/lib/sso-ticket"; +import { OTM_AUDIENCE } from "@/lib/otm"; +import { encodeSession, setSessionCookie, SESSION_MAX_AGE } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +const CONSUMED_TTL_MS = 10 * 60 * 1000; +const consumed = new Map(); + +function claimJti(jti: string): boolean { + const now = Date.now(); + if (consumed.size > 5000) { + for (const [k, at] of consumed) if (now - at > CONSUMED_TTL_MS) consumed.delete(k); + } + const at = consumed.get(jti); + if (at && now - at < CONSUMED_TTL_MS) return false; + consumed.set(jti, now); + return true; +} + +// req.url reports the container's internal listen address behind Caddy; the +// forwarded headers tell us the URL the browser actually used. +function publicUrl(req: NextRequest, path: string): URL { + const h = req.headers; + const host = h.get("x-forwarded-host") ?? h.get("host") ?? req.nextUrl.host; + const proto = h.get("x-forwarded-proto")?.split(",")[0].trim() ?? req.nextUrl.protocol.replace(":", ""); + return new URL(path, `${proto}://${host}`); +} + +function safeNext(next: string | undefined): string | undefined { + if (!next || !next.startsWith("/") || next.startsWith("//")) return undefined; + return next; +} + +function bounce(req: NextRequest, reason: string): NextResponse { + return NextResponse.redirect(publicUrl(req, `/?signin=${reason}`)); +} + +export async function GET(req: NextRequest) { + try { + const token = req.nextUrl.searchParams.get("token"); + if (!token) return bounce(req, "invalid_token"); + const secret = process.env.OTM_SSO_SECRET?.trim(); + if (!secret) return bounce(req, "not_enabled"); + + const payload = verifySsoTicket(secret, token, { expectedAud: OTM_AUDIENCE }); + if (!payload) return bounce(req, "invalid_token"); + // Single-use FIRST: claim before any session material exists. + if (!claimJti(payload.jti)) return bounce(req, "link_already_used"); + + const email = payload.email.trim().toLowerCase(); + if (!email) return bounce(req, "invalid_token"); + + const value = encodeSession({ + sub: payload.sub, + email, + name: payload.name?.trim() || undefined, + exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE, + }); + if (!value) return bounce(req, "not_enabled"); + + const res = NextResponse.redirect(publicUrl(req, safeNext(payload.next) ?? "/")); + setSessionCookie(res, req, value); + return res; + } catch { + return bounce(req, "server_error"); + } +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..c8791aa --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,19 @@ +// Who's signed in (if anyone) and whether platform AI is even wired up on this +// deployment. The UI decides from this what to show next to the AI toggle. + +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { readSession, platformAiEnabled } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const s = readSession(req); + return NextResponse.json( + { + platformAi: platformAiEnabled(), + user: s ? { email: s.email, name: s.name ?? null } : null, + }, + { headers: { "cache-control": "no-store" } }, + ); +} diff --git a/src/app/api/auth/signout/route.ts b/src/app/api/auth/signout/route.ts new file mode 100644 index 0000000..48f8bc1 --- /dev/null +++ b/src/app/api/auth/signout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { clearSessionCookie } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + clearSessionCookie(res); + return res; +} diff --git a/src/app/globals.css b/src/app/globals.css index 3eb0930..44b7f53 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -360,6 +360,30 @@ h2 { text-decoration: underline; } +.signin { + font-size: 14px; + text-decoration: none; + white-space: nowrap; +} + +.signin:hover { + text-decoration: underline; +} + +.wrap button.linkish { + background: none; + border: none; + padding: 0; + font-size: 12px; + color: var(--faint); + text-decoration: underline; +} + +.wrap button.linkish:hover:not(:disabled) { + color: var(--muted); + border-color: transparent; +} + /* ---------- notices ---------- */ .notice { diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 4f8fceb..0efab78 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -46,7 +46,8 @@ export default function SettingsPage() {

The name finder works fully offline without this — the built-in generator needs no key and costs nothing. Adding your own API key gets you a second, usually more - imaginative set of suggestions alongside it. + imaginative set of suggestions alongside it. Don’t have a key? Sign in with a free + OTM account on the main page and AI names are included — a key here overrides that.

diff --git a/src/components/Finder.tsx b/src/components/Finder.tsx index 23e12f3..d30bca4 100644 --- a/src/components/Finder.tsx +++ b/src/components/Finder.tsx @@ -5,6 +5,8 @@ import { generateNames, scoreName, type Idea, type Style } from "@/lib/generate" import { DEFAULT_TLDS, TLDS, UNVERIFIABLE_TLDS } from "@/lib/tlds"; import { DEFAULT_REGISTRAR, REGISTRARS, registerUrl } from "@/lib/registrars"; import { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai"; +import { generateWithPlatformAi } from "@/lib/platform-ai"; +import { otmSsoUrl } from "@/lib/otm"; import { loadWishlist, saveWishlist, wishlistAsText, WISHLIST_MAX, type SavedName } from "@/lib/wishlist"; type Availability = "available" | "taken" | "unverified-available" | "unknown"; @@ -45,6 +47,12 @@ export default function Finder() { const [error, setError] = useState(null); const [ai, setAi] = useState(null); const [useAi, setUseAi] = useState(false); + // Platform AI: who's signed in with OTM (null = nobody) and whether this + // deployment has the gateway wired at all. Fetched once on mount. + const [account, setAccount] = useState<{ email: string; name: string | null } | null>(null); + const [platformAi, setPlatformAi] = useState(false); + const [usePlatformAi, setUsePlatformAi] = useState(true); + const [signinNote, setSigninNote] = useState(null); const [saved, setSaved] = useState([]); const [savedLoaded, setSavedLoaded] = useState(false); const [rechecking, setRechecking] = useState(false); @@ -159,6 +167,38 @@ export default function Finder() { setAi(loadAiSettings()); }, []); + useEffect(() => { + let alive = true; + fetch("/api/auth/session", { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .then((b: { platformAi?: boolean; user?: { email: string; name: string | null } | null } | null) => { + if (!alive || !b) return; + setPlatformAi(Boolean(b.platformAi)); + setAccount(b.user ?? null); + }) + .catch(() => {}); + // The SSO callback bounces failures back here as ?signin=. + const reason = new URLSearchParams(window.location.search).get("signin"); + if (reason) { + setSigninNote( + reason === "link_already_used" + ? "That sign-in link was already used — try again." + : reason === "not_enabled" + ? "Sign-in isn't enabled on this deployment." + : "Sign-in didn't complete — try again.", + ); + window.history.replaceState(null, "", window.location.pathname); + } + return () => { + alive = false; + }; + }, []); + + const signOut = async () => { + await fetch("/api/auth/signout", { method: "POST" }).catch(() => {}); + setAccount(null); + }; + // Abandon in-flight lookups when a new search starts, so a slow batch from // the previous query can't paint stale statuses over the new results. const runId = useRef(0); @@ -242,11 +282,16 @@ export default function Finder() { seed: nextSeed, }); - // The visitor's own AI key, if they saved one and asked us to use it. - // A failure here is never fatal — the built-in list is already computed. - if (useAi && ai) { + // AI names: the visitor's own key if they saved one and asked us to use + // it, else the platform gateway if they're signed in with OTM. A failure + // here is never fatal — the built-in list is already computed. + const wantsOwnKey = useAi && ai; + const wantsPlatform = !ai && platformAi && account && usePlatformAi; + if (wantsOwnKey || wantsPlatform) { try { - const aiNames = await generateWithAi(ai, parsedKeywords, style, 16); + const aiNames = wantsOwnKey + ? await generateWithAi(ai, parsedKeywords, style, 16) + : await generateWithPlatformAi(parsedKeywords, style, 16); if (runId.current !== myRun) return; const seen = new Set(list.map((i) => i.name)); const extra: Idea[] = aiNames @@ -265,7 +310,7 @@ export default function Finder() { setBusy(false); await lookup(domainsFor(list, tlds), myRun); }, - [ai, domainsFor, lookup, parsedKeywords, style, tlds, useAi], + [account, ai, domainsFor, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi], ); const onGenerate = () => { @@ -370,6 +415,26 @@ export default function Finder() { /> Use my {ai.provider === "anthropic" ? "Claude" : "Gemini"} key + ) : platformAi && account ? ( + + ) : platformAi ? ( + + Sign in with OTM for AI names → + ) : null} @@ -377,6 +442,7 @@ export default function Finder() {

Pick at least one extension to check.

) : null} + {signinNote ?

{signinNote}

: null} {error ?

{error}

: null} {showsUnverifiable && ideas.length > 0 ? ( diff --git a/src/lib/ai-prompt.ts b/src/lib/ai-prompt.ts new file mode 100644 index 0000000..e7fa9ce --- /dev/null +++ b/src/lib/ai-prompt.ts @@ -0,0 +1,44 @@ +// The AI naming brief — shared by the browser-direct BYO-key path (src/lib/ai.ts) +// and the server-side platform-gateway path (app/api/ai-names). Pure: prompt +// text in, names out. Kept in one place so the two paths can't drift in what +// they ask for or how strictly they clean the answer. + +export const SYSTEM_PROMPT = [ + "You name businesses. Given a description, return brandable company names.", + "", + "Rules:", + "- 4 to 12 letters, lowercase, letters only (no spaces, digits, or hyphens).", + "- Easy to say out loud and to spell after hearing it once.", + "- Prefer real words, evocative compounds, and clean coinages.", + "- Avoid: the literal category word alone, startup filler (-ly on everything,", + " 'solutions', 'synergy', 'hub' overuse), and misspellings of common words.", + "- Vary the register: some grounded and concrete, some abstract and premium.", + "", + 'Respond with ONLY a JSON array of strings. No prose, no code fence.', +].join("\n"); + +export function userPrompt(keywords: string[], style: string, count: number): string { + return [ + `Business description / keywords: ${keywords.join(", ")}`, + `Preferred style: ${style}`, + `Return exactly ${count} names as a JSON array of lowercase strings.`, + ].join("\n"); +} + +/** Pull a JSON array of names out of a model response that may still have prose + * or a code fence around it. */ +export function parseNames(text: string): string[] { + const start = text.indexOf("["); + const end = text.lastIndexOf("]"); + if (start === -1 || end === -1 || end <= start) return []; + try { + const parsed = JSON.parse(text.slice(start, end + 1)) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .filter((n): n is string => typeof n === "string") + .map((n) => n.toLowerCase().replace(/[^a-z]/g, "")) + .filter((n) => n.length >= 3 && n.length <= 18); + } catch { + return []; + } +} diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 5ceb588..a68fc86 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,4 +1,6 @@ -// Optional AI name generation, using the VISITOR'S OWN API key. +// Optional AI name generation, using the VISITOR'S OWN API key. (The other AI +// path — the platform's metered gateway for signed-in OTM accounts — is +// src/lib/platform-ai.ts + app/api/ai-names; both share src/lib/ai-prompt.ts.) // // The key lives in this browser's localStorage and the request goes straight // from the browser to Anthropic/Google. It never touches our server, never @@ -11,6 +13,8 @@ // that owns the key — the distinction is whose key it is. The user pasted their // own, knowingly, into their own browser. +import { SYSTEM_PROMPT, userPrompt, parseNames } from "@/lib/ai-prompt"; + export type AiProvider = "anthropic" | "gemini"; export interface AiSettings { @@ -39,46 +43,6 @@ export function saveAiSettings(settings: AiSettings | null): void { else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } -const SYSTEM_PROMPT = [ - "You name businesses. Given a description, return brandable company names.", - "", - "Rules:", - "- 4 to 12 letters, lowercase, letters only (no spaces, digits, or hyphens).", - "- Easy to say out loud and to spell after hearing it once.", - "- Prefer real words, evocative compounds, and clean coinages.", - "- Avoid: the literal category word alone, startup filler (-ly on everything,", - " 'solutions', 'synergy', 'hub' overuse), and misspellings of common words.", - "- Vary the register: some grounded and concrete, some abstract and premium.", - "", - 'Respond with ONLY a JSON array of strings. No prose, no code fence.', -].join("\n"); - -function userPrompt(keywords: string[], style: string, count: number): string { - return [ - `Business description / keywords: ${keywords.join(", ")}`, - `Preferred style: ${style}`, - `Return exactly ${count} names as a JSON array of lowercase strings.`, - ].join("\n"); -} - -/** Pull a JSON array of names out of a model response that may still have prose - * or a code fence around it. */ -function parseNames(text: string): string[] { - const start = text.indexOf("["); - const end = text.lastIndexOf("]"); - if (start === -1 || end === -1 || end <= start) return []; - try { - const parsed = JSON.parse(text.slice(start, end + 1)) as unknown; - if (!Array.isArray(parsed)) return []; - return parsed - .filter((n): n is string => typeof n === "string") - .map((n) => n.toLowerCase().replace(/[^a-z]/g, "")) - .filter((n) => n.length >= 3 && n.length <= 18); - } catch { - return []; - } -} - async function callAnthropic(key: string, prompt: string): Promise { const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", diff --git a/src/lib/otm.ts b/src/lib/otm.ts new file mode 100644 index 0000000..359bc55 --- /dev/null +++ b/src/lib/otm.ts @@ -0,0 +1,29 @@ +// Where sign-in happens: OTM. This app has no accounts of its own — a free +// public tool doesn't need them — but the AI names feature spends the +// platform's metered Anthropic credit, so it's gated behind "Sign in with OTM" +// (any verified OTM account; `publicAccess` in the platform's first-party +// catalogue). Same bounce qr uses. + +const OTM_BASE = "https://admin.poweredbyotm.com"; + +/** This app's SSO audience — must match the platform's FIRST_PARTY_APPS id. */ +export const OTM_AUDIENCE = "app:names"; + +function safeNext(next?: string): string | undefined { + return next && next.startsWith("/") && !next.startsWith("//") ? next : undefined; +} + +/** Sign in (or straight back with a ticket if already signed in to OTM). */ +export function otmSsoUrl(next?: string): string { + const base = `${OTM_BASE}/sso/authorize?aud=${encodeURIComponent(OTM_AUDIENCE)}`; + const n = safeNext(next); + return n ? `${base}&next=${encodeURIComponent(n)}` : base; +} + +/** Create an OTM account, then land back here via the same /sso/authorize hop. */ +export function otmSignUpUrl(next?: string): string { + const back = + `/sso/authorize?aud=${encodeURIComponent(OTM_AUDIENCE)}` + + (safeNext(next) ? `&next=${encodeURIComponent(safeNext(next)!)}` : ""); + return `${OTM_BASE}/signup?next=${encodeURIComponent(back)}`; +} diff --git a/src/lib/platform-ai.ts b/src/lib/platform-ai.ts new file mode 100644 index 0000000..65c9085 --- /dev/null +++ b/src/lib/platform-ai.ts @@ -0,0 +1,13 @@ +// Client helper for the platform-gateway AI path: POST /api/ai-names and get +// names back. Requires the OTM sign-in cookie; the route does the gating. + +export async function generateWithPlatformAi(keywords: string[], style: string, count = 16): Promise { + const res = await fetch("/api/ai-names", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ keywords, style, count }), + }); + const body = (await res.json().catch(() => ({}))) as { names?: string[]; error?: string }; + if (!res.ok) throw new Error(body.error ?? `AI request failed (${res.status}).`); + return body.names ?? []; +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..09c76ea --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,96 @@ +// The app's own session: a tiny HMAC-signed cookie, minted only by the OTM SSO +// callback. No NextAuth, no DB — there is nothing to store. The payload is the +// OTM identity we were handed (account id, email, name) plus an expiry; the +// signature is over the base64url payload with AUTH_SECRET (injected by the +// platform because the app is registered needsAuthSecret). +// +// Only two things read it: /api/auth/session (so the UI knows who's signed in) +// and /api/ai-names (the metered feature it exists to gate). + +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { NextRequest, NextResponse } from "next/server"; + +export const SESSION_COOKIE = "names.session"; +export const SESSION_MAX_AGE = 12 * 60 * 60; // seconds + +export interface Session { + /** OTM account id — stable, what per-user rate limits key on. */ + sub: string; + email: string; + name?: string; + exp: number; // unix seconds +} + +function secret(): string | null { + const s = (process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET)?.trim(); + return s || null; +} + +function sign(key: string, data: string): string { + return createHmac("sha256", key).update(data).digest("base64url"); +} + +/** Sign a session into a cookie value, or null when the app has no secret. */ +export function encodeSession(s: Session): string | null { + const key = secret(); + if (!key) return null; + const payload = Buffer.from(JSON.stringify(s), "utf8").toString("base64url"); + return `${payload}.${sign(key, payload)}`; +} + +/** Verify + decode. Null on any failure — never throws. */ +export function decodeSession(value: string | undefined | null, now = Date.now()): Session | null { + try { + const key = secret(); + if (!key || !value) return null; + const dot = value.lastIndexOf("."); + if (dot <= 0) return null; + const payload = value.slice(0, dot); + const sig = value.slice(dot + 1); + const expected = Buffer.from(sign(key, payload), "utf8"); + const actual = Buffer.from(sig, "utf8"); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null; + const s = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Partial; + if (typeof s.sub !== "string" || !s.sub) return null; + if (typeof s.email !== "string" || !s.email) return null; + if (typeof s.exp !== "number" || Math.floor(now / 1000) >= s.exp) return null; + return { sub: s.sub, email: s.email, name: typeof s.name === "string" ? s.name : undefined, exp: s.exp }; + } catch { + return null; + } +} + +export function readSession(req: NextRequest): Session | null { + return decodeSession(req.cookies.get(SESSION_COOKIE)?.value); +} + +function isHttps(req: NextRequest): boolean { + const proto = req.headers.get("x-forwarded-proto")?.split(",")[0].trim(); + if (proto) return proto === "https"; + return req.nextUrl.protocol === "https:"; +} + +export function setSessionCookie(res: NextResponse, req: NextRequest, value: string): void { + res.cookies.set(SESSION_COOKIE, value, { + httpOnly: true, + secure: isHttps(req), + sameSite: "lax", + path: "/", + maxAge: SESSION_MAX_AGE, + }); +} + +export function clearSessionCookie(res: NextResponse): void { + res.cookies.set(SESSION_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 }); +} + +/** Everything the platform must have injected for platform AI to work. + * Missing any → the feature is simply absent (own-key path still works). */ +export function platformAiEnabled(): boolean { + return Boolean( + secret() && + process.env.OTM_SSO_SECRET?.trim() && + process.env.ANTHROPIC_BASE_URL?.trim() && + process.env.ANTHROPIC_API_KEY?.trim(), + ); +} diff --git a/src/lib/sso-ticket.ts b/src/lib/sso-ticket.ts new file mode 100644 index 0000000..4e15785 --- /dev/null +++ b/src/lib/sso-ticket.ts @@ -0,0 +1,85 @@ +// Universal OTM login — the consumer-side ticket verifier. Verbatim port of the +// platform's packages/account-panel/src/sso-ticket.ts (same file qr vendors), +// vendored here because names has no NextAuth at all — that package's route +// factory mints a NextAuth cookie; we take the pure verifier only and mint our +// own signed cookie in app/api/auth/otm-sso/route.ts (src/lib/session.ts). +// The control plane (otm-admin lib/sso-core.ts) is the canonical implementation of this format — keep all three in sync if it +// ever changes. +// +// Format: compact HS256 JWS, payload {aud, sub, email, name?, role, jti, iat, +// exp, next?}, signed with a per-audience secret the control plane derives as +// HMAC(master, "otm-sso:") — an app's OTM_SSO_SECRET can verify only +// tickets minted for ITS audience. +// +// Deliberate hardenings vs. the older operator-magic verifier: exp is +// MANDATORY (absence = invalid, not = eternal), and the audience is checked +// explicitly so a misconfigured shared secret still fails closed. + +import crypto from "node:crypto"; + +export interface SsoTicketPayload { + aud: string; + sub: string; + email: string; + name?: string; + role: string; + jti: string; + iat: number; + exp: number; + next?: string; +} + +function sign(secret: string, signingInput: string): string { + return crypto.createHmac("sha256", secret).update(signingInput).digest("base64url"); +} + +/** + * Verify a ticket. Returns null on ANY failure and never throws — callers sit + * on request paths where a thrown parse error would become a 500 instead of a + * clean redirect, and a distinguishing error would be an oracle. + */ +export function verifySsoTicket( + secret: string, + token: string, + opts?: { now?: number; expectedAud?: string }, +): SsoTicketPayload | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const headerB64 = parts[0]; + const payloadB64 = parts[1]; + const sigB64 = parts[2]; + if (!headerB64 || !payloadB64 || !sigB64) return null; + + // alg is read from the (unauthenticated) header only to REJECT — it never + // selects the algorithm (the classic alg:"none"/HS-vs-RS confusion). + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString("utf8")); + if (!header || header.alg !== "HS256") return null; + + // Signature BEFORE any claim parsing; length-check first because + // timingSafeEqual throws on a length mismatch. + const expected = Buffer.from(sign(secret, `${headerB64}.${payloadB64}`), "utf8"); + const actual = Buffer.from(sigB64, "utf8"); + if (expected.length !== actual.length) return null; + if (!crypto.timingSafeEqual(expected, actual)) return null; + + const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8")); + if (!payload || typeof payload !== "object") return null; + + if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) return null; + const nowSec = Math.floor((opts?.now ?? Date.now()) / 1000); + if (nowSec >= payload.exp) return null; + + if (typeof payload.jti !== "string" || !payload.jti) return null; + if (typeof payload.sub !== "string" || !payload.sub) return null; + if (typeof payload.aud !== "string" || !payload.aud) return null; + if (typeof payload.role !== "string" || !payload.role) return null; + if (typeof payload.email !== "string" || !payload.email) return null; + + if (opts?.expectedAud !== undefined && payload.aud !== opts.expectedAud) return null; + + return payload as SsoTicketPayload; + } catch { + return null; + } +} diff --git a/src/lib/version.ts b/src/lib/version.ts index cee0055..e936168 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -2,7 +2,7 @@ // /changelog. Bump VERSION and prepend an entry when shipping a user-facing // change. Same convention as rpo. -export const VERSION = "0.2.0"; +export const VERSION = "0.3.0"; export interface ChangelogEntry { version: string; @@ -11,6 +11,14 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.3.0", + date: "2026-08-16", + changes: [ + "AI names for everyone with a free OTM account: sign in with OTM (top of the controls) and an 'AI names' toggle adds Claude-generated ideas alongside the built-in list — no API key needed. Requests run through the platform's metered gateway with a modest hourly allowance per account.", + "Bringing your own key still works and overrides the platform path; it stays browser-direct as before.", + ], + }, { version: "0.2.0", date: "2026-08-16",