@rmdes/indiekit-endpoint-donation 0.1.0-alpha.1 → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +23 -7
- package/lib/controllers/campaigns.js +21 -11
- package/lib/controllers/consent.js +9 -5
- package/lib/controllers/donations.js +36 -13
- package/lib/controllers/manual.js +38 -16
- package/lib/controllers/stats.js +4 -1
- package/lib/validate.js +74 -0
- package/package.json +2 -1
package/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
|
|
4
4
|
import express from "express";
|
|
5
|
+
import rateLimit from "express-rate-limit";
|
|
5
6
|
import { waitForReady } from "@rmdes/indiekit-startup-gate";
|
|
6
7
|
|
|
7
8
|
import { dashboardController } from "./lib/controllers/dashboard.js";
|
|
@@ -89,25 +90,40 @@ export default class DonationEndpoint {
|
|
|
89
90
|
router.post("/sync", syncController.runOnce);
|
|
90
91
|
router.post("/rebuild", syncController.triggerRebuild);
|
|
91
92
|
|
|
93
|
+
// Stats JSON (donor-data-sanitized but still admin-only as defense
|
|
94
|
+
// in depth — the public Eleventy site reads MongoDB directly via the
|
|
95
|
+
// _data/donations.js loader, not via this HTTP endpoint).
|
|
96
|
+
router.get("/stats.json", statsController.json);
|
|
97
|
+
router.get("/stats/:campaignId.json", statsController.byCampaign);
|
|
98
|
+
|
|
92
99
|
return router;
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
// Public routes — Stripe webhook
|
|
102
|
+
// Public routes — Stripe webhook ONLY. Everything else (stats, donations,
|
|
103
|
+
// campaigns) is under the admin-protected `routes` getter above.
|
|
104
|
+
//
|
|
105
|
+
// Rate-limit the webhook endpoint to bound damage from a misbehaving
|
|
106
|
+
// upstream OR a forged-but-unsigned flood. Stripe typically sends one
|
|
107
|
+
// event at a time with exponential backoff on failure — 60/min/IP is
|
|
108
|
+
// well above legitimate traffic and well below "abuse" threshold.
|
|
96
109
|
get routesPublic() {
|
|
97
110
|
const router = express.Router();
|
|
98
111
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
const webhookLimiter = rateLimit({
|
|
113
|
+
windowMs: 60 * 1000, // 1 minute window
|
|
114
|
+
max: 60, // 60 requests per IP per window
|
|
115
|
+
message: { error: "rate_limited" },
|
|
116
|
+
standardHeaders: true,
|
|
117
|
+
legacyHeaders: false,
|
|
118
|
+
});
|
|
119
|
+
|
|
102
120
|
router.post(
|
|
103
121
|
"/webhook",
|
|
122
|
+
webhookLimiter,
|
|
104
123
|
express.raw({ type: "application/json" }),
|
|
105
124
|
webhookController.receive,
|
|
106
125
|
);
|
|
107
126
|
|
|
108
|
-
router.get("/stats.json", statsController.json);
|
|
109
|
-
router.get("/stats/:campaignId.json", statsController.byCampaign);
|
|
110
|
-
|
|
111
127
|
return router;
|
|
112
128
|
}
|
|
113
129
|
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Campaign list + detail +
|
|
2
|
+
* Campaign list + detail + local-override edit.
|
|
3
|
+
* Admin-only (session middleware). Inputs validated to prevent NoSQL
|
|
4
|
+
* injection via untyped params + body fields.
|
|
5
|
+
*
|
|
3
6
|
* Campaigns are authoritatively defined in Stripe Products — this admin
|
|
4
|
-
* UI is read-mostly. The only writable fields are local overrides
|
|
5
|
-
* (
|
|
6
|
-
*
|
|
7
|
+
* UI is read-mostly. The only writable fields are local overrides:
|
|
8
|
+
* - hidden_on_public (hide from /soutenir/ + /transparence/)
|
|
9
|
+
* - display_order (sort hint)
|
|
7
10
|
* @module controllers/campaigns
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { listCampaigns, getCampaign, upsertCampaign } from "../storage/campaigns.js";
|
|
11
14
|
import { sumByCampaign } from "../storage/donations.js";
|
|
15
|
+
import * as v from "../validate.js";
|
|
12
16
|
|
|
13
17
|
async function list(request, response, next) {
|
|
14
18
|
try {
|
|
15
19
|
const { application } = request.app.locals;
|
|
16
20
|
const campaigns = await listCampaigns(application);
|
|
17
|
-
// Augment each campaign with its current donation total.
|
|
18
21
|
const augmented = await Promise.all(
|
|
19
22
|
campaigns.map(async (c) => {
|
|
20
23
|
const { total, count } = await sumByCampaign(application, c.stripe_product_id);
|
|
@@ -32,7 +35,10 @@ async function list(request, response, next) {
|
|
|
32
35
|
async function detail(request, response, next) {
|
|
33
36
|
try {
|
|
34
37
|
const { application } = request.app.locals;
|
|
35
|
-
|
|
38
|
+
let productId;
|
|
39
|
+
try { productId = v.safeId(request.params.id); }
|
|
40
|
+
catch { return response.status(400).render("donation-not-found"); }
|
|
41
|
+
const c = await getCampaign(application, productId);
|
|
36
42
|
if (!c) return response.status(404).render("donation-not-found");
|
|
37
43
|
const { total, count } = await sumByCampaign(application, c.stripe_product_id);
|
|
38
44
|
response.render("donation-campaign-edit", {
|
|
@@ -46,15 +52,19 @@ async function detail(request, response, next) {
|
|
|
46
52
|
async function update(request, response, next) {
|
|
47
53
|
try {
|
|
48
54
|
const { application } = request.app.locals;
|
|
49
|
-
|
|
55
|
+
let productId;
|
|
56
|
+
try { productId = v.safeId(request.params.id); }
|
|
57
|
+
catch { return response.status(400).render("donation-not-found"); }
|
|
58
|
+
const c = await getCampaign(application, productId);
|
|
50
59
|
if (!c) return response.status(404).render("donation-not-found");
|
|
51
|
-
|
|
60
|
+
|
|
61
|
+
const display_order = v.nonNegInt(request.body.display_order) ?? (c.display_order ?? 100);
|
|
52
62
|
await upsertCampaign(application, {
|
|
53
63
|
...c,
|
|
54
|
-
hidden_on_public: request.body.hidden_on_public
|
|
55
|
-
display_order
|
|
64
|
+
hidden_on_public: v.checkbox(request.body.hidden_on_public),
|
|
65
|
+
display_order,
|
|
56
66
|
});
|
|
57
|
-
response.redirect(`${application.donationEndpoint}/campaigns/${
|
|
67
|
+
response.redirect(`${application.donationEndpoint}/campaigns/${productId}`);
|
|
58
68
|
} catch (err) { next(err); }
|
|
59
69
|
}
|
|
60
70
|
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Toggle a donation's public-display consent flag.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Admin-only (session middleware). Input validated to prevent NoSQL
|
|
4
|
+
* injection via untyped params.
|
|
5
5
|
* @module controllers/consent
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getDonation, updateConsent } from "../storage/donations.js";
|
|
9
9
|
import { triggerRebuild } from "../build/trigger.js";
|
|
10
|
+
import * as v from "../validate.js";
|
|
10
11
|
|
|
11
12
|
async function toggle(request, response, next) {
|
|
12
13
|
try {
|
|
13
14
|
const { application } = request.app.locals;
|
|
14
|
-
|
|
15
|
+
let id;
|
|
16
|
+
try { id = v.safeId(request.params.id); }
|
|
17
|
+
catch { return response.status(400).render("donation-not-found"); }
|
|
18
|
+
const current = await getDonation(application, id);
|
|
15
19
|
if (!current) return response.status(404).render("donation-not-found");
|
|
16
|
-
await updateConsent(application,
|
|
20
|
+
await updateConsent(application, id, !current.donor?.consent_public);
|
|
17
21
|
await triggerRebuild(application);
|
|
18
|
-
response.redirect(`${application.donationEndpoint}/donations/${
|
|
22
|
+
response.redirect(`${application.donationEndpoint}/donations/${id}`);
|
|
19
23
|
} catch (err) { next(err); }
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Donations list + detail + edit + delete.
|
|
2
|
+
* Donations list + detail + edit + delete. Admin-only (session middleware).
|
|
3
|
+
* All inputs validated through lib/validate.js before reaching MongoDB.
|
|
3
4
|
* @module controllers/donations
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { listDonations, getDonation, upsertDonation, removeDonation } from "../storage/donations.js";
|
|
8
|
+
import * as v from "../validate.js";
|
|
7
9
|
|
|
8
10
|
async function list(request, response, next) {
|
|
9
11
|
try {
|
|
10
12
|
const { application } = request.app.locals;
|
|
11
|
-
|
|
13
|
+
// Filters from query string: never trust as Mongo operators.
|
|
14
|
+
let campaign_id = null;
|
|
15
|
+
if (request.query.campaign_id) {
|
|
16
|
+
try { campaign_id = v.safeId(request.query.campaign_id); } catch { /* ignore */ }
|
|
17
|
+
}
|
|
18
|
+
const sourceVal = v.str(request.query.source, { max: 30, allowEmpty: true });
|
|
19
|
+
const source = ["stripe", "givewp_migrated", "manual"].includes(sourceVal) ? sourceVal : null;
|
|
20
|
+
const limit = v.nonNegInt(request.query.limit, { max: 200 }) ?? 50;
|
|
21
|
+
const page = v.nonNegInt(request.query.page, { max: 10000 }) ?? 1;
|
|
22
|
+
|
|
12
23
|
const items = await listDonations(application, {
|
|
13
24
|
campaign_id,
|
|
14
25
|
source,
|
|
15
|
-
limit
|
|
16
|
-
skip: (
|
|
26
|
+
limit,
|
|
27
|
+
skip: (page - 1) * limit,
|
|
17
28
|
includeRefunded: true,
|
|
18
29
|
});
|
|
19
30
|
response.render("donation-donations", {
|
|
@@ -28,7 +39,10 @@ async function list(request, response, next) {
|
|
|
28
39
|
async function detail(request, response, next) {
|
|
29
40
|
try {
|
|
30
41
|
const { application } = request.app.locals;
|
|
31
|
-
|
|
42
|
+
let id;
|
|
43
|
+
try { id = v.safeId(request.params.id); }
|
|
44
|
+
catch { return response.status(400).render("donation-not-found"); }
|
|
45
|
+
const donation = await getDonation(application, id);
|
|
32
46
|
if (!donation) return response.status(404).render("donation-not-found");
|
|
33
47
|
response.render("donation-donation-edit", {
|
|
34
48
|
title: donation.stripe_id,
|
|
@@ -39,30 +53,39 @@ async function detail(request, response, next) {
|
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
async function update(request, response, next) {
|
|
42
|
-
// TODO: validate inputs, only allow editing display name / message / consent flag.
|
|
43
|
-
// Stripe data (amount, date, campaign, donor_id) is canonical from Stripe — not editable.
|
|
44
56
|
try {
|
|
45
57
|
const { application } = request.app.locals;
|
|
46
|
-
|
|
58
|
+
let id;
|
|
59
|
+
try { id = v.safeId(request.params.id); }
|
|
60
|
+
catch { return response.status(400).render("donation-not-found"); }
|
|
61
|
+
const current = await getDonation(application, id);
|
|
47
62
|
if (!current) return response.status(404).render("donation-not-found");
|
|
63
|
+
|
|
64
|
+
// Only editable fields: display name, message, consent flag.
|
|
65
|
+
// Stripe-canonical fields (amount, date, campaign, donor_id) are NEVER mutated.
|
|
66
|
+
const display = v.str(request.body.display, { max: 60, allowEmpty: true });
|
|
67
|
+
const message = v.str(request.body.message, { max: 200, allowEmpty: true });
|
|
48
68
|
const next_ = {
|
|
49
69
|
...current,
|
|
50
70
|
donor: {
|
|
51
71
|
...current.donor,
|
|
52
|
-
display:
|
|
53
|
-
message:
|
|
54
|
-
consent_public: request.body.consent_public
|
|
72
|
+
display: display || null,
|
|
73
|
+
message: message || null,
|
|
74
|
+
consent_public: v.checkbox(request.body.consent_public),
|
|
55
75
|
},
|
|
56
76
|
};
|
|
57
77
|
await upsertDonation(application, next_);
|
|
58
|
-
response.redirect(`${application.donationEndpoint}/donations/${
|
|
78
|
+
response.redirect(`${application.donationEndpoint}/donations/${id}`);
|
|
59
79
|
} catch (err) { next(err); }
|
|
60
80
|
}
|
|
61
81
|
|
|
62
82
|
async function remove(request, response, next) {
|
|
63
83
|
try {
|
|
64
84
|
const { application } = request.app.locals;
|
|
65
|
-
|
|
85
|
+
let id;
|
|
86
|
+
try { id = v.safeId(request.params.id); }
|
|
87
|
+
catch { return response.status(400).render("donation-not-found"); }
|
|
88
|
+
await removeDonation(application, id);
|
|
66
89
|
response.redirect(`${application.donationEndpoint}/donations`);
|
|
67
90
|
} catch (err) { next(err); }
|
|
68
91
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Manual offline-donation entry — fills the gap for cash/bank-transfer
|
|
3
|
-
* gifts that never went through Stripe.
|
|
3
|
+
* gifts that never went through Stripe. Admin-only (session middleware).
|
|
4
4
|
* @module controllers/manual
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { upsertDonation } from "../storage/donations.js";
|
|
8
8
|
import { listCampaigns } from "../storage/campaigns.js";
|
|
9
9
|
import { triggerRebuild } from "../build/trigger.js";
|
|
10
|
+
import * as v from "../validate.js";
|
|
10
11
|
|
|
11
12
|
async function form(request, response, next) {
|
|
12
13
|
try {
|
|
@@ -21,36 +22,57 @@ async function form(request, response, next) {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
async function create(request, response, next) {
|
|
25
|
+
const { application } = request.app.locals;
|
|
26
|
+
const fail = (reason) =>
|
|
27
|
+
response.status(400).render("donation-manual-error", {
|
|
28
|
+
title: request.__("donation.manual.error.title"),
|
|
29
|
+
message: reason,
|
|
30
|
+
baseUrl: application.donationEndpoint,
|
|
31
|
+
});
|
|
32
|
+
|
|
24
33
|
try {
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
});
|
|
34
|
+
// Native validation — see lib/validate.js for the helpers.
|
|
35
|
+
const amount_cents = v.cents(request.body.amount);
|
|
36
|
+
if (amount_cents === null) return fail("Montant invalide.");
|
|
37
|
+
|
|
38
|
+
let campaign_id;
|
|
39
|
+
try {
|
|
40
|
+
campaign_id = v.safeId(request.body.campaign_id);
|
|
41
|
+
} catch {
|
|
42
|
+
return fail("Identifiant de campagne invalide.");
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
const date = v.isoDate(request.body.date);
|
|
46
|
+
if (!date) return fail("Date invalide.");
|
|
47
|
+
|
|
48
|
+
const currencyCode = v.currency(request.body.currency)
|
|
49
|
+
|| application.donationConfig?.currency
|
|
50
|
+
|| "EUR";
|
|
51
|
+
|
|
52
|
+
const donor_display = v.str(request.body.donor_display, { max: 60, allowEmpty: true });
|
|
53
|
+
const message = v.str(request.body.message, { max: 200, allowEmpty: true });
|
|
54
|
+
const note = v.str(request.body.note, { max: 500, allowEmpty: true });
|
|
55
|
+
|
|
37
56
|
const id = `manual_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
38
57
|
const donation = {
|
|
39
58
|
stripe_id: id,
|
|
40
59
|
date: new Date(date),
|
|
41
60
|
amount_cents,
|
|
42
|
-
currency:
|
|
61
|
+
currency: currencyCode,
|
|
43
62
|
campaign_id,
|
|
44
63
|
recurring: false,
|
|
45
64
|
donor: {
|
|
46
65
|
donor_id: `manual_${id}`,
|
|
47
|
-
display: donor_display
|
|
48
|
-
consent_public: consent_public
|
|
49
|
-
message: message
|
|
66
|
+
display: donor_display || null,
|
|
67
|
+
consent_public: v.checkbox(request.body.consent_public),
|
|
68
|
+
message: message || null,
|
|
50
69
|
},
|
|
51
70
|
source: "manual",
|
|
52
71
|
refunded: false,
|
|
53
|
-
meta: {
|
|
72
|
+
meta: {
|
|
73
|
+
entered_by: typeof request.session?.me === "string" ? request.session.me : "unknown",
|
|
74
|
+
note: note || null,
|
|
75
|
+
},
|
|
54
76
|
};
|
|
55
77
|
await upsertDonation(application, donation);
|
|
56
78
|
await triggerRebuild(application);
|
package/lib/controllers/stats.js
CHANGED
|
@@ -11,12 +11,15 @@ import { listCampaigns } from "../storage/campaigns.js";
|
|
|
11
11
|
async function json(request, response, next) {
|
|
12
12
|
try {
|
|
13
13
|
const { application } = request.app.locals;
|
|
14
|
-
const [stats,
|
|
14
|
+
const [stats, allCampaigns, donations] = await Promise.all([
|
|
15
15
|
lifetimeStats(application),
|
|
16
16
|
listCampaigns(application),
|
|
17
17
|
listDonations(application, { limit: 100 }),
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
|
+
// Public endpoint — never surface campaigns the admin chose to hide.
|
|
21
|
+
const campaigns = allCampaigns.filter((c) => !c.hidden_on_public);
|
|
22
|
+
|
|
20
23
|
// Augment campaigns with their current totals.
|
|
21
24
|
const enriched = await Promise.all(
|
|
22
25
|
campaigns.map(async (c) => {
|
package/lib/validate.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native input validation helpers. Used by admin controllers to coerce
|
|
3
|
+
* request.body / request.params fields to safe types BEFORE they hit
|
|
4
|
+
* MongoDB or get rendered back to clients.
|
|
5
|
+
*
|
|
6
|
+
* Why no Zod or similar: the plugin keeps its dependency footprint
|
|
7
|
+
* minimal. These helpers cover the narrow set of inputs the donation
|
|
8
|
+
* plugin actually accepts and reject everything else as bad input.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Coerce to a non-empty string of bounded length. Returns null if invalid. */
|
|
12
|
+
export function str(value, { max = 200, allowEmpty = false } = {}) {
|
|
13
|
+
if (typeof value !== "string") return null;
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
if (!allowEmpty && !trimmed) return null;
|
|
16
|
+
return trimmed.slice(0, max);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Coerce to a positive integer (cents). Returns null on invalid input. */
|
|
20
|
+
export function cents(value, { min = 1, max = 1_000_000_00 } = {}) {
|
|
21
|
+
// Accept "12,50" (FR) and "12.50" (en). Floor-fail on garbage.
|
|
22
|
+
const cleaned = typeof value === "string"
|
|
23
|
+
? value.replace(",", ".").trim()
|
|
24
|
+
: value;
|
|
25
|
+
const n = Number(cleaned);
|
|
26
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
27
|
+
const c = Math.round(n * 100);
|
|
28
|
+
if (c < min || c > max) return null;
|
|
29
|
+
return c;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Coerce to ISO 8601 date string (YYYY-MM-DD or full). Returns null if invalid. */
|
|
33
|
+
export function isoDate(value) {
|
|
34
|
+
if (typeof value !== "string") return null;
|
|
35
|
+
const d = new Date(value);
|
|
36
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
37
|
+
return d.toISOString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Coerce to a non-negative integer (for display_order etc). */
|
|
41
|
+
export function nonNegInt(value, { max = 1000 } = {}) {
|
|
42
|
+
const n = Number(value);
|
|
43
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
44
|
+
if (n > max) return null;
|
|
45
|
+
return Math.floor(n);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Coerce HTML form checkbox value to boolean. */
|
|
49
|
+
export function checkbox(value) {
|
|
50
|
+
return value === "on" || value === "true" || value === true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Coerce a Stripe / Mongo identifier to a string. Strips any object/array
|
|
55
|
+
* shape that could be mistaken for a Mongo query operator (e.g. {$ne: null}).
|
|
56
|
+
* Throws on truly hostile input so the caller can return 400.
|
|
57
|
+
*/
|
|
58
|
+
export function safeId(value, { maxLen = 80 } = {}) {
|
|
59
|
+
if (typeof value !== "string") {
|
|
60
|
+
throw new Error("id must be a string");
|
|
61
|
+
}
|
|
62
|
+
// Allow alphanumerics, underscore, hyphen — Stripe IDs and our own.
|
|
63
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length > maxLen) {
|
|
64
|
+
throw new Error("id contains invalid characters or is too long");
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Allow-listed currency code. */
|
|
70
|
+
export function currency(value, { allowed = ["EUR", "USD", "GBP", "CHF"] } = {}) {
|
|
71
|
+
if (typeof value !== "string") return null;
|
|
72
|
+
const upper = value.toUpperCase();
|
|
73
|
+
return allowed.includes(upper) ? upper : null;
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-donation",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
4
|
"description": "Stripe-backed donation endpoint for Indiekit. Treats Stripe Products as campaigns, PaymentIntents/Checkout Sessions as donations, exposes admin UI + public stats API + rebuild trigger.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"@indiekit/frontend": "^1.0.0-beta.27",
|
|
38
38
|
"@rmdes/indiekit-startup-gate": "^1.0.0",
|
|
39
39
|
"express": "^5.0.0",
|
|
40
|
+
"express-rate-limit": "^8.5.2",
|
|
40
41
|
"stripe": "^17.0.0"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|