@mohasinac/appkit 4.67.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.
Files changed (27) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/seo/sitemap.js +27 -13
  3. package/dist/_internal/server/features/site-settings/adapters.js +27 -0
  4. package/dist/_internal/server/functions/firestore.d.ts +18 -1
  5. package/dist/_internal/server/functions/firestore.js +25 -1
  6. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  7. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  8. package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.d.ts +53 -0
  9. package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.js +63 -0
  10. package/dist/_internal/shared/features/site-settings/types.d.ts +46 -0
  11. package/dist/_internal/shared/listing-types/feature-flags.d.ts +12 -0
  12. package/dist/_internal/shared/listing-types/feature-flags.js +14 -0
  13. package/dist/_internal/shared/listing-types/hidden-surfaces.d.ts +112 -0
  14. package/dist/_internal/shared/listing-types/hidden-surfaces.js +170 -0
  15. package/dist/_internal/shared/listing-types/route-gating.d.ts +67 -25
  16. package/dist/_internal/shared/listing-types/route-gating.js +71 -27
  17. package/dist/features/about/components/HelpPageView.js +19 -1
  18. package/dist/features/admin/schemas/firestore.d.ts +23 -0
  19. package/dist/features/admin/schemas/firestore.js +63 -8
  20. package/dist/features/homepage/components/MarketplaceHomepageView.js +66 -4
  21. package/dist/features/homepage/lib/section-gating.d.ts +84 -0
  22. package/dist/features/homepage/lib/section-gating.js +224 -0
  23. package/dist/features/homepage/lib/slide-gating.d.ts +14 -14
  24. package/dist/features/homepage/lib/slide-gating.js +21 -15
  25. package/dist/features/products/components/CardStoreLine.js +23 -0
  26. package/dist/features/products/components/ProductDetailPageView.js +30 -3
  27. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.67.0",
4
- "head": "a745f11c80fa634f73acc9013502f5bcc17972db",
3
+ "version": "4.69.0",
4
+ "head": "f8d0558f54602d80ef0200435f7a49f8848d5928",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-26T11:54:19.893Z"
7
+ "builtAt": "2026-10-27T12:56:14.309Z"
8
8
  }
@@ -16,8 +16,8 @@ import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
16
16
  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
- import { ALL_BROWSE_PAGES, ALL_LISTING_TYPES, browsePageForPath, isBrowsePageEnabled, isListingSurfaceVisible, } from "../../../shared/listing-types/feature-flags.js";
20
- import { pluginFor } from "../../../shared/listing-types/_registry.js";
19
+ import { ALL_BROWSE_PAGES, browsePageForPath, isBrowsePageEnabled, } from "../../../shared/listing-types/feature-flags.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";
@@ -667,25 +667,39 @@ export async function buildSitemap({ baseUrl }) {
667
667
  * the sitemap rather than being withdrawn by a job that has not run.
668
668
  */
669
669
  const counts = await getSurfaceCounts();
670
- const hiddenSurfacePaths = new Set(ALL_LISTING_TYPES.filter((t) => !isListingSurfaceVisible(t, settings, counts))
671
- .map((t) => String(pluginFor(t).browseRoute ?? ""))
672
- .filter(Boolean));
673
670
  /*
674
- * A route shared by several types survives while ANY of them is visible —
675
- * `/art` spans art and stickers. Without this, hiding stickers alone would
676
- * delist a page art still fills.
671
+ * 🛑 ONE definition, shared with the edge gate. These two loops used to live
672
+ * here and are now `hiddenSurfaceRoutes` — because `toPublicSiteSettings`
673
+ * needs the same answer to tell `src/proxy.ts` which browse routes to 404,
674
+ * and a sitemap that advertises a URL the edge 404s is a crawl error on a
675
+ * page we asked Google to fetch.
677
676
  */
678
- for (const t of ALL_LISTING_TYPES) {
679
- if (isListingSurfaceVisible(t, settings, counts)) {
680
- hiddenSurfacePaths.delete(String(pluginFor(t).browseRoute ?? ""));
681
- }
682
- }
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);
683
695
  const keep = (entry) => {
684
696
  const path = entry.url.startsWith("http")
685
697
  ? new URL(entry.url).pathname
686
698
  : entry.url;
687
699
  if (hiddenSurfacePaths.has(path.replace(/\/+$/, "") || "/"))
688
700
  return false;
701
+ if (isUnderClosedPrefix(path, closedPrefixes))
702
+ return false;
689
703
  if (closed.size === 0)
690
704
  return true;
691
705
  const page = browsePageForPath(path);
@@ -1,3 +1,4 @@
1
+ import { closedSurfacePrefixes, hiddenSurfaceRoutes, } from "../../../shared/listing-types/hidden-surfaces.js";
1
2
  /**
2
3
  * Document fields with a proven `useSiteSettings()` reader. Every entry below
3
4
  * was traced to a call site; anything without one belongs in the private list,
@@ -197,6 +198,32 @@ export function toPublicSiteSettings(doc, opts) {
197
198
  * would take the entire catalogue off the navbar.
198
199
  */
199
200
  surfaceCounts: opts?.surfaceCounts,
201
+ /*
202
+ * Derived from the two fields above it — the admin flags on `doc` and the
203
+ * measured counts in `opts`. Computed here rather than in the proxy because
204
+ * the proxy runs on the EDGE: `hiddenSurfaceRoutes` reaches the listing-type
205
+ * registry, and pulling that into the middleware bundle to re-derive an
206
+ * answer the server already holds would be the wrong trade. The edge gets a
207
+ * list of strings to match, exactly like `disabledRoutes`.
208
+ */
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),
200
227
  // D9 — undefined reads as OFF, never a default-true `??`.
