From 2a46af558224516853f2dbc8a22d0ae103bd5a41 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger 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 ');
+ 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 ) 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: 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( 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(
+
{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.`
+ : ''}
+
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({