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/.env.example CHANGED
@@ -14,9 +14,11 @@
14
14
  # Public base URL used in emails/magic links (default http://localhost:PORT)
15
15
  #BASE_URL=https://booking.example.com
16
16
 
17
- # Set when running behind a reverse proxy (Caddy/nginx/Traefik) so rate
18
- # limiting keys on the first X-Forwarded-For hop. Leave unset otherwise —
19
- # the header is client-spoofable without a trusted proxy in front.
17
+ # Set when running behind a reverse proxy (Caddy/nginx/Traefik). Rate limiting
18
+ # then keys on the first X-Forwarded-For hop, and X-Forwarded-Proto is trusted
19
+ # so emailed magic links and Stripe redirects use https and the session cookie
20
+ # is marked Secure. Leave unset otherwise — both headers are client-spoofable
21
+ # without a trusted proxy in front.
20
22
  #TRUST_PROXY=1
21
23
 
22
24
  # --- SMTP (optional). When unset, emails are written to data/outbox/*.eml
@@ -29,6 +31,8 @@
29
31
 
30
32
  # --- Stripe (optional, bring-your-own account). When unset, purchase flows
31
33
  # fall back to "pay at studio" and manual activation.
34
+ # Online payment needs BOTH of the first two. With only the secret key the app
35
+ # keeps using pay-at-studio, because the webhook is what creates the pass.
32
36
  #STRIPE_SECRET_KEY=sk_test_...
33
37
  #STRIPE_WEBHOOK_SECRET=whsec_...
34
38
  #STRIPE_PUBLISHABLE_KEY=pk_test_...
