Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/routes/coinpay.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { grantCoinpayPass } from '@nichedb/payments/coinpay-pass';
import { saveOpenProfileContact } from '@profullstack/profile';
import { getCookie, setCookie } from 'hono/cookie';
import {
Expand Down Expand Up @@ -114,13 +115,23 @@ 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');
if (!user || c.get('viaKey') || String(user.id) !== String(pending.userId))
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 {
Expand All @@ -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);
Expand Down
54 changes: 52 additions & 2 deletions apps/web/src/routes/coinpay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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 });
};

Expand Down Expand Up @@ -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' }),
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/views/premium.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</p>
<p class="muted">
Deposit $99 in your CoinPay wallet and get a free month:{' '}
<a href="https://coinpayportal.com/pass">CoinPay Pass</a>. Sign in with CoinPay to use
it.
</p>
<form method="get" action="/premium#plans" class="row premium-promo">
<input type="hidden" name="term" value={selectedTerm} />
<label class="small" for="promo">
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/payments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"exports": {
".": "./src/index.js",
"./membership": "./src/membership.js",
"./referrals": "./src/referrals.js"
"./referrals": "./src/referrals.js",
"./coinpay-pass": "./src/coinpay-pass.js"
},
"dependencies": {
"@profullstack/coinpay": "^0.25.0",
Expand Down
71 changes: 71 additions & 0 deletions packages/payments/src/coinpay-pass.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Profullstack Pass: a CoinPay user who deposited $99 into their own CoinPay
* wallet gets a free month on every Profullstack property
* (https://coinpayportal.com/pass).
*
* CoinPay reports the pass as a userinfo claim, which this site only sees at
* sign-in. The sign-in callback turns an active pass into a free Pro term from
* now until the pass expires: one `memberships` row, plan 'pro', price 0, keyed
* on grant_ref `coinpay-pass:<coinpay sub>:<expires_at>`. That row IS the
* persisted pass: isProUser / activeMembership already read the furthest-out
* Pro term, and a paid Pro term bought during the pass stacks after it.
*
* Idempotent on the (user_id, grant_ref) partial unique index: signing in ten
* times during one pass inserts one row. Never stacks on a Pro term the member
* already holds: the pass is "Pro until it expires". Data dumps are not part
* of it.
*/

export const PASS_URL = 'https://coinpayportal.com/pass';
export const PASS_GRANT_PREFIX = 'coinpay-pass';
export const PASS_PLAN = 'pro';

/**
* Active only when `active === true` AND the expiry parses to a future date.
* Accepts the raw claim (`{ active, expires_at }`) or the parsed one
* coinpay-onboard finishSignIn returns (`{ active, expiresAt: Date }`).
*/
export function normalizePass(pass, now = new Date()) {
if (!pass || typeof pass !== 'object') return { active: false, expiresAt: null };
const raw = pass.expiresAt ?? pass.expires_at ?? null;
const parsed =
raw instanceof Date ? raw : typeof raw === 'string' && raw.trim() ? new Date(raw) : null;
const expiresAt = parsed && Number.isFinite(parsed.getTime()) ? parsed : null;
const active = pass.active === true && expiresAt !== null && expiresAt.getTime() > now.getTime();
return { active, expiresAt };
}

export function passGrantRef(sub, expiresAt) {
return `${PASS_GRANT_PREFIX}:${sub}:${expiresAt.toISOString()}`;
}

/**
* Grant Pro until the pass expires, once per pass.
*
* @param {(text: string, params: unknown[]) => Promise<object[]>} run
* @returns {Promise<null | { expires_at: unknown, replayed: boolean }>}
*/
export async function grantCoinpayPass(
run,
{ userId, sub, pass, now = new Date(), currency = 'USD' },
) {
if (!userId || !sub) return null;
const p = normalizePass(pass, now);
if (!p.active) return null;
const ref = passGrantRef(sub, p.expiresAt);
const exp = p.expiresAt.toISOString();
const [row] = await run(
`insert into memberships (user_id, payment_id, started_at, expires_at, price_cents, currency, plan, grant_ref)
select $1::uuid, null, now(), $2::timestamptz, 0, $3, $4, $5
where $2::timestamptz > now()
on conflict do nothing
returning expires_at`,
[userId, exp, currency, PASS_PLAN, ref],
);
if (row) return { ...row, replayed: false };
const [existing] = await run(
'select expires_at from memberships where user_id = $1::uuid and grant_ref = $2',
[userId, ref],
);
return existing ? { ...existing, replayed: true } : null;
}
Loading