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/.env.example +7 -3
- package/.gitattributes +8 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +49 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +30 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +20 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/guards.yml +45 -0
- package/CHANGELOG.md +124 -86
- package/README.md +263 -253
- package/SECURITY.md +27 -0
- package/SPEC.md +4 -4
- package/package.json +54 -54
- package/runner-lock.json +15 -0
- package/src/app.js +156 -141
- package/src/routes/public.js +2 -2
- package/src/services/auth.js +88 -81
- package/src/services/booking.js +19 -13
- package/src/services/stripe.js +6 -0
- package/test/deps.test.js +108 -0
- package/test/membership-cycle.test.js +78 -0
- package/test/proxy-auth.test.js +114 -0
- package/test/stripe-halfconfig.test.js +98 -0
- package/test/web.test.js +5 -1
- package/views/admin/dashboard.ejs +1 -1
- package/views/admin/settings.ejs +7 -2
- package/PROGRESS.md +0 -60
package/src/services/booking.js
CHANGED
|
@@ -24,22 +24,28 @@ function bookedCount(db, instanceId) {
|
|
|
24
24
|
/** Lazy monthly-credit cycle reset for a credit-based membership. */
|
|
25
25
|
function refreshMembershipCycle(db, m, todayLocal) {
|
|
26
26
|
if (m.unlimited || !m.credits_per_month) return m;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
// Cycles anchor on the join date rather than stepping from the previous
|
|
28
|
+
// cycle. Stepping compounds February's day clamp: a membership joined on the
|
|
29
|
+
// 31st becomes the 28th that month and then stays the 28th for good. From
|
|
30
|
+
// the anchor it clamps only where it has to — 31 Jan, 28 Feb, 31 Mar.
|
|
31
|
+
const anchor = m.started_on;
|
|
32
|
+
const [ay, am] = anchor.split('-').map(Number);
|
|
33
|
+
const [ty, tm] = todayLocal.split('-').map(Number);
|
|
34
|
+
let n = (ty - ay) * 12 + (tm - am);
|
|
35
|
+
if (n > 0 && addMonths(anchor, n) > todayLocal) n -= 1;
|
|
36
|
+
const cycleStart = addMonths(anchor, Math.max(0, n));
|
|
37
|
+
|
|
38
|
+
if (m.cycle_started_on === cycleStart) return m;
|
|
39
|
+
// Only a new month refills credits. Correcting a drifted day inside the
|
|
40
|
+
// current month must not hand the client a free reset.
|
|
41
|
+
const newMonth = (m.cycle_started_on || anchor).slice(0, 7) !== cycleStart.slice(0, 7);
|
|
42
|
+
if (newMonth) {
|
|
35
43
|
db.prepare('UPDATE memberships SET cycle_started_on = ?, credits_used_this_cycle = 0 WHERE id = ?')
|
|
36
44
|
.run(cycleStart, m.id);
|
|
37
|
-
|
|
38
|
-
} else if (!m.cycle_started_on) {
|
|
39
|
-
db.prepare('UPDATE memberships SET cycle_started_on = ? WHERE id = ?').run(cycleStart, m.id);
|
|
40
|
-
m = { ...m, cycle_started_on: cycleStart };
|
|
45
|
+
return { ...m, cycle_started_on: cycleStart, credits_used_this_cycle: 0 };
|
|
41
46
|
}
|
|
42
|
-
|
|
47
|
+
db.prepare('UPDATE memberships SET cycle_started_on = ? WHERE id = ?').run(cycleStart, m.id);
|
|
48
|
+
return { ...m, cycle_started_on: cycleStart };
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
function addMonths(dateStr, n) {
|
package/src/services/stripe.js
CHANGED
|
@@ -23,6 +23,12 @@ export function createStripeService({ env = process.env, client } = {}) {
|
|
|
23
23
|
return {
|
|
24
24
|
configured: Boolean(secretKey),
|
|
25
25
|
webhookConfigured: Boolean(webhookSecret),
|
|
26
|
+
// Checkout is only safe to offer when the webhook that fulfils it will be
|
|
27
|
+
// accepted. With a secret key but no webhook secret the client pays at
|
|
28
|
+
// Stripe and /webhooks/stripe answers 501, so no pass is ever created and
|
|
29
|
+
// no payment is recorded — the money lands in Stripe with nothing to show
|
|
30
|
+
// for it. Half-configured falls back to the manual pay-at-studio flow.
|
|
31
|
+
checkoutReady: Boolean(secretKey && webhookSecret),
|
|
26
32
|
publishableKey,
|
|
27
33
|
|
|
28
34
|
/** One-time Checkout for a class pack. Returns the session (url etc). */
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The Stripe and mailer suites both run against stand-ins: test/stripe.test.js
|
|
2
|
+
// injects a mock client, and every other test leaves SMTP_HOST unset so the
|
|
3
|
+
// mailer takes the outbox branch. Neither imports the real package, so a major
|
|
4
|
+
// bump can rewrite the API and still see a green suite. These tests pin the two
|
|
5
|
+
// bits of third-party behaviour the app cannot work without.
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { createStripeService } from '../src/services/stripe.js';
|
|
11
|
+
import { createMailer } from '../src/services/mailer.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Enough of an SMTP server for nodemailer to complete a send. Resolves with the
|
|
15
|
+
* DATA payload it received. No STARTTLS advertised, so the client stays plain.
|
|
16
|
+
*/
|
|
17
|
+
function smtpSink() {
|
|
18
|
+
let resolveData;
|
|
19
|
+
const received = new Promise((r) => { resolveData = r; });
|
|
20
|
+
const server = net.createServer((socket) => {
|
|
21
|
+
let inData = false;
|
|
22
|
+
let body = '';
|
|
23
|
+
socket.write('220 sink.test ESMTP\r\n');
|
|
24
|
+
socket.on('data', (chunk) => {
|
|
25
|
+
if (inData) {
|
|
26
|
+
body += chunk.toString('utf8');
|
|
27
|
+
if (body.includes('\r\n.\r\n')) {
|
|
28
|
+
inData = false;
|
|
29
|
+
socket.write('250 2.0.0 Ok: queued\r\n');
|
|
30
|
+
resolveData(body.slice(0, body.indexOf('\r\n.\r\n')));
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const line of chunk.toString('utf8').split('\r\n').filter(Boolean)) {
|
|
35
|
+
const verb = line.slice(0, 4).toUpperCase();
|
|
36
|
+
if (verb === 'EHLO' || verb === 'HELO') socket.write('250-sink.test\r\n250 8BITMIME\r\n');
|
|
37
|
+
else if (verb === 'DATA') { inData = true; socket.write('354 End data with <CR><LF>.<CR><LF>\r\n'); }
|
|
38
|
+
else if (verb === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); }
|
|
39
|
+
else socket.write('250 2.0.0 Ok\r\n');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
socket.on('error', () => {});
|
|
43
|
+
});
|
|
44
|
+
return { server, received };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test('mailer: the real nodemailer SMTP path delivers when SMTP_HOST is set', async (t) => {
|
|
48
|
+
const { server, received } = smtpSink();
|
|
49
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
50
|
+
t.after(() => server.close());
|
|
51
|
+
|
|
52
|
+
const mailer = createMailer({
|
|
53
|
+
env: { SMTP_HOST: '127.0.0.1', SMTP_PORT: String(server.address().port), SMTP_FROM: 'studio@test.hk' },
|
|
54
|
+
});
|
|
55
|
+
assert.equal(mailer.smtpConfigured, true);
|
|
56
|
+
|
|
57
|
+
const ok = await mailer.send({ to: 'rider@test.hk', subject: 'Booked: Yoga', text: 'See you there' });
|
|
58
|
+
assert.equal(ok, true, 'send() reported failure — it swallows errors, so check the mailer log');
|
|
59
|
+
|
|
60
|
+
const raw = await received;
|
|
61
|
+
assert.match(raw, /^To: rider@test\.hk$/m);
|
|
62
|
+
assert.match(raw, /^From: studio@test\.hk$/m);
|
|
63
|
+
assert.match(raw, /Booked: Yoga/);
|
|
64
|
+
assert.match(raw, /See you there/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('mailer: send() resolves false rather than throwing when SMTP is unreachable', async () => {
|
|
68
|
+
// A dead port: booking flows call send() fire-and-forget and must not 500.
|
|
69
|
+
const mailer = createMailer({ env: { SMTP_HOST: '127.0.0.1', SMTP_PORT: '1' } });
|
|
70
|
+
assert.equal(await mailer.send({ to: 'a@test.hk', subject: 's', text: 't' }), false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('stripe: the real SDK verifies a good webhook signature and rejects a forged one', async () => {
|
|
74
|
+
const secret = 'whsec_deps_test';
|
|
75
|
+
const svc = createStripeService({
|
|
76
|
+
env: { STRIPE_SECRET_KEY: 'sk_test_deps', STRIPE_WEBHOOK_SECRET: secret },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const payload = JSON.stringify({
|
|
80
|
+
id: 'evt_1', type: 'checkout.session.completed', data: { object: { id: 'cs_1' } },
|
|
81
|
+
});
|
|
82
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
83
|
+
const sign = (body) => crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
|
|
84
|
+
|
|
85
|
+
const event = await svc.parseWebhook(payload, `t=${ts},v1=${sign(payload)}`);
|
|
86
|
+
assert.equal(event.type, 'checkout.session.completed');
|
|
87
|
+
assert.equal(event.data.object.id, 'cs_1');
|
|
88
|
+
|
|
89
|
+
// A payload swapped after signing must not verify — this is the only thing
|
|
90
|
+
// stopping anyone who knows the URL from granting themselves a membership.
|
|
91
|
+
const forged = payload.replace('cs_1', 'cs_forged');
|
|
92
|
+
await assert.rejects(
|
|
93
|
+
() => svc.parseWebhook(forged, `t=${ts},v1=${sign(payload)}`),
|
|
94
|
+
/signature/i,
|
|
95
|
+
);
|
|
96
|
+
await assert.rejects(() => svc.parseWebhook(payload, `t=${ts},v1=deadbeef`), /signature/i);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('stripe: the real SDK still exposes the Checkout surface the mock stands in for', async () => {
|
|
100
|
+
const svc = createStripeService({ env: { STRIPE_SECRET_KEY: 'sk_test_deps' } });
|
|
101
|
+
// Reaches into the client without a network call: a rename of
|
|
102
|
+
// checkout.sessions.create is a TypeError here, not a runtime 500 in prod.
|
|
103
|
+
const Stripe = (await import('stripe')).default;
|
|
104
|
+
const client = new Stripe('sk_test_deps');
|
|
105
|
+
assert.equal(typeof client.checkout.sessions.create, 'function');
|
|
106
|
+
assert.equal(typeof client.webhooks.constructEvent, 'function');
|
|
107
|
+
assert.equal(svc.configured, true);
|
|
108
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Monthly credit cycles for a membership joined near the end of a month.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { resolvePayment } from '../src/services/booking.js';
|
|
5
|
+
import { testDb, makeClient, makeMembership } from './helpers.js';
|
|
6
|
+
|
|
7
|
+
/** Run the lazy cycle refresh for `today` and report the stored cycle start. */
|
|
8
|
+
function cycleOn(startedOn, today) {
|
|
9
|
+
const db = testDb();
|
|
10
|
+
const clientId = makeClient(db);
|
|
11
|
+
const id = makeMembership(db, clientId, { unlimited: false, creditsPerMonth: 4, startedOn });
|
|
12
|
+
db.prepare('UPDATE memberships SET cycle_started_on = ? WHERE id = ?').run(startedOn, id);
|
|
13
|
+
resolvePayment(db, clientId, { starts_at: `${today}T10:00:00Z`, credits_required: 1 }, { now: `${today}T09:00:00Z` });
|
|
14
|
+
return db.prepare('SELECT cycle_started_on FROM memberships WHERE id = ?').get(id).cycle_started_on;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('a cycle anchored on the 31st returns to the 31st after February', () => {
|
|
18
|
+
// Stepping each cycle from the last one clamped to 28 Feb and then kept the
|
|
19
|
+
// 28th for good, quietly moving the client's refill date forward for ever.
|
|
20
|
+
const cases = [
|
|
21
|
+
['2026-01-31', '2026-02-27', '2026-01-31'],
|
|
22
|
+
['2026-01-31', '2026-02-28', '2026-02-28'], // February has to clamp
|
|
23
|
+
['2026-01-31', '2026-03-30', '2026-02-28'],
|
|
24
|
+
['2026-01-31', '2026-03-31', '2026-03-31'], // and March must recover
|
|
25
|
+
['2026-01-31', '2026-04-30', '2026-04-30'],
|
|
26
|
+
['2026-01-31', '2026-05-31', '2026-05-31'],
|
|
27
|
+
];
|
|
28
|
+
for (const [started, today, want] of cases) {
|
|
29
|
+
assert.equal(cycleOn(started, today), want, `started ${started}, on ${today}`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('cycle anchoring handles leap years, year boundaries and mid-month joins', () => {
|
|
34
|
+
const cases = [
|
|
35
|
+
['2024-01-31', '2024-02-29', '2024-02-29'], // leap February
|
|
36
|
+
['2024-01-31', '2024-03-31', '2024-03-31'],
|
|
37
|
+
['2025-11-30', '2026-02-27', '2026-01-30'], // across a year boundary
|
|
38
|
+
['2025-11-30', '2026-02-28', '2026-02-28'], // 30th anchor clamps, and it is today
|
|
39
|
+
['2026-01-15', '2026-03-14', '2026-02-15'], // ordinary anchor, unaffected
|
|
40
|
+
['2026-01-15', '2026-03-15', '2026-03-15'],
|
|
41
|
+
];
|
|
42
|
+
for (const [started, today, want] of cases) {
|
|
43
|
+
assert.equal(cycleOn(started, today), want, `started ${started}, on ${today}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('correcting an already-drifted cycle does not refill credits mid-month', () => {
|
|
48
|
+
const db = testDb();
|
|
49
|
+
const clientId = makeClient(db);
|
|
50
|
+
const id = makeMembership(db, clientId, { unlimited: false, creditsPerMonth: 4, startedOn: '2026-01-31' });
|
|
51
|
+
// The state the old stepping logic left behind: June pinned to the 28th,
|
|
52
|
+
// three of four credits already spent this cycle.
|
|
53
|
+
db.prepare("UPDATE memberships SET cycle_started_on = '2026-06-28', credits_used_this_cycle = 3 WHERE id = ?")
|
|
54
|
+
.run(id);
|
|
55
|
+
|
|
56
|
+
resolvePayment(db, clientId, { starts_at: '2026-06-30T10:00:00Z', credits_required: 1 }, { now: '2026-06-30T09:00:00Z' });
|
|
57
|
+
|
|
58
|
+
const row = db.prepare('SELECT cycle_started_on, credits_used_this_cycle FROM memberships WHERE id = ?').get(id);
|
|
59
|
+
assert.equal(row.cycle_started_on, '2026-06-30', 'the drifted date should be corrected');
|
|
60
|
+
assert.equal(row.credits_used_this_cycle, 3, 'same month, so credits must not be refilled');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a genuinely new month still refills credits', () => {
|
|
64
|
+
const db = testDb();
|
|
65
|
+
const clientId = makeClient(db);
|
|
66
|
+
const id = makeMembership(db, clientId, { unlimited: false, creditsPerMonth: 4, startedOn: '2026-01-31' });
|
|
67
|
+
db.prepare("UPDATE memberships SET cycle_started_on = '2026-01-31', credits_used_this_cycle = 4 WHERE id = ?")
|
|
68
|
+
.run(id);
|
|
69
|
+
|
|
70
|
+
const resolved = resolvePayment(
|
|
71
|
+
db, clientId, { starts_at: '2026-03-31T10:00:00Z', credits_required: 1 }, { now: '2026-03-31T09:00:00Z' },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const row = db.prepare('SELECT cycle_started_on, credits_used_this_cycle FROM memberships WHERE id = ?').get(id);
|
|
75
|
+
assert.equal(row.cycle_started_on, '2026-03-31');
|
|
76
|
+
assert.equal(row.credits_used_this_cycle, 0);
|
|
77
|
+
assert.equal(resolved.paidWith, 'membership', 'a refilled cycle should cover the class again');
|
|
78
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Deployment-shape regressions: the app behind a TLS-terminating reverse proxy
|
|
2
|
+
// (the layout README recommends), and the staff login as an email oracle.
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import request from 'supertest';
|
|
6
|
+
import { createApp } from '../src/app.js';
|
|
7
|
+
import { createStripeService } from '../src/services/stripe.js';
|
|
8
|
+
import { createUser, authenticate } from '../src/services/auth.js';
|
|
9
|
+
import { testDb, makeClient, csrfToken } from './helpers.js';
|
|
10
|
+
import { setSetting } from '../src/db/index.js';
|
|
11
|
+
|
|
12
|
+
/** The documented reverse-proxy deployment: TRUST_PROXY set, BASE_URL unset. */
|
|
13
|
+
function proxiedApp(db, extraEnv = {}) {
|
|
14
|
+
return createApp({ db, env: { TRUST_PROXY: '1', ...extraEnv } });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const asHttps = (r) => r
|
|
18
|
+
.set('X-Forwarded-Proto', 'https')
|
|
19
|
+
.set('Host', 'booking.example.com');
|
|
20
|
+
|
|
21
|
+
test('magic links use https when the proxy terminated TLS', async () => {
|
|
22
|
+
const db = testDb();
|
|
23
|
+
makeClient(db, { email: 'rider@test.hk' });
|
|
24
|
+
const agent = request.agent(proxiedApp(db));
|
|
25
|
+
const token = await csrfToken(agent, '/magic-link');
|
|
26
|
+
|
|
27
|
+
const res = await asHttps(agent.post('/magic-link'))
|
|
28
|
+
.type('form').send({ _csrf: token, email: 'rider@test.hk' });
|
|
29
|
+
|
|
30
|
+
const link = res.text.match(/https?:\/\/[^\s"<]+\/me\?token=[^\s"<&]+/);
|
|
31
|
+
assert.ok(link, 'magic link should be shown on screen when SMTP is off');
|
|
32
|
+
// The link carries a 7-day auth token. Emitting it as http:// puts that
|
|
33
|
+
// token on the wire in plaintext.
|
|
34
|
+
assert.match(link[0], /^https:\/\/booking\.example\.com\/me\?token=/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the session cookie is marked Secure behind an https proxy, not on plain http', async () => {
|
|
38
|
+
const db = testDb();
|
|
39
|
+
const app = proxiedApp(db);
|
|
40
|
+
const secureCookies = (r) => (r.headers['set-cookie'] || []).join(' ');
|
|
41
|
+
|
|
42
|
+
const https = await asHttps(request(app).get('/magic-link'));
|
|
43
|
+
assert.match(secureCookies(https), /secure/i);
|
|
44
|
+
|
|
45
|
+
const http = await request(app).get('/magic-link').set('Host', 'booking.example.com');
|
|
46
|
+
assert.doesNotMatch(secureCookies(http), /secure/i, 'local http dev must still work');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('Stripe redirect URLs inherit the proxied scheme', async () => {
|
|
50
|
+
const db = testDb();
|
|
51
|
+
const created = [];
|
|
52
|
+
const stripeService = createStripeService({
|
|
53
|
+
env: { STRIPE_SECRET_KEY: 'sk_test_x', STRIPE_WEBHOOK_SECRET: 'whsec_x' },
|
|
54
|
+
client: {
|
|
55
|
+
checkout: { sessions: { create: async (p) => { created.push(p); return { id: 'cs_1', url: 'https://checkout.test/s' }; } } },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
db.prepare(
|
|
59
|
+
"INSERT INTO pack_products (name, credits, price_cents, active) VALUES ('10 Pack', 10, 150000, 1)"
|
|
60
|
+
).run();
|
|
61
|
+
const productId = db.prepare('SELECT id FROM pack_products').get().id;
|
|
62
|
+
|
|
63
|
+
const app = createApp({ db, stripeService, env: { TRUST_PROXY: '1' } });
|
|
64
|
+
const agent = request.agent(app);
|
|
65
|
+
const token = await csrfToken(agent, '/buy');
|
|
66
|
+
await asHttps(agent.post(`/buy/pack/${productId}`))
|
|
67
|
+
.type('form').send({ _csrf: token, email: 'rider@test.hk' });
|
|
68
|
+
|
|
69
|
+
assert.equal(created.length, 1, 'checkout session should have been created');
|
|
70
|
+
assert.match(created[0].success_url, /^https:\/\/booking\.example\.com\//);
|
|
71
|
+
assert.match(created[0].cancel_url, /^https:\/\/booking\.example\.com\//);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('app_secret is not reachable from templates', async () => {
|
|
75
|
+
const db = testDb();
|
|
76
|
+
setSetting(db, 'app_secret', 'SECRET-THAT-MUST-NOT-RENDER');
|
|
77
|
+
const app = createApp({ db, env: {} });
|
|
78
|
+
// Swap the view engine for one that dumps everything the template was given,
|
|
79
|
+
// so this asserts on what a view can actually reach rather than on internals.
|
|
80
|
+
app.engine('ejs', (file, options, cb) => cb(null, JSON.stringify(options)));
|
|
81
|
+
|
|
82
|
+
const body = (await request(app).get('/no-such-page')).text;
|
|
83
|
+
|
|
84
|
+
assert.doesNotMatch(body, /SECRET-THAT-MUST-NOT-RENDER/,
|
|
85
|
+
'app_secret signs sessions and magic tokens — a view must not be able to render it');
|
|
86
|
+
assert.match(body, /studio_name/, 'the rest of settings should still be there');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('login takes the same work for a known and an unknown email', async () => {
|
|
90
|
+
const db = testDb();
|
|
91
|
+
createUser(db, { email: 'owner@studio.hk', password: 'correct horse battery', role: 'owner' });
|
|
92
|
+
|
|
93
|
+
// Skipping bcrypt on a missing user answered ~3000x faster and made the
|
|
94
|
+
// login form enumerate staff emails. Wall-clock is too flaky to assert on,
|
|
95
|
+
// so assert the observable cause: a bcrypt comparison happens either way.
|
|
96
|
+
const { default: bcrypt } = await import('bcryptjs');
|
|
97
|
+
const realCompare = bcrypt.compareSync;
|
|
98
|
+
let compares = 0;
|
|
99
|
+
bcrypt.compareSync = (...args) => { compares += 1; return realCompare.apply(bcrypt, args); };
|
|
100
|
+
try {
|
|
101
|
+
compares = 0;
|
|
102
|
+
assert.equal(authenticate(db, 'nosuchuser@studio.hk', 'guess'), null);
|
|
103
|
+
assert.equal(compares, 1, 'an unknown email must still pay for a bcrypt comparison');
|
|
104
|
+
|
|
105
|
+
compares = 0;
|
|
106
|
+
assert.equal(authenticate(db, 'owner@studio.hk', 'guess'), null);
|
|
107
|
+
assert.equal(compares, 1);
|
|
108
|
+
} finally {
|
|
109
|
+
bcrypt.compareSync = realCompare;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// and the happy path still works
|
|
113
|
+
assert.ok(authenticate(db, 'owner@studio.hk', 'correct horse battery'));
|
|
114
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// A Stripe secret key without STRIPE_WEBHOOK_SECRET used to send clients to
|
|
2
|
+
// Checkout anyway. They paid, /webhooks/stripe answered 501, and no pass and no
|
|
3
|
+
// payment row was ever created.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import request from 'supertest';
|
|
7
|
+
import { createApp } from '../src/app.js';
|
|
8
|
+
import { createStripeService } from '../src/services/stripe.js';
|
|
9
|
+
import { createUser } from '../src/services/auth.js';
|
|
10
|
+
import { testDb, csrfToken } from './helpers.js';
|
|
11
|
+
|
|
12
|
+
/** A studio with one pack, one subscribable plan, and the given Stripe env. */
|
|
13
|
+
function studio(env) {
|
|
14
|
+
const db = testDb();
|
|
15
|
+
createUser(db, { email: 'owner@test.test', password: 'password123', role: 'owner' });
|
|
16
|
+
db.prepare("INSERT INTO pack_products (name, credits, price_cents, active) VALUES ('10 Pack', 10, 150000, 1)").run();
|
|
17
|
+
db.prepare("INSERT INTO membership_plans (name, price_cents, unlimited, active, stripe_price_id) VALUES ('Unlimited', 90000, 1, 1, 'price_x')").run();
|
|
18
|
+
const created = [];
|
|
19
|
+
const stripeService = createStripeService({
|
|
20
|
+
env,
|
|
21
|
+
client: {
|
|
22
|
+
checkout: {
|
|
23
|
+
sessions: {
|
|
24
|
+
create: async (params) => {
|
|
25
|
+
created.push(params);
|
|
26
|
+
return { id: 'cs_1', url: 'https://checkout.stripe.test/cs_1' };
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
db,
|
|
34
|
+
created,
|
|
35
|
+
packId: db.prepare('SELECT id FROM pack_products').get().id,
|
|
36
|
+
planId: db.prepare('SELECT id FROM membership_plans').get().id,
|
|
37
|
+
app: createApp({ db, stripeService, env: {} }),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const HALF = { STRIPE_SECRET_KEY: 'sk_test_x' };
|
|
42
|
+
const FULL = { STRIPE_SECRET_KEY: 'sk_test_x', STRIPE_WEBHOOK_SECRET: 'whsec_x' };
|
|
43
|
+
|
|
44
|
+
async function buy(app, path, form) {
|
|
45
|
+
const agent = request.agent(app);
|
|
46
|
+
const _csrf = await csrfToken(agent, '/buy');
|
|
47
|
+
return agent.post(path).type('form').send({ _csrf, ...form });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('a secret key without a webhook secret does not take money online', async () => {
|
|
51
|
+
const { app, created, packId, planId } = studio(HALF);
|
|
52
|
+
|
|
53
|
+
const pack = await buy(app, `/buy/pack/${packId}`, { email: 'rider@test.hk' });
|
|
54
|
+
assert.equal(pack.status, 200, 'no redirect to Checkout');
|
|
55
|
+
assert.match(pack.text, /pay/i);
|
|
56
|
+
|
|
57
|
+
const plan = await buy(app, `/buy/membership/${planId}`, { email: 'rider@test.hk' });
|
|
58
|
+
assert.equal(plan.status, 200);
|
|
59
|
+
|
|
60
|
+
assert.deepEqual(created, [], 'no Checkout session should be created at all');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the buy page offers pay-at-studio when the webhook secret is missing', async () => {
|
|
64
|
+
const half = await request(studio(HALF).app).get('/buy');
|
|
65
|
+
assert.doesNotMatch(half.text, /Buy online|Subscribe online/);
|
|
66
|
+
|
|
67
|
+
const full = await request(studio(FULL).app).get('/buy');
|
|
68
|
+
assert.match(full.text, /Buy online/);
|
|
69
|
+
assert.match(full.text, /Subscribe online/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('both keys present still redirects to Checkout', async () => {
|
|
73
|
+
const { app, created, packId, planId } = studio(FULL);
|
|
74
|
+
|
|
75
|
+
const pack = await buy(app, `/buy/pack/${packId}`, { email: 'rider@test.hk' });
|
|
76
|
+
assert.equal(pack.status, 303);
|
|
77
|
+
assert.equal(pack.headers.location, 'https://checkout.stripe.test/cs_1');
|
|
78
|
+
|
|
79
|
+
const plan = await buy(app, `/buy/membership/${planId}`, { email: 'rider@test.hk' });
|
|
80
|
+
assert.equal(plan.status, 303);
|
|
81
|
+
|
|
82
|
+
assert.equal(created.length, 2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('admin pages report the missing webhook secret instead of "connected"', async () => {
|
|
86
|
+
const { app } = studio(HALF);
|
|
87
|
+
const agent = request.agent(app);
|
|
88
|
+
const _csrf = await csrfToken(agent, '/admin/login');
|
|
89
|
+
await agent.post('/admin/login').type('form').send({ email: 'owner@test.test', password: 'password123', _csrf });
|
|
90
|
+
|
|
91
|
+
const dash = await agent.get('/admin');
|
|
92
|
+
assert.doesNotMatch(dash.text, /Stripe: connected/);
|
|
93
|
+
assert.match(dash.text, /webhook secret missing/i);
|
|
94
|
+
|
|
95
|
+
const settings = await agent.get('/admin/settings');
|
|
96
|
+
assert.match(settings.text, /Online payment is off/i);
|
|
97
|
+
assert.doesNotMatch(settings.text, /see \.env/);
|
|
98
|
+
});
|
package/test/web.test.js
CHANGED
|
@@ -257,7 +257,11 @@ test('admin: rules CRUD generates instances; roster check-in works over HTTP', a
|
|
|
257
257
|
|
|
258
258
|
// book someone in, then check them in via admin
|
|
259
259
|
const clientId = makeClient(db, { email: 'roster@test.hk' });
|
|
260
|
-
|
|
260
|
+
// the earliest instance of a Tuesday 18:30 rule is today's when the suite
|
|
261
|
+
// runs on a Tuesday evening, and book() rejects a class that has started
|
|
262
|
+
const instId = db.prepare(
|
|
263
|
+
'SELECT id FROM class_instances WHERE starts_at > ? ORDER BY starts_at LIMIT 1'
|
|
264
|
+
).get(new Date().toISOString()).id;
|
|
261
265
|
const { book } = await import('../src/services/booking.js');
|
|
262
266
|
const { booking } = book(db, clientId, instId);
|
|
263
267
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
</article>
|
|
8
8
|
<article>
|
|
9
9
|
<h4>Status</h4>
|
|
10
|
-
<p><small>Stripe: <%= stripeConfigured ? 'connected' : 'not connected — purchases fall back to pay-at-studio' %><br>
|
|
10
|
+
<p><small>Stripe: <%= stripeConfigured ? 'connected' : (stripeKeySet ? 'key set, webhook secret missing. Online payment is off, see Settings' : 'not connected — purchases fall back to pay-at-studio') %><br>
|
|
11
11
|
Email: <%= smtpConfigured ? 'SMTP configured' : 'off — messages saved to data/outbox, links shown on screen' %></small></p>
|
|
12
12
|
</article>
|
|
13
13
|
</div>
|
package/views/admin/settings.ejs
CHANGED
|
@@ -26,8 +26,13 @@
|
|
|
26
26
|
<article>
|
|
27
27
|
<h5>Integrations (set via environment / .env)</h5>
|
|
28
28
|
<p><small>
|
|
29
|
-
Stripe secret key: <strong><%=
|
|
30
|
-
Stripe webhook secret: <strong><%=
|
|
29
|
+
Stripe secret key: <strong><%= stripeKeySet ? 'set' : 'not set' %></strong><br>
|
|
30
|
+
Stripe webhook secret: <strong><%= stripeWebhookSet ? 'set' : 'not set' %></strong><br>
|
|
31
|
+
<% if (stripeKeySet && !stripeWebhookSet) { %>
|
|
32
|
+
<strong>Online payment is off.</strong> Without STRIPE_WEBHOOK_SECRET the
|
|
33
|
+
fulfilment webhook is rejected, so a client would be charged and receive
|
|
34
|
+
nothing. Purchases use pay-at-studio until it is set.<br>
|
|
35
|
+
<% } %>
|
|
31
36
|
SMTP: <strong><%= smtpConfigured ? 'configured' : 'not set (emails go to data/outbox/, links shown on screen)' %></strong>
|
|
32
37
|
</small></p>
|
|
33
38
|
</article>
|
package/PROGRESS.md
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# Studio OS — build progress
|
|
2
|
-
|
|
3
|
-
Authoritative spec: `SPEC.md`. Node used: **v22.18.0** (better-sqlite3 11.x prebuilt, SQLite 3.49.2 — no compile needed).
|
|
4
|
-
Run tests: `npm test`. Start: `npm start` (PORT env, default 3000).
|
|
5
|
-
|
|
6
|
-
## Done
|
|
7
|
-
- [x] Scaffold: package.json (studio-os, MIT, type:module), deps installed, htmx 1.9.12 + pico.css 2.0.6 vendored in `public/vendor/` (commit 2cb4364)
|
|
8
|
-
- [x] DB: `src/db/schema.sql` (migration v1), `src/db/index.js` (WAL, FK on, migrations, settings helpers, audit), `src/lib/time.js` (Intl-based tz conversion, no tz lib)
|
|
9
|
-
- [x] Core booking engine `src/services/booking.js`: payment resolution (membership → soonest-expiring pack → drop-in), atomic capacity via transactions, waitlist FIFO + promotion (re-resolves payment at promotion), cancellation window refund / late-cancel forfeit (policy setting), cancelClass full refund, attendance marking
|
|
10
|
-
- [x] Rolling schedule generator `src/services/schedule.js` (8-week horizon, idempotent on (rule_id, starts_at))
|
|
11
|
-
- [x] Tests: 31 passing (`test/booking.test.js`, `test/schedule.test.js`) covering every SPEC booking rule incl. capacity race, FIFO promotion, credit deduct/refund, soonest-expiring pack selection, tz materialization (commit fadbc47)
|
|
12
|
-
- [x] Auth service (bcryptjs + HMAC magic-link tokens), mailer (SMTP or `data/outbox/*.eml` fallback + all templates), Stripe service (isolated, injectable client, idempotent fulfillment by `payments.stripe_session_id` UNIQUE)
|
|
13
|
-
- [x] App skeleton `src/app.js` (webhook raw-body mounted before parsers, cookie-session, setup gate, view locals) + `src/server.js` (boot + daily generator)
|
|
14
|
-
- [x] Routes: setup wizard, staff login/logout, webhooks, public (schedule/class/book with waiver/buy/magic-link request), `src/routes/me.js` (magic-link self-service + cancel)
|
|
15
|
-
- [x] Admin routes + full EJS UI (public + admin), PWA assets (manifest, sw.js, custom.css), 16 supertest web tests (commits 897ad8e, 7449c52)
|
|
16
|
-
- [x] Mindbody importer: `src/services/importer.js` (CSV parser, mapping defaults + overrides, idempotent by email), `scripts/import-mindbody.mjs` CLI (--clients/--passes/--mapping/--db/--dry-run), realistic fixtures `test/fixtures/mindbody-{clients,passes}.csv` (edge cases: missing email, quoted commas, uppercase email, malformed row), admin `/admin/import` page wired, 8 importer tests + 1 web test (56 total green)
|
|
17
|
-
|
|
18
|
-
- [x] Stripe webhook tests `test/stripe.test.js` (11 tests, mock client, no network): signature reject, pack/membership/drop-in fulfillment, replay idempotency, subscription cancel, 500-retry on unknown product, checkout session params (67 total green)
|
|
19
|
-
- [x] `scripts/seed.mjs` (demo studio: 3 instructors, 4 class types, 8 weekly rules → 64 instances, products, 4 clients w/ passes+membership, 2×2 demo bookings; refuses non-empty DB without --force; login owner@example.com/studio-demo), Dockerfile (node:22-slim, VOLUME /app/data) + .dockerignore + docker-compose.yml (studio-data volume), README.md (quickstart Docker+Node, Mindbody migration guide, Stripe guide, limitations, roadmap), CHANGELOG.md. NOTE: docker build not run at the time — verified later, see the hardening pass below.
|
|
20
|
-
|
|
21
|
-
## Self-verification (2026-07-27, item 8) — ALL PASSED
|
|
22
|
-
Live server on PORT=3791, DB_PATH=data/verify-studio.db (deleted after), Node v22.18.0:
|
|
23
|
-
1. `npm test` → **67/67 pass, 0 fail** (booking 21 + schedule 10 + web 17 + importer 8 + stripe 11).
|
|
24
|
-
2. Fresh empty DB boot: `GET /` → 302 `Location: /setup`; `GET /setup` → 200 "Welcome to Studio OS".
|
|
25
|
-
3. `node scripts/seed.mjs` → 3 instructors, 4 class types, 8 rules → 64 instances, products, 4 clients, 4 demo bookings. Re-run without `--force` correctly refused (exit 1).
|
|
26
|
-
4. Seeded boot: `GET /` → 200; schedule lists 20 upcoming instances (Vinyasa/Pilates/Handstand/Yin) with "spots left".
|
|
27
|
-
5. Booking round-trip: `POST /class/33/book` (new guest, waiver_agree=1) → 200 "Booked"; duplicate re-POST did NOT create a second booking; admin login (`POST /admin/login` → 302) then `GET /admin/instances/33` roster shows the client, paid_with `drop_in_manual` ("pay at studio"), check-in buttons.
|
|
28
|
-
6. Magic-link cancel: `POST /magic-link` → on-screen `/me?token=...` (SMTP off); `GET /me?token` → 200 listing the booking; `POST /me/cancel/5` → 302 back to /me, flash "cancelled", list empty; DB row: status='cancelled', cancelled_at set; roster no longer shows the client.
|
|
29
|
-
7. Server stopped cleanly. NOT verified: `docker build` ($0/no-network guardrail — Dockerfile untested).
|
|
30
|
-
|
|
31
|
-
## Hardening pass (2026-07-28)
|
|
32
|
-
- [x] CSRF protection: random per-session token stored in the cookie-session, exposed as `res.locals.csrfToken`, hidden `_csrf` input added to all 36 POST forms (public book/buy/magic-link, /me cancel, setup, admin login/logout and every admin form). Middleware in `src/app.js` rejects POST/PUT/DELETE/PATCH with missing/mismatched token (403, timing-safe compare; `x-csrf-token` header also accepted). `/webhooks/stripe` exempt (mounted before the session layer + explicit path guard; authenticated by Stripe signature instead). Tests updated to cookie agents + `csrfToken()` helper; 2 explicit tests (no/wrong token → 403 + nothing written; valid token works; webhook stays exempt). 69/69 green.
|
|
33
|
-
|
|
34
|
-
- [x] Docker verification (2026-07-28, Docker Desktop 4.44.2 / Engine 28.3.2, linux/amd64): `docker compose build` succeeded first try — `node:22-slim` + better-sqlite3 prebuilt glibc binary, no python3/make/g++ stage needed. Found + fixed a real compose bug: `environment:` list containing only comments parses as null → "must be a mapping" validation error; block now fully commented in mapping form. Live run: `docker compose up -d` → `GET /` 302 `/setup` on fresh volume → completed setup wizard via curl (CSRF cookie+token round-trip) → admin one-off class POST → public schedule renders Yoga Flow → guest booking on `/class/1` → "Booked"; booking POST without CSRF token → 403 in-container. `docker compose down -v` clean. Verified commands recorded in README.
|
|
35
|
-
- [x] Rate limiting: `src/lib/ratelimit.js` (dependency-free in-memory fixed window, key = client IP + matched route pattern, injectable clock, X-Forwarded-For honored only when `TRUST_PROXY` env set). Wired in `src/app.js` before the CSRF check: `/magic-link` 5/15min, `/admin/login` 10/15min, booking/buy POSTs (`/class/:id/book`, `/buy/pack/:id`, `/buy/membership/:id`) 30/15min → friendly 429 + Retry-After. 4 tests in `test/ratelimit.test.js` (trigger, window reset via fake clock — no sleeps, XFF spoof/trust behavior). README limitations updated → new Security section; `TRUST_PROXY` documented in `.env.example`. 73/73 green. NOTE: limiter is per-process and resets on restart (documented).
|
|
36
|
-
|
|
37
|
-
## v0.2.0 — instructor logins (2026-07-28) — SHIPPED, ALL VERIFIED
|
|
38
|
-
- [x] Migration v2 (`src/db/002-instructor-role.sql`): users role CHECK rebuilt to allow 'instructor' (owner/staff untouched — verified against a real v1 file DB: both users kept, v2 applied, instructor insert OK); new `instructor_class_assignments (instructor_id→users, class_id→class_instances, PK both, ON DELETE CASCADE)` + idx_ica_class.
|
|
39
|
-
- [x] Guards (`src/services/auth.js`): `requireAdmin` (admin router; instructor → 403, anon → 302 login), `requireInstructor` (instructor portal; admins → /admin). Login (`src/routes/auth.js`): `/login` aliases `/admin/login`; POST redirects by role (instructor → /instructor/schedule).
|
|
40
|
-
- [x] Instructor portal (`src/routes/instructor.js` + `views/instructor/{schedule,roster}.ejs`): schedule (assigned classes from studio-local today, sorted by date), roster (attendee + status, 'attended' displayed as 'checked-in'; NO client emails exposed), POST checkin (assignment-guarded, booking must belong to the class, reuses `markAttendance`).
|
|
41
|
-
- [x] Admin UI: Instructors page gains logins section; `/admin/instructors/new` (GET+POST, password ≥8, UNIQUE-email friendly error); `/admin/instructors/:id/classes` checkbox assignment (rewrites only the displayed upcoming window; past assignments kept). Routes declared BEFORE `POST /instructors/:id` so 'new' isn't captured as :id.
|
|
42
|
-
- [x] Tests: `test/instructor.test.js` (8) + 4 new view-integrity tests = **110/110 green** (`npm test`).
|
|
43
|
-
- [x] Live E2E on PORT=3799 (seeded DB, curl, then killed + scratch DB deleted): owner created kim@studio.test, assigned class 25 of 2; instructor login → 302 /instructor/schedule; schedule showed only Mat Pilates #25; /admin → 403; roster #9 → 403; roster #25 → 200; check-in booking 1 → Alice Chan 'checked-in'; check-in on #9 → 403; POST without CSRF → 403.
|
|
44
|
-
- [x] package.json 0.2.0, CHANGELOG 0.2.0 entry, README (feature bullet + walkthrough step 6, roadmap line removed).
|
|
45
|
-
|
|
46
|
-
## Next
|
|
47
|
-
- v0.2.0 complete. Optional future: docker rebuild sanity check on next release; screenshots of the instructor portal for README.
|
|
48
|
-
|
|
49
|
-
## Pragmatic choices where SPEC is silent (decided, noted per guardrail)
|
|
50
|
-
- **pack_products table added** (schema v1): SPEC's buy page sells "class packs" but only defines per-client `passes`; a purchasable catalogue was needed. Same for drop-in pending payments → `payments.status` ('paid'|'pending').
|
|
51
|
-
- **Waitlisted bookings do not deduct credits**; deduction happens at promotion (same tx), payment re-resolved at promotion time.
|
|
52
|
-
- **Drop-in online**: booking is created immediately; payment row pending; Stripe checkout (kind=drop_in metadata) marks it paid via webhook. Capacity is not held hostage to checkout completion.
|
|
53
|
-
- **Sessions**: cookie-session (signed cookie) — SPEC allows either. app_secret auto-generated into settings, overridable via APP_SECRET env.
|
|
54
|
-
- ~~No CSRF tokens in v0.1 (same-site=lax cookies); listed in README limitations.~~ Added in the 2026-07-28 hardening pass (session-bound `_csrf` token, no csurf dependency).
|
|
55
|
-
- `npm test` uses glob `"test/*.test.js"` — `node --test test/` breaks under Git Bash path mangling on Windows.
|
|
56
|
-
|
|
57
|
-
## How to resume
|
|
58
|
-
- `cd D:\Repos\ideas\studio-os && npm test` (expect 31+ green) and `git log --oneline` to see where things stand.
|
|
59
|
-
- Work top-down through “Next”. Commit after each numbered item at minimum.
|
|
60
|
-
- Guardrails: stay inside this folder; $0 / no network at boot or in tests; no remotes/publishing; Stripe/SMTP env-gated.
|