diff --git a/app/api/account/coinpay/route.ts b/app/api/account/coinpay/route.ts new file mode 100644 index 0000000..209979f --- /dev/null +++ b/app/api/account/coinpay/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveAccountId, unauthorized } from "@/lib/api"; +import { coinpayLinkStatus, getAccountById, unlinkCoinpay } from "@/lib/db"; +import { coinpayConfigured, readSession, signSession, SESSION_COOKIE, sessionCookieOptions } from "@/lib/session"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** CoinPay link status for the dashboard's Connect / Disconnect card. */ +export async function GET(req: NextRequest) { + const id = await resolveAccountId(req); + if (!id) return unauthorized(); + return NextResponse.json({ enabled: coinpayConfigured(), ...(await coinpayLinkStatus(id)) }); +} + +/** Disconnect CoinPay. Refused when it is the account's only way to sign in. */ +export async function DELETE(req: NextRequest) { + const id = await resolveAccountId(req); + if (!id) return unauthorized(); + const r = await unlinkCoinpay(id); + if (!r.ok) { + const error = r.reason === "only_login" + ? "CoinPay is the only way into this account, so it cannot be disconnected." + : "CoinPay is not connected."; + return NextResponse.json({ error }, { status: 409 }); + } + const res = NextResponse.json({ ok: true }); + // A session that was a CoinPay sign-in would otherwise resolve to a fresh + // CoinPay-only account on the next request: move it onto the account itself. + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); + if (s && !s.sub.startsWith("acct:")) { + const acct = await getAccountById(id); + res.cookies.set(SESSION_COOKIE, signSession({ sub: `acct:${id}`, email: acct?.email ?? null, name: s.name ?? null }), sessionCookieOptions(req)); + } + return res; +} diff --git a/app/auth/coinpay/callback/route.ts b/app/auth/coinpay/callback/route.ts index 82b71de..085da7e 100644 --- a/app/auth/coinpay/callback/route.ts +++ b/app/auth/coinpay/callback/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { coinpayConfigured, signSession, SESSION_COOKIE, sessionCookieOptions } from "@/lib/session"; -import { exchangeCode, fetchUserinfo } from "@/lib/oauth"; +import { coinpayConfigured, readSession, signSession, SESSION_COOKIE, sessionCookieOptions } from "@/lib/session"; +import { finishCoinpaySignIn, stateMatches, PENDING_COOKIES } from "@/lib/oauth"; import { requestHosts, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin"; -import { upsertUser, accountIdForCoinpay } from "@/lib/db"; +import { upsertUser, accountIdForCoinpay, linkCoinpay } from "@/lib/db"; import { acceptInviteAndNotify, INVITE_COOKIE } from "@/lib/credits"; export const runtime = "nodejs"; @@ -19,34 +19,54 @@ function requestIsHttps(req: NextRequest): boolean { return req.nextUrl.protocol === "https:" || (process.env.APP_BASE_URL || "").startsWith("https://"); } +function clearPending(res: NextResponse) { + for (const name of Object.values(PENDING_COOKIES)) res.cookies.delete(name); +} + export async function GET(req: NextRequest) { if (!coinpayConfigured()) return new NextResponse("CoinPay login is not configured yet.", { status: 503 }); const sp = req.nextUrl.searchParams; if (sp.get("error")) return fail(sp.get("error_description") || sp.get("error")!); const code = sp.get("code"); if (!code) return fail("no authorization code"); - const state = sp.get("state"); - const cookieState = req.cookies.get("cp_state")?.value; - if (!state || state !== cookieState) return fail("state mismatch"); - const verifier = req.cookies.get("cp_pkce")?.value; - if (!verifier) return fail("missing PKCE verifier (session expired)"); + if (!stateMatches(req.cookies.get(PENDING_COOKIES.state)?.value, sp.get("state"))) return fail("state mismatch"); + const verifier = req.cookies.get(PENDING_COOKIES.verifier)?.value; + const nonce = req.cookies.get(PENDING_COOKIES.nonce)?.value; + if (!verifier || !nonce) return fail("missing PKCE verifier (session expired)"); + const mode = req.cookies.get(PENDING_COOKIES.mode)?.value === "connect" ? "connect" : "login"; // Same derivation as the authorize step, so the redirect_uri matches byte for // byte -- the callback arrives on whatever host that step named. const origin = resolveOrigin(requestHosts(req.headers), requestIsHttps(req)); + let info: { sub: string; email: string | null; name: string | null }; try { - const tokens = await exchangeCode(code, verifier, redirectUriFor(origin)); - const info = await fetchUserinfo(tokens.access_token); - if (!info?.sub) return fail("no subject in userinfo"); + info = await finishCoinpaySignIn({ code, verifier, nonce, redirectUri: redirectUriFor(origin) }); + } catch (err: any) { + console.error("[auth] callback error:", err?.message); + return fail("CoinPay sign-in could not be verified"); + } + + try { + if (mode === "connect") { + // Linking needs the password account to be signed in right now. + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); + if (!s?.sub?.startsWith("acct:")) return fail("sign in to your moshcoding account first"); + const r = await linkCoinpay(s.sub.slice("acct:".length), info.sub); + const res = NextResponse.redirect(`${origin}/dashboard?coinpay=${r.ok ? "linked" : r.reason}`); + clearPending(res); + return res; + } + await upsertUser({ sub: info.sub, email: info.email, name: info.name }); + // Bound by sub; a first sign-in gets its own account (never one found by email). + const accountId = await accountIdForCoinpay(info.sub); // Arrived through an invite link (/i/ set the cookie): accept it on // the account this CoinPay identity is bound to. Never blocks the sign-in. const invite = req.cookies.get(INVITE_COOKIE)?.value; let landing = `${origin}/`; if (invite) { - const accountId = await accountIdForCoinpay(info.sub, info.email).catch(() => null); const r = accountId ? await acceptInviteAndNotify(invite, accountId, origin).catch(() => null) : null; landing = `${origin}/invites?invite=${encodeURIComponent(r ? (r.ok ? r.status : r.reason) : "no_account")}`; } @@ -54,8 +74,7 @@ export async function GET(req: NextRequest) { // Land back on the host the user started from, where the session cookie applies. const res = NextResponse.redirect(landing); res.cookies.set(SESSION_COOKIE, signSession({ sub: info.sub, email: info.email ?? null, name: info.name ?? null }), sessionCookieOptions(req)); - res.cookies.delete("cp_pkce"); - res.cookies.delete("cp_state"); + clearPending(res); if (invite) res.cookies.delete(INVITE_COOKIE); return res; } catch (err: any) { diff --git a/app/auth/login/route.ts b/app/auth/login/route.ts index a7360fc..60d0451 100644 --- a/app/auth/login/route.ts +++ b/app/auth/login/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { coinpayConfigured } from "@/lib/session"; -import { makePkce, authorizeUrl } from "@/lib/oauth"; +import { coinpayConfigured, readSession, SESSION_COOKIE } from "@/lib/session"; +import { makePkce, authorizeUrl, PENDING_COOKIES } from "@/lib/oauth"; import { requestHosts, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin"; export const runtime = "nodejs"; @@ -13,18 +13,32 @@ function requestIsHttps(req: NextRequest): boolean { return req.nextUrl.protocol === "https:" || (process.env.APP_BASE_URL || "").startsWith("https://"); } +/** + * Starts "Sign in with CoinPay". `?mode=connect` links CoinPay to the password + * account that is signed in right now, so it needs that session; it is the + * only way a CoinPay identity joins an existing account (never by email). + */ export async function GET(req: NextRequest) { if (!coinpayConfigured()) { return new NextResponse("CoinPay login is not configured yet.", { status: 503 }); } + const mode = req.nextUrl.searchParams.get("mode") === "connect" ? "connect" : "login"; + if (mode === "connect") { + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); + if (!s?.sub?.startsWith("acct:")) { + return NextResponse.redirect(new URL("/login", req.nextUrl.origin)); + } + } const secure = requestIsHttps(req); // Send the IdP back to the host the user is actually on, so the host-only - // cp_pkce / cp_state cookies set below are still readable at the callback. + // pending cookies set below are still readable at the callback. const origin = resolveOrigin(requestHosts(req.headers), secure); - const { verifier, challenge, state } = makePkce(); - const res = NextResponse.redirect(authorizeUrl(challenge, state, redirectUriFor(origin))); + const { verifier, challenge, state, nonce } = makePkce(); + const res = NextResponse.redirect(authorizeUrl(challenge, state, redirectUriFor(origin), nonce)); const opts = { httpOnly: true, sameSite: "lax" as const, secure, path: "/", maxAge: 600 }; - res.cookies.set("cp_pkce", verifier, opts); - res.cookies.set("cp_state", state, opts); + res.cookies.set(PENDING_COOKIES.verifier, verifier, opts); + res.cookies.set(PENDING_COOKIES.state, state, opts); + res.cookies.set(PENDING_COOKIES.nonce, nonce, opts); + res.cookies.set(PENDING_COOKIES.mode, mode, opts); return res; } diff --git a/app/dashboard/[[...tab]]/page.tsx b/app/dashboard/[[...tab]]/page.tsx index 174c148..001e1d0 100644 --- a/app/dashboard/[[...tab]]/page.tsx +++ b/app/dashboard/[[...tab]]/page.tsx @@ -575,10 +575,60 @@ function AccountPanel({ onError, onOk }: { onError: (m: string) => void; onOk: ( setWallet(e.target.value)} /> + + ); } +const COINPAY_RESULT: Record = { + linked: { t: "CoinPay connected. You can sign in with it now.", ok: true }, + taken: { t: "That CoinPay account is already linked to another moshcoding account.", ok: false }, + already_linked: { t: "This account already has a different CoinPay account connected.", ok: false }, + no_account: { t: "Sign in to your moshcoding account first.", ok: false }, +}; + +/** Connect / Disconnect CoinPay. Connecting needs a password sign-in (never by email match). */ +function CoinpayCard({ onError, onOk }: { onError: (m: string) => void; onOk: (m: string) => void }) { + const [st, setSt] = useState<{ enabled: boolean; linked: boolean; canDisconnect: boolean } | null>(null); + const load = () => fetch("/api/account/coinpay").then((r) => r.ok ? r.json() : null).then(setSt).catch(() => setSt(null)); + useEffect(() => { + load(); + const res = new URLSearchParams(window.location.search).get("coinpay"); + const m = res ? COINPAY_RESULT[res] : null; + if (m) (m.ok ? onOk : onError)(m.t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (!st?.enabled) return null; + const disconnect = async () => { + try { + const r = await fetch("/api/account/coinpay", { method: "DELETE" }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error); + onOk("CoinPay disconnected."); + load(); + } catch (e: any) { onError(e.message || "Couldn't disconnect."); } + }; + return ( + <> +

Sign in with CoinPay

+ {st.linked ? ( +
+ Connected + {st.canDisconnect + ? + : CoinPay is this account's only sign-in, so it stays connected.} +
+ ) : ( +
+ Connect CoinPay + Then sign in with CoinPay as well as your password. +
+ )} + + ); +} + function VideosPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (m: string) => void }) { const [domains, setDomains] = useState(undefined); const [active, setActive] = useState(null); diff --git a/app/login/page.tsx b/app/login/page.tsx index 86524b6..537540b 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -42,6 +42,13 @@ export default function LoginPage() { {msg &&

{msg.t}

} + {coinpay && ( +
+ Sign in with CoinPay +

— or with your email and password —

+
+ )} +
setEmail(e.target.value)} />
setPassword(e.target.value)} />
@@ -50,12 +57,6 @@ export default function LoginPage() { Forgot password?

No account? Claim your page — free

- {coinpay && ( - <> -

— or —

- Log in with CoinPay - - )}
); diff --git a/app/signup/page.tsx b/app/signup/page.tsx index 046dbf6..8970270 100644 --- a/app/signup/page.tsx +++ b/app/signup/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ProfileFields } from "@profullstack/profile/react"; // The shared Profullstack profile, minus email (this form has its own box). @@ -25,6 +25,11 @@ export default function SignupPage() { const [profileErrors, setProfileErrors] = useState>({}); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null); + const [coinpay, setCoinpay] = useState(false); + + useEffect(() => { + fetch("/api/me").then((r) => r.json()).then((m) => setCoinpay(Boolean(m?.coinpayEnabled))).catch(() => {}); + }, []); const setHandle = (k: string, v: string) => setHandles((h) => ({ ...h, [k]: v })); @@ -57,6 +62,13 @@ export default function SignupPage() { ← home + {coinpay && ( +
+ Sign up with CoinPay +

