Skip to content

Commit e60d31a

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
refactor(pwa): paid auto-rotation runs on @profullstack/key-rotation (#273)
The checkout, webhook settle, return check and expiry/renewal sweep from #272 move into the new @profullstack/key-rotation package (0.1.0) so other fleet apps can sell the same add-on. lib/auto-rotation.mjs keeps its exports and binds the package to credshare_teams, owners/admins, the audit log and the app.logicsrc.com CoinPay business. No schema or behaviour change. Co-authored-by: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0cfb94e commit e60d31a

4 files changed

Lines changed: 62 additions & 152 deletions

File tree

‎apps/pwa/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"dependencies": {
1414
"@logicsrc/cli": "workspace:*",
1515
"@profullstack/coinpay-onboard": "0.3.1",
16+
"@profullstack/key-rotation": "0.1.0",
1617
"@profullstack/footer": "0.5.0",
1718
"@profullstack/libsql-pg": "^0.1.4",
1819
"@profullstack/profile": "0.1.6",

‎apps/pwa/src/lib/auto-rotation.mjs‎

Lines changed: 52 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,37 @@
11
// Paid automatic rotation (Anthony, 2026-10-29): $29 buys one team 30 days of
22
// rotation_auto_approve, so its due runs rotate without the approval click.
33
//
4-
// CoinPay payments are one-off (crypto or PayPal), so nothing renews by itself:
5-
// a checkout buys a fixed term and paying again extends it from the current end.
6-
// The webhook and the Rotation page's return check both settle through
7-
// settlePayment(), which is idempotent on the checkout session id. The hourly
8-
// sweep turns the switch off when the paid days run out and emails the team's
9-
// owners and admins a renewal link 3 days before.
4+
// The checkout, webhook settle, return check and hourly expiry/renewal sweep live
5+
// in @profullstack/key-rotation; this file binds it to credshare_teams, the
6+
// team's owners and admins, the audit log and this app's CoinPay business.
107
//
118
// rotation_auto_paid_until NULL with the switch on means it was flipped by hand
129
// (a comp): it never expires and a payment does not cap it.
13-
import { CoinPay, readWebhook, verifyWebhook } from "@profullstack/coinpay-onboard";
10+
import { CoinPay, readWebhook } from "@profullstack/coinpay-onboard";
11+
import { createAutoRotation, sqlStore, resendMailer, DAY_MS } from "@profullstack/key-rotation";
1412
import { get, all, run } from "../db.mjs";
1513
import { config } from "../config.mjs";
1614

17-
export const DAY_MS = 24 * 60 * 60 * 1000;
15+
export { DAY_MS, readWebhook };
1816
export const AUTO_ROTATION_PRICE_USD = Number(process.env.AUTO_ROTATION_PRICE_USD || 29);
1917
export const AUTO_ROTATION_DAYS = 30;
2018
export const RENEWAL_NOTICE_DAYS = 3;
2119
export const PROPERTY = "app.logicsrc.com";
2220
export const PURPOSE = "auto_rotation";
2321

24-
export { readWebhook };
25-
26-
/** Is automatic rotation on for this team right now? */
27-
export function autoRotationState(team, now = Date.now()) {
28-
const flag = Boolean(Number(team?.rotation_auto_approve));
29-
const paidUntil = team?.rotation_auto_paid_until == null ? null : Number(team.rotation_auto_paid_until);
30-
const manual = flag && paidUntil === null;
31-
const on = flag && (manual || paidUntil > now);
32-
return { on, manual, paidUntil, expired: flag && !manual && paidUntil <= now, daysLeft: on && !manual ? Math.ceil((paidUntil - now) / DAY_MS) : null };
33-
}
34-
35-
export const autoRotationOn = (team, now = Date.now()) => autoRotationState(team, now).on;
22+
export const store = sqlStore({
23+
get, all, run,
24+
subjects: { table: "credshare_teams" },
25+
payments: { table: "rotation_payments", subject: "team_id" },
26+
approverEmails: async (teamId) => (await all(
27+
`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')`,
28+
[teamId]
29+
)).map((r) => r.email).filter(Boolean),
30+
audit: ({ key, subject, userId, action, paidUntil, now }) => run(
31+
`INSERT INTO credshare_audit (id, team_id, actor_user_id, action, key_name, created_at) VALUES (?,?,?,?,?,?)`,
32+
[`aud_${key}`, subject.id, userId || subject.created_by, action === "extended" ? "rotation.auto.extended" : "rotation.auto.paid", new Date(paidUntil).toISOString().slice(0, 10), now]
33+
),
34+
});
3635

3736
/** A CoinPay client on app.logicsrc.com's business key. */
3837
export function coinpayClient(env = process.env) {
@@ -41,149 +40,50 @@ export function coinpayClient(env = process.env) {
4140
return new CoinPay({ token, base: env.COINPAY_API_URL || undefined });
4241
}
4342

44-
/** Open a hosted checkout for 30 days of automatic rotation on one team. */
45-
export async function createCheckout({ team, user, client = coinpayClient(), origin = config.origin }) {
46-
if (!user?.id || !user?.email) throw new Error("A signed-in user with an email is required");
47-
const back = `${origin}/teams/${encodeURIComponent(team.slug)}/rotation`;
48-
return client.createCheckoutSession({
49-
amount: AUTO_ROTATION_PRICE_USD,
50-
currency: "USD",
51-
description: `LogicSRC automatic rotation for ${team.slug}, ${AUTO_ROTATION_DAYS} days`,
52-
customerEmail: user.email,
53-
accountId: user.id,
43+
/** The module bound to this app; `origin` only changes the links (tests). */
44+
export function autoRotation(origin = config.origin, send = resendMailer(config.resend)) {
45+
return createAutoRotation({
5446
property: PROPERTY,
55-
metadata: { purpose: PURPOSE, team_id: team.id, team_slug: team.slug, user_id: user.id, days: AUTO_ROTATION_DAYS },
56-
successUrl: `${back}?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
57-
cancelUrl: `${back}?checkout=cancelled`,
58-
expiresInMinutes: 60,
47+
purpose: PURPOSE,
48+
product: "LogicSRC",
49+
priceUsd: AUTO_ROTATION_PRICE_USD,
50+
days: AUTO_ROTATION_DAYS,
51+
noticeDays: RENEWAL_NOTICE_DAYS,
52+
store,
53+
coinpay: () => coinpayClient(),
54+
webhookSecret: () => process.env.COINPAY_WEBHOOK_SECRET,
55+
pageUrl: (team) => `${origin}/teams/${encodeURIComponent(team.slug)}/rotation`,
56+
send,
5957
});
6058
}
59+
const app = autoRotation();
6160

62-
/** Verify X-CoinPay-Signature over the raw body. */
63-
export function verifySignature(rawBody, header, secret = process.env.COINPAY_WEBHOOK_SECRET) {
64-
if (!secret || !rawBody) return false;
65-
try { return verifyWebhook(rawBody, header, secret); } catch { return false; }
66-
}
61+
/** Is automatic rotation on for this team row right now? */
62+
export const autoRotationState = (team, now = Date.now()) => app.state(store.toSubject(team), now);
63+
export const autoRotationOn = (team, now = Date.now()) => autoRotationState(team, now).on;
6764

68-
/**
69-
* Grant the days a paid checkout bought. Idempotent on the checkout session (or
70-
* payment) id: the first settle extends the team, any later one (confirmed then
71-
* forwarded, a webhook retry, the return check) changes nothing.
72-
*/
73-
export async function settlePayment(payment, now = Date.now()) {
74-
const { checkoutSessionId, paymentId, teamId } = payment;
75-
const key = checkoutSessionId || paymentId;
76-
if (!key) return { handled: false, reason: "no checkout session or payment id" };
77-
if (!teamId) return { handled: false, reason: "no team_id" };
78-
// No amount check: only this site's business key can open a session, at the
79-
// server's price, and a crypto webhook's `amount` may be in the coin, not USD.
80-
const team = await get(`SELECT * FROM credshare_teams WHERE id = ?`, [teamId]);
81-
if (!team) return { handled: false, reason: "unknown team" };
65+
/** Open a hosted checkout for 30 days of automatic rotation on one team. */
66+
export const createCheckout = ({ team, user, client, origin }) =>
67+
(origin ? autoRotation(origin) : app).createCheckout({ subject: team, user, client });
8268

83-
const ins = await run(
84-
`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`,
85-
[key, paymentId || null, teamId, payment.userId || null, payment.amountUsd ?? null, payment.method || null, AUTO_ROTATION_DAYS, now]
86-
);
87-
if (!Number(ins.rowsAffected)) return { handled: true, duplicate: true, teamId };
69+
export const verifySignature = (rawBody, header, secret) => app.verifySignature(rawBody, header, secret);
8870

89-
const state = autoRotationState(team, now);
90-
if (state.manual) {
91-
await run(`UPDATE rotation_payments SET paid_until = NULL WHERE checkout_session_id = ?`, [key]);
92-
return { handled: true, action: "already-on-by-hand", teamId };
93-
}
94-
const from = state.on ? state.paidUntil : now;
95-
const paidUntil = from + AUTO_ROTATION_DAYS * DAY_MS;
96-
await run(
97-
`UPDATE credshare_teams SET rotation_auto_approve = 1, rotation_auto_paid_until = ?, rotation_auto_reminded_for = NULL WHERE id = ?`,
98-
[paidUntil, teamId]
99-
);
100-
await run(`UPDATE rotation_payments SET paid_until = ? WHERE checkout_session_id = ?`, [paidUntil, key]);
101-
await run(
102-
`INSERT INTO credshare_audit (id, team_id, actor_user_id, action, key_name, created_at) VALUES (?,?,?,?,?,?)`,
103-
[`aud_${key}`, teamId, payment.userId || team.created_by, state.on ? "rotation.auto.extended" : "rotation.auto.paid", new Date(paidUntil).toISOString().slice(0, 10), now]
104-
).catch(() => {});
105-
return { handled: true, action: state.on ? "extended" : "activated", teamId, paidUntil };
71+
/** Grant the days a paid checkout bought; idempotent on the checkout session id. */
72+
export async function settlePayment({ teamId, ...payment }, now = Date.now()) {
73+
const { subjectId, ...result } = await app.settlePayment({ ...payment, subjectId: teamId }, now);
74+
return subjectId === undefined ? result : { ...result, teamId: subjectId };
10675
}
10776

10877
/** Settle from a verified webhook payload. */
10978
export async function settleWebhook(payload, now = Date.now()) {
110-
const event = readWebhook(payload);
111-
if (!event.paid) return { handled: false, type: event.type, status: event.status };
112-
if (event.property && event.property !== PROPERTY) return { handled: false, reason: "other property" };
113-
const meta = event.metadata || {};
114-
if (meta.purpose && meta.purpose !== PURPOSE) return { handled: false, reason: `purpose ${meta.purpose}` };
115-
return settlePayment({
116-
checkoutSessionId: event.checkoutSessionId,
117-
paymentId: event.paymentId,
118-
teamId: meta.team_id,
119-
userId: meta.user_id || event.accountId,
120-
amountUsd: event.amountUsd,
121-
method: event.method,
122-
}, now);
123-
}
124-
125-
/**
126-
* The Rotation page's return check: if CoinPay already marks the session paid
127-
* for this team, settle it now (the webhook may still be on its way).
128-
*/
129-
export async function reconcileReturn(sessionId, team, client = coinpayClient(), now = Date.now()) {
130-
if (!sessionId || !/^cs_[A-Za-z0-9_-]+$/.test(sessionId)) return null;
131-
const session = await client.checkoutSession(sessionId);
132-
const meta = session.metadata || {};
133-
if (meta.team_id !== team.id || (meta.purpose && meta.purpose !== PURPOSE)) return null;
134-
if (session.status === "paid") {
135-
await settlePayment({
136-
checkoutSessionId: session.id,
137-
paymentId: session.paymentId || null,
138-
teamId: team.id,
139-
userId: meta.user_id || session.accountId,
140-
amountUsd: session.amount,
141-
method: session.paidMethod || null,
142-
}, now);
143-
}
144-
return session.status;
145-
}
146-
147-
async function sendMail(to, subject, text) {
148-
if (!config.resend.apiKey || !to.length) return false;
149-
const response = await fetch("https://api.resend.com/emails", {
150-
method: "POST",
151-
headers: { authorization: `Bearer ${config.resend.apiKey}`, "content-type": "application/json" },
152-
body: JSON.stringify({ from: config.resend.from, to, subject, text }),
153-
}).catch(() => null);
154-
return Boolean(response && response.ok);
79+
const { subjectId, ...result } = await app.settleWebhook(payload, now);
80+
return subjectId === undefined ? result : { ...result, teamId: subjectId };
15581
}
15682

157-
async function approverEmails(teamId) {
158-
return (await all(
159-
`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')`,
160-
[teamId]
161-
)).map((r) => r.email).filter(Boolean);
162-
}
83+
/** The Rotation page's return check. */
84+
export const reconcileReturn = (sessionId, team, client, now = Date.now()) =>
85+
app.reconcileReturn(sessionId, team, client, now);
16386

164-
/**
165-
* The hourly sweep: turn the switch off for teams whose paid days ran out, and
166-
* email a renewal link to teams with 3 days or less left (once per paid term).
167-
*/
168-
export async function sweepAutoRotation({ now = Date.now(), send = sendMail, origin = config.origin } = {}) {
169-
const lapsed = await all(
170-
`SELECT id, slug FROM credshare_teams WHERE rotation_auto_approve = 1 AND rotation_auto_paid_until IS NOT NULL AND rotation_auto_paid_until <= ?`,
171-
[now]
172-
);
173-
for (const t of lapsed) {
174-
await run(`UPDATE credshare_teams SET rotation_auto_approve = 0 WHERE id = ? AND rotation_auto_paid_until <= ?`, [t.id, now]);
175-
await send(await approverEmails(t.id), `Automatic rotation is now off for ${t.slug}`,
176-
`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`);
177-
}
178-
const ending = await all(
179-
`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)`,
180-
[now, now + RENEWAL_NOTICE_DAYS * DAY_MS]
181-
);
182-
for (const t of ending) {
183-
const until = new Date(Number(t.rotation_auto_paid_until)).toISOString().slice(0, 10);
184-
await send(await approverEmails(t.id), `Automatic rotation for ${t.slug} ends ${until}`,
185-
`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`);
186-
await run(`UPDATE credshare_teams SET rotation_auto_reminded_for = ? WHERE id = ?`, [t.rotation_auto_paid_until, t.id]);
187-
}
188-
return { expired: lapsed.map((t) => t.slug), reminded: ending.map((t) => t.slug) };
189-
}
87+
/** The hourly sweep: expiry + one renewal notice per term. */
88+
export const sweepAutoRotation = ({ now = Date.now(), send, origin } = {}) =>
89+
(origin ? autoRotation(origin) : app).sweep({ now, ...(send ? { send } : {}) });

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/rotation.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ the switch off, so due keys wait for approval again. A team switched on by hand
147147
(`rotation_auto_approve = 1` with `rotation_auto_paid_until` NULL) never
148148
expires. State: `GET /api/credshare/teams/:slug/rotation/auto`.
149149

150+
The billing logic (checkout, webhook settle, return check, sweep) is the
151+
[`@profullstack/key-rotation`](https://github.com/profullstack/key-rotation)
152+
package; `apps/pwa/src/lib/auto-rotation.mjs` only binds it to `credshare_teams`,
153+
the audit log and this app's CoinPay business, so other fleet apps can sell the
154+
same add-on.
155+
150156
```sh
151157
logicsrc teams rotate-keys profullstack --queue-due
152158
logicsrc teams rotation-approve profullstack <run-id>

0 commit comments

Comments
 (0)