@mohasinac/appkit 4.68.0 โ†’ 4.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.68.0",
4
- "head": "d9da5b62aa583840cb50fa07288dc03177d1d991",
3
+ "version": "4.69.0",
4
+ "head": "f8d0558f54602d80ef0200435f7a49f8848d5928",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-26T21:11:44.047Z"
7
+ "builtAt": "2026-10-27T12:56:14.309Z"
8
8
  }
@@ -17,7 +17,7 @@ import { PRODUCT_DETAIL_LISTING_TYPES } from "./product-detail-types.js";
17
17
  import { safeRead } from "../../../../errors/safe-read.js";
18
18
  import { siteSettingsRepository } from "../../../../features/admin/repository/site-settings.repository.js";
19
19
  import { ALL_BROWSE_PAGES, browsePageForPath, isBrowsePageEnabled, } from "../../../shared/listing-types/feature-flags.js";
20
- import { hiddenSurfaceRoutes } from "../../../shared/listing-types/hidden-surfaces.js";
20
+ import { closedSurfacePrefixes, hiddenSurfaceRoutes, isUnderClosedPrefix, } from "../../../shared/listing-types/hidden-surfaces.js";
21
21
  import { getSurfaceCounts } from "../products/surface-counts.js";
22
22
  // Product field strings โ€” matches consumer field-names.ts
23
23
  const PRODUCT_STATUS = "status";
