From 50eefed36d2ad800917b99991aaa5a5d04b4ade2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 05:11:16 +0000 Subject: [PATCH] feat(rotation): buy automatic rotation, $29 per team per 30 days (CoinPay) - Rotation page button + POST /api/credshare/teams/:slug/rotation/auto/checkout open a CoinPay hosted checkout (owner/admin only). - POST /api/coinpay/webhook and the page's return check settle once per checkout session (rotation_payments); paying again extends from the current end. - Hourly sweep: renewal email 3 days before, switch off when the days run out. A team switched on by hand (paid_until NULL) never expires. - Migration 012 (both dialects), CSP form-action allows coinpayportal.com. Co-Authored-By: Claude Opus 5.5 --- apps/pwa/package.json | 3 +- apps/pwa/src/lib/auto-rotation.mjs | 189 ++++++++++++++++++ apps/pwa/src/lib/security-headers.mjs | 5 +- .../012_auto_rotation_billing.sql | 18 ++ .../migrations/012_auto_rotation_billing.sql | 23 +++ apps/pwa/src/routes/auto-rotation.mjs | 62 ++++++ apps/pwa/src/routes/rotation.mjs | 27 ++- apps/pwa/src/server.mjs | 7 + apps/pwa/test/auto-rotation.test.mjs | 174 ++++++++++++++++ apps/pwa/test/rotation.test.mjs | 2 +- bun.lock | 5 +- docs/rotation.md | 21 +- 12 files changed, 521 insertions(+), 15 deletions(-) create mode 100644 apps/pwa/src/lib/auto-rotation.mjs create mode 100644 apps/pwa/src/migrations-pg/012_auto_rotation_billing.sql create mode 100644 apps/pwa/src/migrations/012_auto_rotation_billing.sql create mode 100644 apps/pwa/src/routes/auto-rotation.mjs create mode 100644 apps/pwa/test/auto-rotation.test.mjs diff --git a/apps/pwa/package.json b/apps/pwa/package.json index c33fa1dd..8a286f02 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -3,7 +3,7 @@ "version": "0.1.1", "private": true, "type": "module", - "description": "LogicSRC credentials \u2014 Express app on Postgres (@profullstack/libsql-pg): auth + end-to-end-encrypted team credential sharing.", + "description": "LogicSRC credentials — Express app on Postgres (@profullstack/libsql-pg): auth + end-to-end-encrypted team credential sharing.", "scripts": { "start": "bun src/server.mjs", "dev": "bun --watch src/server.mjs", @@ -12,6 +12,7 @@ }, "dependencies": { "@logicsrc/cli": "workspace:*", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/footer": "0.5.0", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/profile": "0.1.6", diff --git a/apps/pwa/src/lib/auto-rotation.mjs b/apps/pwa/src/lib/auto-rotation.mjs new file mode 100644 index 00000000..a346dee9 --- /dev/null +++ b/apps/pwa/src/lib/auto-rotation.mjs @@ -0,0 +1,189 @@ +// Paid automatic rotation (Anthony, 2026-10-29): $29 buys one team 30 days of +// rotation_auto_approve, so its due runs rotate without the approval click. +// +// CoinPay payments are one-off (crypto or PayPal), so nothing renews by itself: +// a checkout buys a fixed term and paying again extends it from the current end. +// The webhook and the Rotation page's return check both settle through +// settlePayment(), which is idempotent on the checkout session id. The hourly +// sweep turns the switch off when the paid days run out and emails the team's +// owners and admins a renewal link 3 days before. +// +// rotation_auto_paid_until NULL with the switch on means it was flipped by hand +// (a comp): it never expires and a payment does not cap it. +import { CoinPay, readWebhook, verifyWebhook } from "@profullstack/coinpay-onboard"; +import { get, all, run } from "../db.mjs"; +import { config } from "../config.mjs"; + +export const DAY_MS = 24 * 60 * 60 * 1000; +export const AUTO_ROTATION_PRICE_USD = Number(process.env.AUTO_ROTATION_PRICE_USD || 29); +export const AUTO_ROTATION_DAYS = 30; +export const RENEWAL_NOTICE_DAYS = 3; +export const PROPERTY = "app.logicsrc.com"; +export const PURPOSE = "auto_rotation"; + +export { readWebhook }; + +/** Is automatic rotation on for this team right now? */ +export function autoRotationState(team, now = Date.now()) { + const flag = Boolean(Number(team?.rotation_auto_approve)); + const paidUntil = team?.rotation_auto_paid_until == null ? null : Number(team.rotation_auto_paid_until); + const manual = flag && paidUntil === null; + const on = flag && (manual || paidUntil > now); + return { on, manual, paidUntil, expired: flag && !manual && paidUntil <= now, daysLeft: on && !manual ? Math.ceil((paidUntil - now) / DAY_MS) : null }; +} + +export const autoRotationOn = (team, now = Date.now()) => autoRotationState(team, now).on; + +/** A CoinPay client on app.logicsrc.com's business key. */ +export function coinpayClient(env = process.env) { + const token = env.COINPAY_API_KEY; + if (!token) throw new Error("COINPAY_API_KEY is not configured"); + return new CoinPay({ token, base: env.COINPAY_API_URL || undefined }); +} + +/** Open a hosted checkout for 30 days of automatic rotation on one team. */ +export async function createCheckout({ team, user, client = coinpayClient(), origin = config.origin }) { + if (!user?.id || !user?.email) throw new Error("A signed-in user with an email is required"); + const back = `${origin}/teams/${encodeURIComponent(team.slug)}/rotation`; + return client.createCheckoutSession({ + amount: AUTO_ROTATION_PRICE_USD, + currency: "USD", + description: `LogicSRC automatic rotation for ${team.slug}, ${AUTO_ROTATION_DAYS} days`, + customerEmail: user.email, + accountId: user.id, + property: PROPERTY, + metadata: { purpose: PURPOSE, team_id: team.id, team_slug: team.slug, user_id: user.id, days: AUTO_ROTATION_DAYS }, + successUrl: `${back}?checkout=success&session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${back}?checkout=cancelled`, + expiresInMinutes: 60, + }); +} + +/** Verify X-CoinPay-Signature over the raw body. */ +export function verifySignature(rawBody, header, secret = process.env.COINPAY_WEBHOOK_SECRET) { + if (!secret || !rawBody) return false; + try { return verifyWebhook(rawBody, header, secret); } catch { return false; } +} + +/** + * Grant the days a paid checkout bought. Idempotent on the checkout session (or + * payment) id: the first settle extends the team, any later one (confirmed then + * forwarded, a webhook retry, the return check) changes nothing. + */ +export async function settlePayment(payment, now = Date.now()) { + const { checkoutSessionId, paymentId, teamId } = payment; + const key = checkoutSessionId || paymentId; + if (!key) return { handled: false, reason: "no checkout session or payment id" }; + if (!teamId) return { handled: false, reason: "no team_id" }; + // No amount check: only this site's business key can open a session, at the + // server's price, and a crypto webhook's `amount` may be in the coin, not USD. + const team = await get(`SELECT * FROM credshare_teams WHERE id = ?`, [teamId]); + if (!team) return { handled: false, reason: "unknown team" }; + + const ins = await run( + `INSERT INTO rotation_payments (checkout_session_id, payment_id, team_id, user_id, amount_usd, method, days, created_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT (checkout_session_id) DO NOTHING`, + [key, paymentId || null, teamId, payment.userId || null, payment.amountUsd ?? null, payment.method || null, AUTO_ROTATION_DAYS, now] + ); + if (!Number(ins.rowsAffected)) return { handled: true, duplicate: true, teamId }; + + const state = autoRotationState(team, now); + if (state.manual) { + await run(`UPDATE rotation_payments SET paid_until = NULL WHERE checkout_session_id = ?`, [key]); + return { handled: true, action: "already-on-by-hand", teamId }; + } + const from = state.on ? state.paidUntil : now; + const paidUntil = from + AUTO_ROTATION_DAYS * DAY_MS; + await run( + `UPDATE credshare_teams SET rotation_auto_approve = 1, rotation_auto_paid_until = ?, rotation_auto_reminded_for = NULL WHERE id = ?`, + [paidUntil, teamId] + ); + await run(`UPDATE rotation_payments SET paid_until = ? WHERE checkout_session_id = ?`, [paidUntil, key]); + await run( + `INSERT INTO credshare_audit (id, team_id, actor_user_id, action, key_name, created_at) VALUES (?,?,?,?,?,?)`, + [`aud_${key}`, teamId, payment.userId || team.created_by, state.on ? "rotation.auto.extended" : "rotation.auto.paid", new Date(paidUntil).toISOString().slice(0, 10), now] + ).catch(() => {}); + return { handled: true, action: state.on ? "extended" : "activated", teamId, paidUntil }; +} + +/** Settle from a verified webhook payload. */ +export async function settleWebhook(payload, now = Date.now()) { + const event = readWebhook(payload); + if (!event.paid) return { handled: false, type: event.type, status: event.status }; + if (event.property && event.property !== PROPERTY) return { handled: false, reason: "other property" }; + const meta = event.metadata || {}; + if (meta.purpose && meta.purpose !== PURPOSE) return { handled: false, reason: `purpose ${meta.purpose}` }; + return settlePayment({ + checkoutSessionId: event.checkoutSessionId, + paymentId: event.paymentId, + teamId: meta.team_id, + userId: meta.user_id || event.accountId, + amountUsd: event.amountUsd, + method: event.method, + }, now); +} + +/** + * The Rotation page's return check: if CoinPay already marks the session paid + * for this team, settle it now (the webhook may still be on its way). + */ +export async function reconcileReturn(sessionId, team, client = coinpayClient(), now = Date.now()) { + if (!sessionId || !/^cs_[A-Za-z0-9_-]+$/.test(sessionId)) return null; + const session = await client.checkoutSession(sessionId); + const meta = session.metadata || {}; + if (meta.team_id !== team.id || (meta.purpose && meta.purpose !== PURPOSE)) return null; + if (session.status === "paid") { + await settlePayment({ + checkoutSessionId: session.id, + paymentId: session.paymentId || null, + teamId: team.id, + userId: meta.user_id || session.accountId, + amountUsd: session.amount, + method: session.paidMethod || null, + }, now); + } + return session.status; +} + +async function sendMail(to, subject, text) { + if (!config.resend.apiKey || !to.length) return false; + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { authorization: `Bearer ${config.resend.apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({ from: config.resend.from, to, subject, text }), + }).catch(() => null); + return Boolean(response && response.ok); +} + +async function approverEmails(teamId) { + return (await all( + `SELECT u.email FROM credshare_members m JOIN users u ON u.id = m.user_id WHERE m.team_id = ? AND m.status = 'active' AND m.role IN ('owner', 'admin')`, + [teamId] + )).map((r) => r.email).filter(Boolean); +} + +/** + * The hourly sweep: turn the switch off for teams whose paid days ran out, and + * email a renewal link to teams with 3 days or less left (once per paid term). + */ +export async function sweepAutoRotation({ now = Date.now(), send = sendMail, origin = config.origin } = {}) { + const lapsed = await all( + `SELECT id, slug FROM credshare_teams WHERE rotation_auto_approve = 1 AND rotation_auto_paid_until IS NOT NULL AND rotation_auto_paid_until <= ?`, + [now] + ); + for (const t of lapsed) { + await run(`UPDATE credshare_teams SET rotation_auto_approve = 0 WHERE id = ? AND rotation_auto_paid_until <= ?`, [t.id, now]); + await send(await approverEmails(t.id), `Automatic rotation is now off for ${t.slug}`, + `The paid automatic rotation for the LogicSRC team "${t.slug}" has ended. Due keys now wait for an owner or admin to approve them again.\n\nTurn it back on ($${AUTO_ROTATION_PRICE_USD} for ${AUTO_ROTATION_DAYS} days): ${origin}/teams/${encodeURIComponent(t.slug)}/rotation`); + } + const ending = await all( + `SELECT id, slug, rotation_auto_paid_until FROM credshare_teams WHERE rotation_auto_approve = 1 AND rotation_auto_paid_until > ? AND rotation_auto_paid_until <= ? AND (rotation_auto_reminded_for IS NULL OR rotation_auto_reminded_for <> rotation_auto_paid_until)`, + [now, now + RENEWAL_NOTICE_DAYS * DAY_MS] + ); + for (const t of ending) { + const until = new Date(Number(t.rotation_auto_paid_until)).toISOString().slice(0, 10); + await send(await approverEmails(t.id), `Automatic rotation for ${t.slug} ends ${until}`, + `The paid automatic rotation for the LogicSRC team "${t.slug}" ends on ${until} (UTC). After that, due keys wait for an owner or admin to approve them.\n\nRenew for another ${AUTO_ROTATION_DAYS} days ($${AUTO_ROTATION_PRICE_USD}, added to the current end): ${origin}/teams/${encodeURIComponent(t.slug)}/rotation`); + await run(`UPDATE credshare_teams SET rotation_auto_reminded_for = ? WHERE id = ?`, [t.rotation_auto_paid_until, t.id]); + } + return { expired: lapsed.map((t) => t.slug), reminded: ending.map((t) => t.slug) }; +} diff --git a/apps/pwa/src/lib/security-headers.mjs b/apps/pwa/src/lib/security-headers.mjs index 3269a352..2a7eb3fd 100644 --- a/apps/pwa/src/lib/security-headers.mjs +++ b/apps/pwa/src/lib/security-headers.mjs @@ -8,7 +8,8 @@ // cannot read storage. // // form-action allows the CLI's loopback login redirect (http://127.0.0.1:) -// and the shared footer's newsletter form (pit.world). +// the shared footer's newsletter form (pit.world), and the redirect to CoinPay's +// hosted checkout after the "Turn on automatic rotation" form (coinpayportal.com). export const CSP = [ "default-src 'self'", "script-src 'self' 'wasm-unsafe-eval'", @@ -22,7 +23,7 @@ export const CSP = [ "base-uri 'none'", "frame-src 'none'", "frame-ancestors 'none'", - "form-action 'self' http://127.0.0.1:* http://localhost:* https://pit.world" + "form-action 'self' http://127.0.0.1:* http://localhost:* https://pit.world https://coinpayportal.com" ].join("; "); export function securityHeaders(req, res, next) { diff --git a/apps/pwa/src/migrations-pg/012_auto_rotation_billing.sql b/apps/pwa/src/migrations-pg/012_auto_rotation_billing.sql new file mode 100644 index 00000000..29addb62 --- /dev/null +++ b/apps/pwa/src/migrations-pg/012_auto_rotation_billing.sql @@ -0,0 +1,18 @@ +-- Postgres copy of migrations/012_auto_rotation_billing.sql (INTEGER -> bigint). Additive only. +-- Paid automatic rotation: a CoinPay checkout buys 30 days of rotation_auto_approve +-- for one team; NULL paid_until = flipped by hand, never expires. +-- No inline comments: the pg shim joins lines. +ALTER TABLE credshare_teams ADD COLUMN IF NOT EXISTS rotation_auto_paid_until bigint; +ALTER TABLE credshare_teams ADD COLUMN IF NOT EXISTS rotation_auto_reminded_for bigint; +CREATE TABLE IF NOT EXISTS rotation_payments ( + checkout_session_id TEXT PRIMARY KEY, + payment_id TEXT, + team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE, + user_id TEXT, + amount_usd double precision, + method TEXT, + days bigint NOT NULL, + paid_until bigint, + created_at bigint NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_rotation_payments_team ON rotation_payments(team_id, created_at); diff --git a/apps/pwa/src/migrations/012_auto_rotation_billing.sql b/apps/pwa/src/migrations/012_auto_rotation_billing.sql new file mode 100644 index 00000000..df5c274f --- /dev/null +++ b/apps/pwa/src/migrations/012_auto_rotation_billing.sql @@ -0,0 +1,23 @@ +-- Paid automatic rotation (Anthony, 2026-10-29: $29 per team per 30 days). +-- +-- A CoinPay checkout buys 30 days of rotation_auto_approve for one team. +-- rotation_auto_paid_until is when the paid days end: NULL means the switch was +-- flipped by hand and never expires. The hourly sweep turns the switch off once +-- that time passes, and emails the team's owners and admins a renewal link 3 +-- days before (rotation_auto_reminded_for = the paid_until it reminded about). +-- rotation_payments is the idempotency ledger: one row per checkout session. +-- Additive only. +ALTER TABLE credshare_teams ADD COLUMN rotation_auto_paid_until INTEGER; +ALTER TABLE credshare_teams ADD COLUMN rotation_auto_reminded_for INTEGER; +CREATE TABLE IF NOT EXISTS rotation_payments ( + checkout_session_id TEXT PRIMARY KEY, + payment_id TEXT, + team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE, + user_id TEXT, + amount_usd REAL, + method TEXT, + days INTEGER NOT NULL, + paid_until INTEGER, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_rotation_payments_team ON rotation_payments(team_id, created_at); diff --git a/apps/pwa/src/routes/auto-rotation.mjs b/apps/pwa/src/routes/auto-rotation.mjs new file mode 100644 index 00000000..24558d93 --- /dev/null +++ b/apps/pwa/src/routes/auto-rotation.mjs @@ -0,0 +1,62 @@ +// Buying automatic rotation ($29 / 30 days per team) through CoinPay. +// +// POST /teams/:slug/rotation/auto/checkout the Rotation page button: 303 to CoinPay checkout +// POST /api/credshare/teams/:slug/rotation/auto/checkout the same for the CLI / MCP: {url, sessionId} +// GET /api/credshare/teams/:slug/rotation/auto the switch's state +// POST /api/coinpay/webhook CoinPay events (X-CoinPay-Signature over the raw body) +// +// Only a team owner or admin can buy (a person, never a machine key). The webhook +// and the page's return check settle through lib/auto-rotation.mjs settlePayment(). +import { Router } from "express"; +import { get } from "../db.mjs"; +import { requireAuth } from "../lib/session.mjs"; +import { api, requireMember } from "./credshare.mjs"; +import { autoRotationState, createCheckout, settleWebhook, verifySignature, AUTO_ROTATION_PRICE_USD, AUTO_ROTATION_DAYS } from "../lib/auto-rotation.mjs"; + +export const autoRotationRouter = Router(); + +const canBuy = (member) => member?.role === "owner" || member?.role === "admin"; + +async function buyer(userId) { + return get(`SELECT id, email FROM users WHERE id = ?`, [userId]); +} + +autoRotationRouter.post("/teams/:slug/rotation/auto/checkout", requireAuth, async (req, res, next) => { + const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [req.params.slug]); + const member = team && await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ? AND status = 'active'`, [team.id, req.user.id]); + if (!team || !member) return next(); + const back = `/teams/${encodeURIComponent(team.slug)}/rotation`; + if (!canBuy(member)) return res.redirect(`${back}?err=${encodeURIComponent("Only a team owner or admin can turn on automatic rotation.")}`); + try { + const session = await createCheckout({ team, user: await buyer(req.user.id) }); + res.redirect(303, session.url); + } catch (e) { + console.error("auto-rotation checkout failed:", e.message); + res.redirect(`${back}?err=${encodeURIComponent("Could not open the CoinPay checkout. Try again in a minute.")}`); + } +}); + +autoRotationRouter.get("/api/credshare/teams/:slug/rotation/auto", api(async (req, res, user, key) => { + const ctx = await requireMember(res, req.params.slug, user.id, key); if (!ctx) return; + const s = autoRotationState(ctx.team); + res.json({ team: ctx.team.slug, on: s.on, manual: s.manual, paidUntil: s.paidUntil ? new Date(s.paidUntil).toISOString() : null, daysLeft: s.daysLeft, priceUsd: AUTO_ROTATION_PRICE_USD, days: AUTO_ROTATION_DAYS }); +}, { machine: "read" })); + +autoRotationRouter.post("/api/credshare/teams/:slug/rotation/auto/checkout", api(async (req, res, user, key) => { + const ctx = await requireMember(res, req.params.slug, user.id, key); if (!ctx) return; + if (!canBuy(ctx.member)) return res.status(403).json({ error: "Only a team owner or admin can turn on automatic rotation." }); + const session = await createCheckout({ team: ctx.team, user: await buyer(user.id) }); + res.status(201).json({ sessionId: session.id, url: session.url, priceUsd: AUTO_ROTATION_PRICE_USD, days: AUTO_ROTATION_DAYS }); +})); + +autoRotationRouter.post("/api/coinpay/webhook", async (req, res) => { + if (!verifySignature(req.rawBody, req.get("x-coinpay-signature"))) return res.status(401).json({ error: "Invalid signature" }); + try { + const result = await settleWebhook(req.body); + console.log("coinpay webhook", JSON.stringify(result)); + res.json({ received: true, ...result }); + } catch (e) { + console.error("coinpay webhook settle failed:", e); + res.status(500).json({ error: "Settle failed" }); + } +}); diff --git a/apps/pwa/src/routes/rotation.mjs b/apps/pwa/src/routes/rotation.mjs index fa467c03..a2c0c9cd 100644 --- a/apps/pwa/src/routes/rotation.mjs +++ b/apps/pwa/src/routes/rotation.mjs @@ -28,6 +28,7 @@ import { page, footer, appBar, esc } from "../lib/html.mjs"; import { requireAuth, csrfInput } from "../lib/session.mjs"; import { keyCoversVault } from "../lib/apikey.mjs"; import { api, requireMember, audit } from "./credshare.mjs"; +import { autoRotationOn, autoRotationState, reconcileReturn, AUTO_ROTATION_PRICE_USD, AUTO_ROTATION_DAYS } from "../lib/auto-rotation.mjs"; import { rotationPlan, parseRunRequest, sanitizeResults, runRow, summarize, RotationRunError, maxAgeDays, DUE_RUN_MAX_KEYS, OPEN_RUN_STATUSES } from "../lib/rotation.mjs"; export const rotationRouter = Router(); @@ -81,7 +82,7 @@ export async function queueDueRun(team, user, req, { now = Date.now(), vaultFilt if (open) return { run: open, created: false, due: due.length, maxAgeDays: maxAge }; if (!due.length) return { run: null, created: false, due: 0, maxAgeDays: maxAge }; const keys = [...new Set(due.sort((a, b) => String(a.lastRotated).localeCompare(String(b.lastRotated))).map((p) => p.key))].slice(0, DUE_RUN_MAX_KEYS); - const auto = Boolean(Number(team.rotation_auto_approve)); + const auto = autoRotationOn(team, now); const runId = id(); await run( `INSERT INTO credshare_rotation_runs (id, team_id, requested_by, keys, project, env, approve, options, status, approved_at, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, @@ -131,7 +132,7 @@ rotationRouter.get("/api/credshare/teams/:slug/rotation", api(async (req, res, u vaultFilter: isMachine(key) ? (name) => keyCoversVault(key, name) : undefined, maxAge: maxAgeDays(ctx.team) }); - res.json({ team: ctx.team.slug, maxAgeDays: maxAgeDays(ctx.team), autoApprove: Boolean(Number(ctx.team.rotation_auto_approve)), plan, source: "catalog", note: "Computed from key names and fingerprints with the built-in catalog. Encrypted __ROTATE__ entries (hasEntry) can override it: logicsrc teams rotation shows the decrypted spec." }); + res.json({ team: ctx.team.slug, maxAgeDays: maxAgeDays(ctx.team), autoApprove: autoRotationOn(ctx.team), autoRotation: autoRotationState(ctx.team), plan, source: "catalog", note: "Computed from key names and fingerprints with the built-in catalog. Encrypted __ROTATE__ entries (hasEntry) can override it: logicsrc teams rotation shows the decrypted spec." }); }, { machine: "read" })); rotationRouter.get("/api/credshare/teams/:slug/rotation/runs", api(async (req, res, user, key) => { @@ -245,7 +246,19 @@ rotationRouter.get("/teams/:slug/rotation", requireAuth, async (req, res, next) const project = req.query.project ? String(req.query.project) : undefined; const env = req.query.env ? String(req.query.env) : undefined; const maxAge = maxAgeDays(ctx.team); - const autoApprove = Boolean(Number(ctx.team.rotation_auto_approve)); + let checkoutMsg = ""; + if (req.query.checkout === "success" && req.query.session_id) { + try { + const status = await reconcileReturn(String(req.query.session_id), ctx.team); + if (status === "paid") ctx.team = await get(`SELECT * FROM credshare_teams WHERE id = ?`, [ctx.team.id]); + checkoutMsg = status === "paid" ? "Payment received: automatic rotation is on." : "Thanks. Automatic rotation turns on as soon as CoinPay confirms the payment (a few minutes for most coins)."; + } catch (e) { + console.error("auto-rotation return check failed:", e.message); + checkoutMsg = "Thanks. Automatic rotation turns on as soon as CoinPay confirms the payment."; + } + } + const autoState = autoRotationState(ctx.team); + const autoApprove = autoState.on; const plan = await rotationPlan(ctx.team.id, { project, env, maxAge }); const due = plan.filter((p) => p.due); const runs = await listRuns(ctx.team.id, { limit: 25 }); @@ -257,14 +270,16 @@ rotationRouter.get("/teams/:slug/rotation", requireAuth, async (req, res, next) const body = `${appBar(req)}

