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?
+
+
+ void claim()}
+ disabled={phase.kind === "claiming"}
+ >
+ {phase.kind === "claiming" ? "Claiming…" : `Claim ${title}`}
+
+
+
+ >
+ ) : (
+ <>
+ 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" ? (
+
+ void load()}>
+ Check again
+
+
+ ) : 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 (
+ void signOut()}>
+ Use a different account
+
+ );
+}
+
+/**
+ * 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
+
+ >
+ );
+}
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{" "}
+ setSentTo(null)}>
+ send another
+
+ .
+
+ );
+ }
+
+ return (
+
+ );
+}
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 (
+ <>
+
+ {busy ? "Waiting for your passkey…" : label}
+
+ {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 (
+ <>
+
+
+ {busy ? "Waiting for your device…" : "Add a passkey"}
+
+
+ {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.",
}),
};