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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ command. Exit codes: 0 ok, 1 error, 2 usage, 3 gate not met, 4 jobs waiting for
- **Dashboard** at `/app`: coverage, portfolio, campaigns (preview, approve, run, pause, cancel),
human-action queue, accounts, reports with Copy Markdown and downloads.

## Credit codes and invites

- **Credit codes** (`@profullstack/redeem-credits`) grant Pro days. Redeem under Settings > Plan
("Have a code?"), `POST /api/v1/credits/redeem`, `submitfleet redeem CODE` or the
`submitfleet_redeem_code` MCP tool. Every grant extends `users.pro_until` through one path,
idempotent on (user, source). Mint on the server: `submitfleet credits mint --days 30 [--max 1]
[--expires-days 30] [--label L]` (also `credits list`, `credits revoke ID`), run against the
local database (on the hosted box: `docker exec <app container> node dist/cli.js credits mint ...`).
- **Invites** (`@profullstack/affiliates`): 3 per account at `/invites`, landing at `/i/<code>`.
The code is remembered on the device and on the emailed sign-in link. A new account (7 days or
younger) accepts one invite; both people get `SUBMITFLEET_INVITE_BONUS_DAYS` (default 14) Pro
days, refused for the same mailbox (+tags, Gmail dots), throwaway inboxes, the same network,
`?ref=` sign-ups and past `SUBMITFLEET_INVITE_MONTHLY_CAP` (default 5) bonuses per inviter per 30 days.
- `?ref=<code>` on any page is first-touch affiliate attribution (cookie `referral_code`).

## Adapters

Every adapter implements `capabilities`, `validate`, `prepare` (no remote writes), `preflight`,
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/submitfleet",
"version": "0.3.2",
"version": "0.4.0",
"description": "SubmitFleet: launch your product on 339 launch sites and directories from one profile. Prepared submissions, accounts with passkeys, a tracked launch queue, CLI, MCP server and API.",
"type": "module",
"license": "MIT",
Expand Down Expand Up @@ -51,11 +51,14 @@
"@hono/node-server": "2.1.5",
"@libsql/client": "0.18.0",
"@modelcontextprotocol/sdk": "1.32.2",
"@profullstack/affiliates": "0.3.0",
"@profullstack/footer": "^0.4.0",
"@profullstack/profile": "0.1.6",
"@profullstack/redeem-credits": "0.2.0",
"@profullstack/x402-gateway": "^0.13.1",
"@simplewebauthn/server": "^14.0.3",
"hono": "4.13.14",
"mailchecker": "6.0.21",
"undici": "^8.12.3",
"zod": "4.6.7"
},
Expand Down
44 changes: 44 additions & 0 deletions pnpm-lock.yaml

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

16 changes: 12 additions & 4 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,17 +172,25 @@ export async function recentEmailCount(db: Db, email: string, minutes = 15, now
* an address they cannot read); a sign-in link for an unknown address also creates it,
* which makes the emailed link the way in from nothing.
*/
export async function consumeEmailToken(db: Db, token: string, now = Date.now()): Promise<{ user: User; created: boolean; purpose: LinkPurpose }> {
export interface LinkMeta {
/** Invite code carried on the emailed link, so another browser or phone still counts it. */
invite_code?: string | null;
/** First-touch ?ref= affiliate code, carried the same way. */
ref_code?: string | null;
}

export async function consumeEmailToken(db: Db, token: string, now = Date.now()): Promise<{ user: User; created: boolean; purpose: LinkPurpose; meta: LinkMeta }> {
const h = sha256(token);
const ts = new Date(now).toISOString();
const n = await run(db, 'UPDATE email_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?', ts, h, ts);
if (n !== 1) throw new SubmitFleetError('link_invalid', 'this link has expired or was already used; request a new one');
const row = (await one<{ email: string; purpose: LinkPurpose; data_json: string }>(db, 'SELECT email, purpose, data_json FROM email_tokens WHERE token_hash = ?', h))!;
const data = parseJson<{ name?: string; password_hash?: string; profile?: Record<string, string | null> }>(row.data_json, {});
const data = parseJson<{ name?: string; password_hash?: string; profile?: Record<string, string | null> } & LinkMeta>(row.data_json, {});
const meta: LinkMeta = { invite_code: data.invite_code ?? null, ref_code: data.ref_code ?? null };
const existing = await getUserByEmail(db, row.email);
if (existing) {
await run(db, 'UPDATE users SET email_verified_at = COALESCE(email_verified_at, ?), failed_logins = 0, locked_until = NULL WHERE id = ?', ts, existing.id);
return { user: existing, created: false, purpose: row.purpose };
return { user: existing, created: false, purpose: row.purpose, meta };
}
const user = await createUser(db, row.email, { name: data.name ?? null, passwordHash: data.password_hash ?? null });
if (data.profile) {
Expand All @@ -191,7 +199,7 @@ export async function consumeEmailToken(db: Db, token: string, now = Date.now())
console.error('[submitfleet] signup profile save failed:', e instanceof Error ? e.message : e),
);
}
return { user, created: true, purpose: row.purpose };
return { user, created: true, purpose: row.purpose, meta };
}

