diff --git a/apps/site/app/_components/account-view.tsx b/apps/site/app/_components/account-view.tsx index 407e6d2..747aa27 100644 --- a/apps/site/app/_components/account-view.tsx +++ b/apps/site/app/_components/account-view.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { ResendVerification } from "./resend-verification"; +import { AddPasskey } from "./passkey-button"; /** * Client-side account and library views. @@ -148,6 +149,10 @@ export function AccountView() { )} +

Passkeys

+

Sign in with your fingerprint, face or device PIN instead of an email link.

+ +

Devices

Devices are remembered so you can play offline. There is no hardware fingerprinting; revoke @@ -268,7 +273,15 @@ export function LibraryView() { {item.status} {item.grantedAt?.slice(0, 10)} - {item.active ? Play : Support} + + {item.active ? ( + + Play + + ) : ( + Support + )} + ))} diff --git a/apps/site/app/_components/auth-client.ts b/apps/site/app/_components/auth-client.ts new file mode 100644 index 0000000..f762215 --- /dev/null +++ b/apps/site/app/_components/auth-client.ts @@ -0,0 +1,34 @@ +"use client"; + +import { createAuthClient } from "better-auth/client"; +import { passkeyClient } from "@better-auth/passkey/client"; + +/** + * Browser client for Better Auth, used only for the passkey ceremonies (the + * WebAuthn calls need the browser API and the plugin's challenge handling). + * Everything else on the site talks to /api/v1/auth with plain fetch. + */ +export const authClient = createAuthClient({ + basePath: "/api/v1/auth", + plugins: [passkeyClient()], +}); + +/** True when this browser can do WebAuthn at all. */ +export function passkeysSupported(): boolean { + return typeof window !== "undefined" && typeof window.PublicKeyCredential === "function"; +} + +/** Who is signed in, from Better Auth's own session endpoint. */ +export async function currentSession(): Promise<{ userId: string; email: string } | null> { + try { + const response = await fetch("/api/v1/auth/get-session", { + credentials: "include", + cache: "no-store", + }); + if (!response.ok) return null; + const body = (await response.json()) as { user?: { id: string; email: string } } | null; + return body?.user ? { userId: body.user.id, email: body.user.email } : null; + } catch { + return null; + } +} diff --git a/apps/site/app/_components/claim-state.test.ts b/apps/site/app/_components/claim-state.test.ts new file mode 100644 index 0000000..05ea2f6 --- /dev/null +++ b/apps/site/app/_components/claim-state.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { claimMessage, type ClaimState } from "./claim-state"; + +const STATES: ClaimState[] = [ + "claimable", + "claimed_by_you", + "claimed", + "expired", + "pending", + "unavailable", + "invalid", +]; + +describe("claim page copy", () => { + it("has a heading and detail for every state, and no em dashes", () => { + for (const state of STATES) { + const message = claimMessage(state, "False Dawn"); + expect(message.heading.length, state).toBeGreaterThan(0); + expect(message.detail.length, state).toBeGreaterThan(0); + expect(`${message.heading} ${message.detail}`).not.toMatch(/—/); + } + }); + + it("only calls the good states good", () => { + const ok = STATES.filter((state) => claimMessage(state, "X").ok); + expect(ok).toEqual(["claimable", "claimed_by_you"]); + }); + + it("names the episode where it matters", () => { + expect(claimMessage("claimable", "False Dawn").heading).toContain("False Dawn"); + expect(claimMessage("claimed", "False Dawn").detail).toContain("False Dawn"); + }); +}); diff --git a/apps/site/app/_components/claim-state.ts b/apps/site/app/_components/claim-state.ts new file mode 100644 index 0000000..53f70cb --- /dev/null +++ b/apps/site/app/_components/claim-state.ts @@ -0,0 +1,60 @@ +/** + * What the claim page says for each state the API reports. Pure, so the copy + * is tested and cannot drift from the states the API can return. + */ + +export type ClaimState = + "claimable" | "claimed_by_you" | "claimed" | "expired" | "pending" | "unavailable" | "invalid"; + +export interface ClaimMessage { + ok: boolean; + heading: string; + detail: string; +} + +export function claimMessage(state: ClaimState, title: string): ClaimMessage { + switch (state) { + case "claimable": + return { + ok: true, + heading: `${title} is paid for.`, + detail: "Attach it to an account and it is yours on every supported platform.", + }; + case "claimed_by_you": + return { ok: true, heading: "Already yours.", detail: `${title} is in your library.` }; + case "claimed": + return { + ok: false, + heading: "This link has already been used.", + detail: `${title} was claimed to an account. Each claim link works once.`, + }; + case "expired": + return { + ok: false, + heading: "This claim link has expired.", + detail: "Links last 7 days. Your purchase is safe: we can email you a fresh one.", + }; + case "pending": + return { + ok: false, + heading: "Your payment is still confirming.", + detail: + "Crypto confirms when the network does, usually within minutes. Check again shortly.", + }; + case "unavailable": + return { + ok: false, + heading: "This order can no longer be claimed.", + detail: + "It was refunded, reversed or flagged for review. Contact support if that is wrong.", + }; + case "invalid": + default: + return { + ok: false, + heading: "This claim link is not valid.", + detail: + "It may be incomplete. Copy the whole link from the email, or request a fresh one with the email you paid with.", + }; + } +} diff --git a/apps/site/app/_components/claim-view.tsx b/apps/site/app/_components/claim-view.tsx new file mode 100644 index 0000000..585f874 --- /dev/null +++ b/apps/site/app/_components/claim-view.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; +import { currentSession } from "./auth-client"; +import { MagicLinkForm } from "./magic-link-form"; +import { PasskeySignIn } from "./passkey-button"; +import { claimMessage, type ClaimState } from "./claim-state"; + +/** + * The page behind the emailed `/claim/` link (PRD §23.4). + * + * 1. Ask the API what the link is (claimable, used, expired...). + * 2. Signed in: attach the purchase on one click. Signed out: sign in or sign + * up by email link or passkey, and come back here to finish. + * 3. The worker attaches the order; poll until the episode is ours, then go + * to it. + * + * Nothing here unlocks content. The API and the worker decide; the page only + * reports what they said. + */ + +interface Status { + state: ClaimState; + email: string | null; + episodeTitle: string | null; + playUrl: string | null; +} + +type Phase = + | { kind: "loading" } + | { kind: "ready" } + | { kind: "claiming" } + | { kind: "opening"; playUrl: string } + | { kind: "error"; message: string }; + +const POLL_MS = 1500; +const POLL_LIMIT = 40; + +async function fetchStatus(token: string): Promise { + const response = await fetch("/api/v1/checkout/claim/status", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + cache: "no-store", + body: JSON.stringify({ token }), + }); + if (!response.ok) throw new Error(`status ${response.status}`); + return (await response.json()) as Status; +} + +export function ClaimView({ token }: { token: string }) { + const [status, setStatus] = useState(null); + const [session, setSession] = useState<{ email: string } | null>(null); + const [phase, setPhase] = useState({ kind: "loading" }); + const autoTried = useRef(false); + const claimPath = `/claim/${token}`; + + const load = useCallback(async () => { + try { + const [s, me] = await Promise.all([fetchStatus(token), currentSession()]); + setStatus(s); + setSession(me); + if (s.state === "claimed_by_you" && s.playUrl) { + setPhase({ kind: "opening", playUrl: s.playUrl }); + window.location.assign(s.playUrl); + return; + } + setPhase({ kind: "ready" }); + } catch { + setPhase({ + kind: "error", + message: "Could not reach the server. Check your connection and reload this page.", + }); + } + }, [token]); + + useEffect(() => { + void load(); + }, [load]); + + const claim = useCallback(async () => { + setPhase({ kind: "claiming" }); + try { + const response = await fetch("/api/v1/checkout/claim", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ token }), + }); + const body = (await response.json().catch(() => ({}))) as { + status?: string; + playUrl?: string; + error?: { code?: string; message?: string }; + }; + if (response.status === 200 && body.playUrl) { + setPhase({ kind: "opening", playUrl: body.playUrl }); + window.location.assign(body.playUrl); + return; + } + if (response.status !== 202) { + // The API refused with a reason; re-read the link so the page shows + // the matching explanation and the right next step. + await load(); + if (response.status === 429) { + setPhase({ kind: "error", message: "Too many attempts. Wait a few minutes and retry." }); + } else if (response.status === 401) { + setPhase({ kind: "error", message: "Your session ended. Sign in again to claim." }); + } + return; + } + + // Queued. The worker attaches the order in a second or two. + for (let attempt = 0; attempt < POLL_LIMIT; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + const next = await fetchStatus(token).catch(() => null); + if (next?.state === "claimed_by_you" && next.playUrl) { + setPhase({ kind: "opening", playUrl: next.playUrl }); + window.location.assign(next.playUrl); + return; + } + if (next && next.state !== "claimable") { + setStatus(next); + setPhase({ kind: "ready" }); + return; + } + } + setPhase({ + kind: "error", + message: + "Claiming is taking longer than it should. Your purchase is safe; reload this page in a minute.", + }); + } catch { + setPhase({ kind: "error", message: "Could not reach the server. Try again." }); + } + }, [token, load]); + + // Coming back from the emailed sign-in link (or a passkey), finish the + // claim without another click: that is what the person set out to do. + useEffect(() => { + if (autoTried.current || phase.kind !== "ready") return; + if (status?.state !== "claimable" || !session) return; + const params = new URLSearchParams(window.location.search); + if (params.get("auto") !== "1") return; + autoTried.current = true; + void claim(); + }, [phase, status, session, claim]); + + if (phase.kind === "loading") return

Checking your claim link…

; + if (phase.kind === "opening") { + return ( +
+ It is yours. Opening {status?.episodeTitle ?? "the episode"}…{" "} + Go now. +
+ ); + } + if (!status) { + return ( +

+ {phase.kind === "error" ? phase.message : "Something went wrong."} +

+ ); + } + + const title = status.episodeTitle ?? "your episode"; + const signInError = signInErrorFromUrl(); + const message = claimMessage(status.state, title); + + return ( + <> + {phase.kind === "error" ? ( +

+ {phase.message} +

+ ) : null} + {signInError ? ( +

+ {signInError} +

+ ) : null} + +
+ {message.heading} {message.detail} +
+ + {status.state === "claimable" ? ( + session ? ( + <> +

+ Signed in as {session.email}. Add {title} to this account? +

+

+ + +

+ + ) : ( + <> +

Sign in or create an account

+

+ We email a one-click sign-in link. No password needed, and the same link creates your + account if you do not have one. The claim finishes as soon as you open it. +

+ +

+ window.location.assign(`${claimPath}?auto=1`)} + /> +

+

+ Prefer a password?{" "} + Sign in. +

+ + ) + ) : null} + + {status.state === "pending" ? ( +

+ +

+ ) : null} + + {status.state === "claimed" && !session ? ( + <> +

If it was you, sign in to that account and the episode is in your library.

+ + + ) : null} + + {status.state === "expired" ? : null} + {status.state === "invalid" ? : null} + +

+ Stuck? Contact support with the email you paid with. +

+ + ); +} + +function signInErrorFromUrl(): string | null { + if (typeof window === "undefined") return null; + const error = new URLSearchParams(window.location.search).get("error"); + if (!error) return null; + if (/expired/i.test(error)) { + return "That sign-in link expired. Send yourself a new one below."; + } + return "That sign-in link did not work. It may have been used already. Send yourself a new one below."; +} + +function SignOutLink() { + async function signOut() { + await fetch("/api/v1/auth/sign-out", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: "{}", + }); + window.location.reload(); + } + return ( + + ); +} + +/** + * Email a fresh claim link. With the old link it goes to the address that + * paid; without one, to the address typed, but only if it paid for an + * unclaimed order. The answer is the same either way. + */ +function ResendForm({ token, email }: { token?: string; email: string | null }) { + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + const typed = String(new FormData(event.currentTarget).get("email") ?? "").trim(); + try { + const response = await fetch("/api/v1/checkout/claim/resend", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ ...(token ? { token } : {}), ...(typed ? { email: typed } : {}) }), + }); + if (response.status === 429) { + setError("Too many requests. Try again in an hour, or contact support."); + return; + } + if (!response.ok) { + setError(`That did not work (${response.status}). Try again.`); + return; + } + setDone(true); + } catch { + setError("Could not reach the server. Check your connection and try again."); + } finally { + setBusy(false); + } + } + + if (done) { + return ( +
+ Sent. If that address paid for an episode that has not been claimed, a + fresh claim link is on its way. It works for 7 days. Check spam if it is not there in a few + minutes. +
+ ); + } + + return ( + <> +

Get a fresh link

+
+
+ + +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ + ); +} diff --git a/apps/site/app/_components/magic-link-form.tsx b/apps/site/app/_components/magic-link-form.tsx new file mode 100644 index 0000000..fbe54c3 --- /dev/null +++ b/apps/site/app/_components/magic-link-form.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState, type FormEvent } from "react"; + +/** + * Email me a sign-in link. The house sign-in: the link proves the address and + * the address is the account, so the same link signs a new person up. + * + * `callbackURL` must be a path on this site; Better Auth resolves it against + * its own origin after the link is opened. + */ +export function MagicLinkForm({ + callbackURL, + defaultEmail = "", + submitLabel = "Email me a sign-in link", + idPrefix = "magic", +}: { + callbackURL: string; + defaultEmail?: string; + submitLabel?: string; + idPrefix?: string; +}) { + const [busy, setBusy] = useState(false); + const [sentTo, setSentTo] = useState(null); + const [error, setError] = useState(null); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + const email = String(new FormData(event.currentTarget).get("email") ?? "").trim(); + + try { + const response = await fetch("/api/v1/auth/sign-in/magic-link", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + email, + callbackURL, + newUserCallbackURL: callbackURL, + errorCallbackURL: callbackURL, + }), + }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { message?: string }; + setError( + response.status === 429 + ? "Too many requests. Wait a minute and try again." + : (body.message ?? `That did not work (${response.status}).`), + ); + return; + } + setSentTo(email); + } catch { + setError("Could not reach the server. Check your connection and try again."); + } finally { + setBusy(false); + } + } + + if (sentTo) { + return ( +
+ Check your email. A sign-in link is on its way to {sentTo}. It works once + and expires in 15 minutes. Open it in this browser. Not there? Check spam, or{" "} + + . +
+ ); + } + + return ( +
+
+ + +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ ); +} diff --git a/apps/site/app/_components/passkey-button.tsx b/apps/site/app/_components/passkey-button.tsx new file mode 100644 index 0000000..7fd7d37 --- /dev/null +++ b/apps/site/app/_components/passkey-button.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { authClient, passkeysSupported } from "./auth-client"; + +/** Sign in with a passkey already saved to an account, then run `onSignedIn`. */ +export function PasskeySignIn({ + onSignedIn, + label = "Sign in with a passkey", +}: { + onSignedIn: () => void; + label?: string; +}) { + const [supported, setSupported] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => setSupported(passkeysSupported()), []); + if (!supported) return null; + + async function signIn() { + setBusy(true); + setError(null); + try { + const result = await authClient.signIn.passkey(); + if (result?.error) { + setError( + "No passkey worked for this site. Use the email link instead; you can add a passkey from your account afterwards.", + ); + return; + } + onSignedIn(); + } catch { + setError("The passkey prompt was closed or failed. Try again or use the email link."); + } finally { + setBusy(false); + } + } + + return ( + <> + + {error ? ( +

+ {error} +

+ ) : null} + + ); +} + +/** Save a passkey to the signed-in account. */ +export function AddPasskey() { + const [supported, setSupported] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + + useEffect(() => setSupported(passkeysSupported()), []); + if (!supported) return

This browser cannot save a passkey. Email sign-in links still work.

; + + async function add() { + setBusy(true); + setMessage(null); + try { + const result = await authClient.passkey.addPasskey(); + setMessage( + result?.error + ? { ok: false, text: "The passkey was not saved. Try again." } + : { ok: true, text: "Passkey saved. Next time, sign in with it." }, + ); + } catch { + setMessage({ ok: false, text: "The passkey prompt was closed or failed. Try again." }); + } finally { + setBusy(false); + } + } + + return ( + <> +

+ +

+ {message ? ( +

+ {message.text} +

+ ) : null} + + ); +} diff --git a/apps/site/app/_components/passkey-login.tsx b/apps/site/app/_components/passkey-login.tsx new file mode 100644 index 0000000..ea3773f --- /dev/null +++ b/apps/site/app/_components/passkey-login.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { PasskeySignIn } from "./passkey-button"; + +/** Passkey sign-in on the login page; a client wrapper so it can redirect. */ +export function PasskeyLogin({ next }: { next: string }) { + return window.location.assign(next)} />; +} diff --git a/apps/site/app/claim/[token]/page.tsx b/apps/site/app/claim/[token]/page.tsx new file mode 100644 index 0000000..f75c544 --- /dev/null +++ b/apps/site/app/claim/[token]/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { PageShell } from "../../_components/page-shell"; +import { ClaimView } from "../../_components/claim-view"; + +export const metadata: Metadata = { + title: "Claim your episode", + // A claim link is a secret; keep it out of indexes and other sites' logs. + robots: { index: false, follow: false }, + referrer: "no-referrer", +}; + +export default async function ClaimPage({ params }: { params: Promise<{ token: string }> }) { + const { token } = await params; + return ( + + + + ); +} diff --git a/apps/site/app/login/page.tsx b/apps/site/app/login/page.tsx index 290f7ac..e784809 100644 --- a/apps/site/app/login/page.tsx +++ b/apps/site/app/login/page.tsx @@ -1,12 +1,30 @@ import type { Metadata } from "next"; import { PageShell } from "../_components/page-shell"; import { AuthForm } from "../_components/auth-form"; +import { MagicLinkForm } from "../_components/magic-link-form"; +import { PasskeyLogin } from "../_components/passkey-login"; +import { safeNext } from "../_components/safe-next"; export const metadata: Metadata = { title: "Sign in" }; -export default function LoginPage() { +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + const nextParam = typeof params.next === "string" ? params.next : ""; + const next = safeNext(`?${new URLSearchParams({ next: nextParam })}`) ?? "/account"; + return ( +

We email you a one-click link. No password needed; a new address gets a new account.

+ +

+ +

+ +

Or use a password

=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="], - "@profullstack/footer": ["@profullstack/footer@0.2.1", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-KHmKVwrlzCBrSAAJ4zobNFCxTg8VF0gvNMeAut00dX3+gbWCtk6eOwIR2AaqEC7smj6Aio6cIexzt/dUnv1McA=="], + "@profullstack/footer": ["@profullstack/footer@0.2.5", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-/CKwFP3m+qM0ZFTC9Rj11fgClutJzeYliX44Qp4qwrv2JgA/1k4tVOB3mhkPhaeckUhnfg5UyESDI1uoWr9c2w=="], "@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "9.0.5", "prettier": "3.9.6", "react-promise-suspense": "0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="], @@ -894,6 +930,10 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "5.0.3", "selderee": "0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], + + "@simplewebauthn/server": ["@simplewebauthn/server@13.3.3", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@smithy/core": ["@smithy/core@3.30.0", "", { "dependencies": { "@smithy/types": "4.16.1", "tslib": "2.8.1" } }, "sha512-dl2yRglDxfzH9uJ4fSo4zTaAHa0zH7+V7BZMRWy8hEYIKT1BiqMUK/CN6T3ADQ3kbA5N1tmUulroJ2UtONS7Kw=="], @@ -1042,6 +1082,8 @@ "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "4.12.5", "inherits": "2.0.4", "minimalistic-assert": "1.0.1", "safer-buffer": "2.1.2" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "asn1js": ["asn1js@3.1.0", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.2.0", "tslib": "^2.8.1" } }, "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -1962,6 +2004,10 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "1.0.1", "side-channel": "1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], @@ -1990,6 +2036,8 @@ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "1.2.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "1.0.9", "define-properties": "1.2.1", "es-abstract": "1.24.2", "es-errors": "1.3.0", "es-object-atoms": "1.1.2", "get-intrinsic": "1.3.0", "get-proto": "1.0.1", "which-builtin-type": "1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -2226,6 +2274,8 @@ "tsx": ["tsx@4.23.1", "", { "dependencies": { "esbuild": "0.28.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ=="], + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="], @@ -2566,6 +2616,8 @@ "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "tsyringe/tslib": ["tslib@1.9.3", "", {}, "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ=="], + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], diff --git a/packages/auth/package.json b/packages/auth/package.json index 5ece2de..5b9d867 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -14,8 +14,10 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { + "@better-auth/passkey": "1.6.25", + "@nightcell7/database": "workspace:*", + "@simplewebauthn/server": "13.3.3", "better-auth": "^1.6.25", - "zod": "^4.1.13", - "@nightcell7/database": "workspace:*" + "zod": "^4.1.13" } } diff --git a/packages/auth/src/server.test.ts b/packages/auth/src/server.test.ts new file mode 100644 index 0000000..4f5bd58 --- /dev/null +++ b/packages/auth/src/server.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { displayNameSchema } from "./index"; +import { defaultDisplayName } from "./server"; + +describe("display name for a magic link or passkey signup", () => { + it("derives a valid, suffixed name from the email", () => { + const name = defaultDisplayName("leila.k@example.com", () => 0.1234); + expect(name).toBe("leila.k-1234"); + expect(displayNameSchema.safeParse(name).success).toBe(true); + }); + + it("stays valid for awkward addresses", () => { + for (const email of [ + "+++@example.com", + "a@example.com", + "_under@example.com", + "very.long.local.part.indeed.yes@example.com", + "émile@example.com", + ]) { + const name = defaultDisplayName(email, () => 0.5); + expect(displayNameSchema.safeParse(name).success, `${email} -> ${name}`).toBe(true); + } + }); +}); diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 4509216..49ecc74 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -1,7 +1,9 @@ import { betterAuth, type BetterAuthOptions } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { magicLink } from "better-auth/plugins/magic-link"; +import { passkey } from "@better-auth/passkey"; import type { Database } from "@nightcell7/database"; -import { accounts, sessions, users, verifications } from "@nightcell7/database"; +import { accounts, passkeys, sessions, users, verifications } from "@nightcell7/database"; import { DISPLAY_NAME_MAX, DISPLAY_NAME_MIN } from "./index.js"; /** @@ -32,7 +34,27 @@ export interface AuthConfig { export type AuthEmail = | { kind: "verify-email"; to: string; verifyUrl: string } - | { kind: "password-reset"; to: string; resetUrl: string }; + | { kind: "password-reset"; to: string; resetUrl: string } + | { kind: "magic-link"; to: string; signInUrl: string }; + +/** How long an emailed sign-in link works. */ +export const MAGIC_LINK_TTL_SECONDS = 15 * 60; + +/** + * A display name for an account created by a magic link or passkey, where no + * signup form asked for one. Derived from the email's local part so it reads + * like the person, with a random suffix because display names are unique. + * Always satisfies `displayNameSchema`; the player can change it later. + */ +export function defaultDisplayName(email: string, random: () => number = Math.random): string { + const local = (email.split("@")[0] ?? "") + .replace(/[^A-Za-z0-9_.-]/g, "") + .replace(/^[^A-Za-z0-9]+/, ""); + const base = (local || "player").slice(0, DISPLAY_NAME_MAX - 5); + const suffix = String(Math.floor(random() * 10_000)).padStart(4, "0"); + const name = `${base}-${suffix}`; + return name.length >= DISPLAY_NAME_MIN ? name : `player-${suffix}`; +} export function createAuth(config: AuthConfig) { const options = { @@ -50,9 +72,24 @@ export function createAuth(config: AuthConfig) { session: sessions, account: accounts, verification: verifications, + passkey: passkeys, }, }), + databaseHooks: { + user: { + create: { + // The email+password signup form asks for a display name; a magic + // link or passkey signup does not, and the column is NOT NULL. + before: async (user) => { + const record = user as typeof user & { displayName?: string | null }; + if (record.displayName) return { data: record }; + return { data: { ...record, displayName: defaultDisplayName(record.email) } }; + }, + }, + }, + }, + emailAndPassword: { enabled: true, // A verified email is what multiplayer eligibility is built on @@ -116,6 +153,22 @@ export function createAuth(config: AuthConfig) { window: 60, max: 20, }, + + plugins: [ + // The house sign-in: the link proves the address, and the address is + // the account, so the same link signs a new buyer up. + magicLink({ + expiresIn: MAGIC_LINK_TTL_SECONDS, + sendMagicLink: async ({ email, url }) => { + await config.sendEmail({ kind: "magic-link", to: email, signInUrl: url }); + }, + }), + passkey({ + rpID: new URL(config.publicOrigin).hostname, + rpName: "NIGHTCELL 7", + origin: config.publicOrigin, + }), + ], } satisfies BetterAuthOptions; return betterAuth(options); diff --git a/packages/database/drizzle-pg/0001_passkeys.sql b/packages/database/drizzle-pg/0001_passkeys.sql new file mode 100644 index 0000000..8094844 --- /dev/null +++ b/packages/database/drizzle-pg/0001_passkeys.sql @@ -0,0 +1,17 @@ +CREATE TABLE "passkeys" ( + "id" text PRIMARY KEY NOT NULL, + "name" text, + "public_key" text NOT NULL, + "user_id" text NOT NULL, + "credential_id" text NOT NULL, + "counter" integer NOT NULL, + "device_type" text NOT NULL, + "backed_up" boolean NOT NULL, + "transports" text, + "created_at" timestamp with time zone, + "aaguid" text +); +--> statement-breakpoint +ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "passkeys_user_idx" ON "passkeys" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "passkeys_credential_idx" ON "passkeys" USING btree ("credential_id"); \ No newline at end of file diff --git a/packages/database/drizzle-pg/meta/0001_snapshot.json b/packages/database/drizzle-pg/meta/0001_snapshot.json new file mode 100644 index 0000000..44e9b95 --- /dev/null +++ b/packages/database/drizzle-pg/meta/0001_snapshot.json @@ -0,0 +1,2356 @@ +{ + "id": "7996c929-0c7c-4232-a4e1-131306d90df0", + "prevId": "f3e9dccf-ca7c-4a1a-8c12-f30bb0e68e8c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_provider_account_unique": { + "name": "accounts_provider_account_unique", + "nullsNotDistinct": false, + "columns": ["provider_id", "account_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "devices_user_idx": { + "name": "devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.downloads": { + "name": "downloads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "downloads_user_idx": { + "name": "downloads_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "downloads_user_id_users_id_fk": { + "name": "downloads_user_id_users_id_fk", + "tableFrom": "downloads", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "downloads_episode_id_episodes_id_fk": { + "name": "downloads_episode_id_episodes_id_fk", + "tableFrom": "downloads", + "tableTo": "episodes", + "columnsFrom": ["episode_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "downloads_version_id_episode_versions_id_fk": { + "name": "downloads_version_id_episode_versions_id_fk", + "tableFrom": "downloads", + "tableTo": "episode_versions", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlement_events": { + "name": "entitlement_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + } + }, + "indexes": { + "entitlement_events_idx": { + "name": "entitlement_events_idx", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlement_events_entitlement_id_entitlements_id_fk": { + "name": "entitlement_events_entitlement_id_entitlements_id_fk", + "tableFrom": "entitlement_events", + "tableTo": "entitlements", + "columnsFrom": ["entitlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_order_id": { + "name": "source_order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "entitlements_user_id_users_id_fk": { + "name": "entitlements_user_id_users_id_fk", + "tableFrom": "entitlements", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_episode_id_episodes_id_fk": { + "name": "entitlements_episode_id_episodes_id_fk", + "tableFrom": "entitlements", + "tableTo": "episodes", + "columnsFrom": ["episode_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "entitlements_source_order_id_orders_id_fk": { + "name": "entitlements_source_order_id_orders_id_fk", + "tableFrom": "entitlements", + "tableTo": "orders", + "columnsFrom": ["source_order_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_user_episode_unique": { + "name": "entitlements_user_episode_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "episode_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episode_versions": { + "name": "episode_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest_key": { + "name": "manifest_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "minimum_game_version": { + "name": "minimum_game_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "published_at": { + "name": "published_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "episode_versions_episode_id_episodes_id_fk": { + "name": "episode_versions_episode_id_episodes_id_fk", + "tableFrom": "episode_versions", + "tableTo": "episodes", + "columnsFrom": ["episode_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "episode_versions_unique": { + "name": "episode_versions_unique", + "nullsNotDistinct": false, + "columns": ["episode_id", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theater": { + "name": "theater", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "release_at": { + "name": "release_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_asset_id": { + "name": "cover_asset_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trailer_url": { + "name": "trailer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_version_id": { + "name": "current_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "episodes_slug_unique": { + "name": "episodes_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_version": { + "name": "build_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "multiplayer_match_id": { + "name": "multiplayer_match_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + } + }, + "indexes": {}, + "foreignKeys": { + "feedback_user_id_users_id_fk": { + "name": "feedback_user_id_users_id_fk", + "tableFrom": "feedback", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.multiplayer_bans": { + "name": "multiplayer_bans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'multiplayer'" + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "multiplayer_bans_user_idx": { + "name": "multiplayer_bans_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "multiplayer_bans_user_id_users_id_fk": { + "name": "multiplayer_bans_user_id_users_id_fk", + "tableFrom": "multiplayer_bans", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.multiplayer_blocks": { + "name": "multiplayer_blocks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + } + }, + "indexes": {}, + "foreignKeys": { + "multiplayer_blocks_user_id_users_id_fk": { + "name": "multiplayer_blocks_user_id_users_id_fk", + "tableFrom": "multiplayer_blocks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "multiplayer_blocks_blocked_user_id_users_id_fk": { + "name": "multiplayer_blocks_blocked_user_id_users_id_fk", + "tableFrom": "multiplayer_blocks", + "tableTo": "users", + "columnsFrom": ["blocked_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "multiplayer_blocks_user_id_blocked_user_id_pk": { + "name": "multiplayer_blocks_user_id_blocked_user_id_pk", + "columns": ["user_id", "blocked_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.multiplayer_match_players": { + "name": "multiplayer_match_players", + "schema": "", + "columns": { + "match_id": { + "name": "match_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team": { + "name": "team", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconnect_count": { + "name": "reconnect_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "kills": { + "name": "kills", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deaths": { + "name": "deaths", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "assists": { + "name": "assists", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reportable_session_id": { + "name": "reportable_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "multiplayer_match_players_user_idx": { + "name": "multiplayer_match_players_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "multiplayer_match_players_match_id_multiplayer_matches_id_fk": { + "name": "multiplayer_match_players_match_id_multiplayer_matches_id_fk", + "tableFrom": "multiplayer_match_players", + "tableTo": "multiplayer_matches", + "columnsFrom": ["match_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "multiplayer_match_players_match_id_user_id_pk": { + "name": "multiplayer_match_players_match_id_user_id_pk", + "columns": ["match_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.multiplayer_matches": { + "name": "multiplayer_matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_id": { + "name": "map_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "region_id": { + "name": "region_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shard_id": { + "name": "shard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "build_version": { + "name": "build_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "winning_team": { + "name": "winning_team", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_hash": { + "name": "result_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "multiplayer_matches_started_idx": { + "name": "multiplayer_matches_started_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "multiplayer_matches_result_hash_unique": { + "name": "multiplayer_matches_result_hash_unique", + "nullsNotDistinct": false, + "columns": ["result_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.multiplayer_profiles": { + "name": "multiplayer_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name_snapshot": { + "name": "display_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "matches_played": { + "name": "matches_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wins": { + "name": "wins", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "losses": { + "name": "losses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "kills": { + "name": "kills", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deaths": { + "name": "deaths", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "assists": { + "name": "assists", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_match_at": { + "name": "last_match_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matchmaking_restricted_until": { + "name": "matchmaking_restricted_until", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + } + }, + "indexes": {}, + "foreignKeys": { + "multiplayer_profiles_user_id_users_id_fk": { + "name": "multiplayer_profiles_user_id_users_id_fk", + "tableFrom": "multiplayer_profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.multiplayer_reports": { + "name": "multiplayer_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reporter_user_id": { + "name": "reporter_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reported_user_id": { + "name": "reported_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_id": { + "name": "match_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "evidence_metadata": { + "name": "evidence_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "multiplayer_reports_reported_idx": { + "name": "multiplayer_reports_reported_idx", + "columns": [ + { + "expression": "reported_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "multiplayer_reports_reporter_user_id_users_id_fk": { + "name": "multiplayer_reports_reporter_user_id_users_id_fk", + "tableFrom": "multiplayer_reports", + "tableTo": "users", + "columnsFrom": ["reporter_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "multiplayer_reports_reported_user_id_users_id_fk": { + "name": "multiplayer_reports_reported_user_id_users_id_fk", + "tableFrom": "multiplayer_reports", + "tableTo": "users", + "columnsFrom": ["reported_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "multiplayer_reports_match_id_multiplayer_matches_id_fk": { + "name": "multiplayer_reports_match_id_multiplayer_matches_id_fk", + "tableFrom": "multiplayer_reports", + "tableTo": "multiplayer_matches", + "columnsFrom": ["match_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "multiplayer_reports_dedupe": { + "name": "multiplayer_reports_dedupe", + "nullsNotDistinct": false, + "columns": ["reporter_user_id", "reported_user_id", "match_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.newsletter_subscribers": { + "name": "newsletter_subscribers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "newsletter_subscribers_email_unique": { + "name": "newsletter_subscribers_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.offline_licenses": { + "name": "offline_licenses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "offline_licenses_user_idx": { + "name": "offline_licenses_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "offline_licenses_user_id_users_id_fk": { + "name": "offline_licenses_user_id_users_id_fk", + "tableFrom": "offline_licenses", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "offline_licenses_episode_id_episodes_id_fk": { + "name": "offline_licenses_episode_id_episodes_id_fk", + "tableFrom": "offline_licenses", + "tableTo": "episodes", + "columnsFrom": ["episode_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "offline_licenses_token_unique": { + "name": "offline_licenses_token_unique", + "nullsNotDistinct": false, + "columns": ["token_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_items": { + "name": "order_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unit_amount": { + "name": "unit_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": { + "order_items_order_idx": { + "name": "order_items_order_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": ["order_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_episode_id_episodes_id_fk": { + "name": "order_items_episode_id_episodes_id_fk", + "tableFrom": "order_items", + "tableTo": "episodes", + "columnsFrom": ["episode_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'coinpay'" + }, + "coinpay_order_id": { + "name": "coinpay_order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coinpay_payment_id": { + "name": "coinpay_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtotal": { + "name": "subtotal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tax": { + "name": "tax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fulfilled_at": { + "name": "fulfilled_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refunded_at": { + "name": "refunded_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(timezone('utc', now()), 'YYYY-MM-DD HH24:MI:SS')" + } + }, + "indexes": { + "orders_user_idx": { + "name": "orders_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_status_idx": { + "name": "orders_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_unique": { + "name": "orders_idempotency_unique", + "nullsNotDistinct": false, + "columns": ["idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkeys_user_idx": { + "name": "passkeys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkeys_credential_idx": { + "name": "passkeys_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_events": { + "name": "payment_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'coinpay'" + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "received_at": { + "name": "received_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payment_events_status_idx": { + "name": "payment_events_status_idx", + "columns": [ + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": ["order_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payment_events_provider_event_unique": { + "name": "payment_events_provider_event_unique", + "nullsNotDistinct": false, + "columns": ["provider_event_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unit_amount": { + "name": "unit_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "coinpay_product_id": { + "name": "coinpay_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tax_code": { + "name": "tax_code", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "prices_episode_idx": { + "name": "prices_episode_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_episode_id_episodes_id_fk": { + "name": "prices_episode_id_episodes_id_fk", + "tableFrom": "prices", + "tableTo": "episodes", + "columnsFrom": ["episode_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_changed_at": { + "name": "display_name_changed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "users_display_name_unique": { + "name": "users_display_name_unique", + "nullsNotDistinct": false, + "columns": ["display_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/database/drizzle-pg/meta/_journal.json b/packages/database/drizzle-pg/meta/_journal.json index 37c91b5..5be7571 100644 --- a/packages/database/drizzle-pg/meta/_journal.json +++ b/packages/database/drizzle-pg/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1790334818655, "tag": "0000_perfect_sasquatch", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1792585342779, + "tag": "0001_passkeys", + "breakpoints": true } ] } diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 63ef09d..81cb637 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -156,6 +156,35 @@ export const verifications = pgTable( }), ); +/** + * WebAuthn passkeys, written by the Better Auth passkey plugin. + * + * Sign-in is magic link plus passkeys (the house auth); a password stays as an + * option for devices that can do neither. + */ +export const passkeys = pgTable( + "passkeys", + { + id: text("id").primaryKey(), + name: text("name"), + publicKey: text("public_key").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + credentialID: text("credential_id").notNull(), + counter: integer("counter").notNull(), + deviceType: text("device_type").notNull(), + backedUp: boolean("backed_up").notNull(), + transports: text("transports"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }), + aaguid: text("aaguid"), + }, + (table) => ({ + userIdx: index("passkeys_user_idx").on(table.userId), + credentialIdx: index("passkeys_credential_idx").on(table.credentialID), + }), +); + export const devices = pgTable( "devices", { diff --git a/packages/entitlements/src/delivery.test.ts b/packages/entitlements/src/delivery.test.ts index fe7e08c..29426ea 100644 --- a/packages/entitlements/src/delivery.test.ts +++ b/packages/entitlements/src/delivery.test.ts @@ -12,7 +12,9 @@ import { CLAIM_TOKEN_TTL_SECONDS, createClaimToken, createOfflineLicense, + describeClaim, evaluateClaim, + readClaimTokenIgnoringExpiry, isOfflineLicenseUsable, verifyClaimToken, verifyOfflineLicense, @@ -314,3 +316,68 @@ describe("content delivery", () => { expect(compareSemver("0.2.0-beta.1", "0.2.0")).toBe(0); }); }); + +describe("claim link status (the /claim page)", () => { + const token = createClaimToken( + { orderId: "ord_1", email: "buyer@example.com" }, + SECRET, + NOW, + ).token; + const order = { id: "ord_1", status: "fulfilled", userId: null as string | null }; + + it("is claimable when paid, unowned and in date", () => { + expect(describeClaim(token, SECRET, NOW, { order })).toEqual({ + state: "claimable", + orderId: "ord_1", + email: "buyer@example.com", + }); + }); + + it("tells the owner it is theirs, even after the link expired", () => { + const owned = { ...order, userId: "u_me" }; + const later = NOW + CLAIM_TOKEN_TTL_SECONDS + 10; + expect(describeClaim(token, SECRET, later, { order: owned, viewerUserId: "u_me" }).state).toBe( + "claimed_by_you", + ); + expect(describeClaim(token, SECRET, NOW, { order: owned, viewerUserId: "u_other" }).state).toBe( + "claimed", + ); + expect(describeClaim(token, SECRET, NOW, { order: owned }).state).toBe("claimed"); + }); + + it("reports an expired link with the order it names", () => { + const status = describeClaim(token, SECRET, NOW + CLAIM_TOKEN_TTL_SECONDS, { order }); + expect(status).toEqual({ state: "expired", orderId: "ord_1", email: "buyer@example.com" }); + }); + + it("separates in-flight payments from dead orders", () => { + for (const status of ["created", "pending", "confirming", "paid"]) { + expect(describeClaim(token, SECRET, NOW, { order: { ...order, status } }).state).toBe( + "pending", + ); + } + for (const status of ["refunded", "reversed", "disputed", "canceled", "expired"]) { + expect(describeClaim(token, SECRET, NOW, { order: { ...order, status } }).state).toBe( + "unavailable", + ); + } + }); + + it("is invalid for a forged, tampered or orphaned link", () => { + expect(describeClaim(token, "another-secret-entirely-000000000", NOW, { order }).state).toBe( + "invalid", + ); + expect(describeClaim(`${token}x`, SECRET, NOW, { order }).state).toBe("invalid"); + expect(describeClaim("not-a-token", SECRET, NOW, { order }).state).toBe("invalid"); + expect(describeClaim(token, SECRET, NOW, { order: null }).state).toBe("invalid"); + expect(describeClaim(token, SECRET, NOW, { order: { ...order, id: "ord_other" } }).state).toBe( + "invalid", + ); + }); + + it("reads an expired token for a resend but still rejects a forged one", () => { + const read = readClaimTokenIgnoringExpiry(token, SECRET); + expect(read.ok && read.claims.orderId).toBe("ord_1"); + expect(readClaimTokenIgnoringExpiry(token, "another-secret-entirely-000000000").ok).toBe(false); + }); +}); diff --git a/packages/entitlements/src/server.ts b/packages/entitlements/src/server.ts index 277362c..a33f82d 100644 --- a/packages/entitlements/src/server.ts +++ b/packages/entitlements/src/server.ts @@ -107,6 +107,81 @@ export function verifyClaimToken( return decode(token, secret, claimClaimsSchema, nowSeconds); } +/** + * Read a claim token whose signature is valid, even if it has expired. + * + * Only for deciding what to tell the holder (and where to send a fresh link): + * an expired token still names its order, and that order's buyer is who a + * replacement goes to. Never use this to attach a purchase. + */ +export function readClaimTokenIgnoringExpiry( + token: string, + secret: string, +): + | { ok: true; claims: ClaimClaims } + | { ok: false; reason: "malformed" | "bad_signature" | "invalid_claims" } { + // `decode` rejects only `exp <= now`; at now = -1 nothing has expired. + const decoded = decode(token, secret, claimClaimsSchema, -1); + if (decoded.ok) return decoded; + return { ok: false, reason: decoded.reason === "expired" ? "invalid_claims" : decoded.reason }; +} + +/** What the claim page tells the person holding a link. */ +export type ClaimState = + /** Paid, unowned, link in date: sign in and it is yours. */ + | "claimable" + /** Already attached to the account looking at it. */ + | "claimed_by_you" + /** Already attached to some other account. */ + | "claimed" + /** The link is out of date; a fresh one can be emailed. */ + | "expired" + /** Paid but fulfilment has not finished; try again in a minute. */ + | "pending" + /** Refunded, reversed, disputed or otherwise not claimable. */ + | "unavailable" + /** Not a link we issued, or its order no longer exists. */ + | "invalid"; + +const PENDING_ORDER_STATUSES = new Set(["created", "pending", "confirming", "paid"]); + +export interface ClaimStatus { + state: ClaimState; + orderId?: string; + /** The payment email the token was issued to. */ + email?: string; +} + +/** + * Classify a claim link for display. Pure; the API loads the order and the + * viewer and this decides. Ownership is checked before expiry so a buyer who + * already claimed is sent to the episode rather than told the link expired. + */ +export function describeClaim( + token: string, + secret: string, + nowSeconds: number, + context: { + order: { id: string; status: string; userId: string | null } | null; + viewerUserId?: string | null; + }, +): ClaimStatus { + const read = readClaimTokenIgnoringExpiry(token, secret); + if (!read.ok) return { state: "invalid" }; + const { orderId, email, exp } = read.claims; + const order = context.order; + if (!order || order.id !== orderId) return { state: "invalid" }; + + if (order.userId) { + const mine = Boolean(context.viewerUserId) && order.userId === context.viewerUserId; + return { state: mine ? "claimed_by_you" : "claimed", orderId, email }; + } + if (exp <= nowSeconds) return { state: "expired", orderId, email }; + if (order.status === "fulfilled") return { state: "claimable", orderId, email }; + if (PENDING_ORDER_STATUSES.has(order.status)) return { state: "pending", orderId, email }; + return { state: "unavailable", orderId, email }; +} + export type ClaimOutcome = | { ok: true; orderId: string; email: string } | { diff --git a/services/api/src/app.test.ts b/services/api/src/app.test.ts index 2bf82af..3557a65 100644 --- a/services/api/src/app.test.ts +++ b/services/api/src/app.test.ts @@ -4,6 +4,7 @@ import { SESSION_COOKIE_NAME } from "@nightcell7/auth"; import { COINPAY_SIGNATURE_HEADER, signWebhookPayload } from "@nightcell7/coinpay"; import { CONTENT_VERSION, PROTOCOL_VERSION } from "@nightcell7/multiplayer-protocol"; import { verifyTicket } from "@nightcell7/multiplayer-protocol/server"; +import { CLAIM_TOKEN_TTL_SECONDS, createClaimToken } from "@nightcell7/entitlements/server"; import { createApp, buildSyncUrl, generatePrivateCode, readCookie } from "./app"; import type { Dependencies } from "./app"; import { MemoryRateLimiter } from "./rate-limit"; @@ -71,6 +72,12 @@ function fakeRepos(overrides: Partial = {}): Repositories { async listOrders() { return []; }, + async findOrderForClaim() { + return null; + }, + async listUnclaimedGuestOrders() { + return []; + }, async recordPaymentEvent(input) { if (seenEvents.has(input.providerEventId)) return { duplicate: true }; seenEvents.add(input.providerEventId); @@ -596,3 +603,184 @@ describe("helpers", () => { } }); }); + +describe("guest claim", () => { + const nowSeconds = () => Math.floor(Date.now() / 1000); + const tokenFor = (orderId = "ord_guest", at = nowSeconds()) => + createClaimToken({ orderId, email: "buyer@example.com" }, env.AUTH_SECRET, at).token; + + function guestOrder(overrides: { status?: string; userId?: string | null } = {}) { + return { + id: "ord_guest", + status: overrides.status ?? "fulfilled", + userId: overrides.userId ?? null, + email: "buyer@example.com", + episodeIds: ["false-dawn"], + }; + } + + function withOrder(order: ReturnType | null) { + return harness({ + findOrderForClaim: async (id) => (order && order.id === id ? order : null), + listUnclaimedGuestOrders: async (email) => + order && !order.userId && email.toLowerCase() === order.email ? [order] : [], + }); + } + + async function status(app: Harness["app"], token: string, session?: string) { + const res = await app.request("/api/v1/checkout/claim/status", jsonPost({ token }, session)); + expect(res.status).toBe(200); + return (await res.json()) as { + state: string; + email: string | null; + episodeTitle: string | null; + playUrl: string | null; + }; + } + + it("describes a claimable link to a signed-out buyer", async () => { + const body = await status(withOrder(guestOrder()).app, tokenFor()); + expect(body).toMatchObject({ + state: "claimable", + email: "buyer@example.com", + episodeTitle: "False Dawn", + playUrl: null, + }); + }); + + it("sends the owner straight to the episode", async () => { + const body = await status( + withOrder(guestOrder({ userId: "u_verified" })).app, + tokenFor(), + "verified", + ); + expect(body.state).toBe("claimed_by_you"); + expect(body.playUrl).toBe("/play?mode=campaign&episode=false-dawn"); + }); + + it("calls out used, expired and forged links", async () => { + expect((await status(withOrder(guestOrder({ userId: "u_x" })).app, tokenFor())).state).toBe( + "claimed", + ); + const old = tokenFor("ord_guest", nowSeconds() - CLAIM_TOKEN_TTL_SECONDS - 5); + expect((await status(withOrder(guestOrder()).app, old)).state).toBe("expired"); + expect((await status(withOrder(guestOrder()).app, `${tokenFor()}tampered`)).state).toBe( + "invalid", + ); + }); + + it("requires a session to claim", async () => { + const res = await withOrder(guestOrder()).app.request( + "/api/v1/checkout/claim", + jsonPost({ token: tokenFor() }), + ); + expect(res.status).toBe(401); + }); + + it("queues a claimable link for the signed-in account", async () => { + const harnessed = withOrder(guestOrder()); + const res = await harnessed.app.request( + "/api/v1/checkout/claim", + jsonPost({ token: tokenFor() }, "verified"), + ); + expect(res.status).toBe(202); + expect(harnessed.enqueued).toEqual([ + expect.objectContaining({ + queue: "entitlements", + name: "claim", + payload: expect.objectContaining({ userId: "u_verified" }), + }), + ]); + }); + + it("refuses links that can never succeed, with a code, and queues nothing", async () => { + const cases: [ReturnType, string, number, string][] = [ + [guestOrder({ userId: "u_other" }), tokenFor(), 409, "already_claimed"], + [ + guestOrder(), + tokenFor("ord_guest", nowSeconds() - CLAIM_TOKEN_TTL_SECONDS - 5), + 410, + "claim_expired", + ], + [guestOrder({ status: "confirming" }), tokenFor(), 409, "claim_pending"], + [guestOrder({ status: "refunded" }), tokenFor(), 409, "claim_unavailable"], + [guestOrder(), "x".repeat(40), 400, "invalid_claim"], + ]; + for (const [order, token, code, reason] of cases) { + const harnessed = withOrder(order); + const res = await harnessed.app.request( + "/api/v1/checkout/claim", + jsonPost({ token }, "verified"), + ); + expect(res.status, reason).toBe(code); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe(reason); + expect(harnessed.enqueued).toHaveLength(0); + } + }); + + it("answers an already-claimed-by-you claim without queueing", async () => { + const harnessed = withOrder(guestOrder({ userId: "u_verified" })); + const res = await harnessed.app.request( + "/api/v1/checkout/claim", + jsonPost({ token: tokenFor() }, "verified"), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + status: "claimed", + playUrl: "/play?mode=campaign&episode=false-dawn", + }); + expect(harnessed.enqueued).toHaveLength(0); + }); + + it("re-sends a fresh link for an expired token, to the paying address only", async () => { + const harnessed = withOrder(guestOrder()); + const old = tokenFor("ord_guest", nowSeconds() - CLAIM_TOKEN_TTL_SECONDS - 5); + const res = await harnessed.app.request( + "/api/v1/checkout/claim/resend", + jsonPost({ token: old }), + ); + expect(res.status).toBe(202); + expect(harnessed.enqueued).toHaveLength(1); + const job = harnessed.enqueued[0]!.payload as { to: string; claimUrl: string; kind: string }; + expect(job.kind).toBe("purchase-claim"); + expect(job.to).toBe("buyer@example.com"); + expect(job.claimUrl).toMatch(/^https:\/\/nightcell7\.com\/claim\/[\w-]+\.[\w-]+$/); + // The new link is live. + const fresh = job.claimUrl.split("/claim/")[1]!; + expect((await status(harnessed.app, fresh)).state).toBe("claimable"); + }); + + it("re-sends by email without revealing whether the address bought anything", async () => { + const hit = withOrder(guestOrder()); + const miss = withOrder(guestOrder()); + const a = await hit.app.request( + "/api/v1/checkout/claim/resend", + jsonPost({ email: "Buyer@Example.com" }), + ); + const b = await miss.app.request( + "/api/v1/checkout/claim/resend", + jsonPost({ email: "nobody@example.com" }), + ); + expect(a.status).toBe(202); + expect(b.status).toBe(202); + expect(await a.json()).toEqual(await b.json()); + expect(hit.enqueued).toHaveLength(1); + expect(miss.enqueued).toHaveLength(0); + }); + + it("never re-sends for a claimed order or a forged token", async () => { + const claimed = withOrder(guestOrder({ userId: "u_other" })); + await claimed.app.request("/api/v1/checkout/claim/resend", jsonPost({ token: tokenFor() })); + expect(claimed.enqueued).toHaveLength(0); + + const forged = withOrder(guestOrder()); + const bad = createClaimToken( + { orderId: "ord_guest", email: "attacker@example.com" }, + "a-different-secret-000000000000", + nowSeconds(), + ).token; + await forged.app.request("/api/v1/checkout/claim/resend", jsonPost({ token: bad })); + expect(forged.enqueued).toHaveLength(0); + }); +}); diff --git a/services/api/src/app.ts b/services/api/src/app.ts index 98920b0..dafd7ec 100644 --- a/services/api/src/app.ts +++ b/services/api/src/app.ts @@ -16,7 +16,13 @@ import { hasAccess, resolvePrice, } from "@nightcell7/entitlements"; -import { createOfflineLicense } from "@nightcell7/entitlements/server"; +import { + createClaimToken, + createOfflineLicense, + describeClaim, + readClaimTokenIgnoringExpiry, + type ClaimStatus, +} from "@nightcell7/entitlements/server"; import { signManifest, type ContentManifest, type ObjectSigner } from "@nightcell7/content-schema"; import { COINPAY_SIGNATURE_HEADER, @@ -364,13 +370,98 @@ export function createApp(deps: Dependencies) { }); }); + // ------------------------------------------------------------ guest claim + + /** + * Guest purchase claim (PRD §23.4). A guest pays, the worker emails a + * one-time `/claim/` link, and the page behind it uses these routes. + * The token travels in a POST body, never a URL path, so it stays out of + * access logs. + */ + const claimTokenSchema = z.object({ token: z.string().min(16).max(1024) }); + + async function claimStatusFor(token: string, viewerUserId: string | null) { + const read = readClaimTokenIgnoringExpiry(token, deps.env.AUTH_SECRET); + const order = read.ok ? await deps.repos.findOrderForClaim(read.claims.orderId) : null; + const status: ClaimStatus = describeClaim( + token, + deps.env.AUTH_SECRET, + Math.floor(now().getTime() / 1000), + { order, viewerUserId }, + ); + const episodeId = order?.episodeIds[0] ?? null; + const entry = episodeId ? CATALOG.find((e) => e.episodeId === episodeId) : undefined; + return { status, order, episodeId, episodeTitle: entry?.title ?? null }; + } + + function playUrl(episodeId: string | null): string { + return episodeId ? `/play?mode=campaign&episode=${encodeURIComponent(episodeId)}` : "/library"; + } + + /** Mint a fresh link for a paid, unclaimed guest order and mail it. */ + async function sendClaimEmail(order: { id: string; email: string | null; episodeIds: string[] }) { + if (!order.email) return; + const claim = createClaimToken( + { orderId: order.id, email: order.email }, + deps.env.AUTH_SECRET, + Math.floor(now().getTime() / 1000), + ); + const entry = CATALOG.find((e) => e.episodeId === order.episodeIds[0]); + await deps.enqueue("email", "purchase-claim", { + kind: "purchase-claim", + to: order.email, + claimUrl: `${deps.env.PUBLIC_ORIGIN}/claim/${claim.token}`, + episodeTitle: entry?.title ?? "NIGHTCELL 7", + }); + } + + /** What a claim link is, for the page to explain before anyone signs in. */ + v1.post("/checkout/claim/status", async (c) => { + await limit("claimStatus", clientKey(c)); + const body = claimTokenSchema.parse(await c.req.json()); + const account = c.get("account"); + const { status, episodeId, episodeTitle } = await claimStatusFor( + body.token, + account?.userId ?? null, + ); + return c.json({ + state: status.state, + // The token already carries the payment email in its readable payload, + // so returning it reveals nothing the holder does not have. + email: status.email ?? null, + episodeId, + episodeTitle, + playUrl: status.state === "claimed_by_you" ? playUrl(episodeId) : null, + }); + }); + v1.post("/checkout/claim", async (c) => { - const body = z.object({ token: z.string().min(16).max(512) }).parse(await c.req.json()); + const body = claimTokenSchema.parse(await c.req.json()); const account = requireAccount(c); await limit("claim", account.userId!); + // Refuse what can never succeed here, with a reason the page can show, + // rather than queueing it and leaving the buyer polling. + const { status, episodeId } = await claimStatusFor(body.token, account.userId!); + switch (status.state) { + case "claimed_by_you": + return c.json({ status: "claimed", playUrl: playUrl(episodeId) }); + case "claimable": + break; + case "claimed": + throw new ApiError(409, "already_claimed", "This purchase is already on another account."); + case "expired": + throw new ApiError(410, "claim_expired", "This claim link has expired."); + case "pending": + throw new ApiError(409, "claim_pending", "This payment has not finished confirming yet."); + case "unavailable": + throw new ApiError(409, "claim_unavailable", "This order can no longer be claimed."); + default: + throw badRequest("invalid_claim", "This claim link is not valid."); + } + // Claim resolution is durable work with an audit trail, so it is queued - // rather than performed inline. + // rather than performed inline. The worker re-checks everything. await deps.enqueue("entitlements", "claim", { token: body.token, userId: account.userId, @@ -379,6 +470,45 @@ export function createApp(deps: Dependencies) { return c.json({ status: "processing" }, 202); }); + /** + * Email a fresh claim link. Takes the old (even expired) link, or the + * payment email. Always answers the same way, so it cannot be used to learn + * whether an address bought anything; the link only ever goes to the + * address that paid. + */ + v1.post("/checkout/claim/resend", async (c) => { + await limit("claimResend", clientKey(c)); + const body = z + .object({ + token: z.string().min(16).max(1024).optional(), + email: z.string().email().max(320).optional(), + }) + .refine((value) => value.token || value.email, "token or email is required") + .parse(await c.req.json()); + + const orders: { id: string; email: string | null; episodeIds: string[] }[] = []; + if (body.token) { + const read = readClaimTokenIgnoringExpiry(body.token, deps.env.AUTH_SECRET); + if (read.ok) { + const order = await deps.repos.findOrderForClaim(read.claims.orderId); + if (order && !order.userId && order.status === "fulfilled") orders.push(order); + } + } + if (body.email) { + await limit("claimResend", `email:${body.email.toLowerCase()}`); + for (const order of await deps.repos.listUnclaimedGuestOrders(body.email)) { + if (!orders.some((o) => o.id === order.id)) orders.push(order); + } + } + + for (const order of orders) await sendClaimEmail(order); + deps.logger.info("claim link resend", { + correlationId: c.get("correlationId"), + orders: orders.length, + }); + return c.json({ sent: true }, 202); + }); + // ------------------------------------------------------- coinpay webhook /** diff --git a/services/api/src/rate-limit.ts b/services/api/src/rate-limit.ts index a62a87f..1b04ce3 100644 --- a/services/api/src/rate-limit.ts +++ b/services/api/src/rate-limit.ts @@ -18,6 +18,10 @@ export interface RateLimitRule { export const RATE_LIMITS = { checkout: { limit: 10, windowSeconds: 60 }, claim: { limit: 10, windowSeconds: 600 }, + /** The claim page polls this while the worker attaches a purchase. */ + claimStatus: { limit: 120, windowSeconds: 600 }, + /** Per IP and per email; every request may send mail. */ + claimResend: { limit: 5, windowSeconds: 3600 }, ticket: { limit: 20, windowSeconds: 60 }, matchmaking: { limit: 30, windowSeconds: 60 }, privateMatch: { limit: 10, windowSeconds: 300 }, diff --git a/services/api/src/repository.ts b/services/api/src/repository.ts index 235c440..867d9a3 100644 --- a/services/api/src/repository.ts +++ b/services/api/src/repository.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import type { Database } from "@nightcell7/database"; import { devices, @@ -46,6 +46,10 @@ export interface Repositories { createOrder(input: CreateOrderInput): Promise; attachCoinpayInvoice(orderId: string, invoiceId: string): Promise; listOrders(userId: string): Promise; + /** The order a claim link names, with the episodes it bought. */ + findOrderForClaim(orderId: string): Promise; + /** Paid guest orders for an email that nobody has claimed yet. */ + listUnclaimedGuestOrders(email: string): Promise; recordPaymentEvent(input: RecordPaymentEventInput): Promise<{ duplicate: boolean }>; @@ -70,6 +74,14 @@ export interface Repositories { createFeedback(input: CreateFeedbackInput): Promise; } +export interface ClaimOrderRow { + id: string; + status: string; + userId: string | null; + email: string | null; + episodeIds: string[]; +} + export interface CreateOrderInput { id: string; userId: string | null; @@ -326,6 +338,60 @@ export function createRepositories(db: Database): Repositories { .orderBy(desc(orders.createdAt)); }, + async findOrderForClaim(orderId) { + const rows = await db + .select({ + id: orders.id, + status: orders.status, + userId: orders.userId, + email: orders.email, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + const order = rows[0]; + if (!order) return null; + const items = await db + .select({ episodeId: orderItems.episodeId }) + .from(orderItems) + .where(eq(orderItems.orderId, orderId)); + return { ...order, episodeIds: items.map((item) => item.episodeId) }; + }, + + async listUnclaimedGuestOrders(email) { + const rows = await db + .select({ + id: orders.id, + status: orders.status, + userId: orders.userId, + email: orders.email, + }) + .from(orders) + .where( + and( + isNull(orders.userId), + eq(orders.status, "fulfilled"), + sql`lower(${orders.email}) = ${email.toLowerCase()}`, + ), + ) + .orderBy(desc(orders.createdAt)) + .limit(10); + if (rows.length === 0) return []; + const items = await db + .select({ orderId: orderItems.orderId, episodeId: orderItems.episodeId }) + .from(orderItems) + .where( + inArray( + orderItems.orderId, + rows.map((row) => row.id), + ), + ); + return rows.map((row) => ({ + ...row, + episodeIds: items.filter((item) => item.orderId === row.id).map((item) => item.episodeId), + })); + }, + async recordPaymentEvent(input) { try { await db.insert(paymentEvents).values({ diff --git a/services/worker/src/processors/email.ts b/services/worker/src/processors/email.ts index 8cb8e95..3d3c857 100644 --- a/services/worker/src/processors/email.ts +++ b/services/worker/src/processors/email.ts @@ -15,6 +15,7 @@ import type { Logger } from "@nightcell7/observability"; export type EmailJob = | { kind: "verify-email"; to: string; verifyUrl: string } | { kind: "password-reset"; to: string; resetUrl: string } + | { kind: "magic-link"; to: string; signInUrl: string } | { kind: "purchase-claim"; to: string; claimUrl: string; episodeTitle: string } | { kind: "purchase-receipt"; to: string; episodeTitle: string; orderId: string; total: string } | { kind: "entitlement-revoked"; to: string; episodeTitle: string; reason: string }; @@ -176,19 +177,36 @@ export function renderEmail(job: EmailJob): { subject: string; text: string; htm }), }; + case "magic-link": + return { + subject: "Sign in to NIGHTCELL 7", + text: `Sign in to NIGHTCELL 7:\n\n${job.signInUrl}\n\nThis link works once and expires in 15 minutes. No account yet? The same link creates one. If you did not ask for it, ignore this message.`, + html: layout({ + heading: "Sign in", + body: [ + "Use the button below to sign in. No password needed.", + "No account yet? The same link creates one for this address.", + ], + action: { label: "Sign in", url: job.signInUrl }, + footnote: + "This link works once and expires in 15 minutes. If you did not ask for it, ignore it and nothing happens.", + }), + }; + case "purchase-claim": return { subject: `Claim ${job.episodeTitle}`, - text: `Thank you for buying ${job.episodeTitle}.\n\nClaim it to your account:\n\n${job.claimUrl}\n\nThis link works once. Your purchase includes both campaigns, the Complete Truth epilogue, and every supported platform.`, + text: `Thank you for buying ${job.episodeTitle}.\n\nClaim it to your account:\n\n${job.claimUrl}\n\nThis link works once and expires in 7 days. Lost it or let it expire? Request a fresh one at the same page. Your purchase includes both campaigns, the Complete Truth epilogue, and every supported platform.`, html: layout({ heading: `Claim ${job.episodeTitle}`, body: [ "Thank you for your purchase. Claim it to an account and it is yours on every supported platform.", + "No account yet? The claim page signs you up with a one-click email link.", "Your purchase includes both campaigns and the Complete Truth epilogue.", ], action: { label: "Claim your episode", url: job.claimUrl }, footnote: - "This link works once. Keep it private — anyone with it can claim the purchase.", + "This link works once and expires in 7 days. Keep it private: anyone with it can claim the purchase.", }), };