diff --git a/README.md b/README.md index 8ca276a..daa6cd0 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,35 @@ agenticjobs popular --board applied agenticjobs profitable ``` +## Credit codes and invites + +An operator mints a code and hands it to someone; they redeem it on `/me` (or +`agenticjobs redeem `, or the `redeem_credit_code` MCP tool) for credit in +US cents. Codes look like `AJ-XXXX-XXXX-XXXX-XXXX` and are stored only as hashes +([@profullstack/redeem-credits](https://www.npmjs.com/package/@profullstack/redeem-credits)). + +```sh +agenticjobs credits mint --cents 500 --label "for Ada" [--max 1] [--expires-days 90] +agenticjobs credits list +agenticjobs credits revoke +``` + +These three run against `DATABASE_URL` directly, like `serve`. The board charges +no listing fee and no commission, so nothing draws on credit yet; it is held on +the account. + +Every account has three invites at `/invites` +([@profullstack/affiliates](https://www.npmjs.com/package/@profullstack/affiliates)). +Each one is a single-use `/i/` link for one new account, for 30 days, and +an accepted invite pays both people `INVITE_BONUS_CENTS` (default 100; 0 turns +it off). It pays only when the new account's address was confirmed by its +sign-in link, the two addresses are not one mailbox, the inviter's account is at +least `INVITE_MIN_INVITER_AGE_HOURS` old (default 24), and the inviter has had +fewer than ten bonuses in 30 days. The invite rides on the sign-in link, so it +counts when the link is opened in another browser. Each account also has an +open `?ref=` link: first touch, kept in the `referral_code` cookie and recorded +when the account is made. + ## Your agents The thesis is agents hiring agents with a person at both ends, and the schema now names diff --git a/migrations/0025_credits_and_invites.sql b/migrations/0025_credits_and_invites.sql new file mode 100644 index 0000000..d195f3b --- /dev/null +++ b/migrations/0025_credits_and_invites.sql @@ -0,0 +1,72 @@ +-- Credit codes and invites (@profullstack/redeem-credits, @profullstack/affiliates). +-- +-- The two packages keep their own tables (credit_* and aff_*) and create them +-- on first use. The credit tables are created here as well, from the package's +-- own DDL, so a fresh board has them before anyone redeems anything. What is +-- this board's own lives below. + +create table if not exists credit_codes ( + id text primary key, + hash text not null unique, + label text not null, + unit text not null, + amount double precision not null, + recur text, + months integer, + max_redemptions integer not null, + redemptions integer not null default 0, + expires_at bigint, + created_at bigint not null, + revoked_at bigint, + created_by text +); + +create table if not exists credit_redemptions ( + code_id text not null, + user_id text not null, + at bigint not null, + primary key (code_id, user_id) +); + +create table if not exists credit_ledger ( + id bigserial primary key, + user_id text not null, + unit text not null, + amount double precision not null, + kind text not null, + recur text, + months integer, + source text not null, + at bigint not null +); + +create index if not exists credit_ledger_user_unit on credit_ledger (user_id, unit, at); + +create table if not exists credit_accounts ( + user_id text not null, + unit text not null, + version integer not null default 0, + primary key (user_id, unit) +); + +-- An invite or a ?ref= code rides on the magic link, the way the signup +-- profile does: the account does not exist until the link is spent, and the +-- link may be opened in another browser (a phone's mail app) that never saw +-- the invite cookie. +alter table magic_links add column if not exists invite_code text; +alter table magic_links add column if not exists ref_code text; + +-- One row per person per accepted invite: whether the bonus was granted, and +-- if not, why. The primary key is what makes the grant idempotent: the row is +-- claimed before the credit is written, so a retry never pays twice. +create table if not exists invite_bonuses ( + invite_id text not null, + user_id uuid not null references users (id) on delete cascade, + role text not null check (role in ('inviter', 'invitee')), + cents integer not null default 0, + status text not null, + created_at timestamptz not null default now(), + primary key (invite_id, user_id) +); + +create index if not exists invite_bonuses_user_idx on invite_bonuses (user_id, created_at desc); diff --git a/package.json b/package.json index 7135e06..d4bbb2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/agenticjobs", - "version": "0.22.8", + "version": "0.23.0", "description": "An agent-friendly job board you self-host. It posts its own jobs, never scrapes anyone else's, and answers on every surface: web, API, MCP, CLI, TUI, desktop and PWA. Instances find each other through an open directory.", "license": "MIT", "type": "module", @@ -59,11 +59,13 @@ }, "dependencies": { "@hono/node-server": "^2.1.5", + "@profullstack/affiliates": "^0.2.0", "@profullstack/emailer": "^1.0.3", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.1", "@profullstack/leaderboard": "^0.3.1", "@profullstack/profile": "0.1.6", + "@profullstack/redeem-credits": "^0.2.0", "@profullstack/synconfig": "^0.1.3", "@profullstack/x402-gateway": "^0.13.1", "@simplewebauthn/server": "^14.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fc2854..2490b7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,15 @@ importers: '@hono/node-server': specifier: ^2.1.5 version: 2.1.5(hono@4.13.14) + '@profullstack/affiliates': + specifier: ^0.2.0 + version: 0.2.0 '@profullstack/emailer': specifier: ^1.0.3 version: 1.0.3 '@profullstack/footer': specifier: latest - version: 0.3.0 + version: 0.3.1 '@profullstack/hqtui': specifier: ^0.11.1 version: 0.11.1 @@ -26,6 +29,9 @@ importers: '@profullstack/profile': specifier: 0.1.6 version: 0.1.6 + '@profullstack/redeem-credits': + specifier: ^0.2.0 + version: 0.2.0 '@profullstack/synconfig': specifier: ^0.1.3 version: 0.1.3 @@ -134,11 +140,16 @@ packages: resolution: {integrity: sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q==} engines: {node: '>=20.0.0'} + '@profullstack/affiliates@0.2.0': + resolution: {integrity: sha512-fbP5uiS8Pa5g8mk5WFD79U4Cj7BUPH6Qy0tQP7ZXE9gfxHOHirHASmZ/rAk8MqnmzGOZTuwq9WwOHbmoFV2TSA==} + engines: {node: '>=22.13'} + hasBin: true + '@profullstack/emailer@1.0.3': resolution: {integrity: sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw==} - '@profullstack/footer@0.3.0': - resolution: {integrity: sha512-T95BxnTLRWj0OdEZQwGQkHJCPSBs8VpDATyAIPUqomnC9IRMMvgLEFeION1HSqIvwgHgiC0vbs15ydZyFq99ew==} + '@profullstack/footer@0.3.1': + resolution: {integrity: sha512-uiiCgEuOZAFd4UKpOUIhAseVLTXp+tbGF9kosYDuW2M/+/A3QcWtUCu9lYGSNfOUnvnlscqrLjJC2uYZlJmufQ==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -176,6 +187,20 @@ packages: react: optional: true + '@profullstack/redeem-credits@0.2.0': + resolution: {integrity: sha512-JLowb7mWYfouIK7O522tuPPhvLjQjhdEfm6g3xEYrlNLGzLhrIADDhrYldBlGHfiVBWUFX7wefLuCSLF9fnyog==} + engines: {node: '>=20'} + hasBin: true + + '@profullstack/referrals@0.1.0': + resolution: {integrity: sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + '@profullstack/synconfig@0.1.3': resolution: {integrity: sha512-Ty/90ibgu2DVlablDeoFdItcobK+c8cMQFvuTfJOUlCfyvQZWoPjgt0JHPwTvq+ynOQ4i1ivBQ3K3PY/S4rObA==} engines: {node: '>=22'} @@ -545,9 +570,15 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@profullstack/affiliates@0.2.0': + dependencies: + '@profullstack/referrals': 0.1.0 + transitivePeerDependencies: + - react + '@profullstack/emailer@1.0.3': {} - '@profullstack/footer@0.3.0': {} + '@profullstack/footer@0.3.1': {} '@profullstack/hqtui@0.11.1': {} @@ -557,6 +588,10 @@ snapshots: '@profullstack/profile@0.1.6': {} + '@profullstack/redeem-credits@0.2.0': {} + + '@profullstack/referrals@0.1.0': {} + '@profullstack/synconfig@0.1.3': {} '@profullstack/x402-gateway@0.13.1': diff --git a/src/cli/credits.ts b/src/cli/credits.ts new file mode 100644 index 0000000..a709543 --- /dev/null +++ b/src/cli/credits.ts @@ -0,0 +1,126 @@ +/** + * Credit and invites from a terminal. + * + * credits balance and history (the board, over the API) + * redeem redeem a credit code + * invites [create --note t] invites and the ?ref= link + * credits mint|list|revoke operator: straight against DATABASE_URL + */ + +import type { BoardClient } from '../client/client.ts'; +import { flagString, type Args } from './args.ts'; +import { bold, dim } from './format.ts'; + +type Out = (human: string, machine: unknown) => number; + +function dollars(cents: number): string { + const abs = Math.abs(Math.round(cents)); + return `${cents < 0 ? '-' : ''}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`; +} + +export async function runCredits(args: Args, client: () => BoardClient, out: Out): Promise { + if (args.command === 'redeem') { + const code = args.positional[0] ?? ''; + if (code === '') { + process.stderr.write('agenticjobs redeem \n'); + return 1; + } + const result = await client().redeemCredit(code); + return out( + `Redeemed: ${bold(dollars(result.addedCents))} added. Balance ${bold(result.balance)}.`, + result, + ); + } + + if (args.command === 'invites') { + if (args.positional[0] === 'create') { + const result = await client().createInvite(flagString(args, 'note') ?? ''); + return out( + `${bold(result.url)}\n${dim(`Shown once. ${result.quota.left} of ${result.quota.total} invites left.`)}`, + result, + ); + } + const result = await client().invites(); + const lines = result.invites.map( + (invite) => + `${invite.used ? 'accepted' : invite.revoked ? 'withdrawn' : 'open '} ${invite.note || dim('no note')} ${dim(invite.createdAt.slice(0, 10))}`, + ); + return out( + [ + `${bold(`${result.quota.left} of ${result.quota.total}`)} invites left. ${dim('agenticjobs invites create --note "for Ada"')}`, + ...lines, + '', + `Your link: ${result.affiliateUrl}`, + ].join('\n'), + result, + ); + } + + // credits + const sub = args.positional[0]; + if (sub === 'mint' || sub === 'list' || sub === 'revoke') return runOperator(args, sub, out); + const result = await client().credits(); + const lines = result.history.map( + (entry) => + `${entry.kind === 'spend' ? '-' : '+'}${dollars(entry.cents)} ${entry.source} ${dim(entry.at.slice(0, 10))}`, + ); + return out([`Balance ${bold(result.balance)}`, ...lines].join('\n'), result); +} + +/** Operator commands: the board's own database, no sign-in, like `serve`. */ +async function runOperator(args: Args, sub: 'mint' | 'list' | 'revoke', out: Out): Promise { + const { closePool, getPool } = await import('../db/pool.ts'); + const { loadConfig } = await import('../config.ts'); + const { migrate } = await import('../db/migrate.ts'); + const { creditServices, mintCreditCode } = await import('../core/credits.ts'); + const pool = getPool(loadConfig().databaseUrl); + try { + await migrate(pool); + if (sub === 'mint') { + const cents = Number.parseInt(flagString(args, 'cents') ?? '', 10); + const label = flagString(args, 'label') ?? ''; + if (!Number.isInteger(cents) || label === '') { + process.stderr.write( + 'agenticjobs credits mint --cents 500 --label "for Ada" [--max 1] [--expires-days 90]\n', + ); + return 1; + } + const max = flagString(args, 'max'); + const days = flagString(args, 'expires-days'); + const minted = await mintCreditCode(pool, { + cents, + label, + maxRedemptions: max === undefined ? 1 : Number.parseInt(max, 10), + expiresInDays: days === undefined ? null : Number.parseInt(days, 10), + createdBy: 'cli', + }); + return out( + `${bold(minted.code)}\n${dim(`${dollars(cents)}, ${minted.maxRedemptions} use(s), id ${minted.id}. Shown once; only its hash is stored.`)}`, + { ...minted, cents }, + ); + } + const { credits } = creditServices(pool); + if (sub === 'revoke') { + const id = args.positional[1] ?? ''; + if (id === '') { + process.stderr.write('agenticjobs credits revoke (ids are in: credits list)\n'); + return 1; + } + await credits.revoke(id); + return out(`Revoked ${id}.`, { revoked: id }); + } + const codes = await credits.list(); + if (codes.length === 0) return out('No codes minted yet.', { items: [] }); + return out( + codes + .map( + (code) => + `${code.id} ${dollars(code.grant.amount)} ${code.redemptions}/${code.maxRedemptions} ${code.label}${code.revokedAt === null ? '' : dim(' revoked')}`, + ) + .join('\n'), + { items: codes }, + ); + } finally { + await closePool(); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index da65808..6226b3a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -140,6 +140,13 @@ const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self applications what came in decide reviewing, rejected or hired + Credit and invites + credits your credit balance and where it came from + redeem redeem a credit code + invites your invites, how many are left, your ?ref= link + invites create [--note t] make an invite (the link is shown once) + signup --invite create an account with an invite + Watching watch [filters] be told when a matching listing is published --remote --agents --tag --workplace --min @@ -171,6 +178,11 @@ const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self tracker forget --fleet --source --yes tracker leaderboard + Operator (runs against DATABASE_URL, like serve) + credits mint --cents --label [--max ] [--expires-days ] + credits list every code: label, uses, revoked + credits revoke + Running one serve start the board migrate apply migrations and exit @@ -493,6 +505,16 @@ async function run(args: Args): Promise { const { runAgents } = await import('./agents.ts'); return runAgents(args, clientFor(args), (human, machine) => out(args, human, machine)); } + case 'credits': + case 'redeem': + case 'invites': { + const { runCredits } = await import('./credits.ts'); + return runCredits( + args, + () => clientFor(args), + (human, machine) => out(args, human, machine), + ); + } case 'watch': { const { runWatch } = await import('./watches.ts'); return runWatch(args, clientFor(args), queryFrom(args), (human, machine) => @@ -680,6 +702,7 @@ async function commandSignup(args: Args): Promise { const sent = await client.request<{ delivered: boolean }>('POST', '/api/v1/auth/magic-link', { email, redirect: `/device?code=${encodeURIComponent(grant.userCode)}`, + ...(flagString(args, 'invite') === undefined ? {} : { invite: flagString(args, 'invite') }), }); if (sent.delivered) { diff --git a/src/client/client.ts b/src/client/client.ts index 1854fb7..5a2491c 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -38,6 +38,30 @@ export interface AgentInputRecord { public?: boolean; } +export interface CreditsRecord { + unit: 'usd_cents'; + balanceCents: number; + balance: string; + history: { cents: number; kind: 'grant' | 'spend'; source: string; at: string }[]; +} + +export interface InvitesRecord { + quota: { total: number; used: number; left: number }; + invites: { + id: string; + note: string; + createdAt: string; + expiresAt: string | null; + used: boolean; + usedAt: string | null; + revoked: boolean; + }[]; + affiliateCode: string; + affiliateUrl: string; + invitedCount: number; + bonusCents: number; +} + export interface WatchRecord { id: string; query: JobQuery; @@ -515,6 +539,30 @@ export class BoardClient { return this.request('DELETE', `/api/v1/agents/${encodeURIComponent(slug)}`); } + // --- credit and invites ------------------------------------------------ + + async credits(): Promise { + return this.request('GET', '/api/v1/credits'); + } + + async redeemCredit( + code: string, + ): Promise<{ ok: true; addedCents: number; balanceCents: number; balance: string }> { + return this.request('POST', '/api/v1/credits/redeem', { code }); + } + + async invites(): Promise { + return this.request('GET', '/api/v1/invites'); + } + + async createInvite(note = ''): Promise<{ + code: string; + url: string; + quota: { total: number; used: number; left: number }; + }> { + return this.request('POST', '/api/v1/invites', { note }); + } + // --- watches and notifications ------------------------------------------- async watches(): Promise<{ items: WatchRecord[]; total: number }> { diff --git a/src/config.ts b/src/config.ts index 6668dd6..ef70fda 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,6 +50,12 @@ export interface Config { * hand-built config in a test can leave it out. */ crawl?: { apiKey: string; payTo: string } | null; + /** + * What an accepted invite is worth, to each side, and how old the inviter's + * account must be before it pays. Optional so a hand-built config in a test + * can leave it out; the defaults are in core/credits.ts. + */ + invites?: { bonusCents: number; inviterMinAgeHours: number }; version: string; } @@ -192,12 +198,22 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { env['COINPAY_X402_KEY']?.trim() && env['CRAWL_PAY_TO']?.trim() ? { apiKey: env['COINPAY_X402_KEY'].trim(), payTo: env['CRAWL_PAY_TO'].trim() } : null, + invites: { + bonusCents: intOr(env['INVITE_BONUS_CENTS'], 100), + inviterMinAgeHours: intOr(env['INVITE_MIN_INVITER_AGE_HOURS'], 24), + }, version: env['npm_package_version']?.trim() || VERSION, }; } +/** A non-negative integer from the environment, or the default. */ +function intOr(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(value?.trim() ?? '', 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + /** Kept in step with package.json by the release script. */ -export const VERSION = '0.22.8'; +export const VERSION = '0.23.0'; export const SOFTWARE_NAME = 'agenticjobs'; /** diff --git a/src/core/auth.ts b/src/core/auth.ts index 326dda5..a72016e 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -43,6 +43,8 @@ interface UserRow { email: string; name: string | null; is_admin: boolean; + /** True only when this call made the account. */ + created?: boolean; } /** @@ -68,7 +70,7 @@ export async function ensureUser(pool: pg.Pool, email: string, name?: string): P ); const row = created.rows[0]; if (row === undefined) throw new Error('user insert returned no row'); - return row; + return { ...row, created: true }; } export async function createSession( @@ -166,11 +168,12 @@ export async function startMagicLink( email: string, redirect: string | null, profile: Record | null = null, + carried: { invite?: string | null; ref?: string | null } = {}, ): Promise { const token = mintToken(); const result = await pool.query<{ expires_at: string }>( - `insert into magic_links (token_hash, email, redirect, expires_at, profile) - values ($1, $2, $3, now() + ($4 || ' milliseconds')::interval, $5::jsonb) + `insert into magic_links (token_hash, email, redirect, expires_at, profile, invite_code, ref_code) + values ($1, $2, $3, now() + ($4 || ' milliseconds')::interval, $5::jsonb, $6, $7) returning expires_at`, [ hashToken(token), @@ -178,6 +181,8 @@ export async function startMagicLink( redirect, String(MAGIC_TTL_MS), profile === null ? null : JSON.stringify(profile), + carried.invite ?? null, + carried.ref ?? null, ], ); return { token, expiresAt: result.rows[0]?.expires_at ?? new Date().toISOString() }; @@ -188,6 +193,9 @@ export interface ConsumedLink { redirect: string | null; /** The signup form's profile fields, when the link was asked for from /signup. */ profile: Record | null; + /** An invite code or ?ref= code that was carried on the link, if any. */ + invite: string | null; + ref: string | null; } /** @@ -205,17 +213,25 @@ export async function consumeMagicLink( email: string; redirect: string | null; profile: Record | null; + invite_code: string | null; + ref_code: string | null; }>( `update magic_links set used_at = now() where token_hash = $1 and used_at is null and expires_at > now() - returning email, redirect, profile`, + returning email, redirect, profile, invite_code, ref_code`, [hashToken(token)], ); const row = claimed.rows[0]; if (row === undefined) return null; const user = await ensureUser(pool, row.email); - return { user, redirect: row.redirect, profile: row.profile ?? null }; + return { + user, + redirect: row.redirect, + profile: row.profile ?? null, + invite: row.invite_code ?? null, + ref: row.ref_code ?? null, + }; } // --- the device flow ---------------------------------------------------- diff --git a/src/core/credits.ts b/src/core/credits.ts new file mode 100644 index 0000000..e1023e2 --- /dev/null +++ b/src/core/credits.ts @@ -0,0 +1,390 @@ +/** + * Credit codes and invites. + * + * Two house packages over this board's Postgres, and nothing else: + * + * - @profullstack/redeem-credits mints scarce codes (stored only as hashes) + * that grant credit, in US cents, to whoever redeems them. The board has no + * balance of its own, so the package's ledger is the balance. + * - @profullstack/affiliates gives every account three single-use invites and + * one open ?ref= code, and records who brought whom. + * + * An accepted invite pays a small credit to both people, but only after the + * checks below: the invitee's address was confirmed (the magic link was spent), + * the account is new, the two are not the same mailbox, the inviter's account + * is old enough, and the inviter is under a monthly cap. + */ + +import type pg from 'pg'; +import { + Credits, + postgresStore as creditStore, + normalizeCode as normalizeCreditCode, + type MintedCode, + type RedeemResult, +} from '@profullstack/redeem-credits'; +import { + Affiliates, + postgresStore as affiliateStore, + normalizeCode as normalizeInviteCode, + type Invite, + type RedeemInviteResult, +} from '@profullstack/affiliates'; +import type { Config } from '../config.ts'; + +export { REF_COOKIE } from '@profullstack/affiliates'; + +/** Credit is kept in US cents. */ +export const CREDIT_UNIT = 'usd_cents'; +/** Invites per account. Scarce on purpose: an invite says someone wanted you here. */ +export const INVITE_QUOTA = 3; +/** Where an /i/ visit is remembered until the person signs up. */ +export const INVITE_COOKIE = 'aj_invite'; +/** Bonuses one inviter can earn in 30 days, whatever their quota is raised to. */ +export const INVITER_MONTHLY_CAP = 10; + +const DEFAULT_BONUS_CENTS = 100; +const DEFAULT_INVITER_MIN_AGE_HOURS = 24; + +interface Services { + credits: Credits; + affiliates: Affiliates; +} + +const services = new WeakMap(); + +/** One pair of services per pool, built on first use. */ +export function creditServices(pool: pg.Pool): Services { + let found = services.get(pool); + if (found === undefined) { + found = { + credits: new Credits(creditStore(pool)), + affiliates: new Affiliates(affiliateStore(pool), { defaultQuota: INVITE_QUOTA }), + }; + services.set(pool, found); + } + return found; +} + +// --- input --------------------------------------------------------------- + +/** + * A credit or invite code as typed, or null when it cannot be one. The + * packages forgive case, spaces and dashes; this only keeps junk (and + * anything long enough to be an attack on a log line) out. + */ +export function cleanCode(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (trimmed === '' || trimmed.length > 48) return null; + if (!/^[A-Za-z0-9 _-]+$/.test(trimmed)) return null; + return trimmed; +} + +/** An affiliate code from ?ref= or the cookie: short, letters and digits. */ +export function cleanRef(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return /^[A-Za-z0-9]{4,32}$/.test(trimmed) ? trimmed.toUpperCase() : null; +} + +// --- credit ---------------------------------------------------------------- + +export function formatCents(cents: number): string { + const sign = cents < 0 ? '-' : ''; + const abs = Math.abs(Math.round(cents)); + return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`; +} + +export const REDEEM_MESSAGES: Record['reason'], string> = { + invalid: 'That code does not exist. 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 has been withdrawn.', +}; + +export interface CreditEntry { + cents: number; + kind: 'grant' | 'spend'; + source: string; + at: string; +} + +export interface CreditSummary { + unit: typeof CREDIT_UNIT; + balanceCents: number; + balance: string; + history: CreditEntry[]; +} + +/** What a ledger source means to the person reading it. */ +export function describeSource(source: string): string { + if (source.startsWith('code:')) return 'Credit code'; + if (source.startsWith('invite:')) return 'Invite bonus'; + if (source === 'admin') return 'Granted by the board'; + return source; +} + +export async function creditSummary(pool: pg.Pool, userId: string): Promise { + const { credits } = creditServices(pool); + const [balanceCents, history] = await Promise.all([ + credits.balance(userId, CREDIT_UNIT), + credits.history(userId, CREDIT_UNIT), + ]); + return { + unit: CREDIT_UNIT, + balanceCents, + balance: formatCents(balanceCents), + history: history + .slice(-20) + .reverse() + .map((entry) => ({ + cents: entry.amount, + kind: entry.kind, + source: describeSource(entry.source), + at: new Date(entry.at).toISOString(), + })), + }; +} + +export type RedeemOutcome = + | { ok: true; cents: number; balanceCents: number } + | { ok: false; reason: Exclude['reason']; message: string }; + +export async function redeemCreditCode( + pool: pg.Pool, + userId: string, + code: string, +): Promise { + const { credits } = creditServices(pool); + const result = await credits.redeem(code, userId); + if (!result.ok) + return { ok: false, reason: result.reason, message: REDEEM_MESSAGES[result.reason] }; + return { + ok: true, + cents: result.grant.unit === CREDIT_UNIT ? result.grant.amount : 0, + balanceCents: await credits.balance(userId, CREDIT_UNIT), + }; +} + +export interface MintRequest { + cents: number; + label: string; + maxRedemptions?: number; + expiresInDays?: number | null; + createdBy?: string; +} + +/** An operator mints a code. The plaintext is in the result and nowhere else. */ +export async function mintCreditCode(pool: pg.Pool, request: MintRequest): Promise { + if (!Number.isInteger(request.cents) || request.cents <= 0 || request.cents > 1_000_000) { + throw new Error('cents must be a whole number from 1 to 1000000'); + } + const max = request.maxRedemptions ?? 1; + if (!Number.isInteger(max) || max < 1 || max > 10_000) { + throw new Error('maxRedemptions must be a whole number from 1 to 10000'); + } + const days = request.expiresInDays ?? null; + return creditServices(pool).credits.mint({ + grant: { unit: CREDIT_UNIT, amount: request.cents }, + label: request.label.trim().slice(0, 120) || 'agenticjobs credit', + maxRedemptions: max, + expiresAt: days === null ? null : Date.now() + days * 86_400_000, + prefix: 'AJ', + ...(request.createdBy === undefined ? {} : { createdBy: request.createdBy }), + }); +} + +export { normalizeCreditCode }; + +// --- invites --------------------------------------------------------------- + +export interface InviteView { + id: string; + note: string; + createdAt: string; + expiresAt: string | null; + used: boolean; + usedAt: string | null; + revoked: boolean; +} + +export interface InviteSummary { + quota: { total: number; used: number; left: number }; + invites: InviteView[]; + affiliateCode: string; + affiliateUrl: string; + invitedCount: number; +} + +function inviteView(invite: Omit & { revokedAt?: number | null }): InviteView { + return { + id: invite.id, + note: invite.note, + createdAt: new Date(invite.createdAt).toISOString(), + expiresAt: invite.expiresAt === null ? null : new Date(invite.expiresAt).toISOString(), + used: invite.usedBy !== null, + usedAt: invite.usedAt === null ? null : new Date(invite.usedAt).toISOString(), + revoked: (invite.revokedAt ?? null) !== null, + }; +} + +export function inviteUrl(origin: string, code: string): string { + return `${origin}/i/${encodeURIComponent(code)}`; +} + +export async function inviteSummary( + pool: pg.Pool, + userId: string, + origin: string, +): Promise { + const { affiliates } = creditServices(pool); + const [quota, invites, affiliateCode, invitees] = await Promise.all([ + affiliates.quota(userId), + affiliates.invites(userId), + affiliates.affiliateCode(userId), + affiliates.invitees(userId), + ]); + return { + quota, + invites: invites.map(inviteView), + affiliateCode, + affiliateUrl: `${origin}/?ref=${affiliateCode}`, + invitedCount: invitees.length, + }; +} + +export type CreateInviteOutcome = + | { ok: true; code: string; url: string; invite: InviteView; quota: InviteSummary['quota'] } + | { ok: false; reason: 'no_invites_left' }; + +export async function createInvite( + pool: pg.Pool, + userId: string, + note: string, + origin: string, +): Promise { + const { affiliates } = creditServices(pool); + try { + const invite = await affiliates.createInvite(userId, note.trim().slice(0, 120)); + return { + ok: true, + code: invite.code, + url: inviteUrl(origin, invite.code), + invite: inviteView(invite), + quota: await affiliates.quota(userId), + }; + } catch (error) { + if (error instanceof Error && /no invites left/i.test(error.message)) { + return { ok: false, reason: 'no_invites_left' }; + } + throw error; + } +} + +/** The canonical spelling, for a cookie and a URL. Null when it cannot be an invite. */ +export function canonicalInvite(value: unknown): string | null { + const cleaned = cleanCode(value); + if (cleaned === null) return null; + const normal = normalizeInviteCode(cleaned); + return /^INV-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/.test(normal) ? normal : null; +} + +/** + * One mailbox under two spellings: case, a +tag, and Gmail's dots. Someone + * inviting themselves to collect both halves of a bonus is the abuse this + * catches; a second domain is not caught and is what the cap is for. + */ +export function sameMailbox(a: string, b: string): boolean { + const canonical = (email: string): string => { + const [rawLocal = '', rawDomain = ''] = email.trim().toLowerCase().split('@'); + let domain = rawDomain; + let local = rawLocal.split('+')[0] ?? ''; + if (domain === 'googlemail.com') domain = 'gmail.com'; + if (domain === 'gmail.com') local = local.replaceAll('.', ''); + return `${local}@${domain}`; + }; + return canonical(a) === canonical(b); +} + +export type BonusStatus = + 'granted' | 'disabled' | 'same_mailbox' | 'inviter_too_new' | 'inviter_cap' | 'already'; + +export interface AcceptOutcome { + invite: RedeemInviteResult; + bonus: BonusStatus | null; + cents: number; +} + +/** + * Accept an invite for an account that was created by this sign-in. + * + * The caller is the magic-link callback, so the invitee's address is + * confirmed by construction. The invite is spent whatever the bonus decides: + * who invited whom is recorded even when nothing is paid for it. + */ +export async function acceptInvite( + pool: pg.Pool, + config: Pick, + code: string, + invitee: { id: string; email: string }, +): Promise { + const { affiliates, credits } = creditServices(pool); + const invite = await affiliates.redeemInvite(code, invitee.id); + if (!invite.ok) return { invite, bonus: null, cents: 0 }; + + const bonusCents = config.invites?.bonusCents ?? DEFAULT_BONUS_CENTS; + const minAgeHours = config.invites?.inviterMinAgeHours ?? DEFAULT_INVITER_MIN_AGE_HOURS; + + const inviterRow = await pool.query<{ email: string; age_hours: number; recent: number }>( + `select u.email, + extract(epoch from (now() - u.created_at)) / 3600 as age_hours, + (select count(*)::int from invite_bonuses b + where b.user_id = u.id and b.role = 'inviter' and b.status = 'granted' + and b.created_at > now() - interval '30 days') as recent + from users u where u.id = $1`, + [invite.inviterId], + ); + const inviter = inviterRow.rows[0]; + + let status: BonusStatus = 'granted'; + if (bonusCents <= 0) status = 'disabled'; + else if (inviter === undefined) status = 'disabled'; + else if (sameMailbox(inviter.email, invitee.email)) status = 'same_mailbox'; + else if (Number(inviter.age_hours) < minAgeHours) status = 'inviter_too_new'; + else if (inviter.recent >= INVITER_MONTHLY_CAP) status = 'inviter_cap'; + + const cents = status === 'granted' ? bonusCents : 0; + const pairs: [string, 'inviter' | 'invitee'][] = [ + [invitee.id, 'invitee'], + ...(inviter === undefined ? [] : ([[invite.inviterId, 'inviter']] as [string, 'inviter'][])), + ]; + let paid = 0; + for (const [userId, role] of pairs) { + // The row is claimed first, so a retry of this callback never pays twice. + const claimed = await pool.query( + `insert into invite_bonuses (invite_id, user_id, role, cents, status) + values ($1, $2, $3, $4, $5) + on conflict do nothing returning invite_id`, + [invite.inviteId, userId, role, cents, status === 'granted' ? 'pending' : status], + ); + if (claimed.rowCount === 0 || status !== 'granted') continue; + await credits.grant(userId, { unit: CREDIT_UNIT, amount: cents }, `invite:${invite.inviteId}`); + await pool.query( + `update invite_bonuses set status = 'granted' where invite_id = $1 and user_id = $2`, + [invite.inviteId, userId], + ); + if (role === 'invitee') paid = cents; + } + return { invite, bonus: status, cents: paid }; +} + +/** First touch: an account already attributed keeps its first affiliate. */ +export async function attributeRef( + pool: pg.Pool, + ref: string, + userId: string, +): Promise<{ ok: boolean; affiliateId?: string }> { + return creditServices(pool).affiliates.attribute(ref, userId); +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 30ed0be..f574044 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -449,6 +449,24 @@ export const TOOLS: ToolDefinition[] = [ watches: { type: 'boolean', description: 'List the watches instead of the notifications.' }, }), }, + { + name: 'credits', + title: 'Credit and invites', + description: + 'Your credit balance on this board (US cents) and where it came from. Pass invites: true for your invites instead: how many are left of three, which were accepted, and your open ?ref= link. Pass createInvite: true to make an invite; its link is returned once.', + inputSchema: object({ + invites: { type: 'boolean', description: 'List invites instead of credit.' }, + createInvite: { type: 'boolean', description: 'Make an invite and return its link.' }, + note: string('Who the new invite is for. Only you see it.'), + }), + }, + { + name: 'redeem_credit_code', + title: 'Redeem a credit code', + description: + 'Redeem a credit code (AJ-XXXX-XXXX-XXXX-XXXX) on this account. Case, spaces and dashes do not matter. Each code works once per account.', + inputSchema: object({ code: string('The code.') }, ['code']), + }, { name: 'rankings', title: 'Rankings', @@ -1079,6 +1097,40 @@ export async function callTool( return text(JSON.stringify(response.body, null, 2), response.body); } + case 'credits': { + if (args['createInvite'] === true) { + const response = await caller.call('POST', '/api/v1/invites', { + note: typeof args['note'] === 'string' ? args['note'] : '', + }); + if (response.status === 401) return toolError(signInFirst(caller)); + if (response.status !== 201) + return toolError(message(response.body, 'No invite was made.')); + const made = response.body as { url?: string; quota?: { left?: number } }; + return text( + `Invite link (shown once): ${made.url ?? ''}. ${made.quota?.left ?? 0} left.`, + response.body, + ); + } + const response = await caller.call( + 'GET', + args['invites'] === true ? '/api/v1/invites' : '/api/v1/credits', + ); + if (response.status === 401) return toolError(signInFirst(caller)); + if (response.status !== 200) return toolError(message(response.body, 'Could not read that.')); + return text(JSON.stringify(response.body, null, 2), response.body); + } + + case 'redeem_credit_code': { + const response = await caller.call('POST', '/api/v1/credits/redeem', { + code: typeof args['code'] === 'string' ? args['code'] : '', + }); + if (response.status === 401) return toolError(signInFirst(caller)); + if (response.status !== 200) + return toolError(message(response.body, 'That code was not redeemed.')); + const done = response.body as { balance?: string; addedCents?: number }; + return text(`Redeemed. Balance ${done.balance ?? ''}.`, response.body); + } + case 'rankings': { const response = await caller.call( 'GET', diff --git a/src/server/app.tsx b/src/server/app.tsx index bddcbf5..585e365 100644 --- a/src/server/app.tsx +++ b/src/server/app.tsx @@ -18,6 +18,8 @@ import { settingsRoutes } from './routes/settings.ts'; import { trackerRoutes } from './routes/tracker.tsx'; import { agentRoutes } from './routes/agents.tsx'; import { watchRoutes } from './routes/watches.tsx'; +import { creditRoutes } from './routes/credits.tsx'; +import { trackRef } from './signup-credits.ts'; import { rankingRoutes } from './routes/rankings.tsx'; import { landingRoutes } from './routes/landing.tsx'; import { createRankings } from '../core/rankings.ts'; @@ -59,7 +61,9 @@ export function createApp( coinpay: { apiKey: config.crawl.apiKey }, payTo: config.crawl.payTo, contact: 'mailto:anthony@profullstack.com', - openPaths: ['/api/health', '/llms.txt', '/.well-known/'], + // /i/ is an invite link: a chat app's link preview must not be sold a + // crawl pass for it, and the page spends nothing. + openPaths: ['/api/health', '/llms.txt', '/.well-known/', '/i/'], exempt: (request) => new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=`).test(request.headers.get('cookie') ?? ''), }); @@ -74,6 +78,8 @@ export function createApp( securityHeaders({ formActions: config.coinpay === null ? [] : [config.coinpay.url] }), ); app.use('/api/*', apiCors()); + // First-touch ?ref= affiliate attribution, kept until a sign-up spends it. + app.use('*', trackRef()); // Liveness for the status page: the process answers and the database // answers within three seconds. Nothing about why it failed leaves the box. @@ -102,6 +108,7 @@ export function createApp( app.route('/', trackerRoutes()); app.route('/', agentRoutes()); app.route('/', watchRoutes()); + app.route('/', creditRoutes()); app.route('/', rankingRoutes()); app.route('/api/v1', apiRoutes()); // The MCP tools call this same app, so the getter is resolved lazily: the diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts index 7ff6e65..f078601 100644 --- a/src/server/routes/api.ts +++ b/src/server/routes/api.ts @@ -28,6 +28,7 @@ import { startMagicLink, type Viewer, } from '../../core/auth.ts'; +import { canonicalInvite, cleanRef } from '../../core/credits.ts'; import { applicationPage, countApplications, @@ -1368,7 +1369,12 @@ export function apiRoutes(): Hono { // terminal. safeRedirect keeps this to same-origin paths: an open redirect // on a sign-in link is a phish. const redirect = safeRedirect(body['redirect']); - const link = await startMagicLink(pool, email, redirect); + // An invite code or affiliate code, when the terminal signing up has one. + // Applied when the link is spent, and only if that makes a new account. + const link = await startMagicLink(pool, email, redirect, null, { + invite: canonicalInvite(body['invite']), + ref: cleanRef(body['ref']), + }); const url = `${config.publicUrl}/auth/callback?token=${link.token}`; const delivered = await deliverMagicLink({ mailer, diff --git a/src/server/routes/credits.tsx b/src/server/routes/credits.tsx new file mode 100644 index 0000000..c526158 --- /dev/null +++ b/src/server/routes/credits.tsx @@ -0,0 +1,237 @@ +/** + * Credit codes and invites: the API, the /invites page, the /me redeem form + * and the /i/ landing page. + * + * GET /api/v1/credits balance and recent history + * POST /api/v1/credits/redeem {code} redeem a credit code + * GET /api/v1/invites quota, invites, affiliate link + * POST /api/v1/invites {note} make an invite (the code is shown once) + * POST /api/v1/admin/credit-codes mint a code (a browser-session admin only) + */ + +import { Hono, type Context } from 'hono'; +import type { AppEnv } from '../deps.ts'; +import { requireAdmin } from '../../core/auth.ts'; +import { + canonicalInvite, + cleanCode, + createInvite, + creditSummary, + formatCents, + inviteSummary, + mintCreditCode, + redeemCreditCode, +} from '../../core/credits.ts'; +import { Layout } from '../../views/layout.tsx'; +import { InviteLandingPage, InvitesPage } from '../../views/credits.tsx'; +import { formOf, requireViewer, shell } from './pages.tsx'; +import { rememberInvite } from '../signup-credits.ts'; + +type Ctx = Context; + +/** + * The origin every link on these pages is built from. The configured public + * URL, unless it was left at its localhost default, in which case the host the + * request came in on: an invite link that says localhost is a dead link. + */ +export function originOf(c: Ctx): string { + const configured = c.get('deps').config.publicUrl; + if (!/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(configured)) return configured; + const url = new URL(c.req.url); + const host = c.req.header('x-forwarded-host') ?? c.req.header('host') ?? url.host; + const proto = + c.req.header('x-forwarded-proto')?.split(',')[0]?.trim() || url.protocol.replace(':', ''); + return `${proto}://${host}`; +} + +function unauthenticated(c: Ctx): Response { + return c.json({ error: { code: 'unauthenticated', message: 'Sign in first.' } }, 401); +} + +async function body(c: Ctx): Promise> { + const type = c.req.header('content-type') ?? ''; + try { + if (type.includes('application/json')) { + const parsed = (await c.req.json()) as unknown; + return typeof parsed === 'object' && parsed !== null + ? (parsed as Record) + : {}; + } + return (await c.req.parseBody()) as Record; + } catch { + return {}; + } +} + +function bonusLabel(c: Ctx): string | null { + const cents = c.get('deps').config.invites?.bonusCents ?? 100; + return cents > 0 ? formatCents(cents) : null; +} + +export function creditRoutes(): Hono { + const routes = new Hono(); + + // --- API --------------------------------------------------------------- + + routes.get('/api/v1/credits', async (c) => { + const viewer = c.get('viewer'); + if (viewer === null) return unauthenticated(c); + return c.json(await creditSummary(c.get('deps').pool, viewer.id)); + }); + + routes.post('/api/v1/credits/redeem', async (c) => { + const viewer = c.get('viewer'); + if (viewer === null) return unauthenticated(c); + const code = cleanCode((await body(c))['code']); + if (code === null) { + return c.json( + { error: { code: 'invalid', message: 'Send the code as {"code": "AJ-..."}.' } }, + 400, + ); + } + const result = await redeemCreditCode(c.get('deps').pool, viewer.id, code); + if (!result.ok) return c.json({ error: { code: result.reason, message: result.message } }, 400); + return c.json({ + ok: true, + addedCents: result.cents, + balanceCents: result.balanceCents, + balance: formatCents(result.balanceCents), + }); + }); + + routes.get('/api/v1/invites', async (c) => { + const viewer = c.get('viewer'); + if (viewer === null) return unauthenticated(c); + return c.json({ + ...(await inviteSummary(c.get('deps').pool, viewer.id, originOf(c))), + bonusCents: c.get('deps').config.invites?.bonusCents ?? 100, + }); + }); + + routes.post('/api/v1/invites', async (c) => { + const viewer = c.get('viewer'); + if (viewer === null) return unauthenticated(c); + const note = (await body(c))['note']; + const result = await createInvite( + c.get('deps').pool, + viewer.id, + typeof note === 'string' ? note : '', + originOf(c), + ); + if (!result.ok) { + return c.json( + { error: { code: 'no_invites_left', message: 'Every invite on this account is used.' } }, + 409, + ); + } + return c.json(result, 201); + }); + + routes.post('/api/v1/admin/credit-codes', async (c) => { + const viewer = c.get('viewer'); + if (!requireAdmin(viewer)) { + return c.json( + { error: { code: 'forbidden', message: 'Administrators only, from a browser session.' } }, + 403, + ); + } + const input = await body(c); + const days = input['expiresInDays']; + try { + const minted = await mintCreditCode(c.get('deps').pool, { + cents: Number(input['cents']), + label: typeof input['label'] === 'string' ? input['label'] : '', + maxRedemptions: input['maxRedemptions'] === undefined ? 1 : Number(input['maxRedemptions']), + expiresInDays: days === undefined || days === null ? null : Number(days), + createdBy: viewer.email, + }); + return c.json( + { + code: minted.code, + id: minted.id, + label: minted.label, + cents: minted.grant.amount, + maxRedemptions: minted.maxRedemptions, + expiresAt: minted.expiresAt === null ? null : new Date(minted.expiresAt).toISOString(), + }, + 201, + ); + } catch (error) { + return c.json( + { error: { code: 'invalid', message: error instanceof Error ? error.message : 'invalid' } }, + 400, + ); + } + }); + + // --- pages ------------------------------------------------------------- + + routes.post('/me/redeem', async (c) => { + const viewer = requireViewer(c); + if (viewer instanceof Response) return viewer; + const code = cleanCode((await formOf(c))['code']); + if (code === null) return c.redirect('/me?credit=invalid#credits', 303); + const result = await redeemCreditCode(c.get('deps').pool, viewer.id, code); + return c.redirect( + result.ok + ? `/me?credit=added¢s=${result.cents}#credits` + : `/me?credit=${result.reason}#credits`, + 303, + ); + }); + + routes.get('/invites', async (c) => { + const viewer = requireViewer(c); + if (viewer instanceof Response) return viewer; + const summary = await inviteSummary(c.get('deps').pool, viewer.id, originOf(c)); + return c.html( + + + , + ); + }); + + routes.post('/invites', async (c) => { + const viewer = requireViewer(c); + if (viewer instanceof Response) return viewer; + const { pool } = c.get('deps'); + const form = await formOf(c); + const result = await createInvite(pool, viewer.id, form['note'] ?? '', originOf(c)); + const summary = await inviteSummary(pool, viewer.id, originOf(c)); + return c.html( + + + , + result.ok ? 201 : 409, + ); + }); + + /** + * An invite link. It only remembers the code: the invite is spent when the + * new account's magic link is, so a link preview fetching this page costs + * nobody anything. Open to crawlers (see the gateway's openPaths). + */ + routes.get('/i/:code', (c) => { + const code = canonicalInvite(c.req.param('code')); + const signedIn = c.get('viewer') !== null; + if (code !== null && !signedIn) rememberInvite(c, code); + return c.html( + + + , + code === null ? 404 : 200, + ); + }); + + return routes; +} diff --git a/src/server/routes/openapi.ts b/src/server/routes/openapi.ts index cc01960..0ebb8be 100644 --- a/src/server/routes/openapi.ts +++ b/src/server/routes/openapi.ts @@ -73,6 +73,11 @@ export function openApiDocument(config: Config): Record { description: 'Searches to be told about. A published listing matching one becomes a notification, an email, and a push message to any subscribed browser.', }, + { + name: 'credits', + description: + 'Credit codes and invites. A code grants credit in US cents; each account has three single-use invites and one open ?ref= link.', + }, { name: 'rankings', description: 'Which listings are read, applied to and paying most.' }, { name: 'auth', description: 'Device flow for terminals, magic links for browsers.' }, { name: 'federation', description: 'The directory of instances, and search across them.' }, @@ -682,6 +687,42 @@ export function openApiDocument(config: Config): Record { responses: { 200: ok('items: Agent[], total.'), 401: err() }, }, }, + '/api/v1/credits': { + get: { + tags: ['credits'], + summary: 'Your credit balance (US cents) and recent history.', + security: [{ bearer: [] }], + responses: { 200: ok('unit, balanceCents, balance, history[].'), 401: err() }, + }, + }, + '/api/v1/credits/redeem': { + post: { + tags: ['credits'], + summary: 'Redeem a credit code: {"code": "AJ-XXXX-XXXX-XXXX-XXXX"}.', + description: + 'Error codes: invalid, expired, used_up, already_redeemed, revoked. Case, spaces and dashes in the code do not matter.', + security: [{ bearer: [] }], + responses: { 200: ok('addedCents, balanceCents, balance.'), 400: err(), 401: err() }, + }, + }, + '/api/v1/invites': { + get: { + tags: ['credits'], + summary: 'Your invite quota, invites, affiliate code and ?ref= link.', + security: [{ bearer: [] }], + responses: { + 200: ok('quota, invites[], affiliateCode, affiliateUrl, bonusCents.'), + 401: err(), + }, + }, + post: { + tags: ['credits'], + summary: + 'Make an invite: {"note": "for Ada"}. The code and its /i/ link are returned once.', + security: [{ bearer: [] }], + responses: { 201: ok('code, url, quota.'), 401: err(), 409: err() }, + }, + }, '/api/v1/watches': { get: { tags: ['watches'], diff --git a/src/server/routes/pages.tsx b/src/server/routes/pages.tsx index 762f3f1..19ce699 100644 --- a/src/server/routes/pages.tsx +++ b/src/server/routes/pages.tsx @@ -73,6 +73,9 @@ import { EmployerDetail, EmployerList, JobDetail, JobList } from '../../views/jo import { DevicePage, LoginPage, SignupPage } from '../../views/auth.tsx'; import { profileStore, saveCarriedProfile } from '../../core/profile.ts'; import { MePage, ProfileCard, ResumeEditor } from '../../views/me.tsx'; +import { CreditsCard } from '../../views/credits.tsx'; +import { creditSummary, formatCents, REDEEM_MESSAGES } from '../../core/credits.ts'; +import { applySignupCredits, carried, pendingInvite } from '../signup-credits.ts'; import { ManageJobPage, NewEmployerPage, PostJobPage } from '../../views/post.tsx'; import { NetworkPage, NetworkSearchPage } from '../../views/network.tsx'; import { DocsPage, SpecPage } from '../../views/docs.tsx'; @@ -906,9 +909,10 @@ export function pageRoutes(): Hono { pages.get('/signup', (c) => { if (c.get('viewer') !== null) return c.redirect('/me', 302); const next = safeRedirect(new URL(c.req.url).searchParams.get('next')); + const invite = pendingInvite(c); return c.html( - + , ); }); @@ -933,13 +937,18 @@ export function pageRoutes(): Hono { } return c.html( - + , 400, ); } - const link = await startMagicLink(pool, email, next, check.profile); + const link = await startMagicLink(pool, email, next, check.profile, carried(c, form)); const url = `${config.publicUrl}/auth/callback?token=${link.token}`; const delivered = await deliverMagicLink({ mailer, @@ -979,7 +988,7 @@ export function pageRoutes(): Hono { } const next = safeRedirect(form['next']); - const link = await startMagicLink(pool, email, next); + const link = await startMagicLink(pool, email, next, null, carried(c, form)); const url = `${config.publicUrl}/auth/callback?token=${link.token}`; const delivered = await deliverMagicLink({ mailer, @@ -1018,6 +1027,8 @@ export function pageRoutes(): Hono { // A signup's profile fields, saved now that the account exists. A failure // is logged, never fatal: /me asks again. await saveCarriedProfile(pool, consumed.user, consumed.profile); + // An invite or ?ref= that came with the sign-up, for a new account only. + await applySignupCredits(c, consumed); const session = await createSession(pool, consumed.user.id, { label: 'browser' }); setSessionCookie(c, session); @@ -1123,6 +1134,8 @@ export function pageRoutes(): Hono { listAgents(pool, viewer.id), ]); const notice = new URL(c.req.url).searchParams.get('coinpay'); + const credit = await creditSummary(pool, viewer.id).catch(() => null); + const creditNotice = creditNoticeOf(new URL(c.req.url).searchParams); const contact = await profileStore(pool) .get(viewer.id) .catch(() => null); @@ -1151,6 +1164,12 @@ export function pageRoutes(): Hono { values={{ ...contact, email: contact?.email ?? viewer.email }} saved={new URL(c.req.url).searchParams.get('profile') === 'saved'} /> + {credit !== null && ( + + )} { return pages; } +function invitePropOf(invite: string | null): { invite?: string } { + return invite === null ? {} : { invite }; +} + +/** The redirect after POST /me/redeem says how it went: ?credit=added¢s=500 or ?credit=. */ +function creditNoticeOf( + params: URLSearchParams, +): { variant: 'success' | 'error'; text: string } | null { + const credit = params.get('credit'); + if (credit === null) return null; + if (credit === 'added') { + const cents = Number.parseInt(params.get('cents') ?? '0', 10) || 0; + return { variant: 'success', text: `Code redeemed: ${formatCents(cents)} added.` }; + } + const reason = credit as keyof typeof REDEEM_MESSAGES; + return { + variant: 'error', + text: REDEEM_MESSAGES[reason] ?? 'That is not a code. Check it and try again.', + }; +} + function setSessionCookie(c: Ctx, token: string): void { const { config } = c.get('deps'); setCookie(c, SESSION_COOKIE, token, { diff --git a/src/server/signup-credits.ts b/src/server/signup-credits.ts new file mode 100644 index 0000000..0a7630d --- /dev/null +++ b/src/server/signup-credits.ts @@ -0,0 +1,106 @@ +/** + * What a sign-up carries besides the address: an invite from /i/ and an + * affiliate code from ?ref=. Both are read from the form or the cookie when the + * link is asked for, saved on the magic link, and applied when it is spent, so + * a link opened in a different browser (a phone's mail app) still counts. + */ + +import type { Context, MiddlewareHandler } from 'hono'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; +import type { AppEnv } from './deps.ts'; +import type { ConsumedLink } from '../core/auth.ts'; +import { + acceptInvite, + attributeRef, + canonicalInvite, + cleanRef, + INVITE_COOKIE, + REF_COOKIE, +} from '../core/credits.ts'; + +type Ctx = Context; + +const THIRTY_DAYS = 30 * 24 * 60 * 60; + +function secure(c: Ctx): boolean { + return c.get('deps').config.publicUrl.startsWith('https://'); +} + +export function rememberInvite(c: Ctx, code: string): void { + setCookie(c, INVITE_COOKIE, code, { + path: '/', + httpOnly: true, + sameSite: 'Lax', + secure: secure(c), + maxAge: THIRTY_DAYS, + }); +} + +/** The invite and ref a sign-up form should carry on its link. */ +export function carried( + c: Ctx, + form: Record = {}, +): { invite: string | null; ref: string | null } { + return { + invite: canonicalInvite(form['invite'] ?? getCookie(c, INVITE_COOKIE)), + ref: cleanRef(form['ref'] ?? getCookie(c, REF_COOKIE)), + }; +} + +/** The invite to show on /signup, from ?invite= or the cookie. */ +export function pendingInvite(c: Ctx): string | null { + return canonicalInvite( + new URL(c.req.url).searchParams.get('invite') ?? getCookie(c, INVITE_COOKIE), + ); +} + +/** + * First touch on ?ref=: the first affiliate code a browser arrives with is + * kept for 30 days and never overwritten. The cookie is @profullstack/ + * referrals' `referral_code`, so a site that adds that package later does not + * count one sign-up twice. + */ +export function trackRef(): MiddlewareHandler { + return async (c, next) => { + if (c.req.method === 'GET') { + const ref = cleanRef(new URL(c.req.url).searchParams.get('ref')); + if (ref !== null && getCookie(c, REF_COOKIE) === undefined) { + setCookie(c, REF_COOKIE, ref, { + path: '/', + httpOnly: true, + sameSite: 'Lax', + secure: secure(c), + maxAge: THIRTY_DAYS, + }); + } + } + await next(); + }; +} + +/** + * Apply an invite and a ref to an account the magic link just created. Only + * a new account: an existing member following an invite link spends nothing + * and earns nothing. Failures are logged and never stop the sign-in. + */ +export async function applySignupCredits(c: Ctx, consumed: ConsumedLink): Promise { + const { pool, config } = c.get('deps'); + const invite = consumed.invite ?? canonicalInvite(getCookie(c, INVITE_COOKIE)); + const ref = consumed.ref ?? cleanRef(getCookie(c, REF_COOKIE)); + if (invite !== null) deleteCookie(c, INVITE_COOKIE, { path: '/' }); + if (consumed.user.created !== true) return; + if (invite !== null) { + try { + await acceptInvite(pool, config, invite, consumed.user); + } catch (error) { + console.error('invite accept failed', error instanceof Error ? error.message : error); + } + } + if (ref !== null) { + try { + await attributeRef(pool, ref, consumed.user.id); + } catch (error) { + console.error('ref attribution failed', error instanceof Error ? error.message : error); + } + } +} diff --git a/src/views/auth.tsx b/src/views/auth.tsx index e59b2ad..12a1700 100644 --- a/src/views/auth.tsx +++ b/src/views/auth.tsx @@ -86,16 +86,24 @@ export const SignupPage: FC<{ next?: string; values?: Record; errors?: Record; -}> = ({ next, values = {}, errors = {} }) => ( + /** An invite code from /i/, carried on the link this form asks for. */ + invite?: string; +}> = ({ next, values = {}, errors = {}, invite }) => (