// ── Passkeys ──
Expand Down
39 changes: 39 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ Campaigns
report export ID [--format markdown|json|csv]
effort log --minutes M --activity TEXT [--campaign ID]

Plan, codes and invites (signed in)
plan your plan and Pro days
redeem CODE redeem a credit code for Pro days
invites [list] | invites create [--note TEXT] | invites accept CODE

Credit codes (admin, local database)
credits mint --days N [--max 1] [--expires-days D] [--label L] prints the code once
credits list | credits revoke ID

Run
worker [--once] [--interval-ms 5000]
serve [--port 8787] [--no-worker]
Expand Down Expand Up @@ -155,6 +164,10 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
url: { type: 'string' },
'no-browser': { type: 'boolean' },
key: { type: 'string' },
days: { type: 'string' },
max: { type: 'string' },
'expires-days': { type: 'string' },
note: { type: 'string' },
},
});
const [cmd, sub, arg] = pos;
Expand Down Expand Up @@ -206,6 +219,18 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
const num = (v: unknown, d?: number) => (v === undefined ? d : Number(v));
try {
switch (cmd) {
case 'credits': {
if (sub === 'mint') {
if (!o.days) return usage('--days is required');
const m = await fleet.rewards.mint({ days: Number(o.days), maxRedemptions: o.max ? Number(o.max) : 1, expiresInDays: o['expires-days'] ? Number(o['expires-days']) : null, label: o.label as string | undefined, createdBy: 'cli' });
log('Give this code to the person. It is shown once; only its hash is stored.');
out({ code: m.code, id: m.id, days: m.grant.amount, max_redemptions: m.maxRedemptions, expires_at: m.expiresAt ? new Date(m.expiresAt).toISOString() : null, label: m.label });
return EXIT.ok;
}
if (sub === 'list') return out(await fleet.rewards.credits.list()), EXIT.ok;
if (sub === 'revoke' && arg) return await fleet.rewards.credits.revoke(arg), out({ revoked: arg }), EXIT.ok;
return usage();
}
case 'init': {
const k = await createApiKey(fleet.db, ws, 'owner', 'local owner');
log('Workspace ready. Store this owner key now; it is not shown again:');
Expand Down Expand Up @@ -510,6 +535,20 @@ async function runRemote(cmd: string, sub: string | undefined, arg: string | und
return null;
case 'queue':
return show(await remote('GET', '/human-queue'));
case 'plan':
return show(await remote('GET', '/plan'));
case 'redeem':
if (!sub) return null;
return show(await remote('POST', '/credits/redeem', { code: sub }));
case 'invites':
if (!sub || sub === 'list') return show(await remote('GET', '/invites'));
if (sub === 'create') {
const inv = (await remote('POST', '/invites', { note: (o.note as string) ?? undefined })) as { code: string };
const a = await accessToken();
return show({ ...inv, link: `${a.url.replace(/\/+$/, '')}/i/${inv.code}` });
}
if (sub === 'accept' && arg) return show(await remote('POST', '/invites/accept', { code: arg }));
return null;
case 'submission':
if (sub === 'get' && arg) return show(await remote('GET', `/submissions/${arg}`));
if (sub === 'kit' && arg) return show(await remote('GET', `/submissions/${arg}/kit`));
Expand Down
2 changes: 1 addition & 1 deletion src/crawl-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function createCrawlGate(env: Record<string, string | undefined> = proces
coinpay: { apiKey },
payTo,
contact: 'mailto:anthony@profullstack.com',
openPaths: ['/healthz', '/readyz', '/install.sh', '/llms.txt', '/api/', '/mcp', '/oauth/'],
openPaths: ['/healthz', '/readyz', '/install.sh', '/llms.txt', '/api/', '/mcp', '/oauth/', '/i/', '/invites'],
exempt: (request: Request) => session.test(request.headers.get('cookie') ?? ''),
});
}
38 changes: 37 additions & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { z } from 'zod';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Mailer } from './mail.js';
import { mountWeb, resolveActor, sameOrigin, WEB_ROOT } from './web.js';
import { clientIp, mountWeb, resolveActor, sameOrigin, WEB_ROOT } from './web.js';
import { allow } from './auth.js';
import { buildMcpServer } from './mcp.js';
import { createCrawlGate, type CrawlGate } from './crawl-gate.js';
import { x402Gateway } from '@profullstack/x402-gateway/hono';
Expand Down Expand Up @@ -46,6 +47,8 @@ const STATUS: Record<string, number> = {
url_unresolvable: 400,
fetch_failed: 502,
mail_unavailable: 503,
person_required: 403,
invites_exhausted: 409,
};

const productBody = z.object({
Expand Down Expand Up @@ -169,6 +172,39 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[];
},
});

