fix: self-service signup, unified key store, persistent data volume

- Added /v1/signup/free endpoint for instant API key provisioning
- Built unified key store (services/keys.ts) with file-based persistence
- Refactored auth middleware to use key store (no more hardcoded env keys)
- Refactored usage middleware to check key tier from store
- Updated billing to use key store for Pro key provisioning
- Landing page: replaced mailto: link with signup modal
- Landing page: Pro checkout button now properly calls /v1/billing/checkout
- Added Docker volume for persistent key storage
- Success page now renders HTML instead of raw JSON
- Tested: signup → key → PDF generation works end-to-end
This commit is contained in:
DocFast Bot 2026-02-14 14:20:05 +00:00
parent c12c1176b0
commit 467a97ae1c
9 changed files with 361 additions and 126 deletions

118
src/services/keys.ts Normal file
View file

@ -0,0 +1,118 @@
import { randomBytes } from "crypto";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, "../../data");
const KEYS_FILE = path.join(DATA_DIR, "keys.json");
export interface ApiKey {
key: string;
tier: "free" | "pro";
email: string;
createdAt: string;
stripeCustomerId?: string;
}
interface KeyStore {
keys: ApiKey[];
}
let store: KeyStore = { keys: [] };
function ensureDataDir(): void {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
}
}
export function loadKeys(): void {
ensureDataDir();
if (existsSync(KEYS_FILE)) {
try {
store = JSON.parse(readFileSync(KEYS_FILE, "utf-8"));
} catch {
store = { keys: [] };
}
}
// Also load seed keys from env
const envKeys = process.env.API_KEYS?.split(",").map((k) => k.trim()).filter(Boolean) || [];
for (const k of envKeys) {
if (!store.keys.find((e) => e.key === k)) {
store.keys.push({ key: k, tier: "pro", email: "seed@docfast.dev", createdAt: new Date().toISOString() });
}
}
}
function save(): void {
ensureDataDir();
writeFileSync(KEYS_FILE, JSON.stringify(store, null, 2));
}
export function isValidKey(key: string): boolean {
return store.keys.some((k) => k.key === key);
}
export function getKeyInfo(key: string): ApiKey | undefined {
return store.keys.find((k) => k.key === key);
}
export function isProKey(key: string): boolean {
const info = getKeyInfo(key);
return info?.tier === "pro";
}
function generateKey(prefix: string): string {
return `${prefix}_${randomBytes(24).toString("hex")}`;
}
export function createFreeKey(email: string): ApiKey {
// Check if email already has a free key
const existing = store.keys.find((k) => k.email === email && k.tier === "free");
if (existing) return existing;
const entry: ApiKey = {
key: generateKey("df_free"),
tier: "free",
email,
createdAt: new Date().toISOString(),
};
store.keys.push(entry);
save();
return entry;
}
export function createProKey(email: string, stripeCustomerId: string): ApiKey {
const existing = store.keys.find((k) => k.stripeCustomerId === stripeCustomerId);
if (existing) {
existing.tier = "pro";
save();
return existing;
}
const entry: ApiKey = {
key: generateKey("df_pro"),
tier: "pro",
email,
createdAt: new Date().toISOString(),
stripeCustomerId,
};
store.keys.push(entry);
save();
return entry;
}
export function revokeByCustomer(stripeCustomerId: string): boolean {
const idx = store.keys.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
if (idx >= 0) {
store.keys.splice(idx, 1);
save();
return true;
}
return false;
}
export function getAllKeys(): ApiKey[] {
return [...store.keys];
}