From f06df1d95aeece0d3affda2e888420cf0febedbd Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 16:08:18 +0000 Subject: [PATCH] feat: Profullstack Pass pays the Starter pack's credits once per pass on CoinPay sign-in fetchCoinpayClaims reads the userinfo pass claim (coinpay-onboard ^0.5.0 passFromUserinfo); the callback stores coinpay_identities.pass_until and grants the cheapest pack's credits through grant_code_credits() (promo, never cash-backed) with source coinpay-pass::. A partial unique index keeps it to one grant per pass even when several accounts link the same CoinPay sub. Pricing page links CoinPay Pass. Co-Authored-By: Claude Opus 5.5 --- app/(marketing)/pricing/page.tsx | 6 ++ app/api/v1/coinpay/oauth/callback/route.ts | 11 ++++ bun.lock | 4 +- lib/coinpay-pass.ts | 61 +++++++++++++++++++ lib/coinpay-signin.ts | 5 +- package.json | 2 +- .../20261107160000_coinpay_pass.sql | 18 ++++++ tests/coinpay-pass.test.ts | 60 ++++++++++++++++++ tests/coinpay-signin.test.ts | 12 +++- 9 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 lib/coinpay-pass.ts create mode 100644 supabase/migrations/20261107160000_coinpay_pass.sql create mode 100644 tests/coinpay-pass.test.ts diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx index 2142edd8..35b66b88 100644 --- a/app/(marketing)/pricing/page.tsx +++ b/app/(marketing)/pricing/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { PASS_CREDITS } from "@/lib/coinpay-pass"; import { ANON_DAILY_SCANS, CREDIT_PACKS, @@ -72,6 +73,11 @@ export default function PricingPage() {

One balance across the whole platform. No module has its own plan.

+

+ Deposit $99 in your CoinPay wallet and get a free month:{" "} + CoinPay Pass{" "} + ({PASS_CREDITS} free credits when you sign in with CoinPay). +

diff --git a/app/api/v1/coinpay/oauth/callback/route.ts b/app/api/v1/coinpay/oauth/callback/route.ts index 418d807d..7990e4b8 100644 --- a/app/api/v1/coinpay/oauth/callback/route.ts +++ b/app/api/v1/coinpay/oauth/callback/route.ts @@ -16,6 +16,7 @@ import { stateMatches, supabaseCoinpayStore, } from "@/lib/coinpay-signin"; +import { applyCoinpayPass, supabasePassStore } from "@/lib/coinpay-pass"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -74,6 +75,16 @@ export async function GET(req: NextRequest) { return back(failPath, "CoinPay sign-in failed. Try again."); } if (!resolved.ok) return back(failPath, resolved.error); + // Profullstack Pass: free credits once per pass. Never blocks the sign-in. + if (claims.pass) { + try { + if (await applyCoinpayPass(supabasePassStore(sb), resolved.userId, claims.sub, claims.pass)) { + console.log("[coinpay-signin] Profullstack Pass credits granted", resolved.userId); + } + } catch (e) { + console.error("[coinpay-signin] pass grant", e instanceof Error ? e.message : e); + } + } if (pending.mode === "connect" && claims.openprofile_url) { try { await saveOpenProfileContact(profileStore(), resolved.userId, claims.openprofile_url); } catch { console.warn("[coinpay-signin] OpenProfile import skipped"); } diff --git a/bun.lock b/bun.lock index dd6fd997..8f93a8a3 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ "@profullstack/ai-providers": "0.1.1", "@profullstack/autoblog": "^0.5.0", "@profullstack/coinpay": "^0.25.0", - "@profullstack/coinpay-onboard": "0.4.0", + "@profullstack/coinpay-onboard": "^0.5.0", "@profullstack/fleetbar": "0.2.0", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.0", @@ -323,7 +323,7 @@ "@profullstack/coinpay": ["@profullstack/coinpay@0.25.0", "", { "dependencies": { "@inquirer/prompts": "^8.3.0", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@profullstack/hqtui": "^0.6.3", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0" }, "bin": { "coinpay": "bin/coinpay.js" } }, "sha512-ZoK8COqBCdJXvr0fO5UWw9HPh88CdMBLycc/dU6kXwAyeIrwZ4WkBApisuDgs4leXNN+SMjwex2etgrEvlICTQ=="], - "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.4.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-L2P0fxNswZnXt1tvo6t19L/RrJN204vrWnIEY65QI+zriZN7JJPY4Ap6Nv8MLkyA5wDvY88s0+Dgjf9Myw/tUA=="], + "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.5.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-yqSx3pgJLP521Km+XNNn9fyjbbHppgozuGTtzTln9yy+2I0h0bjqXSpsFPwoSgVb5QcyBx+Wzv5170Qb7va1iQ=="], "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], diff --git a/lib/coinpay-pass.ts b/lib/coinpay-pass.ts new file mode 100644 index 00000000..ac93e9fa --- /dev/null +++ b/lib/coinpay-pass.ts @@ -0,0 +1,61 @@ +// Profullstack Pass (2026-11-07): a user who deposits $99 into their own +// CoinPay wallet gets a free month on every Profullstack property. crawlproof is +// pay-as-you-go, so the pass pays the smallest credit pack once per pass, through +// grant_code_credits() (promo credits: never cash-backed, never paid out to a +// publisher). Keyed on 'coinpay-pass::': a repeat sign-in +// during the same pass never pays twice, a renewal pays again, and a second +// crawlproof account linked to the same CoinPay sub gets nothing. +// +// CoinPay reports the pass on userinfo, which is only seen at sign-in, so the +// callback stores coinpay_identities.pass_until and calls applyCoinpayPass(). +import type { CoinpayPass } from "@profullstack/coinpay-onboard/pass"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { CREDIT_PACKS } from "./credits"; + +export { PASS_URL, passFromUserinfo } from "@profullstack/coinpay-onboard/pass"; + +/** What one pass is worth here: the smallest (cheapest) credit pack. */ +export const PASS_CREDITS = [...CREDIT_PACKS].sort((a, b) => a.amountCents - b.amountCents)[0]!.credits; + +export const passSource = (sub: string, expiresAt: Date) => `coinpay-pass:${sub}:${expiresAt.toISOString()}`; + +export interface PassStore { + setPassUntil(userId: string, sub: string, until: string | null): Promise; + /** applied=false when this source was already paid (to anyone). */ + grantOnce(userId: string, credits: number, source: string): Promise<{ applied: boolean }>; +} + +export async function applyCoinpayPass( + store: PassStore, + userId: string, + sub: string, + pass: CoinpayPass, + now: Date = new Date(), +): Promise { + const active = pass.active && pass.expiresAt !== null && pass.expiresAt.getTime() > now.getTime(); + await store.setPassUntil(userId, sub, active ? pass.expiresAt!.toISOString() : null); + if (!active) return false; + return (await store.grantOnce(userId, PASS_CREDITS, passSource(sub, pass.expiresAt!))).applied; +} + +export function supabasePassStore(sb: SupabaseClient): PassStore { + return { + async setPassUntil(userId, sub, until) { + const { error } = await sb + .from("coinpay_identities") + .update({ pass_until: until }) + .eq("user_id", userId) + .eq("coinpay_sub", sub); + if (error) throw new Error(`pass_until: ${error.message}`); + }, + async grantOnce(userId, credits, source) { + const { data, error } = await sb.rpc("grant_code_credits", { p_user: userId, p_source: source, p_credits: credits }); + if (error) { + // code_credit_grants_one_coinpay_pass_idx: another account linked to the same sub was paid. + if (error.code === "23505" || /duplicate key/i.test(error.message)) return { applied: false }; + throw new Error(`grant_code_credits: ${error.message}`); + } + return { applied: Boolean((data as { applied?: boolean } | null)?.applied) }; + }, + }; +} diff --git a/lib/coinpay-signin.ts b/lib/coinpay-signin.ts index e396af30..62fe580e 100644 --- a/lib/coinpay-signin.ts +++ b/lib/coinpay-signin.ts @@ -17,6 +17,7 @@ // memory; supabaseCoinpayStore() is the real one. import { fetchGlobalCoinpayWallets, type GlobalCoinpayWallet } from "./coinpay-wallet-data"; import { resolveCoinPayAccountLink } from "@profullstack/coinpay-onboard/identity"; +import { passFromUserinfo, type CoinpayPass } from "@profullstack/coinpay-onboard/pass"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { SupabaseClient } from "@supabase/supabase-js"; @@ -119,7 +120,7 @@ export function stateMatches(expected: string | null | undefined, got: string | return a.length === b.length && timingSafeEqual(a, b); } -export type CoinpayClaims = { sub: string; email: string | null; name: string | null; openprofile_url?: string }; +export type CoinpayClaims = { sub: string; email: string | null; name: string | null; openprofile_url?: string; pass?: CoinpayPass }; /** * Check the ID token that came straight back from the token endpoint over TLS @@ -188,6 +189,8 @@ export async function fetchCoinpayClaims( email: typeof info.email === "string" && info.email.includes("@") ? info.email.slice(0, 320) : null, name: typeof info.name === "string" && info.name.trim() ? info.name.trim().slice(0, 200) : null, ...(typeof info.openprofile_url === "string" ? { openprofile_url: info.openprofile_url } : {}), + // Profullstack Pass; clamps a stale or malformed claim to no pass. + pass: passFromUserinfo(info), }, }; } diff --git a/package.json b/package.json index 5343bdce..f4761e0c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@profullstack/ai-providers": "0.1.1", "@profullstack/autoblog": "^0.5.0", "@profullstack/coinpay": "^0.25.0", - "@profullstack/coinpay-onboard": "0.4.0", + "@profullstack/coinpay-onboard": "^0.5.0", "@profullstack/fleetbar": "0.2.0", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.0", diff --git a/supabase/migrations/20261107160000_coinpay_pass.sql b/supabase/migrations/20261107160000_coinpay_pass.sql new file mode 100644 index 00000000..ff244906 --- /dev/null +++ b/supabase/migrations/20261107160000_coinpay_pass.sql @@ -0,0 +1,18 @@ +-- Profullstack Pass (2026-11-07): a CoinPay user who deposits $99 into their +-- own CoinPay wallet gets a free month on every Profullstack property. +-- crawlproof is pay-as-you-go, so a pass pays the smallest credit pack once per +-- pass through grant_code_credits() (source 'coinpay-pass::'), +-- which also adds promo_credits: pass credits are not cash-backed and never pay +-- a publisher. +-- +-- coinpay_identities.pass_until: the pass expiry CoinPay reported at the last +-- sign-in (the claim is only seen then). +-- One grant per pass per CoinPay account, even when several crawlproof accounts +-- link the same sub: code_credit_grants' PK is (user_id, source), so the source +-- alone is made unique for pass grants. Additive only. + +alter table public.coinpay_identities add column if not exists pass_until timestamptz; + +create unique index if not exists code_credit_grants_one_coinpay_pass_idx + on public.code_credit_grants (source) + where source like 'coinpay-pass:%'; diff --git a/tests/coinpay-pass.test.ts b/tests/coinpay-pass.test.ts new file mode 100644 index 00000000..a71fc98d --- /dev/null +++ b/tests/coinpay-pass.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { applyCoinpayPass, PASS_CREDITS, passFromUserinfo, passSource, type PassStore } from "@/lib/coinpay-pass"; +import { CREDIT_PACKS } from "@/lib/credits"; + +function memoryStore() { + const until = new Map(); + const paidSources = new Set(); + const balance = new Map(); + const store: PassStore = { + async setPassUntil(userId, sub, u) { until.set(`${userId}:${sub}`, u); }, + async grantOnce(userId, credits, source) { + // Mirrors code_credit_grants_one_coinpay_pass_idx: one grant per source. + if (paidSources.has(source)) return { applied: false }; + paidSources.add(source); + balance.set(userId, (balance.get(userId) ?? 0) + credits); + return { applied: true }; + }, + }; + return { store, until, balance }; +} + +const now = new Date("2026-11-07T12:00:00Z"); +const until = "2026-12-07T12:00:00.000Z"; +const active = () => passFromUserinfo({ pass: { active: true, expires_at: until } }, now); + +describe("Profullstack Pass credits", () => { + it("is worth the cheapest credit pack", () => { + const cheapest = CREDIT_PACKS.reduce((a, b) => (b.amountCents < a.amountCents ? b : a)); + expect(PASS_CREDITS).toBe(cheapest.credits); + expect(PASS_CREDITS).toBeGreaterThan(0); + expect(passSource("s", new Date(until))).toBe(`coinpay-pass:s:${until}`); + }); + + it("pays once per pass, records pass_until, and pays again on renewal", async () => { + const m = memoryStore(); + expect(await applyCoinpayPass(m.store, "u1", "sub1", active(), now)).toBe(true); + expect(await applyCoinpayPass(m.store, "u1", "sub1", active(), now)).toBe(false); + expect(m.balance.get("u1")).toBe(PASS_CREDITS); + expect(m.until.get("u1:sub1")).toBe(until); + const renewed = passFromUserinfo({ pass: { active: true, expires_at: "2027-01-07T12:00:00.000Z" } }, now); + expect(await applyCoinpayPass(m.store, "u1", "sub1", renewed, now)).toBe(true); + expect(m.balance.get("u1")).toBe(2 * PASS_CREDITS); + }); + + it("a second account linked to the same CoinPay sub gets nothing", async () => { + const m = memoryStore(); + expect(await applyCoinpayPass(m.store, "u1", "sub1", active(), now)).toBe(true); + expect(await applyCoinpayPass(m.store, "u2", "sub1", active(), now)).toBe(false); + expect(m.balance.get("u2")).toBeUndefined(); + }); + + it("pays nothing without an active, unexpired pass", async () => { + const m = memoryStore(); + for (const claims of [{}, { pass: { active: false } }, { pass: { active: true } }, { pass: { active: true, expires_at: "x" } }, { pass: { active: true, expires_at: "2026-10-01T00:00:00Z" } }]) { + expect(await applyCoinpayPass(m.store, "u3", "sub3", passFromUserinfo(claims, now), now)).toBe(false); + } + expect(m.balance.get("u3")).toBeUndefined(); + expect(m.until.get("u3:sub3")).toBeNull(); + }); +}); diff --git a/tests/coinpay-signin.test.ts b/tests/coinpay-signin.test.ts index 70362cca..b3ca410a 100644 --- a/tests/coinpay-signin.test.ts +++ b/tests/coinpay-signin.test.ts @@ -229,9 +229,19 @@ describe("ID token checks", () => { : Response.json({ sub: "s1", email: "a@b.co", name: "Ann" })) as unknown as typeof fetch; expect(await fetchCoinpayClaims(CFG, { code: "c", verifier: "v", nonce: "n1" }, fake)).toEqual({ ok: true, - claims: { sub: "s1", email: "a@b.co", name: "Ann" }, + claims: { sub: "s1", email: "a@b.co", name: "Ann", pass: { active: false, expiresAt: null } }, }); }); + + it("fetchCoinpayClaims carries an active Profullstack Pass from userinfo", async () => { + const expires = new Date(Date.now() + 86_400_000).toISOString(); + const fake = (async (url: string) => + String(url).endsWith("/token") + ? Response.json({ access_token: "at", id_token: jwt(good) }) + : Response.json({ sub: "s1", pass: { active: true, expires_at: expires } })) as unknown as typeof fetch; + const r = await fetchCoinpayClaims(CFG, { code: "c", verifier: "v", nonce: "n1" }, fake); + expect(r.ok && r.claims.pass).toEqual({ active: true, expiresAt: new Date(expires) }); + }); }); describe("placeholder emails", () => {