From ea37b6274a3acddac61c113f18789c7067ffad8c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 16:14:29 +0000 Subject: [PATCH] feat(pass): Profullstack Pass turns on the paid plan until it ends CoinPay sign-in (and Connect CoinPay) reads the userinfo `pass` claim (coinpay-onboard ^0.5.0) and stores its end on accounts.coinpay_pass_until for the account bound to the CoinPay sub. paidUntil() is the later of the granted days and the pass, so hasPaidPlan() is on until the pass ends without using up granted days. /invites links the CoinPay Pass. Co-Authored-By: Claude Opus 5.5 --- app/auth/coinpay/callback/route.ts | 9 +++++- app/invites/page.tsx | 1 + bun.lock | 4 +-- lib/db.ts | 2 ++ lib/plan.ts | 44 +++++++++++++++++++++++++- package.json | 2 +- tests/coinpay-pass.test.mjs | 51 ++++++++++++++++++++++++++++++ 7 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 tests/coinpay-pass.test.mjs diff --git a/app/auth/coinpay/callback/route.ts b/app/auth/coinpay/callback/route.ts index 085da7e..5856d4d 100644 --- a/app/auth/coinpay/callback/route.ts +++ b/app/auth/coinpay/callback/route.ts @@ -4,6 +4,8 @@ import { finishCoinpaySignIn, stateMatches, PENDING_COOKIES } from "@/lib/oauth" import { requestHosts, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin"; import { upsertUser, accountIdForCoinpay, linkCoinpay } from "@/lib/db"; import { acceptInviteAndNotify, INVITE_COOKIE } from "@/lib/credits"; +import { recordCoinpayPass } from "@/lib/plan"; +import type { CoinpayPass } from "@profullstack/coinpay-onboard/pass"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -39,7 +41,7 @@ export async function GET(req: NextRequest) { // byte -- the callback arrives on whatever host that step named. const origin = resolveOrigin(requestHosts(req.headers), requestIsHttps(req)); - let info: { sub: string; email: string | null; name: string | null }; + let info: { sub: string; email: string | null; name: string | null; pass?: CoinpayPass }; try { info = await finishCoinpaySignIn({ code, verifier, nonce, redirectUri: redirectUriFor(origin) }); } catch (err: any) { @@ -53,6 +55,7 @@ export async function GET(req: NextRequest) { const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); if (!s?.sub?.startsWith("acct:")) return fail("sign in to your moshcoding account first"); const r = await linkCoinpay(s.sub.slice("acct:".length), info.sub); + if (r.ok) await recordCoinpayPass(s.sub.slice("acct:".length), info.pass).catch((err) => console.error("[auth] pass:", err?.message)); const res = NextResponse.redirect(`${origin}/dashboard?coinpay=${r.ok ? "linked" : r.reason}`); clearPending(res); return res; @@ -61,6 +64,10 @@ export async function GET(req: NextRequest) { await upsertUser({ sub: info.sub, email: info.email, name: info.name }); // Bound by sub; a first sign-in gets its own account (never one found by email). const accountId = await accountIdForCoinpay(info.sub); + // Profullstack Pass: the paid plan until it ends. Never blocks the sign-in. + if (accountId) { + await recordCoinpayPass(accountId, info.pass).catch((err) => console.error("[auth] pass:", err?.message)); + } // Arrived through an invite link (/i/ set the cookie): accept it on // the account this CoinPay identity is bound to. Never blocks the sign-in. diff --git a/app/invites/page.tsx b/app/invites/page.tsx index 53fcbe1..0023d53 100644 --- a/app/invites/page.tsx +++ b/app/invites/page.tsx @@ -134,6 +134,7 @@ export default function InvitesPage() { {plan && {plan.plan === "paid" ? `paid plan${plan.paid_until ? ` ยท until ${day(plan.paid_until)}` : ""}` : "free plan"}}

A credit code adds days of the paid affiliate plan ($1/mo): set your own commission rate instead of the 80% floor.

+

Deposit $99 in your CoinPay wallet and get a free month: CoinPay Pass

setCode(e.target.value)} autoComplete="off" /> diff --git a/bun.lock b/bun.lock index 820c3af..f2136a8 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "moshcoding", "dependencies": { "@profullstack/affiliates": "0.3.0", - "@profullstack/coinpay-onboard": "0.3.1", + "@profullstack/coinpay-onboard": "^0.5.0", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/player": "0.17.0", @@ -141,7 +141,7 @@ "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], - "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.3.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ=="], + "@profullstack/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/db.ts b/lib/db.ts index 8fbf1cf..cb761be 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -232,6 +232,8 @@ async function initSchema(): Promise { // config added via ALTER for accounts tables created before it existed. await addColumnIfMissing("accounts", "config", "TEXT NOT NULL DEFAULT '{}'"); await addColumnIfMissing("accounts", "is_admin", "INTEGER NOT NULL DEFAULT 0"); + // Profullstack Pass end (ISO), stored at CoinPay sign-in; see lib/plan.ts. + await addColumnIfMissing("accounts", "coinpay_pass_until", "TEXT"); // The CoinPay identity (OIDC `sub`) an account is bound to. CoinPay does not // verify email addresses (its userinfo always says email_verified=false), so // a CoinPay sign-in must never be matched to an account by email alone once diff --git a/lib/plan.ts b/lib/plan.ts index 473cf4a..04aa9b5 100644 --- a/lib/plan.ts +++ b/lib/plan.ts @@ -7,9 +7,12 @@ // future $1/mo checkout should call grantPaidDays() with its payment id as the // source, so paid and granted time share one path. +import type { CoinpayPass } from "@profullstack/coinpay-onboard/pass"; import { db, ensureSchema, enrollAffiliate } from "./db"; import { sqlTime } from "./sql-time"; +export { PASS_URL } from "@profullstack/coinpay-onboard/pass"; + const DAY_MS = 86_400_000; /** A stored timestamp (SQLite text "YYYY-MM-DD HH:MM:SS", or Postgres) in ms. */ @@ -36,13 +39,52 @@ export function paidUntilFromGrants(grants: { days: number; granted_at: unknown return end; } +/** The later of the granted run and a Profullstack Pass end. */ +export function laterOf(granted: number | null, pass: number | null): number | null { + const g = granted !== null && Number.isFinite(granted) ? granted : null; + const p = pass !== null && Number.isFinite(pass) ? pass : null; + if (g === null) return p; + if (p === null) return g; + return Math.max(g, p); +} + export async function paidUntil(accountId: string): Promise { await ensureSchema(); const res = await db().execute({ sql: `SELECT days, granted_at FROM plan_grants WHERE account_id = ?`, args: [accountId], }); - return paidUntilFromGrants(res.rows as any); + const acct = await db().execute({ sql: `SELECT coinpay_pass_until FROM accounts WHERE id = ?`, args: [accountId] }); + const pass = toMs(acct.rows[0]?.coinpay_pass_until); + return laterOf(paidUntilFromGrants(res.rows as any), Number.isFinite(pass) ? pass : null); +} + +/** + * Profullstack Pass (https://coinpayportal.com/pass): a CoinPay user who + * deposited $99 into their own CoinPay wallet gets a free month of the paid + * plan on every Profullstack property. CoinPay reports it only in the userinfo + * `pass` claim at sign-in, so the callback stores its end on the account bound + * to the CoinPay `sub` (accountIdForCoinpay, never by email). paidUntil() + * takes the later of it and the granted days, so the pass never uses them up. + */ +export function passUntilToStore(pass: CoinpayPass | null | undefined, now = Date.now()): string | null { + if (!pass?.active || !pass.expiresAt) return null; + const t = pass.expiresAt.getTime(); + return Number.isFinite(t) && t > now ? pass.expiresAt.toISOString() : null; +} + +/** Stores an active pass on the account; never shortens a later one. True when it wrote. */ +export async function recordCoinpayPass(accountId: string, pass: CoinpayPass | null | undefined, now = Date.now()): Promise { + const until = passUntilToStore(pass, now); + if (!accountId || !until) return false; + await ensureSchema(); + const cur = await db().execute({ sql: `SELECT coinpay_pass_until FROM accounts WHERE id = ?`, args: [accountId] }); + const had = toMs(cur.rows[0]?.coinpay_pass_until); + if (Number.isFinite(had) && had >= Date.parse(until)) return false; + await db().execute({ sql: `UPDATE accounts SET coinpay_pass_until = ? WHERE id = ?`, args: [until, accountId] }); + // The paid plan is the affiliate plan, so enroll like grantPaidDays() does. + await enrollAffiliate(accountId); + return true; } /** diff --git a/package.json b/package.json index 3d997c8..beb1a4f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@profullstack/affiliates": "0.3.0", - "@profullstack/coinpay-onboard": "0.3.1", + "@profullstack/coinpay-onboard": "^0.5.0", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/player": "0.17.0", diff --git a/tests/coinpay-pass.test.mjs b/tests/coinpay-pass.test.mjs new file mode 100644 index 0000000..bfb447b --- /dev/null +++ b/tests/coinpay-pass.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +process.env.TURSO_DATABASE_URL = "file::memory:"; +delete process.env.DATABASE_URL; +process.env.SESSION_SECRET = "test-secret"; + +const dbm = await import("../lib/db.ts"); +const plan = await import("../lib/plan.ts"); +const { passFromUserinfo } = await import("@profullstack/coinpay-onboard/pass"); +await dbm.ensureSchema(); + +const DAY = 86_400_000; +const inDays = (d) => new Date(Date.now() + d * DAY).toISOString(); +const claim = (pass) => passFromUserinfo({ pass }); + +test("only an active pass with a future end is stored", () => { + const end = inDays(30); + assert.equal(plan.passUntilToStore(claim({ active: true, expires_at: end })), end); + assert.equal(plan.passUntilToStore(claim({ active: false, expires_at: end })), null); + assert.equal(plan.passUntilToStore(claim({ active: true, expires_at: "nope" })), null); + assert.equal(plan.passUntilToStore(claim({ active: true, expires_at: inDays(-1) })), null); + assert.equal(plan.passUntilToStore(undefined), null); +}); + +test("a pass turns on the paid plan for the sub-bound account until it ends", async () => { + const id = await dbm.accountIdForCoinpay("pass-sub-1"); + assert.equal(await plan.hasPaidPlan(id, "free"), false); + const end = inDays(30); + assert.equal(await plan.recordCoinpayPass(id, claim({ active: true, expires_at: end })), true); + assert.equal(await plan.hasPaidPlan(id, "free"), true); + assert.equal(await plan.paidUntil(id), Date.parse(end)); + assert.equal(await plan.hasPaidPlan(id, "free", Date.parse(end) + 1), false); + assert.ok(await dbm.getAffiliate(id), "enrolled as an affiliate"); + // Idempotent, and a shorter claim never shortens the stored pass. + assert.equal(await plan.recordCoinpayPass(id, claim({ active: true, expires_at: end })), false); + assert.equal(await plan.recordCoinpayPass(id, claim({ active: true, expires_at: inDays(5) })), false); + assert.equal(await plan.paidUntil(id), Date.parse(end)); +}); + +test("granted days that run longer than the pass win, and are not used up by it", async () => { + const id = await dbm.accountIdForCoinpay("pass-sub-2"); + await plan.grantPaidDays(id, 60, "code:test"); + const granted = await plan.paidUntil(id); + await plan.recordCoinpayPass(id, claim({ active: true, expires_at: inDays(30) })); + assert.equal(await plan.paidUntil(id), granted); + assert.equal(plan.laterOf(null, null), null); + assert.equal(plan.laterOf(5, null), 5); + assert.equal(plan.laterOf(null, 7), 7); + assert.equal(plan.laterOf(5, 7), 7); +});