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 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,124 @@
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.3.0 — 2026-09-17
4
+
5
+ - **Security: magic links were emailed as `http://` behind a reverse proxy.**
6
+ `TRUST_PROXY` was read by the rate limiter but Express's own `trust proxy`
7
+ setting was never applied, so with TLS terminating at the proxy
8
+ `req.protocol` read `http` and every emailed link carried its 7-day auth
9
+ token over plaintext. Stripe's success/cancel redirects had the same scheme.
10
+ Setting `TRUST_PROXY=1` now also makes the app honour `X-Forwarded-Proto`,
11
+ and the session cookie picks up its `Secure` flag over https (it stays off
12
+ on plain http, so local dev is unchanged). If you run behind Caddy/nginx/
13
+ Traefik, set `TRUST_PROXY=1` or pin `BASE_URL` to your https origin.
14
+ - **Security: the staff login could enumerate accounts.** `authenticate()`
15
+ returned early for an unknown email and skipped bcrypt entirely, answering
16
+ ~3000x faster than for a real one. Both paths now do one bcrypt comparison.
17
+ `app_secret` is also no longer reachable from templates.
18
+ - **A half-configured Stripe took money and delivered nothing.** Online
19
+ checkout was offered whenever `STRIPE_SECRET_KEY` was set, but fulfilment
20
+ needs `STRIPE_WEBHOOK_SECRET` too. Without it the client paid at Stripe,
21
+ `/webhooks/stripe` rejected the callback with 501, and no pass and no payment
22
+ row was created. Checkout is now offered only when both are set, otherwise
23
+ purchases use the existing pay-at-studio flow. The admin dashboard and
24
+ settings page say which key is missing instead of reporting "connected".
25
+ - **Membership credit cycles drifted for end-of-month joins.** Cycles were
26
+ stepped from the previous cycle, so February's day clamp compounded: a
27
+ membership joined on the 31st became the 28th and stayed there. Cycles now
28
+ anchor on the join date (31 Jan, 28 Feb, 31 Mar). Correcting an
29
+ already-drifted membership does not refill its credits mid-month.
30
+ - **Node 22 is now the minimum** (was 20), and better-sqlite3 moves 11 → 13.
31
+ The Docker image already ships Node 22, so this only matters if you run the
32
+ app straight off your own Node. README and SPEC still said 20+; they now
33
+ agree with `engines`.
34
+ - **Dependencies**: stripe 16 → 22, nodemailer 9 → 10 (clears
35
+ GHSA-8m3c-c648-2xjj), marked → 18.0.13. `npm audit` is clean.
36
+ - **Tests**: 110 → 127. The Stripe and mailer suites only ever ran against a
37
+ mock client and the offline outbox, so neither package was imported under
38
+ test and a major bump could not have failed CI. New tests exercise the real
39
+ packages, including webhook signature verification.
40
+
41
+ ## 0.2.0 — 2026-07-28
42
+
43
+ - **Instructor logins.** New `instructor` user role alongside the existing
44
+ owner/staff (admin) roles — existing accounts are untouched by the
45
+ migration (v2 rebuilds the `users` CHECK; SQLite cannot alter one in
46
+ place). Instructors sign in on the same staff login form (`/login` now
47
+ aliases `/admin/login`) and land on their own portal:
48
+ - `GET /instructor/schedule` — the classes they are assigned to, from
49
+ today onward, sorted by date
50
+ - `GET /instructor/classes/:id/roster` — attendee list with status
51
+ (booked / checked-in / no-show / cancelled), class time and capacity;
52
+ client emails are *not* exposed to instructors
53
+ - `POST /instructor/classes/:id/checkin/:booking_id` — check an attendee
54
+ in (same `markAttendance` business logic as the admin roster)
55
+ - Route guards: every `/admin/*` route returns **403** for instructor
56
+ accounts; rosters and check-in return **403** for classes the
57
+ instructor is not assigned to.
58
+ - **Admin: instructor account management.** The Instructors page gains an
59
+ "Instructor logins" section; `/admin/instructors/new` creates an account
60
+ (name, email, password ≥ 8 chars, role `instructor`);
61
+ `/admin/instructors/:id/classes` assigns/unassigns upcoming classes via
62
+ checkboxes (many-to-many `instructor_class_assignments` table, cascade
63
+ on account/instance deletion; past assignments are left untouched).
64
+ - 12 new tests (guards, scoping, check-in, admin CRUD, migration; the four
65
+ new views covered by the integrity suite) — 110 total, no network.
66
+ Upgrade path verified against a real v1 database file.
67
+
68
+ ## 0.1.0-hardening — 2026-07-28
69
+
70
+ - **Fix: CSRF inputs injected inside attribute values.** The hardening pass had
71
+ inserted the hidden `_csrf` input at the wrong offset in 22 forms whose
72
+ `action` contained an EJS expression, corrupting the submit URL and leaking
73
+ markup as visible text. All POST forms now carry the token immediately after
74
+ the form open tag, enforced by a new static views-integrity test suite
75
+ (`test/views.test.js`), and verified with a real in-browser form submission.
76
+ - **README screenshots**: 8 captured views of the seeded demo studio (public
77
+ schedule/booking/buy, admin dashboard/schedule/roster/client/reports).
78
+ - **Docker verified end-to-end**: `docker compose build && up -d` → setup
79
+ wizard, admin class creation, public schedule and a guest booking all
80
+ exercised against the running container, then `down -v`. Fixed
81
+ `docker-compose.yml`: an `environment:` key with only commented entries is
82
+ invalid YAML for compose ("must be a mapping") — the example block is now
83
+ fully commented out.
84
+ - **Rate limiting**: dependency-free in-memory fixed-window limiter
85
+ (`src/lib/ratelimit.js`), keyed per client IP + route: magic-link requests
86
+ 5/15 min, admin login 10/15 min, public booking/buy POSTs 30/15 min;
87
+ friendly 429 with `Retry-After`. `X-Forwarded-For` is honored only when
88
+ `TRUST_PROXY` is set. Single-process counters (reset on restart) — see the
89
+ new README Security section.
90
+ - **CSRF protection**: session-bound random token (stored in the signed
91
+ cookie-session, no deprecated `csurf` dependency), hidden `_csrf` input on
92
+ every state-changing form (public booking/buy/magic-link, client cancel,
93
+ setup wizard, admin login and all admin forms); POST/PUT/DELETE/PATCH
94
+ without a valid token → 403. `/webhooks/stripe` exempt (Stripe-signature
95
+ verified raw body instead). `x-csrf-token` header accepted as an
96
+ alternative to the form field.
97
+
98
+ ## 0.1.0 — 2026-07-27
99
+
100
+ First release. Single-studio, self-hosted, bring-your-own Stripe.
101
+
102
+ - Public schedule (14-day view, filters), class pages, guest booking with
103
+ waiver capture, returning-client booking by email
104
+ - Booking engine: membership → soonest-expiring pack → drop-in payment
105
+ resolution; atomic capacity; FIFO waitlist with auto-promotion;
106
+ cancellation-window refunds; late-cancel forfeit/refund policy; attendance
107
+ - Client self-service via HMAC magic links (no client passwords): view
108
+ upcoming bookings, cancel within policy
109
+ - Rolling 8-week schedule materialization from weekly rules (boot + daily +
110
+ on change), one-off classes, class cancellation with notify + refund
111
+ - Admin: dashboard (today's rosters, week revenue, expiring passes), class
112
+ types / instructors / weekly rules CRUD, roster check-in/no-show/walk-in,
113
+ client profiles (manual passes, memberships, payments, waiver, magic link),
114
+ products (packs + membership plans), revenue & attendance reports + CSV,
115
+ settings, SQLite backup via `VACUUM INTO`
116
+ - Buy page: Stripe Checkout for packs (one-time) and memberships
117
+ (subscription) with signature-verified, idempotent webhook fulfillment;
118
+ full manual "pay at studio" fallback when Stripe is unconfigured
119
+ - Email via SMTP, or `data/outbox/*.eml` + on-screen links when unset
120
+ - Mindbody CSV importer (clients + pricing options): CLI + admin page,
121
+ JSON column mapping with defaults, dry-run, idempotent by email
122
+ - Seed script (`npm run seed`), Dockerfile + docker-compose, PWA manifest +
123
+ service worker, vendored htmx/Pico.css (no CDN), 67 tests (no network)
124
+