diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..c7a7bba --- /dev/null +++ b/auth.ts @@ -0,0 +1,60 @@ +/** + * Minimal partner token auth for agentify-help standalone app. + * Replaces server/partner-api-routes.ts validatePartnerToken. + */ +import { pool } from "./db.js"; +import crypto from "crypto"; + +function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +export interface PartnerTokenContext { + id: number; + partner_name: string; + permissions: string[]; + area_codes: string[] | null; +} + +export async function validatePartnerToken( + authHeader: string | undefined +): Promise { + if (!authHeader?.startsWith("Bearer ws_")) return null; + const token = authHeader.slice(7); + if (!token.startsWith("ws_")) return null; + + const hash = hashToken(token); + const result = await pool.query( + `SELECT id, partner_name, permissions, area_codes + FROM partner_api_tokens + WHERE token_hash = $1 AND active = true AND revoked_at IS NULL + LIMIT 1`, + [hash] + ); + if (!result.rows.length) return null; + + const r = result.rows[0]; + pool.query( + `UPDATE partner_api_tokens SET last_used_at = NOW(), use_count = use_count + 1 WHERE id = $1`, + [r.id] + ).catch(() => {}); + + return { + id: r.id, + partner_name: r.partner_name, + permissions: (r.permissions as string[]) || [], + area_codes: r.area_codes || null, + }; +} + +export function requirePermission(permission: string) { + return async (req: any, res: any, next: any) => { + const ctx = await validatePartnerToken(req.headers.authorization).catch(() => null); + if (!ctx) return res.status(401).json({ error: "Unauthorized" }); + if (!ctx.permissions.includes(permission) && !ctx.permissions.includes("geo.seed")) { + return res.status(403).json({ error: "Forbidden", required: permission }); + } + (req as any).partner = ctx; + next(); + }; +}