Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>`, 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 <id>
```

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/<code>` 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
Expand Down
72 changes: 72 additions & 0 deletions migrations/0025_credits_and_invites.sql
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
43 changes: 39 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 126 additions & 0 deletions src/cli/credits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Credit and invites from a terminal.
*
* credits balance and history (the board, over the API)
* redeem <code> 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<number> {
if (args.command === 'redeem') {
const code = args.positional[0] ?? '';
if (code === '') {
process.stderr.write('agenticjobs redeem <code>\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<number> {
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 <id> (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();
}
}
Loading
Loading