studio-os 0.2.0 → 0.4.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.4.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": "^18.0.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,163 @@
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
+ // A pasted Mindbody client export is 100+ bytes a row once URL-encoded, so
57
+ // the 100kb default refused any studio with more than a few hundred clients.
58
+ app.use('/admin/import', express.urlencoded({ extended: true, limit: '20mb' }));
59
+ app.use(express.urlencoded({ extended: true }));
60
+ app.use(express.static(path.join(__dirname, '..', 'public')));
61
+ app.use(cookieSession({
62
+ name: 'studio_session',
63
+ keys: [getSetting(db, 'app_secret', 'dev-secret')],
64
+ sameSite: 'lax',
65
+ httpOnly: true,
66
+ maxAge: 30 * 24 * 3600000,
67
+ }));
68
+
69
+ // View locals: settings + formatting helpers, refreshed per request.
70
+ app.use((req, res, next) => {
71
+ // app_secret signs the session cookie and every magic-link token, so it
72
+ // stays out of the object every template can reach. No view renders it
73
+ // today; this is so none can start to.
74
+ const { app_secret: _secret, ...s } = allSettings(db);
75
+ res.locals.settings = s;
76
+ res.locals.tz = s.timezone || 'Asia/Hong_Kong';
77
+ res.locals.currency = s.currency || 'HKD';
78
+ res.locals.fmtDt = (iso, opts) => formatInTz(res.locals.tz, iso, opts);
79
+ res.locals.fmtMoney = (cents) => new Intl.NumberFormat('en', {
80
+ style: 'currency', currency: res.locals.currency, minimumFractionDigits: 0,
81
+ }).format((cents || 0) / 100);
82
+ res.locals.user = req.session && req.session.userId
83
+ ? db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(req.session.userId)
84
+ : null;
85
+ // Public views ask "can we take money online?", which needs both keys.
86
+ res.locals.stripeConfigured = services.stripe.checkoutReady;
87
+ res.locals.stripeKeySet = services.stripe.configured;
88
+ res.locals.stripeWebhookSet = services.stripe.webhookConfigured;
89
+ res.locals.smtpConfigured = services.mailer.smtpConfigured;
90
+ res.locals.flash = req.session ? req.session.flash : null;
91
+ if (req.session) delete req.session.flash;
92
+ next();
93
+ });
94
+
95
+ // Rate limiting (before CSRF so hammering can't even probe tokens):
96
+ // in-memory fixed window, per IP + route. Single-process; resets on restart.
97
+ const RL_WINDOW = 15 * 60 * 1000;
98
+ app.post('/magic-link', createRateLimiter({
99
+ windowMs: RL_WINDOW, max: 5, env, now,
100
+ message: 'Too many link requests — please wait 15 minutes and try again.',
101
+ }));
102
+ app.post('/admin/login', createRateLimiter({
103
+ windowMs: RL_WINDOW, max: 10, env, now,
104
+ message: 'Too many login attempts — please wait 15 minutes and try again.',
105
+ }));
106
+ app.post(['/class/:id/book', '/buy/pack/:id', '/buy/membership/:id'], createRateLimiter({
107
+ windowMs: RL_WINDOW, max: 30, env, now,
108
+ message: 'Too many booking attempts from your address — please wait a few minutes and try again.',
109
+ }));
110
+
111
+ // CSRF protection: a random per-session token lives in the signed cookie
112
+ // session and must be echoed back (hidden `_csrf` input or `x-csrf-token`
113
+ // header) on every state-changing request. /webhooks/stripe is exempt — it
114
+ // is mounted before this middleware and authenticated by Stripe signature.
115
+ app.use((req, res, next) => {
116
+ if (!req.session.csrf) req.session.csrf = crypto.randomBytes(24).toString('hex');
117
+ res.locals.csrfToken = req.session.csrf;
118
+ if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
119
+ if (req.path.startsWith('/webhooks/')) return next();
120
+ const sent = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
121
+ const a = Buffer.from(String(sent));
122
+ const b = Buffer.from(req.session.csrf);
123
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
124
+ return res.status(403).render('error', {
125
+ title: 'Session expired',
126
+ message: 'Your form session expired or the security token was missing. Please go back, refresh the page, and try again.',
127
+ });
128
+ }
129
+ next();
130
+ });
131
+
132
+ // First-run gate: until setup completes, everything routes to the wizard.
133
+ app.use((req, res, next) => {
134
+ const done = getSetting(db, 'setup_complete', '0') === '1';
135
+ if (!done && !req.path.startsWith('/setup') && !req.path.startsWith('/vendor')
136
+ && !req.path.startsWith('/css') && !req.path.startsWith('/js')) {
137
+ return res.redirect('/setup');
138
+ }
139
+ if (done && req.path.startsWith('/setup')) return res.redirect('/');
140
+ next();
141
+ });
142
+
143
+ app.use('/setup', setupRoutes(services));
144
+ app.use('/', authRoutes(services));
145
+ app.use('/admin', adminRoutes(services));
146
+ app.use('/instructor', instructorRoutes(services));
147
+ app.use('/me', meRoutes(services));
148
+ app.use('/', publicRoutes(services));
149
+
150
+ // 404 + error handler
151
+ app.use((req, res) => res.status(404).render('error', { title: 'Not found', message: 'Page not found.' }));
152
+ // eslint-disable-next-line no-unused-vars
153
+ app.use((err, req, res, next) => {
154
+ console.error('[error]', err);
155
+ // Body-parser errors (oversized or malformed bodies) arrive before the
156
+ // locals middleware has run, and the layout can't render without these.
157
+ res.locals = { settings: {}, user: null, flash: null, ...res.locals };
158
+ const status = err.status >= 400 && err.status < 500 ? err.status : 500;
159
+ res.status(status).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
160
+ });
161
+
162
+ return app;
163
+ }
@@ -0,0 +1,6 @@
1
+ -- v3: membership renewals are recorded from Stripe invoices, keyed on the
2
+ -- invoice id so webhook retries can't record the same month twice.
3
+
4
+ ALTER TABLE payments ADD COLUMN stripe_invoice_id TEXT;
5
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_payments_invoice
6
+ ON payments(stripe_invoice_id) WHERE stripe_invoice_id IS NOT NULL;
package/src/db/index.js CHANGED
@@ -1,82 +1,83 @@
1
- import Database from 'better-sqlite3';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import crypto from 'node:crypto';
5
- import { fileURLToPath } from 'node:url';
6
-
7
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
-
9
- const MIGRATIONS = [
10
- { version: 1, file: 'schema.sql' },
11
- { version: 2, file: '002-instructor-role.sql' },
12
- ];
13
-
14
- const DEFAULT_SETTINGS = {
15
- studio_name: '',
16
- timezone: 'Asia/Hong_Kong',
17
- currency: 'HKD',
18
- cancellation_window_hours: '12',
19
- late_cancel_policy: 'forfeit', // forfeit | refund
20
- waiver_markdown: '## Liability Waiver\n\nI understand that participation in classes involves physical activity and I take part at my own risk. I confirm I am in good health and will inform the instructor of any injuries or conditions.\n\nBy checking the box I agree to this waiver.',
21
- setup_complete: '0',
22
- horizon_weeks: '8',
23
- };
24
-
25
- /**
26
- * Open (or create) the database, enable WAL, run migrations, seed defaults.
27
- * @param {string} [dbPath] file path or ':memory:' (tests)
28
- */
29
- export function openDb(dbPath) {
30
- const file = dbPath || process.env.DB_PATH || path.join(process.cwd(), 'data', 'studio.db');
31
- if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
32
- const db = new Database(file);
33
- db.pragma('journal_mode = WAL');
34
- db.pragma('foreign_keys = ON');
35
- db.pragma('busy_timeout = 5000');
36
- migrate(db);
37
- return db;
38
- }
39
-
40
- function migrate(db) {
41
- db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
42
- const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
43
- for (const m of MIGRATIONS) {
44
- if (applied.has(m.version)) continue;
45
- const sql = fs.readFileSync(path.join(__dirname, m.file), 'utf8');
46
- db.transaction(() => {
47
- db.exec(sql);
48
- db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
49
- .run(m.version, new Date().toISOString());
50
- })();
51
- }
52
- // seed default settings (INSERT OR IGNORE keeps existing values)
53
- const ins = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
54
- for (const [k, v] of Object.entries(DEFAULT_SETTINGS)) ins.run(k, v);
55
- // app secret: generated once, persisted, overridable via env
56
- if (!process.env.APP_SECRET) {
57
- ins.run('app_secret', crypto.randomBytes(32).toString('hex'));
58
- }
59
- }
60
-
61
- export function getSetting(db, key, fallback = '') {
62
- if (key === 'app_secret' && process.env.APP_SECRET) return process.env.APP_SECRET;
63
- const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
64
- return row ? row.value : fallback;
65
- }
66
-
67
- export function setSetting(db, key, value) {
68
- db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
69
- .run(key, String(value));
70
- }
71
-
72
- export function allSettings(db) {
73
- const out = {};
74
- for (const r of db.prepare('SELECT key, value FROM settings').all()) out[r.key] = r.value;
75
- if (process.env.APP_SECRET) out.app_secret = process.env.APP_SECRET;
76
- return out;
77
- }
78
-
79
- export function audit(db, who, action, entity = '') {
80
- db.prepare('INSERT INTO audit_log (who, action, entity, at) VALUES (?, ?, ?, ?)')
81
- .run(who || '', action, entity, new Date().toISOString());
82
- }
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ const MIGRATIONS = [
10
+ { version: 1, file: 'schema.sql' },
11
+ { version: 2, file: '002-instructor-role.sql' },
12
+ { version: 3, file: '003-stripe-invoices.sql' },
13
+ ];
14
+
15
+ const DEFAULT_SETTINGS = {
16
+ studio_name: '',
17
+ timezone: 'Asia/Hong_Kong',
18
+ currency: 'HKD',
19
+ cancellation_window_hours: '12',
20
+ late_cancel_policy: 'forfeit', // forfeit | refund
21
+ waiver_markdown: '## Liability Waiver\n\nI understand that participation in classes involves physical activity and I take part at my own risk. I confirm I am in good health and will inform the instructor of any injuries or conditions.\n\nBy checking the box I agree to this waiver.',
22
+ setup_complete: '0',
23
+ horizon_weeks: '8',
24
+ };
25
+
26
+ /**
27
+ * Open (or create) the database, enable WAL, run migrations, seed defaults.
28
+ * @param {string} [dbPath] file path or ':memory:' (tests)
29
+ */
30
+ export function openDb(dbPath) {
31
+ const file = dbPath || process.env.DB_PATH || path.join(process.cwd(), 'data', 'studio.db');
32
+ if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
33
+ const db = new Database(file);
34
+ db.pragma('journal_mode = WAL');
35
+ db.pragma('foreign_keys = ON');
36
+ db.pragma('busy_timeout = 5000');
37
+ migrate(db);
38
+ return db;
39
+ }
40
+
41
+ function migrate(db) {
42
+ db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
43
+ const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
44
+ for (const m of MIGRATIONS) {
45
+ if (applied.has(m.version)) continue;
46
+ const sql = fs.readFileSync(path.join(__dirname, m.file), 'utf8');
47
+ db.transaction(() => {
48
+ db.exec(sql);
49
+ db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
50
+ .run(m.version, new Date().toISOString());
51
+ })();
52
+ }
53
+ // seed default settings (INSERT OR IGNORE keeps existing values)
54
+ const ins = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
55
+ for (const [k, v] of Object.entries(DEFAULT_SETTINGS)) ins.run(k, v);
56
+ // app secret: generated once, persisted, overridable via env
57
+ if (!process.env.APP_SECRET) {
58
+ ins.run('app_secret', crypto.randomBytes(32).toString('hex'));
59
+ }
60
+ }
61
+
62
+ export function getSetting(db, key, fallback = '') {
63
+ if (key === 'app_secret' && process.env.APP_SECRET) return process.env.APP_SECRET;
64
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
65
+ return row ? row.value : fallback;
66
+ }
67
+
68
+ export function setSetting(db, key, value) {
69
+ db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
70
+ .run(key, String(value));
71
+ }
72
+
73
+ export function allSettings(db) {
74
+ const out = {};
75
+ for (const r of db.prepare('SELECT key, value FROM settings').all()) out[r.key] = r.value;
76
+ if (process.env.APP_SECRET) out.app_secret = process.env.APP_SECRET;
77
+ return out;
78
+ }
79
+
80
+ export function audit(db, who, action, entity = '') {
81
+ db.prepare('INSERT INTO audit_log (who, action, entity, at) VALUES (?, ?, ?, ?)')
82
+ .run(who || '', action, entity, new Date().toISOString());
83
+ }
package/src/routes/me.js CHANGED
@@ -10,7 +10,7 @@ export default function meRoutes(services) {
10
10
  const r = Router();
11
11
 
12
12
  function requireClient(req, res) {
13
- const token = req.query.token || req.body.token;
13
+ const token = req.query.token || (req.body && req.body.token);
14
14
  const clientId = verifyMagicToken(db, token);
15
15
  if (!clientId) {
16
16
  res.status(401).render('public/magic_request', {