@@ -675,12 +675,31 @@ export async function buildSitemap({ baseUrl }) {
675
675
  * page we asked Google to fetch.
676
676
  */
677
677
  const hiddenSurfacePaths = new Set(hiddenSurfaceRoutes(settings, counts));
678
+ /*
679
+ * ๐Ÿ›‘ `disabledRoutes` WAS NEVER APPLIED HERE, and it showed. Measured against
680
+ * the production sitemap on 2026-10-27, with `disabledRoutes: ["/blog",
681
+ * "/reviews"]` live and both paths already 404ing at the edge, the sitemap
682
+ * advertised **17 `/blog` URLs and one `/reviews` URL**.
683
+ *
684
+ * That is 18 crawl errors on pages we explicitly asked Google to fetch โ€” the
685
+ * precise failure the `closed` filter below exists to prevent, on the one
686
+ * axis it did not cover. The browse-page half was handled because
687
+ * `browsePageForPath` is prefix-aware; the hand-disabled half had no reader.
688
+ *
689
+ * `closedSurfacePrefixes` is the SAME function `toPublicSiteSettings` feeds
690
+ * to `src/proxy.ts`, so the list of URLs this withholds and the list the edge
691
+ * 404s are now derived once and cannot drift (the whole reason
692
+ * `hiddenSurfaceRoutes` was extracted in the first place).
693
+ */
694
+ const closedPrefixes = closedSurfacePrefixes(settings);
678
695
  const keep = (entry) => {
679
696
  const path = entry.url.startsWith("http")
680
697
  ? new URL(entry.url).pathname
681
698
  : entry.url;
682
699
  if (hiddenSurfacePaths.has(path.replace(/\/+$/, "") || "/"))
683
700
  return false;
701
+ if (isUnderClosedPrefix(path, closedPrefixes))
702
+ return false;
684
703
  if (closed.size === 0)
685
704
  return true;
686
705
  const page = browsePageForPath(path);
@@ -1,4 +1,4 @@
1
- import { hiddenSurfaceRoutes } from "../../../shared/listing-types/hidden-surfaces.js";
1
+ import { closedSurfacePrefixes, hiddenSurfaceRoutes, } from "../../../shared/listing-types/hidden-surfaces.js";
2
2
  /**
3
3
  * Document fields with a proven `useSiteSettings()` reader. Every entry below
4
4
  * was traced to a call site; anything without one belongs in the private list,
@@ -207,6 +207,23 @@ export function toPublicSiteSettings(doc, opts) {
207
207
  * list of strings to match, exactly like `disabledRoutes`.
208
208
  */
209
209
  hiddenSurfacePaths: hiddenSurfaceRoutes(doc, opts?.surfaceCounts),
210
+ /*
211
+ * The PREFIX half of the same answer โ€” closed browse pages, plus the
212
+ * `disabledRoutes` the line above already carries.
213
+ *
214
+ * ๐Ÿ›‘ A SEPARATE FIELD rather than more entries in `hiddenSurfacePaths`,
215
+ * because the proxy applies the two with different match rules and merging
216
+ * them would force one rule on both. Exact-only is load-bearing for the
217
+ * listing-type list (`art`/`spares`/`standard` share `/products/{slug}`);
218
+ * prefix is load-bearing here, because closing `/categories` while serving
219
+ * all 4,698 `/categories/{slug}` pages is what production was doing.
220
+ *
221
+ * Overlaps `disabledRoutes` on purpose. The proxy unions both into one
222
+ * prefix set, so a duplicate is free โ€” and deriving the whole prefix list
223
+ * in ONE place is what stops the sitemap and the edge disagreeing, which
224
+ * is the only reason `hiddenSurfaceRoutes` was extracted.
225
+ */
226
+ hiddenSurfacePrefixes: closedSurfacePrefixes(doc),
210
227
  // D9 โ€” undefined reads as OFF, never a default-true `??`.
211
228
  chat: { enabled: doc.chat?.enabled === true },
212
229
  /*
@@ -156,4 +156,28 @@ export interface PublicSiteSettings {
156
156
  * never arrived" are otherwise indistinguishable on the wire.
157
157
  */
158
158
  hiddenSurfacePaths: string[];
159
+ /**
160
+ * Closed routes that take their DESCENDANTS with them โ€” closed browse pages
161
+ * (`/categories`, `/brands`, `/franchise`, `/stores`, `/sellers`) plus
162
+ * `disabledRoutes`. Derived by `closedSurfacePrefixes`, not stored.
163
+ *
164
+ * ๐Ÿ›‘ SEPARATE FROM `hiddenSurfacePaths` because the two carry different
165
+ * MATCH RULES, and each rule is load-bearing in opposite directions. Exact
166
+ * matching is required above (`art`/`stickers`/`spares`/`standard` all detail
167
+ * at `/products/{slug}`, so a prefix rule would 404 the whole standard
168
+ * catalogue the day `spares` emptied). Prefix matching is required here: all
169
+ * four browse pages had been closed since 2026-10-24 and production still
170
+ * served `/categories/category-beyblade-x` and `/stores/store-beyblade-arena`
171
+ * as real, indexable 200s. A closed section whose 4,698 detail pages stay
172
+ * open is not closed.
173
+ *
174
+ * Overlaps `disabledRoutes` deliberately โ€” the proxy unions both into one
175
+ * prefix set, and deriving the full list once is what stops the sitemap and
176
+ * the edge disagreeing about which URLs exist.
177
+ *
178
+ * ๐Ÿ›‘ Always emitted, defaulted to `[]`, for the same reason as the two lists
179
+ * above it: the proxy fails OPEN, so "nothing closed" and "the field never
180
+ * arrived" are otherwise indistinguishable on the wire.
181
+ */
182
+ hiddenSurfacePrefixes: string[];
159
183
  }
@@ -65,6 +65,18 @@ export declare function isBrowsePageEnabled(page: BrowsePageKey, settings: Featu
65
65
  export declare function enabledBrowsePages(settings: FeatureFlagSnapshot | null | undefined): BrowsePageKey[];
66
66
  /** Narrow an untrusted string โ€” a route param, a form value โ€” to the union. */
67
67
  export declare function isBrowsePageKey(value: unknown): value is BrowsePageKey;
68
+ /**
69
+ * The path prefixes a given section owns โ€” the inverse of `browsePageForPath`.
70
+ *
71
+ * ๐Ÿ›‘ Exported so `closedSurfacePrefixes` can build the edge-gate list from the
72
+ * SAME table `browsePageForPath` matches against. It is one key to five paths
73
+ * (`stores` owns both `/stores` and `/sellers`), so a caller that re-derived
74
+ * "the path for a key" from `ROUTES` would get four entries and leave
75
+ * `/sellers` served โ€” the exact half-closed door the `BrowsePageKey` docstring
76
+ * above exists to forbid. Root Cause #61: every hand-written second copy of an
77
+ * enumeration in this codebase has drifted at least once.
78
+ */
79
+ export declare function browsePagePrefixes(page: BrowsePageKey): string[];
68
80
  /**
69
81
  * Which browse section a link points into, or `null` for a link that is not in
70
82
  * one. Used to drop nav, sidebar, mobile-sheet and footer entries for a closed
@@ -66,6 +66,20 @@ const BROWSE_PAGE_PREFIXES = [
66
66
  [String(ROUTES.PUBLIC.STORES), "stores"],
67
67
  [String(ROUTES.PUBLIC.SELLERS), "stores"],
68
68
  ];
69
+ /**
70
+ * The path prefixes a given section owns โ€” the inverse of `browsePageForPath`.
71
+ *
72
+ * ๐Ÿ›‘ Exported so `closedSurfacePrefixes` can build the edge-gate list from the
73
+ * SAME table `browsePageForPath` matches against. It is one key to five paths
74
+ * (`stores` owns both `/stores` and `/sellers`), so a caller that re-derived
75
+ * "the path for a key" from `ROUTES` would get four entries and leave
76
+ * `/sellers` served โ€” the exact half-closed door the `BrowsePageKey` docstring
77
+ * above exists to forbid. Root Cause #61: every hand-written second copy of an
78
+ * enumeration in this codebase has drifted at least once.
79
+ */
80
+ export function browsePagePrefixes(page) {
81
+ return BROWSE_PAGE_PREFIXES.filter(([, key]) => key === page).map(([prefix]) => prefix);
82
+ }
69
83
  /**
70
84
  * Which browse section a link points into, or `null` for a link that is not in
71
85
  * one. Used to drop nav, sidebar, mobile-sheet and footer entries for a closed
@@ -1,43 +1,112 @@
1
1
  /**
2
- * Which public BROWSE ROUTES must not be served, given the admin flags and the
2
+ * Which public routes must not be served, given the admin flags and the
3
3
  * measured counts.
4
4
  *
5
- * ## Why this is one function and not two copies
5
+ * ## Why this is one module and not copies at each reader
6
6
  *
7
- * Two places need this answer and they must never disagree:
7
+ * Two places need these answers and they must never disagree:
8
8
  *
9
- * ยท `buildSitemap` โ€” decides which browse URLs are advertised.
10
- * ยท `toPublicSiteSettings` โ†’ `src/proxy.ts` โ€” decides which browse URLs 404.
9
+ * ยท `buildSitemap` โ€” decides which URLs are advertised.
10
+ * ยท `toPublicSiteSettings` โ†’ `src/proxy.ts` โ€” decides which URLs 404.
11
11
  *
12
12
  * A disagreement is not cosmetic: the sitemap advertising a URL the edge 404s
13
13
  * is a crawl error on a page we told Google to fetch, and the reverse serves a
14
14
  * surface we have decided is closed. They were two hand-written copies of the
15
- * same two loops when this was extracted.
15
+ * same two loops when this was extracted โ€” and the disagreement showed up
16
+ * anyway on the one axis only one of them read (see `closedSurfacePrefixes`).
16
17
  *
17
- * ## ๐Ÿ›‘ EXACT PATHS, never prefixes
18
+ * ## ๐Ÿ›‘ TWO FUNCTIONS, because the MATCH RULE differs โ€” and each rule is
19
+ * load-bearing in the opposite direction
18
20
  *
19
- * `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`,
20
- * so a prefix rule would delist โ€” or 404 โ€” the entire standard catalogue the
21
- * day `spares` emptied. An empty type contributes no detail URLs anyway, since
22
- * its own fetcher returns none, so the exact-path rule loses nothing real.
21
+ * | | rule | why the other rule would break it |
22
+ * |---|---|---|
23
+ * | `hiddenSurfaceRoutes` โ€” listing-type browse pages | **exact path** | `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`, so a prefix rule would 404 the whole standard catalogue the day `spares` emptied |
24
+ * | `closedSurfacePrefixes` โ€” browse sections, bundles, `disabledRoutes` | **prefix** | `/categories/{slug}` is owned by `categories` alone, and exact-only matching left all 4,698 of them serving a real 200 while `/categories` said 404 |
25
+ *
26
+ * Do not merge them. Picking either rule for both is a live defect one way or
27
+ * the other, and both directions have now been observed in production.
23
28
  *
24
29
  * ## ๐Ÿ›‘ A SHARED route survives while ANY of its types is visible
25
30
  *
26
31
  * `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
27
- * others. Without the second pass, hiding `stickers` alone would close a page
28
- * `art` still fills โ€” and hiding `spares` alone would close `/products`.
32
+ * others. Without `hiddenSurfaceRoutes`' second pass, hiding `stickers` alone
33
+ * would close a page `art` still fills โ€” and hiding `spares` alone would close
34
+ * `/products`.
29
35
  *
30
36
  * @tag domain:products
31
37
  * @tag layer:shared
32
38
  * @tag access:isomorphic
33
39
  * @tag sideEffects:none
34
40
  */
35
- import { type SurfaceCountSnapshot } from "./feature-flags";
41
+ import { type BrowsePageKey, type SurfaceCountSnapshot } from "./feature-flags";
36
42
  /** The snapshot shape `isListingSurfaceVisible` reads its admin flags from. */
37
43
  interface SettingsSnapshot {
38
44
  listings?: {
39
45
  listingTypes?: Partial<Record<string, boolean>>;
46
+ browsePages?: Partial<Record<BrowsePageKey, boolean>>;
47
+ categoryTypes?: Partial<Record<string, boolean>>;
48
+ closedPaths?: string[];
40
49
  };
50
+ disabledRoutes?: string[];
41
51
  }
42
52
  export declare function hiddenSurfaceRoutes(settings: SettingsSnapshot | null | undefined, counts: SurfaceCountSnapshot | null | undefined): string[];
53
+ /**
54
+ * Closed routes that must take their DESCENDANTS with them.
55
+ *
56
+ * ## Why this is a second function and not more entries in the first
57
+ *
58
+ * `hiddenSurfaceRoutes` above is exact-path-only, and its docstring explains
59
+ * why it has to be: `art`, `stickers`, `spares` and `standard` all detail at
60
+ * `/products/{slug}`, so a prefix rule there would 404 the whole standard
61
+ * catalogue the day `spares` emptied.
62
+ *
63
+ * A closed BROWSE PAGE has the opposite property. `/categories/{slug}` belongs
64
+ * to `categories` and to nothing else โ€” no open surface details underneath it
65
+ * โ€” so there is no shared-route hazard, and exact-only matching leaves the
66
+ * interesting half of the section served.
67
+ *
68
+ * ๐Ÿ›‘ THIS WAS MEASURED OPEN IN PRODUCTION, 2026-10-27. All four browse pages
69
+ * had been `false` in `listings.browsePages` since 2026-10-24 and:
70
+ *
71
+ * ```
72
+ * /categories 200 body = "404 โ€” Page not found"
73
+ * /categories/category-beyblade-x 200 a real, fully-rendered category page
74
+ * /stores/store-beyblade-arena 200 a real, fully-rendered store page
75
+ * ```
76
+ *
77
+ * The index was a SOFT 404 (`guardBrowsePage` throws `notFound()` after the
78
+ * shell has streamed, which cannot set the status โ€” see the site-wide soft-404
79
+ * note) and the 4,698 detail pages were never guarded at all. So "the section
80
+ * is closed" meant "the front door says 404 while every room stays open and
81
+ * indexable".
82
+ *
83
+ * ## `disabledRoutes` is folded in here, not left to the proxy
84
+ *
85
+ * The proxy already prefix-matches `disabledRoutes`, so including it changes
86
+ * nothing at the edge. It is here because the SITEMAP needs the same list and
87
+ * did not have it: measured the same day, the sitemap advertised 17 `/blog`
88
+ * URLs and one `/reviews` URL, every one of which the edge already 404s. That
89
+ * is a crawl error on a page we asked Google to fetch โ€” Root Cause #81's
90
+ * host/sitemap disagreement in miniature, and the precise reason
91
+ * `hiddenSurfaceRoutes` was extracted into one definition in the first place.
92
+ *
93
+ * One list, two readers, no way for them to disagree:
94
+ * ยท `toPublicSiteSettings` โ†’ `src/proxy.ts` โ€” which paths 404.
95
+ * ยท `buildSitemap` โ€” which paths are advertised.
96
+ *
97
+ * Fails OPEN on a missing settings document: every `isBrowsePageEnabled` call
98
+ * returns `true` and the list is empty, so a transient Firestore error cannot
99
+ * withdraw the catalogue (Root Cause #72).
100
+ */
101
+ export declare function closedSurfacePrefixes(settings: SettingsSnapshot | null | undefined): string[];
102
+ /**
103
+ * Does `path` sit on or under one of `prefixes`?
104
+ *
105
+ * ๐Ÿ›‘ A `/`-delimited boundary, never a bare `startsWith`. `/store` is the
106
+ * SELLER DASHBOARD and `/stores` is the public directory, so a substring test
107
+ * would 404 a signed-in seller's own dashboard the moment an admin closed the
108
+ * public storefronts โ€” a different surface, a different audience, one
109
+ * character apart. Same rule, same reason, as `browsePageForPath`.
110
+ */
111
+ export declare function isUnderClosedPrefix(path: string, prefixes: readonly string[]): boolean;
43
112
  export {};
@@ -1,39 +1,46 @@
1
1
  /**
2
- * Which public BROWSE ROUTES must not be served, given the admin flags and the
2
+ * Which public routes must not be served, given the admin flags and the
3
3
  * measured counts.
4
4
  *
5
- * ## Why this is one function and not two copies
5
+ * ## Why this is one module and not copies at each reader
6
6
  *
7
- * Two places need this answer and they must never disagree:
7
+ * Two places need these answers and they must never disagree:
8
8
  *
9
- * ยท `buildSitemap` โ€” decides which browse URLs are advertised.
10
- * ยท `toPublicSiteSettings` โ†’ `src/proxy.ts` โ€” decides which browse URLs 404.
9
+ * ยท `buildSitemap` โ€” decides which URLs are advertised.
10
+ * ยท `toPublicSiteSettings` โ†’ `src/proxy.ts` โ€” decides which URLs 404.
11
11
  *
12
12
  * A disagreement is not cosmetic: the sitemap advertising a URL the edge 404s
13
13
  * is a crawl error on a page we told Google to fetch, and the reverse serves a
14
14
  * surface we have decided is closed. They were two hand-written copies of the
15
- * same two loops when this was extracted.
15
+ * same two loops when this was extracted โ€” and the disagreement showed up
16
+ * anyway on the one axis only one of them read (see `closedSurfacePrefixes`).
16
17
  *
17
- * ## ๐Ÿ›‘ EXACT PATHS, never prefixes
18
+ * ## ๐Ÿ›‘ TWO FUNCTIONS, because the MATCH RULE differs โ€” and each rule is
19
+ * load-bearing in the opposite direction
18
20
  *
19
- * `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`,
20
- * so a prefix rule would delist โ€” or 404 โ€” the entire standard catalogue the
21
- * day `spares` emptied. An empty type contributes no detail URLs anyway, since
22
- * its own fetcher returns none, so the exact-path rule loses nothing real.
21
+ * | | rule | why the other rule would break it |
22
+ * |---|---|---|
23
+ * | `hiddenSurfaceRoutes` โ€” listing-type browse pages | **exact path** | `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`, so a prefix rule would 404 the whole standard catalogue the day `spares` emptied |
24
+ * | `closedSurfacePrefixes` โ€” browse sections, bundles, `disabledRoutes` | **prefix** | `/categories/{slug}` is owned by `categories` alone, and exact-only matching left all 4,698 of them serving a real 200 while `/categories` said 404 |
25
+ *
26
+ * Do not merge them. Picking either rule for both is a live defect one way or
27
+ * the other, and both directions have now been observed in production.
23
28
  *
24
29
  * ## ๐Ÿ›‘ A SHARED route survives while ANY of its types is visible
25
30
  *
26
31
  * `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
27
- * others. Without the second pass, hiding `stickers` alone would close a page
28
- * `art` still fills โ€” and hiding `spares` alone would close `/products`.
32
+ * others. Without `hiddenSurfaceRoutes`' second pass, hiding `stickers` alone
33
+ * would close a page `art` still fills โ€” and hiding `spares` alone would close
34
+ * `/products`.
29
35
  *
30
36
  * @tag domain:products
31
37
  * @tag layer:shared
32
38
  * @tag access:isomorphic
33
39
  * @tag sideEffects:none
34
40
  */
35
- import { ALL_LISTING_TYPES, isListingSurfaceVisible, } from "./feature-flags.js";
41
+ import { ALL_BROWSE_PAGES, ALL_LISTING_TYPES, browsePagePrefixes, isBrowsePageEnabled, isCategoryTypeEnabled, isListingSurfaceVisible, } from "./feature-flags.js";
36
42
  import { pluginFor } from "./_registry.js";
43
+ import { ROUTES } from "../../../next/routing/route-map.js";
37
44
  export function hiddenSurfaceRoutes(settings, counts) {
38
45
  const hidden = new Set();
39
46
  const routeOf = (type) => String(pluginFor(type).browseRoute ?? "");
@@ -55,3 +62,109 @@ export function hiddenSurfaceRoutes(settings, counts) {
55
62
  }
56
63
  return [...hidden];
57
64
  }
65
+ /**
66
+ * Closed routes that must take their DESCENDANTS with them.
67
+ *
68
+ * ## Why this is a second function and not more entries in the first
69
+ *
70
+ * `hiddenSurfaceRoutes` above is exact-path-only, and its docstring explains
71
+ * why it has to be: `art`, `stickers`, `spares` and `standard` all detail at
72
+ * `/products/{slug}`, so a prefix rule there would 404 the whole standard
73
+ * catalogue the day `spares` emptied.
74
+ *
75
+ * A closed BROWSE PAGE has the opposite property. `/categories/{slug}` belongs
76
+ * to `categories` and to nothing else โ€” no open surface details underneath it
77
+ * โ€” so there is no shared-route hazard, and exact-only matching leaves the
78
+ * interesting half of the section served.
79
+ *
80
+ * ๐Ÿ›‘ THIS WAS MEASURED OPEN IN PRODUCTION, 2026-10-27. All four browse pages
81
+ * had been `false` in `listings.browsePages` since 2026-10-24 and:
82
+ *
83
+ * ```
84
+ * /categories 200 body = "404 โ€” Page not found"
85
+ * /categories/category-beyblade-x 200 a real, fully-rendered category page
86
+ * /stores/store-beyblade-arena 200 a real, fully-rendered store page
87
+ * ```
88
+ *
89
+ * The index was a SOFT 404 (`guardBrowsePage` throws `notFound()` after the
90
+ * shell has streamed, which cannot set the status โ€” see the site-wide soft-404
91
+ * note) and the 4,698 detail pages were never guarded at all. So "the section
92
+ * is closed" meant "the front door says 404 while every room stays open and
93
+ * indexable".
94
+ *
95
+ * ## `disabledRoutes` is folded in here, not left to the proxy
96
+ *
97
+ * The proxy already prefix-matches `disabledRoutes`, so including it changes
98
+ * nothing at the edge. It is here because the SITEMAP needs the same list and
99
+ * did not have it: measured the same day, the sitemap advertised 17 `/blog`
100
+ * URLs and one `/reviews` URL, every one of which the edge already 404s. That
101
+ * is a crawl error on a page we asked Google to fetch โ€” Root Cause #81's
102
+ * host/sitemap disagreement in miniature, and the precise reason
103
+ * `hiddenSurfaceRoutes` was extracted into one definition in the first place.
104
+ *
105
+ * One list, two readers, no way for them to disagree:
106
+ * ยท `toPublicSiteSettings` โ†’ `src/proxy.ts` โ€” which paths 404.
107
+ * ยท `buildSitemap` โ€” which paths are advertised.
108
+ *
109
+ * Fails OPEN on a missing settings document: every `isBrowsePageEnabled` call
110
+ * returns `true` and the list is empty, so a transient Firestore error cannot
111
+ * withdraw the catalogue (Root Cause #72).
112
+ */
113
+ export function closedSurfacePrefixes(settings) {
114
+ const closed = new Set();
115
+ for (const page of ALL_BROWSE_PAGES) {
116
+ if (isBrowsePageEnabled(page, settings))
117
+ continue;
118
+ for (const prefix of browsePagePrefixes(page)) {
119
+ if (prefix)
120
+ closed.add(prefix);
121
+ }
122
+ }
123
+ /*
124
+ * Bundles โ€” a CATEGORY type, not a listing type and not a browse page, so it
125
+ * reaches neither loop above.
126
+ *
127
+ * `listings.categoryTypes.bundle` is the control that already exists for it,
128
+ * and `HOMEPAGE_SECTION_CATEGORY_TYPE` in `MarketplaceHomepageView` already
129
+ * drops the bundles rail from it โ€” so until this line the flag hid the rail
130
+ * while `/bundles` and every `/bundles/{slug}` went on serving. That is the
131
+ * same half-closed door the browse pages had.
132
+ *
133
+ * Prefix rather than exact for the same reason as a browse page: `/bundles`
134
+ * is owned by bundles alone, with no open surface detailing underneath it.
135
+ */
136
+ if (!isCategoryTypeEnabled("bundle", settings)) {
137
+ closed.add(String(ROUTES.PUBLIC.BUNDLES));
138
+ }
139
+ /*
140
+ * Admin-set feature withdrawals โ€” `/seller-guide`, `/fees`, `/scams`, โ€ฆ
141
+ *
142
+ * ๐Ÿ›‘ A SEPARATE FIELD from `disabledRoutes` below, not a duplicate of it.
143
+ * That array is DERIVED from `navConfig` and fully recomputed on every nav
144
+ * toggle, so a non-nav path appended there is wiped by the next admin save.
145
+ * Anything footer-only โ€” which is most of a marketplace's explainer
146
+ * surfaces โ€” has no nav item and can only live here.
147
+ */
148
+ for (const path of settings?.listings?.closedPaths ?? []) {
149
+ if (typeof path === "string" && path.startsWith("/"))
150
+ closed.add(path);
151
+ }
152
+ for (const route of settings?.disabledRoutes ?? []) {
153
+ if (typeof route === "string" && route.startsWith("/"))
154
+ closed.add(route);
155
+ }
156
+ return [...closed];
157
+ }
158
+ /**
159
+ * Does `path` sit on or under one of `prefixes`?
160
+ *
161
+ * ๐Ÿ›‘ A `/`-delimited boundary, never a bare `startsWith`. `/store` is the
162
+ * SELLER DASHBOARD and `/stores` is the public directory, so a substring test
163
+ * would 404 a signed-in seller's own dashboard the moment an admin closed the
164
+ * public storefronts โ€” a different surface, a different audience, one
165
+ * character apart. Same rule, same reason, as `browsePageForPath`.
166
+ */
167
+ export function isUnderClosedPrefix(path, prefixes) {
168
+ const normalised = path.replace(/\/+$/, "") || "/";
169
+ return prefixes.some((p) => normalised === p || normalised.startsWith(`${p}/`));
170
+ }
@@ -17,40 +17,82 @@
17
17
  * type-keyed gate could see them โ€” the only thing that identifies them is
18
18
  * where they point.
19
19
  *
20
- * ## The rule, and the one case that makes it non-obvious
20
+ * ## Where the rules are written down
21
21
  *
22
- * A href is disabled when EVERY listing type that claims its route is
23
- * disabled. Not "any" โ€” `art` and `stickers` share `/art`, so a site that
24
- * sells art but not stickers must keep `/art` reachable. Treating one disabled
25
- * claimant as enough would silently delete a working destination.
22
+ * On `isClosedHref` below โ€” the three axes it reads (listing-type flags ANDed
23
+ * with measured counts, closed browse sections, hand-disabled routes), what it
24
+ * deliberately does not match, and the production measurement that forced each
25
+ * one. Stated there and not restated here deliberately: two comments
26
+ * disagreeing about what a gate matches is a defect this codebase keeps paying
27
+ * for, and the only safe number of descriptions is one.
26
28
  *
27
- * ## What it deliberately does NOT match
28
- *
29
- * Editorial pages that merely have a type's name in the path โ€” `/help/auctions`,
30
- * `/how-auctions-work`, `/seller-guide/prize-draws`. None of them starts with
31
- * `/auctions`, so prefix matching excludes them for free, and that is correct:
32
- * an explainer about auctions is still honest reading when auctions are off.
33
- * A substring test would have eaten all three.
34
- *
35
- * Store tabs (`/stores/{slug}/auctions`) are also out of scope โ€” those are
36
- * resolved by `resolveStoreTabs`, which owns its own visibility, and a second
37
- * owner is how a mirror drifts (Root Cause #42).
29
+ * Store tabs (`/stores/{slug}/auctions`) are out of scope โ€” those are resolved
30
+ * by `resolveStoreTabs`, which owns its own visibility, and a second owner is
31
+ * how a mirror drifts (Root Cause #42).
38
32
  */
33
+ import { type BrowsePageKey, type SurfaceCountSnapshot } from "./feature-flags";
39
34
  import type { ListingType } from "../../../features/products/types/index";
40
35
  interface ListingFlagSnapshot {
41
36
  listings?: {
42
37
  listingTypes?: Partial<Record<ListingType, boolean>>;
38
+ browsePages?: Partial<Record<BrowsePageKey, boolean>>;
43
39
  };
40
+ disabledRoutes?: string[];
44
41
  }
45
42
  /**
46
- * `true` when `href` points at a listing type's browse page (or any page
47
- * beneath it) and every type that browses there is disabled.
48
- *
49
- * Fails OPEN on anything it cannot classify: an unknown path, an external URL
50
- * or a missing settings document all return `false`. A settings read that
51
- * errored must not strip working destinations out of the homepage โ€” the same
52
- * reasoning as the suggestions endpoint's TTL cache and `src/proxy.ts`'s
53
- * disabled-routes gate.
43
+ * Everything needed to answer "is this destination closed", resolved once.
44
+ *
45
+ * ๐Ÿ›‘ A CONTEXT RATHER THAN THREE LOOSE ARGUMENTS, because the prefix list is
46
+ * derived work and the callers are loops. The live homepage carries **34 links
47
+ * to `/categories` alone** (measured 2026-10-27); rebuilding the closed-prefix
48
+ * array inside the predicate would rebuild it 34 times for one render.
49
+ */
50
+ export interface ClosedSurfaceContext {
51
+ settings: ListingFlagSnapshot | null | undefined;
52
+ counts: SurfaceCountSnapshot | null | undefined;
53
+ /** Closed browse sections + `disabledRoutes`, pre-derived. */
54
+ prefixes: string[];
55
+ }
56
+ export declare function closedSurfaceContext(settings: ListingFlagSnapshot | null | undefined, counts?: SurfaceCountSnapshot | null): ClosedSurfaceContext;
57
+ /**
58
+ * `true` when `href` leads somewhere this site has closed.
59
+ *
60
+ * ## THREE axes, because authored content can point at any of them
61
+ *
62
+ * This replaced `isDisabledListingHref`, which read `listings.listingTypes`
63
+ * and nothing else โ€” and the two axes it could not see were the two doing
64
+ * visible damage. Measured on the live homepage, 2026-10-27:
65
+ *
66
+ * ยท **counts.** `art` and `spares` had flag `true` and measured count `0`,
67
+ * so they were auto-hidden everywhere โ€” `/art` and `/spares` both answered
68
+ * a real **404** at the edge โ€” while the homepage went on shipping 3 links
69
+ * to each from the "Shop extras" cards. `isListingTypeEnabled` reads the
70
+ * flag only, so it called both of them enabled. `isListingSurfaceVisible`
71
+ * ANDs the flag with the count, which is the rule every other surface on
72
+ * the site already uses.
73
+ * ยท **browse sections.** All four had been `false` since 2026-10-24, and the
74
+ * homepage carried **34** `/categories` links across the welcome hero's
75
+ * secondary CTA, the 35-button systems matrix and the 4-banner generation
76
+ * promo grid.
77
+ *
78
+ * ## Deliberately link-based, never keyword-based
79
+ *
80
+ * Unchanged from the original, and still the point: nothing here reads copy.
81
+ * "Win it at your price" names no type, so a word search would miss it, and it
82
+ * would also eat the authenticity slide for saying "bid" in passing. A
83
+ * destination is checkable; prose is not.
84
+ *
85
+ * ## What it still does NOT match
86
+ *
87
+ * Editorial pages that merely carry a name in the path โ€” `/help/auctions`,
88
+ * `/how-auctions-work`, `/seller-guide/prize-draws`. None starts with
89
+ * `/auctions`, so the `/`-delimited boundary excludes them for free, and that
90
+ * is correct: an explainer about auctions is honest reading when auctions are
91
+ * off.
92
+ *
93
+ * Fails OPEN on anything it cannot classify โ€” an unknown path, an external
94
+ * URL, a missing settings document. A settings read that errored must not
95
+ * strip working destinations off the homepage (Root Cause #72).
54
96
  */
55
- export declare function isDisabledListingHref(href: string | undefined | null, settings: ListingFlagSnapshot | null | undefined): boolean;
97
+ export declare function isClosedHref(href: string | undefined | null, ctx: ClosedSurfaceContext): boolean;
56
98
  export {};