diff --git a/api/app/endpoints/system.py b/api/app/endpoints/system.py index e77d7bae..e8649476 100644 --- a/api/app/endpoints/system.py +++ b/api/app/endpoints/system.py @@ -1,4 +1,5 @@ import json +import logging import requests from fastapi import APIRouter, HTTPException @@ -16,6 +17,7 @@ from app.v3.services import clickhouse as ch router = APIRouter() +log = logging.getLogger(__name__) @router.post("/") @@ -155,7 +157,7 @@ def pwa_listing(url: str): # render. Over the cap → treat as no manifest. _MANIFEST_MAX_BYTES = 256 * 1024 try: - with requests.get(manifest_url, {"Accept": "application/json"}, timeout=1, stream=True) as resp: + with requests.get(manifest_url, headers={"Accept": "application/json"}, timeout=1, stream=True) as resp: resp.raise_for_status() chunks = [] total = 0 @@ -166,6 +168,13 @@ def pwa_listing(url: str): chunks.append(chunk) return json.loads(b"".join(chunks)) except requests.exceptions.RequestException: + log.info("[pwa_listing] fetch failed for %s — NO_PWA", manifest_url) + raise exceptions.NO_PWA + except (json.JSONDecodeError, UnicodeDecodeError): + # A 200 with a non-JSON body (an SPA fallback returning HTML, a text + # file, ...) means there is no manifest at that path — the store + # falls back to the registered name. Not a server error. + log.info("[pwa_listing] non-JSON manifest body at %s — NO_PWA", manifest_url) raise exceptions.NO_PWA diff --git a/api/tests/test_v3_endpoints.py b/api/tests/test_v3_endpoints.py index 8de39222..5bfe6bc1 100644 --- a/api/tests/test_v3_endpoints.py +++ b/api/tests/test_v3_endpoints.py @@ -1343,6 +1343,22 @@ def test_no_manifest_returns_no_pwa(self, client): resp = client.get("/pwa_listing", params={"url": "https://host/docs/notes/"}) assert resp.status_code == 401 # NO_PWA — the store falls back to the registered name + def test_non_json_200_body_returns_no_pwa(self, client): + """A 200 with a non-JSON body (SPA fallback HTML) is NO_PWA, not a 500. + + Regression: a registered file URL (.../index.html) makes the manifest + lookup hit /manifest.json, which the static server's SPA + fallback answers with HTML + 200 — json.loads used to 500 on it. + """ + fake = MagicMock() + fake.__enter__ = MagicMock(return_value=fake) + fake.__exit__ = MagicMock(return_value=False) + fake.raise_for_status = MagicMock() + fake.iter_content = MagicMock(return_value=iter([b""])) + with patch("app.endpoints.system.requests.get", return_value=fake): + resp = client.get("/pwa_listing", params={"url": "https://host/docs/media/index.html"}) + assert resp.status_code == 401 # NO_PWA — never a 500 + class TestAppsRegister: def test_register_normalizes_url(self, client, token): diff --git a/knowledge/changelogs/CHANGELOG.md b/knowledge/changelogs/CHANGELOG.md index 31df53be..0e77f772 100644 --- a/knowledge/changelogs/CHANGELOG.md +++ b/knowledge/changelogs/CHANGELOG.md @@ -1,7 +1,11 @@ +3.22.1 || 27.08.2026 +fix(api): `/pwa_listing` no longer 500s when the manifest URL answers 200 with a non-JSON body — it returns NO_PWA (401) and the store falls back to the registered name, per D47. Operator: the dev store 500'd with `JSONDecodeError: Expecting value: line 1 column 1 (char 0)` on `GET /pwa_listing?url=https://dev.web10.app/docs/media/index.html`. Root cause: the registered app URL is a *file* (`.../index.html`), so the manifest lookup hits `.../index.html/manifest.json` — the static server's SPA fallback answers that with HTML + 200, and `json.loads()` raised `JSONDecodeError` (a `ValueError`, not a `RequestException`), which the endpoint's `except requests.exceptions.RequestException` never caught → 500 with the raw error class in the body. The designed behavior (D47, `app-store/overview.md`): no manifest → NO_PWA → the store falls back to the registered name, then the host. Fix: catch `json.JSONDecodeError` + `UnicodeDecodeError` → NO_PWA, with a `[pwa_listing]` log line at each NO_PWA decision point (fetch failure, non-JSON body). Also fixed a latent bug in the same call: `requests.get(url, {"Accept": ...})` passed the header dict as the **params** positional (2nd arg) — the Accept header was going out as a query string (`?Accept=application%2Fjson`); now `headers=`. Regression test: a 200 with an HTML body → 401, never a 500. Note the deeper data issue left alone (a D47 identity call, not a bug): the media demo is registered under its `index.html` file URL on dev, and a file URL can never have a manifest at `{url}/manifest.json` — the store now degrades gracefully instead of 500ing. 715 API tests green. + 3.22.0 || 27.08.2026 docs(strategy) + docs(kb): D54 — the Ad Catalog + the composer integration, planned. Operator, after the ads backend status check: "i want the ads to work in the authenticator, i.e. torture the ads, where there is an ad catalog kind of thing for the user, that has their ads. then in the web10 social experience when you make a post, you can pick from your ad catalog an ad/ads to put, or round robin your ads." **The spec (D54):** (1) **the Ad Catalog is a surface of the Studio** (`ui/src/components/Studio/`) — the Partner Links card (D50) is the *ingest* (offers), the catalog is the *inventory* (the ads built from them). The catalog read is the canonical per-viewer read of `ads` run by the owner (`w.read('ads', { groups: [followers group] })`) — no owner special-case, no new endpoint; what's in the catalog is exactly what gets delivered. Rows: creative / offer / status / numbers / attached posts (reverse `ref_value`); actions: new (the ingest flow), edit, pause/resume, retire (tombstone). (2) **the composer attaches an ad by the universal link** — the post's `ref` → `ref_value` = the ad's `doc_id` (the 3.16.2 write path). The post *carries* the ad, does not copy it: the ad keeps its identity, its numbers, its lifecycle (pause the ad → it stops rendering on every post that carries it, checked at render time). The ad block (creative + offer + disclosure) renders under the post; the disclosure is never optional. (3) **round-robin is the D51 dissemination setting applied at render time** — the composer's "Rotate my ads" is a per-post opt-in to the creator's `settings`-doc dissemination, curated by the shared `curateAds` SDK helper with app-local state; explicit pick beats automatic rotation. (4) **no new tables, no new endpoints, no new SDK surface** — the only new code is UI (catalog screen, composer control, ad block) + a `body.status` field (`active` | `paused`) curation filters on. **Docs:** new KB spec `knowledge-base/web10-v3/social/ads-catalog.md` (the two surfaces, the data-model map, the security invariants — I3/I5 hold unchanged — v0 scope); D54 in `decisions.md`; plan.md section `Ads: The Catalog + Composer (D54)`; the `ads` lane re-scoped (the composer's `PostComposer.tsx` + ad block carved out of the `social-v3` lane; new bites: the catalog screen, the composer ad control, the torture-gauntlet e2e; the conformance + `curateAds` items now explicitly gate the surfaces). No code change — this is the spec the build bites implement. 3.21.1 || 27.08.2026 feature(api) + test(api): the power-mean ranking moves from Python into ClickHouse — the v1 scale-up. Operator, on the branch: "okay, continue! but explain what this even is" → "B! this is clickhouse". **What v0 did (3.18.2):** a sorted `/v3/read` fetched the FULL group membership into Python, ran two ref-count queries, scored every post in-process, sorted, then paged — an O(N) fetch + sort on every read. **What v1 does:** the ranking runs in SQL. `read_documents_in_groups` now dispatches the sort path to a new `_group_docs_ranked_query`: the board base (the same documents ⋈ doc_groups ⋈ group_members + block/share/hidden anti-joins, extracted into a shared `_board_base_sql` fragment the no-sort path also uses) is LEFT JOINed to EXACT engagement counts — one grouped scan of the `reactions` and `comments` collections, no per-row subquery, no maintained counter table — the power-mean score is computed in SQL (`_power_mean_score_sql`, mirroring `_power_mean_score` / `powerMean.ts` exactly: same saturating normalizers, epsilon floor, p=0 geometric mean, recency-only reverse-chron shortcut), and `ORDER BY` + `LIMIT`/`OFFSET` happen in ClickHouse. No full membership fetch into Python, no in-process sort. **The design call (B over A):** `discover.md` / `feed-lens-integration.md` floated a maintained `post_engagement` counter table (option A), but the operator picked the SQL aggregation join (option B) — "this is clickhouse." B is exact (no staleness), race-free (a read-modify-write counter is not atomic in ClickHouse → lost updates), needs no backfill, and touches no write path; it matches the house's own 3.15.0 "metric-as-query, no maintained counters" precedent. The counter table remains a clean v2 trigger if the board ever outgrows the read-time scan. **ClickHouse gotcha:** the board base's `doc_id` collides with `doc_groups.doc_id`, so ClickHouse names the subquery column `p.doc_id` (qualified) and `b.doc_id` won't resolve through the alias — the fix is an explicit `p.doc_id AS doc_id`. **Degenerate all-zero sort:** v0 scored every post 0 and the stable sort preserved created_at DESC; the SQL falls back to a chronological `ORDER BY` to match. **Tests:** the mocked `TestPowerMeanRead` tests are re-aligned to the single-query shape (query shape, params, row mapping — a mock can't verify the SQL math). The ranking math is proven by a real-ClickHouse equivalence check (`.context/verify_power_mean_sql.py`, run against a live CH): the SQL score matches `_power_mean_score` to ≤1e-9 over a 175-input × 6-sort-config grid, and the full ranked query orders a realistic board correctly (most-loved, newest, balanced-vs-Python, paging tiles the full order). 771 API tests green (1 pre-existing `test_change_pass` passlib/bcrypt env failure, red on `origin/dev` too), ruff check + format clean. + 3.21.0 || 27.08.2026 feature(marketing-ui): D53 bite 4 — the group directory UI (the browse surface). Operator: "merged continue!" **The surface:** `/groups` (the directory) + `/groups/:id` (the detail), in `marketing/marketing-ui` alongside the app store (the same public, anon-browsable store pattern). **Directory (`GroupDirectory.tsx`):** fetches `GET /v3/groups/directory` (the minimal list of discoverable groups), renders a responsive grid of `GroupCard`s, with a search box (name/owner/slug) + topic filter chips (the directory's tags, client-side). Empty state when no groups are listed; skeleton while loading. **Detail (`GroupDetail.tsx`):** fetches `GET /v3/groups/detail?group_id=` (the flexible, principal-based read) and shows the group metadata (name, owner, join-policy badge, member count, description, tags) always; posts when the reader is a member (`posts_state: ok`), else a "join to view posts" card (the unlisted-model gated content); a not-found state for a 404. **`GroupCard`** (`components/GroupCard.tsx`): avatar (identity `avatar_ref`, else fallback letter), name, owner, join-policy badge, member count, tags; deep-links to `/groups/:id` with the URL-encoded group id (the `?api=` override survives the navigation, same as the app store). **Wiring:** routes in `App.tsx` + a "Groups" Navbar link. **Tests:** 14 new UI tests (card render/link/skeleton; directory headline/cards/empty/search/tag-filter; detail name/posts/join-to-view/404/skeleton); 228 marketing-ui tests green, build clean. Screenshots captured (desktop + 375px) per the §12 override. **This completes the `groups-directory` lane** — the group directory is built end to end (schema → endpoints → opt-in toggle → browse surface).