// ── Plan, credit codes and invites (a signed-in person, not a workspace key) ──
const person = async (c: Context<Env>) => {
const id = A(c).id;
const user = id.startsWith('user:') ? await getUser(fleet.db, id.slice(5)) : null;
if (!user) throw new SubmitFleetError('person_required', 'sign in as a person (browser or submitfleet login); workspace API keys have no plan');
return user;
};
const throttle = (c: Context<Env>, key: string, n: number, minutes: number) => {
if (!allow(`${key}:${A(c).id}`, n, minutes * 60_000) || !allow(`${key}:ip:${clientIp(c)}`, n * 3, minutes * 60_000)) {
throw new SubmitFleetError('rate_limited', 'too many attempts; wait a few minutes');
}
};
api.get('/plan', async (c) => c.json(await fleet.rewards.plan((await person(c)).id)));
api.post('/credits/redeem', async (c) => {
const u = await person(c);
throttle(c, 'redeem', 10, 10);
const b = await body(c, z.object({ code: z.string().min(1).max(64) }));
return c.json(await fleet.rewards.redeem(u.id, b.code));
});
api.get('/invites', async (c) => c.json(await fleet.rewards.invites((await person(c)).id)));
api.post('/invites', async (c) => {
const u = await person(c);
throttle(c, 'invite-create', 10, 60);
const b = await body(c, z.object({ note: z.string().max(80).optional() }));
return c.json(await fleet.rewards.createInvite(u, b.note, clientIp(c)), 201);
});
api.post('/invites/accept', async (c) => {
const u = await person(c);
throttle(c, 'invite-accept', 10, 10);
const b = await body(c, z.object({ code: z.string().min(1).max(64) }));
return c.json(await fleet.rewards.acceptInvite(u.id, b.code, clientIp(c)));
});

api.get('/whoami', (c) => c.json({ workspace_id: A(c).workspaceId, role: A(c).role, key_id: A(c).id }));