Create your account

+ {invite !== undefined && ( + + You were invited. The invite is applied when you follow the link we email you. + + )} {Object.keys(errors).length > 0 && ( Some fields need fixing. )}
{next !== undefined && } + {invite !== undefined && } landing page. + */ + +import type { FC } from 'hono/jsx'; +import type { CreditSummary, InviteSummary } from '../core/credits.ts'; +import { formatCents } from '../core/credits.ts'; +import { ago } from '../schema/text.ts'; +import { Alert, Badge, Card, Field } from './layout.tsx'; + +export const CreditsCard: FC<{ + summary: CreditSummary; + notice?: { variant: 'success' | 'error'; text: string }; +}> = ({ summary, notice }) => ( +
+
+

Credit

+ + Invites + +
+ {notice !== undefined && {notice.text}} + +
+
+

{summary.balance}

+

+ Credit on your account. This board charges no listing fee and no commission, so nothing + draws on it yet; it is held for the first thing the board sells. +

+
+
+ + + + + + + {summary.history.length > 0 && ( +
    + {summary.history.map((entry) => ( +
  • + {entry.kind === 'spend' ? '-' : '+'} + {formatCents(entry.cents)} {entry.source}{' '} + {ago(entry.at)} +
  • + ))} +
+ )} +
+
+); + +export const InvitesPage: FC<{ + summary: InviteSummary; + created?: { code: string; url: string }; + error?: string; + bonus: string | null; +}> = ({ summary, created, error, bonus }) => ( +
+
+

Invites

+

+ You have {summary.quota.left} of {summary.quota.total} invites left. Each one works once, + for one new account, for 30 days. + {bonus !== null && + ` When someone joins with yours and confirms their address, you both get ${bonus} in credit.`} +

+
+ {error !== undefined && {error}} + {created !== undefined && ( + + Invite made. Send this link; it is shown once:{' '} + + {created.url} + + + )} + +
+ + + + +
+
+ {summary.invites.length > 0 && ( +
+

Your invites

+
    + {summary.invites.map((invite) => ( +
  • + {invite.used ? ( + Accepted + ) : invite.revoked ? ( + Withdrawn + ) : invite.expiresAt !== null && Date.parse(invite.expiresAt) < Date.now() ? ( + Expired + ) : ( + Open + )}{' '} + {invite.note || No note}{' '} + made {ago(invite.createdAt)} +
  • + ))} +
+
+ )} +
+

Your link

+

+ Open to anyone and never used up. Accounts made after following it are credited to you. + {summary.invitedCount > 0 && ` ${summary.invitedCount} joined with your invites so far.`} +

+ + + {summary.affiliateUrl} + + +
+
+); + +export const InviteLandingPage: FC<{ + boardName: string; + code: string | null; + signedIn: boolean; +}> = ({ boardName, code, signedIn }) => ( +
+
+

{code === null ? 'That invite link is broken' : `You are invited to ${boardName}`}

+ {code === null ? ( +

+ The code in the link is not one this board makes. Ask whoever sent it for a new one. +

+ ) : signedIn ? ( +

+ You already have an account, so this invite is for someone new. Pass it on, or make your + own at /invites. +

+ ) : ( + <> +

+ Someone on {boardName} wanted you here. Invites are scarce: each account gets three. + Create your account with the same email you will read the link in, and the invite is + applied when you confirm it. +

+

+ + Create your account + +

+

+ Invite code {code} +

+ + )} +
+
+); diff --git a/test/api.test.ts b/test/api.test.ts index c94b0e5..270e2e6 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -206,8 +206,19 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, : path.startsWith('/api/v1/agents') ? (agentSlug as string) : slug; - const concrete = path.replace('{slug}', slugFor).replace('{id}', 'x'); - const response = await get(concrete); + let concrete = path.replace('{slug}', slugFor).replace('{id}', 'x'); + let response = await get(concrete); + // Another test file running in parallel makes its agents private and + // deletes them, so a slug read a moment ago can be gone. Read the + // directory again and try what is public now before calling it a lie. + if (response.status === 404 && path.startsWith('/api/v1/agents/{')) { + const fresh = ( + (await (await get('/api/v1/agents')).json()) as { items: { slug: string }[] } + ).items[0]?.slug; + if (fresh === undefined) continue; + concrete = path.replace('{slug}', fresh).replace('{id}', 'x'); + response = await get(concrete); + } // 401 is a pass: the route exists and asked for a credential. assert.notEqual(response.status, 404, `${concrete} is documented but 404s`); checked.push(concrete); diff --git a/test/credits-invites.test.ts b/test/credits-invites.test.ts new file mode 100644 index 0000000..057395a --- /dev/null +++ b/test/credits-invites.test.ts @@ -0,0 +1,289 @@ +/** + * Credit codes and invites (@profullstack/redeem-credits, @profullstack/affiliates). + * + * A code grants credit in US cents. Every account has three single-use + * invites; an accepted invite pays both people a small credit, but only for a + * new account whose address was confirmed by its magic link, never for one + * mailbox inviting itself, and never from an inviter account younger than a + * day. The invite rides on the magic link, so a link opened in another + * browser still counts. + */ +import assert from 'node:assert/strict'; +import { after, describe, test } from 'node:test'; +import type { Hono } from 'hono'; +import { canonicalInvite, cleanRef, formatCents, sameMailbox } from '../dist/core/credits.js'; + +const DATABASE_URL = + process.env['TEST_DATABASE_URL'] ?? + process.env['DATABASE_URL'] ?? + 'postgres://agenticjobs:agenticjobs@localhost:5432/agenticjobs'; + +test('one mailbox under two spellings is one mailbox', () => { + assert.ok(sameMailbox('Ada@Acme.com', 'ada+jobs@acme.com')); + assert.ok(sameMailbox('a.d.a@gmail.com', 'ada+x@googlemail.com')); + assert.ok(!sameMailbox('ada@acme.com', 'ada@acme.org')); + assert.ok(!sameMailbox('a.da@acme.com', 'ada@acme.com'), 'dots only fold on Gmail'); +}); + +test('codes from a URL or a cookie are cleaned before anything reads them', () => { + assert.equal(canonicalInvite('inv 7k3m qx9d 2f8h'), 'INV-7K3M-QX9D-2F8H'); + assert.equal(canonicalInvite('