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
3 changes: 2 additions & 1 deletion apps/pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
189 changes: 189 additions & 0 deletions apps/pwa/src/lib/auto-rotation.mjs
Original file line number Diff line number Diff line change
@@ -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) };
}
5 changes: 3 additions & 2 deletions apps/pwa/src/lib/security-headers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
// cannot read storage.
//
// form-action allows the CLI's loopback login redirect (http://127.0.0.1:<port>)
// 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'",
Expand All @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions apps/pwa/src/migrations-pg/012_auto_rotation_billing.sql
Original file line number Diff line number Diff line change
@@ -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);
23 changes: 23 additions & 0 deletions apps/pwa/src/migrations/012_auto_rotation_billing.sql
Original file line number Diff line number Diff line change
@@ -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);
62 changes: 62 additions & 0 deletions apps/pwa/src/routes/auto-rotation.mjs
Original file line number Diff line number Diff line change
@@ -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" });
}
});
Loading
Loading