From 2a46af558224516853f2dbc8a22d0ae103bd5a41 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 18:34:24 +0000 Subject: [PATCH] feat: credit codes and invites (pass days) A credit code (@profullstack/redeem-credits 0.2.0) grants pass_days; an invite (@profullstack/affiliates 0.3.0) gives both sides 7 days of the pass when a new account is made from the emailed link. Both go through the existing pass path: a $0 crawl_sales row owned by the account, idempotent on its ref. While it runs, accessFor serves the account as a pass on the session, its API keys, the CLI and MCP, and /settings shows the signed cp_ bearer token for it. - /settings: "Have a code?" + pass panel; admin mint/revoke panel - /account/invites (3 per account, from 7 days old), /i/ landing, invite carried on login_tokens.invite_code so another browser counts - bonus withheld for same mailbox (+tag, gmail dots), throwaway domains, same network as the invite's creation, inviter cap 10/30 days - ?ref= affiliate links: first-touch attribution of new accounts - API /api/v1/credits/redeem, /pass, /invites, /admin/credits; MCP redeem_code, list_invites, create_invite; CLI 0.2.0 redeem, pass, invites - scripts/credit-codes.js for minting on the box - migration 0003 (package DDL + crawl_sales.user_id/source, invite tables) Co-Authored-By: Claude Opus 5.5 --- apps/cli/package.json | 2 +- apps/cli/src/index.js | 49 +- apps/web/package.json | 6 +- apps/web/src/app.js | 3 + apps/web/src/lib/credits.js | 460 ++++++++++++++++++ apps/web/src/lib/gate.js | 25 +- apps/web/src/lib/llms.js | 4 + apps/web/src/lib/mcp/tools.js | 40 +- apps/web/src/routes/api.js | 5 + apps/web/src/routes/auth.js | 48 +- apps/web/src/routes/credits.js | 209 ++++++++ apps/web/src/routes/pages.js | 58 ++- apps/web/src/views/account.jsx | 27 +- apps/web/src/views/credits.jsx | 290 +++++++++++ bun.lock | 8 + packages/auth/src/index.js | 8 +- packages/config/src/index.js | 14 + .../0003_credit_codes_and_invites.sql | 157 ++++++ packages/db/src/accounts.js | 111 ++++- scripts/credit-codes.js | 51 ++ test/credits.test.js | 292 +++++++++++ 21 files changed, 1827 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/lib/credits.js create mode 100644 apps/web/src/routes/credits.js create mode 100644 apps/web/src/views/credits.jsx create mode 100644 packages/db/migrations/0003_credit_codes_and_invites.sql create mode 100644 scripts/credit-codes.js create mode 100644 test/credits.test.js diff --git a/apps/cli/package.json b/apps/cli/package.json index 5ba5084..b4dee98 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/r4ck", - "version": "0.1.0", + "version": "0.2.0", "description": "r4ck.dev from the terminal and for agents: search every VPS, bare metal, GPU and PaaS offer for sale, drill down by spec, price and place, and bridge the same tools into any MCP client.", "type": "module", "main": "src/index.js", diff --git a/apps/cli/src/index.js b/apps/cli/src/index.js index fcf7234..14bebc2 100644 --- a/apps/cli/src/index.js +++ b/apps/cli/src/index.js @@ -9,7 +9,7 @@ import { createInterface } from 'node:readline'; * reply verbatim, otherwise a table a person can read. `r4ck mcp` bridges * stdio to the site's MCP endpoint so any client gets every tool. */ -export const VERSION = '0.1.0'; +export const VERSION = '0.2.0'; const DEFAULT_API = process.env.R4CK_API ?? 'https://r4ck.dev'; const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'r4ck'); const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); @@ -54,6 +54,17 @@ export const COMMANDS = [ usage: 'saved [list|add [filters]|rm ]', help: 'Saved searches on the account.', }, + { + name: 'redeem', + usage: 'redeem ', + help: 'Redeem a credit code for days of the pass on your account.', + }, + { name: 'pass', usage: 'pass', help: 'When the pass on your account runs out, and its token.' }, + { + name: 'invites', + usage: 'invites [list|create [note]|revoke ]', + help: 'Your scarce invites (both sides get pass days) and your link.', + }, { name: 'open', usage: 'open ', @@ -441,6 +452,42 @@ export async function run( } throw new Error('usage: r4ck saved [list|add [filters]|rm ]'); } + case 'redeem': { + if (!rest[0]) throw new Error('usage: r4ck redeem '); + const r = await client.post('/api/v1/credits/redeem', { code: rest.join(' ') }); + return flags.json + ? json(r) + : say(`Redeemed: ${r.days} days of pass. Unmetered until ${r.pass_until}.`); + } + case 'pass': { + const r = await client.get('/api/v1/pass'); + if (flags.json) return json(r); + return say( + r.until + ? `pass until ${r.until}${r.token ? `\n${r.header}: ${r.token}` : ''}` + : `no pass on this account (buy one at ${client.base}/crawl or redeem a code)`, + ); + } + case 'invites': { + const [sub = 'list', ...more] = rest; + if (sub === 'list') { + const r = await client.get('/api/v1/invites'); + if (flags.json) return json(r); + say(`${r.quota.left} of ${r.quota.total} invites left; ${r.bonus_days} pass days each`); + if (!r.can_send) say(r.why_not); + for (const i of r.invites) say(`${i.id} ${i.status} ${i.note}`); + return say(`your link: ${r.affiliate.url}`); + } + if (sub === 'create') { + const r = await client.post('/api/v1/invites', { note: more.join(' ') }); + return flags.json ? json(r) : say(r.invite.url); + } + if (sub === 'revoke') { + await client.post(`/api/v1/invites/${encodeURIComponent(more[0] ?? '')}/revoke`); + return say('withdrawn'); + } + throw new Error('usage: r4ck invites [list|create [note]|revoke ]'); + } case 'open': { if (/^\d+$/.test(rest[0] ?? '')) return say(`${client.base}/servers/${rest[0]}`); return say(`${client.base}/servers?${queryFrom(flags, rest.join(' ') || undefined)}`); diff --git a/apps/web/package.json b/apps/web/package.json index f6d9f42..bf3b2f1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,9 +5,12 @@ "type": "module", "main": "src/main.js", "dependencies": { - "@profullstack/footer": "latest", + "@profullstack/affiliates": "0.3.0", "@profullstack/emailer": "^1.0.3", + "@profullstack/footer": "latest", "@profullstack/profile": "0.1.6", + "@profullstack/r4ck": "workspace:*", + "@profullstack/redeem-credits": "0.2.0", "@profullstack/throttle": "^0.3.4", "@profullstack/x402-gateway": "^0.13.1", "@r4ck/auth": "workspace:*", @@ -15,7 +18,6 @@ "@r4ck/core": "workspace:*", "@r4ck/db": "workspace:*", "@r4ck/sync": "workspace:*", - "@profullstack/r4ck": "workspace:*", "@simplewebauthn/browser": "^14.0.0", "hono": "^4.13.14" } diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 36dbce7..9636237 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -3,6 +3,7 @@ import { gate } from './lib/gate.js'; import { Denied, loadUser, render, wantsJson } from './lib/http.js'; import { registerApi } from './routes/api.js'; import { registerAuth } from './routes/auth.js'; +import { registerCredits, rememberRef } from './routes/credits.js'; import { registerMcp } from './routes/mcp.js'; import { registerPages } from './routes/pages.js'; import { registerStatic } from './routes/static.js'; @@ -23,6 +24,7 @@ app.use('*', async (c, next) => { }); app.use('*', gate); app.use('*', loadUser); +app.use('*', rememberRef); app.onError(async (err, c) => { if (err?.redirect) return c.redirect(err.redirect, 303); @@ -52,5 +54,6 @@ app.notFound(async (c) => { registerStatic(app); registerAuth(app); registerApi(app); +registerCredits(app); registerMcp(app); registerPages(app); diff --git a/apps/web/src/lib/credits.js b/apps/web/src/lib/credits.js new file mode 100644 index 0000000..026a5dc --- /dev/null +++ b/apps/web/src/lib/credits.js @@ -0,0 +1,460 @@ +/** + * Credit codes and invites: @profullstack/redeem-credits and + * @profullstack/affiliates over r4ck's own Postgres. + * + * r4ck sells one thing, the x402 pass ($1 a day, unmetered API). Neither + * package keeps a second entitlement: a redeemed code and an invite bonus both + * end in `grantPassDays`, a crawl_sales row (the table every sold pass lands + * in) at $0, owned by the account, with a ref that makes the grant idempotent. + * While it runs, `accessFor` serves the account as a pass on every door + * (session, API key, CLI, MCP), and the settings page hands out the same + * signed bearer token a paid pass is. + * + * - A code grants `pass_days` (minted by an admin on /settings, the API or + * scripts/credit-codes.js). One-off only. + * - An invite is scarce (config.invites.quota per account, single use, from an + * account at least inviterMinAgeDays old). When the invited person's account + * is CREATED by spending the emailed link (which proves the mailbox), both + * get config.invites.bonusDays, unless a spam check withholds it. + * - Affiliate links (/?ref=) record first-touch attribution of new + * accounts. r4ck had no referral programme before, so nothing is counted twice. + */ +import { createHash } from 'node:crypto'; +import { + Affiliates, + postgresStore as affiliatesStore, + hashCode as inviteHash, + normalizeCode as normalizeInvite, +} from '@profullstack/affiliates'; +import { Credits, postgresStore as creditsStore } from '@profullstack/redeem-credits'; +import { config } from '@r4ck/config'; +import { sql } from '@r4ck/db'; +import * as accounts from '@r4ck/db/accounts'; +import { accountPassToken } from './gate.js'; + +export const UNIT = 'pass_days'; +const DAY_MS = 86_400_000; + +/** Bun's SQL client in the `query(text, params) -> { rows }` shape both stores take. */ +export const pgPool = (db = sql) => ({ + query: async (text, params = []) => ({ rows: await db.unsafe(text, params) }), +}); + +/** Days of the pass, through the same table a sold pass is written to. */ +export async function grantPass({ userId, days, ref, source }) { + return accounts.grantPassDays({ + userId, + days, + ref: `grant:${ref}:${userId}`, + source, + passMinutes: config.x402.passMinutes, + }); +} + +/** The account's pass: when it runs out and the bearer token for it. */ +export async function passFor(user) { + const until = await accounts.accountPassUntil(user.id); + return { + until: until?.toISOString() ?? null, + token: until ? await accountPassToken(user.id, until) : null, + header: 'x-crawl-pass', + }; +} + +/* ------------------------------------------------------------ spam rules -- */ + +// Throwaway inboxes: the cheap way to farm a bonus. Not exhaustive; it only has +// to make farming cost more than a week of pass is worth. +const DISPOSABLE_DOMAINS = new Set([ + '10minutemail.com', + '20minutemail.com', + 'anonaddy.me', + 'burnermail.io', + 'dispostable.com', + 'dropmail.me', + 'emailnator.com', + 'emailondeck.com', + 'fakeinbox.com', + 'getairmail.com', + 'getnada.com', + 'guerrillamail.com', + 'guerrillamail.net', + 'guerrillamailblock.com', + 'inboxkitten.com', + 'mail.tm', + 'mailcatch.com', + 'maildrop.cc', + 'mailinator.com', + 'mailnesia.com', + 'mailpoof.com', + 'mintemail.com', + 'moakt.com', + 'mohmal.com', + 'mytemp.email', + 'nada.email', + 'sharklasers.com', + 'spam4.me', + 'spamgourmet.com', + 'temp-mail.io', + 'temp-mail.org', + 'tempail.com', + 'tempmail.com', + 'tempmail.dev', + 'tempmailo.com', + 'tempr.email', + 'throwawaymail.com', + 'tmail.ws', + 'tmpmail.net', + 'tmpmail.org', + 'trashmail.com', + 'trashmail.de', + 'yopmail.com', + 'yopmail.net', +]); + +export const emailDomain = (email) => + String(email ?? '') + .slice(String(email ?? '').lastIndexOf('@') + 1) + .trim() + .toLowerCase(); + +export function isDisposableEmail(email) { + const domain = emailDomain(email); + if (DISPOSABLE_DOMAINS.has(domain)) return true; + for (const d of DISPOSABLE_DOMAINS) if (domain.endsWith(`.${d}`)) return true; + return false; +} + +/** The mailbox an address lands in: plus tags dropped, Gmail dots ignored. */ +export function canonicalMailbox(email) { + const e = String(email ?? ''); + const at = e.lastIndexOf('@'); + if (at < 1) return e.trim().toLowerCase(); + let local = e.slice(0, at).trim().toLowerCase(); + let domain = emailDomain(e); + const plus = local.indexOf('+'); + if (plus > 0) local = local.slice(0, plus); + if (domain === 'gmail.com' || domain === 'googlemail.com') { + local = local.replace(/\./g, ''); + domain = 'gmail.com'; + } + return `${local}@${domain}`; +} + +/** A salted hash of an address: enough to say "same network", never the address. */ +export function ipHash(ip) { + if (!ip || ip === '0.0.0.0') return null; + const salt = config.x402.coinpayKey || 'r4ck-invites'; + return createHash('sha256').update(`${salt}:${ip}`).digest('hex').slice(0, 32); +} + +const ageDays = (at, now = Date.now()) => { + const t = at ? new Date(at).getTime() : Number.NaN; + return Number.isFinite(t) ? (now - t) / DAY_MS : 0; +}; + +/** Whether an account may hand out invites yet. */ +export function canSendInvites(user, now = Date.now()) { + const min = config.invites.inviterMinAgeDays; + if (user?.role === 'admin' || ageDays(user?.created_at, now) >= min) return { ok: true }; + return { + ok: false, + reason: `Invites open up once your account is ${min} days old.`, + }; +} + +/** + * Whether paying the bonus for an accepted invite is safe. The new account has + * already proved its inbox (the emailed link); this catches the cheap farms. + */ +export function bonusVerdict({ + inviterEmail, + inviteeEmail, + inviterIpHash, + inviteeIpHash, + inviterBonuses30d, +}) { + if (canonicalMailbox(inviterEmail) === canonicalMailbox(inviteeEmail)) + return { ok: false, reason: 'same mailbox' }; + if (isDisposableEmail(inviteeEmail)) return { ok: false, reason: 'disposable email' }; + if (inviterIpHash && inviteeIpHash && inviterIpHash === inviteeIpHash) + return { ok: false, reason: 'same network as the inviter' }; + if (inviterBonuses30d >= config.invites.monthlyCap) + return { ok: false, reason: 'inviter monthly cap reached' }; + return { ok: true }; +} + +/* -------------------------------------------------------------- services -- */ + +/** + * The two services, built once per process and lazily (nothing touches the + * database at import time). Tests pass their own through `makeCredits`. + */ +export function makeCredits({ + creditStore, + affiliateStore, + grant = grantPass, + now, + quota = config.invites.quota, + bonusDays = config.invites.bonusDays, + ttlDays = config.invites.ttlDays, +} = {}) { + const credits = new Credits(creditStore, { + ...(now ? { now } : {}), + onGrant: async (userId, g, source) => { + if (g.unit !== UNIT) throw new Error(`r4ck grants ${UNIT}, not ${g.unit}`); + await grant({ userId, days: g.amount, ref: source, source: 'code' }); + }, + }); + const aff = new Affiliates(affiliateStore, { + ...(now ? { now } : {}), + defaultQuota: quota, + inviteTtlMs: ttlDays > 0 ? ttlDays * DAY_MS : null, + }); + return { credits, aff, grant, bonusDays }; +} + +let shared = null; +export function services() { + shared ??= makeCredits({ + creditStore: creditsStore(pgPool()), + affiliateStore: affiliatesStore(pgPool()), + }); + return shared; +} + +/* ---------------------------------------------------------- credit codes -- */ + +export const REDEEM_ERRORS = { + invalid: 'That code is not one we issued. Check it and try again.', + expired: 'That code has expired.', + used_up: 'That code has already been used.', + already_redeemed: 'You have already redeemed that code.', + revoked: 'That code was withdrawn.', +}; + +const intIn = (v, lo, hi) => { + const n = Number(v); + return Number.isInteger(n) && n >= lo && n <= hi ? n : null; +}; +const bad = (message) => Object.assign(new Error(message), { status: 400 }); + +/** Admin: make a code worth `days` of pass. The plaintext is returned once. */ +export async function mintPassCode( + { days, label = '', maxRedemptions = 1, expiresInDays = null, createdBy = null }, + svc = services(), +) { + const d = intIn(days, 1, 3650); + if (!d) throw bad('days must be a whole number from 1 to 3650'); + const max = intIn(maxRedemptions ?? 1, 1, 100_000); + if (!max) throw bad('max must be from 1 to 100000'); + let expiresAt = null; + if (expiresInDays !== null && expiresInDays !== undefined && expiresInDays !== '') { + const e = intIn(expiresInDays, 1, 3650); + if (!e) throw bad('expires must be 1 to 3650 days'); + expiresAt = Date.now() + e * DAY_MS; + } + const minted = await svc.credits.mint({ + grant: { unit: UNIT, amount: d }, + label: String(label ?? '').slice(0, 120), + maxRedemptions: max, + expiresAt, + createdBy: createdBy ?? undefined, + }); + return codeOut(minted, minted.code); +} + +export function codeOut(c, plaintext = undefined) { + return { + ...(plaintext ? { code: plaintext } : {}), + id: c.id, + label: c.label, + days: c.grant.amount, + unit: c.grant.unit, + max_redemptions: c.maxRedemptions, + redemptions: c.redemptions ?? 0, + expires_at: c.expiresAt ? new Date(c.expiresAt).toISOString() : null, + created_at: new Date(c.createdAt).toISOString(), + revoked_at: c.revokedAt ? new Date(c.revokedAt).toISOString() : null, + }; +} + +/** + * Redeem a code for the signed-in person: `{ ok, days, until }`, or + * `{ ok: false, reason, error }` in words a person can act on. + */ +export async function redeemCode(userId, input, svc = services()) { + const code = String(input ?? '').trim(); + if (!code) return { ok: false, reason: 'invalid', error: 'Type the code first.' }; + if (code.length > 64) return { ok: false, reason: 'invalid', error: REDEEM_ERRORS.invalid }; + let r; + try { + r = await svc.credits.redeem(code, String(userId)); + } catch (err) { + // The code is spent but the pass did not save (the redemption stands, per + // redeem-credits). grantPass is idempotent on its ref, so an admin can + // re-run it for 'code:' and still add one term. + console.error('[credits] grant after redeem failed:', err.message); + return { + ok: false, + reason: 'grant_failed', + error: 'The code was accepted but the pass did not save. Write to us and we will add it.', + }; + } + if (!r.ok) return { ok: false, reason: r.reason, error: REDEEM_ERRORS[r.reason] ?? 'No.' }; + const until = await accounts.accountPassUntil(userId); + return { ok: true, days: r.grant.amount, code_id: r.codeId, until: until?.toISOString() ?? null }; +} + +/* --------------------------------------------------------------- invites -- */ + +export const looksLikeInvite = (s) => { + if (typeof s !== 'string' || s.length > 40) return false; + const compact = s.toUpperCase().replace(/[^0-9A-Z]/g, ''); + const body = compact.startsWith('INV') ? compact.slice(3) : compact; + return body.length === 12; +}; +export const canonicalInvite = (s) => normalizeInvite(String(s ?? '')); + +/** What an invite link is, without spending it: for the /i/ page. */ +export async function inviteStatus(code, svc = services()) { + if (!looksLikeInvite(code)) return 'invalid'; + const row = await svc.aff.store.getInviteByHash(inviteHash(code)); + if (!row) return 'invalid'; + if (row.revokedAt !== null) return 'revoked'; + if (row.usedBy !== null) return 'used'; + if (row.expiresAt !== null && Date.now() >= row.expiresAt) return 'expired'; + return 'ok'; +} + +export function inviteOut(i, { plaintext, origin = config.siteUrl } = {}) { + const status = + i.usedBy !== null + ? 'used' + : i.revokedAt + ? 'revoked' + : i.expiresAt !== null && Date.now() >= i.expiresAt + ? 'expired' + : 'open'; + return { + ...(plaintext ? { code: plaintext, url: `${origin}/i/${plaintext}` } : {}), + id: i.id, + note: i.note ?? '', + status, + created_at: new Date(i.createdAt).toISOString(), + expires_at: i.expiresAt ? new Date(i.expiresAt).toISOString() : null, + used_at: i.usedAt ? new Date(i.usedAt).toISOString() : null, + }; +} + +export async function myInvites(user, { origin } = {}, svc = services()) { + const [quota, invites, affCode] = await Promise.all([ + svc.aff.quota(String(user.id)), + svc.aff.invites(String(user.id)), + svc.aff.affiliateCode(String(user.id)), + ]); + const gate = canSendInvites(user); + return { + quota, + can_send: gate.ok, + why_not: gate.ok ? null : gate.reason, + bonus_days: svc.bonusDays, + invites: invites.map((i) => inviteOut(i, { origin })), + affiliate: { code: affCode, url: `${origin ?? config.siteUrl}/?ref=${affCode}` }, + }; +} + +export async function createInvite(user, { note = '', ip = null, origin } = {}, svc = services()) { + const gate = canSendInvites(user); + if (!gate.ok) return { ok: false, error: gate.reason }; + let made; + try { + made = await svc.aff.createInvite(String(user.id), String(note ?? '').slice(0, 80)); + } catch (err) { + if (/no invites left/.test(err.message)) + return { + ok: false, + error: 'No invites left. Each account gets a few; they are meant to be scarce.', + }; + throw err; + } + await accounts + .recordInviteOrigin({ inviteId: made.id, ipHash: ipHash(ip) }) + .catch((err) => console.error('[invites] origin not recorded:', err.message)); + return { ok: true, invite: inviteOut(made, { plaintext: made.code, origin }) }; +} + +/** Withdraw one of MY unused invites. Someone else's id answers like an unknown one. */ +export async function revokeInvite(userId, inviteId, svc = services()) { + const mine = await svc.aff.invites(String(userId)); + const invite = mine.find((i) => i.id === String(inviteId)); + if (!invite) return { ok: false, error: 'No such invite.' }; + if (invite.usedBy !== null) return { ok: false, error: 'That invite was already used.' }; + await svc.aff.revokeInvite(invite.id); + return { ok: true }; +} + +/** + * A NEW account has just been created by spending its emailed link: accept the + * invite it came with, then pay both sides once unless a spam check says no. + * Called only for a created account, never on an ordinary sign-in, so an + * existing member cannot collect bonuses by redeeming invites. + */ +export async function acceptInvite({ user, code, ip = null }, svc = services()) { + if (!code || !looksLikeInvite(code)) return { ok: false, reason: 'invalid' }; + const r = await svc.aff.redeemInvite(code, String(user.id)); + if (!r.ok) return r; + const days = svc.bonusDays; + if (!(days > 0)) return { ...r, days: 0, paid: false }; + const inviter = await accounts.getUserById(r.inviterId); + const verdict = inviter + ? bonusVerdict({ + inviterEmail: inviter.email, + inviteeEmail: user.email, + inviterIpHash: await accounts.inviteOrigin(r.inviteId), + inviteeIpHash: ipHash(ip), + inviterBonuses30d: await accounts.inviterBonusesSince(inviter.id, 30), + }) + : { ok: false, reason: 'inviter is gone' }; + const reason = verdict.ok ? null : verdict.reason; + const sides = [['invitee', String(user.id)]]; + if (inviter) sides.push(['inviter', inviter.id]); + for (const [side, userId] of sides) { + const first = await accounts.decideInviteBonus({ + inviteId: r.inviteId, + side, + userId, + days, + paid: verdict.ok, + reason, + }); + if (first && verdict.ok) + await svc.grant({ userId, days, ref: `invite:${r.inviteId}:${side}`, source: 'invite' }); + } + if (!verdict.ok) console.log(`[invites] bonus withheld for ${r.inviteId}: ${reason}`); + return { ...r, days: verdict.ok ? days : 0, paid: verdict.ok, reason }; +} + +/** First-touch attribution of a new account to an affiliate link (?ref=). */ +export async function attributeReferral({ userId, code }, svc = services()) { + if (!code) return { ok: false }; + return svc.aff.attribute(String(code), String(userId)); +} + +/* ----------------------------------------------------------------- hosts -- */ + +/** + * The origin links are built on, from the request's own host when it is one of + * ours (so a dev box builds links to itself), never localhost in production. + */ +export function originFrom(c) { + const site = new URL(config.siteUrl); + const host = String(c.req.header('x-forwarded-host') ?? c.req.header('host') ?? '') + .split(',')[0] + .trim() + .toLowerCase(); + const apex = site.host.replace(/^www\./, ''); + if (host === apex || host === `www.${apex}`) return `${site.protocol}//${apex}`; + if (!config.isProd && /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host)) return `http://${host}`; + return config.siteUrl; +} diff --git a/apps/web/src/lib/gate.js b/apps/web/src/lib/gate.js index 662eda6..6ee3afd 100644 --- a/apps/web/src/lib/gate.js +++ b/apps/web/src/lib/gate.js @@ -1,5 +1,5 @@ import { createThrottle, memoryStore } from '@profullstack/throttle'; -import { createGateway } from '@profullstack/x402-gateway'; +import { createGateway, mintPass } from '@profullstack/x402-gateway'; import { config } from '@r4ck/config'; import * as accounts from '@r4ck/db/accounts'; import { renderCrawl } from '../views/crawl.jsx'; @@ -28,6 +28,8 @@ export const OPEN_PATHS = [ '/docs', '/docs/', '/.well-known/', + // An invite link is a person's first visit, often unfurled by a chat app first. + '/i/', ]; export const gateway = createGateway({ @@ -102,6 +104,10 @@ export async function accessFor(c) { if (token && (await gateway.verifyPass(token))) return { plan: 'pass', perHour: null, maxRows: 500, bulk: true, bucket: null }; const user = c.get('user'); + // A pass the account holds (a redeemed code, an invite bonus) covers every + // request it makes: the browser session, its API keys, the CLI and MCP. + if (user && (await accounts.accountPassUntil(user.id).catch(() => null))) + return { plan: 'pass', perHour: null, maxRows: 500, bulk: true, bucket: null }; if (user && c.get('viaKey')) return { plan: 'key', @@ -127,6 +133,23 @@ export async function accessFor(c) { }; } +/** + * A bearer pass for the account's running term, the same signed token a paid + * pass is, so it also opens r4ck from a script with no key. Null when no + * signing secret is configured. Checked with the gateway's own verify, so a + * token this mints is one the gateway honours. + */ +export async function accountPassToken(userId, until) { + const secret = gateway.options.secret || gateway.options.coinpay.apiKey; + if (!secret || !until) return null; + const minted = await mintPass({ + secret, + ref: `account:${userId}`, + expiresAt: Math.floor(new Date(until).getTime() / 1000), + }); + return (await gateway.verifyPass(minted.token)) ? minted.token : null; +} + export function callerAddress(c) { const h = c.req.header('x-real-ip') ?? c.req.header('cf-connecting-ip'); if (h) return h.trim(); diff --git a/apps/web/src/lib/llms.js b/apps/web/src/lib/llms.js index b2b1509..7ec7cc0 100644 --- a/apps/web/src/lib/llms.js +++ b/apps/web/src/lib/llms.js @@ -116,5 +116,9 @@ without a browser. \`has=mcp\` finds the ones with an MCP server. Anonymous: 300 requests an hour, 50 rows a page. A free key (\`Authorization: Bearer r4k_…\`, made at ${site}/settings) gives 6,000 an hour, 200 rows and CSV export. An x402 pass (\`${site}/crawl\`, one dollar a day) removes the allowance. Over the limit you get a 402 with the offer, not a 429. + +A credit code (\`CR-…\`) adds days of the pass to an account: \`POST ${site}/api/v1/credits/redeem +{"code": "…"}\` with the key, \`r4ck redeem \`, or the \`redeem_code\` MCP tool. While it runs, +every key on that account is served as a pass. `; } diff --git a/apps/web/src/lib/mcp/tools.js b/apps/web/src/lib/mcp/tools.js index 0574d36..22386fd 100644 --- a/apps/web/src/lib/mcp/tools.js +++ b/apps/web/src/lib/mcp/tools.js @@ -1,5 +1,6 @@ import { config } from '@r4ck/config'; import { ALL_PARAMS, KINDS, SORTS } from '@r4ck/core/facets'; +import * as credits from '../credits.js'; import * as svc from '../service.js'; /** @@ -180,6 +181,41 @@ export const TOOLS = [ schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, run: (args, ctx) => svc.deleteSaved(needUser(ctx), String(args.id ?? '')), }, + { + name: 'redeem_code', + title: 'Redeem a credit code', + description: + 'Redeem a credit code (CR-…) on the caller’s account for days of the pass: no hourly allowance and 500 rows a page on every key, the CLI and MCP while it runs.', + key: true, + schema: { type: 'object', properties: { code: { type: 'string' } }, required: ['code'] }, + run: async (args, ctx) => { + const r = await credits.redeemCode(needUser(ctx).id, args.code); + if (!r.ok) throw toolError(r.error); + return { ok: true, unit: credits.UNIT, days: r.days, pass_until: r.until }; + }, + }, + { + name: 'list_invites', + title: 'My invites', + description: + 'The caller’s invites (quota, status of each) and open affiliate link. Invites are scarce and single use.', + key: true, + schema: { type: 'object', properties: {} }, + run: (_args, ctx) => credits.myInvites(needUser(ctx)), + }, + { + name: 'create_invite', + title: 'Make an invite', + description: + 'Make one invite link from the caller’s quota. The link is returned once; both sides get days of the pass when a new account is made from it.', + key: true, + schema: { type: 'object', properties: { note: { type: 'string' } } }, + run: async (args, ctx) => { + const r = await credits.createInvite(needUser(ctx), { note: args.note }); + if (!r.ok) throw toolError(r.error); + return r.invite; + }, + }, ]; export const TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t])); @@ -199,7 +235,9 @@ export const describe = (t) => ({ description: t.description, inputSchema: t.schema, annotations: { - readOnlyHint: !['save_search', 'delete_saved_search'].includes(t.name), + readOnlyHint: !['save_search', 'delete_saved_search', 'redeem_code', 'create_invite'].includes( + t.name, + ), openWorldHint: false, }, }); diff --git a/apps/web/src/routes/api.js b/apps/web/src/routes/api.js index d643d17..126e71d 100644 --- a/apps/web/src/routes/api.js +++ b/apps/web/src/routes/api.js @@ -3,6 +3,7 @@ import * as auth from '@r4ck/auth'; import { config } from '@r4ck/config'; import { KINDS, SORTS } from '@r4ck/core/facets'; import { currentDaemon, openserverOrigins } from '@r4ck/sync/daemon'; +import { passFor } from '../lib/credits.js'; import { meterApi, pricing } from '../lib/gate.js'; import { Denied, requireUser } from '../lib/http.js'; import { profiles, profileUser } from '../lib/profile.js'; @@ -69,6 +70,9 @@ export function registerApi(app) { '/api/v1/me/profile', '/api/v1/keys', '/api/v1/saved', + '/api/v1/pass', + '/api/v1/credits/redeem', + '/api/v1/invites', ], kinds: KINDS, sorts: SORTS, @@ -179,6 +183,7 @@ export function registerApi(app) { email: user.email, role: user.role, plan: c.get('access').plan, + pass_until: (await passFor(user)).until, keys: keys.map(keyOut), saved: (await svc.listSaved(user)).saved, }); diff --git a/apps/web/src/routes/auth.js b/apps/web/src/routes/auth.js index 17d5b91..28ef001 100644 --- a/apps/web/src/routes/auth.js +++ b/apps/web/src/routes/auth.js @@ -3,12 +3,23 @@ import * as auth from '@r4ck/auth'; import { config } from '@r4ck/config'; import * as accounts from '@r4ck/db/accounts'; import { getCookie, setCookie } from 'hono/cookie'; +import { acceptInvite, attributeReferral, looksLikeInvite } from '../lib/credits.js'; +import { callerAddress } from '../lib/gate.js'; import { render, requireUser, respond } from '../lib/http.js'; import { sendLoginLink } from '../lib/mail.js'; import { profiles } from '../lib/profile.js'; import { SignIn } from '../views/account.jsx'; const PK_COOKIE = 'r4k_pk'; +/** Set by /i/; also carried on the emailed link (login_tokens.invite_code). */ +export const INVITE_COOKIE = 'r4k_invite'; +/** @profullstack/affiliates' REF_COOKIE: set from ?ref= on any page. */ +export const REF_COOKIE = 'referral_code'; +const inviteFrom = (c) => { + const v = getCookie(c, INVITE_COOKIE); + return v && looksLikeInvite(v) ? v : null; +}; +const clearCookie = (c, name) => setCookie(c, name, '', { path: '/', maxAge: 0 }); export function registerAuth(app) { app.get('/login', async (c) => { @@ -19,7 +30,9 @@ export function registerAuth(app) { }); app.get('/signup', async (c) => { if (c.get('user')) return c.redirect('/settings', 303); - return c.html(await render()); + return c.html( + await render(), + ); }); /** Request a link. The answer is identical whether or not the address exists. */ @@ -40,7 +53,13 @@ export function registerAuth(app) { if (json) return c.json({ error: 'Some fields need fixing.', errors: check.errors }, 400); return c.html( await render( - , + , ), 400, ); @@ -52,15 +71,16 @@ export function registerAuth(app) { .trim() .toLowerCase(); const next = String(body.next ?? '/settings'); + const invite = looksLikeInvite(body.invite) ? String(body.invite) : inviteFrom(c); if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { try { - const url = await auth.createLoginLink(email, { next, profile }); + const url = await auth.createLoginLink(email, { next, profile, invite }); await sendLoginLink({ email, url }); } catch (err) { console.error('[auth] link send failed:', err.message); if (!config.mail.enabled) console.log( - `[auth] mail is not configured; the link would have been ${await auth.createLoginLink(email, { next, profile })}`, + `[auth] mail is not configured; the link would have been ${await auth.createLoginLink(email, { next, profile, invite })}`, ); } } @@ -90,7 +110,25 @@ export function registerAuth(app) { email: result.user.email, }).catch((err) => console.error('[auth] signup profile save failed:', err.message)); } - c.header('set-cookie', auth.sessionCookie(result.sessionId)); + if (result.user?.created) { + // An affiliate link (?ref=) seen before sign-up: first touch, new accounts only. + const ref = getCookie(c, REF_COOKIE); + if (ref) + await attributeReferral({ userId: result.user.id, code: ref }).catch((err) => + console.error('[auth] attribution failed:', err.message), + ); + // An invite counts only for an account this link just created: spending + // the link proved the mailbox. The code comes from the link itself first, + // so a link opened in another browser still counts. + const invite = result.invite ?? inviteFrom(c); + if (invite) + await acceptInvite({ user: result.user, code: invite, ip: callerAddress(c) }).catch((err) => + console.error('[auth] invite accept failed:', err.message), + ); + } + if (getCookie(c, REF_COOKIE)) clearCookie(c, REF_COOKIE); + if (getCookie(c, INVITE_COOKIE)) clearCookie(c, INVITE_COOKIE); + c.header('set-cookie', auth.sessionCookie(result.sessionId), { append: true }); const next = c.req.query('next'); return c.redirect(next?.startsWith('/') ? next : '/settings', 303); }); diff --git a/apps/web/src/routes/credits.js b/apps/web/src/routes/credits.js new file mode 100644 index 0000000..cada4b3 --- /dev/null +++ b/apps/web/src/routes/credits.js @@ -0,0 +1,209 @@ +/** + * Credit codes and invites, served: the "Have a code?" redeem endpoint (on + * /settings), /account/invites and its API, the /i/ landing, ?ref= + * affiliate links and the admin's mint. Everything that decides anything is in + * lib/credits.js. + * + * Every write answers on two paths: /api/v1/... for API callers (always JSON, + * see wantsJson) and a twin without the prefix for the page's plain forms, + * which get a 303 (or the page) back. + */ +import { config } from '@r4ck/config'; +import { getCookie, setCookie } from 'hono/cookie'; +import * as lib from '../lib/credits.js'; +import { callerAddress } from '../lib/gate.js'; +import { Denied, render, requireUser, respond, wantsJson } from '../lib/http.js'; +import { InviteLanding, InvitesPage } from '../views/credits.jsx'; +import { INVITE_COOKIE, REF_COOKIE } from './auth.js'; +import { settingsPage } from './pages.js'; + +const requireAdmin = (c) => { + const user = requireUser(c); + if (user.role !== 'admin') throw new Denied('Admins only.', 403); + return user; +}; + +async function readBody(c) { + const ct = c.req.header('content-type') ?? ''; + if (ct.includes('application/json')) return (await c.req.json().catch(() => ({}))) ?? {}; + return c.req.parseBody(); +} + +const cookieOpts = (maxAge) => ({ + path: '/', + maxAge, + sameSite: 'Lax', + httpOnly: true, + secure: config.isProd, +}); + +/** + * ?ref= on any page, before sign-up: remember it for the account the + * emailed link creates (first touch, so an existing cookie is kept). + */ +export async function rememberRef(c, next) { + const ref = c.req.query('ref'); + if ( + ref && + c.req.method === 'GET' && + /^[A-Za-z0-9]{6,16}$/.test(ref) && + !getCookie(c, REF_COOKIE) && + !getCookie(c, config.session.cookie) + ) + setCookie(c, REF_COOKIE, ref.toUpperCase(), cookieOpts(30 * 86400)); + await next(); +} + +export function registerCredits(app, deps = {}) { + const svc = () => deps.services ?? lib.services(); + + /* --------------------------------------------------------- credit codes -- */ + + app.on('POST', ['/api/v1/credits/redeem', '/settings/redeem'], async (c) => { + const user = requireUser(c); + const body = await readBody(c); + const r = await lib.redeemCode(user.id, body.code, svc()); + if (!r.ok) + return respond(c, { + json: { ok: false, reason: r.reason, error: r.error }, + status: r.reason === 'grant_failed' ? 500 : 400, + redirectTo: '/settings#redeem', + error: r.error, + }); + return respond(c, { + json: { ok: true, unit: lib.UNIT, days: r.days, pass_until: r.until }, + redirectTo: '/settings#redeem', + notice: `Redeemed: ${r.days} day${r.days === 1 ? '' : 's'} of pass added. Your account is unmetered until ${new Date(r.until).toUTCString()}.`, + }); + }); + + app.get('/api/v1/pass', async (c) => c.json(await lib.passFor(requireUser(c)))); + + /* ------------------------------------------------------------- invites -- */ + + app.get('/invites', (c) => c.redirect('/account/invites', 302)); + + app.get('/account/invites', async (c) => { + const user = requireUser(c); + const mine = await lib.myInvites(user, { origin: lib.originFrom(c) }, svc()); + c.header('cache-control', 'private, no-store'); + return c.html( + await render( + , + ), + ); + }); + + app.get('/api/v1/invites', async (c) => { + const user = requireUser(c); + return c.json(await lib.myInvites(user, { origin: lib.originFrom(c) }, svc())); + }); + + app.on('POST', ['/api/v1/invites', '/account/invites'], async (c) => { + const user = requireUser(c); + const body = await readBody(c); + const origin = lib.originFrom(c); + const r = await lib.createInvite( + user, + { note: body.note, ip: callerAddress(c), origin }, + svc(), + ); + if (!r.ok) + return respond(c, { + json: { ok: false, error: r.error }, + status: 409, + redirectTo: '/account/invites', + error: r.error, + }); + if (wantsJson(c)) return c.json({ ok: true, invite: r.invite }, 201); + // The plaintext exists only now: render it here, never in a URL. + const mine = await lib.myInvites(user, { origin }, svc()); + c.header('cache-control', 'private, no-store'); + return c.html(await render()); + }); + + app.on('POST', ['/api/v1/invites/:id/revoke', '/account/invites/:id/revoke'], async (c) => { + const user = requireUser(c); + const r = await lib.revokeInvite(user.id, c.req.param('id'), svc()); + if (!r.ok) return respond(c, { status: 404, redirectTo: '/account/invites', error: r.error }); + return respond(c, { + redirectTo: '/account/invites', + notice: 'Invite withdrawn; the slot is yours again.', + }); + }); + + /** An invite link: remember the code (it also rides on the emailed link), then sign up. */ + app.get('/i/:code', async (c) => { + const raw = String(c.req.param('code') ?? '').slice(0, 40); + const status = await lib.inviteStatus(raw, svc()); + const code = status === 'invalid' ? raw.slice(0, 24) : lib.canonicalInvite(raw); + const user = c.get('user'); + if (status === 'ok' && !user) + setCookie(c, INVITE_COOKIE, code, cookieOpts(config.invites.ttlDays * 86400)); + c.header('cache-control', 'private, no-store'); + c.header('x-robots-tag', 'noindex'); + const origin = lib.originFrom(c); + if (wantsJson(c)) + return c.json({ + code, + status, + bonus_days: config.invites.bonusDays, + signup: `${origin}/signup`, + }); + return c.html( + await render(), + status === 'ok' ? 200 : 404, + ); + }); + + /* --------------------------------------------------------------- admin -- */ + + app.get('/api/v1/admin/credits', async (c) => { + requireAdmin(c); + return c.json({ codes: (await svc().credits.list()).map((x) => lib.codeOut(x)) }); + }); + + app.on('POST', ['/api/v1/admin/credits', '/settings/credits'], async (c) => { + const user = requireAdmin(c); + const body = await readBody(c); + let made; + try { + made = await lib.mintPassCode( + { + days: body.days, + label: body.label, + maxRedemptions: body.max || body.max_redemptions || 1, + expiresInDays: body.expires_days ?? body.expires_in_days ?? null, + createdBy: user.email, + }, + svc(), + ); + } catch (err) { + if (err.status === 400) + return respond(c, { status: 400, redirectTo: '/settings#codes', error: err.message }); + throw err; + } + if (wantsJson(c)) return c.json({ ok: true, ...made }, 201); + // Shown once, on the page itself: the plaintext never goes in a URL. + return settingsPage(c, { freshCode: made }); + }); + + app.on( + 'POST', + ['/api/v1/admin/credits/:id/revoke', '/settings/credits/:id/revoke'], + async (c) => { + requireAdmin(c); + try { + await svc().credits.revoke(c.req.param('id')); + } catch { + return respond(c, { status: 404, redirectTo: '/settings#codes', error: 'No such code.' }); + } + return respond(c, { redirectTo: '/settings#codes', notice: 'Code revoked.' }); + }, + ); +} diff --git a/apps/web/src/routes/pages.js b/apps/web/src/routes/pages.js index 8836b1e..45df8c0 100644 --- a/apps/web/src/routes/pages.js +++ b/apps/web/src/routes/pages.js @@ -2,6 +2,7 @@ import { validateProfile } from '@profullstack/profile'; import * as auth from '@r4ck/auth'; import { config } from '@r4ck/config'; import * as accounts from '@r4ck/db/accounts'; +import * as credits from '../lib/credits.js'; import { accessFor } from '../lib/gate.js'; import { Denied, notice, render, requireUser, respond, wantsJson } from '../lib/http.js'; import { profiles } from '../lib/profile.js'; @@ -13,6 +14,40 @@ import { Home } from '../views/home.jsx'; import { Provider, Providers } from '../views/providers.jsx'; import { Compare, Server, Servers } from '../views/servers.jsx'; +/** + * The account page. `extra.freshCode` is a credit code an admin just minted, + * shown once on the page that answers the form, never put in a URL. + */ +export async function settingsPage(c, extra = {}) { + const user = requireUser(c); + const admin = user.role === 'admin'; + const [keys, passkeys, saved, contact, pass, codes] = await Promise.all([ + auth.listApiKeys(user.id), + accounts.listPasskeys(user.id), + svc.listSaved(user), + profiles.get(user.id), + credits.passFor(user), + admin ? credits.services().credits.list() : Promise.resolve(null), + ]); + c.header('cache-control', 'private, no-store'); + return c.html( + await render( + credits.codeOut(x)) : null} + freshCode={extra.freshCode ?? null} + {...notice(c)} + />, + ), + ); +} + const raw = (c) => { const out = {}; for (const [k, v] of new URL(c.req.url).searchParams) @@ -105,28 +140,7 @@ export function registerPages(app) { app.get('/docs/cli', async (c) => c.html(await render())); /* ------------------------------------------------------------ account -- */ - app.get('/settings', async (c) => { - const user = requireUser(c); - const [keys, passkeys, saved, contact] = await Promise.all([ - auth.listApiKeys(user.id), - accounts.listPasskeys(user.id), - svc.listSaved(user), - profiles.get(user.id), - ]); - return c.html( - await render( - , - ), - ); - }); + app.get('/settings', (c) => settingsPage(c)); // The shared profile (names, company, title, LinkedIn), for a page without // script; /api/v1/me/profile is the same thing for API callers. Not under // /api/, where every answer is JSON, so a plain form post gets its redirect. diff --git a/apps/web/src/views/account.jsx b/apps/web/src/views/account.jsx index b2c3a5d..15242a1 100644 --- a/apps/web/src/views/account.jsx +++ b/apps/web/src/views/account.jsx @@ -1,6 +1,7 @@ import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile'; import { config } from '@r4ck/config'; import { raw } from 'hono/html'; +import { CodesPanel, PassPanel } from './credits.jsx'; import { ago } from './home.jsx'; import { Layout } from './Layout.jsx'; @@ -19,6 +20,7 @@ export function SignIn({ user = null, values = null, errors = null, + invite = null, }) { const signup = mode === 'signup'; return ( @@ -36,6 +38,14 @@ export function SignIn({ : 'A link by email, or a passkey if you have one here. No passwords.'}

{error ?

{error}

: null} + {signup && invite && !sent ? ( +

+ You were invited ({invite}). + {config.invites.bonusDays > 0 + ? ` Make your account from the emailed link and you both get ${config.invites.bonusDays} days of the pass.` + : ''} +

+ ) : null} {sent ? (

If that address can receive mail, a sign-in link is on its way. It works once and @@ -46,6 +56,7 @@ export function SignIn({

{signup ? : null} + {signup && invite ? : null}