studio-os 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,54 +1,54 @@
1
- {
2
- "name": "studio-os",
3
- "version": "0.2.0",
4
- "description": "Self-hosted studio management: booking, class packs, memberships. Bring your own Stripe.",
5
- "license": "MIT",
6
- "type": "module",
7
- "main": "src/server.js",
8
- "scripts": {
9
- "start": "node src/server.js",
10
- "dev": "node --watch src/server.js",
11
- "test": "node --test \"test/*.test.js\"",
12
- "seed": "node scripts/seed.mjs",
13
- "import-mindbody": "node scripts/import-mindbody.mjs"
14
- },
15
- "engines": {
16
- "node": ">=20"
17
- },
18
- "dependencies": {
19
- "bcryptjs": "^2.4.3",
20
- "better-sqlite3": "^11.10.0",
21
- "cookie-session": "^2.1.0",
22
- "dotenv": "^16.4.5",
23
- "ejs": "^3.1.10",
24
- "express": "^4.21.2",
25
- "marked": "^12.0.2",
26
- "nodemailer": "^6.9.16",
27
- "stripe": "^16.12.0"
28
- },
29
- "devDependencies": {
30
- "supertest": "^7.0.0"
31
- },
32
- "repository": {
33
- "type": "git",
34
- "url": "git+https://github.com/Booyaka101/studio-os.git"
35
- },
36
- "homepage": "https://github.com/Booyaka101/studio-os#readme",
37
- "bugs": {
38
- "url": "https://github.com/Booyaka101/studio-os/issues"
39
- },
40
- "keywords": [
41
- "self-hosted",
42
- "studio",
43
- "yoga",
44
- "pilates",
45
- "gym",
46
- "booking",
47
- "class-packs",
48
- "memberships",
49
- "mindbody-alternative",
50
- "stripe",
51
- "scheduling",
52
- "fitness"
53
- ]
54
- }
1
+ {
2
+ "name": "studio-os",
3
+ "version": "0.3.0",
4
+ "description": "Self-hosted studio management: booking, class packs, memberships. Bring your own Stripe.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/server.js",
8
+ "scripts": {
9
+ "start": "node src/server.js",
10
+ "dev": "node --watch src/server.js",
11
+ "test": "node --test \"test/*.test.js\"",
12
+ "seed": "node scripts/seed.mjs",
13
+ "import-mindbody": "node scripts/import-mindbody.mjs"
14
+ },
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "dependencies": {
19
+ "bcryptjs": "^3.0.3",
20
+ "better-sqlite3": "^13.0.1",
21
+ "cookie-session": "^2.1.0",
22
+ "dotenv": "^17.4.2",
23
+ "ejs": "^6.0.1",
24
+ "express": "^5.2.1",
25
+ "marked": "^18.0.9",
26
+ "nodemailer": "^10.0.9",
27
+ "stripe": "^22.6.2"
28
+ },
29
+ "devDependencies": {
30
+ "supertest": "^7.0.0"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/Booyaka101/studio-os.git"
35
+ },
36
+ "homepage": "https://github.com/Booyaka101/studio-os#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/Booyaka101/studio-os/issues"
39
+ },
40
+ "keywords": [
41
+ "self-hosted",
42
+ "studio",
43
+ "yoga",
44
+ "pilates",
45
+ "gym",
46
+ "booking",
47
+ "class-packs",
48
+ "memberships",
49
+ "mindbody-alternative",
50
+ "stripe",
51
+ "scheduling",
52
+ "fitness"
53
+ ]
54
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "label": "ubuntu-24.04",
4
+ "imageOS": "ubuntu24",
5
+ "imageVersion": "20260720.247.2",
6
+ "tools": {
7
+ "Node.js": {
8
+ "versions": [
9
+ "22.23.1"
10
+ ],
11
+ "source": "manifest"
12
+ }
13
+ },
14
+ "updatedAt": "2026-08-05T04:27:45.130Z"
15
+ }
package/src/app.js CHANGED
@@ -1,141 +1,156 @@
1
- import express from 'express';
2
- import cookieSession from 'cookie-session';
3
- import crypto from 'node:crypto';
4
- import path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { getSetting, allSettings } from './db/index.js';
7
- import { formatInTz } from './lib/time.js';
8
- import { createRateLimiter } from './lib/ratelimit.js';
9
- import { createMailer } from './services/mailer.js';
10
- import { createStripeService } from './services/stripe.js';
11
- import setupRoutes from './routes/setup.js';
12
- import authRoutes from './routes/auth.js';
13
- import publicRoutes from './routes/public.js';
14
- import meRoutes from './routes/me.js';
15
- import adminRoutes from './routes/admin.js';
16
- import instructorRoutes from './routes/instructor.js';
17
- import webhookRoutes from './routes/webhooks.js';
18
-
19
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
-
21
- /**
22
- * Build the Express app. Dependencies are injectable for tests:
23
- * createApp({ db, mailer?, stripeService?, env?, now? })
24
- * `now` (clock) is used by the rate limiters so tests can move time.
25
- */
26
- export function createApp({ db, mailer, stripeService, env = process.env, now = Date.now }) {
27
- const app = express();
28
- app.set('view engine', 'ejs');
29
- app.set('views', path.join(__dirname, '..', 'views'));
30
- app.disable('x-powered-by');
31
-
32
- const services = {
33
- db,
34
- mailer: mailer || createMailer({ env }),
35
- stripe: stripeService || createStripeService({ env }),
36
- env,
37
- baseUrl(req) {
38
- return env.BASE_URL || `${req.protocol}://${req.get('host')}`;
39
- },
40
- };
41
- app.locals.services = services;
42
-
43
- // Stripe webhook needs the raw body for signature verification — mount
44
- // before any body parser.
45
- app.use('/webhooks/stripe', express.raw({ type: '*/*' }), webhookRoutes(services));
46
-
47
- app.use(express.urlencoded({ extended: true }));
48
- app.use(express.static(path.join(__dirname, '..', 'public')));
49
- app.use(cookieSession({
50
- name: 'studio_session',
51
- keys: [getSetting(db, 'app_secret', 'dev-secret')],
52
- sameSite: 'lax',
53
- httpOnly: true,
54
- maxAge: 30 * 24 * 3600000,
55
- }));
56
-
57
- // View locals: settings + formatting helpers, refreshed per request.
58
- app.use((req, res, next) => {
59
- const s = allSettings(db);
60
- res.locals.settings = s;
61
- res.locals.tz = s.timezone || 'Asia/Hong_Kong';
62
- res.locals.currency = s.currency || 'HKD';
63
- res.locals.fmtDt = (iso, opts) => formatInTz(res.locals.tz, iso, opts);
64
- res.locals.fmtMoney = (cents) => new Intl.NumberFormat('en', {
65
- style: 'currency', currency: res.locals.currency, minimumFractionDigits: 0,
66
- }).format((cents || 0) / 100);
67
- res.locals.user = req.session && req.session.userId
68
- ? db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(req.session.userId)
69
- : null;
70
- res.locals.stripeConfigured = services.stripe.configured;
71
- res.locals.smtpConfigured = services.mailer.smtpConfigured;
72
- res.locals.flash = req.session ? req.session.flash : null;
73
- if (req.session) delete req.session.flash;
74
- next();
75
- });
76
-
77
- // Rate limiting (before CSRF so hammering can't even probe tokens):
78
- // in-memory fixed window, per IP + route. Single-process; resets on restart.
79
- const RL_WINDOW = 15 * 60 * 1000;
80
- app.post('/magic-link', createRateLimiter({
81
- windowMs: RL_WINDOW, max: 5, env, now,
82
- message: 'Too many link requests — please wait 15 minutes and try again.',
83
- }));
84
- app.post('/admin/login', createRateLimiter({
85
- windowMs: RL_WINDOW, max: 10, env, now,
86
- message: 'Too many login attempts — please wait 15 minutes and try again.',
87
- }));
88
- app.post(['/class/:id/book', '/buy/pack/:id', '/buy/membership/:id'], createRateLimiter({
89
- windowMs: RL_WINDOW, max: 30, env, now,
90
- message: 'Too many booking attempts from your address — please wait a few minutes and try again.',
91
- }));
92
-
93
- // CSRF protection: a random per-session token lives in the signed cookie
94
- // session and must be echoed back (hidden `_csrf` input or `x-csrf-token`
95
- // header) on every state-changing request. /webhooks/stripe is exempt — it
96
- // is mounted before this middleware and authenticated by Stripe signature.
97
- app.use((req, res, next) => {
98
- if (!req.session.csrf) req.session.csrf = crypto.randomBytes(24).toString('hex');
99
- res.locals.csrfToken = req.session.csrf;
100
- if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
101
- if (req.path.startsWith('/webhooks/')) return next();
102
- const sent = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
103
- const a = Buffer.from(String(sent));
104
- const b = Buffer.from(req.session.csrf);
105
- if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
106
- return res.status(403).render('error', {
107
- title: 'Session expired',
108
- message: 'Your form session expired or the security token was missing. Please go back, refresh the page, and try again.',
109
- });
110
- }
111
- next();
112
- });
113
-
114
- // First-run gate: until setup completes, everything routes to the wizard.
115
- app.use((req, res, next) => {
116
- const done = getSetting(db, 'setup_complete', '0') === '1';
117
- if (!done && !req.path.startsWith('/setup') && !req.path.startsWith('/vendor')
118
- && !req.path.startsWith('/css') && !req.path.startsWith('/js')) {
119
- return res.redirect('/setup');
120
- }
121
- if (done && req.path.startsWith('/setup')) return res.redirect('/');
122
- next();
123
- });
124
-
125
- app.use('/setup', setupRoutes(services));
126
- app.use('/', authRoutes(services));
127
- app.use('/admin', adminRoutes(services));
128
- app.use('/instructor', instructorRoutes(services));
129
- app.use('/me', meRoutes(services));
130
- app.use('/', publicRoutes(services));
131
-
132
- // 404 + error handler
133
- app.use((req, res) => res.status(404).render('error', { title: 'Not found', message: 'Page not found.' }));
134
- // eslint-disable-next-line no-unused-vars
135
- app.use((err, req, res, next) => {
136
- console.error('[error]', err);
137
- res.status(500).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
138
- });
139
-
140
- return app;
141
- }
1
+ import express from 'express';
2
+ import cookieSession from 'cookie-session';
3
+ import crypto from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { getSetting, allSettings } from './db/index.js';
7
+ import { formatInTz } from './lib/time.js';
8
+ import { createRateLimiter } from './lib/ratelimit.js';
9
+ import { createMailer } from './services/mailer.js';
10
+ import { createStripeService } from './services/stripe.js';
11
+ import setupRoutes from './routes/setup.js';
12
+ import authRoutes from './routes/auth.js';
13
+ import publicRoutes from './routes/public.js';
14
+ import meRoutes from './routes/me.js';
15
+ import adminRoutes from './routes/admin.js';
16
+ import instructorRoutes from './routes/instructor.js';
17
+ import webhookRoutes from './routes/webhooks.js';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+
21
+ /**
22
+ * Build the Express app. Dependencies are injectable for tests:
23
+ * createApp({ db, mailer?, stripeService?, env?, now? })
24
+ * `now` (clock) is used by the rate limiters so tests can move time.
25
+ */
26
+ export function createApp({ db, mailer, stripeService, env = process.env, now = Date.now }) {
27
+ const app = express();
28
+ app.set('view engine', 'ejs');
29
+ app.set('views', path.join(__dirname, '..', 'views'));
30
+ app.disable('x-powered-by');
31
+
32
+ // Behind a TLS-terminating proxy the socket is plain HTTP, so req.protocol
33
+ // reads 'http' and every emailed magic link ships a 7-day auth token over
34
+ // plaintext. Honouring X-Forwarded-Proto is what makes baseUrl() correct.
35
+ // TRUST_PROXY is a hop count ('1') or any Express trust-proxy value.
36
+ if (env.TRUST_PROXY) {
37
+ const n = Number(env.TRUST_PROXY);
38
+ app.set('trust proxy', Number.isInteger(n) && n > 0 ? n : env.TRUST_PROXY);
39
+ }
40
+
41
+ const services = {
42
+ db,
43
+ mailer: mailer || createMailer({ env }),
44
+ stripe: stripeService || createStripeService({ env }),
45
+ env,
46
+ baseUrl(req) {
47
+ return env.BASE_URL || `${req.protocol}://${req.get('host')}`;
48
+ },
49
+ };
50
+ app.locals.services = services;
51
+
52
+ // Stripe webhook needs the raw body for signature verification — mount
53
+ // before any body parser.
54
+ app.use('/webhooks/stripe', express.raw({ type: '*/*' }), webhookRoutes(services));
55
+
56
+ app.use(express.urlencoded({ extended: true }));
57
+ app.use(express.static(path.join(__dirname, '..', 'public')));
58
+ app.use(cookieSession({
59
+ name: 'studio_session',
60
+ keys: [getSetting(db, 'app_secret', 'dev-secret')],
61
+ sameSite: 'lax',
62
+ httpOnly: true,
63
+ maxAge: 30 * 24 * 3600000,
64
+ }));
65
+
66
+ // View locals: settings + formatting helpers, refreshed per request.
67
+ app.use((req, res, next) => {
68
+ // app_secret signs the session cookie and every magic-link token, so it
69
+ // stays out of the object every template can reach. No view renders it
70
+ // today; this is so none can start to.
71
+ const { app_secret: _secret, ...s } = allSettings(db);
72
+ res.locals.settings = s;
73
+ res.locals.tz = s.timezone || 'Asia/Hong_Kong';
74
+ res.locals.currency = s.currency || 'HKD';
75
+ res.locals.fmtDt = (iso, opts) => formatInTz(res.locals.tz, iso, opts);
76
+ res.locals.fmtMoney = (cents) => new Intl.NumberFormat('en', {
77
+ style: 'currency', currency: res.locals.currency, minimumFractionDigits: 0,
78
+ }).format((cents || 0) / 100);
79
+ res.locals.user = req.session && req.session.userId
80
+ ? db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(req.session.userId)
81
+ : null;
82
+ // Public views ask "can we take money online?", which needs both keys.
83
+ res.locals.stripeConfigured = services.stripe.checkoutReady;
84
+ res.locals.stripeKeySet = services.stripe.configured;
85
+ res.locals.stripeWebhookSet = services.stripe.webhookConfigured;
86
+ res.locals.smtpConfigured = services.mailer.smtpConfigured;
87
+ res.locals.flash = req.session ? req.session.flash : null;
88
+ if (req.session) delete req.session.flash;
89
+ next();
90
+ });
91
+
92
+ // Rate limiting (before CSRF so hammering can't even probe tokens):
93
+ // in-memory fixed window, per IP + route. Single-process; resets on restart.
94
+ const RL_WINDOW = 15 * 60 * 1000;
95
+ app.post('/magic-link', createRateLimiter({
96
+ windowMs: RL_WINDOW, max: 5, env, now,
97
+ message: 'Too many link requests — please wait 15 minutes and try again.',
98
+ }));
99
+ app.post('/admin/login', createRateLimiter({
100
+ windowMs: RL_WINDOW, max: 10, env, now,
101
+ message: 'Too many login attempts — please wait 15 minutes and try again.',
102
+ }));
103
+ app.post(['/class/:id/book', '/buy/pack/:id', '/buy/membership/:id'], createRateLimiter({
104
+ windowMs: RL_WINDOW, max: 30, env, now,
105
+ message: 'Too many booking attempts from your address — please wait a few minutes and try again.',
106
+ }));
107
+
108
+ // CSRF protection: a random per-session token lives in the signed cookie
109
+ // session and must be echoed back (hidden `_csrf` input or `x-csrf-token`
110
+ // header) on every state-changing request. /webhooks/stripe is exempt — it
111
+ // is mounted before this middleware and authenticated by Stripe signature.
112
+ app.use((req, res, next) => {
113
+ if (!req.session.csrf) req.session.csrf = crypto.randomBytes(24).toString('hex');
114
+ res.locals.csrfToken = req.session.csrf;
115
+ if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
116
+ if (req.path.startsWith('/webhooks/')) return next();
117
+ const sent = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
118
+ const a = Buffer.from(String(sent));
119
+ const b = Buffer.from(req.session.csrf);
120
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
121
+ return res.status(403).render('error', {
122
+ title: 'Session expired',
123
+ message: 'Your form session expired or the security token was missing. Please go back, refresh the page, and try again.',
124
+ });
125
+ }
126
+ next();
127
+ });
128
+
129
+ // First-run gate: until setup completes, everything routes to the wizard.
130
+ app.use((req, res, next) => {
131
+ const done = getSetting(db, 'setup_complete', '0') === '1';
132
+ if (!done && !req.path.startsWith('/setup') && !req.path.startsWith('/vendor')
133
+ && !req.path.startsWith('/css') && !req.path.startsWith('/js')) {
134
+ return res.redirect('/setup');
135
+ }
136
+ if (done && req.path.startsWith('/setup')) return res.redirect('/');
137
+ next();
138
+ });
139
+
140
+ app.use('/setup', setupRoutes(services));
141
+ app.use('/', authRoutes(services));
142
+ app.use('/admin', adminRoutes(services));
143
+ app.use('/instructor', instructorRoutes(services));
144
+ app.use('/me', meRoutes(services));
145
+ app.use('/', publicRoutes(services));
146
+
147
+ // 404 + error handler
148
+ app.use((req, res) => res.status(404).render('error', { title: 'Not found', message: 'Page not found.' }));
149
+ // eslint-disable-next-line no-unused-vars
150
+ app.use((err, req, res, next) => {
151
+ console.error('[error]', err);
152
+ res.status(500).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
153
+ });
154
+
155
+ return app;
156
+ }
@@ -150,7 +150,7 @@ export default function publicRoutes(services) {
150
150
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
151
151
  return res.status(400).render('public/buy_manual', { title: 'Buy', product, plan: null, email: null, error: 'A valid email is required.' });
152
152
  }