201
228
  chat: { enabled: doc.chat?.enabled === true },
202
229
  /*
@@ -18,6 +18,23 @@ export declare const onPrizeDrawPaymentConfirmed: import("./types").DocumentTrig
18
18
  export declare const prizeDrawSoldOutReveal: import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>;
19
19
  export declare const onReviewWrite: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
20
20
  export declare const onCategoryWrite: import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>;
21
+ /**
22
+ * 🛑 The ONE trigger that invalidates the whole page cache.
23
+ *
24
+ * `documentWritten` and not `documentUpdated`: a settings document that is
25
+ * CREATED on first access (`getSingleton` writes defaults when it is missing)
26
+ * changes the site exactly as much as an edit does.
27
+ *
28
+ * `siteSettings` is a singleton, so the `{settingsId}` wildcard matches one
29
+ * document in practice — it is a wildcard because a trigger path must be, and
30
+ * the handler reads the id from it rather than hardcoding `"global"`.
31
+ *
32
+ * See the handler for why this carries no field-level diff while
33
+ * `onProductWrite` must, and for the measurement that prompted it: a listing
34
+ * type switched off in Firestore while its browse page kept serving a cached
35
+ * 200 because nothing marked it stale.
36
+ */
37
+ export declare const onSiteSettingsWrite: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
21
38
  export declare const onStoreWrite: import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>;
22
39
  export declare const onSupportTicketCreate: import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>;
23
40
  export declare const onSupportTicketUpdate: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
