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. +