MPS-22: kill-switch feature flags for karaoke + torrent

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.
This commit is contained in:
russell@unturf.com 2026-05-09 16:51:24 -04:00
parent b3d9b2b39c
commit 2659ebdcbe
No known key found for this signature in database
15 changed files with 458 additions and 21 deletions

View file

@ -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`.

View file

@ -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}

View file

@ -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

140
docs/tickets/mps-22.md Normal file
View file

@ -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

View file

@ -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."""

View file

@ -53,7 +53,7 @@
<div class="landing-feature-card well">
<div class="landing-feature-icon">&#127912;</div>
<h3 class="type-title">Creative Tools</h3>
<p class="type-body-sm">Sandbox filters, karaoke vocal isolation, and more. Built for creators.</p>
<p class="type-body-sm">Sandbox filters{% if request.karaoke_enabled %}, karaoke vocal isolation{% endif %}, and more. Built for creators.</p>
</div>
<div class="landing-feature-card well">
<div class="landing-feature-icon">&#128200;</div>

View file

@ -288,7 +288,7 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
<br />
<br />
{% if torrent_enabled %}
{% if request.torrent_enabled and torrent_enabled %}
<label>Torrent Distribution</label>
{% if torrent_seeded_but_private %}

View file

@ -390,6 +390,7 @@
<br />
{% endif %}
{% if request.karaoke_enabled %}
<section class="one-column">
<section class="shop-settings well">
@ -469,6 +470,7 @@
<br />
<br />
{% endif %}
<section class="one-column">
<section class="shop-settings well">
@ -1288,6 +1290,7 @@ Existing sales honored for download buy purchasers.
<br />
<section class="one-column">
{% if request.torrent_enabled %}
<section class="shop-settings well">
<h3>Torrent Distribution</h3>
@ -1320,6 +1323,7 @@ Existing sales honored for download buy purchasers.
{% endif %}
</section>
{% endif %}
<section class="shop-settings-checksum well2" style="margin-top: var(--space-6);">
<h3>File Checksums</h3>

View file

@ -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)

View file

@ -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}))

View file

@ -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
),
}

View file

@ -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
),
}

View file

@ -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 = (

View file

@ -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:

View file

@ -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