diff --git a/apps/cli/package.json b/apps/cli/package.json index a5b76181..470aa950 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/nichedb", - "version": "0.50.0", + "version": "0.51.0", "description": "CLI and MCP bridge for NicheDB: browse collections, manage sources and feeds, search items, from any deployment", "type": "module", "main": "src/index.js", diff --git a/apps/cli/src/index.js b/apps/cli/src/index.js index 7f8aaf7c..4429acf9 100644 --- a/apps/cli/src/index.js +++ b/apps/cli/src/index.js @@ -15,7 +15,7 @@ import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; -export const VERSION = '0.50.0'; +export const VERSION = '0.51.0'; const GEO_FLAGS = ['lat', 'long', 'radius', 'unit', 'bbox', 'sort', 'offset']; const DEFAULT_API = process.env.NICHEDB_API ?? 'https://nichedb.dev'; const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'nichedb'); @@ -321,6 +321,26 @@ export const COMMANDS = [ summary: 'Take a handle: your URL becomes /c/profiles/ (key required).', options: [], }, + { + name: 'redeem', + usage: 'redeem ', + summary: 'Redeem a credit code for days of Premium (key required).', + options: ['--json'], + }, + { + name: 'invites', + usage: 'invites [create [--note …] | revoke ]', + summary: + 'Your invites: how many are left, and each one. create makes one (its link is shown once); revoke withdraws an unused one (key required).', + options: ['--note ', '--json'], + }, + { + name: 'credits mint', + usage: 'credits mint --days [--label …] [--max ] [--expires ]', + summary: + 'Admin: mint a code worth n days of Premium. The code is shown once. Also credits list|revoke .', + options: ['--days ', '--label ', '--max ', '--expires ', '--json'], + }, { name: 'login', usage: 'login [--api ] [--key ]', @@ -1281,6 +1301,71 @@ export async function run( out(await client.text(`/api/v1/profiles/${encodeURIComponent(sub)}/openprofile.md`)); return 0; } + case 'redeem': { + const code = rest.join(' ').trim(); + if (!code) throw new Error('redeem '); + const r = await client.post('/api/v1/credits/redeem', { code }); + out(json ? JSON.stringify(r, null, 2) : `Redeemed: ${r.days} days of Premium added.`); + return 0; + } + case 'invites': { + const [sub, arg] = rest; + if (sub === 'create') { + const r = await client.post('/api/v1/invites', { note: flags.note ?? '' }); + out(json ? JSON.stringify(r.invite, null, 2) : `${r.invite.url}\n(shown once)`); + return 0; + } + if (sub === 'revoke') { + if (!arg) throw new Error('invites revoke '); + await client.post(`/api/v1/invites/${encodeURIComponent(arg)}/revoke`, {}); + out(`Withdrew ${arg}.`); + return 0; + } + const r = await client.get('/api/v1/invites'); + if (json) { + out(JSON.stringify(r, null, 2)); + return 0; + } + out( + `${r.quota.left} of ${r.quota.total} invites left · ${r.bonus_days} days of Premium each when one is used`, + ); + for (const i of r.invites) out(`${pad(i.id, 18)} ${pad(i.status, 8)} ${i.note}`); + return 0; + } + case 'credits': { + const [sub, arg] = rest; + if (sub === 'mint') { + const r = await client.post('/api/v1/admin/credits', { + days: Number(flags.days), + label: flags.label ?? '', + max: flags.max ? Number(flags.max) : 1, + expires_days: flags.expires ? Number(flags.expires) : null, + }); + out( + json + ? JSON.stringify(r, null, 2) + : `${r.code}\n${r.days} days of Premium · ${r.max_redemptions} use(s) · id ${r.id}`, + ); + return 0; + } + if (sub === 'revoke') { + if (!arg) throw new Error('credits revoke '); + await client.post(`/api/v1/admin/credits/${encodeURIComponent(arg)}/revoke`, {}); + out(`Revoked ${arg}.`); + return 0; + } + if (sub && sub !== 'list') throw new Error('credits mint|list|revoke '); + const { codes } = await client.get('/api/v1/admin/credits'); + if (json) { + out(JSON.stringify(codes, null, 2)); + return 0; + } + for (const c of codes) + out( + `${pad(c.id, 18)} ${pad(`${c.days}d`, 6)} ${pad(`${c.redemptions}/${c.max_redemptions}`, 8)} ${c.revoked_at ? 'revoked ' : ''}${c.label}`, + ); + return 0; + } case 'mcp': return serveMcp({ client, stdin, stdout }); default: diff --git a/apps/web/package.json b/apps/web/package.json index b0f1d49e..dcafd66e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/web", - "version": "0.50.0", + "version": "0.51.0", "private": true, "type": "module", "scripts": { @@ -21,6 +21,7 @@ "@nichedb/payments": "workspace:*", "@nichedb/premium": "workspace:*", "@nichedb/queue": "workspace:*", + "@profullstack/affiliates": "^0.2.0", "@profullstack/footer": "latest", "@profullstack/leaderboard": "^0.3.1", "@profullstack/nichedb": "workspace:*", @@ -28,6 +29,7 @@ "@profullstack/openprofile": "^0.2.0", "@profullstack/partners": "0.2.0", "@profullstack/profile": "0.1.6", + "@profullstack/redeem-credits": "^0.2.0", "@profullstack/throttle": "^0.3.4", "@profullstack/x402-gateway": "0.13.1", "@simplewebauthn/browser": "^14.0.0", diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 3da48485..db537f35 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -16,6 +16,7 @@ import { registerAutomotive } from './routes/automotive.js'; import { registerBooks } from './routes/books.js'; import { registerBusiness } from './routes/business.js'; import { registerCrawlStatus } from './routes/crawlstatus.js'; +import { registerCredits } from './routes/credits.js'; import { registerDataDumps } from './routes/data-dumps.js'; import { registerKnowledge } from './routes/knowledge.js'; import { registerLyrics } from './routes/lyrics.js'; @@ -141,6 +142,8 @@ app.get('/healthz', (c) => c.text('ok')); registerStatic(app, gateway); registerAuth(app); +// Before pages: /i/INV-… is an invite, /i/ (pages) is an item. +registerCredits(app); registerPages(app); registerManage(app); registerSubmit(app); diff --git a/apps/web/src/lib/credits.js b/apps/web/src/lib/credits.js new file mode 100644 index 00000000..b3470776 --- /dev/null +++ b/apps/web/src/lib/credits.js @@ -0,0 +1,289 @@ +/** + * Credit codes and invites: @profullstack/redeem-credits and + * @profullstack/affiliates over nichedb's own Postgres. + * + * Neither package keeps a second entitlement. A redeemed code and an accepted + * invite both end in `grantMembership`, the same memberships row a paid + * Premium term writes, with a `grant_ref` that makes the grant idempotent. So + * "is this person Premium" has one answer, wherever the days came from. + * + * - A code grants `premium_days` (minted by an admin: /admin/credits, the API + * or `nichedb credits mint`). One-off only: a monthly grant would need a + * monthly top-up job, and Premium is sold in days anyway. + * - An invite is scarce (config.invites.quota per account, single use). When + * the invited person's account is created by spending the emailed link + * (which proves the mailbox), both of them get config.invites.bonusDays. + * - Affiliate links stay what they were: the @profullstack/referrals code at + * /r/ that pays commission. Its codes are "external" to affiliates, so + * a sign-up is attributed once, by the system that already pays for it. + */ +import { config } from '@nichedb/config'; +import { sql } from '@nichedb/db'; +import { grantMembership } from '@nichedb/payments/membership'; +import { + Affiliates, + postgresStore as affiliatesStore, + hashCode as inviteHash, + normalizeCode as normalizeInvite, +} from '@profullstack/affiliates'; +import { Credits, postgresStore as creditsStore } from '@profullstack/redeem-credits'; + +export { affiliatesStore, creditsStore }; + +export const UNIT = 'premium_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) }), +}); + +/** A free Premium term, through the same insert a paid one takes. */ +export async function grantPremiumDays({ userId, days, ref, db = sql }) { + return grantMembership(db, { + userId, + paymentId: null, + priceCents: 0, + currency: config.premium.currency, + termDays: Math.trunc(days), + plan: 'premium', + grantRef: ref, + }); +} + +/** Is this an @profullstack/referrals code (the existing affiliate programme)? */ +async function isReferralCode(code) { + const [row] = await sql` + select 1 as ok from referral_codes where code = ${String(code).trim().toUpperCase()} + `; + return Boolean(row); +} + +/** + * 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 = grantPremiumDays, + externalCodes = isReferralCode, + 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(`nichedb grants ${UNIT}, not ${g.unit}`); + await grant({ userId, days: g.amount, ref: source }); + }, + }); + const aff = new Affiliates(affiliateStore, { + ...(now ? { now } : {}), + defaultQuota: quota, + inviteTtlMs: ttlDays > 0 ? ttlDays * DAY_MS : null, + externalCodes, + }); + 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; +}; + +/** Admin: make a code worth `days` of Premium. The plaintext is returned once. */ +export async function mintPremiumCode( + { days, label = '', maxRedemptions = 1, expiresInDays = null, createdBy = null }, + svc = services(), +) { + const d = intIn(days, 1, 3650); + if (!d) + throw Object.assign(new Error('days must be a whole number from 1 to 3650'), { status: 400 }); + const max = intIn(maxRedemptions ?? 1, 1, 100_000); + if (!max) throw Object.assign(new Error('max must be from 1 to 100000'), { status: 400 }); + let expiresAt = null; + if (expiresInDays !== null && expiresInDays !== undefined && expiresInDays !== '') { + const e = intIn(expiresInDays, 1, 3650); + if (!e) throw Object.assign(new Error('expires must be 1 to 3650 days'), { status: 400 }); + 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 }` or `{ ok: false, + * reason, error }` with 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 term did not save (the redemption stands, per + // redeem-credits). An admin adds the days by hand; grant_ref 'code:' + // means doing so twice still adds one term. + console.error('[credits] grant after redeem failed:', err.message); + return { + ok: false, + reason: 'grant_failed', + error: + 'The code was accepted but the Premium days did not save. Write to us and we will add them.', + }; + } + if (!r.ok) return { ok: false, reason: r.reason, error: REDEEM_ERRORS[r.reason] ?? 'No.' }; + return { ok: true, days: r.grant.amount, code_id: r.codeId }; +} + +/* ------------------------------------------------------------------ invites -- */ + +export const INVITE_ERRORS = { + invalid: 'That invite is not one we issued.', + expired: 'That invite has expired.', + used: 'That invite has already been used.', + self: 'You cannot accept your own invite.', + revoked: 'That invite was withdrawn.', +}; + +export const looksLikeInvite = (s) => /^[A-Za-z0-9 -]{12,24}$/.test(String(s ?? '')); +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 = undefined) { + const status = + i.usedBy !== null + ? 'used' + : i.revokedAt + ? 'revoked' + : i.expiresAt !== null && Date.now() >= i.expiresAt + ? 'expired' + : 'open'; + return { + ...(plaintext ? { code: plaintext, url: `${config.siteUrl}/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(userId, svc = services()) { + const [quota, invites] = await Promise.all([ + svc.aff.quota(String(userId)), + svc.aff.invites(String(userId)), + ]); + return { quota, invites: invites.map((i) => inviteOut(i)), bonus_days: svc.bonusDays }; +} + +export async function createInvite(userId, note = '', svc = services()) { + try { + const made = await svc.aff.createInvite(String(userId), String(note ?? '').slice(0, 80)); + return { ok: true, invite: inviteOut(made, made.code) }; + } 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; + } +} + +/** 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 and give both people their days. 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({ userId, code }, svc = services()) { + if (!code || !looksLikeInvite(code)) return { ok: false, reason: 'invalid' }; + const r = await svc.aff.redeemInvite(code, String(userId)); + if (!r.ok) return r; + const days = svc.bonusDays; + if (days > 0) { + await svc.grant({ userId: String(userId), days, ref: `invite:${r.inviteId}:invitee` }); + await svc.grant({ userId: r.inviterId, days, ref: `invite:${r.inviteId}:inviter` }); + } + return { ...r, days }; +} + +/** + * First-touch attribution for a new account from the ?ref= / referral cookie. + * nichedb's affiliate codes are @profullstack/referrals codes, which affiliates + * reports as external; those go to the existing `onExternal` (users.referred_by, + * which prices the first purchase and pays the commission). Never both. + */ +export async function attributeReferral({ userId, code, onExternal }, svc = services()) { + if (!code) return { ok: false }; + const r = await svc.aff.attribute(String(code), String(userId)); + if (r.external && onExternal) await onExternal({ userId, code }); + return r; +} diff --git a/apps/web/src/lib/llms.js b/apps/web/src/lib/llms.js index 3e5c7f94..50b52780 100644 --- a/apps/web/src/lib/llms.js +++ b/apps/web/src/lib/llms.js @@ -184,6 +184,10 @@ export async function llmsTxt() { ` you have paid here the less a day costs: ${loyaltyLine()}.`, ' A pass may switch ads or tracking off for its own requests with `?disable=ads,tracking`.', '', + `Credit codes (CR-…) are worth days of Premium: redeem with POST ${base}/api/v1/credits/redeem {"code"},`, + `\`nichedb redeem \` or the redeem_code MCP tool. Invites are scarce (${config.invites.quota} per account):`, + `GET/POST ${base}/api/v1/invites; when an invited person signs up, both get ${config.invites.bonusDays} days of Premium.`, + '', `Source code: https://github.com/profullstack/niche-db (MIT).`, ]; return lines.join('\n'); diff --git a/apps/web/src/lib/mcp/tools.js b/apps/web/src/lib/mcp/tools.js index b540b96f..30d9094a 100644 --- a/apps/web/src/lib/mcp/tools.js +++ b/apps/web/src/lib/mcp/tools.js @@ -22,6 +22,7 @@ import * as trafficStore from '@nichedb/db/traffic'; import { enqueueRun } from '@nichedb/queue'; import { whereToBuy } from '../books.js'; import { businessReport, hasInput, normaliseInput, rdapLookup } from '../business.js'; +import * as credits from '../credits.js'; import { liveReputationDeps } from '../dnsbl.js'; import { areaOut } from '../population.js'; import { @@ -815,6 +816,43 @@ export const TOOLS = [ return { approved: stats.approved, full, ...params, addresses: rows }; }, }, + { + name: 'redeem_code', + description: + 'Redeem a credit code (CR-XXXX-XXXX-XXXX-XXXX) for the key owner. A code is worth days of Premium, added after any term already held. Needs a key.', + inputSchema: { + type: 'object', + properties: { code: str('The credit code, any case, dashes optional') }, + required: ['code'], + }, + run: async ({ code }, ctx) => { + const user = needUser(ctx); + const r = await credits.redeemCode(user.id, code); + if (!r.ok) throw toolError(r.error); + return { ok: true, unit: credits.UNIT, days: r.days }; + }, + }, + { + name: 'list_invites', + description: + "The key owner's invites: how many are left of the quota, and each invite with its status (open, used, expired, revoked). Needs a key.", + inputSchema: { type: 'object', properties: {} }, + run: async (_args, ctx) => credits.myInvites(needUser(ctx).id), + }, + { + name: 'create_invite', + description: + 'Make one invite link as the key owner. Invites are scarce (a few per account); when the invited person signs up, both get days of Premium. The link is returned once. Needs a key.', + inputSchema: { + type: 'object', + properties: { note: str('Who it is for (only the owner sees this)') }, + }, + run: async ({ note }, ctx) => { + const r = await credits.createInvite(needUser(ctx).id, note ?? ''); + if (!r.ok) throw toolError(r.error); + return r.invite; + }, + }, ]; export const TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t])); diff --git a/apps/web/src/routes/auth.js b/apps/web/src/routes/auth.js index 8043df47..891605b4 100644 --- a/apps/web/src/routes/auth.js +++ b/apps/web/src/routes/auth.js @@ -7,11 +7,18 @@ import { vapidKeysFromEnv, vapidPublicKeyResponse } from '@profullstack/notifica import { saveSignupProfile, validateProfile } from '@profullstack/profile'; import { getCookie, setCookie } from 'hono/cookie'; import { AUTH, attempt, callerAddress, forgive, VIEW } from '../lib/auth-throttle.js'; +import { acceptInvite, attributeReferral, looksLikeInvite } from '../lib/credits.js'; import { render, requireUser, respond } from '../lib/http.js'; import { profiles } from '../lib/profile.js'; import { SignIn } from '../views/pages.jsx'; const REF_COOKIE = 'ndb_ref'; +/** Set by /i/; also carried on the emailed link (login_tokens.invite_code). */ +export const INVITE_COOKIE = 'ndb_invite'; +const inviteFrom = (c) => { + const v = getCookie(c, INVITE_COOKIE); + return v && looksLikeInvite(v) ? v : null; +}; const waitFor = (s) => s >= 3600 ? `${Math.ceil(s / 3600)} hour(s)` @@ -60,6 +67,7 @@ export function registerAuth(app) { mode="login" next={c.req.query('next')} referral={referral} + invite={inviteFrom(c)} error={c.req.query('error')} />, ), @@ -70,7 +78,14 @@ export function registerAuth(app) { if (c.get('user')) return c.redirect('/following', 303); const referral = rememberReferral(c); return c.html( - await render(), + await render( + , + ), ); }); /** A referral link: remember the code, land on sign-up. */ @@ -117,15 +132,16 @@ export function registerAuth(app) { .trim() .toLowerCase(); const next = String(body.next ?? '/following'); + 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 })}`, ); } } @@ -157,12 +173,31 @@ export function registerAuth(app) { }).catch((err) => console.error('[auth] signup profile save failed:', err.message)); } const ref = getCookie(c, REF_COOKIE); - if (ref && result.user?.created) - await q.setReferredBy({ userId: result.user.id, code: ref }).catch(() => {}); + if (ref && result.user?.created) { + // One attribution per sign-up: a referrals code (the programme that pays) + // goes to users.referred_by as before; an affiliates code is recorded there. + await attributeReferral({ + userId: result.user.id, + code: ref, + onExternal: ({ userId, code }) => q.setReferredBy({ userId, code }), + }).catch(async (err) => { + console.error('[auth] attribution failed:', err.message); + await q.setReferredBy({ userId: result.user.id, code: ref }).catch(() => {}); + }); + } + // An invite counts only for an account this link just created: spending the + // link proved the mailbox, so this is a real, confirmed sign-up. The code + // comes from the link itself first, so another browser still counts. + const invite = result.invite ?? inviteFrom(c); + if (invite && result.user?.created) + await acceptInvite({ userId: result.user.id, code: invite }).catch((err) => + console.error('[auth] invite accept failed:', err.message), + ); const caller = callerAddress(c); if (caller) forgive(`token:${caller}`); c.header('set-cookie', auth.sessionCookie(result.sessionId)); if (ref) setCookie(c, REF_COOKIE, '', { path: '/', maxAge: 0 }); + if (getCookie(c, INVITE_COOKIE)) setCookie(c, INVITE_COOKIE, '', { path: '/', maxAge: 0 }); const next = c.req.query('next'); return c.redirect(next?.startsWith('/') ? next : '/following', 303); }); diff --git a/apps/web/src/routes/credits.js b/apps/web/src/routes/credits.js new file mode 100644 index 00000000..4a39c7ed --- /dev/null +++ b/apps/web/src/routes/credits.js @@ -0,0 +1,225 @@ +/** + * Credit codes and invites, served: the "Have a code?" redeem endpoint, the + * /invites page and API, the /i/ landing, and the admin's mint. + * Everything that decides anything is in lib/credits.js. + */ +import { config } from '@nichedb/config'; +import { connection } from '@nichedb/queue'; +import { getCookie, setCookie } from 'hono/cookie'; +import * as lib from '../lib/credits.js'; +import { render, requireUser, respond, wantsJson } from '../lib/http.js'; +import { Denied, isAdmin } from '../lib/service.js'; +import { AdminCreditsPage, InviteLanding, InvitesPage } from '../views/credits.jsx'; +import { INVITE_COOKIE } from './auth.js'; + +const requireAdmin = (c) => { + const user = requireUser(c); + if (!isAdmin(user)) 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(); +} + +/** + * A one-time plaintext (a new invite or code) for the page the form lands on, + * kept out of the URL. Its own key, so the API-key flash on /settings never + * picks it up. + */ +function redisFlash() { + const key = (kind, c) => { + const sid = getCookie(c, config.session.cookie); + return sid ? `flash:${kind}:${sid}` : null; + }; + return { + async put(c, kind, value) { + const k = key(kind, c); + if (k) await connection.set(k, JSON.stringify(value), 'EX', 120).catch(() => {}); + }, + async take(c, kind) { + const k = key(kind, c); + if (!k) return null; + try { + const v = await connection.get(k); + if (v) await connection.del(k); + return v ? JSON.parse(v) : null; + } catch { + return null; + } + }, + }; +} + +const safeBack = (v, fallback) => { + const s = String(v ?? ''); + return s.startsWith('/') && !s.startsWith('//') ? s : fallback; +}; + +/* + * Every write answers on two paths: /api/v1/… for API callers (always JSON, + * see wantsJson) and the same path without the prefix for the page's plain + * forms, which get a 303 back with a notice. + */ +export function registerCredits(app, deps = {}) { + const svc = () => deps.services ?? lib.services(); + const flash = deps.flash ?? redisFlash(); + + /* --------------------------------------------------------- credit codes -- */ + + app.on('POST', ['/api/v1/credits/redeem', '/credits/redeem'], async (c) => { + const user = requireUser(c); + const body = await readBody(c); + const back = safeBack(body.back, '/settings'); + 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: back, + error: r.error, + }); + return respond(c, { + json: { ok: true, unit: lib.UNIT, days: r.days }, + redirectTo: back, + notice: `Redeemed: ${r.days} days of Premium added.`, + }); + }); + + /* ------------------------------------------------------------- invites -- */ + + app.get('/invites', async (c) => { + const user = requireUser(c); + const [mine, made] = await Promise.all([ + lib.myInvites(user.id, svc()), + flash.take(c, 'invite'), + ]); + return c.html( + await render( + , + ), + ); + }); + + app.get('/api/v1/invites', async (c) => { + const user = requireUser(c); + return c.json(await lib.myInvites(user.id, svc())); + }); + + app.on('POST', ['/api/v1/invites', '/invites'], async (c) => { + const user = requireUser(c); + const body = await readBody(c); + const r = await lib.createInvite(user.id, body.note, svc()); + if (!r.ok) return respond(c, { status: 409, redirectTo: '/invites', error: r.error }); + if (!wantsJson(c)) await flash.put(c, 'invite', r.invite); + return respond(c, { json: { ok: true, invite: r.invite }, redirectTo: '/invites' }); + }); + + app.on('POST', ['/api/v1/invites/:id/revoke', '/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: '/invites', error: r.error }); + return respond(c, { + redirectTo: '/invites', + notice: 'Invite withdrawn; the slot is yours again.', + }); + }); + + /** + * An invite link: remember the code (also carried on the emailed link), then + * sign up. /i/ is an item page; an invite always starts INV-, so the + * pattern keeps the two apart, and this is registered before the item route. + */ + app.get('/i/:code{[Ii][Nn][Vv]-[A-Za-z0-9-]{12,20}}', async (c) => { + const raw = c.req.param('code'); + 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, { + path: '/', + maxAge: config.invites.ttlDays * 86400, + sameSite: 'Lax', + httpOnly: true, + secure: config.isProd, + }); + c.header('cache-control', 'private, no-store'); + if (wantsJson(c)) + return c.json({ + code, + status, + bonus_days: config.invites.bonusDays, + signup: `${config.siteUrl}/signup`, + }); + return c.html( + await render(), + status === 'ok' ? 200 : 404, + ); + }); + + /* --------------------------------------------------------------- admin -- */ + + app.get('/admin/credits', async (c) => { + const user = requireAdmin(c); + const [codes, made] = await Promise.all([svc().credits.list(), flash.take(c, 'credit-code')]); + return c.html( + await render( + lib.codeOut(x))} + made={made} + notice={c.req.query('notice')} + error={c.req.query('error')} + />, + ), + ); + }); + + 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', '/admin/credits'], async (c) => { + const user = requireAdmin(c); + const body = await readBody(c); + let made; + try { + made = await lib.mintPremiumCode( + { + 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: '/admin/credits', error: err.message }); + throw err; + } + if (!wantsJson(c)) await flash.put(c, 'credit-code', made); + return respond(c, { json: { ok: true, ...made }, redirectTo: '/admin/credits' }); + }); + + app.on('POST', ['/api/v1/admin/credits/:id/revoke', '/admin/credits/:id/revoke'], async (c) => { + requireAdmin(c); + try { + await svc().credits.revoke(c.req.param('id')); + } catch { + return respond(c, { status: 404, redirectTo: '/admin/credits', error: 'No such code.' }); + } + return respond(c, { redirectTo: '/admin/credits', notice: 'Revoked.' }); + }); +} diff --git a/apps/web/src/routes/credits.test.js b/apps/web/src/routes/credits.test.js new file mode 100644 index 00000000..ecd34777 --- /dev/null +++ b/apps/web/src/routes/credits.test.js @@ -0,0 +1,162 @@ +import { describe, expect, test } from 'bun:test'; +import { memoryStore as affMemory } from '@profullstack/affiliates'; +import { memoryStore as creditsMemory } from '@profullstack/redeem-credits'; +import { Hono } from 'hono'; + +process.env.DATABASE_URL ??= 'postgres://test:test@localhost:5432/test'; +process.env.SITE_URL ??= 'https://nichedb.test'; +const { config } = await import('@nichedb/config'); +const lib = await import('../lib/credits.js'); +const { Denied } = await import('../lib/service.js'); +const { registerCredits } = await import('./credits.js'); + +const ALICE = { id: '11111111-1111-4111-8111-111111111111', email: 'a@x.test', role: 'user' }; +const ADMIN = { id: '22222222-2222-4222-8222-222222222222', email: 'b@x.test', role: 'admin' }; + +function build(user = ALICE) { + const grants = []; + const services = lib.makeCredits({ + creditStore: creditsMemory(), + affiliateStore: affMemory(), + grant: async (g) => grants.push(g), + externalCodes: () => false, + quota: 2, + bonusDays: 7, + }); + const flashed = {}; + const flash = { + put: async (_c, kind, v) => { + flashed[kind] = v; + }, + take: async (_c, kind) => flashed[kind] ?? null, + }; + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('user', user); + await next(); + }); + app.onError((err, c) => { + if (err.redirect) return c.redirect(err.redirect, 303); + if (err instanceof Denied) return c.json({ error: err.message }, err.status); + throw err; + }); + registerCredits(app, { services, flash }); + return { app, services, grants, flashed }; +} +const json = (body) => ({ + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(body), +}); + +describe('redeem', () => { + test('JSON: a code is worth its days, through the grant hook', async () => { + const { app, services, grants } = build(); + const made = await lib.mintPremiumCode({ days: 14 }, services); + const res = await app.request('/api/v1/credits/redeem', json({ code: made.code })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, unit: 'premium_days', days: 14 }); + expect(grants).toEqual([{ userId: ALICE.id, days: 14, ref: `code:${made.id}` }]); + const again = await app.request('/api/v1/credits/redeem', json({ code: made.code })); + expect(again.status).toBe(400); + expect((await again.json()).reason).toBe('already_redeemed'); + }); + + test('form: back to the page it came from with a notice', async () => { + const { app, services } = build(); + const made = await lib.mintPremiumCode({ days: 3 }, services); + const res = await app.request('/credits/redeem', { + method: 'POST', + body: new URLSearchParams({ code: made.code, back: '/account/billing' }), + }); + expect(res.status).toBe(303); + expect(res.headers.get('location')).toStartWith('/account/billing?notice='); + const off = await app.request('/credits/redeem', { + method: 'POST', + body: new URLSearchParams({ code: 'x', back: '//evil.example' }), + }); + expect(off.headers.get('location')).toStartWith('/settings?error='); + }); + + test('signed out is told to sign in', async () => { + const { app } = build(null); + const res = await app.request('/api/v1/credits/redeem', json({ code: 'CR-1' })); + expect(res.status).toBe(401); + }); +}); + +describe('invites', () => { + test('create, list, quota, landing, revoke', async () => { + const { app } = build(); + const made = await (await app.request('/api/v1/invites', json({ note: 'bob' }))).json(); + expect(made.invite.url).toStartWith(`${config.siteUrl}/i/INV-`); + await app.request('/api/v1/invites', json({})); + const third = await app.request('/api/v1/invites', json({})); + expect(third.status).toBe(409); + + const mine = await (await app.request('/api/v1/invites')).json(); + expect(mine.quota).toEqual({ total: 2, used: 2, left: 0 }); + expect(mine.bonus_days).toBe(7); + + const { app: anon } = build(null); + // anon shares nothing with app's stores, so check the landing on app itself signed out: + const landing = await anon.request(`/i/${made.invite.code}`); + expect(landing.status).toBe(404); + + const revoke = await app.request(`/api/v1/invites/${made.invite.id}/revoke`, json({})); + expect(revoke.status).toBe(200); + }); + + test('/i/ remembers a good invite for a signed-out visitor', async () => { + const { app, services } = build(null); + const { invite } = await lib.createInvite(ALICE.id, '', services); + const res = await app.request(`/i/${invite.code.toLowerCase()}`); + expect(res.status).toBe(200); + expect(res.headers.get('set-cookie')).toContain(`ndb_invite=${invite.code}`); + expect(await res.text()).toContain('Create your account'); + }); + + test('/i/ is not an invite (that is an item page)', async () => { + const { app } = build(null); + expect((await app.request('/i/12345')).status).toBe(404); + expect((await app.request('/i/12345')).headers.get('set-cookie')).toBeNull(); + }); + + test('the invites page renders the plaintext once', async () => { + const { app } = build(); + const res = await app.request('/invites', { + method: 'POST', + body: new URLSearchParams({ note: 'carol' }), + }); + expect(res.status).toBe(303); + const page = await (await app.request('/invites')).text(); + expect(page).toContain('/i/INV-'); + expect(page).toContain('carol'); + }); +}); + +describe('admin mint', () => { + test('admins only', async () => { + const { app } = build(ALICE); + expect((await app.request('/api/v1/admin/credits', json({ days: 5 }))).status).toBe(403); + }); + + test('mints, lists without the code, revokes', async () => { + const { app } = build(ADMIN); + const res = await app.request( + '/api/v1/admin/credits', + json({ days: 30, label: 'him', max: 1 }), + ); + const made = await res.json(); + expect(made.code).toMatch(/^CR-/); + expect(made.days).toBe(30); + const { codes } = await (await app.request('/api/v1/admin/credits')).json(); + expect(codes).toHaveLength(1); + expect(codes[0].code).toBeUndefined(); + expect((await app.request(`/api/v1/admin/credits/${made.id}/revoke`, json({}))).status).toBe( + 200, + ); + const bad = await app.request('/api/v1/admin/credits', json({ days: 0 })); + expect(bad.status).toBe(400); + }); +}); diff --git a/apps/web/src/routes/mcp.js b/apps/web/src/routes/mcp.js index 481724fd..c8f96c0c 100644 --- a/apps/web/src/routes/mcp.js +++ b/apps/web/src/routes/mcp.js @@ -30,6 +30,9 @@ const KEY_TOOLS = new Set([ 'run_source', 'claim_profile', 'update_profile', + 'redeem_code', + 'list_invites', + 'create_invite', ]); export function registerMcp(app) { diff --git a/apps/web/src/views/account.jsx b/apps/web/src/views/account.jsx index a34ecbf5..a921de35 100644 --- a/apps/web/src/views/account.jsx +++ b/apps/web/src/views/account.jsx @@ -5,6 +5,7 @@ */ import { config } from '@nichedb/config'; import { Notice } from './components.jsx'; +import { RedeemPanel } from './credits.jsx'; import { Layout } from './Layout.jsx'; const when = (d) => new Date(d).toLocaleDateString('en-US'); @@ -48,6 +49,7 @@ export const BillingPage = ({ user, plans = [], notice, error }) => ( ))} ) : null} +

What this page does is also written down for machines at{' '} /.well-known/opensaas.json ( diff --git a/apps/web/src/views/credits.jsx b/apps/web/src/views/credits.jsx new file mode 100644 index 00000000..16a29802 --- /dev/null +++ b/apps/web/src/views/credits.jsx @@ -0,0 +1,237 @@ +/** + * Credit codes and invites, as pages. Each form posts to the same endpoint an + * API caller uses (/credits/redeem, /invites); the API answers the same + * handlers under /api/v1. + */ +import { config } from '@nichedb/config'; +import { Notice } from './components.jsx'; +import { Layout } from './Layout.jsx'; + +const when = (d) => (d ? new Date(d).toLocaleDateString('en-US') : ''); + +/** "Have a code?": on /settings and /account/billing. */ +export const RedeemPanel = ({ back = '/settings' }) => ( +

+

Have a code?

+

+ A code from us is worth days of Premium. It is added to any term you already hold. Each code + works once per account. +

+
+ + + +
+
+); + +export const InvitesPage = ({ user, quota, invites = [], made = null, notice, error }) => ( + +

Invites

+ +

+ You get {quota.total} invites, and that is the point: an invite says somebody wanted you here. + When the person you invite creates an account through the emailed link, you each get{' '} + {config.invites.bonusDays} days of Premium. +

+ {made ? ( +
+ +
+ ) : null} +
+

+ {quota.left} of {quota.total} left +

+ {quota.left > 0 ? ( +
+ + +
+ ) : ( +

All used. An unused invite you withdraw comes back.

+ )} + {invites.length ? ( + + + + + + + + + + {invites.map((i) => ( + + + + + + + ))} + +
ForStatusMade +
{i.note || '—'} + {i.status} + {i.used_at ? ` ${when(i.used_at)}` : ''} + {i.status === 'open' && i.expires_at ? ` until ${when(i.expires_at)}` : ''} + {when(i.created_at)} + {i.status === 'open' ? ( +
+ +
+ ) : null} +
+ ) : null} +
+

+ Want to share more widely? Your referral link on Settings has no limit + and pays you a share of what the people it brings spend. Invites are also at{' '} + /api/v1/invites, nichedb invites and the MCP tools. +

+
+); + +const LANDING = { + ok: null, + invalid: 'That invite is not one we issued.', + used: 'That invite has already been used.', + expired: 'That invite has expired.', + revoked: 'That invite was withdrawn.', +}; + +export const InviteLanding = ({ user, code, status }) => ( + +
+

{status === 'ok' ? 'You were invited' : 'Invite'}

+ {status === 'ok' ? ( + user ? ( + + ) : ( + <> +

+ Somebody on {config.siteName} wanted you here. Create your account and you both get{' '} + {config.invites.bonusDays} days of Premium as soon as you follow the link we email + you. +

+

+ + Create your account + +

+

+ Invite {code} is kept with your sign-up, so the emailed link + works in any browser. +

+ + ) + ) : ( + + )} +
+
+); + +export const AdminCreditsPage = ({ user, codes = [], made = null, notice, error }) => ( + +

Credit codes

+ + {made ? ( +
+ +
+ ) : null} +
+

Mint

+
+ + + + + +
+

+ Also nichedb credits mint --days 30 --label "…" with an admin key. +

+
+
+

Codes

+ {codes.length ? ( + + + + + + + + + + + + {codes.map((c) => ( + + + + + + + + + ))} + +
LabelDaysUsedExpiresMade +
+ {c.label || '—'} {c.id} + {c.days} + {c.redemptions}/{c.max_redemptions} + {c.revoked_at ? 'revoked' : c.expires_at ? when(c.expires_at) : 'never'}{when(c.created_at)} + {c.revoked_at ? null : ( +
+ +
+ )} +
+ ) : ( +

None yet.

+ )} +
+
+); diff --git a/apps/web/src/views/pages.jsx b/apps/web/src/views/pages.jsx index 49193135..32256ee9 100644 --- a/apps/web/src/views/pages.jsx +++ b/apps/web/src/views/pages.jsx @@ -23,6 +23,7 @@ import { Tags, When, } from './components.jsx'; +import { RedeemPanel } from './credits.jsx'; import { EnrichmentBlocks } from './enrichment.jsx'; import { Layout } from './Layout.jsx'; import { PremiumUpsell } from './PremiumUpsell.jsx'; @@ -649,7 +650,7 @@ export const Following = ({ user, follows, items }) => ( */ const PROFILE_FIELDS = PROFILE_COLUMNS.filter((f) => f !== 'email'); -export const SignIn = ({ mode, sent, next, error, referral, values, errors }) => ( +export const SignIn = ({ mode, sent, next, error, referral, invite, values, errors }) => (

{mode === 'signup' ? 'Create your account' : 'Sign in'}

@@ -668,9 +669,16 @@ export const SignIn = ({ mode, sent, next, error, referral, values, errors }) => Referred with code {referral}: 20% off Pro on your first purchase.

) : null} + {invite ? ( + + ) : null} {error ? : null}
+ {invite ? : null} {mode === 'signup' ? : null}
+ + +
+

Invites

+

+ A few invites per account. When someone you invite signs up, you each get{' '} + {config.invites.bonusDays} days of Premium. Make an invite +

+
+