studio-os 0.4.0 → 0.6.0
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/CHANGELOG.md +64 -0
- package/README.md +9 -4
- package/package.json +6 -5
- package/src/app.js +6 -0
- package/src/db/004-payment-booking.sql +5 -0
- package/src/db/index.js +1 -0
- package/src/lib/email.js +5 -0
- package/src/lib/ratelimit.js +65 -24
- package/src/routes/admin.js +791 -692
- package/src/routes/me.js +3 -9
- package/src/routes/public.js +14 -27
- package/src/routes/setup.js +2 -1
- package/src/services/booking.js +61 -8
- package/src/services/importer.js +3 -2
- package/src/services/notify.js +19 -0
- package/src/services/stripe.js +51 -0
- package/test/booking.test.js +80 -1
- package/test/ratelimit.test.js +28 -0
- package/test/stripe.test.js +90 -1
- package/test/web.test.js +184 -1
- package/views/admin/class_types.ejs +1 -1
- package/views/admin/roster.ejs +19 -0
- package/views/admin/rules.ejs +1 -1
- package/views/public/book_result.ejs +1 -1
- package/views/public/buy_thanks.ejs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 — 2026-09-27
|
|
4
|
+
|
|
5
|
+
**Upgrading:** in the Stripe dashboard, add `customer.subscription.updated`
|
|
6
|
+
to your webhook endpoint's events. Without it, failed renewals still won't
|
|
7
|
+
pause memberships. No database changes.
|
|
8
|
+
|
|
9
|
+
- **A failed renewal left the membership active.** When a card was declined
|
|
10
|
+
the subscription went past due, but the client kept booking on it. The
|
|
11
|
+
membership now pauses until the payment goes through, and resumes by
|
|
12
|
+
itself when it does.
|
|
13
|
+
- **Pausing or cancelling a Stripe membership didn't stop the billing.**
|
|
14
|
+
The buttons on the client page only changed the app's record. They now
|
|
15
|
+
pause, resume or cancel the subscription itself, and if Stripe says no,
|
|
16
|
+
nothing changes. An unrecognised status used to 500.
|
|
17
|
+
- **Capacity changes skipped classes already on the schedule.** Raising a
|
|
18
|
+
class type's capacity, or a rule's override, left every class already
|
|
19
|
+
generated at the old size. Upcoming classes still at that size now follow,
|
|
20
|
+
and the extra spots go to the waitlist, same as editing one class.
|
|
21
|
+
Classes resized by hand keep their own number.
|
|
22
|
+
- **Rate limiting on every route.** Magic links, login and booking already
|
|
23
|
+
had tight limits. Everything else now sits under a 1000 requests per
|
|
24
|
+
15 minutes ceiling per address, well above what a busy front desk uses.
|
|
25
|
+
IPv6 clients are counted per /56, so rotating addresses no longer gets
|
|
26
|
+
around any limit.
|
|
27
|
+
- **A long junk email could stall the server.** The email check backtracked
|
|
28
|
+
on a 50kb input for about a second, blocking every other request. It's
|
|
29
|
+
linear now.
|
|
30
|
+
- **Dependencies**: express-rate-limit 8.7.0 added.
|
|
31
|
+
- **Tests**: 144 → 151.
|
|
32
|
+
|
|
33
|
+
## 0.5.0 — 2026-09-27
|
|
34
|
+
|
|
35
|
+
**Upgrading:** the database migrates itself on start (schema v4, one new
|
|
36
|
+
column on `payments`). Drop-in dues recorded before the upgrade aren't linked
|
|
37
|
+
to a booking, so they won't clear themselves if that booking is cancelled.
|
|
38
|
+
Check the dashboard's Pending payments for stale ones.
|
|
39
|
+
|
|
40
|
+
- **Editing a weekly rule moved nothing and doubled the schedule.** Changing
|
|
41
|
+
a rule's day or time left every future class at the old slot and then
|
|
42
|
+
generated a second set at the new one. Empty future classes now move with
|
|
43
|
+
the rule, and booked ones stay put with a note saying how many. A malformed
|
|
44
|
+
time used to save, 500, and then break the daily generator for every rule.
|
|
45
|
+
It's rejected now.
|
|
46
|
+
- **Drop-in dues were charged to people who never got a spot.** Booking
|
|
47
|
+
onto the waitlist recorded a pending drop-in payment, and cancelling never
|
|
48
|
+
removed one. Dues are now tied to the booking: recorded when the client
|
|
49
|
+
holds a spot (including when the waitlist promotes them), dropped when
|
|
50
|
+
they cancel in time or the studio cancels the class. A forfeited late
|
|
51
|
+
cancel still owes, same as it would lose a pack credit.
|
|
52
|
+
- **The waitlist got promoted into classes that had already started.** A
|
|
53
|
+
late cancel or a staff roster edit after start time moved the next person
|
|
54
|
+
in and charged them for a class they'd missed. Clients also can't cancel
|
|
55
|
+
once a class has started. Staff still can, to tidy the roster.
|
|
56
|
+
- **The booking form renamed existing clients.** Anyone who typed a known
|
|
57
|
+
email with a different name overwrote that client's name. An existing
|
|
58
|
+
client now keeps theirs.
|
|
59
|
+
- **Edit a single class.** The roster page can change one class's capacity,
|
|
60
|
+
instructor and notes. Extra capacity goes to the waitlist in order, and
|
|
61
|
+
those clients are emailed.
|
|
62
|
+
- A two-credit class said "1 credit deducted". The purchase thank-you page
|
|
63
|
+
said the pass was active before Stripe had confirmed the payment.
|
|
64
|
+
- **Dependencies**: dotenv → 18.0.3, marked → 18.0.14, supertest → 7.3.0.
|
|
65
|
+
- **Tests**: 135 → 144.
|
|
66
|
+
|
|
3
67
|
## 0.4.0 — 2026-09-27
|
|
4
68
|
|
|
5
69
|
**Upgrading with Stripe:** add `checkout.session.async_payment_succeeded` and
|
package/README.md
CHANGED
|
@@ -136,9 +136,12 @@ activate purchases manually from the client profile — fully usable cash-only.
|
|
|
136
136
|
- `STRIPE_WEBHOOK_SECRET` — see step 3
|
|
137
137
|
3. Add a webhook endpoint in Stripe: `https://your-domain/webhooks/stripe`,
|
|
138
138
|
events `checkout.session.completed`, `checkout.session.async_payment_succeeded`,
|
|
139
|
-
`invoice.paid
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
`invoice.paid`, `customer.subscription.updated` and
|
|
140
|
+
`customer.subscription.deleted`. The async event is what fulfils payments
|
|
141
|
+
that clear later, like bank debits; card-only studios never see it.
|
|
142
|
+
`invoice.paid` records each membership renewal in the revenue report, and
|
|
143
|
+
`customer.subscription.updated` pauses a membership whose renewal failed
|
|
144
|
+
until the card goes through.
|
|
142
145
|
Copy the signing secret into `STRIPE_WEBHOOK_SECRET`.
|
|
143
146
|
(Local testing: `stripe listen --forward-to localhost:3000/webhooks/stripe`.)
|
|
144
147
|
The webhook secret is not optional: online payment stays off until it is
|
|
@@ -170,7 +173,9 @@ What's protected out of the box:
|
|
|
170
173
|
authenticated by Stripe's signature over the raw body instead.
|
|
171
174
|
- **Rate limiting** (in-memory fixed window, per IP + route): magic-link
|
|
172
175
|
requests 5/15 min, admin login 10/15 min, public booking/buy POSTs
|
|
173
|
-
30/15 min
|
|
176
|
+
30/15 min, and 1000/15 min across the whole app. IPv6 clients are
|
|
177
|
+
counted per /56, so rotating addresses doesn't reset the count. Over the
|
|
178
|
+
limit → friendly 429. Behind a reverse proxy, set
|
|
174
179
|
`TRUST_PROXY=1` so limits key on the first `X-Forwarded-For` hop; without
|
|
175
180
|
it that header is ignored (it's spoofable).
|
|
176
181
|
- **Reverse-proxy awareness**: `TRUST_PROXY=1` also makes the app honour
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "studio-os",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Self-hosted studio management: booking, class packs, memberships. Bring your own Stripe.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,15 +19,16 @@
|
|
|
19
19
|
"bcryptjs": "^3.0.3",
|
|
20
20
|
"better-sqlite3": "^13.0.1",
|
|
21
21
|
"cookie-session": "^2.1.0",
|
|
22
|
-
"dotenv": "^18.0.
|
|
22
|
+
"dotenv": "^18.0.3",
|
|
23
23
|
"ejs": "^6.0.1",
|
|
24
24
|
"express": "^5.2.1",
|
|
25
|
-
"
|
|
26
|
-
"
|
|
25
|
+
"express-rate-limit": "^8.7.0",
|
|
26
|
+
"marked": "^18.0.14",
|
|
27
|
+
"nodemailer": "^10.0.10",
|
|
27
28
|
"stripe": "^22.6.2"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
|
-
"supertest": "^7.
|
|
31
|
+
"supertest": "^7.3.0"
|
|
31
32
|
},
|
|
32
33
|
"repository": {
|
|
33
34
|
"type": "git",
|
package/src/app.js
CHANGED
|
@@ -58,6 +58,12 @@ export function createApp({ db, mailer, stripeService, env = process.env, now =
|
|
|
58
58
|
app.use('/admin/import', express.urlencoded({ extended: true, limit: '20mb' }));
|
|
59
59
|
app.use(express.urlencoded({ extended: true }));
|
|
60
60
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
61
|
+
// A ceiling on everything else, well above what a busy front desk or a
|
|
62
|
+
// shared studio wifi does. Static files don't count, and the Stripe
|
|
63
|
+
// webhook is mounted before this so a burst of events is never refused.
|
|
64
|
+
app.use(createRateLimiter({
|
|
65
|
+
windowMs: 15 * 60 * 1000, max: 1000, env, now, perRoute: false,
|
|
66
|
+
}));
|
|
61
67
|
app.use(cookieSession({
|
|
62
68
|
name: 'studio_session',
|
|
63
69
|
keys: [getSetting(db, 'app_secret', 'dev-secret')],
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- v4: a drop-in paid at the studio is only owed while its booking stands, so
|
|
2
|
+
-- the pending payment remembers which booking it's for.
|
|
3
|
+
|
|
4
|
+
ALTER TABLE payments ADD COLUMN booking_id INTEGER REFERENCES bookings(id);
|
|
5
|
+
CREATE INDEX IF NOT EXISTS idx_payments_booking ON payments(booking_id) WHERE booking_id IS NOT NULL;
|
package/src/db/index.js
CHANGED
package/src/lib/email.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Linear time on purpose. The old pattern let the domain overlap the dot and
|
|
2
|
+
// backtracked quadratically: one 50kb POST held the event loop for ~1s.
|
|
3
|
+
export function isEmail(s) {
|
|
4
|
+
return typeof s === 'string' && s.length <= 254 && /^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$/.test(s);
|
|
5
|
+
}
|
package/src/lib/ratelimit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// In-memory fixed-window rate limiting on top of express-rate-limit.
|
|
2
2
|
//
|
|
3
3
|
// Deliberately simple and honest about its limits: counters live in this
|
|
4
4
|
// process's memory, so they reset on restart and are per-process (a
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// proxy, set TRUST_PROXY=1 so the client IP is read from the first
|
|
7
7
|
// X-Forwarded-For hop; without it the header is ignored (spoofable) and the
|
|
8
8
|
// socket address is used.
|
|
9
|
+
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
|
|
9
10
|
|
|
10
11
|
export function clientIp(req, env = process.env) {
|
|
11
12
|
if (env.TRUST_PROXY) {
|
|
@@ -15,36 +16,76 @@ export function clientIp(req, env = process.env) {
|
|
|
15
16
|
return (req.socket && req.socket.remoteAddress) || 'unknown';
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
/** Fixed windows on an injectable clock, so tests can move time. */
|
|
20
|
+
class FixedWindowStore {
|
|
21
|
+
constructor(now) {
|
|
22
|
+
this.now = now;
|
|
23
|
+
this.buckets = new Map(); // key → { window, count }
|
|
24
|
+
this.localKeys = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
init({ windowMs }) {
|
|
28
|
+
this.windowMs = windowMs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
increment(key) {
|
|
32
|
+
const windowId = Math.floor(this.now() / this.windowMs);
|
|
33
|
+
let b = this.buckets.get(key);
|
|
30
34
|
if (!b || b.window !== windowId) {
|
|
31
35
|
b = { window: windowId, count: 0 };
|
|
32
|
-
buckets.set(key, b);
|
|
36
|
+
this.buckets.set(key, b);
|
|
33
37
|
}
|
|
34
38
|
b.count += 1;
|
|
35
|
-
|
|
39
|
+
// opportunistic cleanup so the map cannot grow without bound
|
|
40
|
+
if (this.buckets.size > 10000) {
|
|
41
|
+
for (const [k, v] of this.buckets) if (v.window !== windowId) this.buckets.delete(k);
|
|
42
|
+
}
|
|
43
|
+
return { totalHits: b.count, resetTime: new Date((windowId + 1) * this.windowMs) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
decrement(key) {
|
|
47
|
+
const b = this.buckets.get(key);
|
|
48
|
+
if (b && b.count > 0) b.count -= 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
resetKey(key) {
|
|
52
|
+
this.buckets.delete(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* createRateLimiter({ windowMs, max, env?, now?, message?, perRoute? }) → Express middleware.
|
|
58
|
+
* Keyed on client IP (IPv6 by /56, so one host can't rotate addresses) plus,
|
|
59
|
+
* unless perRoute is false, the matched route pattern (so /class/1/book and
|
|
60
|
+
* /class/2/book share one bucket). `now` is injectable for tests.
|
|
61
|
+
*/
|
|
62
|
+
export function createRateLimiter({
|
|
63
|
+
windowMs, max, env = process.env, now = Date.now, message, perRoute = true,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const store = new FixedWindowStore(now);
|
|
66
|
+
const mw = rateLimit({
|
|
67
|
+
windowMs,
|
|
68
|
+
limit: max,
|
|
69
|
+
store,
|
|
70
|
+
standardHeaders: false,
|
|
71
|
+
legacyHeaders: false,
|
|
72
|
+
// clientIp() does its own proxy handling; the library's checks assume req.ip.
|
|
73
|
+
validate: false,
|
|
74
|
+
keyGenerator(req) {
|
|
75
|
+
const ip = ipKeyGenerator(clientIp(req, env));
|
|
76
|
+
if (!perRoute) return ip;
|
|
77
|
+
return `${ip}|${req.method} ${(req.route && req.route.path) || req.path}`;
|
|
78
|
+
},
|
|
79
|
+
handler(req, res) {
|
|
80
|
+
// The app-wide limiter runs before the view locals exist.
|
|
81
|
+
res.locals = { settings: {}, user: null, flash: null, ...res.locals };
|
|
36
82
|
res.set('Retry-After', String(Math.ceil(windowMs / 1000)));
|
|
37
|
-
|
|
83
|
+
res.status(429).render('error', {
|
|
38
84
|
title: 'Too many requests',
|
|
39
85
|
message: message || 'Too many requests from your address — please wait a few minutes and try again.',
|
|
40
86
|
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for (const [k, v] of buckets) if (v.window !== windowId) buckets.delete(k);
|
|
45
|
-
}
|
|
46
|
-
next();
|
|
47
|
-
};
|
|
48
|
-
mw.buckets = buckets; // exposed for tests/inspection
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
mw.buckets = store.buckets; // exposed for tests/inspection
|
|
49
90
|
return mw;
|
|
50
91
|
}
|