153
- if (services.stripe.configured) {
153
+ if (services.stripe.checkoutReady) {
154
154
  try {
155
155
  const session = await services.stripe.createPackCheckout({
156
156
  product, clientEmail: email, baseUrl: services.baseUrl(req),
@@ -171,7 +171,7 @@ export default function publicRoutes(services) {
171
171
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
172
172
  return res.status(400).render('public/buy_manual', { title: 'Buy', product: null, plan, email: null, error: 'A valid email is required.' });
173
173
  }
174
- if (services.stripe.configured && plan.stripe_price_id) {
174
+ if (services.stripe.checkoutReady && plan.stripe_price_id) {
175
175
  try {
176
176
  const session = await services.stripe.createMembershipCheckout({
177
177
  plan, clientEmail: email, baseUrl: services.baseUrl(req),
@@ -1,81 +1,88 @@
1
- // Staff auth (bcryptjs) + HMAC magic-link tokens for client self-service.
2
- import bcrypt from 'bcryptjs';
3
- import crypto from 'node:crypto';
4
- import { getSetting } from '../db/index.js';
5
-
6
- export function hashPassword(plain) {
7
- return bcrypt.hashSync(plain, 10);
8
- }
9
-
10
- export function verifyPassword(plain, hash) {
11
- return bcrypt.compareSync(plain, hash);
12
- }
13
-
14
- export function createUser(db, { email, password, name = '', role = 'staff' }) {
15
- const info = db.prepare(
16
- 'INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)'
17
- ).run(email.trim().toLowerCase(), hashPassword(password), name, role);
18
- return info.lastInsertRowid;
19
- }
20
-
21
- export function authenticate(db, email, password) {
22
- const user = db.prepare('SELECT * FROM users WHERE email = ?').get(String(email || '').trim().toLowerCase());
23
- if (!user) return null;
24
- return verifyPassword(String(password || ''), user.password_hash) ? user : null;
25
- }
26
-
27
- // --- magic links -----------------------------------------------------------
28
-
29
- function hmac(secret, payload) {
30
- return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
31
- }
32
-
33
- /** Signed token for a client, valid for ttlHours (default 7 days). */
34
- export function makeMagicToken(db, clientId, { ttlHours = 24 * 7, now = Date.now() } = {}) {
35
- const secret = getSetting(db, 'app_secret');
36
- const expires = now + ttlHours * 3600000;
37
- const payload = `${clientId}.${expires}`;
38
- return `${payload}.${hmac(secret, payload)}`;
39
- }
40
-
41
- /** Returns clientId or null. Constant-time signature check. */
42
- export function verifyMagicToken(db, token, { now = Date.now() } = {}) {
43
- if (typeof token !== 'string') return null;
44
- const parts = token.split('.');
45
- if (parts.length !== 3) return null;
46
- const [idStr, expStr, sig] = parts;
47
- const secret = getSetting(db, 'app_secret');
48
- const expected = hmac(secret, `${idStr}.${expStr}`);
49
- const a = Buffer.from(sig);
50
- const b = Buffer.from(expected);
51
- if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
52
- if (Number(expStr) < now) return null;
53
- const id = Number(idStr);
54
- return Number.isInteger(id) && id > 0 ? id : null;
55
- }
56
-
57
- // --- express middleware ----------------------------------------------------
58
-
59
- export function requireStaff(req, res, next) {
60
- if (req.session && req.session.userId) return next();
61
- return res.redirect('/admin/login');
62
- }
63
-
64
- /** Admin area: any logged-in owner/staff. Instructors get a hard 403. */
65
- export function requireAdmin(req, res, next) {
66
- if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
67
- if (res.locals.user.role === 'instructor') {
68
- return res.status(403).render('error', {
69
- title: 'Forbidden',
70
- message: 'Instructor accounts cannot access the admin area.',
71
- });
72
- }
73
- next();
74
- }
75
-
76
- /** Instructor portal: instructor role only; admins are sent to their own UI. */
77
- export function requireInstructor(req, res, next) {
78
- if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
79
- if (res.locals.user.role !== 'instructor') return res.redirect('/admin');
80
- next();
81
- }
1
+ // Staff auth (bcryptjs) + HMAC magic-link tokens for client self-service.
2
+ import bcrypt from 'bcryptjs';
3
+ import crypto from 'node:crypto';
4
+ import { getSetting } from '../db/index.js';
5
+
6
+ export function hashPassword(plain) {
7
+ return bcrypt.hashSync(plain, 10);
8
+ }
9
+
10
+ export function verifyPassword(plain, hash) {
11
+ return bcrypt.compareSync(plain, hash);
12
+ }
13
+
14
+ export function createUser(db, { email, password, name = '', role = 'staff' }) {
15
+ const info = db.prepare(
16
+ 'INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)'
17
+ ).run(email.trim().toLowerCase(), hashPassword(password), name, role);
18
+ return info.lastInsertRowid;
19
+ }
20
+
21
+ // Returning early for an unknown email skips bcrypt entirely, which answers in
22
+ // microseconds instead of tens of milliseconds and turns the login form into a
23
+ // staff-email oracle. Comparing against a throwaway hash of the same cost keeps
24
+ // both paths the same shape. /magic-link already refuses to leak which emails
25
+ // exist; this is the same promise on the staff side.
26
+ const DUMMY_HASH = bcrypt.hashSync('password-that-is-never-valid', 10);
27
+
28
+ export function authenticate(db, email, password) {
29
+ const user = db.prepare('SELECT * FROM users WHERE email = ?').get(String(email || '').trim().toLowerCase());
30
+ const ok = verifyPassword(String(password || ''), user ? user.password_hash : DUMMY_HASH);
31
+ return user && ok ? user : null;
32
+ }
33
+
34
+ // --- magic links -----------------------------------------------------------
35
+
36
+ function hmac(secret, payload) {
37
+ return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
38
+ }
39
+
40
+ /** Signed token for a client, valid for ttlHours (default 7 days). */
41
+ export function makeMagicToken(db, clientId, { ttlHours = 24 * 7, now = Date.now() } = {}) {
42
+ const secret = getSetting(db, 'app_secret');
43
+ const expires = now + ttlHours * 3600000;
44
+ const payload = `${clientId}.${expires}`;
45
+ return `${payload}.${hmac(secret, payload)}`;
46
+ }
47
+
48
+ /** Returns clientId or null. Constant-time signature check. */
49
+ export function verifyMagicToken(db, token, { now = Date.now() } = {}) {
50
+ if (typeof token !== 'string') return null;
51
+ const parts = token.split('.');
52
+ if (parts.length !== 3) return null;
53
+ const [idStr, expStr, sig] = parts;
54
+ const secret = getSetting(db, 'app_secret');
55
+ const expected = hmac(secret, `${idStr}.${expStr}`);
56
+ const a = Buffer.from(sig);
57
+ const b = Buffer.from(expected);
58
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
59
+ if (Number(expStr) < now) return null;
60
+ const id = Number(idStr);
61
+ return Number.isInteger(id) && id > 0 ? id : null;
62
+ }
63
+
64
+ // --- express middleware ----------------------------------------------------
65
+
66
+ export function requireStaff(req, res, next) {
67
+ if (req.session && req.session.userId) return next();
68
+ return res.redirect('/admin/login');
69
+ }
70
+
71
+ /** Admin area: any logged-in owner/staff. Instructors get a hard 403. */
72
+ export function requireAdmin(req, res, next) {
73
+ if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
74
+ if (res.locals.user.role === 'instructor') {
75
+ return res.status(403).render('error', {
76
+ title: 'Forbidden',
77
+ message: 'Instructor accounts cannot access the admin area.',
78
+ });
79
+ }
80
+ next();
81
+ }
82
+
83
+ /** Instructor portal: instructor role only; admins are sent to their own UI. */
84
+ export function requireInstructor(req, res, next) {
85
+ if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
86
+ if (res.locals.user.role !== 'instructor') return res.redirect('/admin');
87
+ next();
88
+ }