diff --git a/app/api/v1/coinpay/oauth/callback/route.ts b/app/api/v1/coinpay/oauth/callback/route.ts
index 418d807d..7990e4b8 100644
--- a/app/api/v1/coinpay/oauth/callback/route.ts
+++ b/app/api/v1/coinpay/oauth/callback/route.ts
@@ -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";
@@ -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"); }
diff --git a/bun.lock b/bun.lock
index dd6fd997..8f93a8a3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,7 +13,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",
@@ -323,7 +323,7 @@
"@profullstack/coinpay": ["@profullstack/coinpay@0.25.0", "", { "dependencies": { "@inquirer/prompts": "^8.3.0", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@profullstack/hqtui": "^0.6.3", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0" }, "bin": { "coinpay": "bin/coinpay.js" } }, "sha512-ZoK8COqBCdJXvr0fO5UWw9HPh88CdMBLycc/dU6kXwAyeIrwZ4WkBApisuDgs4leXNN+SMjwex2etgrEvlICTQ=="],
- "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.4.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-L2P0fxNswZnXt1tvo6t19L/RrJN204vrWnIEY65QI+zriZN7JJPY4Ap6Nv8MLkyA5wDvY88s0+Dgjf9Myw/tUA=="],
+ "@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/coinpay-pass.ts b/lib/coinpay-pass.ts
new file mode 100644
index 00000000..ac93e9fa
--- /dev/null
+++ b/lib/coinpay-pass.ts
@@ -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::': 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;
+ /** 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 {
+ 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) };
+ },
+ };
+}
diff --git a/lib/coinpay-signin.ts b/lib/coinpay-signin.ts
index e396af30..62fe580e 100644
--- a/lib/coinpay-signin.ts
+++ b/lib/coinpay-signin.ts
@@ -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";
@@ -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
@@ -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),
},
};
}
diff --git a/package.json b/package.json
index 5343bdce..f4761e0c 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/supabase/migrations/20261107160000_coinpay_pass.sql b/supabase/migrations/20261107160000_coinpay_pass.sql
new file mode 100644
index 00000000..ff244906
--- /dev/null
+++ b/supabase/migrations/20261107160000_coinpay_pass.sql
@@ -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::'),
+-- 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:%';
diff --git a/tests/coinpay-pass.test.ts b/tests/coinpay-pass.test.ts
new file mode 100644
index 00000000..a71fc98d
--- /dev/null
+++ b/tests/coinpay-pass.test.ts
@@ -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();
+ const paidSources = new Set();
+ const balance = new Map();
+ 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();
+ });
+});
diff --git a/tests/coinpay-signin.test.ts b/tests/coinpay-signin.test.ts
index 70362cca..b3ca410a 100644
--- a/tests/coinpay-signin.test.ts
+++ b/tests/coinpay-signin.test.ts
@@ -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", () => {