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
9 changes: 9 additions & 0 deletions .changeset/billing-recent-auth-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@codelitdev/platform": minor
---

`createBillingActionGrants` takes `recentAuthActions`, the billing actions that need a recent sign-in. The default is every action, as before. Pass only the actions that are hard to undo, such as `["payer_transfer"]`, or `[]` for none.

**Product changes for this release**

- Asking for a fresh sign-in before checkout, a plan change, the portal, or a cancellation stops people mid-purchase without protecting anything: the single-use token already stops replay and CSRF, and the provider confirms the payer. Set `recentAuthActions` to the actions your product treats as sensitive.
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,12 @@ Admin auth follows FrontLit:
- API and MCP accept configured OAuth tokens and tenant-scoped API keys.
- Product code checks membership on every request.
- Sensitive actions use recent auth and a single-use action token or equivalent grant.
Sensitive means hard to undo or able to move money or control away from the
owner: deleting a tenant, changing payout or payment credentials, or moving
who pays for a subscription. Buying, changing, or cancelling a plan is not:
the payment provider confirms the payer, the single-use token already stops
replay and CSRF, and asking someone to sign in again before they pay only
loses the sale.

### 8.1 Auth persistence and schema lifecycle

Expand Down
5 changes: 3 additions & 2 deletions docs/decisions/0013-platform-billing-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ encryption keys. These adapters belong next to it, not inside it.

- Products delete their own grant, encryption, return-URL, worker, webhook,
and error-mapping code, and adopt these adapters.
- Billing actions need a recent sign-in. Products must handle
`recent_authentication_required` by asking the person to sign in again.
- Billing actions need a recent sign-in by default. Products choose which
actions do with `recentAuthActions` (see the architecture guide's rule on
sensitive actions) and handle `recent_authentication_required` for those.
- The `exports` test of `@codelitdev/platform` now lists the two billing
exports.
47 changes: 46 additions & 1 deletion packages/platform/src/billing/billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BillingConfigurationError,
BillingWorkflowError,
} from "@codelitdev/billing/core";
import type { BillingAction } from "@codelitdev/billing/workflows";
import { frozenClock } from "../clock.js";
import {
billingErrorResponse,
Expand All @@ -19,7 +20,12 @@ import {
const now = new Date("2026-06-01T12:00:00.000Z");
const target = { kind: "user", id: "user_1" };

function grants(options: { recentAuthMaxAgeMs?: number } = {}) {
function grants(
options: {
recentAuthMaxAgeMs?: number;
recentAuthActions?: readonly BillingAction[];
} = {},
) {
return createBillingActionGrants({
store: new MemoryActionGrantStore(),
clock: frozenClock(now),
Expand Down Expand Up @@ -88,6 +94,45 @@ describe("billing action grants", () => {
}
});

it("asks for a recent sign-in only for the actions the product names", async () => {
const g = grants({
recentAuthMaxAgeMs: 60_000,
recentAuthActions: ["payer_transfer"],
});
const signedInLongAgo = new Date(now.getTime() - 3_600_000);
const checkout = await g.issue({
actorId: "user_1",
sessionId: "session_1",
sessionCreatedAt: signedInLongAgo,
action: "checkout",
target,
});
expect(checkout.ok).toBe(true);
const transfer = await g.issue({
actorId: "user_1",
sessionId: "session_1",
sessionCreatedAt: signedInLongAgo,
action: "payer_transfer",
target,
});
expect(transfer).toEqual({ ok: false, error: "recent_authentication_required" });

const none = grants({ recentAuthMaxAgeMs: 60_000, recentAuthActions: [] });
for (const action of ["checkout", "cancellation", "payer_transfer"] as const) {
expect(
(
await none.issue({
actorId: "user_1",
sessionId: "session_1",
sessionCreatedAt: signedInLongAgo,
action,
target,
})
).ok,
).toBe(true);
}
});

it("requires a recent sign-in and rejects expired tokens", async () => {
const g = grants({ recentAuthMaxAgeMs: 60_000 });
const stale = await g.issue({
Expand Down
16 changes: 15 additions & 1 deletion packages/platform/src/billing/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,20 @@ export function createBillingActionGrants(options: {
ttlMs?: number;
/** How recently the session must have signed in. Default 15 minutes. */
recentAuthMaxAgeMs?: number;
/**
* The actions that need a recent sign-in. Default: every action. Pass the
* ones that are hard to undo, such as `payer_transfer`, or `[]` for none.
* Buying and managing a plan rarely qualify: the provider confirms the
* payer, and the single-use token already stops replay and CSRF.
*/
recentAuthActions?: readonly BillingAction[];
}) {
const clock = options.clock ?? systemClock;
const ttlMs = options.ttlMs ?? 5 * 60 * 1000;
const recentAuthMaxAgeMs = options.recentAuthMaxAgeMs ?? 15 * 60 * 1000;
const recentAuthActions = options.recentAuthActions
? new Set<BillingAction>(options.recentAuthActions)
: null;

async function issue(input: {
actorId: string;
Expand All @@ -65,7 +75,11 @@ export function createBillingActionGrants(options: {
target: BillingGrantTarget;
}): Promise<IssueBillingGrantResult> {
const now = clock.now();
if (now.getTime() - input.sessionCreatedAt.getTime() > recentAuthMaxAgeMs) {
const needsRecentAuth = recentAuthActions?.has(input.action) ?? true;
if (
needsRecentAuth &&
now.getTime() - input.sessionCreatedAt.getTime() > recentAuthMaxAgeMs
) {
return { ok: false, error: "recent_authentication_required" };
}
await options.store.purgeExpired?.(now);
Expand Down
Loading