package/.gitattributes ADDED
@@ -0,0 +1,8 @@
1
+ # Normalise to LF in the repository and in the working tree on every platform.
2
+ # A Windows clone with core.autocrlf=true otherwise rewrites shebangs to CRLF,
3
+ # and a publish from that clone ships a binary Linux rejects as "bad interpreter".
4
+ * text=auto eol=lf
5
+
6
+ # Collapsed in pull requests and excluded from the language bar. Dependabot
7
+ # opens these weekly and the churn is machine-written, not reviewable by eye.
8
+ package-lock.json linguist-generated=true
@@ -0,0 +1 @@
1
+ github: Booyaka101
@@ -0,0 +1,49 @@
1
+ name: Bug report
2
+ description: Something didn't work the way it should
3
+ labels: [bug]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for the report. Please **never paste Stripe keys, session cookies or customer data** — screenshots of a live studio leak more than people expect. Redact before posting.
9
+
10
+ - type: textarea
11
+ id: what
12
+ attributes:
13
+ label: What happened
14
+ description: What you ran, and what came back.
15
+ placeholder: |
16
+ $ npm start
17
+ Error: ...
18
+ validations:
19
+ required: true
20
+
21
+ - type: textarea
22
+ id: expected
23
+ attributes:
24
+ label: What you expected instead
25
+ validations:
26
+ required: true
27
+
28
+ - type: input
29
+ id: version
30
+ attributes:
31
+ label: Studio OS version
32
+ description: From `package.json`, or the tag you deployed.
33
+ validations:
34
+ required: true
35
+
36
+ - type: input
37
+ id: runtime
38
+ attributes:
39
+ label: How you are running it
40
+ description: Node version, or the Docker image tag, plus the OS and the database you pointed it at.
41
+ placeholder: Docker, node:22-alpine, Postgres 16 on Debian 12
42
+ validations:
43
+ required: true
44
+
45
+ - type: textarea
46
+ id: impact
47
+ attributes:
48
+ label: Did this affect real bookings or payments?
49
+ description: Say so plainly if it did. Anything touching money or a customer's booking gets looked at first.
@@ -0,0 +1,8 @@
1
+ blank_issues_enabled: true
2
+ contact_links:
3
+ - name: Stripe API documentation
4
+ url: https://docs.stripe.com/api
5
+ about: Payment intents, webhooks and payout timing are Stripe's behaviour, not this app's.
6
+ - name: Security issue
7
+ url: https://github.com/Booyaka101/studio-os/security/advisories/new
8
+ about: Please report vulnerabilities privately, not as a public issue.
@@ -0,0 +1,30 @@
1
+ name: Feature request
2
+ description: Suggest something Studio OS should do
3
+ labels: [enhancement]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Self-hosted. It holds your customers' booking data and talks to your own payment account. Nothing is sent to us.
9
+
10
+ - type: textarea
11
+ id: problem
12
+ attributes:
13
+ label: What are you trying to do?
14
+ description: The situation, not the solution. What got in your way?
15
+ validations:
16
+ required: true
17
+
18
+ - type: textarea
19
+ id: proposal
20
+ attributes:
21
+ label: What would you like it to do?
22
+ validations:
23
+ required: false
24
+
25
+ - type: textarea
26
+ id: alternatives
27
+ attributes:
28
+ label: What are you doing instead today?
29
+ validations:
30
+ required: false
@@ -0,0 +1,20 @@
1
+ ## What this changes
2
+
3
+ <!-- And why. Link an issue if there is one. -->
4
+
5
+ ## How you verified it
6
+
7
+ <!--
8
+ Be specific, and keep the claims separate — they carry different weight:
9
+ - `npm test` passes
10
+ - ran it against a real project
11
+ - added a test case covering this
12
+ -->
13
+
14
+ ## Checklist
15
+
16
+ - [ ] `npm test` passes
17
+ - [ ] Added or updated a test for this change
18
+ - [ ] Updated `README.md` if behaviour changed
19
+ - [ ] Added a `CHANGELOG.md` entry under "Unreleased"
20
+ - [ ] No Stripe key, session cookie or customer data in the diff or in any screenshot
@@ -0,0 +1,33 @@
1
+ version: 2
2
+
3
+ # Staying current is a standing requirement, so it is automated rather than
4
+ # remembered.
5
+ updates:
6
+ - package-ecosystem: npm
7
+ directory: /
8
+ schedule:
9
+ interval: weekly
10
+ open-pull-requests-limit: 10
11
+ commit-message:
12
+ prefix: 'chore(deps)'
13
+ groups:
14
+ # Minor and patch only. Majors deliberately arrive as their own PR so one
15
+ # breaking upgrade cannot redden, and block, a batch of safe ones.
16
+ all-minor-patch:
17
+ patterns: ['*']
18
+ update-types: ['minor', 'patch']
19
+
20
+ - package-ecosystem: github-actions
21
+ directory: /
22
+ schedule:
23
+ interval: weekly
24
+ open-pull-requests-limit: 10
25
+ commit-message:
26
+ prefix: 'chore(ci)'
27
+ groups:
28
+ # Minor and patch only. Majors deliberately arrive as their own PR so one
29
+ # breaking upgrade cannot redden, and block, a batch of safe ones.
30
+ actions-minor-patch:
31
+ patterns: ['*']
32
+ update-types: ['minor', 'patch']
33
+
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+
18
+ # Our own tool. Locks the runner image's tool versions and diffs them when
19
+ # GitHub bumps the image — including when a floating label like
20
+ # ubuntu-latest silently re-points. Report-only; no fail-on yet.
21
+ - name: Runner image drift
22
+ uses: Booyaka101/runner-drift@v1
23
+ with:
24
+ fail-on-retirement: 60
25
+
26
+ - uses: actions/setup-node@v7
27
+ with:
28
+ node-version: '22'
29
+
30
+ - run: npm ci
31
+ - run: npm test
@@ -0,0 +1,45 @@
1
+ name: Guards
2
+
3
+ # Our own shipped tools, run against this repo. If they are worth shipping they
4
+ # are worth depending on ourselves — a guard that never runs on our own code is
5
+ # an untested guard.
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ pull_request:
10
+ schedule:
11
+ - cron: '0 6 * * 1'
12
+ workflow_dispatch:
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ guards:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v7
22
+
23
+ - uses: actions/setup-node@v7
24
+ with:
25
+ node-version: '22'
26
+
27
+ - name: Install-script audit
28
+ run: npx --yes npm-script-lens@latest audit --path .
29
+
30
+ # Version cooldown: npm worms are typically identified within hours, so
31
+ # refuse to be among the first to install a brand-new version.
32
+ - name: Dependency cooldown
33
+ run: npx --yes npm-script-lens@latest audit --path . --cooldown
34
+
35
+ # Drift gate: fails if a dependency gains an install script nobody approved.
36
+ - name: Allowlist drift
37
+ run: npx --yes npm-script-lens@1.4.0 sync --path . --check
38
+
39
+ # Shell/Dockerfile/Terraform/PHP — the file types GitHub gated behind paid
40
+ # Advanced Security. This repo ships a Dockerfile. Reports without gating.
41
+ - name: Free GHAS-equivalent scan
42
+ continue-on-error: true
43
+ uses: Booyaka101/ghas-free-pack@v1
44
+ with:
45
+ github-token: ${{ github.token }}
package/CHANGELOG.md CHANGED
@@ -1,86 +1,159 @@
1
- # Changelog
2
-
3
- ## 0.2.0 — 2026-07-28
4
-
5
- - **Instructor logins.** New `instructor` user role alongside the existing
6
- owner/staff (admin) roles — existing accounts are untouched by the
7
- migration (v2 rebuilds the `users` CHECK; SQLite cannot alter one in
8
- place). Instructors sign in on the same staff login form (`/login` now
9
- aliases `/admin/login`) and land on their own portal:
10
- - `GET /instructor/schedule` — the classes they are assigned to, from
11
- today onward, sorted by date
12
- - `GET /instructor/classes/:id/roster` — attendee list with status
13
- (booked / checked-in / no-show / cancelled), class time and capacity;
14
- client emails are *not* exposed to instructors
15
- - `POST /instructor/classes/:id/checkin/:booking_id` — check an attendee
16
- in (same `markAttendance` business logic as the admin roster)
17
- - Route guards: every `/admin/*` route returns **403** for instructor
18
- accounts; rosters and check-in return **403** for classes the
19
- instructor is not assigned to.
20
- - **Admin: instructor account management.** The Instructors page gains an
21
- "Instructor logins" section; `/admin/instructors/new` creates an account
22
- (name, email, password ≥ 8 chars, role `instructor`);
23
- `/admin/instructors/:id/classes` assigns/unassigns upcoming classes via
24
- checkboxes (many-to-many `instructor_class_assignments` table, cascade
25
- on account/instance deletion; past assignments are left untouched).
26
- - 12 new tests (guards, scoping, check-in, admin CRUD, migration; the four
27
- new views covered by the integrity suite) — 110 total, no network.
28
- Upgrade path verified against a real v1 database file.
29
-
30
- ## 0.1.0-hardening — 2026-07-28
31
-
32
- - **Fix: CSRF inputs injected inside attribute values.** The hardening pass had
33
- inserted the hidden `_csrf` input at the wrong offset in 22 forms whose
34
- `action` contained an EJS expression, corrupting the submit URL and leaking
35
- markup as visible text. All POST forms now carry the token immediately after
36
- the form open tag, enforced by a new static views-integrity test suite
37
- (`test/views.test.js`), and verified with a real in-browser form submission.
38
- - **README screenshots**: 8 captured views of the seeded demo studio (public
39
- schedule/booking/buy, admin dashboard/schedule/roster/client/reports).
40
- - **Docker verified end-to-end**: `docker compose build && up -d` → setup
41
- wizard, admin class creation, public schedule and a guest booking all
42
- exercised against the running container, then `down -v`. Fixed
43
- `docker-compose.yml`: an `environment:` key with only commented entries is
44
- invalid YAML for compose ("must be a mapping") — the example block is now
45
- fully commented out.
46
- - **Rate limiting**: dependency-free in-memory fixed-window limiter
47
- (`src/lib/ratelimit.js`), keyed per client IP + route: magic-link requests
48
- 5/15 min, admin login 10/15 min, public booking/buy POSTs 30/15 min;
49
- friendly 429 with `Retry-After`. `X-Forwarded-For` is honored only when
50
- `TRUST_PROXY` is set. Single-process counters (reset on restart) — see the
51
- new README Security section.
52
- - **CSRF protection**: session-bound random token (stored in the signed
53
- cookie-session, no deprecated `csurf` dependency), hidden `_csrf` input on
54
- every state-changing form (public booking/buy/magic-link, client cancel,
55
- setup wizard, admin login and all admin forms); POST/PUT/DELETE/PATCH
56
- without a valid token → 403. `/webhooks/stripe` exempt (Stripe-signature
57
- verified raw body instead). `x-csrf-token` header accepted as an
58
- alternative to the form field.
59
-
60
- ## 0.1.0 — 2026-07-27
61
-
62
- First release. Single-studio, self-hosted, bring-your-own Stripe.
63
-
64
- - Public schedule (14-day view, filters), class pages, guest booking with
65
- waiver capture, returning-client booking by email
66
- - Booking engine: membership → soonest-expiring pack → drop-in payment
67
- resolution; atomic capacity; FIFO waitlist with auto-promotion;
68
- cancellation-window refunds; late-cancel forfeit/refund policy; attendance
69
- - Client self-service via HMAC magic links (no client passwords): view
70
- upcoming bookings, cancel within policy
71
- - Rolling 8-week schedule materialization from weekly rules (boot + daily +
72
- on change), one-off classes, class cancellation with notify + refund
73
- - Admin: dashboard (today's rosters, week revenue, expiring passes), class
74
- types / instructors / weekly rules CRUD, roster check-in/no-show/walk-in,
75
- client profiles (manual passes, memberships, payments, waiver, magic link),
76
- products (packs + membership plans), revenue & attendance reports + CSV,
77
- settings, SQLite backup via `VACUUM INTO`
78
- - Buy page: Stripe Checkout for packs (one-time) and memberships
79
- (subscription) with signature-verified, idempotent webhook fulfillment;
80
- full manual "pay at studio" fallback when Stripe is unconfigured
81
- - Email via SMTP, or `data/outbox/*.eml` + on-screen links when unset
82
- - Mindbody CSV importer (clients + pricing options): CLI + admin page,
83
- JSON column mapping with defaults, dry-run, idempotent by email
84
- - Seed script (`npm run seed`), Dockerfile + docker-compose, PWA manifest +
85
- service worker, vendored htmx/Pico.css (no CDN), 67 tests (no network)
86
-
1
+ # Changelog
2
+
3
+ ## 0.4.0 — 2026-09-27
4
+
5
+ **Upgrading with Stripe:** add `checkout.session.async_payment_succeeded` and
6
+ `invoice.paid` to your webhook endpoint's events. Without them, delayed
7
+ payments never turn into passes and renewals still go unrecorded. The database
8
+ migrates itself on start (schema v3, one new column on `payments`).
9
+
10
+ - **Security: booking in someone else's name handed over their magic link.**
11
+ Booking takes only a name and an email, and the confirmation page always
12
+ showed the booked client's `/me` link, a 7-day token to their bookings,
13
+ passes and cancel buttons. With SMTP configured the link now only goes out
14
+ by email. Without SMTP it's still shown on screen, as before, and the
15
+ README says what that means.
16
+ - **Security: a 100kb POST to any public form leaked a stack trace.** The
17
+ body-size 413 reached the error handler before the layout locals were set,
18
+ `error.ejs` threw, and Express's fallback answered 500 with the
19
+ ReferenceError and server paths (hidden only when `NODE_ENV=production`,
20
+ which the Docker image sets). The error page now renders on its own and 4xx
21
+ statuses stay 4xx.
22
+ - **Stripe renewals were never recorded.** Only a membership's first month
23
+ showed up as a payment, so the revenue report undercounted every
24
+ subscription after month one. `invoice.paid` now records each renewal,
25
+ keyed on the invoice id so webhook retries can't count a month twice.
26
+ - **Delayed Stripe payments got a pass before the money cleared.** Bank
27
+ debits and other delayed methods complete Checkout with `payment_status:
28
+ unpaid`, and fulfilment ignored that. Unpaid sessions now wait for
29
+ `checkout.session.async_payment_succeeded`.
30
+ - **Mindbody imports past a few hundred clients got a 413.** Admin → Import
31
+ now accepts up to 20mb.
32
+ - **The waitlist page said "You're # in line"** with no number. It now shows
33
+ the actual position.
34
+ - `GET /me` without a token answered 500 instead of the "link expired" page.
35
+ - **Dependencies**: dotenv 17 → 18, nodemailer → 10.0.10.
36
+ - **Tests**: 127 → 135.
37
+
38
+ ## 0.3.0 — 2026-09-17
39
+
40
+ - **Security: magic links were emailed as `http://` behind a reverse proxy.**
41
+ `TRUST_PROXY` was read by the rate limiter but Express's own `trust proxy`
42
+ setting was never applied, so with TLS terminating at the proxy
43
+ `req.protocol` read `http` and every emailed link carried its 7-day auth
44
+ token over plaintext. Stripe's success/cancel redirects had the same scheme.
45
+ Setting `TRUST_PROXY=1` now also makes the app honour `X-Forwarded-Proto`,
46
+ and the session cookie picks up its `Secure` flag over https (it stays off
47
+ on plain http, so local dev is unchanged). If you run behind Caddy/nginx/
48
+ Traefik, set `TRUST_PROXY=1` or pin `BASE_URL` to your https origin.
49
+ - **Security: the staff login could enumerate accounts.** `authenticate()`
50
+ returned early for an unknown email and skipped bcrypt entirely, answering
51
+ ~3000x faster than for a real one. Both paths now do one bcrypt comparison.
52
+ `app_secret` is also no longer reachable from templates.
53
+ - **A half-configured Stripe took money and delivered nothing.** Online
54
+ checkout was offered whenever `STRIPE_SECRET_KEY` was set, but fulfilment
55
+ needs `STRIPE_WEBHOOK_SECRET` too. Without it the client paid at Stripe,
56
+ `/webhooks/stripe` rejected the callback with 501, and no pass and no payment
57
+ row was created. Checkout is now offered only when both are set, otherwise
58
+ purchases use the existing pay-at-studio flow. The admin dashboard and
59
+ settings page say which key is missing instead of reporting "connected".
60
+ - **Membership credit cycles drifted for end-of-month joins.** Cycles were
61
+ stepped from the previous cycle, so February's day clamp compounded: a
62
+ membership joined on the 31st became the 28th and stayed there. Cycles now
63
+ anchor on the join date (31 Jan, 28 Feb, 31 Mar). Correcting an
64
+ already-drifted membership does not refill its credits mid-month.
65
+ - **Node 22 is now the minimum** (was 20), and better-sqlite3 moves 11 → 13.
66
+ The Docker image already ships Node 22, so this only matters if you run the
67
+ app straight off your own Node. README and SPEC still said 20+; they now
68
+ agree with `engines`.
69
+ - **Dependencies**: stripe 16 → 22, nodemailer 9 → 10 (clears
70
+ GHSA-8m3c-c648-2xjj), marked → 18.0.13. `npm audit` is clean.
71
+ - **Tests**: 110 → 127. The Stripe and mailer suites only ever ran against a
72
+ mock client and the offline outbox, so neither package was imported under
73
+ test and a major bump could not have failed CI. New tests exercise the real
74
+ packages, including webhook signature verification.
75
+
76
+ ## 0.2.0 — 2026-07-28
77
+
78
+ - **Instructor logins.** New `instructor` user role alongside the existing
79
+ owner/staff (admin) roles — existing accounts are untouched by the
80
+ migration (v2 rebuilds the `users` CHECK; SQLite cannot alter one in
81
+ place). Instructors sign in on the same staff login form (`/login` now
82
+ aliases `/admin/login`) and land on their own portal:
83
+ - `GET /instructor/schedule` — the classes they are assigned to, from
84
+ today onward, sorted by date
85
+ - `GET /instructor/classes/:id/roster` — attendee list with status
86
+ (booked / checked-in / no-show / cancelled), class time and capacity;
87
+ client emails are *not* exposed to instructors
88
+ - `POST /instructor/classes/:id/checkin/:booking_id` — check an attendee
89
+ in (same `markAttendance` business logic as the admin roster)
90
+ - Route guards: every `/admin/*` route returns **403** for instructor
91
+ accounts; rosters and check-in return **403** for classes the
92
+ instructor is not assigned to.
93
+ - **Admin: instructor account management.** The Instructors page gains an
94
+ "Instructor logins" section; `/admin/instructors/new` creates an account
95
+ (name, email, password ≥ 8 chars, role `instructor`);
96
+ `/admin/instructors/:id/classes` assigns/unassigns upcoming classes via
97
+ checkboxes (many-to-many `instructor_class_assignments` table, cascade
98
+ on account/instance deletion; past assignments are left untouched).
99
+ - 12 new tests (guards, scoping, check-in, admin CRUD, migration; the four
100
+ new views covered by the integrity suite) — 110 total, no network.
101
+ Upgrade path verified against a real v1 database file.
102
+
103
+ ## 0.1.0-hardening — 2026-07-28
104
+
105
+ - **Fix: CSRF inputs injected inside attribute values.** The hardening pass had
106
+ inserted the hidden `_csrf` input at the wrong offset in 22 forms whose
107
+ `action` contained an EJS expression, corrupting the submit URL and leaking
108
+ markup as visible text. All POST forms now carry the token immediately after
109
+ the form open tag, enforced by a new static views-integrity test suite
110
+ (`test/views.test.js`), and verified with a real in-browser form submission.
111
+ - **README screenshots**: 8 captured views of the seeded demo studio (public
112
+ schedule/booking/buy, admin dashboard/schedule/roster/client/reports).
113
+ - **Docker verified end-to-end**: `docker compose build && up -d` → setup
114
+ wizard, admin class creation, public schedule and a guest booking all
115
+ exercised against the running container, then `down -v`. Fixed
116
+ `docker-compose.yml`: an `environment:` key with only commented entries is
117
+ invalid YAML for compose ("must be a mapping") — the example block is now
118
+ fully commented out.
119
+ - **Rate limiting**: dependency-free in-memory fixed-window limiter
120
+ (`src/lib/ratelimit.js`), keyed per client IP + route: magic-link requests
121
+ 5/15 min, admin login 10/15 min, public booking/buy POSTs 30/15 min;
122
+ friendly 429 with `Retry-After`. `X-Forwarded-For` is honored only when
123
+ `TRUST_PROXY` is set. Single-process counters (reset on restart) — see the
124
+ new README Security section.
125
+ - **CSRF protection**: session-bound random token (stored in the signed
126
+ cookie-session, no deprecated `csurf` dependency), hidden `_csrf` input on
127
+ every state-changing form (public booking/buy/magic-link, client cancel,
128
+ setup wizard, admin login and all admin forms); POST/PUT/DELETE/PATCH
129
+ without a valid token → 403. `/webhooks/stripe` exempt (Stripe-signature
130
+ verified raw body instead). `x-csrf-token` header accepted as an
131
+ alternative to the form field.
132
+
133
+ ## 0.1.0 — 2026-07-27
134
+
135
+ First release. Single-studio, self-hosted, bring-your-own Stripe.
136
+
137
+ - Public schedule (14-day view, filters), class pages, guest booking with
138
+ waiver capture, returning-client booking by email
139
+ - Booking engine: membership → soonest-expiring pack → drop-in payment
140
+ resolution; atomic capacity; FIFO waitlist with auto-promotion;
141
+ cancellation-window refunds; late-cancel forfeit/refund policy; attendance
142
+ - Client self-service via HMAC magic links (no client passwords): view
143
+ upcoming bookings, cancel within policy
144
+ - Rolling 8-week schedule materialization from weekly rules (boot + daily +
145
+ on change), one-off classes, class cancellation with notify + refund
146
+ - Admin: dashboard (today's rosters, week revenue, expiring passes), class
147
+ types / instructors / weekly rules CRUD, roster check-in/no-show/walk-in,
148
+ client profiles (manual passes, memberships, payments, waiver, magic link),
149
+ products (packs + membership plans), revenue & attendance reports + CSV,
150
+ settings, SQLite backup via `VACUUM INTO`
151
+ - Buy page: Stripe Checkout for packs (one-time) and memberships
152
+ (subscription) with signature-verified, idempotent webhook fulfillment;
153
+ full manual "pay at studio" fallback when Stripe is unconfigured
154
+ - Email via SMTP, or `data/outbox/*.eml` + on-screen links when unset
155
+ - Mindbody CSV importer (clients + pricing options): CLI + admin page,
156
+ JSON column mapping with defaults, dry-run, idempotent by email
157
+ - Seed script (`npm run seed`), Dockerfile + docker-compose, PWA manifest +
158
+ service worker, vendored htmx/Pico.css (no CDN), 67 tests (no network)
159
+