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

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