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
6 changes: 6 additions & 0 deletions bun.lock

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

7 changes: 5 additions & 2 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ Installed, the command is `smshub-cli` (`npm i -g @profullstack/smshub` works to
| `numbers` | your numbers, with expiry for rented ones |
| `rent [--area 415] [--months 1] [--chain USDC_POL] [--no-wait]` | open a checkout; waits until the number is ready |
| `order <id>` | status of a rent/renew order |
| `renew <number> [--months 1]` | extend a rented number |
| `renew <number> [--months 1] [--credit]` | extend a rented number (`--credit` pays from store credit) |
| `redeem <code>` | redeem a credit code for store credit (pays for rentals: `rent --credit`) |
| `credits` | store credit balance and history |
| `invites [new --note ..]` | your 3 invites (an accepted one gives you both $2.50 credit) and your `?ref=` link |
| `messages <number> [--since ISO] [--limit 20]` | texts received, newest first, codes extracted |
| `otp <number> [--wait 60] [--recent 300]` | print the next one-time code (exit 2 if none) |
| `send <to> "<message>" [--from <number>] [--media <url>]` | text someone from your own Twilio/Telnyx number (rented numbers receive only) |
Expand Down Expand Up @@ -58,7 +61,7 @@ Local (stdio), e.g. for Claude Code:
claude mcp add smshub -e SMSHUB_API_KEY=smshub_... -- npx -y @profullstack/smshub mcp
```

Tools: `account_overview`, `list_numbers`, `list_conversations`, `get_conversation`, `send_message`, `list_lines`, `add_line_contact`, `update_line_contact`, `remove_line_contact`, `share_line_contacts`, `voice_menu`, `rent_number`, `renew_number`, `order_status`,
Tools: `account_overview`, `list_numbers`, `list_conversations`, `get_conversation`, `send_message`, `list_lines`, `add_line_contact`, `update_line_contact`, `remove_line_contact`, `share_line_contacts`, `voice_menu`, `rent_number`, `renew_number`, `redeem_code`, `credit_balance`, `list_invites`, `create_invite`, `order_status`,
`list_orders`, `get_messages`, `wait_for_code`.

Rented numbers receive texts only. Some services refuse virtual numbers for sign-up codes.
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/smshub",
"version": "1.6.0",
"version": "1.7.0",
"description": "Rent a phone number and receive SMS / verification codes from your terminal, scripts and AI agents (CLI, TUI, MCP) via smshub.dev",
"type": "module",
"bin": {
Expand Down
49 changes: 42 additions & 7 deletions cli/smshub.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
//
// smshub-cli login --key smshub_... save an API key (Settings > API Keys)
// smshub-cli numbers your numbers
// smshub-cli rent [--area 415] [--months 1] [--chain USDC_POL]
// smshub-cli rent [--area 415] [--months 1] [--chain USDC_POL | --credit] --credit pays from store credit
// smshub-cli order <order-id>
// smshub-cli renew <number> [--months 1]
// smshub-cli renew <number> [--months 1] [--credit]
// smshub-cli redeem <code> redeem a credit code for store credit
// smshub-cli credits store credit balance and history
// smshub-cli invites [new [--note "for Kim"]] your invites (3), make one, your ?ref= link
// smshub-cli messages <number> [--since ISO] [--limit 20]
// smshub-cli otp <number> [--wait 60] print the next code, nothing else
// smshub-cli send <to> "<message>" [--from <number>] [--media <url>] text someone from your own number
Expand All @@ -31,7 +34,7 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";

const VERSION = "1.6.0";
const VERSION = "1.7.0";
const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "smshub");
const CONFIG_FILE = join(CONFIG_DIR, "config.json");

Expand Down Expand Up @@ -143,12 +146,16 @@ const commands = {
async rent(args) {
const { order } = await api("/api/v1/numbers/orders", {
method: "POST",
body: { area_code: args.area, months: Number(args.months || 1), chain: args.chain },
body: { area_code: args.area, months: Number(args.months || 1), chain: args.credit ? "CREDIT" : args.chain },
});
if (args.json && args["no-wait"]) return console.log(JSON.stringify(order, null, 2));
if (!args.json) {
console.log(`Order ${order.id}: $${Number(order.amount_usd).toFixed(2)} in ${order.chain}`);
console.log(`Pay here: ${order.pay_url}`);
if (order.pay_url) console.log(`Pay here: ${order.pay_url}`);
}
if (order.status === "active" || order.status === "failed") {
if (args.json) return console.log(JSON.stringify(order, null, 2));
return console.log(order.status === "active" ? `Your number: ${order.number}` : `${order.status}: ${order.error || ""}`);
}
if (args["no-wait"]) return;
if (!args.json) process.stdout.write("Waiting for the payment to confirm (Ctrl+C to stop; check later with: smshub-cli order " + order.id + ")");
Expand All @@ -173,9 +180,37 @@ const commands = {
const id = await resolveNumberId(args._[1]);
const { order } = await api(`/api/v1/numbers/${id}/renew`, {
method: "POST",
body: { months: Number(args.months || 1), chain: args.chain },
body: { months: Number(args.months || 1), chain: args.credit ? "CREDIT" : args.chain },
});
print(args, order, (o) => console.log(o.pay_url ? `Pay here: ${o.pay_url}` : `${o.status}${o.number ? ": " + o.number : ""}`));
},

async redeem(args) {
const code = args._[1] || args.code;
if (!code) throw new ApiError("Usage: smshub-cli redeem <code>");
const r = await api("/api/v1/redeem", { method: "POST", body: { code } });
print(args, r, (r) => console.log(`Added $${Number(r.credit_usd).toFixed(2)}. Store credit: $${Number(r.balance_usd).toFixed(2)}`));
},

async credits(args) {
const d = await api("/api/v1/credits");
print(args, d, (d) => {
console.log(`Store credit: $${d.balance_usd.toFixed(2)} (pay with it: smshub-cli rent --credit)`);
for (const h of d.history.slice(0, 10)) console.log(`${h.at.slice(0, 10)} ${h.kind === "spend" ? "-" : "+"}$${h.amount_usd.toFixed(2)} ${h.source}`);
});
},

async invites(args) {
if (args._[1] === "new") {
const r = await api("/api/v1/invites", { method: "POST", body: { note: args.note } });
return print(args, r, (r) => console.log(`Invite (shown once): ${r.url}`));
}
const d = await api("/api/v1/invites");
print(args, d, (d) => {
console.log(`${d.quota.left} of ${d.quota.total} invites left. Accepted invite: $${d.bonus_usd.toFixed(2)} credit each.`);
for (const i of d.invites) console.log(`${i.created_at.slice(0, 10)} ${i.used ? "accepted" : "open"} ${i.note || ""}`);
console.log(`Referral link: ${d.referral_link}`);
});
print(args, order, (o) => console.log(`Pay here: ${o.pay_url}`));
},

async lines(args) {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
]
},
"dependencies": {
"@profullstack/affiliates": "^0.2.0",
"@profullstack/footer": "latest",
"@profullstack/profile": "0.1.6",
"@profullstack/redeem-credits": "^0.2.0",
"@profullstack/referrals": "^0.1.0",
"@profullstack/stack": "^0.5.0",
"@profullstack/throttle": "^0.3.4",
Expand Down
56 changes: 56 additions & 0 deletions scripts/mint-credit-code.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// Mint a smshub.dev credit code (store credit that pays for rented numbers).
//
// SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... node scripts/mint-credit-code.mjs --usd 10 --label "for Kim"
// [--max 1] how many accounts may redeem it (default 1)
// [--expires 30d] days until it stops working (default: never)
// [--by riotcoder] who minted it, for the audit trail
// ... node scripts/mint-credit-code.mjs --list
// ... node scripts/mint-credit-code.mjs --revoke crd_xxx
//
// On dev2 with the app's env: set -a; . /home/anthony/www/smshub.dev/app.env; set +a
// The plaintext code is printed once; only its sha256 is stored.

import { createClient } from "@supabase/supabase-js";
import { Credits, supabaseStore } from "@profullstack/redeem-credits";

const args = process.argv.slice(2);
const opt = (name) => {
const i = args.indexOf(`--${name}`);
return i >= 0 ? args[i + 1] : undefined;
};
const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) {
console.error("Set SUPABASE_URL (or NEXT_PUBLIC_SUPABASE_URL) and SUPABASE_SERVICE_ROLE_KEY");
process.exit(1);
}
const credits = new Credits(supabaseStore(createClient(url, key, { auth: { persistSession: false } })));

if (args.includes("--list")) {
for (const c of await credits.list()) {
console.log(`${c.id} ${c.grant.unit}=${c.grant.amount} ${c.redemptions}/${c.maxRedemptions} ${c.revokedAt ? "revoked" : "live"} ${c.label}`);
}
process.exit(0);
}
if (opt("revoke")) {
await credits.revoke(opt("revoke"));
console.log(`revoked ${opt("revoke")}`);
process.exit(0);
}

const usd = Number(opt("usd"));
if (!Number.isFinite(usd) || usd <= 0 || usd > 1000) {
console.error("--usd <amount> is required (0 < usd <= 1000)");
process.exit(1);
}
const days = opt("expires") ? Number(String(opt("expires")).replace(/d$/, "")) : null;
const minted = await credits.mint({
grant: { unit: "usd_cents", amount: Math.round(usd * 100) },
label: opt("label") || `smshub $${usd}`,
maxRedemptions: Number(opt("max") || 1),
expiresAt: days ? Date.now() + days * 86400_000 : null,
createdBy: opt("by") || "admin",
});
console.log(JSON.stringify({ code: minted.code, id: minted.id, usd, max: minted.maxRedemptions, expires_at: minted.expiresAt }, null, 2));
console.log(`Redeem at https://smshub.dev/settings#redeem`);
17 changes: 17 additions & 0 deletions src/app/api/v1/credits/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { resolveUser } from "@/lib/request-user";
import { creditSummary } from "@/lib/rewards";

export const dynamic = "force-dynamic";

/** Store credit balance (USD) and the latest grants and spends. */
export async function GET(request: Request) {
const who = await resolveUser(request);
if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status });
try {
return NextResponse.json(await creditSummary(who.user.userId));
} catch (e) {
console.error("credits error:", e);
return NextResponse.json({ error: "Could not load credit" }, { status: 500 });
}
}
35 changes: 35 additions & 0 deletions src/app/api/v1/invites/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { resolveUser } from "@/lib/request-user";
import { getBrandSiteUrl } from "@/lib/brand-server";
import { checkRateLimit } from "@/lib/rate-limit";
import { createInvite, invitesSummary } from "@/lib/rewards";