@@ -54,7 +71,7 @@ export declare const FIRESTORE_TRIGGER_FUNCTIONS: readonly [import("./types").Do
54
71
  [x: string]: import("../../..").JsonValue;
55
72
  }, {
56
73
  [x: string]: import("../../..").JsonValue;
57
- }>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot, import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<{
74
+ }>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot, import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<{
58
75
  [x: string]: import("../../..").JsonValue;
59
76
  }, {
60
77
  [x: string]: import("../../..").JsonValue;
@@ -6,7 +6,7 @@
6
6
  * The path patterns mirror the original `functions/src/index.ts` declarations
7
7
  * to preserve identical deploy behavior.
8
8
  */
9
- import { onCategoryWriteHandler, onOrderCreateHandler, onOrderStatusChangeHandler, onProductStockChangeHandler, onProductWriteHandler, onReviewWriteHandler, onScamReportCreateHandler, onScamReportUpdateHandler, onStoreWriteHandler, onSupportTicketCreateHandler, onSupportTicketUpdateHandler, onUserBanChangeHandler, onShipmentItemWriteHandler, onShipmentLotWriteHandler, onShipmentHeaderWriteHandler, onShipmentDeletedHandler, onCatalogueSubmittedForApprovalHandler, onJobCreatedHandler, onPrizeDrawPaymentConfirmedHandler, prizeDrawSoldOutRevealHandler, } from "../jobs/handlers/index.js";
9
+ import { onCategoryWriteHandler, onOrderCreateHandler, onOrderStatusChangeHandler, onProductStockChangeHandler, onProductWriteHandler, onReviewWriteHandler, onScamReportCreateHandler, onScamReportUpdateHandler, onSiteSettingsWriteHandler, onStoreWriteHandler, onSupportTicketCreateHandler, onSupportTicketUpdateHandler, onUserBanChangeHandler, onShipmentItemWriteHandler, onShipmentLotWriteHandler, onShipmentHeaderWriteHandler, onShipmentDeletedHandler, onCatalogueSubmittedForApprovalHandler, onJobCreatedHandler, onPrizeDrawPaymentConfirmedHandler, prizeDrawSoldOutRevealHandler, } from "../jobs/handlers/index.js";
10
10
  import { defineFunction } from "./define.js";
11
11
  const REGION = "asia-south1";
12
12
  export const onOrderCreate = defineFunction({
@@ -65,6 +65,29 @@ export const onCategoryWrite = defineFunction({
65
65
  handler: onCategoryWriteHandler,
66
66
  options: { region: REGION },
67
67
  });
68
+ /**
69
+ * 🛑 The ONE trigger that invalidates the whole page cache.
70
+ *
71
+ * `documentWritten` and not `documentUpdated`: a settings document that is
72
+ * CREATED on first access (`getSingleton` writes defaults when it is missing)
73
+ * changes the site exactly as much as an edit does.
74
+ *
75
+ * `siteSettings` is a singleton, so the `{settingsId}` wildcard matches one
76
+ * document in practice — it is a wildcard because a trigger path must be, and
77
+ * the handler reads the id from it rather than hardcoding `"global"`.
78
+ *
79
+ * See the handler for why this carries no field-level diff while
80
+ * `onProductWrite` must, and for the measurement that prompted it: a listing
81
+ * type switched off in Firestore while its browse page kept serving a cached
82
+ * 200 because nothing marked it stale.
83
+ */
84
+ export const onSiteSettingsWrite = defineFunction({
85
+ name: "onSiteSettingsWrite",
86
+ description: "Invalidate the whole page cache when site settings change.",
87
+ trigger: { kind: "documentWritten", pathPattern: "siteSettings/{settingsId}" },
88
+ handler: onSiteSettingsWriteHandler,
89
+ options: { region: REGION },
90
+ });
68
91
  export const onStoreWrite = defineFunction({
69
92
  name: "onStoreWrite",
70
93
  description: "Side-effects on store writes (status mirror, owner uid index).",
@@ -165,6 +188,7 @@ export const FIRESTORE_TRIGGER_FUNCTIONS = [
165
188
  prizeDrawSoldOutReveal,
166
189
  onReviewWrite,
167
190
  onCategoryWrite,
191
+ onSiteSettingsWrite,
168
192
  onStoreWrite,
169
193
  onSupportTicketCreate,
170
194
  onSupportTicketUpdate,
@@ -45,6 +45,7 @@ export { weeklyPayoutEligibilityHandler } from "./weeklyPayoutEligibility";
45
45
  export { onCategoryWriteHandler } from "./onCategoryWrite";
46
46
  export { onProductWriteHandler } from "./onProductWrite";
47
47
  export { onStoreWriteHandler } from "./onStoreWrite";
48
+ export { onSiteSettingsWriteHandler } from "./onSiteSettingsWrite";
48
49
  export { adminAnalyticsHandler, type AdminAnalyticsResult } from "./adminAnalytics";
49
50
  export { countersReconcileNowHandler, type CountersReconcileNowResult, } from "./countersReconcileNow";
50
51
  export { storeAnalyticsHandler, type StoreAnalyticsInput, type StoreAnalyticsResult, } from "./storeAnalytics";
@@ -45,6 +45,7 @@ export { weeklyPayoutEligibilityHandler } from "./weeklyPayoutEligibility.js";
45
45
  export { onCategoryWriteHandler } from "./onCategoryWrite.js";
46
46
  export { onProductWriteHandler } from "./onProductWrite.js";
47
47
  export { onStoreWriteHandler } from "./onStoreWrite.js";
48
+ export { onSiteSettingsWriteHandler } from "./onSiteSettingsWrite.js";
48
49
  export { adminAnalyticsHandler } from "./adminAnalytics.js";
49
50
  export { countersReconcileNowHandler, } from "./countersReconcileNow.js";
50
51
  export { storeAnalyticsHandler, } from "./storeAnalytics.js";
@@ -0,0 +1,53 @@
1
+ import type { FirestoreTriggerHandler } from "../runtime/types";
2
+ import type { JsonValue } from "@mohasinac/appkit";
3
+ /**
4
+ * A site-settings write invalidates the whole page cache.
5
+ *
6
+ * ## Why this trigger had to exist
7
+ *
8
+ * `siteSettings/global` decides the navbar, the footer, the theme tokens, the
9
+ * guest price gate, every listing-type kill switch and `hiddenSurfacePaths` —
10
+ * i.e. something on every page of the site. Nothing observed writes to it.
11
+ *
12
+ * Measured against production 2026-10-27, which is what prompted this: with
13
+ * `listings.listingTypes.auction` set to `false` in Firestore, `/auctions` kept
14
+ * answering **200** from the CDN with `Age` climbing past its own 60-second
15
+ * `revalidate` window, because no writer ever marked the page stale. The flag
16
+ * was correct and invisible. `revalidateTargetsFor("siteSettings")` returned
17
+ * `["/"]` — the homepage alone — so even the one caller that existed could not
18
+ * have fixed it.
19
+ *
20
+ * ## 🛑 Performs NO Firestore write, by construction
21
+ *
22
+ * Root Cause #92 is this project's record of a trigger writing back to the
23
+ * collection it watched, producing 1.02M invocations/day and taking production
24
+ * down. `notifyRevalidate` performs exactly one `fetch` and touches no
25
+ * document, so this cannot re-trigger itself. If it ever needs to persist
26
+ * anything, it needs a different trigger path — not a write.
27
+ *
28
+ * ## 🛑 No field-level diff, deliberately — and that is the opposite of
29
+ * `onProductWrite`
30
+ *
31
+ * `onProductWrite` guards on `productCacheFieldsChanged` because products are
32
+ * written at MACHINE rate: every checkout stock decrement and every bid fires
33
+ * it, so an unguarded full invalidation there is the ISR-write amplification
34
+ * that got this project suspended with HTTP 402 (Root Cause #104).
35
+ *
36
+ * `siteSettings` is the opposite case. It is written by `PUT /api/admin/site`
37
+ * and two operator scripts and by nothing else — every scheduled job that names
38
+ * it only READS it (measured 2026-10-27). So this fires when a human saves
39
+ * settings, which is rare enough that a diff would add a maintained field list
40
+ * to protect against nothing. A settings save that changed only `updatedAt` is
41
+ * not worth a list that goes stale the next time a group is added.
42
+ *
43
+ * 🛑 If a job is ever given a reason to WRITE `siteSettings`, this guard has to
44
+ * come back before that ships — the amplification shape returns the moment the
45
+ * write rate stops being human.
46
+ *
47
+ * ## Best-effort
48
+ *
49
+ * `notifyRevalidate` swallows and logs every failure. A missed invalidation
50
+ * leaves a stale page until its TTL rolls; failing here would achieve nothing
51
+ * because the settings document is already saved.
52
+ */
53
+ export declare const onSiteSettingsWriteHandler: FirestoreTriggerHandler<Record<string, JsonValue>, Record<string, JsonValue>>;
@@ -0,0 +1,63 @@
1
+ import { notifyRevalidate } from "../core/revalidateNotify.js";
2
+ /**
3
+ * A site-settings write invalidates the whole page cache.
4
+ *
5
+ * ## Why this trigger had to exist
6
+ *
7
+ * `siteSettings/global` decides the navbar, the footer, the theme tokens, the
8
+ * guest price gate, every listing-type kill switch and `hiddenSurfacePaths` —
9
+ * i.e. something on every page of the site. Nothing observed writes to it.
10
+ *
11
+ * Measured against production 2026-10-27, which is what prompted this: with
12
+ * `listings.listingTypes.auction` set to `false` in Firestore, `/auctions` kept
13
+ * answering **200** from the CDN with `Age` climbing past its own 60-second
14
+ * `revalidate` window, because no writer ever marked the page stale. The flag
15
+ * was correct and invisible. `revalidateTargetsFor("siteSettings")` returned
16
+ * `["/"]` — the homepage alone — so even the one caller that existed could not
17
+ * have fixed it.
18
+ *
19
+ * ## 🛑 Performs NO Firestore write, by construction
20
+ *
21
+ * Root Cause #92 is this project's record of a trigger writing back to the
22
+ * collection it watched, producing 1.02M invocations/day and taking production
23
+ * down. `notifyRevalidate` performs exactly one `fetch` and touches no
24
+ * document, so this cannot re-trigger itself. If it ever needs to persist
25
+ * anything, it needs a different trigger path — not a write.
26
+ *
27
+ * ## 🛑 No field-level diff, deliberately — and that is the opposite of
28
+ * `onProductWrite`
29
+ *
30
+ * `onProductWrite` guards on `productCacheFieldsChanged` because products are
31
+ * written at MACHINE rate: every checkout stock decrement and every bid fires
32
+ * it, so an unguarded full invalidation there is the ISR-write amplification
33
+ * that got this project suspended with HTTP 402 (Root Cause #104).
34
+ *
35
+ * `siteSettings` is the opposite case. It is written by `PUT /api/admin/site`
36
+ * and two operator scripts and by nothing else — every scheduled job that names
37
+ * it only READS it (measured 2026-10-27). So this fires when a human saves
38
+ * settings, which is rare enough that a diff would add a maintained field list
39
+ * to protect against nothing. A settings save that changed only `updatedAt` is
40
+ * not worth a list that goes stale the next time a group is added.
41
+ *
42
+ * 🛑 If a job is ever given a reason to WRITE `siteSettings`, this guard has to
43
+ * come back before that ships — the amplification shape returns the moment the
44
+ * write rate stops being human.
45
+ *
46
+ * ## Best-effort
47
+ *
48
+ * `notifyRevalidate` swallows and logs every failure. A missed invalidation
49
+ * leaves a stale page until its TTL rolls; failing here would achieve nothing
50
+ * because the settings document is already saved.
51
+ */
52
+ export const onSiteSettingsWriteHandler = async (event, ctx) => {
53
+ await notifyRevalidate({
54
+ collection: "siteSettings",
55
+ /*
56
+ * The singleton id, from the path params rather than hardcoded "global".
57
+ * The trigger pattern is `siteSettings/{settingsId}` and a second
58
+ * document in that collection would otherwise invalidate under the wrong
59
+ * id — and the endpoint logs this value.
60
+ */
61
+ id: event.params.settingsId,
62
+ }, ctx);
63
+ };
@@ -134,4 +134,50 @@ export interface PublicSiteSettings {
134
134
  listingTypes?: Record<string, number>;
135
135
  surfaces?: Record<string, number>;
136
136
  };
137
+ /**
138
+ * Browse routes that must answer 404 — derived from `listings.listingTypes`
139
+ * and `surfaceCounts` by `hiddenSurfaceRoutes`, not a stored field.
140
+ *
141
+ * 🛑 THIS EXISTS BECAUSE A PAGE-LEVEL `notFound()` CANNOT SET THE STATUS.
142
+ * Every one of these pages already calls `guardListingSurface`, and measured
143
+ * against production on 2026-10-27 all seven rendered the "404 — Page not
144
+ * found" body with HTTP **200**: the layout shell has already streamed by the
145
+ * time the guard throws, so the status is no longer mutable. `/reviews` was
146
+ * the one real 404 on the site, and only because it was in `disabledRoutes`
147
+ * and therefore caught at the edge.
148
+ *
149
+ * So the edge is the only place that can answer correctly, and `src/proxy.ts`
150
+ * consumes this exactly as it consumes `disabledRoutes` — same rewrite, same
151
+ * cached fetch, no extra request. The page guards stay as defence in depth
152
+ * (they are what hides the content if this payload never arrives).
153
+ *
154
+ * 🛑 Always emitted, defaulted to `[]`, for the reason spelled out on
155
+ * `disabledRoutes`: the proxy fails OPEN, so "nothing hidden" and "the field
156
+ * never arrived" are otherwise indistinguishable on the wire.
157
+ */
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[];
137
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
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Which public routes must not be served, given the admin flags and the
3
+ * measured counts.
4
+ *
5
+ * ## Why this is one module and not copies at each reader
6
+ *
7
+ * Two places need these answers and they must never disagree:
8
+ *
9
+ * · `buildSitemap` — decides which URLs are advertised.
10
+ * · `toPublicSiteSettings` → `src/proxy.ts` — decides which URLs 404.
11
+ *
12
+ * A disagreement is not cosmetic: the sitemap advertising a URL the edge 404s
13
+ * is a crawl error on a page we told Google to fetch, and the reverse serves a
14
+ * surface we have decided is closed. They were two hand-written copies of the
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`).
17
+ *
18
+ * ## 🛑 TWO FUNCTIONS, because the MATCH RULE differs — and each rule is
19
+ * load-bearing in the opposite direction
20
+ *
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.
28
+ *
29
+ * ## 🛑 A SHARED route survives while ANY of its types is visible
30
+ *
31
+ * `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
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`.
35
+ *
36
+ * @tag domain:products
37
+ * @tag layer:shared
38
+ * @tag access:isomorphic
39
+ * @tag sideEffects:none
40
+ */
41
+ import { type BrowsePageKey, type SurfaceCountSnapshot } from "./feature-flags";
42
+ /** The snapshot shape `isListingSurfaceVisible` reads its admin flags from. */
43
+ interface SettingsSnapshot {
44
+ listings?: {
45
+ listingTypes?: Partial<Record<string, boolean>>;
46
+ browsePages?: Partial<Record<BrowsePageKey, boolean>>;
47
+ categoryTypes?: Partial<Record<string, boolean>>;
48
+ closedPaths?: string[];
49
+ };
50
+ disabledRoutes?: string[];
51
+ }
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;
112
+ export {};