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.
@@ -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
+ });
@@ -121,6 +121,34 @@ test('replayed webhook is a no-op (idempotent by stripe_session_id)', async () =
121
121
  assert.equal(db.prepare('SELECT COUNT(*) c FROM payments').get().c, 1, 'no duplicate payment');
122
122
  });
123
123
 
124
+ test('a delayed payment is fulfilled when it clears, not when checkout completes', async () => {
125
+ const { db, app } = makeApp();
126
+ const productId = db.prepare(
127
+ "INSERT INTO pack_products (name, credits, price_cents) VALUES ('5 Pack', 5, 55000)"
128
+ ).run().lastInsertRowid;
129
+ const session = {
130
+ id: 'cs_debit',
131
+ amount_total: 55000,
132
+ currency: 'hkd',
133
+ metadata: { kind: 'pack', pack_product_id: String(productId), client_email: 'debit@test.hk' },
134
+ };
135
+
136
+ const completed = await postEvent(app, checkoutCompleted({ ...session, payment_status: 'unpaid' }));
137
+ assert.equal(completed.status, 200);
138
+ assert.equal(completed.body.fulfilled, false);
139
+ assert.equal(completed.body.reason, 'awaiting_payment');
140
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM passes').get().c, 0, 'no pass before the money arrives');
141
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM payments').get().c, 0);
142
+
143
+ const cleared = await postEvent(app, {
144
+ type: 'checkout.session.async_payment_succeeded',
145
+ data: { object: { ...session, payment_status: 'paid' } },
146
+ });
147
+ assert.equal(cleared.body.fulfilled, true);
148
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM passes').get().c, 1);
149
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM payments WHERE status = 'paid'").get().c, 1);
150
+ });
151
+
124
152
  test('membership checkout fulfillment: activates membership with subscription id', async () => {
125
153
  const { db, app } = makeApp();
126
154
  const planId = db.prepare(
@@ -148,6 +176,61 @@ test('membership checkout fulfillment: activates membership with subscription id
148
176
  assert.equal(payment.status, 'paid');
149
177
  });
150
178
 
179
+ test('invoice.paid records each membership renewal once, but not the first month again', async () => {
180
+ const { db, app } = makeApp();
181
+ const planId = db.prepare(
182
+ "INSERT INTO membership_plans (name, price_cents, unlimited, stripe_price_id) VALUES ('Unlimited Monthly', 128000, 1, 'price_mock')"
183
+ ).run().lastInsertRowid;
184
+ await postEvent(app, checkoutCompleted({
185
+ id: 'cs_renew', amount_total: 128000, currency: 'hkd', subscription: 'sub_renew',
186
+ metadata: { kind: 'membership', plan_id: String(planId), client_email: 'renew@test.hk' },
187
+ }));
188
+ const invoicePaid = (invoice) => postEvent(app, { type: 'invoice.paid', data: { object: invoice } });
189
+ const payments = () => db.prepare("SELECT * FROM payments WHERE what = 'membership' ORDER BY id").all();
190
+
191
+ // Stripe also sends invoice.paid for the first month, which the Checkout
192
+ // session already recorded.
193
+ const first = await invoicePaid({
194
+ id: 'in_1', billing_reason: 'subscription_create', amount_paid: 128000, currency: 'hkd',
195
+ parent: { subscription_details: { subscription: 'sub_renew' } },
196
+ });
197
+ assert.equal(first.body.fulfilled, false);
198
+ assert.equal(payments().length, 1);
199
+
200
+ const renewal = {
201
+ id: 'in_2', billing_reason: 'subscription_cycle', amount_paid: 128000, currency: 'hkd',
202
+ parent: { subscription_details: { subscription: 'sub_renew' } },
203
+ };
204
+ const second = await invoicePaid(renewal);
205
+ assert.equal(second.body.fulfilled, true);
206
+ assert.equal(second.body.kind, 'membership_renewal');
207
+ const replay = await invoicePaid(renewal);
208
+ assert.equal(replay.body.reason, 'already_processed');
209
+
210
+ // Endpoints pinned to an older API version put the id at the top level.
211
+ const legacy = await invoicePaid({
212
+ id: 'in_3', billing_reason: 'subscription_cycle', amount_paid: 128000, currency: 'hkd',
213
+ subscription: 'sub_renew',
214
+ });
215
+ assert.equal(legacy.body.fulfilled, true);
216
+
217
+ const rows = payments();
218
+ assert.equal(rows.length, 3);
219
+ assert.deepEqual(rows.map((p) => p.stripe_invoice_id), [null, 'in_2', 'in_3']);
220
+ assert.ok(rows.every((p) => p.status === 'paid' && p.method === 'stripe' && p.amount_cents === 128000));
221
+ });
222
+
223
+ test('invoice.paid for a subscription Studio OS never sold is acknowledged and ignored', async () => {
224
+ const { db, app } = makeApp();
225
+ const res = await postEvent(app, {
226
+ type: 'invoice.paid',
227
+ data: { object: { id: 'in_other', billing_reason: 'subscription_cycle', amount_paid: 5000, subscription: 'sub_elsewhere' } },
228
+ });
229
+ assert.equal(res.status, 200);
230
+ assert.equal(res.body.reason, 'unknown_subscription');
231
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM payments').get().c, 0);
232
+ });
233
+
151
234
  test('customer.subscription.deleted cancels the matching membership', async () => {
152
235
  const { db, app } = makeApp();
153
236
  const clientId = db.prepare(
@@ -202,7 +285,7 @@ test('fulfillment failure returns 500 so Stripe retries', async () => {
202
285
 
203
286
  test('unhandled event types are acknowledged and ignored', async () => {
204
287
  const { app } = makeApp();
205
- const res = await postEvent(app, { type: 'invoice.paid', data: { object: {} } });
288
+ const res = await postEvent(app, { type: 'charge.refunded', data: { object: {} } });
206
289
  assert.equal(res.status, 200);
207
290
  assert.equal(res.body.fulfilled, false);
208
291
  assert.match(res.body.reason, /ignored event/);
package/test/web.test.js CHANGED
@@ -178,6 +178,11 @@ test('full class books to waitlist via HTTP', async () => {
178
178
  .send({ email: 'b@t.hk', waiver_agree: '1', name: 'B', _csrf });
179
179
  assert.equal(res.status, 200);
180
180
  assert.match(res.text, /waitlist/i);
181
+ assert.match(res.text, /You're #1 in line/);
182
+
183
+ const second = await agent.post(`/class/${inst}/book`).type('form')
184
+ .send({ email: 'c@t.hk', waiver_agree: '1', name: 'C', _csrf });
185
+ assert.match(second.text, /You're #2 in line/);
181
186
  });
182
187
 
183
188
  test('magic link: verify round-trip, view bookings, cancel within policy', async () => {
@@ -257,7 +262,11 @@ test('admin: rules CRUD generates instances; roster check-in works over HTTP', a
257
262
 
258
263
  // book someone in, then check them in via admin
259
264
  const clientId = makeClient(db, { email: 'roster@test.hk' });
260
- const instId = db.prepare('SELECT id FROM class_instances ORDER BY starts_at LIMIT 1').get().id;
265
+ // the earliest instance of a Tuesday 18:30 rule is today's when the suite
266
+ // runs on a Tuesday evening, and book() rejects a class that has started
267
+ const instId = db.prepare(
268
+ 'SELECT id FROM class_instances WHERE starts_at > ? ORDER BY starts_at LIMIT 1'
269
+ ).get(new Date().toISOString()).id;
261
270
  const { book } = await import('../src/services/booking.js');
262
271
  const { booking } = book(db, clientId, instId);
263
272
 
@@ -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>
@@ -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><%= stripeConfigured ? 'set' : 'not set' %></strong><br>
30
- Stripe webhook secret: <strong><%= locals.services ? '' : '' %><%= stripeConfigured ? (settings.stripe_webhook_hint || 'see .env') : '—' %></strong><br>
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>
@@ -3,7 +3,7 @@
3
3
  <% if (result.waitlisted) { %>
4
4
  <h2>You're on the waitlist</h2>
5
5
  <p><strong><%= inst.class_name %></strong> on <%= fmtDt(inst.starts_at) %> is currently full.
6
- You're #<%= result.booking.id ? '' : '' %> in line — if a spot opens we'll book you in automatically<%= smtpConfigured ? ' and email you' : '' %>.</p>
6
+ You're #<%= waitlistPosition %> in line — if a spot opens we'll book you in automatically<%= smtpConfigured ? ' and email you' : '' %>.</p>
7
7
  <% } else { %>
8
8
  <h2>You're booked!</h2>
9
9
  <p><strong><%= inst.class_name %></strong> · <%= fmtDt(inst.starts_at) %></p>
@@ -15,12 +15,12 @@
15
15
  <% } %>
16
16
  </p>
17
17
  <% } %>
18
- <p>Manage or cancel your bookings any time:</p>
19
- <p><a href="<%= magicUrl %>" role="button" class="outline">My bookings</a></p>
20
- <% if (!smtpConfigured) { %>
18
+ <% if (magicUrl) { %>
19
+ <p>Manage or cancel your bookings any time:</p>
20
+ <p><a href="<%= magicUrl %>" role="button" class="outline">My bookings</a></p>
21
21
  <p><small>Save that link — it's your personal booking manager (valid 7 days; you can request a new one from “My bookings” in the menu).</small></p>
22
22
  <% } else { %>
23
- <p><small>A confirmation email with your personal link is on its way.</small></p>
23
+ <p>A confirmation email with a link to manage or cancel your booking is on its way.</p>
24
24
  <% } %>
25
25
  </article>
26
26
  <%- include('../partials/foot') %>
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.