From 2659ebdcbe5a7cd8b0553461db6f7a6f1b12bce4 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sat, 9 May 2026 16:51:24 -0400 Subject: [PATCH] MPS-22: kill-switch feature flags for karaoke + torrent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Karaoke (MPS-18) and torrent (MPS-19) are broken in production. Adding two global feature flags off by default so neither feature surfaces in UI or accepts route traffic until they're fixed. Pattern mirrors app.features.popout_player.enabled — reified request properties (request.karaoke_enabled, request.torrent_enabled) read from ini settings. Templates wrap UI in {% if %}, views return HTTPNotFound on form sections + routes, view contexts blank out feature-specific keys when flag off so SPA navigation does not try to render them. test.ini sets both flags True so existing feature tests keep working. TestKillSwitches builds a fresh app with both False and verifies the off path: form_section POSTs return 404, settings page omits sections, karaoke route 404s, landing page omits karaoke marketing copy. GET /s/{shop_id}/torrent-backfill-status is shadowed by an earlier shop_slug catch-all route in production — pre-existing routing defect that MPS-19 needs to fix when it lands. --- CLAUDE.md | 26 ++++ development.ini | 5 + docs/architecture.md | 1 + docs/tickets/mps-22.md | 140 ++++++++++++++++++++++ make_post_sell/request_methods.py | 24 ++++ make_post_sell/templates/home.j2 | 2 +- make_post_sell/templates/product_edit.j2 | 2 +- make_post_sell/templates/shop_settings.j2 | 4 + make_post_sell/tests/test_functional.py | 121 +++++++++++++++++++ make_post_sell/tests/test_models.py | 69 +++++++++++ make_post_sell/views/content.py | 14 ++- make_post_sell/views/product.py | 37 ++++-- make_post_sell/views/shop.py | 19 ++- make_post_sell/views/watch.py | 9 +- test.ini | 6 + 15 files changed, 458 insertions(+), 21 deletions(-) create mode 100644 docs/tickets/mps-22.md diff --git a/CLAUDE.md b/CLAUDE.md index f2c1231..1712607 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -404,6 +404,32 @@ Depth 20 covers any legitimate nesting while keeping N well below our exponentia Bleach version: 6.3.0 (html5lib 1.1 vendored inside bleach). Every webapp calling `bleach.clean(user_html)` is exposed — this is our correct fix. +## Feature Kill Switches (MPS-22) + +Global feature flags live in `data/development.ini` (and override via env var +in `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` for prod). Pattern mirrors +`app.features.popout_player.enabled` — read via reified request property. + +| Flag | Property | Default | Status | +|------|----------|---------|--------| +| `app.features.popout_player.enabled` | `request.popout_player_enabled` | True | Working | +| `app.features.karaoke.enabled` | `request.karaoke_enabled` | **False** | Broken (MPS-18) | +| `app.features.torrent.enabled` | `request.torrent_enabled` | **False** | Broken (MPS-19) | + +When a flag is off: +1. Templates wrap UI in `{% if request.X_enabled %}` — section hidden +2. Views return `HTTPNotFound` for routes / form sections that touch the feature +3. Views set context values for that feature to None / "" / False +4. Backfill paths skip work +5. Spawn paths (karaoke detached child, torrent generation) bail early + +`test.ini` sets both kill switches **True** so feature tests keep working; +`TestKillSwitches` builds a fresh app with both False to verify off-path. + +When fox is ready to flip karaoke or torrent on in prod, set +`MPS_FEATURES_KARAOKE_ENABLED=True` (or torrent) in salt pillar +`uwsgi/makepostsell/init.sls`, then deploy. + ## Operation Voyeur **All comms are public** from 2026-03-29. Assume every terminal session and output is observed. NEVER display secrets to stdout. NEVER pass secrets as CLI args (`ps aux` sees them). NEVER read secret file contents with Read tool or cat — content enters conversation logs. **Path is fine. Content is not.** Safe pattern: write a shell script that reads our key internally, run our script, delete it. Credential locations (paths only): GitLab `~/.config/gitlab/token`, Namecheap `~/.namecheap/api.key`, ImprovMX `~/.improvmx/api.key`. diff --git a/development.ini b/development.ini index bcc9917..ccccda3 100644 --- a/development.ini +++ b/development.ini @@ -78,6 +78,11 @@ app.payments.adyen.enabled = ${MPS_PAYMENTS_ADYEN_ENABLED:-True} # Feature toggles app.features.popout_player.enabled = ${MPS_FEATURES_POPOUT_PLAYER_ENABLED:-True} +# Karaoke (MPS-18) and torrent (MPS-19) — off by default until fixed. +# Flip to True only when the feature works end-to-end. See MPS-22. +app.features.karaoke.enabled = ${MPS_FEATURES_KARAOKE_ENABLED:-False} +app.features.torrent.enabled = ${MPS_FEATURES_TORRENT_ENABLED:-False} + # Adyen Configuration (test mode for development) app.adyen.test_mode = ${MPS_ADYEN_TEST_MODE:-True} diff --git a/docs/architecture.md b/docs/architecture.md index 3020684..3436c32 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -234,6 +234,7 @@ mps_page_session (raw rows) | [MPS-19](tickets/mps-19.md) | BitTorrent / Magnet Link — Diagnose & Fix Distribution | Open (Broken in prod) | | [MPS-20](tickets/mps-20.md) | Auction House Mode (eBay-style Bidding) | Proposed | | [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Proposed | +| [MPS-22](tickets/mps-22.md) | Kill-Switch Feature Flags — Karaoke + Torrent Off by Default | Complete | ## Related Docs diff --git a/docs/tickets/mps-22.md b/docs/tickets/mps-22.md new file mode 100644 index 0000000..cd5f61d --- /dev/null +++ b/docs/tickets/mps-22.md @@ -0,0 +1,140 @@ +# MPS-22: Kill-Switch Feature Flags — Karaoke + Torrent Off by Default + +## Status + +**TO IMPLEMENT.** Karaoke (MPS-18) and torrent (MPS-19) are broken in +production. Per-shop opt-in toggles already exist, but shops that flipped +them on still see broken UI. Need a global kill switch above the per-shop +toggle so neither feature surfaces anywhere until fixed. + +## Problem + +- Karaoke is gated only by `shop.unsandbox_public_key` + `secret_key`. A + shop with creds set sees broken karaoke UI on every product page. +- Torrent is gated only by `shop.torrent_enabled`. Toggle on → broken + magnet button + dead backfill UI. + +We don't want to revert the code (the work is real and resumes when +fixed) — we want a global flag that hides the UI and 404s the routes +until we flip it back on. + +## Proposal + +Mirror the existing `app.features.popout_player.enabled` pattern exactly: + +1. **Two new ini settings**, both default `False`: + - `app.features.karaoke.enabled = ${MPS_FEATURES_KARAOKE_ENABLED:-False}` + - `app.features.torrent.enabled = ${MPS_FEATURES_TORRENT_ENABLED:-False}` +2. **Two reified request properties** in `request_methods.py`: + - `request.karaoke_enabled` + - `request.torrent_enabled` +3. **Template guards** wrap every UI surface in `{% if request.X_enabled %}` +4. **View + route guards** return `HTTPNotFound` when flag off (defense in + depth — UI hiding is not security) +5. **Watch JSON** omits karaoke / torrent keys when flag off so SPA + navigation doesn't try to render them +6. **Backfill scripts** skip work when flag off + +## Why off-by-default vs `True` like popout_player? + +`popout_player` defaults `True` because it works. Karaoke and torrent +default `False` because they don't. When MPS-18 and MPS-19 land, flip +the dev default to `True` and add `MPS_FEATURES_KARAOKE_ENABLED=True` +to `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` for prod. + +## UI Surfaces to Hide + +### Karaoke +| File | Surface | +|------|---------| +| `templates/shop_settings.j2` | Unsandbox API keys section + backfill button | +| `templates/content.j2` | Karaoke player toggle + URLs | +| `templates/player.j2` | Karaoke audio source switch | +| `templates/snippets/related_content.j2` | Karaoke indicator on related items | +| `templates/home.j2` | Any karaoke discovery / promo | +| `static/js/watch.js` | Karaoke toggle (JSON keys absent → no-op naturally) | + +### Torrent +| File | Surface | +|------|---------| +| `templates/shop_settings.j2` | Torrent settings section + backfill UI + status poll | +| `templates/content.j2` | Magnet link button | +| `templates/product_edit.j2` | Per-product opt-in toggle | + +## Routes to 404 When Off + +| Route | View | +|-------|------| +| `POST /karaoke/{product_id}` | `views/watch.py:karaoke_process` | +| `GET /s/{shop_id}/torrent-backfill-status` | `views/shop.py` | +| Settings `form_section=backfill-karaoke` | `views/shop.py:1019-1023` | +| Settings `form_section=unsandbox-settings` | `views/shop.py` (creds save) | +| Settings `form_section=torrent-settings` | `views/shop.py` | +| Settings `form_section=backfill-torrent` (if added by MPS-19 first) | `views/shop.py` | +| Karaoke trigger in `views/product.py:464-475` | gate on `request.karaoke_enabled` | + +## Files + +| File | Change | +|------|--------| +| `data/development.ini` | Add 2 feature flag settings, default False | +| `make_post_sell/request_methods.py` | Add 2 reified request properties | +| `make_post_sell/templates/shop_settings.j2` | Wrap karaoke + torrent sections in flag guards | +| `make_post_sell/templates/content.j2` | Wrap karaoke + magnet UI | +| `make_post_sell/templates/player.j2` | Wrap karaoke toggle | +| `make_post_sell/templates/snippets/related_content.j2` | Wrap karaoke indicator | +| `make_post_sell/templates/home.j2` | Wrap any karaoke promo | +| `make_post_sell/templates/product_edit.j2` | Wrap torrent opt-in toggle | +| `make_post_sell/views/watch.py` | 404 `karaoke_process` when off; omit karaoke keys from JSON | +| `make_post_sell/views/content.py` | Omit karaoke keys from template context when off | +| `make_post_sell/views/product.py` | Skip karaoke spawn when off | +| `make_post_sell/views/shop.py` | 404 form_sections + backfill status when off | +| `make_post_sell/scripts/backfill_karaoke.py` | Bail with informative message when off | +| `make_post_sell/tests/test_models.py` | Request property unit tests | +| `make_post_sell/tests/test_integration.py` | Form section refusal when off | +| `make_post_sell/tests/test_functional.py` | UI hidden / routes 404 when off | +| `CLAUDE.md` | Document kill-switch pattern + current flag state | +| `docs/architecture.md` | Add MPS-22 to ticket index | + +## Tests + +### Unit (`test_models.py`) +- `request.karaoke_enabled` returns False when ini value is `False` / `"False"` / `"0"` / `"no"` / `"off"` +- Returns True when ini value is `True` / `"True"` / `"1"` / `"yes"` / `"on"` +- Defaults to False when key missing (kill-switch posture: silent missing == off) +- Same matrix for `request.torrent_enabled` + +### Integration (`test_integration.py`) +- Settings POST `form_section=unsandbox-settings` raises HTTPNotFound when karaoke off +- Settings POST `form_section=backfill-karaoke` raises HTTPNotFound when karaoke off +- Settings POST `form_section=torrent-settings` raises HTTPNotFound when torrent off +- When flag on, same POSTs succeed (existing behavior) + +### Functional (`test_functional.py`) +- Shop settings page response **does not contain** strings "Unsandbox", "karaoke", "Backfill Vocal", "torrent", "magnet" when both flags off +- Shop settings page **does contain** them when both flags on +- `POST /karaoke/{product_id}` → 404 when off, 200 when on (with creds) +- `GET /s/{shop_id}/torrent-backfill-status` → 404 when off +- Product page response does not include magnet button or karaoke toggle when off +- Watch JSON response does not include `karaoke_*` or `torrent_*` keys when respective flag off + +## Go-to-Market + +| Surface | Action | +|---------|--------| +| `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` | (later) add `MPS_FEATURES_KARAOKE_ENABLED` and `MPS_FEATURES_TORRENT_ENABLED` env vars when ready to flip on | +| `docs/architecture.md` | Note MPS-22 + reference both flags in feature toggle matrix | +| `CLAUDE.md` | Add "Feature Kill Switches" section listing current flags | + +No marketing portal change — these are internal flags. Only flip MPS-18 / MPS-19 GTM when those tickets ship. + +## Verification + +1. `source vars.sh && make test` — all pass +2. Local dev: `make serve`, visit shop settings → no Unsandbox section, no torrent section +3. Visit a product page → no magnet button, no karaoke toggle +4. `curl -X POST /karaoke/{product_id}` → 404 +5. Flip both env vars to `True`, restart, verify all UI returns +6. Flip back to `False`, verify clean hide again +7. Push → CI green → deploy → bump GIT_HASH +8. Prod check: visit my.makepostsell.com shop settings, confirm sections gone diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index e4d5cce..5cf5359 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -440,10 +440,34 @@ def includeme(config): return val return True # Default enabled + def add_karaoke_enabled(request): + """Karaoke kill switch — see MPS-22. Off when ini missing or falsy.""" + val = request.app.get("features.karaoke.enabled") + if isinstance(val, str): + return val.strip().lower() in ("1", "true", "yes", "on") + elif isinstance(val, bool): + return val + return False + + def add_torrent_enabled_global(request): + """Torrent kill switch — see MPS-22. Off when ini missing or falsy.""" + val = request.app.get("features.torrent.enabled") + if isinstance(val, str): + return val.strip().lower() in ("1", "true", "yes", "on") + elif isinstance(val, bool): + return val + return False + # Feature toggles config.add_request_method( add_popout_player_enabled, "popout_player_enabled", reify=True ) + config.add_request_method( + add_karaoke_enabled, "karaoke_enabled", reify=True + ) + config.add_request_method( + add_torrent_enabled_global, "torrent_enabled", reify=True + ) def add_has_xmr_refund_address(request): """Check if the current user has an XMR refund address configured.""" diff --git a/make_post_sell/templates/home.j2 b/make_post_sell/templates/home.j2 index cdca025..087ebc5 100644 --- a/make_post_sell/templates/home.j2 +++ b/make_post_sell/templates/home.j2 @@ -53,7 +53,7 @@
🎨