export const dynamic = "force-dynamic";

/** Invite quota (3 per account), the invites made so far, and the ?ref= link. */
export async function GET(request: Request) {
const who = await resolveUser(request);
if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status });
try {
return NextResponse.json(await invitesSummary(who.user.userId, await getBrandSiteUrl(request)));
} catch (e) {
console.error("invites error:", e);
return NextResponse.json({ error: "Could not load invites" }, { status: 500 });
}
}

/** Create an invite: { note? } -> { code, url }. The code is shown once. */
export async function POST(request: Request) {
const who = await resolveUser(request);
if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status });
const rl = checkRateLimit(`invite:${who.user.userId}`, { limit: 10, windowMs: 60 * 60 * 1000 });
if (!rl.allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
try {
const r = await createInvite(who.user.userId, await getBrandSiteUrl(request), body.note);
return NextResponse.json(r, { status: r.ok ? 201 : 403 });
} catch (e) {
console.error("create invite error:", e);
return NextResponse.json({ error: "Could not create the invite" }, { status: 500 });
}
}
26 changes: 26 additions & 0 deletions src/app/api/v1/redeem/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { resolveUser } from "@/lib/request-user";
import { checkRateLimit } from "@/lib/rate-limit";
import { redeemCode } from "@/lib/rewards";

