studio-os 0.5.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,35 @@
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
+
3
33
  ## 0.5.0 — 2026-09-27
4
34
 
5
35
  **Upgrading:** the database migrates itself on start (schema v4, one new
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.5.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",
@@ -22,6 +22,7 @@
22
22
  "dotenv": "^18.0.3",
23
23
  "ejs": "^6.0.1",
24
24
  "express": "^5.2.1",
25
+ "express-rate-limit": "^8.7.0",
25
26
  "marked": "^18.0.14",
26
27
  "nodemailer": "^10.0.10",
27
28
  "stripe": "^22.6.2"
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
+ // 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
  }
@@ -12,9 +12,10 @@ import { importClients, importPasses } from '../services/importer.js';
12
12
  import { emails } from '../services/mailer.js';
13
13
  import { notifyPromoted } from '../services/notify.js';
14
14
  import { localDateStr, localTimeStr, zonedToUtc, addDays, weekdayOf } from '../lib/time.js';
15
+ import { isEmail } from '../lib/email.js';
15
16
 
16
17
  export default function adminRoutes(services) {
17
- const { db, mailer } = services;
18
+ const { db, mailer, stripe } = services;
18
19
  const r = Router();
19
20
  r.use(requireAdmin);
20
21
 
@@ -75,16 +76,46 @@ export default function adminRoutes(services) {
75
76
  res.redirect('/admin/class-types');
76
77
  });
77
78
 