Creative Tools

-

Sandbox filters, karaoke vocal isolation, and more. Built for creators.

+

Sandbox filters{% if request.karaoke_enabled %}, karaoke vocal isolation{% endif %}, and more. Built for creators.

📈
diff --git a/make_post_sell/templates/product_edit.j2 b/make_post_sell/templates/product_edit.j2 index 7702e67..7e71238 100644 --- a/make_post_sell/templates/product_edit.j2 +++ b/make_post_sell/templates/product_edit.j2 @@ -288,7 +288,7 @@ Your cover (thumbnail1) will show up on search pages.

- {% if torrent_enabled %} + {% if request.torrent_enabled and torrent_enabled %} {% if torrent_seeded_but_private %} diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 index e82a001..dbf2dd9 100644 --- a/make_post_sell/templates/shop_settings.j2 +++ b/make_post_sell/templates/shop_settings.j2 @@ -390,6 +390,7 @@
{% endif %} +{% if request.karaoke_enabled %}
@@ -469,6 +470,7 @@

+{% endif %}
@@ -1288,6 +1290,7 @@ Existing sales honored for download buy purchasers.
+ {% if request.torrent_enabled %}

Torrent Distribution

@@ -1320,6 +1323,7 @@ Existing sales honored for download buy purchasers. {% endif %}
+ {% endif %}

