From acd28582eb17d85f19a3d70c8c29ad68f7a1f844 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Sat, 7 Nov 2026 16:13:10 +0000
Subject: [PATCH] feat(pass): Profullstack Pass grants Pro until it expires
CoinPay userinfo carries a `pass` claim (coinpay-onboard 0.5.0 finishSignIn
returns it parsed). On CoinPay sign-in an active pass becomes a free Pro term
from now until pass.expires_at: a memberships row (plan pro, price 0,
grant_ref coinpay-pass::), idempotent on the
(user_id, grant_ref) index. isProUser already reads it, and a paid Pro term
bought during the pass stacks after it. Bound to the CoinPay sub only.
/premium gets one line pointing at https://coinpayportal.com/pass.
Co-Authored-By: Claude Opus 5.5
---
apps/web/package.json | 2 +-
apps/web/src/routes/coinpay.js | 12 +++++
apps/web/src/routes/coinpay.test.js | 54 +++++++++++++++++++-
apps/web/src/views/premium.jsx | 5 ++
bun.lock | 4 +-
packages/payments/package.json | 3 +-
packages/payments/src/coinpay-pass.js | 71 +++++++++++++++++++++++++++
7 files changed, 145 insertions(+), 6 deletions(-)
create mode 100644 packages/payments/src/coinpay-pass.js
diff --git a/apps/web/package.json b/apps/web/package.json
index ec3ac8e..a087ab0 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -22,7 +22,7 @@
"@nichedb/premium": "workspace:*",
"@nichedb/queue": "workspace:*",
"@profullstack/affiliates": "^0.2.0",
- "@profullstack/coinpay-onboard": "0.3.1",
+ "@profullstack/coinpay-onboard": "^0.5.0",
"@profullstack/footer": "latest",
"@profullstack/leaderboard": "^0.3.1",
"@profullstack/nichedb": "workspace:*",
diff --git a/apps/web/src/routes/coinpay.js b/apps/web/src/routes/coinpay.js
index 7b13add..52e22c5 100644
--- a/apps/web/src/routes/coinpay.js
+++ b/apps/web/src/routes/coinpay.js
@@ -1,3 +1,4 @@
+import { grantCoinpayPass } from '@nichedb/payments/coinpay-pass';
import { saveOpenProfileContact } from '@profullstack/profile';
import { getCookie, setCookie } from 'hono/cookie';
import {
@@ -114,6 +115,15 @@ export function registerCoinpay(app, overrides = null) {
return c.text('CoinPay sign-in could not be verified.', 400);
}
const store = identityStore(d.run);
+ // Profullstack Pass: the claim is only seen here, so an active pass becomes
+ // a free Pro term until it expires (once per pass, bound to the CoinPay sub).
+ const grantPass = async (userId) => {
+ try {
+ await grantCoinpayPass(d.run, { userId: String(userId), sub: who.sub, pass: who.pass });
+ } catch (err) {
+ console.error('[coinpay] pass grant failed:', err instanceof Error ? err.message : err);
+ }
+ };
if (pending.mode === 'connect') {
const user = c.get('user');
@@ -121,6 +131,7 @@ export function registerCoinpay(app, overrides = null) {
return c.redirect(back('/login', 'Sign in first, then connect CoinPay.'), 303);
const r = await store.link(user.id, { sub: who.sub, email: who.email });
if (r.ok) {
+ await grantPass(user.id);
const openprofileUrl = who.userinfo?.openprofile_url;
if (typeof openprofileUrl === 'string') {
try {
@@ -143,6 +154,7 @@ export function registerCoinpay(app, overrides = null) {
}
const { userId } = await store.signIn({ sub: who.sub, email: who.email, name: who.name });
+ await grantPass(userId);
const sid = await d.startSession(userId, c.req.header('user-agent'));
c.header('set-cookie', d.sessionCookie(sid), { append: true });
return c.redirect(pending.next || '/following', 303);
diff --git a/apps/web/src/routes/coinpay.test.js b/apps/web/src/routes/coinpay.test.js
index 86cdc19..fb695ff 100644
--- a/apps/web/src/routes/coinpay.test.js
+++ b/apps/web/src/routes/coinpay.test.js
@@ -42,7 +42,7 @@ const addUser = async (email) =>
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const fakeCoinpay =
- ({ sub, email, nonce }) =>
+ ({ sub, email, nonce, pass }) =>
async (url) => {
const u = String(url);
if (u.endsWith('/api/oauth/token'))
@@ -53,7 +53,7 @@ const fakeCoinpay =
id_token: `${b64({ alg: 'HS256' })}.${b64({ iss: 'https://coinpayportal.com', aud: 'cp_test_client', sub, nonce, exp: Math.floor(Date.now() / 1000) + 600 })}.sig`,
});
if (u.endsWith('/api/oauth/userinfo'))
- return Response.json({ sub, email, email_verified: false });
+ return Response.json({ sub, email, email_verified: false, ...(pass ? { pass } : {}) });
return new Response('{}', { status: 404 });
};
@@ -146,6 +146,56 @@ describe('Sign in with CoinPay', () => {
expect(again).toEqual({ userId: sessions[0], created: false });
});
+ test('an active Profullstack Pass makes the account Pro until it expires, once', async () => {
+ const expires = new Date(Date.now() + 20 * 86_400_000).toISOString();
+ const signIn = async (pass) => {
+ const coinpay = {};
+ const { app, sessions } = appWith({ coinpay });
+ const { loc, cookie } = await start(app);
+ coinpay.fetch = fakeCoinpay({
+ sub: 'pass-sub',
+ email: 'pass@example.com',
+ nonce: loc.searchParams.get('nonce'),
+ pass,
+ });
+ await app.request(
+ `/api/v1/coinpay/oauth/callback?code=c&state=${loc.searchParams.get('state')}`,
+ { headers: { cookie } },
+ );
+ return sessions[0];
+ };
+ const userId = await signIn({ active: true, expires_at: expires });
+ await signIn({ active: true, expires_at: expires });
+ const rows = await run(
+ 'select plan, price_cents, expires_at, grant_ref from memberships where user_id = $1',
+ [userId],
+ );
+ expect(rows.length).toBe(1);
+ expect(rows[0].plan).toBe('pro');
+ expect(rows[0].price_cents).toBe(0);
+ expect(new Date(rows[0].expires_at).toISOString()).toBe(expires);
+ expect(rows[0].grant_ref).toBe(`coinpay-pass:pass-sub:${expires}`);
+ });
+
+ test('an inactive or expired pass grants nothing', async () => {
+ for (const [sub, pass] of [
+ ['nopass-a', { active: false }],
+ ['nopass-b', { active: true, expires_at: new Date(Date.now() - 1000).toISOString() }],
+ ['nopass-c', { active: true }],
+ ]) {
+ const coinpay = {};
+ const { app, sessions } = appWith({ coinpay });
+ const { loc, cookie } = await start(app);
+ coinpay.fetch = fakeCoinpay({ sub, email: null, nonce: loc.searchParams.get('nonce'), pass });
+ await app.request(
+ `/api/v1/coinpay/oauth/callback?code=c&state=${loc.searchParams.get('state')}`,
+ { headers: { cookie } },
+ );
+ const rows = await run('select 1 from memberships where user_id = $1', [sessions[0]]);
+ expect(rows.length).toBe(0);
+ }
+ });
+
test('a forged state is refused and nothing is created', async () => {
const coinpay = {
fetch: fakeCoinpay({ sub: 'forged-sub', email: 'f@example.com', nonce: 'n' }),
diff --git a/apps/web/src/views/premium.jsx b/apps/web/src/views/premium.jsx
index 44e11d2..a2094ad 100644
--- a/apps/web/src/views/premium.jsx
+++ b/apps/web/src/views/premium.jsx
@@ -298,6 +298,11 @@ export const PremiumPage = ({
The same Premium benefits in every term. Prices in {config.premium.currency}; paid
upfront through CoinPay in any major coin or PayPal.
+
+ Deposit $99 in your CoinPay wallet and get a free month:{' '}
+ CoinPay Pass. Sign in with CoinPay to use
+ it.
+