export const dynamic = "force-dynamic";

/**
* Redeem a credit code: { code } -> { ok, credit_usd, balance_usd }.
* Store credit pays for rented numbers (order chain "CREDIT").
* Session (web app) or API key (CLI, scripts).
*/
export async function POST(request: Request) {
const who = await resolveUser(request);
if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status });
const rl = checkRateLimit(`redeem:${who.user.userId}`, { limit: 10, windowMs: 15 * 60 * 1000 });
if (!rl.allowed) return NextResponse.json({ error: "Too many tries, wait a few minutes" }, { status: 429 });
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
try {
const r = await redeemCode(who.user.userId, body.code);
return NextResponse.json(r, { status: r.ok ? 200 : 400 });
} catch (e) {
console.error("redeem error:", e);
return NextResponse.json({ error: "Could not redeem the code" }, { status: 500 });
}
}
20 changes: 17 additions & 3 deletions src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { NextResponse, type NextRequest } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getBrandSiteUrl } from "@/lib/brand-server";
import { safeNextPath } from "@/lib/safe-next";
import { saveProfileFromMetadata } from "@/lib/profile";
import { profileStore, saveProfileFromMetadata } from "@/lib/profile";
import { INVITE_COOKIE, onConfirmedSignIn } from "@/lib/rewards";

