Initial import of zappier-edge from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 16:05:17 -04:00
commit 2e035b1e80
128 changed files with 20545 additions and 0 deletions

71
src/db/admin-user-repo.ts Normal file
View file

@ -0,0 +1,71 @@
import Database from 'better-sqlite3';
import { AdminUser, AdminUserRepo, makeAdminUser } from '../admin-users';
/** SQLite-backed admin accounts; seeds from env only when the table is empty. */
export class SqliteAdminUserRepo implements AdminUserRepo {
private constructor(private db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS admin_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
created_ms INTEGER NOT NULL
)
`);
}
static seeded(
db: Database.Database,
seed: { username: string; password: string }[],
): SqliteAdminUserRepo {
const repo = new SqliteAdminUserRepo(db);
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get() as { n: number };
if (n === 0) {
for (const s of seed) repo.save(makeAdminUser(s.username, s.password));
}
return repo;
}
list(): AdminUser[] {
const rows = this.db
.prepare('SELECT * FROM admin_users ORDER BY username')
.all() as Record<string, unknown>[];
return rows.map(toAdminUser);
}
findByUsername(username: string): AdminUser | undefined {
const r = this.db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as
| Record<string, unknown>
| undefined;
return r ? toAdminUser(r) : undefined;
}
save(user: AdminUser): void {
this.db
.prepare(
`INSERT INTO admin_users (id, username, password_hash, active, created_ms)
VALUES (@id, @username, @passwordHash, @active, @createdMs)
ON CONFLICT(username) DO UPDATE SET
password_hash = @passwordHash,
active = @active`,
)
.run({
id: user.id,
username: user.username,
passwordHash: user.passwordHash,
active: user.active ? 1 : 0,
createdMs: user.createdMs,
});
}
}
function toAdminUser(r: Record<string, unknown>): AdminUser {
return {
id: r.id as string,
username: r.username as string,
passwordHash: r.password_hash as string,
active: (r.active as number) === 1,
createdMs: r.created_ms as number,
};
}

68
src/db/billing-repo.ts Normal file
View file

@ -0,0 +1,68 @@
import Database from 'better-sqlite3';
export interface BillingReportRepo {
getReportedCents(customerId: string, period: string): number;
upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void;
}
export interface JobLockRepo {
tryAcquireLock(name: string, ttlMs: number): boolean;
releaseLock(name: string): void;
}
export class SqliteBillingReportRepo implements BillingReportRepo, JobLockRepo {
constructor(
private db: Database.Database,
private now: () => number = Date.now,
) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS billing_reports (
customer_id TEXT NOT NULL,
period TEXT NOT NULL,
reported_cents INTEGER NOT NULL,
reported_at_ms INTEGER NOT NULL,
PRIMARY KEY (customer_id, period)
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS job_locks (
name TEXT PRIMARY KEY,
acquired_at_ms INTEGER NOT NULL
)
`);
}
getReportedCents(customerId: string, period: string): number {
const row = this.db
.prepare('SELECT reported_cents FROM billing_reports WHERE customer_id = ? AND period = ?')
.get(customerId, period) as { reported_cents: number } | undefined;
return row?.reported_cents ?? 0;
}
upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void {
this.db
.prepare(
`INSERT INTO billing_reports (customer_id, period, reported_cents, reported_at_ms)
VALUES (?, ?, ?, ?)
ON CONFLICT(customer_id, period) DO UPDATE SET
reported_cents = excluded.reported_cents,
reported_at_ms = excluded.reported_at_ms`,
)
.run(customerId, period, cumulativeCents, this.now());
}
tryAcquireLock(name: string, ttlMs: number): boolean {
const result = this.db
.prepare(
`INSERT INTO job_locks (name, acquired_at_ms) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET acquired_at_ms = excluded.acquired_at_ms
WHERE job_locks.acquired_at_ms <= excluded.acquired_at_ms - ?`,
)
.run(name, this.now(), ttlMs);
return result.changes === 1;
}
releaseLock(name: string): void {
this.db.prepare('DELETE FROM job_locks WHERE name = ?').run(name);
}
}

