@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/seo/sitemap.js +27 -13
- package/dist/_internal/server/features/site-settings/adapters.js +27 -0
- package/dist/_internal/server/functions/firestore.d.ts +18 -1
- package/dist/_internal/server/functions/firestore.js +25 -1
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.d.ts +53 -0
- package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.js +63 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +46 -0
- package/dist/_internal/shared/listing-types/feature-flags.d.ts +12 -0
- package/dist/_internal/shared/listing-types/feature-flags.js +14 -0
- package/dist/_internal/shared/listing-types/hidden-surfaces.d.ts +112 -0
- package/dist/_internal/shared/listing-types/hidden-surfaces.js +170 -0
- package/dist/_internal/shared/listing-types/route-gating.d.ts +67 -25
- package/dist/_internal/shared/listing-types/route-gating.js +71 -27
- package/dist/features/about/components/HelpPageView.js +19 -1
- package/dist/features/admin/schemas/firestore.d.ts +23 -0
- package/dist/features/admin/schemas/firestore.js +63 -8
- package/dist/features/homepage/components/MarketplaceHomepageView.js +66 -4
- package/dist/features/homepage/lib/section-gating.d.ts +84 -0
- package/dist/features/homepage/lib/section-gating.js +224 -0
- package/dist/features/homepage/lib/slide-gating.d.ts +14 -14
- package/dist/features/homepage/lib/slide-gating.js +21 -15
- package/dist/features/products/components/CardStoreLine.js +23 -0
- package/dist/features/products/components/ProductDetailPageView.js +30 -3
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.69.0",
|
|
4
|
+
"head": "f8d0558f54602d80ef0200435f7a49f8848d5928",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-
|
|
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,
|
|
20
|
-
import {
|
|
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
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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 {};
|