diff --git a/knowledge/changelogs/CHANGELOG.md b/knowledge/changelogs/CHANGELOG.md index 1a3636a3..3320b3f0 100644 --- a/knowledge/changelogs/CHANGELOG.md +++ b/knowledge/changelogs/CHANGELOG.md @@ -1,3 +1,5 @@ +3.16.1 || 26.08.2026 +docs(kb) + docs(strategy): D50 — the app store's endpoint surface is spec'd, and the product page's identity gap is closed on paper. Operator: "in the apps would be cool if there was a little button that said to see more … it expands, has a deeper paragraph description from the manifest, and then has ALL the available stats, the 90d users, the visits, the 30d users, you get what i mean, everything in one screen, and the apps reviews comments kind of thing" — then "clicking the tile opens this modal or whatever it is" — then "in the knowledge base we need to talk about how the app store is going to work, it will need its own endpoints." **The finding that shaped the call:** the product page (PR #426, `/app-store/app/:id`) is broken in two independent ways — it fetches `PATCH /discover/app/{id}`, a route that does not exist in the API (every page 404s into "App not found"), and `list_store_apps` blanks `web10apps_post_id` to `""` (the v3 `apps` table never had the column — it's a v2 vestige), so the card never takes the internal-Link path and the tile opens the site directly. The root cause is an identity gap: the UI wanted a short ID for the route, but D47 already made the full URL the app's identity. **Decided (D50 — renumbered from D49, which #681 took for the real-user metrics model):** (1) the product page is a PAGE, not a modal — the existing route is the surface; tap tile → page, Open button → launch (App Store convention; a modal with no URL state is a review rejection per the deep-link rule, and it's cramped for "everything in one screen"); a "see more" expander on the card is rejected too (third tap target on a small tile, breaks grid row alignment); (2) a new `GET /v3/apps/detail?url=` serves the page — public, pure read, one call: app + rating aggregate + rating list + the full five-metric breakdown (D49 item 5: the grid card shows the headline, the detail page shows the full breakdown — this spec is what that line points at) + the node macro, and NO visit bump (a product-page view is not an app visit; `app_visits` rows come only from SDK pings carrying a verified token); (3) the URL is the key — `web10apps_post_id` is retired, `list_store_apps` stops blanking it, the route param becomes the URL-encoded canonical URL; a parallel ID (post_id / slug / numeric) is rejected — D47: the URL is the identity, a second ID system is drift by construction; (4) reviews = rating + optional comment — `app_ratings` gains a `comment` column, one voice per author (dedup key `(target_app_id, author)`), no separate reviews table; (5) the page shows D49's metric set, not an invention — per app: visits + users_1d/30d/90d/1y (the same realtime `get_app_metrics` queries the grid runs over `app_visits`, exact countDistinct) + the `/v3/stats` node macro (users, app_count, active_users, storage); a consent-based user count (`app_contracts` holders) is rejected — D49's metric set is the store's number, consent is a separate trust surface. **New KB doc:** `knowledge-base/web10-v3/app-store/endpoints.md` — the full surface (register / detail / list / rating / ratings / admin / approve / pwa_listing / stats), the auth split (reads public, writes signed or admin — D41), the detail response shape, what each number means, the product-page flow, and a logistics table (built vs scoped). The build itself (the detail endpoint, the comment column, the card-to-page wiring) is the next bite — this is the spec, per the KB-is-root-of-trust rule. No code changes; docs only. 3.16.0 || 26.08.2026 fix(api) + fix(ui) + feature(ui): the Node Config panel shows what the node actually runs — and its Save button works. Operator review of the panel on auth.dev.web10.app: "seeing alot of blanks", "clickhouse url is blank though... should default to the docker network thing", "media storage settings are empty too... should have the minio values", plus field-trimming asks. Root cause of the blanks: `POST /config` returned the raw saved `node_config` row — empty on a fresh node — so the form rendered blank even though the node was running on its `settings.py` values (env-overridden). New `config_svc.effective_config()`: settings.py is the base (what the node connects with and enforces at boot), the saved config overlays it field by field; `POST /config` now returns that. The ClickHouse URL defaults to the docker-network connection string composed from the `CLICKHOUSE_*` settings (`clickhouse://web10:web10@clickhouse:8123/web10` — the node connects with those parts, the URL is the human-readable reference form), and the S3 section defaults to the MinIO values (`http://minio:9000`, `web10-media`, `minioadmin`/`minioadmin`, `us-east-1`). `effective_config` coerces the env-override loop's raw strings back to typed values (int/float/bool). Field trimming per the operator: Node Identity drops Brand Name, Logo (dark), Logo (light), and Signing Algorithm (kept: Provider Domain, CORS Service Managers, Token Expiry); Stripe drops the four subscription-ID fields + Dev Pay Split (kept: Mode, Test API Key, Live API Key). Credential fields (ClickHouse URL, S3 secret, Twilio auth token, Stripe keys) render as visible text instead of password dots — the panel's job is to show the operator what the node runs, and the surface is admin-gated (`check_admin`: node-signed JWT + admin list); `POST /config` correspondingly strips only `private_key` (the signing secret — the UI has no field for it) instead of the five-field strip list. **The Save button was dead** — it sent `PATCH /config`, a route that does not exist (only `POST /config` and `POST /config/update` do), so every save 405'd; it now POSTs the diff-only payload to `/config/update` (the diff logic stays — unchanged values, including the billing fields the form no longer shows, stay off the wire). Tests: 7 new API unit tests in `test_node_config.py` (effective-config defaults, CH-URL composition, saved-overlay, env-string coercion; endpoint: admin sees effective config + no `private_key`, saved values surface, non-admin → 403 through the real exception handler). 708 API tests + 96 UI tests green, `tsc -b` + build clean. diff --git a/knowledge/knowledge-base/web10-v3/README.md b/knowledge/knowledge-base/web10-v3/README.md index fb8c9c5b..28288d0a 100644 --- a/knowledge/knowledge-base/web10-v3/README.md +++ b/knowledge/knowledge-base/web10-v3/README.md @@ -30,7 +30,8 @@ web10-v3/ ├── setup/ ← node setup & operator config │ └── node-config.md ← the node_config table, admins model, setup flow, /am_admin gate ├── app-store/ ← the node's public storefront -│ └── overview.md ← registration (a path is an app), visits, PWA manifests, store UI +│ ├── overview.md ← registration (a path is an app), visits, PWA manifests, store UI +│ └── endpoints.md ← the store's endpoint surface: the product page, ratings, stats ├── groups/ ← groups as a platform primitive │ ├── overview.md ← policy containers, roles, join policies │ ├── identity.md ← profiles, URLs, service-scoped roles @@ -60,7 +61,7 @@ web10-v3/ - **Auth & Consent** — `auth/auth.md` (token flow), `auth/consent.md` (consent experience, ideal UX) - **DB** — `db/clickhouse.md` (tables, indexes, patterns) - **Setup & Config** — `setup/node-config.md` (node_config table, admins, /am_admin) -- **App Store** — `app-store/overview.md` (registration, visits, PWA manifests) +- **App Store** — `app-store/overview.md` (registration, visits, PWA manifests), `app-store/endpoints.md` (the endpoint surface: product page, ratings, stats) - **Security** — `security/overview.md` (invariants I1–I5, two-contract model, blocking) - **Groups** — `groups/overview.md` (primitive), `groups/identity.md` (profiles) - **Social** — `social/overview.md` (implementation), `social/cross-app-sharing.md` (patterns) diff --git a/knowledge/knowledge-base/web10-v3/app-store/endpoints.md b/knowledge/knowledge-base/web10-v3/app-store/endpoints.md new file mode 100644 index 00000000..47522bf5 --- /dev/null +++ b/knowledge/knowledge-base/web10-v3/app-store/endpoints.md @@ -0,0 +1,258 @@ +# The App Store's Endpoints + +The store's API surface. `overview.md` covers what an app *is* to the store +(identity, registration, the manifest, the metric). This doc covers how the +store *serves* it: every endpoint the storefront and the product page talk +to, what they return, and what the numbers mean. + +## The Page Is the Product + +A visitor is browsing the grid. A tile catches their eye — a name, an icon, +"128 web10 users · 30d." Before they tap Open, they want three answers: + +1. **What is it?** — the description, the screenshots. +2. **Is it real?** — the numbers. Users, visits, how long it's been around. +3. **What do people say?** — the ratings. + +A store that can't answer those three with data is a directory of links. +The App Store answers them with one page and one endpoint: the **product +page** (`/app-store/app/:id`) is served by `GET /v3/apps/detail`. The page +is a URL — refreshable, shareable, bookmarkable. You can send someone the +link to an app, the way you send someone a link to anything else. That is +the "oh, okay" moment: the store is a real place with real pages, not a +list of buttons. + +The endpoint behind it is the store's own, and it is the center of gravity +of this doc. + +## The Identity Call: the URL Is the Key + +The product page is keyed by the **app's URL** — the same identity +registration uses (D47), in canonical form (D49 hardening #4: lowercase +host, no `www.`, one trailing slash, no query or fragment). +`https://www.web10.app/docs/notes/` *is* the app, and the page for it is +`/app-store/app/{urlencoded-canonical-url}`. + +This retires `web10apps_post_id`. The field was a v2 vestige — the +`#web10apps` discovery ledger, dropped in v3 (overview.md). The v3 `apps` +table never had the column; `list_store_apps` still blanks it to `""` for +the UI; the UI's detail route reads that blank. The result: the card never +links to the page, and the page's fetch hits an endpoint that does not +exist (`PATCH /discover/app/{id}` — a phantom). Every tile opens the site; +every page 404s. + +The root cause is an identity gap: the UI wanted a short ID for the route, +but the store's identity is already the URL. Keying the detail endpoint on +the URL deletes the gap instead of papering over it with a second ID +system. No post IDs, no slugs, no numeric IDs. The URL is the app. + +## The Surface + +All store endpoints live under `/v3/apps`, except the manifest proxy +(`GET /pwa_listing`, system router) and the node stats (`POST /v3/stats`, +system router). + +| Endpoint | Auth | Called by | What it does | +|---|---|---|---| +| `POST /v3/apps/register` | anonymous (token rides along when present) | the SDK, on every `createV3Client()` + on sign-in | registration + usage ping (verified tokens only, 3h gate — D49) | +| `GET /v3/apps/detail?url=` | anonymous | the product page | the whole page: app + full metric breakdown + rating aggregate + rating list + node macro | +| `POST /v3/apps/list` | anonymous | the store grid (paginated) | approved apps + realtime metrics, `users_30d` desc, `visits` tiebreak | +| `POST /v3/apps/rating` | signed | SDK `rateApp()` | upsert a 1–5 star rating, optional comment | +| `POST /v3/apps/ratings` | anonymous | product page, SDK `getAppRatings()` | the rating list for an app | +| `POST /v3/apps/admin` | admin | the node console | every app with approval state + rating aggregate | +| `POST /v3/apps/approve` | admin | the node console | approve / reject | +| `GET /pwa_listing?url=` | anonymous | the store grid, the product page | PWA manifest proxy | +| `POST /v3/stats` | anonymous | the store page, the homepage | the node macro: users, documents, groups, app_count, active_users, storage | + +The auth split is the store's posture: **reads are public, writes are +signed or admin.** The store is a public surface (D41 — the node is +readable by design; discovery, search, auditability). A signed-out visitor +can browse, open a product page, and read every rating. Only *rating* +requires a token — a rating is a user's record, and it carries the author's +name. Only *approval* requires admin. + +### `POST /v3/apps/register` — built + +Anonymous by design (the store is a public surface — a signed-out visitor's +app must be able to register). Body: `{url, name?, description?, +icon_url?, screenshots?, token?}`. What the node does with a ping is the +D49 split — two tables, two concerns: + +- **`apps` — the stable registration record.** First time: insert the row + (`review_state: 'pending'`). Repeat with no metadata change: no-op. + Repeat with new metadata (name/description/icon): a new row, + `metadata_version` bumped. It never grows with traffic. +- **`app_visits` — the usage log.** Only a ping carrying a *verified* + token (I2 — signature checked, never an unsigned decode) produces a row, + and only if the `(app_url, username)` pair was last counted more than 3 + hours ago: **if >3h, insert.** Anon / forged / expired pings are dropped + at ingest — they keep the registration alive but never count as a user. +- The SDK rides the session token along when one exists, and **re-fires + the ping on the sign-in transition** — a user's first counted visit + happens the moment they authenticate, so the metric means "users," not + "returning users." +- Never blocks app init — the SDK fires it and forgets. + +The full model is in overview.md. This doc adds nothing to it. + +### `GET /v3/apps/detail?url=` — the product page, missing + +**The endpoint this doc exists to specify.** The product page was built +against a phantom (`PATCH /discover/app/{id}` — no such route in the +API); this is the real one. It is a pure read — **no visit bump.** A +product-page view is not an app visit; `app_visits` rows come only from +SDK pings carrying a verified token (D49). + +GET, not POST: a pure public read with a query param is the +`/pwa_listing` class — cacheable, no body, no side effect. + +Response: + +```json +{ + "url": "https://www.web10.app/docs/notes/", + "name": "Notes", + "description": "Private notes on your node.", + "icon_url": "https://www.web10.app/docs/notes/icon-192.png", + "screenshots": ["https://..."], + "review_state": "approved", + "registered_at": "2026-07-30T01:29:37Z", + "metrics": { + "visits": 1337, + "users_1d": 4, + "users_30d": 128, + "users_90d": 301, + "users_1y": 512 + }, + "rating": { "average": 4.6, "count": 12 }, + "ratings": [ + { "author": "alice", "rating": 5, "comment": "fast.", "created_at": "2026-08-01T12:00:00Z" } + ], + "node": { + "users": 579, + "app_count": 12, + "active_users": 214, + "storage": 1234567890 + } +} +``` + +- Unknown `url` → 404. The page renders its not-found state. +- The UI prefers the PWA manifest for name and icon (via `/pwa_listing`) + over the stored values — the manifest is the identity source + (overview.md). The endpoint returns what's stored; the page decides what + to show. +- `metrics` is the same set the grid shows — D49's item 5: "the grid card + shows the headline, the app detail page shows the full breakdown." One + call for the whole page, including the node macro, so the page never + makes a second round trip for context. + +**What the numbers mean:** + +| Field | Source | Meaning | +|---|---|---| +| `metrics.visits` | `app_visits` count | The windowed, anon-free rows — sustained activity. Each row is already a 3h-windowed counted session, so this is not raw page loads. | +| `metrics.users_1d` / `users_30d` / `users_90d` / `users_1y` | `app_visits` `countDistinctIf(username, …)` | Distinct real users with a counted visit in the trailing window. Exact (not the approximate `uniq()`), so the numbers a visitor sees are trustworthy. | +| `rating.average` / `rating.count` | `app_ratings` aggregate | One rating per author — the table's dedup key is `(target_app_id, author)`, so re-rating replaces. | +| `ratings[]` | `app_ratings` list | The reviews section: author, stars, comment, date. Newest first. | +| `node.users` / `app_count` / `active_users` / `storage` | `/v3/stats` macro | The node's own numbers, same table and windows as the per-app metrics — consistent by construction. Context for the page, not an app metric. | + +The per-app numbers are **real users by construction** (D49): only the +node mints tokens, at login, so an app grows its numbers only by getting +real logged-in web10 users to use it. The honest scoping the page should +carry: the 3h ingest gate means a power user counts once per 3 hours, and +`users_1y` is a long-tail retention stat, never a headline — the headline +is `users_30d`, the same number the grid sorts by. + +### `POST /v3/apps/rating` — built, gains a comment + +Signed. Body: `{token, body: {target_app_id, rating, comment?}}`. +`target_app_id` is the app's **URL** — the SDK's `rateApp({appId: url})` +already keys on it. `rating` is 1–5. `comment` is optional text. + +**Reviews are a rating with words.** No separate reviews table. The +`app_ratings` table gains a `comment String DEFAULT ''` column (an ALTER, +the way `apps.visits` got its column), the rating endpoint accepts it, and +the ratings list returns it. The dedup semantics carry over untouched: one +voice per user per app, latest wins, no history. The store shows the star +first, the text under it. + +### `POST /v3/apps/ratings` — built, gains a comment + +Anonymous. Body: `{body: {target_app_id}}`. Returns the rating list — +author, rating, comment, created_at — newest first. The product page +renders this as the reviews section; an empty list renders an empty state, +not a gap. + +### `POST /v3/apps/list`, `POST /v3/stats`, `GET /pwa_listing` — built + +The grid's plumbing, documented in overview.md (registration, the +plug-slot filter, the manifest proxy, the metric). The division of labor: + +- **`/v3/apps/list`** is the grid's endpoint — the public store list, + paginated. Body: `{limit = 20, offset = 0, token?}` (the token is + reserved, not required — the store is a public surface). Response: + `{apps: [app + metrics], total}` — approved apps with the realtime + metric set, sorted by `users_30d` desc with a `visits` tiebreak. The + grid pages 20 at a time with a load-more instead of rendering every app. +- **`/v3/stats`** is the node macro — `users`, `documents`, `groups`, + `app_count`, `active_users`, `storage`. The per-app array moved to + `/v3/apps/list` (pagination); the macro is the same metric query minus + the `GROUP BY app_url`, so the homepage's "N web10 users · 30d" and the + per-app numbers are consistent by construction. +- **`/pwa_listing`** serves identity — the manifest proxy. + +### `POST /v3/apps/admin`, `POST /v3/apps/approve` — built + +The operator surface (the node console). The admin list: every app with +approval state, the retired `visits` counter (kept for the admin view), +and the rating aggregate. Approve/reject: the review. Small node, +operator's call (overview.md). + +## The Product Page Flow + +```mermaid +sequenceDiagram + participant V as Visitor + participant S as Store grid (/app-store) + participant P as Product page (/app-store/app/:url) + participant N as Node API + + V->>S: browse + S->>N: POST /v3/stats (the node macro) + S->>N: POST /v3/apps/list {limit, offset} (paginated, users_30d desc) + S->>N: GET /pwa_listing?url= (per app — name + icon) + V->>P: tap a tile (route = /app-store/app/{urlencoded-canonical-url}) + P->>N: GET /v3/apps/detail?url= + N-->>P: app + metrics + rating + ratings + node macro + P->>N: GET /pwa_listing?url= (manifest preferred for identity) + P-->>V: what it is, the numbers, what people say + V->>N: (signed in) POST /v3/apps/rating +``` + +One call for the page. The manifest fetch is the same proxy the grid uses — +the page prefers it for identity and falls back to the stored values. + +## What This Is Not + +- **Not a raw usage log.** `app_visits` is a usage log with an honest + scoping: verified users only (anon dropped at ingest), one row per + (app, user) per 3 hours, no per-session granularity. The store counts + *users*, not *page loads* — the raw ping volume is not a metric the + store shows (D49). +- **Not a review platform.** One rating per author, upsert, no history, no + moderation queue. The operator's approve/reject is the moderation. +- **Not a second ID system.** The URL is the app. Anything that introduces + a parallel identifier — post IDs, slugs, numeric IDs — is a rejection. + D47 made the URL the identity, and every store endpoint keys on it. + +## Logistics + +| Piece | State | +|---|---| +| register (stable record + `app_visits` gate) / list (paginated) / rating / ratings / admin / approve | built (D49) | +| `/pwa_listing` manifest proxy | built | +| `/v3/stats` (the node macro) | built | +| `GET /v3/apps/detail` | **missing** — the product page is built against a phantom endpoint and 404s for every app; this doc is the spec. The metric queries it needs already exist (`get_app_metrics`) — the endpoint composes them | +| `app_ratings.comment` column; comment in rating / ratings / detail | scoped here, not built | +| UI: card → page wiring (URL-keyed route, drop `web10apps_post_id`) | not built — `list_store_apps` still blanks `web10apps_post_id` to `""`, which is why the card opens the site instead of the page today | diff --git a/knowledge/knowledge-base/web10-v3/app-store/overview.md b/knowledge/knowledge-base/web10-v3/app-store/overview.md index 27688682..8de589f2 100644 --- a/knowledge/knowledge-base/web10-v3/app-store/overview.md +++ b/knowledge/knowledge-base/web10-v3/app-store/overview.md @@ -7,7 +7,9 @@ actually use — **sorted by active users, no algorithm, no promotion.** This doc is the v3 model: what an app *is* to the store, how it gets there, how it gets counted, and how it gets shown. The data model (the `apps` and `app_visits` tables) lives in `../db/clickhouse.md`; the SDK surface -(`registerApp`, `getApps`, `rateApp`) in `../sdk/api.md`. +(`registerApp`, `getApps`, `rateApp`) in `../sdk/api.md`; the endpoint +surface — every route the storefront and the product page talk to — in +`endpoints.md`. ## An App Is a URL — Including the Path @@ -223,8 +225,9 @@ Ratings live in `app_ratings` (1–5 stars, per author, per app). ## What This Is Not - **Not a discovery feed.** v2 had a `web10apps` post ledger for social - discovery of apps; v3 drops it (`web10apps_post_id` is a vestigial empty - field). The store is the surface. + discovery of apps; v3 drops it (`web10apps_post_id` is retired — D50; + the URL is the key, and the product page is keyed on it). The store is + the surface. - **Not moderated content.** There is no v2 `pending_on_change` review state machine — an approved app's listing updates on repeat registration, and the operator's approve/reject is the review. Small diff --git a/knowledge/knowledge-base/web10-v3/db/clickhouse.md b/knowledge/knowledge-base/web10-v3/db/clickhouse.md index 84687ba2..44949df2 100644 --- a/knowledge/knowledge-base/web10-v3/db/clickhouse.md +++ b/knowledge/knowledge-base/web10-v3/db/clickhouse.md @@ -113,6 +113,7 @@ erDiagram String target_app_id PK String author PK "UInt8" rating + String comment String provider } @@ -410,13 +411,15 @@ longest metric window is 1y) drop out on merge. ## App Ratings -Star ratings for apps. One per (author, target_app). +Star ratings for apps. One per (author, target_app). A review is a rating +with words — `comment` is optional text (D50). ```sql CREATE TABLE app_ratings ( author String, target_app_id String, rating UInt8, + comment String DEFAULT '', provider String, created_at DateTime64(3), updated_at DateTime64(3), diff --git a/knowledge/strategy/decisions.md b/knowledge/strategy/decisions.md index a9e14971..7e1d20cc 100644 --- a/knowledge/strategy/decisions.md +++ b/knowledge/strategy/decisions.md @@ -9,6 +9,71 @@ Status legend: [decided] intent set · [in-progress] · [open] still debating. --- +### D50 — App store product page: the detail endpoint is keyed by the app's URL; `web10apps_post_id` is retired [decided] +Operator, 26.08.2026 — "in the apps would be cool if there was a little +button that said to see more … it expands, has a deeper paragraph +description from the manifest, and then has ALL the available stats, the +90d users, the visits, the 30d users, you get what i mean, everything in +one screen, and the apps reviews comments kind of thing" — then "clicking +the tile opens this modal or whatever it is" — then "in the knowledge base +we need to talk about how the app store is going to work, it will need its +own endpoints." + +**Decided** — + +1. **The product page is a page, not a modal.** The existing + `/app-store/app/:id` route (PR #426) is the surface. Tap tile → page; + the Open button → launch. Deep-linkable and shareable — the address bar + is part of the product. A modal with no URL state is a review rejection + per the deep-link rule, and it is cramped for "everything in one + screen." +2. **A new `GET /v3/apps/detail?url=` serves the page.** Public, pure + read, one call: app + rating aggregate + rating list + the full + five-metric breakdown + the node macro. **No visit bump** — a + product-page view is not an app visit; `app_visits` rows come only from + SDK pings carrying a verified token (D49). D49's item 5 assigns the + detail page the full breakdown ("the grid card shows the headline, the + app detail page shows the full breakdown") — this spec is what that + line points at. +3. **The URL is the key.** D47 made the full URL the app's identity + (canonical form, D49 hardening #4); the detail endpoint keys on it, and + `web10apps_post_id` is retired (v2 vestige — the `#web10apps` discovery + ledger, dropped in v3; the v3 `apps` table never had the column; + `list_store_apps` still blanks it to `""` for the UI; the UI route + param becomes the URL-encoded canonical URL). +4. **Reviews are a rating with words.** `app_ratings` gains a `comment` + column; one voice per author (dedup key `(target_app_id, author)`), + latest wins, no history. No separate reviews table. +5. **The page shows D49's metric set, not an invention.** Per app: + `visits`, `users_1d`, `users_30d`, `users_90d`, `users_1y` — the same + realtime queries over `app_visits` the grid uses (`get_app_metrics`, + exact `countDistinct`). Node context: the `/v3/stats` macro (users, + app_count, active_users, storage). No consent-based user count + (`app_contracts` holders) — D49's metric set is the store's number, and + consent is a separate trust surface, not a store metric. + +**Why:** the product page was built (PR #426) against a phantom endpoint +(`PATCH /discover/app/{id}` — no such route in the API, so every page 404s +into "App not found") and a blanked ID (`list_store_apps` sets +`web10apps_post_id: ""`, so the card never takes the internal-Link path +and the tile opens the site directly). The root cause is an identity gap: +the UI wanted a short ID for the route, but the store's identity is +already the URL. Keying on the URL deletes the gap instead of papering +over it with a second ID system. + +**Rejected:** a modal (no URL state — review rejection; cramped for the +full stats + reviews layout); a "see more" expander on the card (a third +tap target on a small tile; inline expansion breaks grid row alignment); a +parallel ID for the route (post_id / slug / numeric — D47: the URL is the +identity, a second ID system is drift by construction); a separate +`app_reviews` table (duplicates `app_ratings`' dedup semantics); a +consent-based user count on the page (muddies D49's metric set — consent +≠ usage). + +Full model: `knowledge-base/web10-v3/app-store/endpoints.md`. + +--- + ### D49 — App store metrics: real-user activity, windowed at ingest, computed realtime [decided] Operator, 26.08.2026 — after the app store shipped (D47), the operator stress-tested the visit model: "what if an approved app changes its