— or create a password account below —

+
+ )} +

🤘 Earn up to 80% commission on all fees generated with your custom affiliate code.

{msg &&

{msg.t}

} diff --git a/bun.lock b/bun.lock index 7000e48..3903c78 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "moshcoding", "dependencies": { "@profullstack/affiliates": "0.3.0", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/profile": "0.1.6", @@ -31,6 +32,8 @@ "packages": { "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@hono/node-server": ["@hono/node-server@2.1.5", "", { "peerDependencies": { "hono": "^4 || ^5.0.0-0" } }, "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], @@ -113,6 +116,8 @@ "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.2", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw=="], + "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], "@next/env": ["@next/env@16.4.3", "", {}, "sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ=="], @@ -135,6 +140,8 @@ "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], + "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.3.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ=="], + "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], "@profullstack/footer": ["@profullstack/footer@0.4.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-EJZnMUQmWDi9Gzx6BAjogloeaPFFSSCHtHJuC9dsXiOz8M87hfn6Z+Zx9AMYWk8s6xemPrPlkXKGB3PZugkcEA=="], @@ -205,36 +212,162 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w=="], + "body-parser": ["body-parser@2.4.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001819", "", {}, "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "content-disposition": ["content-disposition@2.0.1", "", {}, "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.1", "", {}, "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "express": ["express@5.3.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.3.0", "content-disposition": "^2.0.1", "content-type": "^2.0.0", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.8", "qs": "^6.16.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.1.0", "vary": "^1.1.2" } }, "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g=="], + + "express-rate-limit": ["express-rate-limit@8.7.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.9", "", {}, "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.7.5", "", {}, "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.13", "", {}, "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ=="], + "js-base64": ["js-base64@3.9.4", "", {}, "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], "mailchecker": ["mailchecker@6.0.21", "", {}, "sha512-Zd2Lgj4RMnC0IBbbSjnF76Wbd+kkgKMRONgV9yOMKJhj6hcKIAQtj9MrmiGnT2Db+Dy6MsMQoG2HEhPk2YPuGA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + "next": ["next@16.4.3", "", { "dependencies": { "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.3", "@next/swc-darwin-x64": "16.4.3", "@next/swc-linux-arm64-gnu": "16.4.3", "@next/swc-linux-arm64-musl": "16.4.3", "@next/swc-linux-x64-gnu": "16.4.3", "@next/swc-linux-x64-musl": "16.4.3", "@next/swc-win32-arm64-msvc": "16.4.3", "@next/swc-win32-x64-msvc": "16.4.3", "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.2", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], @@ -253,6 +386,8 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -265,38 +400,96 @@ "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], + + "qs": ["qs@6.17.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@profullstack/libsql-pg/@libsql/client": ["@libsql/client@0.15.15", "", { "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.5.22", "promise-limit": "^2.7.0" } }, "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "express/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "@profullstack/libsql-pg/@libsql/client/@libsql/core": ["@libsql/core@0.15.15", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA=="], "@profullstack/libsql-pg/@libsql/client/@libsql/hrana-client": ["@libsql/hrana-client@0.7.0", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.3.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw=="], diff --git a/lib/db.ts b/lib/db.ts index cb97c50..8fbf1cf 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -765,6 +765,7 @@ export async function createAccount(opts: { }): Promise { await ensureSchema(); const email = opts.email.trim().toLowerCase(); + if (isCoinpayPlaceholderEmail(email)) throw new Error("reserved email address"); // A domain someone else holds is never handed over by a sign-up. if (opts.domain && (await domainClaimedByOther(null, opts.domain))) throw new DomainTakenError(opts.domain); const seedConfig = Object.keys(opts.handles || {}).length ? { socials: opts.handles } : {}; @@ -807,6 +808,7 @@ export async function getAccountByEmail( export async function findOrCreateAccountByEmail(email: string): Promise { await ensureSchema(); const e = email.trim().toLowerCase(); + if (isCoinpayPlaceholderEmail(e)) throw new Error("reserved email address"); const existing = await getAccountByEmail(e); if (existing) return existing; const res = await db().execute({ @@ -820,33 +822,107 @@ export async function findOrCreateAccountByEmail(email: string): Promise { +export async function accountIdForCoinpay(sub: string, _email?: string | null): Promise { await ensureSchema(); if (!sub || sub.startsWith("acct:")) return null; const bound = await db().execute({ sql: `SELECT id FROM accounts WHERE coinpay_sub = ?`, args: [sub] }); if (bound.rows[0]) return String(bound.rows[0].id); - if (!email) return null; - const acct = await findOrCreateAccountByEmail(email); - const claim = await db().execute({ - sql: `UPDATE accounts SET coinpay_sub = ? - WHERE id = ? AND coinpay_sub IS NULL AND password_hash = 'oauth' - RETURNING id`, - args: [sub, acct.id], + // "oauth" sentinel hash never matches scrypt-hex, so password login stays off. + // ON CONFLICT DO NOTHING: two first requests racing both land on one row. + await db().execute({ + sql: `INSERT INTO accounts (email, password_hash, password_salt, config, is_admin, status, plan, coinpay_sub) + VALUES (?, 'oauth', ?, '{}', 0, 'pending', 'free', ?) + ON CONFLICT DO NOTHING`, + args: [coinpayPlaceholderEmail(sub), randomBytes(16).toString("hex"), sub], }); - return claim.rows[0] ? String(claim.rows[0].id) : null; + const made = await db().execute({ sql: `SELECT id FROM accounts WHERE coinpay_sub = ?`, args: [sub] }); + return made.rows[0] ? String(made.rows[0].id) : null; +} + +export type LinkResult = { ok: boolean; reason?: "taken" | "already_linked" | "no_account" }; + +/** + * Links a CoinPay identity to an account the caller is signed in to. Refused + * when the account already has a CoinPay identity or the identity is bound to + * another account (including one made by an earlier CoinPay-only sign-in). + */ +export async function linkCoinpay(accountId: string, sub: string): Promise { + await ensureSchema(); + if (!sub || sub.startsWith("acct:")) return { ok: false, reason: "no_account" }; + const acct = await db().execute({ sql: `SELECT coinpay_sub FROM accounts WHERE id = ?`, args: [accountId] }); + if (!acct.rows[0]) return { ok: false, reason: "no_account" }; + const current = acct.rows[0].coinpay_sub ? String(acct.rows[0].coinpay_sub) : null; + if (current === sub) return { ok: true }; + if (current) return { ok: false, reason: "already_linked" }; + const other = await db().execute({ sql: `SELECT id FROM accounts WHERE coinpay_sub = ?`, args: [sub] }); + if (other.rows[0]) return { ok: false, reason: "taken" }; + try { + const res = await db().execute({ + sql: `UPDATE accounts SET coinpay_sub = ? WHERE id = ? AND coinpay_sub IS NULL RETURNING id`, + args: [sub, accountId], + }); + return res.rows[0] ? { ok: true } : { ok: false, reason: "already_linked" }; + } catch (err: any) { + if (/unique|duplicate/i.test(String(err?.message))) return { ok: false, reason: "taken" }; + throw err; + } +} + +/** + * Removes the CoinPay link, but only from an account that has a password to + * sign in with: for a CoinPay-only account it is the only way in. + */ +export async function unlinkCoinpay(accountId: string): Promise<{ ok: boolean; reason?: "only_login" | "not_linked" }> { + await ensureSchema(); + const acct = await db().execute({ sql: `SELECT coinpay_sub, password_hash FROM accounts WHERE id = ?`, args: [accountId] }); + const r = acct.rows[0]; + if (!r || !r.coinpay_sub) return { ok: false, reason: "not_linked" }; + if (!hasPassword(r.password_hash)) return { ok: false, reason: "only_login" }; + await db().execute({ sql: `UPDATE accounts SET coinpay_sub = NULL WHERE id = ?`, args: [accountId] }); + return { ok: true }; +} + +/** A real password hash, not the "oauth" sentinel of a passwordless account. */ +export function hasPassword(hash: unknown): boolean { + return typeof hash === "string" && hash !== "" && hash !== "oauth"; +} + +/** Whether this account has CoinPay linked and could drop it. */ +export async function coinpayLinkStatus(accountId: string): Promise<{ linked: boolean; canDisconnect: boolean }> { + await ensureSchema(); + const acct = await db().execute({ sql: `SELECT coinpay_sub, password_hash FROM accounts WHERE id = ?`, args: [accountId] }); + const r = acct.rows[0]; + const linked = Boolean(r?.coinpay_sub); + return { linked, canDisconnect: linked && hasPassword(r?.password_hash) }; } /** Sets the tenant domain (used when a CoinPay user claims a page from the dashboard). Throws DomainTakenError for a domain another account holds. */ diff --git a/lib/oauth.ts b/lib/oauth.ts index a39d85b..6b57e6c 100644 --- a/lib/oauth.ts +++ b/lib/oauth.ts @@ -1,4 +1,19 @@ -import crypto from "node:crypto"; +// Sign in with CoinPay: a confidential OIDC client. Authorization code + +// PKCE S256 + state + nonce, the client secret on the code exchange, and the +// id_token checked (issuer, audience, expiry, nonce) with a userinfo `sub` +// that must match it. The protocol half lives in the house package +// @profullstack/coinpay-onboard/oauth; this file only adds our config. +// +// Bind the result to an account by `sub` only (lib/db accountIdForCoinpay). +// CoinPay never verifies addresses: the email is contact information. +import { + authorizeUrl as cpAuthorizeUrl, + finishSignIn, + pkce, + randomToken, + sameState, + SIGN_IN_SCOPES, +} from "@profullstack/coinpay-onboard/oauth"; export const COINPAY_ISSUER = (process.env.COINPAY_ISSUER || "https://coinpayportal.com").replace(/\/$/, ""); export const COINPAY_CLIENT_ID = process.env.COINPAY_CLIENT_ID || ""; @@ -9,52 +24,52 @@ export const APP_BASE_URL = (process.env.APP_BASE_URL || "http://localhost:8080" * callers pass an explicit one derived from the host -- see lib/oauth-origin. */ export const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI || `${APP_BASE_URL}/auth/coinpay/callback`; -const b64url = (buf: crypto.BinaryLike) => Buffer.from(buf as any).toString("base64url"); +/** "login" signs in (or creates a CoinPay-bound account); "connect" links + * CoinPay to the password account that is already signed in. */ +export type OAuthMode = "login" | "connect"; + +/** The short-lived cookies the start route sets and the callback reads. */ +export const PENDING_COOKIES = { verifier: "cp_pkce", state: "cp_state", nonce: "cp_nonce", mode: "cp_mode" } as const; export function makePkce() { - const verifier = b64url(crypto.randomBytes(32)); - const challenge = b64url(crypto.createHash("sha256").update(verifier).digest()); - const state = b64url(crypto.randomBytes(16)); - return { verifier, challenge, state }; + const { verifier, challenge } = pkce(); + return { verifier, challenge, state: randomToken(24), nonce: randomToken(24) }; } -export function authorizeUrl(challenge: string, state: string, redirectUri: string = REDIRECT_URI): string { - const u = new URL(`${COINPAY_ISSUER}/api/oauth/authorize`); - u.search = new URLSearchParams({ - response_type: "code", - client_id: COINPAY_CLIENT_ID, - redirect_uri: redirectUri, - scope: "openid email profile", +export function authorizeUrl(challenge: string, state: string, redirectUri: string = REDIRECT_URI, nonce?: string): string { + return cpAuthorizeUrl({ + base: COINPAY_ISSUER, + clientId: COINPAY_CLIENT_ID, + redirectUri, state, - code_challenge: challenge, - code_challenge_method: "S256", - }).toString(); - return u.toString(); + challenge, + nonce, + scopes: SIGN_IN_SCOPES, + }); } -// `redirectUri` must byte-match the one sent to authorize, or the IdP rejects -// the exchange. Callers derive both from lib/oauth-origin so they agree. -export async function exchangeCode(code: string, verifier: string, redirectUri: string = REDIRECT_URI) { - const res = await fetch(`${COINPAY_ISSUER}/api/oauth/token`, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: COINPAY_CLIENT_ID, - client_secret: COINPAY_CLIENT_SECRET, - code_verifier: verifier, - }), - }); - if (!res.ok) throw new Error(`token exchange failed (${res.status})`); - return res.json(); +/** Constant-time state compare; false when either side is missing. */ +export function stateMatches(expected: string | null | undefined, received: string | null | undefined): boolean { + return sameState(expected, received); } -export async function fetchUserinfo(accessToken: string) { - const res = await fetch(`${COINPAY_ISSUER}/api/oauth/userinfo`, { - headers: { authorization: `Bearer ${accessToken}` }, +/** + * Code -> tokens -> checked id_token -> userinfo. `redirectUri` must byte-match + * the one sent to authorize (both come from lib/oauth-origin). Throws on any + * failure, including a nonce or audience mismatch. + */ +export async function finishCoinpaySignIn( + input: { code: string; verifier: string; nonce: string; redirectUri: string }, + doFetch?: typeof fetch, +) { + return finishSignIn({ + code: input.code, + verifier: input.verifier, + nonce: input.nonce, + redirectUri: input.redirectUri, + clientId: COINPAY_CLIENT_ID, + clientSecret: COINPAY_CLIENT_SECRET, + base: COINPAY_ISSUER, + fetch: doFetch, }); - if (!res.ok) throw new Error(`userinfo failed (${res.status})`); - return res.json(); } diff --git a/package.json b/package.json index 5658a5d..62263e1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@profullstack/affiliates": "0.3.0", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/profile": "0.1.6", diff --git a/tests/coinpay-signin.test.mjs b/tests/coinpay-signin.test.mjs new file mode 100644 index 0000000..c64a3e3 --- /dev/null +++ b/tests/coinpay-signin.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +process.env.TURSO_DATABASE_URL = "file::memory:"; +delete process.env.DATABASE_URL; +process.env.SESSION_SECRET = "test-secret"; +process.env.COINPAY_CLIENT_ID = "cp_test_client"; +process.env.COINPAY_CLIENT_SECRET = "test-client-secret"; +process.env.APP_BASE_URL = "https://moshcoding.com"; + +const dbm = await import("../lib/db.ts"); +const oauth = await import("../lib/oauth.ts"); +const { signSession, SESSION_COOKIE } = await import("../lib/session.ts"); +const { NextRequest } = await import("next/server"); +const loginRoute = await import("../app/auth/login/route.ts"); +const callbackRoute = await import("../app/auth/coinpay/callback/route.ts"); +await dbm.ensureSchema(); + +let n = 0; +const uniq = (p) => `${p}${++n}@example.com`; +const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url"); +const idToken = (claims) => `${b64({ alg: "HS256", typ: "JWT" })}.${b64(claims)}.sig`; + +function fakeCoinpay({ sub, email, nonce }) { + return async (url) => { + const u = String(url); + if (u.endsWith("/api/oauth/token")) { + return new Response(JSON.stringify({ + access_token: "at", token_type: "Bearer", expires_in: 3600, + id_token: idToken({ iss: "https://coinpayportal.com", aud: "cp_test_client", sub, nonce, exp: Math.floor(Date.now() / 1000) + 600 }), + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (u.endsWith("/api/oauth/userinfo")) { + return new Response(JSON.stringify({ sub, email, email_verified: false }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("{}", { status: 404 }); + }; +} + +test("a CoinPay login carrying a victim's email does not get the victim's account", async () => { + const victim = await dbm.createAccount({ email: uniq("victim"), passwordHash: "h", passwordSalt: "s" }); + const id = await dbm.accountIdForCoinpay("attacker-sub", victim.email); + assert.ok(id); + assert.notEqual(id, victim.id); + const made = await dbm.getAccountById(id); + assert.equal(made.email, dbm.coinpayPlaceholderEmail("attacker-sub"), "the CoinPay email is never the account email"); + assert.equal(made.is_admin, false); + assert.equal((await dbm.getAccountById(victim.id)).coinpay_sub, null); +}); + +test("the placeholder domain cannot be used to sign up", async () => { + await assert.rejects(dbm.createAccount({ email: "x@coinpay.moshcoding.invalid", passwordHash: "h", passwordSalt: "s" })); + await assert.rejects(dbm.findOrCreateAccountByEmail("x@coinpay.moshcoding.invalid")); +}); + +test("link / unlink rules", async () => { + const a = await dbm.createAccount({ email: uniq("link"), passwordHash: "h", passwordSalt: "s" }); + assert.deepEqual(await dbm.linkCoinpay(a.id, "link-sub"), { ok: true }); + assert.equal(await dbm.accountIdForCoinpay("link-sub"), a.id); + const b = await dbm.createAccount({ email: uniq("link"), passwordHash: "h", passwordSalt: "s" }); + assert.deepEqual(await dbm.linkCoinpay(b.id, "link-sub"), { ok: false, reason: "taken" }); + assert.deepEqual(await dbm.linkCoinpay(a.id, "other-sub"), { ok: false, reason: "already_linked" }); + assert.deepEqual(await dbm.coinpayLinkStatus(a.id), { linked: true, canDisconnect: true }); + assert.deepEqual(await dbm.unlinkCoinpay(a.id), { ok: true }); + // a CoinPay-only account cannot drop its only sign-in + const only = await dbm.accountIdForCoinpay("only-sub"); + assert.deepEqual(await dbm.coinpayLinkStatus(only), { linked: true, canDisconnect: false }); + assert.deepEqual(await dbm.unlinkCoinpay(only), { ok: false, reason: "only_login" }); +}); + +test("start sends PKCE S256, state and nonce; connect needs a password session", async () => { + const res = await loginRoute.GET(new NextRequest("https://moshcoding.com/auth/login", { headers: { host: "moshcoding.com", "x-forwarded-proto": "https" } })); + assert.equal(res.status, 307); + const loc = new URL(res.headers.get("location")); + assert.equal(loc.origin + loc.pathname, "https://coinpayportal.com/api/oauth/authorize"); + assert.equal(loc.searchParams.get("code_challenge_method"), "S256"); + assert.ok(loc.searchParams.get("state") && loc.searchParams.get("nonce") && loc.searchParams.get("code_challenge")); + assert.equal(loc.searchParams.get("redirect_uri"), "https://moshcoding.com/auth/coinpay/callback"); + + const noSession = await loginRoute.GET(new NextRequest("https://moshcoding.com/auth/login?mode=connect", { headers: { host: "moshcoding.com" } })); + assert.equal(new URL(noSession.headers.get("location")).pathname, "/login"); +}); + +function callbackReq({ state = "s1", cookieState = "s1", mode, session } = {}) { + const cookies = [`cp_state=${cookieState}`, "cp_pkce=verifier", "cp_nonce=n1"]; + if (mode) cookies.push(`cp_mode=${mode}`); + if (session) cookies.push(`${SESSION_COOKIE}=${session}`); + return new NextRequest(`https://moshcoding.com/auth/coinpay/callback?code=c1&state=${state}`, { + headers: { host: "moshcoding.com", "x-forwarded-proto": "https", cookie: cookies.join("; ") }, + }); +} + +test("callback rejects a forged state", async () => { + const res = await callbackRoute.GET(callbackReq({ state: "forged" })); + assert.equal(res.status, 400); + assert.match(await res.text(), /state mismatch/); +}); + +test("callback rejects an id_token whose nonce does not match", async () => { + const real = globalThis.fetch; + globalThis.fetch = fakeCoinpay({ sub: "nonce-sub", email: uniq("n"), nonce: "WRONG" }); + try { + const res = await callbackRoute.GET(callbackReq()); + assert.equal(res.status, 400); + } finally { globalThis.fetch = real; } + await assert.rejects(oauth.finishCoinpaySignIn({ code: "c", verifier: "v", nonce: "n1", redirectUri: "https://moshcoding.com/auth/coinpay/callback" }, fakeCoinpay({ sub: "x", nonce: "other" })), /nonce/); +}); + +test("connect links CoinPay to the signed-in account; login by email collision does not", async () => { + const owner = await dbm.createAccount({ email: uniq("owner"), passwordHash: "h", passwordSalt: "s" }); + const real = globalThis.fetch; + try { + // A CoinPay login with the owner's email: lands on a new account, owner untouched. + globalThis.fetch = fakeCoinpay({ sub: "collide-sub", email: owner.email, nonce: "n1" }); + const login = await callbackRoute.GET(callbackReq()); + assert.equal(login.status, 307); + assert.equal((await dbm.getAccountById(owner.id)).coinpay_sub, null); + assert.notEqual(await dbm.accountIdForCoinpay("collide-sub"), owner.id); + + // connect without a session is refused + globalThis.fetch = fakeCoinpay({ sub: "connect-sub", email: "x@example.com", nonce: "n1" }); + const anon = await callbackRoute.GET(callbackReq({ mode: "connect" })); + assert.equal(anon.status, 400); + assert.equal((await dbm.getAccountById(owner.id)).coinpay_sub, null); + + // connect with the owner's password session links it + const session = signSession({ sub: `acct:${owner.id}`, email: owner.email, name: null }); + const ok = await callbackRoute.GET(callbackReq({ mode: "connect", session })); + assert.equal(ok.status, 307); + assert.match(ok.headers.get("location"), /coinpay=linked/); + assert.equal((await dbm.getAccountById(owner.id)).coinpay_sub, "connect-sub"); + } finally { globalThis.fetch = real; } +}); diff --git a/tests/credits-invites.test.mjs b/tests/credits-invites.test.mjs index cd45a78..fe97bda 100644 --- a/tests/credits-invites.test.mjs +++ b/tests/credits-invites.test.mjs @@ -74,7 +74,8 @@ test("ADMIN_EMAILS never grants admin from an unverified email", async () => { const again = await dbm.findOrCreateAccountByEmail("boss@example.com"); assert.equal(again.id, native.id); assert.equal(again.is_admin, false); - assert.equal(await dbm.accountIdForCoinpay("sub-attacker", "boss@example.com"), null, "a CoinPay sign-in cannot take a password account"); + const other = await dbm.accountIdForCoinpay("sub-attacker", "boss@example.com"); + assert.notEqual(other, native.id, "a CoinPay sign-in cannot take a password account"); }); test("a CoinPay sign-in is bound by sub, not matched by email", async () => { @@ -83,10 +84,13 @@ test("a CoinPay sign-in is bound by sub, not matched by email", async () => { assert.ok(id); assert.equal(await dbm.accountIdForCoinpay("sub-1", email), id); assert.equal(await dbm.accountIdForCoinpay("sub-1", "changed@example.com"), id, "bound sub wins over email"); - assert.equal(await dbm.accountIdForCoinpay("sub-2", email), null, "another CoinPay identity with the same email is refused"); - // an email-only affiliate account (no password, unbound) is claimed by the first CoinPay sign-in + const id2 = await dbm.accountIdForCoinpay("sub-2", email); + assert.ok(id2 && id2 !== id, "another CoinPay identity with the same email gets its own account"); + // an email-only affiliate account (no password, unbound) is NOT claimed by email const joined = await dbm.findOrCreateAccountByEmail(uniq("join")); - assert.equal(await dbm.accountIdForCoinpay("sub-3", joined.email), joined.id); + const id3 = await dbm.accountIdForCoinpay("sub-3", joined.email); + assert.notEqual(id3, joined.id); + assert.equal((await dbm.getAccountById(joined.id)).coinpay_sub, null); assert.equal(await dbm.accountIdForCoinpay("acct:whatever", joined.email), null); });