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:
43
CLAUDE.md
43
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.
|
**names.poweredbyotm.com**. A first-party **Powered by OTM** app.
|
||||||
|
|
||||||
- **Stack:** Next.js 15 (App Router, `output: "standalone"`), React 18, no DB,
|
- **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).
|
route (browsers can't do DNS, and RDAP servers send no CORS headers).
|
||||||
- **Repo:** `tonym/names` on `git.poweredbyotm.com`.
|
- **Repo:** `tonym/names` on `git.poweredbyotm.com`.
|
||||||
- **Deploy:** platform MCP — `build_app("names")` then `deploy_app("names")`.
|
- **Deploy:** platform MCP — `build_app("names")` then `deploy_app("names")`.
|
||||||
@@ -15,8 +15,9 @@ Free business-name generator with live domain availability, at
|
|||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Registered in `platform/apps/otm-admin/lib/first-party-apps.ts` as
|
Registered in `platform/apps/otm-admin/lib/first-party-apps.ts` as
|
||||||
`publicAccess: true`, `needsDb: false`, `needsAuthSecret: false`,
|
`publicAccess: true`, `needsDb: false`, `needsAuthSecret: true` (only for
|
||||||
`needsSuggestions: true`, `uploads: "/app/public/uploads"`.
|
`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
|
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
|
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.
|
live lookups and **Re-check** re-queries through the normal availability route.
|
||||||
No server state, on purpose.
|
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
|
`src/lib/ai-prompt.ts` holds the prompt + parser both paths share; change the
|
||||||
`localStorage` (`names.aiKey`), called browser-direct — same pattern as `rpo`'s
|
brief there, never in one path.
|
||||||
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
|
1. **Platform gateway (default, needs OTM sign-in).** The platform registers
|
||||||
`anthropic-dangerous-direct-browser-access` header is correct *here* and would be
|
this app `needsAnthropic` and injects `ANTHROPIC_BASE_URL` (its metered
|
||||||
wrong in a product that owns the key.
|
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
|
## Registrar links
|
||||||
|
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -60,6 +60,20 @@ straight back:
|
|||||||
`diversify()` caps each strategy at ~28% of the returned list. Without it the
|
`diversify()` caps each strategy at ~28% of the returned list. Without it the
|
||||||
top 20 came back as eighteen coinages — scoring alone clumps badly.
|
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
|
## Bring-your-own AI key
|
||||||
|
|
||||||
Optional. `/settings` stores an Anthropic or Gemini key in `localStorage`
|
Optional. `/settings` stores an Anthropic or Gemini key in `localStorage`
|
||||||
@@ -97,7 +111,10 @@ npm run typecheck
|
|||||||
npm run build
|
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
|
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
|
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
|
`node_modules/@otm/account-panel/src`. The app is always dark, so `<html
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "names-poweredbyotm",
|
"name": "names-poweredbyotm",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3000",
|
"dev": "next dev --port 3000",
|
||||||
|
|||||||
122
src/app/api/ai-names/route.ts
Normal file
122
src/app/api/ai-names/route.ts
Normal 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))] });
|
||||||
|
}
|
||||||
85
src/app/api/auth/otm-sso/route.ts
Normal file
85
src/app/api/auth/otm-sso/route.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/api/auth/session/route.ts
Normal file
19
src/app/api/auth/session/route.ts
Normal 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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/api/auth/signout/route.ts
Normal file
10
src/app/api/auth/signout/route.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -360,6 +360,30 @@ h2 {
|
|||||||
text-decoration: underline;
|
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 ---------- */
|
/* ---------- notices ---------- */
|
||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export default function SettingsPage() {
|
|||||||
<p className="tagline">
|
<p className="tagline">
|
||||||
The name finder works fully offline without this — the built-in generator needs no
|
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
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="notice info">
|
<p className="notice info">
|
||||||
|
|||||||
@@ -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_TLDS, TLDS, UNVERIFIABLE_TLDS } from "@/lib/tlds";
|
||||||
import { DEFAULT_REGISTRAR, REGISTRARS, registerUrl } from "@/lib/registrars";
|
import { DEFAULT_REGISTRAR, REGISTRARS, registerUrl } from "@/lib/registrars";
|
||||||
import { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai";
|
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";
|
import { loadWishlist, saveWishlist, wishlistAsText, WISHLIST_MAX, type SavedName } from "@/lib/wishlist";
|
||||||
|
|
||||||
type Availability = "available" | "taken" | "unverified-available" | "unknown";
|
type Availability = "available" | "taken" | "unverified-available" | "unknown";
|
||||||
@@ -45,6 +47,12 @@ export default function Finder() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [ai, setAi] = useState<AiSettings | null>(null);
|
const [ai, setAi] = useState<AiSettings | null>(null);
|
||||||
const [useAi, setUseAi] = useState(false);
|
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 [saved, setSaved] = useState<SavedName[]>([]);
|
||||||
const [savedLoaded, setSavedLoaded] = useState(false);
|
const [savedLoaded, setSavedLoaded] = useState(false);
|
||||||
const [rechecking, setRechecking] = useState(false);
|
const [rechecking, setRechecking] = useState(false);
|
||||||
@@ -159,6 +167,38 @@ export default function Finder() {
|
|||||||
setAi(loadAiSettings());
|
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
|
// 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.
|
// the previous query can't paint stale statuses over the new results.
|
||||||
const runId = useRef(0);
|
const runId = useRef(0);
|
||||||
@@ -242,11 +282,16 @@ export default function Finder() {
|
|||||||
seed: nextSeed,
|
seed: nextSeed,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The visitor's own AI key, if they saved one and asked us to use it.
|
// AI names: the visitor's own key if they saved one and asked us to use
|
||||||
// A failure here is never fatal — the built-in list is already computed.
|
// it, else the platform gateway if they're signed in with OTM. A failure
|
||||||
if (useAi && ai) {
|
// 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 {
|
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;
|
if (runId.current !== myRun) return;
|
||||||
const seen = new Set(list.map((i) => i.name));
|
const seen = new Set(list.map((i) => i.name));
|
||||||
const extra: Idea[] = aiNames
|
const extra: Idea[] = aiNames
|
||||||
@@ -265,7 +310,7 @@ export default function Finder() {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
await lookup(domainsFor(list, tlds), myRun);
|
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 = () => {
|
const onGenerate = () => {
|
||||||
@@ -370,6 +415,26 @@ export default function Finder() {
|
|||||||
/>
|
/>
|
||||||
Use my {ai.provider === "anthropic" ? "Claude" : "Gemini"} key
|
Use my {ai.provider === "anthropic" ? "Claude" : "Gemini"} key
|
||||||
</label>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -377,6 +442,7 @@ export default function Finder() {
|
|||||||
<p className="notice info">Pick at least one extension to check.</p>
|
<p className="notice info">Pick at least one extension to check.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{signinNote ? <p className="notice warn">{signinNote}</p> : null}
|
||||||
{error ? <p className="notice warn">{error}</p> : null}
|
{error ? <p className="notice warn">{error}</p> : null}
|
||||||
|
|
||||||
{showsUnverifiable && ideas.length > 0 ? (
|
{showsUnverifiable && ideas.length > 0 ? (
|
||||||
|
|||||||
44
src/lib/ai-prompt.ts
Normal file
44
src/lib/ai-prompt.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// 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
|
// that owns the key — the distinction is whose key it is. The user pasted their
|
||||||
// own, knowingly, into their own browser.
|
// own, knowingly, into their own browser.
|
||||||
|
|
||||||
|
import { SYSTEM_PROMPT, userPrompt, parseNames } from "@/lib/ai-prompt";
|
||||||
|
|
||||||
export type AiProvider = "anthropic" | "gemini";
|
export type AiProvider = "anthropic" | "gemini";
|
||||||
|
|
||||||
export interface AiSettings {
|
export interface AiSettings {
|
||||||
@@ -39,46 +43,6 @@ export function saveAiSettings(settings: AiSettings | null): void {
|
|||||||
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
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> {
|
async function callAnthropic(key: string, prompt: string): Promise<string> {
|
||||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
29
src/lib/otm.ts
Normal file
29
src/lib/otm.ts
Normal 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
13
src/lib/platform-ai.ts
Normal 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
96
src/lib/session.ts
Normal 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
85
src/lib/sso-ticket.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// /changelog. Bump VERSION and prepend an entry when shipping a user-facing
|
// /changelog. Bump VERSION and prepend an entry when shipping a user-facing
|
||||||
// change. Same convention as rpo.
|
// change. Same convention as rpo.
|
||||||
|
|
||||||
export const VERSION = "0.2.0";
|
export const VERSION = "0.3.0";
|
||||||
|
|
||||||
export interface ChangelogEntry {
|
export interface ChangelogEntry {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,14 @@ export interface ChangelogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.2.0",
|
||||||
date: "2026-08-16",
|
date: "2026-08-16",
|
||||||
|
|||||||
Reference in New Issue
Block a user