feat: platform AI names behind Sign in with OTM (v0.3.0)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG
This commit is contained in:
2026-08-16 18:27:38 -05:00
parent c7d6812466
commit c307a75880
17 changed files with 666 additions and 60 deletions

View File

@@ -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

View File

@@ -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 `<html

View File

@@ -1,6 +1,6 @@
{
"name": "names-poweredbyotm",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",

View File

@@ -0,0 +1,122 @@
// AI names through the PLATFORM's metered Anthropic gateway — for signed-in
// OTM accounts only.
//
// The platform injects ANTHROPIC_BASE_URL (its gateway) + ANTHROPIC_API_KEY (this
// app's gateway token, NOT an sk-ant key) at deploy time because the app is
// registered needsAnthropic. OTM resolves the real key server-side and meters
// every call against the "Powered By OTM" customer, so the platform's monthly
// cap is the hard stop. What THIS route guarantees is that an anonymous visitor
// can't spend a cent of it: no session cookie → 401, and each signed-in account
// gets a modest hourly allowance on top (a burst of ideas is fine, a script is
// not). Own-key users never come through here — that path is browser-direct.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { readSession, platformAiEnabled } from "@/lib/session";
import { SYSTEM_PROMPT, userPrompt, parseNames } from "@/lib/ai-prompt";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const MODEL = "claude-sonnet-5";
const MAX_COUNT = 24;
// --- Per-account token bucket -----------------------------------------------
// In-memory (one container). Keyed on the OTM account id, so a second browser
// on the same account shares the allowance.
const RATE_CAPACITY = 20; // requests
const RATE_REFILL_PER_SEC = 20 / 3600; // ~20 per hour
const buckets = new Map<string, { tokens: number; at: number }>();
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))] });
}

View File

@@ -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<string, number>();
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");
}
}

View File

@@ -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" } },
);
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -46,7 +46,8 @@ export default function SettingsPage() {
<p className="tagline">
The name finder works fully offline without this &mdash; 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&rsquo;t have a key? Sign in with a free
OTM account on the main page and AI names are included &mdash; a key here overrides that.
</p>
<p className="notice info">

View File

@@ -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<string | null>(null);
const [ai, setAi] = useState<AiSettings | null>(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<string | null>(null);
const [saved, setSaved] = useState<SavedName[]>([]);
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=<reason>.
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
</label>
) : platformAi && account ? (
<label
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" }}
title={`Signed in as ${account.email}`}
>
<input
type="checkbox"
checked={usePlatformAi}
onChange={(e) => setUsePlatformAi(e.target.checked)}
style={{ width: "auto" }}
/>
AI names
<button type="button" className="linkish" onClick={signOut} title={`Sign out ${account.email}`}>
sign out
</button>
</label>
) : platformAi ? (
<a className="signin" href={otmSsoUrl("/")} title="Free OTM account · adds AI-generated names">
Sign in with OTM for AI names
</a>
) : null}
</div>
@@ -377,6 +442,7 @@ export default function Finder() {
<p className="notice info">Pick at least one extension to check.</p>
) : null}
{signinNote ? <p className="notice warn">{signinNote}</p> : null}
{error ? <p className="notice warn">{error}</p> : null}
{showsUnverifiable && ideas.length > 0 ? (

44
src/lib/ai-prompt.ts Normal file
View File

@@ -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 [];
}
}

View File

@@ -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<string> {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",

29
src/lib/otm.ts Normal file
View File

@@ -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)}`;
}

13
src/lib/platform-ai.ts Normal file
View File

@@ -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<string[]> {
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 ?? [];
}

96
src/lib/session.ts Normal file
View File

@@ -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<Session>;
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(),
);
}

85
src/lib/sso-ticket.ts Normal file
View File

@@ -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:<audience>") — 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;
}
}

View File

@@ -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",