112
src/db/customer-repo.ts Normal file
View file

@ -0,0 +1,112 @@
import Database from 'better-sqlite3';
import { Customer, CustomerRepo } from '../auth';
export class SqliteCustomerRepo implements CustomerRepo {
constructor(private db: Database.Database, seed: Customer[] = []) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS customers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
tier_id TEXT NOT NULL,
api_key TEXT NOT NULL UNIQUE,
stripe_customer_id TEXT,
multiplier_override REAL
)
`);
// Idempotent migrations for pre-existing databases.
this.ensureColumn('billing_type', `billing_type TEXT NOT NULL DEFAULT 'stripe'`);
this.ensureColumn('email', 'email TEXT');
this.ensureColumn('password_hash', 'password_hash TEXT');
this.ensureColumn('totp_secret', 'totp_secret TEXT');
this.ensureColumn('totp_enabled', 'totp_enabled INTEGER NOT NULL DEFAULT 0');
this.ensureColumn('balance_cents', 'balance_cents INTEGER NOT NULL DEFAULT 0');
this.ensureColumn('email_invoicing', 'email_invoicing INTEGER NOT NULL DEFAULT 0');
const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number };
if (n === 0) {
for (const c of seed) this.save(c);
}
}
private ensureColumn(name: string, ddl: string): void {
const cols = this.db.prepare('PRAGMA table_info(customers)').all() as { name: string }[];
if (!cols.some((c) => c.name === name)) {
this.db.exec(`ALTER TABLE customers ADD COLUMN ${ddl}`);
}
}
findByApiKey(apiKey: string): Customer | undefined {
const r = this.db.prepare('SELECT * FROM customers WHERE api_key = ?').get(apiKey) as
| Record<string, unknown>
| undefined;
return r ? toCustomer(r) : undefined;
}
findByEmail(email: string): Customer | undefined {
const r = this.db
.prepare('SELECT * FROM customers WHERE lower(email) = lower(?)')
.get(email) as Record<string, unknown> | undefined;
return r ? toCustomer(r) : undefined;
}
list(): Customer[] {
const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record<
string,
unknown
>[];
return rows.map(toCustomer);
}
save(customer: Customer): void {
this.db
.prepare(
`INSERT INTO customers (id, name, tier_id, api_key, stripe_customer_id, multiplier_override, billing_type, email, password_hash, totp_secret, totp_enabled, balance_cents, email_invoicing)
VALUES (@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride, @billingType, @email, @passwordHash, @totpSecret, @totpEnabled, @balanceCents, @emailInvoicing)
ON CONFLICT(id) DO UPDATE SET
name = @name,
tier_id = @tierId,
api_key = @apiKey,
stripe_customer_id = @stripeCustomerId,
multiplier_override = @multiplierOverride,
billing_type = @billingType,
email = @email,
password_hash = @passwordHash,
totp_secret = @totpSecret,
totp_enabled = @totpEnabled,
balance_cents = @balanceCents,
email_invoicing = @emailInvoicing`,
)
.run({
id: customer.id,
name: customer.name,
tierId: customer.tierId,
apiKey: customer.apiKey,
stripeCustomerId: customer.stripeCustomerId ?? null,
multiplierOverride: customer.multiplierOverride ?? null,
billingType: customer.billingType ?? 'stripe',
email: customer.email ?? null,
passwordHash: customer.passwordHash ?? null,
totpSecret: customer.totpSecret ?? null,
totpEnabled: customer.totpEnabled ? 1 : 0,
balanceCents: customer.balanceCents ?? 0,
emailInvoicing: customer.emailInvoicing ? 1 : 0,
});
}
}
function toCustomer(r: Record<string, unknown>): Customer {
return {
id: r.id as string,
name: r.name as string,
tierId: r.tier_id as string,
apiKey: r.api_key as string,
stripeCustomerId: (r.stripe_customer_id as string | null) ?? undefined,
multiplierOverride: (r.multiplier_override as number | null) ?? undefined,
billingType: ((r.billing_type as string | null) ?? 'stripe') as Customer['billingType'],
email: (r.email as string | null) ?? undefined,
passwordHash: (r.password_hash as string | null) ?? undefined,
totpSecret: (r.totp_secret as string | null) ?? undefined,
totpEnabled: ((r.totp_enabled as number | null) ?? 0) === 1,
balanceCents: (r.balance_cents as number | null) ?? 0,
emailInvoicing: ((r.email_invoicing as number | null) ?? 0) === 1,
};
}

119
src/db/invoice-repo.ts Normal file
View file

@ -0,0 +1,119 @@
import Database from 'better-sqlite3';
import { Invoice, InvoiceLine, InvoiceRepo } from '../invoicing';
export class SqliteInvoiceRepo implements InvoiceRepo {
constructor(private db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
period TEXT NOT NULL,
status TEXT NOT NULL,
total_cents INTEGER NOT NULL,
credit_cents INTEGER NOT NULL,
billable_cents INTEGER NOT NULL,
billing_type TEXT NOT NULL DEFAULT 'stripe',
po_number TEXT,
issued_at_ms INTEGER,
due_at_ms INTEGER,
paid_at_ms INTEGER
);
CREATE TABLE IF NOT EXISTS invoice_lines (
invoice_id TEXT NOT NULL,
endpoint_id TEXT NOT NULL,
calls INTEGER NOT NULL,
cents INTEGER NOT NULL,
PRIMARY KEY (invoice_id, endpoint_id)
);
`);
}
save(invoice: Invoice): void {
this.db
.prepare(
`INSERT INTO invoices
(id, customer_id, period, status, total_cents, credit_cents, billable_cents,
billing_type, po_number, issued_at_ms, due_at_ms, paid_at_ms)
VALUES (@id, @customerId, @period, @status, @totalCents, @creditCents, @billableCents,
@billingType, @poNumber, @issuedAtMs, @dueAtMs, @paidAtMs)
ON CONFLICT(id) DO UPDATE SET
customer_id = @customerId, period = @period, status = @status,
total_cents = @totalCents, credit_cents = @creditCents,
billable_cents = @billableCents, billing_type = @billingType,
po_number = @poNumber, issued_at_ms = @issuedAtMs,
due_at_ms = @dueAtMs, paid_at_ms = @paidAtMs`,
)
.run({
...invoice,
poNumber: invoice.poNumber ?? null,
issuedAtMs: invoice.issuedAtMs ?? null,
dueAtMs: invoice.dueAtMs ?? null,
paidAtMs: invoice.paidAtMs ?? null,
});
// Replace the line set atomically with the invoice row.
this.db.prepare('DELETE FROM invoice_lines WHERE invoice_id = ?').run(invoice.id);
const insert = this.db.prepare(
'INSERT INTO invoice_lines (invoice_id, endpoint_id, calls, cents) VALUES (?, ?, ?, ?)',
);
for (const line of invoice.lines) {
insert.run(invoice.id, line.endpointId, line.calls, line.cents);
}
}
get(id: string): Invoice | undefined {
const row = this.db.prepare('SELECT * FROM invoices WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined;
return row ? this.toInvoice(row) : undefined;
}
list(filter: { customerId?: string; period?: string; status?: Invoice['status'] }): Invoice[] {
const where: string[] = [];
const params: Record<string, string> = {};
if (filter.customerId) {
where.push('customer_id = @customerId');
params.customerId = filter.customerId;
}
if (filter.period) {
where.push('period = @period');
params.period = filter.period;
}
if (filter.status) {
where.push('status = @status');
params.status = filter.status;
}
const sql = `SELECT * FROM invoices ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`;
const rows = this.db.prepare(sql).all(params) as Record<string, unknown>[];
return rows.map((r) => this.toInvoice(r));
}
nextSequence(period: string): number {
const { n } = this.db
.prepare('SELECT COUNT(*) AS n FROM invoices WHERE period = ?')
.get(period) as { n: number };
return n + 1;
}
private toInvoice(row: Record<string, unknown>): Invoice {
const lines = this.db
.prepare('SELECT endpoint_id, calls, cents FROM invoice_lines WHERE invoice_id = ? ORDER BY endpoint_id')
.all(row.id as string) as { endpoint_id: string; calls: number; cents: number }[];
return {
id: row.id as string,
customerId: row.customer_id as string,
period: row.period as string,
status: row.status as Invoice['status'],
lines: lines.map(
(l): InvoiceLine => ({ endpointId: l.endpoint_id, calls: l.calls, cents: l.cents }),
),
totalCents: row.total_cents as number,
creditCents: row.credit_cents as number,
billableCents: row.billable_cents as number,
billingType: row.billing_type as Invoice['billingType'],
poNumber: (row.po_number as string | null) ?? undefined,
issuedAtMs: (row.issued_at_ms as number | null) ?? undefined,
dueAtMs: (row.due_at_ms as number | null) ?? undefined,
paidAtMs: (row.paid_at_ms as number | null) ?? undefined,
};
}
}