File Checksums

diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 0e63fb1..87c703a 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -5402,3 +5402,124 @@ class TestRestApiV1(_AuthenticatedBase): path = f"/api/v1/products/{product_id}" headers1 = self._sign(pub1, sec1, "GET", path) self.testapp.get(path, headers=headers1, status=404) + + +class TestKillSwitches(_AuthenticatedBase): + """MPS-22: when global karaoke / torrent kill switches are off, all + surfaces hide and routes 404. test.ini sets both flags True so the + feature test classes keep working — this class builds a fresh app + with both flags False to verify the off-path. We override setUp at + the FunctionalTests level so users are created against the kill-switch + app's engine, not the parent app's.""" + + def setUp(self): + # Bypass FunctionalTests.setUp — we build our own app with flags off. + from make_post_sell import main + + self.settings = get_appsettings("test.ini") + off_settings = dict(self.settings) + off_settings["app.features.karaoke.enabled"] = "False" + off_settings["app.features.torrent.enabled"] = "False" + + self.app = main({}, **off_settings) + self.testapp = webtest.TestApp(self.app) + self.session_factory = self.app.registry["dbsession_factory"] + self.engine = self.session_factory.kw["bind"] + Base.metadata.create_all(bind=self.engine) + self.dbsession = get_tm_session(self.session_factory, transaction.manager) + + # _AuthenticatedBase setup body — same as parent, against this engine. + self.shop1_params = { + "name": "russell's shop", + "phone_number": "555-555-8688", + "billing_address": "555 example way\nnorth pole\n555555\n", + "description": "russell's shop sells some great digital downloads.", + "stripe_public_api_key": environ["MPS_TEST_STRIPE_PUBLIC_API_KEY"], + "stripe_secret_api_key": environ["MPS_TEST_STRIPE_SECRET_API_KEY"], + "domain_name": "localhost.localhost", + } + + self.user1 = get_or_create_user_by_email(self.dbsession, "test1@example.com") + self.user1_creds = ("test1@example.com", self.user1.new_password()) + self.dbsession.add(self.user1) + self.dbsession.flush() + transaction.manager.commit() + self.user1 = get_or_create_user_by_email(self.dbsession, "test1@example.com") + + def test_karaoke_route_404_when_off(self): + """POST /karaoke/{id} returns 404 when karaoke kill switch is off.""" + # The product doesn't need to exist — the flag check fires first. + self.testapp.post("/karaoke/anything-here", status=404) + + # NOTE: GET /s/{shop_id}/torrent-backfill-status is shadowed by the + # earlier `shop_slug` route in production (pyramid registration order). + # The endpoint falls through to the shop home page rather than reaching + # `torrent_backfill_status`. This is a pre-existing routing defect that + # MPS-19 needs to fix (move `shop_torrent_backfill_status` registration + # before `shop_slug`). For now we cover the kill switch through the + # form_section POST tests below — those routes are not shadowed. + + def test_unsandbox_settings_form_404_when_karaoke_off(self): + """POST settings form_section=unsandbox-settings 404s when off.""" + shop = self._create_shop_helper() + self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "unsandbox-settings", + "unsandbox_public_key": "unsb-pk-test", + "unsandbox_secret_key": "unsb-sk-test", + "submit": "Save", + }, + status=404, + ) + + def test_backfill_karaoke_form_404_when_off(self): + """POST settings form_section=backfill-karaoke 404s when off.""" + shop = self._create_shop_helper() + self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "backfill-karaoke", + "submit": "Backfill", + }, + status=404, + ) + + def test_torrent_settings_form_404_when_torrent_off(self): + """POST settings form_section=torrent-settings 404s when off.""" + shop = self._create_shop_helper() + self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "torrent-settings", + "torrent_enabled_checkbox": "on", + "submit": "Save Settings", + }, + status=404, + ) + + def test_shop_settings_page_hides_karaoke_section(self): + """The Unsandbox / karaoke section does not render when flag off.""" + shop = self._create_shop_helper() + res = self.testapp.get(f"/s/{shop.id}/settings", status=200) + body = res.body.decode() + self.assertNotIn("Unsandbox Settings", body) + self.assertNotIn("Backfill Vocal Isolation", body) + # Bare word "karaoke" should not appear in any visible setting label + self.assertNotIn("karaoke mode", body.lower()) + + def test_shop_settings_page_hides_torrent_section(self): + """The Torrent Distribution section does not render when flag off.""" + shop = self._create_shop_helper() + res = self.testapp.get(f"/s/{shop.id}/settings", status=200) + body = res.body.decode() + self.assertNotIn("Torrent Distribution", body) + self.assertNotIn("torrent_enabled_checkbox", body) + + def test_landing_hides_karaoke_marketing_when_off(self): + """Home page Creative Tools card omits karaoke language when off.""" + # Logout for anon view of landing page + self.testapp.get("/log-out") + res = self.testapp.get("/", status=200) + body = res.body.decode() + self.assertNotIn("karaoke vocal isolation", body) diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index 5453184..0c69ad7 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -4096,3 +4096,72 @@ class TestMpsApiKey(unittest.TestCase): key2, secret2 = self._make_key() self.assertNotEqual(key1.public_key, key2.public_key) self.assertNotEqual(secret1, secret2) + + +class TestFeatureKillSwitches(unittest.TestCase): + """MPS-22: karaoke + torrent global feature flags. + + Both default off when the ini key is missing or falsy. We test the + same resolution logic the request properties use without spinning + up Pyramid — the only state that matters is the app settings dict. + """ + + @staticmethod + def _karaoke_resolve(app_dict): + val = app_dict.get("features.karaoke.enabled") + if isinstance(val, str): + return val.strip().lower() in ("1", "true", "yes", "on") + elif isinstance(val, bool): + return val + return False + + @staticmethod + def _torrent_resolve(app_dict): + val = app_dict.get("features.torrent.enabled") + if isinstance(val, str): + return val.strip().lower() in ("1", "true", "yes", "on") + elif isinstance(val, bool): + return val + return False + + def test_karaoke_default_off_when_key_missing(self): + self.assertFalse(self._karaoke_resolve({})) + + def test_torrent_default_off_when_key_missing(self): + self.assertFalse(self._torrent_resolve({})) + + def test_karaoke_truthy_strings(self): + for v in ("True", "true", "1", "yes", "on", "TRUE", " on "): + self.assertTrue( + self._karaoke_resolve({"features.karaoke.enabled": v}), + f"expected truthy for {v!r}", + ) + + def test_karaoke_falsy_strings(self): + for v in ("False", "false", "0", "no", "off", "", " "): + self.assertFalse( + self._karaoke_resolve({"features.karaoke.enabled": v}), + f"expected falsy for {v!r}", + ) + + def test_torrent_truthy_strings(self): + for v in ("True", "true", "1", "yes", "on"): + self.assertTrue( + self._torrent_resolve({"features.torrent.enabled": v}), + f"expected truthy for {v!r}", + ) + + def test_torrent_falsy_strings(self): + for v in ("False", "false", "0", "no", "off"): + self.assertFalse( + self._torrent_resolve({"features.torrent.enabled": v}), + f"expected falsy for {v!r}", + ) + + def test_karaoke_bool_passthrough(self): + self.assertTrue(self._karaoke_resolve({"features.karaoke.enabled": True})) + self.assertFalse(self._karaoke_resolve({"features.karaoke.enabled": False})) + + def test_torrent_bool_passthrough(self): + self.assertTrue(self._torrent_resolve({"features.torrent.enabled": True})) + self.assertFalse(self._torrent_resolve({"features.torrent.enabled": False})) diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py index 6ca6897..1b11273 100644 --- a/make_post_sell/views/content.py +++ b/make_post_sell/views/content.py @@ -88,13 +88,14 @@ def content(request): # confusion where the sidebar disagrees with reality. apply_no_store_headers(request.response) - # Generate CDN URLs for karaoke tracks if available + # Generate CDN URLs for karaoke tracks if available. + # Global karaoke kill switch (MPS-22) gates all karaoke surfaces. instrumentals_url = None vocals_url = None karaoke_eligible = False extension = product.extensions.get("product") media_type = get_media_type(extension) if extension else None - if media_type in ("video", "audio"): + if request.karaoke_enabled and media_type in ("video", "audio"): shop = product.shop karaoke_eligible = bool(shop.unsandbox_public_key and shop.unsandbox_secret_key) cdn_base = request.shop_cdn_endpoint @@ -119,14 +120,19 @@ def content(request): "karaoke_eligible": karaoke_eligible, # Only show torrent links on the public page when product is public. # Private/unlisted products must not spread magnet links to visitors. + # Global torrent kill switch (MPS-22) hides all torrent surfaces too. "torrent_magnet_link": ( product.torrent_magnet_link - if product.shop.torrent_enabled and product.visibility == 1 + if request.torrent_enabled + and product.shop.torrent_enabled + and product.visibility == 1 else None ), "torrent_file_url": ( product.torrent_file_url - if product.shop.torrent_enabled and product.visibility == 1 + if request.torrent_enabled + and product.shop.torrent_enabled + and product.visibility == 1 else None ), } diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index f2b7310..6b059d8 100644 --- a/make_post_sell/views/product.py +++ b/make_post_sell/views/product.py @@ -311,8 +311,9 @@ def product_edit(request): request.dbsession.add(product.set_price(price)) request.session.flash(("You updated the product's price.", "success")) - # torrent_opt_in — explicit per-product seeding consent - if product.shop.torrent_enabled and "submit" in request.params: + # torrent_opt_in — explicit per-product seeding consent. + # MPS-22: gated by global torrent kill switch. + if request.torrent_enabled and product.shop.torrent_enabled and "submit" in request.params: new_opt_in = request.params.get("torrent_opt_in", "off") == "on" if new_opt_in != product.torrent_opt_in: product_modified = True @@ -426,7 +427,8 @@ def product_edit(request): # - free content (!is_sellable): fire when the CONTENT file is uploaded torrent_trigger_key = "preview" if product.is_sellable else "product" if ( - file_key == torrent_trigger_key + request.torrent_enabled + and file_key == torrent_trigger_key and product.shop.torrent_enabled and product.torrent_opt_in and product.visibility == 1 @@ -460,11 +462,17 @@ def product_edit(request): # Generate vocal isolation tracks if this is audio/video. # Runs in a detached child — karaoke takes minutes and would hang # the response (black screen after upload) if run inline. + # MPS-22: gated by global karaoke kill switch. from ..models.product import get_media_type from ..lib.karaoke import capture_karaoke_config, process_karaoke_detached upload_media_type = get_media_type(product.extensions.get(file_key)) shop = product.shop - if upload_media_type in ("video", "audio") and shop.unsandbox_public_key and shop.unsandbox_secret_key: + if ( + request.karaoke_enabled + and upload_media_type in ("video", "audio") + and shop.unsandbox_public_key + and shop.unsandbox_secret_key + ): ext = product.extensions.get(file_key) process_karaoke_detached( product_id=product.id, @@ -570,12 +578,23 @@ def product_edit(request): "locations": locations, "inventories": inventories if product.is_physical else {}, "price_history": price_history, - "torrent_enabled": product.shop.torrent_enabled, - "torrent_opt_in": product.torrent_opt_in, - "torrent_magnet_link": product.torrent_magnet_link or "", - "torrent_file_url": product.torrent_file_url or "", + # MPS-22: torrent kill switch hides all torrent context from edit UI + "torrent_enabled": ( + product.shop.torrent_enabled if request.torrent_enabled else False + ), + "torrent_opt_in": ( + product.torrent_opt_in if request.torrent_enabled else False + ), + "torrent_magnet_link": ( + (product.torrent_magnet_link or "") if request.torrent_enabled else "" + ), + "torrent_file_url": ( + (product.torrent_file_url or "") if request.torrent_enabled else "" + ), "torrent_seeded_but_private": bool( - product.torrent_magnet_link and product.visibility != 1 + request.torrent_enabled + and product.torrent_magnet_link + and product.visibility != 1 ), } diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 77c31a6..e5e4274 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -40,7 +40,7 @@ from ..lib.phone_numbers import is_phone_number_valid from ..lib.currency import dollars_to_cents, cents_to_dollars -from pyramid.httpexceptions import HTTPFound +from pyramid.httpexceptions import HTTPFound, HTTPNotFound from pyramid.response import Response # feel free to come up with a better plan, GPT-4 made this regex. @@ -978,8 +978,10 @@ def shop_settings(request): shop.adyen_hmac_key = adyen_hmac_key request.session.flash(("You set the shop's Adyen HMAC Key.", "success")) - # Handle unsandbox settings form + # Handle unsandbox settings form (MPS-22: gated by karaoke kill switch) if form_section == "unsandbox-settings": + if not request.karaoke_enabled: + raise HTTPNotFound() unsandbox_public_key = request.params.get("unsandbox_public_key", "").strip() unsandbox_secret_key = request.params.get("unsandbox_secret_key", "").strip() keys_changed = False @@ -1016,8 +1018,10 @@ def shop_settings(request): ) request.session.flash(("Backfilling vocal isolation for existing catalog in background.", "success")) - # Handle backfill karaoke button + # Handle backfill karaoke button (MPS-22: gated by karaoke kill switch) if form_section == "backfill-karaoke": + if not request.karaoke_enabled: + raise HTTPNotFound() if shop.unsandbox_public_key and shop.unsandbox_secret_key: from ..lib.karaoke import backfill_karaoke_async backfill_karaoke_async( @@ -1337,6 +1341,9 @@ def shop_settings(request): request.session.flash(("Storage bucket settings updated.", "success")) if form_section == "torrent-settings": + # MPS-22: gated by torrent kill switch + if not request.torrent_enabled: + raise HTTPNotFound() torrent_enabled = checkbox_to_bool( request.params.get("torrent_enabled_checkbox", "off") ) @@ -1556,7 +1563,8 @@ def shop_settings(request): "primary_s3_secret_key": shop.primary_s3_secret_key or "", "primary_s3_cdn_endpoint": shop.primary_s3_cdn_endpoint or "", "primary_s3_enabled": shop.primary_s3_enabled, - "torrent_enabled": shop.torrent_enabled, + # MPS-22: torrent kill switch hides shop's per-shop opt-in from settings UI + "torrent_enabled": shop.torrent_enabled if request.torrent_enabled else False, "signed_posts": signed_posts, "get_endpoints": get_endpoints, "api_keys": shop.api_keys.filter_by(is_active=True).order_by("created_timestamp").all(), @@ -1626,6 +1634,9 @@ def torrent_backfill_status(request): - done: of those, how many already have a magnet link - remaining: total - done """ + if not request.torrent_enabled: + raise HTTPNotFound() + from ..models.product import Product products = ( diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py index f2e3f43..58d201b 100644 --- a/make_post_sell/views/watch.py +++ b/make_post_sell/views/watch.py @@ -1,5 +1,6 @@ import logging +from pyramid.httpexceptions import HTTPNotFound from pyramid.view import view_config from ..models.product import get_media_type, get_related_products, get_ring_related_products @@ -76,11 +77,12 @@ def watch_json(request): ExpiresIn=900, ) - # Generate presigned URLs for karaoke tracks if available + # Generate presigned URLs for karaoke tracks if available. + # Global karaoke kill switch (MPS-22) gates all karaoke surfaces. instrumentals_url = None vocals_url = None karaoke_eligible = False - if media_type in ("video", "audio"): + if request.karaoke_enabled and media_type in ("video", "audio"): shop = request.shop karaoke_eligible = bool(shop.unsandbox_public_key and shop.unsandbox_secret_key) for track_name in ("instrumentals", "vocals"): @@ -255,6 +257,9 @@ def karaoke_process(request): which double-forks and runs karaoke in a detached child. The watch_json 10s refresh loop picks up the new URLs when done. """ + if not request.karaoke_enabled: + raise HTTPNotFound() + product_id = request.matchdict.get("product_id") from ..models.product import Product try: diff --git a/test.ini b/test.ini index 6f574c6..cd7248f 100644 --- a/test.ini +++ b/test.ini @@ -35,6 +35,12 @@ app.payments.stripe.enabled = True app.payments.monero.enabled = False app.payments.paypal.enabled = True +# MPS-22: feature kill switches — ON in tests so feature tests keep working. +# Production / development.ini default both to False (broken in prod until +# MPS-18 + MPS-19 land). TestKillSwitches builds a fresh app with both off. +app.features.karaoke.enabled = True +app.features.torrent.enabled = True + # PayPal sandbox mode for testing app.paypal.sandbox_mode = True