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