export { safeNextPath };

Expand Down Expand Up @@ -32,6 +33,7 @@ export async function GET(request: NextRequest) {
const next = safeNextPath(url.searchParams.get("next"));

let signedIn = false;
const inviteCookie = request.cookies.get(INVITE_COOKIE)?.value ?? null;

try {
const supabase = await createServerSupabaseClient();
Expand All @@ -49,14 +51,26 @@ export async function GET(request: NextRequest) {
} = await supabase.auth.getUser();
signedIn = Boolean(user);
// First confirmation: store the /register profile fields (never blocks).
if (user) await saveProfileFromMetadata(user);
if (user) {
await saveProfileFromMetadata(user);
// A new, confirmed account: spend its invite (both people get credit) and
// record ?ref= first touch. Never blocks sign-in.
await onConfirmedSignIn(request, user, inviteCookie, () =>
profileStore()
.get(user.id)
.then(Boolean)
.catch(() => false)
);
}
} catch (error) {
console.error("Auth callback error:", error);
signedIn = false;
}

if (signedIn) {
return NextResponse.redirect(new URL(next, site));
const res = NextResponse.redirect(new URL(next, site));
if (inviteCookie) res.cookies.delete(INVITE_COOKIE);
return res;
}

const failure = new URL("/login", site);
Expand Down
30 changes: 30 additions & 0 deletions src/app/i/[code]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from "next/server";
import { getBrandSiteUrl } from "@/lib/brand-server";
import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE } from "@/lib/rewards";

export const dynamic = "force-dynamic";

/**
* /i/<code>: an invite link. Keeps the code in a 7-day cookie (read on
* /auth/callback when the new account confirms its email) and lands on the
* sign-up page, which says what the invite gives and also carries the code in
* the account's metadata. The code is checked only when it is spent.
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ code: string }> }) {
const { code: raw } = await params;
const code = decodeURIComponent(raw || "").trim();
const site = await getBrandSiteUrl(request);
if (!/^[A-Za-z0-9-]{4,40}$/.test(code)) return NextResponse.redirect(new URL("/register", site));
const to = new URL("/register", site);
to.searchParams.set("invite", code);
to.searchParams.set("next", "/numbers");
const res = NextResponse.redirect(to);
res.cookies.set(INVITE_COOKIE, code, {
httpOnly: true,
sameSite: "lax",
secure: site.startsWith("https://"),
maxAge: INVITE_COOKIE_MAX_AGE,
path: "/",
});
return res;
}
Loading
Loading