api.get('/products', async (c) => c.json(await fleet.productList(A(c), page(c).limit, page(c).offset)));
Expand Down
14 changes: 13 additions & 1 deletion src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { SubmitFleet } from './service.js';
import { SubmitFleetError } from './util.js';
import { allow } from './auth.js';
import type { Actor } from './workspaces.js';

export const MCP_SERVER_VERSION = '0.3.2';
export const MCP_SERVER_VERSION = '0.4.0';

type Json = unknown;

Expand Down Expand Up @@ -51,6 +52,17 @@ export function buildMcpServer(fleet: SubmitFleet, actor: Actor): McpServer {
const write = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
const read = { readOnlyHint: true, openWorldHint: false };

// Plan and credit codes act for the signed-in person (CLI login / OAuth token), not a workspace key.
const personId = () => {
if (!actor.id.startsWith('user:')) throw new SubmitFleetError('person_required', 'sign in as a person (submitfleet login); workspace API keys have no plan');
return actor.id.slice(5);
};
s.registerTool('submitfleet_plan', { description: 'The signed-in person\'s plan: free or pro, and until when.', inputSchema: {}, annotations: read }, wrap(async () => fleet.rewards.plan(personId())));
s.registerTool('submitfleet_redeem_code', { description: 'Redeem a credit code someone gave the signed-in person, for Pro days.', inputSchema: { code: z.string().min(1).max(64) }, annotations: write }, wrap(async (a) => {
if (!allow(`redeem:${actor.id}`, 10, 600_000)) throw new SubmitFleetError('rate_limited', 'too many attempts; wait a few minutes');
return fleet.rewards.redeem(personId(), a.code);
}));

s.registerTool('submitfleet_product_create', { description: 'Create a product profile from its canonical URL and facts you can source. Facts must be approved by the owner before any campaign can use them.', inputSchema: { url: z.string().url(), ...productFields }, annotations: write }, wrap((a) => fleet.productCreate(actor, a as never)));
s.registerTool('submitfleet_product_get', { description: 'Get one product.', inputSchema: { id: z.string() }, annotations: read }, wrap((a) => fleet.productGet(actor, a.id)));
s.registerTool('submitfleet_product_list', { description: 'List products in this workspace.', inputSchema: page, annotations: read }, wrap((a) => fleet.productList(actor, a.limit, a.offset)));
Expand Down
36 changes: 36 additions & 0 deletions src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,42 @@ ON CONFLICT (user_id) DO UPDATE SET
email = COALESCE(user_profiles.email, excluded.email),
first_name = COALESCE(user_profiles.first_name, excluded.first_name),
last_name = COALESCE(user_profiles.last_name, excluded.last_name)
`,
},
{
// Plan + credit codes + invites. users.pro_until is the plan; plan_grants makes every
// grant idempotent on (user, source). The credit_* / aff_* module tables are created by
// @profullstack/redeem-credits and @profullstack/affiliates on first use.
version: 4,
sql: `
ALTER TABLE users ADD COLUMN pro_until TEXT;

CREATE TABLE plan_grants (
user_id TEXT NOT NULL REFERENCES users(id),
source TEXT NOT NULL,
days INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, source)
);

CREATE TABLE invite_origins (
invite_id TEXT PRIMARY KEY,
ip_hash TEXT,
created_at TEXT NOT NULL
);

CREATE TABLE invite_acceptances (
user_id TEXT PRIMARY KEY REFERENCES users(id),
inviter_id TEXT NOT NULL REFERENCES users(id),
invite_id TEXT NOT NULL UNIQUE,
ip_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'granted', 'denied')),
reason TEXT NOT NULL DEFAULT '',
bonus_days INTEGER NOT NULL DEFAULT 0,
accepted_at TEXT NOT NULL,
settled_at TEXT
);
CREATE INDEX invite_acceptances_inviter ON invite_acceptances (inviter_id, status, settled_at)
`,
},
];
Loading
Loading