79
+ // Future classes still at the old size follow a capacity change, and anyone
80
+ // who moves up from the waitlist is emailed. A class resized by hand on its
81
+ // roster keeps its own number.
82
+ function resizeFuture(req, res, instanceIds, capacity) {
83
+ const results = db.transaction(() => instanceIds.map((id) => updateInstance(db, id, { capacity })))();
84
+ let promoted = 0;
85
+ for (const { instance, promoted: rows } of results) {
86
+ for (const p of rows) notifyPromoted(services, req, res, p, instance);
87
+ promoted += rows.length;
88
+ }
89
+ return promoted;
90
+ }
91
+ const resizedNote = (count, promoted) => (count
92
+ ? ` ${count} upcoming class(es) resized${promoted ? `, ${promoted} moved up from the waitlist and emailed` : ''}.`
93
+ : '');
94
+
78
95
  r.post('/class-types/:id', (req, res) => {
79
96
  const b = req.body;
80
97
  if (b.action === 'toggle') {
81
98
  db.prepare('UPDATE class_types SET active = 1 - active WHERE id = ?').run(req.params.id);
82
99
  } else {
100
+ const capacity = Number(b.capacity || 10);
101
+ if (!Number.isInteger(capacity) || capacity < 1) {
102
+ req.session.flash = 'Capacity must be a whole number, at least 1.';
103
+ return res.redirect('/admin/class-types');
104
+ }
105
+ const type = db.prepare('SELECT capacity FROM class_types WHERE id = ?').get(req.params.id);
106
+ if (!type) return res.redirect('/admin/class-types');
83
107
  db.prepare(
84
108
  'UPDATE class_types SET name=?, description=?, duration_min=?, capacity=?, drop_in_price_cents=?, credits_required=?, color=? WHERE id=?'
85
- ).run(b.name, b.description || '', Number(b.duration_min) || 60, Number(b.capacity) || 10,
109
+ ).run(b.name, b.description || '', Number(b.duration_min) || 60, capacity,
86
110
  Math.round(Number(b.drop_in_price || 0) * 100), Number(b.credits_required) || 1,
87
111
  b.color || '#4f7cac', req.params.id);
112
+ const ids = capacity === type.capacity ? [] : db.prepare(
113
+ `SELECT ci.id FROM class_instances ci LEFT JOIN schedule_rules sr ON sr.id = ci.rule_id
114
+ WHERE ci.class_type_id = ? AND ci.status = 'scheduled' AND ci.starts_at > ?
115
+ AND ci.capacity = ? AND sr.capacity_override IS NULL`
116
+ ).pluck().all(req.params.id, new Date().toISOString(), type.capacity);
117
+ const promoted = resizeFuture(req, res, ids, capacity);
118
+ req.session.flash = `Class type updated.${resizedNote(ids.length, promoted)}`;
88
119
  }
89
120
  audit(db, who(req), 'class_type_update', `class_type:${req.params.id}`);
90
121
  res.redirect('/admin/class-types');
@@ -117,7 +148,7 @@ export default function adminRoutes(services) {
117
148
  r.post('/instructors/new', (req, res) => {
118
149
  const email = String(req.body.email || '').trim().toLowerCase();
119
150
  const password = String(req.body.password || '');
120
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || password.length < 8) {
151
+ if (!isEmail(email) || password.length < 8) {
121
152
  return res.status(400).render('admin/instructor_new', {
122
153
  title: 'New instructor login',
123
154
  error: 'A valid email and a password of at least 8 characters are required.',
@@ -242,15 +273,31 @@ export default function adminRoutes(services) {
242
273
  req.session.flash = 'Start time must be HH:MM.';
243
274
  return res.redirect('/admin/rules');
244
275
  }
276
+ const override = b.capacity_override ? Number(b.capacity_override) : null;
277
+ if (override !== null && (!Number.isInteger(override) || override < 1)) {
278
+ req.session.flash = 'Capacity override must be a whole number, at least 1.';
279
+ return res.redirect('/admin/rules');
280
+ }
281
+ const effectiveCapacity = (ruleId) => db.prepare(
282
+ `SELECT COALESCE(sr.capacity_override, ct.capacity) FROM schedule_rules sr
283
+ JOIN class_types ct ON ct.id = sr.class_type_id WHERE sr.id = ?`
284
+ ).pluck().get(ruleId);
285
+ const before = effectiveCapacity(req.params.id);
245
286
  db.transaction(() => {
246
287
  db.prepare(
247
288
  'UPDATE schedule_rules SET class_type_id=?, instructor_id=?, weekday=?, start_time=?, capacity_override=?, active_from=?, active_until=? WHERE id=?'
248
289
  ).run(Number(b.class_type_id), Number(b.instructor_id) || null, Number(b.weekday),
249
- b.start_time, Number(b.capacity_override) || null, b.active_from || null,
290
+ b.start_time, override, b.active_from || null,
250
291
  b.active_until || null, req.params.id);
251
292
  dropUnbookedFutureInstances(req.params.id);
252
293
  generateInstances(db);
253
294
  })();
295
+ const after = effectiveCapacity(req.params.id);
296
+ const ids = after === before ? [] : db.prepare(
297
+ `SELECT id FROM class_instances WHERE rule_id = ? AND class_type_id = ? AND status = 'scheduled'
298
+ AND starts_at > ? AND capacity = ?`
299
+ ).pluck().all(req.params.id, Number(b.class_type_id), new Date().toISOString(), before);
300
+ const promoted = resizeFuture(req, res, ids, after);
254
301
  // Booked classes stay where they were, so say if any are now off-pattern.
255
302
  const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
256
303
  const stranded = db.prepare(
@@ -259,9 +306,9 @@ export default function adminRoutes(services) {
259
306
  weekdayOf(localDateStr(tz, ci.starts_at)) !== Number(b.weekday)
260
307
  || localTimeStr(tz, ci.starts_at) !== b.start_time
261
308
  )).length;
262
- req.session.flash = stranded
309
+ req.session.flash = (stranded
263
310
  ? `Rule updated. ${stranded} booked class(es) are still at the old time; cancel them from the schedule if they've moved.`
264
- : 'Rule updated and schedule regenerated.';
311
+ : 'Rule updated and schedule regenerated.') + resizedNote(ids.length, promoted);
265
312
  }
266
313
  audit(db, who(req), 'rule_update', `rule:${req.params.id}`);
267
314
  res.redirect('/admin/rules');
@@ -386,7 +433,7 @@ export default function adminRoutes(services) {
386
433
  r.post('/instances/:id/walkin', (req, res) => {
387
434
  const email = String(req.body.email || '').trim().toLowerCase();
388
435
  const name = String(req.body.name || '').trim();
389
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
436
+ if (!isEmail(email)) {
390
437
  req.session.flash = 'A valid email is required for walk-ins.';
391
438
  return res.redirect(`/admin/instances/${req.params.id}`);
392
439
  }
@@ -425,7 +472,7 @@ export default function adminRoutes(services) {
425
472
 
426
473
  r.post('/clients', (req, res) => {
427
474
  const email = String(req.body.email || '').trim().toLowerCase();
428
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
475
+ if (!isEmail(email)) {
429
476
  req.session.flash = 'A valid email is required.';
430
477
  return res.redirect('/admin/clients');
431
478
  }
@@ -495,13 +542,29 @@ export default function adminRoutes(services) {
495
542
  res.redirect(`/admin/clients/${req.params.id}`);
496
543
  });
497
544
 
498
- r.post('/clients/:id/memberships', (req, res) => {
545
+ r.post('/clients/:id/memberships', async (req, res) => {
499
546
  const b = req.body;
500
547
  if (b.action === 'status' && b.membership_id) {
501
- db.prepare('UPDATE memberships SET status = ? WHERE id = ? AND client_id = ?')
502
- .run(b.status, b.membership_id, req.params.id);
503
- audit(db, who(req), 'membership_status', `membership:${b.membership_id} → ${b.status}`);
504
- return res.redirect(`/admin/clients/${req.params.id}`);
548
+ const back = `/admin/clients/${req.params.id}`;
549
+ const m = db.prepare('SELECT * FROM memberships WHERE id = ? AND client_id = ?')
550
+ .get(b.membership_id, req.params.id);
551
+ if (!m || !['active', 'paused', 'cancelled'].includes(b.status)) {
552
+ req.session.flash = 'Membership or status not recognised.';
553
+ return res.redirect(back);
554
+ }
555
+ if (m.stripe_subscription_id && !stripe.configured) {
556
+ req.session.flash = "Stripe isn't configured, so the subscription behind this membership wasn't changed. Change it in the Stripe dashboard too.";
557
+ } else if (m.stripe_subscription_id) {
558
+ try {
559
+ await stripe.setSubscriptionStatus(m.stripe_subscription_id, b.status);
560
+ } catch (err) {
561
+ req.session.flash = `Stripe refused the change, so nothing changed here either: ${err.message}`;
562
+ return res.redirect(back);
563
+ }
564
+ }
565
+ db.prepare('UPDATE memberships SET status = ? WHERE id = ?').run(b.status, m.id);
566
+ audit(db, who(req), 'membership_status', `membership:${m.id} → ${b.status}`);
567
+ return res.redirect(back);
505
568
  }
506
569
  const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
507
570
  const today = localDateStr(tz);
@@ -5,6 +5,7 @@ import { upcomingInstances } from '../services/schedule.js';
5
5
  import { book, resolvePayment, BookingError } from '../services/booking.js';
6
6
  import { makeMagicToken } from '../services/auth.js';
7
7
  import { emails } from '../services/mailer.js';
8
+ import { isEmail } from '../lib/email.js';
8
9
 
9
10
  function findOrCreateClient(db, name, email) {
10
11
  const cleanEmail = String(email || '').trim().toLowerCase();
@@ -73,7 +74,7 @@ export default function publicRoutes(services) {
73
74
  error, form: { name, email },
74
75
  });
75
76
 
76
- if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return renderErr('A valid email is required.');
77
+ if (!isEmail(email)) return renderErr('A valid email is required.');
77
78
 
78
79
  try {
79
80
  const result = db.transaction(() => {
@@ -143,7 +144,7 @@ export default function publicRoutes(services) {
143
144
  const product = db.prepare('SELECT * FROM pack_products WHERE id = ? AND active = 1').get(req.params.id);
144
145
  if (!product) return res.status(404).render('error', { title: 'Not found', message: 'Pack not found.' });
145
146
  const email = String(req.body.email || '').trim().toLowerCase();
146
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
147
+ if (!isEmail(email)) {
147
148
  return res.status(400).render('public/buy_manual', { title: 'Buy', product, plan: null, email: null, error: 'A valid email is required.' });
148
149
  }
149
150
  if (services.stripe.checkoutReady) {
@@ -164,7 +165,7 @@ export default function publicRoutes(services) {
164
165
  const plan = db.prepare('SELECT * FROM membership_plans WHERE id = ? AND active = 1').get(req.params.id);
165
166
  if (!plan) return res.status(404).render('error', { title: 'Not found', message: 'Plan not found.' });
166
167
  const email = String(req.body.email || '').trim().toLowerCase();
167
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
168
+ if (!isEmail(email)) {
168
169
  return res.status(400).render('public/buy_manual', { title: 'Buy', product: null, plan, email: null, error: 'A valid email is required.' });
169
170
  }
170
171
  if (services.stripe.checkoutReady && plan.stripe_price_id) {
@@ -1,6 +1,7 @@
1
1
  import { Router } from 'express';
2
2
  import { setSetting, getSetting, audit } from '../db/index.js';
3
3
  import { createUser } from '../services/auth.js';
4
+ import { isEmail } from '../lib/email.js';
4
5
 
5
6
  const TIMEZONES = [
6
7
  'Asia/Hong_Kong', 'Asia/Singapore', 'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Bangkok',
@@ -21,7 +22,7 @@ export default function setupRoutes({ db }) {
21
22
  const fail = (error) => res.status(400).render('setup', { title: 'Setup', timezones: TIMEZONES, error, form });
22
23
 
23
24
  if (!studio_name || !studio_name.trim()) return fail('Studio name is required.');
24
- if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return fail('A valid owner email is required.');
25
+ if (!isEmail(email)) return fail('A valid owner email is required.');
25
26
  if (!password || password.length < 8) return fail('Password must be at least 8 characters.');
26
27
  if (password !== password2) return fail('Passwords do not match.');
27
28
  try {
@@ -1,6 +1,7 @@
1
1
  // Mindbody CSV importer core. Used by scripts/import-mindbody.mjs and the
2
2
  // admin import page. Idempotent by client email: re-running updates, never
3
3
  // duplicates. No external CSV dependency — small RFC-4180-ish parser below.
4
+ import { isEmail } from '../lib/email.js';
4
5
 
5
6
  /** Parse CSV text into an array of row objects keyed by header. Handles quoted
6
7
  * fields, embedded commas/newlines, and doubled quotes. */
@@ -96,7 +97,7 @@ export function importClients(db, csvText, { mapping = DEFAULT_MAPPING.clients,
96
97
  const work = db.transaction(() => {
97
98
  for (const row of rows) {
98
99
  const email = pick(row, mapping.email).toLowerCase();
99
- if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
100
+ if (!isEmail(email)) {
100
101
  result.skipped++;
101
102
  result.rows.push({ action: 'skip', reason: 'missing/invalid email', row });
102
103
  continue;
@@ -148,7 +149,7 @@ export function importPasses(db, csvText, { mapping = DEFAULT_MAPPING.passes, dr
148
149
  const work = db.transaction(() => {
149
150
  for (const row of rows) {
150
151
  const email = pick(row, mapping.email).toLowerCase();
151
- if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
152
+ if (!isEmail(email)) {
152
153
  result.skipped++;
153
154
  result.rows.push({ action: 'skip', reason: 'missing/invalid email', row });
154
155
  continue;
@@ -67,6 +67,29 @@ export function createStripeService({ env = process.env, client } = {}) {
67
67
  });
68
68
  },
69
69
 
70
+ /**
71
+ * Carry a staff status change over to the membership's subscription, so a
72
+ * cancelled or paused member stops being billed. Pausing voids invoices
73
+ * rather than deferring them.
74
+ */
75
+ async setSubscriptionStatus(subscriptionId, status) {
76
+ const s = await getClient();
77
+ if (status !== 'cancelled') {
78
+ return s.subscriptions.update(subscriptionId, {
79
+ pause_collection: status === 'paused' ? { behavior: 'void' } : '',
80
+ });
81
+ }
82
+ try {
83
+ return await s.subscriptions.cancel(subscriptionId);
84
+ } catch (err) {
85
+ // Already cancelled at Stripe's end is what we wanted anyway.
86
+ if (err.code === 'resource_missing') return null;
87
+ const sub = await s.subscriptions.retrieve(subscriptionId);
88
+ if (sub.status !== 'canceled') throw err;
89
+ return sub;
90
+ }
91
+ },
92
+
70
93
  /** Verify webhook signature and return the event. Throws on bad signature. */
71
94
  async parseWebhook(rawBody, signature) {
72
95
  const s = await getClient();
@@ -191,6 +214,31 @@ export function recordMembershipRenewal(db, invoice) {
191
214
  })();
192
215
  }
193
216
 
217
+ // Subscription status → membership status. A failed renewal pauses the
218
+ // membership, so it stops covering classes until the card goes through.
219
+ const MEMBERSHIP_STATUS = {
220
+ active: 'active', trialing: 'active',
221
+ past_due: 'paused', unpaid: 'paused', paused: 'paused',
222
+ canceled: 'cancelled', incomplete_expired: 'cancelled',
223
+ };
224
+
225
+ /**
226
+ * Follow a subscription's status onto its membership. Only an actual status
227
+ * change counts: every renewal fires customer.subscription.updated too, and
228
+ * those mustn't undo a pause staff set in the app. A cancelled membership
229
+ * stays cancelled.
230
+ */
231
+ function followSubscriptionStatus(db, sub, previous) {
232
+ const status = MEMBERSHIP_STATUS[sub.status];
233
+ if (!('status' in previous) || !status) return { fulfilled: false, reason: 'no_status_change' };
234
+ const r = db.prepare(
235
+ "UPDATE memberships SET status = ? WHERE stripe_subscription_id = ? AND status != 'cancelled'"
236
+ ).run(status, sub.id);
237
+ if (!r.changes) return { fulfilled: false, reason: 'unknown_subscription' };
238
+ audit(db, 'stripe-webhook', 'subscription_status', `${sub.id} ${previous.status} → ${sub.status}`);
239
+ return { fulfilled: true, kind: 'subscription_status', status };
240
+ }
241
+
194
242
  /** Handle a parsed Stripe event. Returns a result object for logging. */
195
243
  export function handleStripeEvent(db, event) {
196
244
  if (event.type === 'checkout.session.completed'
@@ -208,5 +256,8 @@ export function handleStripeEvent(db, event) {
208
256
  audit(db, 'stripe-webhook', 'subscription_cancelled', sub.id);
209
257
  return { fulfilled: r.changes > 0, kind: 'subscription_cancelled' };
210
258
  }
259
+ if (event.type === 'customer.subscription.updated') {
260
+ return followSubscriptionStatus(db, event.data.object, event.data.previous_attributes || {});
261
+ }
211
262
  return { fulfilled: false, reason: `ignored event ${event.type}` };
212
263
  }
@@ -121,3 +121,31 @@ test('X-Forwarded-For is ignored unless TRUST_PROXY is set', async () => {
121
121
  .set('X-Forwarded-For', '2.2.2.2').send({ email: 'a@t.test', _csrf: csrf2 });
122
122
  assert.equal(otherIp.status, 200, 'different forwarded IP has its own bucket');
123
123
  });
124
+
125
+ test('one IPv6 host cannot dodge the limit by rotating addresses', async () => {
126
+ const { app } = makeApp({ env: { TRUST_PROXY: '1' }, now: () => 10 * WINDOW });
127
+ const agent = request.agent(app);
128
+ const _csrf = await csrfToken(agent, '/magic-link');
129
+ const statuses = [];
130
+ for (let i = 1; i <= 6; i++) {
131
+ const res = await agent.post('/magic-link').type('form')
132
+ .set('X-Forwarded-For', `2001:db8:0:1::${i}`).send({ email: 'a@t.test', _csrf });
133
+ statuses.push(res.status);
134
+ }
135
+ assert.deepEqual(statuses, [200, 200, 200, 200, 200, 429]);
136
+ });
137
+
138
+ test('every route sits under a 1000 per 15 min ceiling; static files do not count', async () => {
139
+ let t = 10 * WINDOW;
140
+ const { app } = makeApp({ now: () => t });
141
+ const agent = request.agent(app);
142
+ for (let i = 0; i < 50; i++) assert.equal((await agent.get('/css/custom.css')).status, 200);
143
+ for (let i = 1; i <= 1000; i++) {
144
+ const res = await agent.get('/magic-link');
145
+ if (res.status !== 200) assert.fail(`request ${i} got ${res.status}`);
146
+ }
147
+ const blocked = await agent.get('/admin');
148
+ assert.equal(blocked.status, 429);
149
+ t += WINDOW;
150
+ assert.equal((await agent.get('/magic-link')).status, 200);
151
+ });
@@ -8,14 +8,23 @@ import { createStripeService, fulfillCheckoutSession } from '../src/services/str
8
8
  import { createMailer } from '../src/services/mailer.js';
9
9
  import { createUser } from '../src/services/auth.js';
10
10
  import { openDb, setSetting } from '../src/db/index.js';
11
+ import { csrfToken } from './helpers.js';
11
12
 
12
13
  const VALID_SIG = 't=123,v1=goodsignature';
13
14
 
14
15
  /** Mock of the stripe npm client: signature check + checkout session capture. */
15
16
  function mockStripeClient() {
16
17
  const created = [];
17
- return {
18
+ const subscriptionCalls = [];
19
+ const client = {
18
20
  created,
21
+ subscriptionCalls,
22
+ failNext: null,
23
+ subscriptions: {
24
+ async update(id, params) { return call(['update', id, params]); },
25
+ async cancel(id) { return call(['cancel', id]); },
26
+ async retrieve(id) { return { id, status: 'active' }; },
27
+ },
19
28
  checkout: {
20
29
  sessions: {
21
30
  create: async (params) => {
@@ -32,6 +41,16 @@ function mockStripeClient() {
32
41
  },
33
42
  },
34
43
  };
44
+ function call(entry) {
45
+ if (client.failNext) {
46
+ const err = client.failNext;
47
+ client.failNext = null;
48
+ throw err;
49
+ }
50
+ subscriptionCalls.push(entry);
51
+ return { id: entry[1] };
52
+ }
53
+ return client;
35
54
  }
36
55
 
37
56
  function makeApp() {
@@ -247,6 +266,76 @@ test('customer.subscription.deleted cancels the matching membership', async () =
247
266
  assert.equal(db.prepare('SELECT status FROM memberships WHERE client_id = ?').get(clientId).status, 'cancelled');
248
267
  });
249
268
 
269
+ function membershipWithSub(db, subId, status = 'active') {
270
+ const clientId = db.prepare(
271
+ "INSERT INTO clients (name, email, source) VALUES ('M', ?, 'self')"
272
+ ).run(`${subId}@test.hk`).lastInsertRowid;
273
+ const id = db.prepare(
274
+ `INSERT INTO memberships (client_id, plan_name, status, started_on, stripe_subscription_id, unlimited)
275
+ VALUES (?, 'Unlimited', ?, '2026-07-01', ?, 1)`
276
+ ).run(clientId, status, subId).lastInsertRowid;
277
+ return { clientId, id, status: () => db.prepare('SELECT status FROM memberships WHERE id = ?').get(id).status };
278
+ }
279
+
280
+ test('a failed renewal pauses the membership until the card goes through', async () => {
281
+ const { db, app } = makeApp();
282
+ const m = membershipWithSub(db, 'sub_dunning');
283
+ const updated = (status, previous) => postEvent(app, {
284
+ type: 'customer.subscription.updated',
285
+ data: { object: { id: 'sub_dunning', status }, previous_attributes: previous },
286
+ });
287
+
288
+ await updated('past_due', { status: 'active' });
289
+ assert.equal(m.status(), 'paused');
290
+ await updated('active', { status: 'past_due' });
291
+ assert.equal(m.status(), 'active');
292
+
293
+ // A renewal also fires .updated, without a status change. It must not
294
+ // undo a pause staff set in the app.
295
+ db.prepare("UPDATE memberships SET status = 'paused' WHERE id = ?").run(m.id);
296
+ const renewal = await updated('active', { current_period_end: 1790000000 });
297
+ assert.equal(renewal.body.reason, 'no_status_change');
298
+ assert.equal(m.status(), 'paused');
299
+
300
+ db.prepare("UPDATE memberships SET status = 'cancelled' WHERE id = ?").run(m.id);
301
+ await updated('active', { status: 'past_due' });
302
+ assert.equal(m.status(), 'cancelled', 'a cancelled membership stays cancelled');
303
+ });
304
+
305
+ test('staff pausing, resuming or cancelling a Stripe membership changes the subscription too', async () => {
306
+ const { db, app, client } = makeApp();
307
+ const m = membershipWithSub(db, 'sub_staff');
308
+ const agent = request.agent(app);
309
+ let _csrf = await csrfToken(agent, '/admin/login');
310
+ await agent.post('/admin/login').type('form').send({ email: 'owner@test.test', password: 'password123', _csrf });
311
+ _csrf = await csrfToken(agent, `/admin/clients/${m.clientId}`);
312
+ const setStatus = (status) => agent.post(`/admin/clients/${m.clientId}/memberships`).type('form')
313
+ .send({ action: 'status', membership_id: String(m.id), status, _csrf });
314
+
315
+ await setStatus('paused');
316
+ assert.equal(m.status(), 'paused');
317
+ await setStatus('active');
318
+ assert.equal(m.status(), 'active');
319
+ assert.deepEqual(client.subscriptionCalls, [
320
+ ['update', 'sub_staff', { pause_collection: { behavior: 'void' } }],
321
+ ['update', 'sub_staff', { pause_collection: '' }],
322
+ ]);
323
+
324
+ // Stripe saying no leaves the membership as it was.
325
+ client.failNext = Object.assign(new Error('card_declined'), { code: 'card_declined' });
326
+ const refused = await setStatus('paused');
327
+ assert.equal(refused.status, 302);
328
+ assert.equal(m.status(), 'active');
329
+
330
+ const bogus = await setStatus('frozen');
331
+ assert.equal(bogus.status, 302, 'an unknown status is a flash, not a 500');
332
+ assert.equal(m.status(), 'active');
333
+
334
+ await setStatus('cancelled');
335
+ assert.equal(m.status(), 'cancelled');
336
+ assert.deepEqual(client.subscriptionCalls.at(-1), ['cancel', 'sub_staff']);
337
+ });
338
+
250
339
  test('drop-in checkout marks the pending payment paid', async () => {
251
340
  const { db, app } = makeApp();
252
341
  const clientId = db.prepare(
package/test/web.test.js CHANGED
@@ -173,6 +173,19 @@ test('a two-credit class says two credits came off the pack', async () => {
173
173
  assert.match(res.text, /2 credits deducted from your class pack/);
174
174
  });
175
175
 
176
+ test('a 50kb junk email is turned away without stalling the server', async () => {
177
+ const { db, app } = makeApp();
178
+ const inst = makeInstance(db, makeClassType(db), { hoursFromNow: 48 });
179
+ const agent = request.agent(app);
180
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
181
+ const started = Date.now();
182
+ const res = await agent.post(`/class/${inst}/book`).type('form')
183
+ .send({ email: `a@${'.'.repeat(50000)}@`, waiver_agree: '1', _csrf });
184
+ assert.equal(res.status, 400);
185
+ // The old regex took ~850ms on this; the event loop was blocked throughout.
186
+ assert.ok(Date.now() - started < 300, `took ${Date.now() - started}ms`);
187
+ });
188
+
176
189
  test("booking with an existing client's email does not rename them", async () => {
177
190
  const { db, app } = makeApp();
178
191
  const inst = makeInstance(db, makeClassType(db), { hoursFromNow: 48 });
@@ -386,6 +399,69 @@ test('admin: editing a rule moves its empty classes instead of adding a second s
386
399
  assert.equal(db.prepare('SELECT start_time FROM schedule_rules WHERE id = ?').get(ruleId).start_time, '19:00');
387
400
  });
388
401
 
402
+ test('admin: a class type capacity change reaches upcoming classes still at the old size', async () => {
403
+ const { db, app } = makeApp();
404
+ const agent = request.agent(app);
405
+ const _csrf = await adminLogin(agent);
406
+ const { book } = await import('../src/services/booking.js');
407
+ const type = makeClassType(db, { name: 'Barre', capacity: 1 });
408
+ const booked = makeInstance(db, type, { hoursFromNow: 48 });
409
+ const handSized = makeInstance(db, type, { hoursFromNow: 72, capacity: 5 });
410
+ const past = makeInstance(db, type, { hoursFromNow: -24 });
411
+ const ruleId = db.prepare(
412
+ "INSERT INTO schedule_rules (class_type_id, weekday, start_time, capacity_override) VALUES (?, 1, '09:00', 1)"
413
+ ).run(type).lastInsertRowid;
414
+ const fromRule = makeInstance(db, type, { hoursFromNow: 96 });
415
+ db.prepare('UPDATE class_instances SET rule_id = ? WHERE id = ?').run(ruleId, fromRule);
416
+ book(db, makeClient(db, { email: 'first@type.hk' }), booked);
417
+ const { booking: wl } = book(db, makeClient(db, { email: 'waiting@type.hk' }), booked);
418
+ const capacity = (id) => db.prepare('SELECT capacity FROM class_instances WHERE id = ?').get(id).capacity;
419
+
420
+ const res = await agent.post(`/admin/class-types/${type}`).type('form')
421
+ .send({ name: 'Barre', capacity: '2', drop_in_price: '150', _csrf });
422
+ assert.equal(res.status, 302);
423
+ assert.deepEqual([booked, handSized, past, fromRule].map(capacity), [2, 5, 1, 1]);
424
+ assert.equal(db.prepare('SELECT status FROM bookings WHERE id = ?').get(wl.id).status, 'booked');
425
+ assert.match((await agent.get('/admin/class-types')).text, /1 upcoming class\(es\) resized, 1 moved up from the waitlist/);
426
+ await new Promise((r) => setTimeout(r, 100));
427
+ const mails = fs.readdirSync(OUTBOX).map((f) => fs.readFileSync(path.join(OUTBOX, f), 'utf8'));
428
+ assert.ok(mails.some((m) => m.includes('To: waiting@type.hk')));
429
+
430
+ const bad = await agent.post(`/admin/class-types/${type}`).type('form')
431
+ .send({ name: 'Barre', capacity: '-3', _csrf });
432
+ assert.equal(bad.status, 302);
433
+ assert.equal(db.prepare('SELECT capacity FROM class_types WHERE id = ?').get(type).capacity, 2);
434
+ });
435
+
436
+ test('admin: a rule capacity change reaches its booked classes too', async () => {
437
+ const { db, app } = makeApp();
438
+ const agent = request.agent(app);
439
+ const _csrf = await adminLogin(agent);
440
+ const { book } = await import('../src/services/booking.js');
441
+ const type = makeClassType(db, { name: 'Reformer', capacity: 1 });
442
+ await agent.post('/admin/rules').type('form').send({
443
+ class_type_id: String(type), weekday: '2', start_time: '07:00', _csrf,
444
+ });
445
+ const ruleId = db.prepare('SELECT id FROM schedule_rules').get().id;
446
+ const next = db.prepare('SELECT id FROM class_instances WHERE rule_id = ? ORDER BY starts_at').get(ruleId).id;
447
+ book(db, makeClient(db, { email: 'first@rule.hk' }), next);
448
+ const { booking: wl } = book(db, makeClient(db, { email: 'waiting@rule.hk' }), next);
449
+
450
+ const res = await agent.post(`/admin/rules/${ruleId}`).type('form').send({
451
+ class_type_id: String(type), weekday: '2', start_time: '07:00', capacity_override: '3', _csrf,
452
+ });
453
+ assert.equal(res.status, 302);
454
+ const caps = db.prepare('SELECT DISTINCT capacity FROM class_instances WHERE rule_id = ?').pluck().all(ruleId);
455
+ assert.deepEqual(caps, [3]);
456
+ assert.equal(db.prepare('SELECT status FROM bookings WHERE id = ?').get(wl.id).status, 'booked');
457
+
458
+ const bad = await agent.post(`/admin/rules/${ruleId}`).type('form').send({
459
+ class_type_id: String(type), weekday: '2', start_time: '07:00', capacity_override: '-1', _csrf,
460
+ });
461
+ assert.equal(bad.status, 302, 'a negative override is a flash, not a 500');
462
+ assert.equal(db.prepare('SELECT capacity_override FROM schedule_rules WHERE id = ?').get(ruleId).capacity_override, 3);
463
+ });
464
+
389
465
  test('admin: manual pass sale + payment, revenue report and CSV export', async () => {
390
466
  const { db, app } = makeApp();
391
467
  const agent = request.agent(app);
@@ -31,7 +31,7 @@
31
31
  <label>Description <input name="description" value="<%= t.description %>"></label>
32
32
  <div class="grid">
33
33
  <label>Duration <input type="number" name="duration_min" value="<%= t.duration_min %>"></label>
34
- <label>Capacity <input type="number" name="capacity" value="<%= t.capacity %>"></label>
34
+ <label>Capacity <input type="number" name="capacity" value="<%= t.capacity %>" min="1"></label>
35
35
  <label>Drop-in price <input type="number" name="drop_in_price" value="<%= (t.drop_in_price_cents / 100).toFixed(2) %>" step="0.01"></label>
36
36
  <label>Credits <input type="number" name="credits_required" value="<%= t.credits_required %>"></label>
37
37
  </div>
@@ -57,7 +57,7 @@
57
57
  <label>Start <input name="start_time" value="<%= rule.start_time %>"></label>
58
58
  </div>
59
59
  <div class="grid">
60
- <label>Capacity override <input type="number" name="capacity_override" value="<%= rule.capacity_override || '' %>"></label>
60
+ <label>Capacity override <input type="number" name="capacity_override" value="<%= rule.capacity_override || '' %>" min="1" placeholder="(class default)"></label>
61
61
  <label>Active from <input type="date" name="active_from" value="<%= rule.active_from || '' %>"></label>
62
62
  <label>Active until <input type="date" name="active_until" value="<%= rule.active_until || '' %>"></label>
63
63
  </div>