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 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` and `customer.subscription.deleted`. The async event is what
140
- fulfils payments that clear later, like bank debits; card-only studios never
141
- see it. `invoice.paid` records each membership renewal in the revenue report.
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. Over the limit → friendly 429. Behind a reverse proxy, set
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.4.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.2",
22
+ "dotenv": "^18.0.3",
23
23
  "ejs": "^6.0.1",
24
24
  "express": "^5.2.1",
25
- "marked": "^18.0.9",
26
- "nodemailer": "^10.0.9",
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.0.0"
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
@@ -10,6 +10,7 @@ const MIGRATIONS = [
10
10
  { version: 1, file: 'schema.sql' },
11
11
  { version: 2, file: '002-instructor-role.sql' },
12
12
  { version: 3, file: '003-stripe-invoices.sql' },
13
+ { version: 4, file: '004-payment-booking.sql' },
13
14
  ];
14
15
 
15
16
  const DEFAULT_SETTINGS = {
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- // Tiny in-memory fixed-window rate limiter. No dependencies.
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
- * createRateLimiter({ windowMs, max, env?, now?, message? }) → Express middleware.
20
- * Fixed window keyed on client IP + matched route pattern (so /class/1/book
21
- * and /class/2/book share one bucket). `now` is injectable for tests.
22
- */
23
- export function createRateLimiter({ windowMs, max, env = process.env, now = Date.now, message } = {}) {
24
- const buckets = new Map(); // key → { window, count }
25
- const mw = (req, res, next) => {
26
- const windowId = Math.floor(now() / windowMs);
27
- const route = (req.route && req.route.path) || req.path;
28
- const key = `${clientIp(req, env)}|${req.method} ${route}`;
29
- let b = buckets.get(key);
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
- if (b.count > max) {
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
- return res.status(429).render('error', {
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
- // opportunistic cleanup so the map cannot grow without bound
43
- if (buckets.size > 10000) {
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
  }