106
src/db/pricing-store.ts Normal file
View file

@ -0,0 +1,106 @@
import Database from 'better-sqlite3';
import {
DEFAULT_RATE_CARD,
DEFAULT_TIERS,
PriceRule,
PricingStore,
RateCard,
TierConfig,
} from '../pricing';
export class SqlitePricingStore implements PricingStore {
constructor(
private db: Database.Database,
seedCard: RateCard = DEFAULT_RATE_CARD,
seedTiers: TierConfig[] = DEFAULT_TIERS,
) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS price_endpoints (
endpoint_id TEXT PRIMARY KEY,
rule_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tiers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
multiplier REAL NOT NULL,
monthly_credit_cents INTEGER NOT NULL,
default_rule_json TEXT
)
`);
const { n: endpointCount } = this.db
.prepare('SELECT COUNT(*) AS n FROM price_endpoints')
.get() as { n: number };
if (endpointCount === 0) {
for (const [id, rule] of Object.entries(seedCard.endpoints)) {
this.upsertEndpoint(id, rule);
}
}
const { n: tierCount } = this.db.prepare('SELECT COUNT(*) AS n FROM tiers').get() as {
n: number;
};
if (tierCount === 0) {
for (const tier of seedTiers) {
this.upsertTier(tier);
}
}
}
getRateCard(): RateCard {
const rows = this.db.prepare('SELECT * FROM price_endpoints').all() as Record<string, unknown>[];
const endpoints: Record<string, PriceRule> = {};
for (const r of rows) {
endpoints[r.endpoint_id as string] = JSON.parse(r.rule_json as string) as PriceRule;
}
return { endpoints };
}
getTiers(): TierConfig[] {
const rows = this.db.prepare('SELECT * FROM tiers ORDER BY rowid').all() as Record<string, unknown>[];
return rows.map((r) => ({
id: r.id as string,
name: r.name as string,
multiplier: r.multiplier as number,
monthlyCreditCents: r.monthly_credit_cents as number,
defaultRule: r.default_rule_json
? (JSON.parse(r.default_rule_json as string) as PriceRule)
: undefined,
}));
}
upsertEndpoint(endpointId: string, rule: PriceRule): void {
this.db
.prepare(
`INSERT INTO price_endpoints (endpoint_id, rule_json) VALUES (?, ?)
ON CONFLICT(endpoint_id) DO UPDATE SET rule_json = excluded.rule_json`,
)
.run(endpointId, JSON.stringify(rule));
}
deleteEndpoint(endpointId: string): void {
this.db.prepare('DELETE FROM price_endpoints WHERE endpoint_id = ?').run(endpointId);
}
upsertTier(tier: TierConfig): void {
this.db
.prepare(
`INSERT INTO tiers (id, name, multiplier, monthly_credit_cents, default_rule_json)
VALUES (@id, @name, @multiplier, @monthlyCreditCents, @defaultRuleJson)
ON CONFLICT(id) DO UPDATE SET
name = @name,
multiplier = @multiplier,
monthly_credit_cents = @monthlyCreditCents,
default_rule_json = @defaultRuleJson`,
)
.run({
id: tier.id,
name: tier.name,
multiplier: tier.multiplier,
monthlyCreditCents: tier.monthlyCreditCents,
defaultRuleJson: tier.defaultRule ? JSON.stringify(tier.defaultRule) : null,
});
}
deleteTier(tierId: string): void {
this.db.prepare('DELETE FROM tiers WHERE id = ?').run(tierId);
}
}

51
src/db/session-repo.ts Normal file
View file

@ -0,0 +1,51 @@
import Database from 'better-sqlite3';
import { newSessionToken, PortalSession, SessionRepo } from '../accounts';
/** SQLite-backed portal sessions; survives server restarts. */
export class SqliteSessionRepo implements SessionRepo {
constructor(private db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS portal_sessions (
token TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
created_ms INTEGER NOT NULL,
expires_ms INTEGER NOT NULL
)
`);
}
create(customerId: string, ttlMs: number): PortalSession {
const now = Date.now();
const session: PortalSession = {
token: newSessionToken(),
customerId,
createdMs: now,
expiresMs: now + ttlMs,
};
this.db
.prepare(
'INSERT INTO portal_sessions (token, customer_id, created_ms, expires_ms) VALUES (?, ?, ?, ?)',
)
.run(session.token, session.customerId, session.createdMs, session.expiresMs);
return session;
}
get(token: string, nowMs = Date.now()): PortalSession | undefined {
const r = this.db
.prepare('SELECT * FROM portal_sessions WHERE token = ?')
.get(token) as
| { token: string; customer_id: string; created_ms: number; expires_ms: number }
| undefined;
if (!r || r.expires_ms <= nowMs) return undefined;
return {
token: r.token,
customerId: r.customer_id,
createdMs: r.created_ms,
expiresMs: r.expires_ms,
};
}
delete(token: string): void {
this.db.prepare('DELETE FROM portal_sessions WHERE token = ?').run(token);
}
}

