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
6 changes: 6 additions & 0 deletions app/(marketing)/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
import { PASS_CREDITS } from "@/lib/coinpay-pass";
import {
ANON_DAILY_SCANS,
CREDIT_PACKS,
Expand Down Expand Up @@ -72,6 +73,11 @@ export default function PricingPage() {
<p className="mx-auto mt-2 max-w-2xl text-center text-sm text-[var(--color-muted)]">
One balance across the whole platform. No module has its own plan.
</p>
<p className="mx-auto mt-2 max-w-2xl text-center text-sm text-[var(--color-muted)]">
Deposit $99 in your CoinPay wallet and get a free month:{" "}
<a href="https://coinpayportal.com/pass" className="underline">CoinPay Pass</a>{" "}
({PASS_CREDITS} free credits when you sign in with CoinPay).
</p>
<div className="mx-auto mt-6 grid max-w-4xl gap-4 sm:grid-cols-3">
<div className="card p-5">
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
Expand Down
11 changes: 11 additions & 0 deletions app/api/v1/coinpay/oauth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"); }
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.

61 changes: 61 additions & 0 deletions lib/coinpay-pass.ts
Original file line number Diff line number Diff line change
@@ -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:<sub>:<expires_at>': 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<void>;
/** 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<boolean> {
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) };
},
};
}
5 changes: 4 additions & 1 deletion lib/coinpay-signin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
},
};
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions supabase/migrations/20261107160000_coinpay_pass.sql
Original file line number Diff line number Diff line change
@@ -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:<sub>:<expires_at>'),
-- 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:%';
60 changes: 60 additions & 0 deletions tests/coinpay-pass.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | null>();
const paidSources = new Set<string>();
const balance = new Map<string, number>();
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();
});
});
12 changes: 11 additions & 1 deletion tests/coinpay-signin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down