Rotation /${esc(slug)}

${plan.length}
- ${msg ? `
${esc(msg)}
` : ""}${err ? `
${esc(err)}
` : ""} + ${msg || checkoutMsg ? `
${esc(msg || checkoutMsg)}
` : ""}${err ? `
${esc(err)}
` : ""}

How each secret in this team is rotated: ${auto} automatic, ${plan.length - auto} by hand or report-only. Values never reach this server, so a queued run is executed by the CLI or an agent: logicsrc teams rotate-keys ${esc(slug)} --from-queue [--approve] [--propagate] [--desktop <ssh-host>]. The runner's own --approve is needed for approved runs to write anything. Runbook: logicsrc.com/docs/rotation.

${maxAge}-day policy. ${due.length ? `${due.length} automatic secret${due.length === 1 ? " is" : "s are"} older than ${maxAge} days${due.some((p) => p.disruptive) ? ` (${due.filter((p) => p.disruptive).length} of them: ${esc("rotating signs users out")})` : ""}.` : `Every automatic secret is younger than ${maxAge} days.`} ${autoApprove - ? `Automatic rotation is on for this team: due keys rotate without a click.` - : `Due keys are queued as one run that waits for an owner or admin to approve it; nothing rotates before that. Automatic rotation without the approval step is a paid add-on.`} + ? `Automatic rotation is on for this team: due keys rotate without a click.${autoState.manual ? "" : ` Paid through ${esc(new Date(autoState.paidUntil).toISOString().slice(0, 10))} (${autoState.daysLeft} day${autoState.daysLeft === 1 ? "" : "s"} left).`}` + : `Due keys are queued as one run that waits for an owner or admin to approve it; nothing rotates before that.${autoState.expired ? " Automatic rotation for this team has ended." : ""}`} + ${!autoState.manual && approver ? `
${csrfInput(req)} Crypto or PayPal through CoinPay. One payment, nothing renews by itself.
` : ""} + ${!autoState.manual && !approver && !autoApprove ? `Automatic rotation without the approval step is a paid add-on ($${AUTO_ROTATION_PRICE_USD} / ${AUTO_ROTATION_DAYS} days) an owner or admin can turn on.` : ""} ${due.length && approver ? `
${csrfInput(req)}
` : ""}
diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index 6ca5b224..729f9e66 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -11,6 +11,8 @@ import { passkeyRouter } from "./routes/passkey.mjs"; import { coinpayRouter } from "./routes/coinpay.mjs"; import { credshareRouter } from "./routes/credshare.mjs"; import { rotationRouter } from "./routes/rotation.mjs"; +import { autoRotationRouter } from "./routes/auto-rotation.mjs"; +import { sweepAutoRotation } from "./lib/auto-rotation.mjs"; import { apiKeysRouter } from "./routes/apikeys.mjs"; import { opencredsRouter } from "./routes/opencreds.mjs"; import { keyringRouter } from "./routes/keyring.mjs"; @@ -80,6 +82,7 @@ app.use(authRouter); // GET / (+ /auth/login|register|logout) app.use(passkeyRouter); app.use(coinpayRouter); app.use(credshareRouter); // /api/credshare/* (session or lsk_ Bearer) +app.use(autoRotationRouter); // paid automatic rotation: CoinPay checkout + /api/coinpay/webhook app.use(rotationRouter); // /api/credshare/teams/:slug/rotation* + /teams/:slug/rotation (plan + run queue, never values) app.use(apiKeysRouter); // /api/keys (session or a person's lsk_ key; never a machine key) app.use(opencredsRouter); // /api/opencreds/* — personal vault sync (session or lsk_ Bearer) @@ -101,6 +104,10 @@ app.use((err, _req, res, _next) => { async function main() { await migrate(); app.listen(config.port, () => console.log(`🔐 logicsrc credentials on :${config.port} (${config.env}) — ${config.origin}`)); + // Paid automatic rotation: switch off lapsed teams, email renewal notices. Hourly. + const sweep = () => sweepAutoRotation().then((r) => (r.expired.length || r.reminded.length) && console.log("auto-rotation sweep", JSON.stringify(r))).catch((e) => console.error("auto-rotation sweep failed:", e.message)); + setTimeout(sweep, 60_000).unref(); + setInterval(sweep, 60 * 60 * 1000).unref(); } main().catch((e) => { console.error("boot failed:", e); process.exit(1); }); diff --git a/apps/pwa/test/auto-rotation.test.mjs b/apps/pwa/test/auto-rotation.test.mjs new file mode 100644 index 00000000..6ca967f0 --- /dev/null +++ b/apps/pwa/test/auto-rotation.test.mjs @@ -0,0 +1,174 @@ +// Paid automatic rotation (lib/auto-rotation.mjs, routes/auto-rotation.mjs): a +// CoinPay checkout buys a team 30 days of auto-approve; settling is idempotent, +// a second payment extends from the current end, a hand-flipped team is never +// capped, the hourly sweep switches lapsed teams off and sends one renewal notice. +process.env.DATABASE_URL = process.env.PWA_TEST_DATABASE_URL || ":memory:"; +process.env.COINPAY_WEBHOOK_SECRET = "whsec_test"; + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import express from "express"; +import { signWebhook } from "@profullstack/coinpay-onboard"; + +const here = dirname(fileURLToPath(import.meta.url)); +const { db, run, get, isPostgres } = await import("../src/db.mjs"); +const { autoRotationRouter } = await import("../src/routes/auto-rotation.mjs"); +const { rotationRouter, queueDueRun } = await import("../src/routes/rotation.mjs"); +const ar = await import("../src/lib/auto-rotation.mjs"); + +const DAY = 24 * 60 * 60 * 1000; +const now = Date.now(); + +async function migrate() { + if (isPostgres) { + await db.execute("DROP SCHEMA public CASCADE"); + await db.execute("CREATE SCHEMA public"); + } + for (const file of ["001_auth.sql", "002_credshare.sql", "005_machine_keys.sql", "007_hardening.sql", "010_rotation_runs.sql", "011_rotation_policy.sql", "012_auto_rotation_billing.sql"]) { + const sql = readFileSync(join(here, "..", "src", isPostgres ? "migrations-pg" : "migrations", file), "utf8"); + for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) await db.execute(statement); + } +} + +await migrate(); +for (const [uid, email] of [["u_owner", "owner@example.com"], ["u_member", "member@example.com"]]) { + await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, [uid, email, now]); +} +async function team(id, { auto = 0, paidUntil = null } = {}) { + await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at, rotation_auto_approve, rotation_auto_paid_until) VALUES (?,?,?,?,?,?,?)`, [id, id, id, "u_owner", now, auto, paidUntil]); + await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, created_at) VALUES (?,?,?,?,?,?,?)`, [`${id}-o`, id, "u_owner", "owner@example.com", "owner", "active", now]); + await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, created_at) VALUES (?,?,?,?,?,?,?)`, [`${id}-m`, id, "u_member", "member@example.com", "member", "active", now]); + const vault = `${id}--prod`; + await run(`INSERT INTO credshare_vaults (id, team_id, name, created_by, created_at) VALUES (?,?,?,?,?)`, [vault, id, vault, "u_owner", now]); + await run(`INSERT INTO credshare_secrets (vault_id, name, nonce, ciphertext, fingerprint, version, updated_by, updated_at) VALUES (?,?,?,?,?,?,?,?)`, [vault, "CRON_SECRET", "n", "CIPHERTEXT", `${id}-cron`, 1, "u_owner", now - 120 * DAY]); +} +const row = (id) => get(`SELECT * FROM credshare_teams WHERE id = ?`, [id]); + +async function serve(userId) { + const app = express(); + app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } })); + app.use(express.urlencoded({ extended: false })); + app.use((req, _res, next) => { if (userId) req.user = { id: userId }; req.csrfToken = "t"; next(); }); + app.use(autoRotationRouter); + app.use(rotationRouter); + const server = app.listen(0); + await new Promise((r) => server.once("listening", r)); + const base = `http://127.0.0.1:${server.address().port}`; + const call = async (method, path, body, headers = {}) => { + const raw = body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body); + const res = await fetch(`${base}${path}`, { method, redirect: "manual", headers: raw ? { "content-type": "application/json", ...headers } : headers, body: raw }); + const text = await res.text(); + return { status: res.status, location: res.headers.get("location"), body: text.startsWith("{") ? JSON.parse(text) : text }; + }; + return { call, close: () => new Promise((r) => server.close(r)) }; +} + +test("state: off, paid, lapsed, and hand-flipped", () => { + assert.equal(ar.autoRotationState({ rotation_auto_approve: 0 }, now).on, false); + const paid = ar.autoRotationState({ rotation_auto_approve: 1, rotation_auto_paid_until: now + 2.5 * DAY }, now); + assert.equal(paid.on, true); + assert.equal(paid.daysLeft, 3); + const lapsed = ar.autoRotationState({ rotation_auto_approve: 1, rotation_auto_paid_until: now - 1 }, now); + assert.equal(lapsed.on, false); + assert.equal(lapsed.expired, true); + const manual = ar.autoRotationState({ rotation_auto_approve: 1, rotation_auto_paid_until: null }, now); + assert.equal(manual.on, true); + assert.equal(manual.manual, true); +}); + +test("settle: activates 30 days, is idempotent, and a second payment extends from the end", async () => { + await team("buy"); + const first = await ar.settlePayment({ checkoutSessionId: "cs_one", teamId: "buy", userId: "u_owner", amountUsd: 29, method: "USDC_POL" }, now); + assert.equal(first.action, "activated"); + assert.equal(first.paidUntil, now + 30 * DAY); + assert.equal(Number((await row("buy")).rotation_auto_approve), 1); + const dup = await ar.settlePayment({ checkoutSessionId: "cs_one", teamId: "buy", amountUsd: 29 }, now + 1000); + assert.equal(dup.duplicate, true); + assert.equal(Number((await row("buy")).rotation_auto_paid_until), now + 30 * DAY); + const second = await ar.settlePayment({ checkoutSessionId: "cs_two", teamId: "buy", amountUsd: 29 }, now + 5 * DAY); + assert.equal(second.action, "extended"); + assert.equal(Number((await row("buy")).rotation_auto_paid_until), now + 60 * DAY); +}); + +test("settle: a hand-flipped team is never capped by a payment", async () => { + await team("comp", { auto: 1 }); + const r = await ar.settlePayment({ checkoutSessionId: "cs_comp", teamId: "comp", amountUsd: 29 }, now); + assert.equal(r.action, "already-on-by-hand"); + assert.equal((await row("comp")).rotation_auto_paid_until, null); +}); + +test("webhook: rejects a bad signature, settles a signed paid event once, ignores other properties", async (t) => { + await team("hook"); + const app = await serve(null); + t.after(app.close); + const event = (id, extra = {}) => JSON.stringify({ id, type: "payment.confirmed", data: { payment_id: `pay_${id}`, status: "confirmed", checkout_session_id: `cs_${id}`, amount_usd: 29, blockchain: "USDC_POL", property: "app.logicsrc.com", metadata: { purpose: "auto_rotation", team_id: "hook", user_id: "u_owner" }, ...extra } }); + const body = event("h1"); + assert.equal((await app.call("POST", "/api/coinpay/webhook", body, { "x-coinpay-signature": "t=1,v1=bad" })).status, 401); + const ok = await app.call("POST", "/api/coinpay/webhook", body, { "x-coinpay-signature": signWebhook(body, "whsec_test") }); + assert.equal(ok.status, 200); + assert.equal(ok.body.action, "activated"); + const again = await app.call("POST", "/api/coinpay/webhook", body, { "x-coinpay-signature": signWebhook(body, "whsec_test") }); + assert.equal(again.body.duplicate, true); + const other = event("h2", { property: "logicsrc.com" }); + const o = await app.call("POST", "/api/coinpay/webhook", other, { "x-coinpay-signature": signWebhook(other, "whsec_test") }); + assert.equal(o.body.handled, false); + const pending = JSON.stringify({ id: "h3", type: "payment.pending", data: { status: "pending", metadata: { team_id: "hook" } } }); + assert.equal((await app.call("POST", "/api/coinpay/webhook", pending, { "x-coinpay-signature": signWebhook(pending, "whsec_test") })).body.handled, false); +}); + +test("sweep: one renewal notice per term, then switches a lapsed team off", async () => { + await team("ending", { auto: 1, paidUntil: now + 2 * DAY }); + await team("lapsed", { auto: 1, paidUntil: now - 1000 }); + const sent = []; + const send = async (to, subject, text) => { sent.push({ to, subject, text }); return true; }; + const r1 = await ar.sweepAutoRotation({ now, send, origin: "https://app.example" }); + assert.ok(r1.reminded.includes("ending")); + assert.ok(r1.expired.includes("lapsed")); + assert.ok(!r1.reminded.includes("comp") && !r1.expired.includes("comp")); // hand-flipped: never touched + assert.equal(Number((await row("lapsed")).rotation_auto_approve), 0); + assert.equal(Number((await row("ending")).rotation_auto_approve), 1); + const notice = sent.find((m) => m.subject.includes("ending")); + assert.deepEqual(notice.to, ["owner@example.com"]); // owners/admins only + assert.match(notice.text, /https:\/\/app\.example\/teams\/ending\/rotation/); + const r2 = await ar.sweepAutoRotation({ now: now + 1000, send }); + assert.ok(!r2.reminded.includes("ending")); + // Renewing clears the notice for the new term. + await ar.settlePayment({ checkoutSessionId: "cs_renew", teamId: "ending", amountUsd: 29 }, now); + assert.equal((await row("ending")).rotation_auto_reminded_for, null); +}); + +test("due runs: a paid team skips approval, a lapsed one waits for it", async () => { + const paid = await queueDueRun(await row("buy"), { id: "u_owner" }, null, { now }); + assert.equal(paid.run.status, "queued"); + await team("late", { auto: 1, paidUntil: now - 1000 }); // lapsed but not swept yet + const late = await queueDueRun(await row("late"), { id: "u_owner" }, null, { now }); + assert.equal(late.run.status, "awaiting-approval"); +}); + +test("checkout: owner gets a CoinPay URL, a member is refused, the page shows the button", async (t) => { + await team("shop"); + const owner = await serve("u_owner"); + const member = await serve("u_member"); + t.after(owner.close); + t.after(member.close); + const page = await owner.call("GET", "/teams/shop/rotation"); + assert.match(page.body, /Turn on automatic rotation, \$29 \/ 30 days/); + assert.match(page.body, /action="\/teams\/shop\/rotation\/auto\/checkout"/); + const memberPage = await member.call("GET", "/teams/shop/rotation"); + assert.ok(!memberPage.body.includes("/rotation/auto/checkout")); + const refused = await member.call("POST", "/teams/shop/rotation/auto/checkout", {}); + assert.equal(refused.status, 302); + assert.match(refused.location, /err=/); + // createCheckout builds the session from the server's own price and the team. + let input; + const client = { createCheckoutSession: async (i) => { input = i; return { id: "cs_x", url: "https://coinpayportal.com/pay/cs_x" }; } }; + const s = await ar.createCheckout({ team: await row("shop"), user: { id: "u_owner", email: "owner@example.com" }, client, origin: "https://app.example" }); + assert.equal(s.url, "https://coinpayportal.com/pay/cs_x"); + assert.equal(input.amount, 29); + assert.equal(input.property, "app.logicsrc.com"); + assert.deepEqual(input.metadata, { purpose: "auto_rotation", team_id: "shop", team_slug: "shop", user_id: "u_owner", days: 30 }); + assert.equal(input.successUrl, "https://app.example/teams/shop/rotation?checkout=success&session_id={CHECKOUT_SESSION_ID}"); +}); diff --git a/apps/pwa/test/rotation.test.mjs b/apps/pwa/test/rotation.test.mjs index 8b3420cc..4fae6f04 100644 --- a/apps/pwa/test/rotation.test.mjs +++ b/apps/pwa/test/rotation.test.mjs @@ -250,7 +250,7 @@ test("policy: the Rotation page shows the 90-day policy, due pills and an Approv const { body } = await owner.call("GET", "/teams/policy/rotation"); assert.match(body, /90-day policy/); assert.match(body, /pill warn">due/); - assert.match(body, /paid add-on/); + assert.match(body, /Turn on automatic rotation, \$29 \/ 30 days/); const { body: premium } = await owner.call("GET", "/teams/premium/rotation"); assert.match(premium, /Automatic rotation is on/); const s = await owner.call("POST", "/api/credshare/teams/strict/rotation/due"); diff --git a/bun.lock b/bun.lock index 8e6a09ee..80a5aad4 100644 --- a/bun.lock +++ b/bun.lock @@ -83,6 +83,7 @@ "version": "0.1.1", "dependencies": { "@logicsrc/cli": "workspace:*", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/footer": "0.5.0", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/profile": "0.1.6", @@ -148,7 +149,7 @@ }, "packages/cli": { "name": "@logicsrc/cli", - "version": "0.15.0", + "version": "0.16.0", "bin": { "logicsrc": "./dist/index.js", }, @@ -753,6 +754,8 @@ "@profullstack/autoblog": ["@profullstack/autoblog@github:profullstack/autoblog#75e54af", {}, "profullstack-autoblog-75e54af", "sha512-f/YwwiVTzKz659+6RHgeGueSdUrWAnon5OHT1YfOI5TdLZPDrLFGE88051fzQXAlsBrkDK1jrm216OIrKygnRA=="], + "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.3.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ=="], + "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], "@profullstack/footer": ["@profullstack/footer@0.5.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-TrllS87nK7J0c/UgYckaFIWVhLOtr6m73XXFSHIuSfOg7NfuWUdwjtsfMXGaZiiUnmMYj3zhGcfzW0DicDwl8w=="], diff --git a/docs/rotation.md b/docs/rotation.md index 5050e303..b250fbcc 100644 --- a/docs/rotation.md +++ b/docs/rotation.md @@ -129,10 +129,23 @@ its own). The plan shows each key's age and a `due` flag once it is past it. Only automatic keys are ever due. `never` keys (wallets, Stripe, signing) and manual keys stay on the manual list in the weekly mail. -**Automatic rotation (paid add-on).** A team with `rotation_auto_approve` on -skips step 2: its due runs are queued already approved and rotate on the next -runner tick. It is off for every team by default. It is switched on per team by -us when the add-on is bought; there is no self-serve toggle yet. +**Automatic rotation (paid add-on, $29 per team per 30 days).** A team with +automatic rotation on skips step 2: its due runs are queued already approved and +rotate on the next runner tick. It is off for every team by default. + +An owner or admin buys it with the "Turn on automatic rotation" button on the +Rotation page (or `POST /api/credshare/teams/:slug/rotation/auto/checkout`, +which returns the checkout URL). Payment is a CoinPay hosted checkout, crypto or +PayPal, one-off: nothing renews by itself. Paying again adds 30 days to the +current end. The CoinPay webhook (`POST /api/coinpay/webhook`, business +"app.logicsrc.com") and the page's return check both settle the payment, once +per checkout session (`rotation_payments`). + +The app sweeps hourly: 3 days before the paid days end it emails the team's +owners and admins a renewal link (once per term), and when they end it turns +the switch off, so due keys wait for approval again. A team switched on by hand +(`rotation_auto_approve = 1` with `rotation_auto_paid_until` NULL) never +expires. State: `GET /api/credshare/teams/:slug/rotation/auto`. ```sh logicsrc teams rotate-keys profullstack --queue-due