61
src/db/usage-repo.ts Normal file
View file

@ -0,0 +1,61 @@
import Database from 'better-sqlite3';
import { summarize, UsageEntry, UsageRepo, UsageSummary } from '../usage';
export class SqliteUsageRepo implements UsageRepo {
constructor(private db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS usage_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
endpoint_id TEXT NOT NULL,
cents INTEGER NOT NULL,
metadata_bytes INTEGER NOT NULL,
attachment_bytes INTEGER NOT NULL,
timestamp_ms INTEGER NOT NULL
)
`);
}
record(entry: UsageEntry): void {
this.db
.prepare(
`INSERT INTO usage_entries
(customer_id, endpoint_id, cents, metadata_bytes, attachment_bytes, timestamp_ms)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
entry.customerId,
entry.endpointId,
entry.cents,
entry.metadataBytes,
entry.attachmentBytes,
entry.timestamp.getTime(),
);
}
listFor(customerId: string, since?: Date): UsageEntry[] {
const rows = (
since
? this.db
.prepare(
'SELECT * FROM usage_entries WHERE customer_id = ? AND timestamp_ms >= ? ORDER BY timestamp_ms',
)
.all(customerId, since.getTime())
: this.db
.prepare('SELECT * FROM usage_entries WHERE customer_id = ? ORDER BY timestamp_ms')
.all(customerId)
) as Record<string, unknown>[];
return rows.map((r) => ({
customerId: r.customer_id as string,
endpointId: r.endpoint_id as string,
cents: r.cents as number,
metadataBytes: r.metadata_bytes as number,
attachmentBytes: r.attachment_bytes as number,
timestamp: new Date(r.timestamp_ms as number),
}));
}
summaryFor(customerId: string, since?: Date): UsageSummary {
return summarize(customerId, this.listFor(customerId, since));
}
}