@mohasinac/appkit 4.30.16 → 4.30.18

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 (98) hide show
  1. package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +27 -2
  2. package/dist/_internal/shared/types/action-error-reporter.d.ts +40 -0
  3. package/dist/_internal/shared/types/action-error-reporter.js +27 -0
  4. package/dist/_internal/shared/types/action-result.d.ts +10 -0
  5. package/dist/_internal/shared/types/action-result.js +26 -0
  6. package/dist/client/api/surface-error.d.ts +6 -4
  7. package/dist/client/api/surface-error.js +19 -8
  8. package/dist/contracts/realtime-session.d.ts +27 -0
  9. package/dist/contracts/realtime-session.js +118 -0
  10. package/dist/core/unit-of-work.js +21 -13
  11. package/dist/errors/base-error.js +14 -1
  12. package/dist/errors/database-error.js +11 -1
  13. package/dist/errors/describe-cause.d.ts +5 -0
  14. package/dist/errors/describe-cause.js +76 -0
  15. package/dist/errors/error-display-map.d.ts +32 -1
  16. package/dist/errors/error-display-map.js +46 -0
  17. package/dist/errors/error-mapping.d.ts +47 -7
  18. package/dist/errors/error-mapping.js +46 -5
  19. package/dist/errors/index.d.ts +1 -0
  20. package/dist/errors/index.js +4 -0
  21. package/dist/features/about/components/PublicProfileView.js +4 -4
  22. package/dist/features/account/components/AddressBook.js +2 -2
  23. package/dist/features/account/components/AddressesIndexListing.js +2 -2
  24. package/dist/features/admin/components/AdminAnalyticsView.js +2 -2
  25. package/dist/features/admin/components/AdminCouponsView.js +2 -2
  26. package/dist/features/admin/components/AdminGuideHubView.js +2 -2
  27. package/dist/features/admin/components/AdminMediaView.js +2 -2
  28. package/dist/features/admin/components/AdminViewCards.js +2 -2
  29. package/dist/features/auctions/components/AuctionDetailPageView.js +1 -1
  30. package/dist/features/auctions/components/MarketplaceAuctionGrid.d.ts +1 -2
  31. package/dist/features/auctions/components/MarketplaceAuctionGrid.js +8 -7
  32. package/dist/features/auctions/components/PlaceBidFormClient.js +32 -15
  33. package/dist/features/before-after/components/BeforeAfterGallery.js +2 -2
  34. package/dist/features/blog/components/BlogIndexListing.js +3 -3
  35. package/dist/features/blog/components/BlogListView.js +3 -3
  36. package/dist/features/blog/components/BlogPostView.js +2 -2
  37. package/dist/features/categories/components/BundleCollage.js +2 -2
  38. package/dist/features/categories/components/CategoriesIndexListing.js +3 -3
  39. package/dist/features/categories/components/CategoryBundlesListing.js +2 -2
  40. package/dist/features/categories/components/CategoryGrid.js +2 -2
  41. package/dist/features/categories/components/CategoryProductsListing.js +2 -2
  42. package/dist/features/categories/components/CategoryStoresListing.js +2 -2
  43. package/dist/features/collections/components/CollectionCard.js +2 -2
  44. package/dist/features/events/components/EventRafflesSection.js +2 -2
  45. package/dist/features/events/components/EventsIndexListing.js +2 -2
  46. package/dist/features/events/components/EventsListView.js +3 -3
  47. package/dist/features/homepage/components/FeaturedBundlesSection.js +2 -2
  48. package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
  49. package/dist/features/homepage/components/HomepageSkeleton.js +1 -1
  50. package/dist/features/homepage/components/SecurityHighlightsSection.js +1 -1
  51. package/dist/features/homepage/components/SocialFeedSection.js +2 -2
  52. package/dist/features/homepage/components/StatsCounterSection.js +1 -1
  53. package/dist/features/homepage/components/TrustBadges.js +2 -2
  54. package/dist/features/homepage/components/TrustFeaturesSection.js +1 -1
  55. package/dist/features/messages/hooks/useConversation.js +39 -15
  56. package/dist/features/messages/hooks/useConversations.js +36 -14
  57. package/dist/features/messages/repository/conversations.repository.d.ts +23 -0
  58. package/dist/features/messages/repository/conversations.repository.js +53 -0
  59. package/dist/features/pre-orders/components/PreOrdersIndexListing.js +2 -3
  60. package/dist/features/products/components/AuctionsIndexListing.js +2 -3
  61. package/dist/features/products/components/PrizeDrawCollage.js +2 -2
  62. package/dist/features/products/components/PrizeDrawsIndexListing.js +2 -3
  63. package/dist/features/products/components/PrizeDrawsSection.js +2 -2
  64. package/dist/features/products/components/ProductGrid.js +2 -7
  65. package/dist/features/products/components/ProductsIndexListing.js +3 -3
  66. package/dist/features/products/components/RelatedProducts.js +2 -2
  67. package/dist/features/promotions/components/CouponsIndexListing.js +1 -1
  68. package/dist/features/reviews/components/ReviewDetailPageView.js +2 -2
  69. package/dist/features/reviews/components/ReviewsListingPanel.js +2 -2
  70. package/dist/features/seller/components/SellerCouponsView.js +2 -2
  71. package/dist/features/seller/components/SellerDashboardView.js +3 -3
  72. package/dist/features/server-errors/schemas/firestore.d.ts +9 -0
  73. package/dist/features/stores/components/StoreAuctionsListing.js +2 -3
  74. package/dist/features/stores/components/StoreClassifiedsListing.js +2 -3
  75. package/dist/features/stores/components/StoreDigitalCodesListing.js +2 -3
  76. package/dist/features/stores/components/StoreGuideHubView.js +2 -2
  77. package/dist/features/stores/components/StoreLiveItemsListing.js +2 -3
  78. package/dist/features/stores/components/StorePreOrdersListing.js +2 -3
  79. package/dist/features/stores/components/StoreProductsListing.js +2 -2
  80. package/dist/features/stores/components/StoresIndexListing.js +3 -3
  81. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +54 -1
  82. package/dist/next/api/routeHandler.js +27 -7
  83. package/dist/providers/db-firebase/base.repository.js +34 -18
  84. package/dist/react/hooks/useRealtimeEvent.js +22 -4
  85. package/dist/server.d.ts +2 -0
  86. package/dist/server.js +5 -0
  87. package/dist/styles.css +84 -20
  88. package/dist/tailwind-utilities.css +1 -1
  89. package/dist/tokens/tokens.css +7 -2
  90. package/dist/ui/components/Layout.d.ts +31 -18
  91. package/dist/ui/components/Layout.js +30 -19
  92. package/dist/ui/components/Layout.style.css +76 -17
  93. package/dist/ui/components/ListingLayout.d.ts +2 -0
  94. package/dist/ui/components/ListingLayout.js +16 -2
  95. package/dist/ui/components/StickyToolbar.js +3 -48
  96. package/dist/ui/components/toolbar-collapse.d.ts +44 -0
  97. package/dist/ui/components/toolbar-collapse.js +93 -0
  98. package/package.json +1 -1
@@ -1,5 +1,7 @@
1
1
  import { batchDelete, getTestDataRefs } from "../handlers/_helpers";
2
2
  import { BID_FIELDS } from "../../../../constants/field-names";
3
+ import { encryptPiiFields } from "../../../../security/pii-encrypt";
4
+ import { BID_PII_FIELDS, OFFER_PII_FIELDS, } from "../../../../security/pii-schemas";
3
5
  import { SANDBOX_COLLECTIONS } from "./testerSandboxCleanup";
4
6
  // Direct imports from each defining module (not the seed-data barrel) —
5
7
  // this is internal appkit code, not part of the package's public API surface.
@@ -10,6 +12,29 @@ import { blogTesterSeedData } from "../../../../features/tester/seed-data/blog-t
10
12
  import { eventsTesterSeedData } from "../../../../features/tester/seed-data/events-tester-seed-data";
11
13
  import { offersTesterSeedData } from "../../../../features/tester/seed-data/offers-tester-seed-data";
12
14
  import { bidsTesterSeedData } from "../../../../features/tester/seed-data/bids-tester-seed-data";
15
+ /**
16
+ * PII per sandbox collection — the same map `seed-cli.mjs` applies.
17
+ *
18
+ * This job wrote every document with a bare `batch.set(...)`, while its own
19
+ * comment claimed to mirror seed-cli's loadGeneric. loadGeneric ENCRYPTS;
20
+ * this did not. So the 4-hourly refresh re-seeded sandbox PII in plaintext and
21
+ * quietly undid the backfill for those fixtures — caught by reading the raw
22
+ * documents after a deploy, where `bids` had 37 encrypted rows and one
23
+ * plaintext one that had not existed at backfill time.
24
+ *
25
+ * A scheduled job writing straight to Firestore bypasses `applyWriteHooks`
26
+ * exactly like the hand-rolled repository overrides did. Same defect, one
27
+ * level up.
28
+ */
29
+ const SANDBOX_PII = {
30
+ offers: OFFER_PII_FIELDS,
31
+ bids: BID_PII_FIELDS,
32
+ products: ["sellerName", "sellerEmail"],
33
+ };
34
+ function encryptForSandbox(collection, data) {
35
+ const fields = SANDBOX_PII[collection];
36
+ return fields ? encryptPiiFields(data, [...fields]) : data;
37
+ }
13
38
  const SEED_BY_COLLECTION = {
14
39
  categories: categoriesTesterSeedData,
15
40
  stores: storesTesterSeedData,
@@ -44,7 +69,7 @@ export async function runTesterSandboxRefresh(ctx) {
44
69
  // merge:true mirrors seed-cli.mjs's loadGeneric — the seed payload
45
70
  // carries every canonical field, so this fully overwrites anything
46
71
  // a tester edited through the UI.
47
- batch.set(ctx.db.collection(collection).doc(id), data, { merge: true });
72
+ batch.set(ctx.db.collection(collection).doc(id), encryptForSandbox(collection, data), { merge: true });
48
73
  reverted++;
49
74
  }
50
75
  await batch.commit();
@@ -66,7 +91,7 @@ export async function runTesterSandboxRefresh(ctx) {
66
91
  const { id, ...data } = bid;
67
92
  if (!id)
68
93
  continue;
69
- bidBatch.set(ctx.db.collection("bids").doc(id), data, { merge: true });
94
+ bidBatch.set(ctx.db.collection("bids").doc(id), encryptForSandbox("bids", data), { merge: true });
70
95
  reverted++;
71
96
  }
72
97
  await bidBatch.commit();
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Registration seam for recording server-action failures.
3
+ *
4
+ * WHY A HOOK AND NOT A DIRECT IMPORT
5
+ * `wrapAction` lives in `_internal/shared/`, and the module that hosts it also
6
+ * exports `isOk` / `unwrap` — type guards that CLIENT components import. A
7
+ * static `serverErrorsRepository` import there would drag `firebase-admin`
8
+ * into the client bundle graph, which is exactly the Turbopack trap in
9
+ * CLAUDE.md Root Cause #6. So the recorder is injected from the server side
10
+ * instead, and this module stays dependency-free.
11
+ *
12
+ * WHERE IT IS REGISTERED
13
+ * `src/instrumentation.ts` — server-only and guaranteed to be loaded by
14
+ * Next.js. Deliberately NOT a client bootstrap component: `setErrorTracker`
15
+ * was registered that way, the component it lived in was orphaned during a
16
+ * refactor, and every error boundary silently lost its digest for months
17
+ * (CLAUDE.md Root Cause #78). A registration nothing imports is invisible to
18
+ * `tsc`, so `audit-observability-registration.mjs` asserts both hooks keep a
19
+ * reachable call site.
20
+ */
21
+ export interface ActionErrorReport {
22
+ /** Stable error code from the mapped envelope. */
23
+ code: string;
24
+ /** The UNSCRUBBED message — never the client-facing one. */
25
+ message: string;
26
+ /** Wrapper stack. Begins at the wrap site; see `cause`. */
27
+ stack?: string;
28
+ /** Flattened `Error.cause` chain — where the real frames live. */
29
+ cause?: string;
30
+ }
31
+ export type ActionErrorReporter = (report: ActionErrorReport) => void;
32
+ export declare function setActionErrorReporter(fn: ActionErrorReporter): void;
33
+ /**
34
+ * Report a server-action failure. Never throws and never awaits — the caller is
35
+ * already on a failure path and must not be made slower or more fragile by
36
+ * observability.
37
+ */
38
+ export declare function reportActionError(report: ActionErrorReport): void;
39
+ /** Test/introspection helper — true once a reporter has been installed. */
40
+ export declare function hasActionErrorReporter(): boolean;
@@ -0,0 +1,27 @@
1
+ import { normalizeError } from "../../../errors/normalize";
2
+ let reporter = null;
3
+ export function setActionErrorReporter(fn) {
4
+ reporter = fn;
5
+ }
6
+ /**
7
+ * Report a server-action failure. Never throws and never awaits — the caller is
8
+ * already on a failure path and must not be made slower or more fragile by
9
+ * observability.
10
+ */
11
+ export function reportActionError(report) {
12
+ if (!reporter)
13
+ return;
14
+ try {
15
+ reporter(report);
16
+ // This IS the error-reporting path. A reporter that throws must not turn a
17
+ // handled action failure into an unhandled one, and there is nowhere left
18
+ // to escalate to — the thing that would have reported it is what failed.
19
+ }
20
+ catch (reporterErr) {
21
+ void normalizeError(reporterErr);
22
+ }
23
+ }
24
+ /** Test/introspection helper — true once a reporter has been installed. */
25
+ export function hasActionErrorReporter() {
26
+ return reporter !== null;
27
+ }
@@ -36,6 +36,16 @@ export type ActionResult<T> = {
36
36
  * ```
37
37
  *
38
38
  * `wrapAction` itself never throws. Callers can rely on the returned envelope.
39
+ *
40
+ * 🛑 BECAUSE it never throws, it is also the ONLY thing that will ever see the
41
+ * failure. Next.js calls `onRequestError` only for errors that ESCAPE a server
42
+ * action; converting the throw into a return value means that hook never fires.
43
+ * This catch used to be `void normalizeError(err)` — a pure classifier whose
44
+ * result is discarded — and then a return: no log, no store, no rethrow. All
45
+ * 222 server actions in this codebase (68 in appkit, 154 in the consumer) were
46
+ * therefore completely unobserved, which is why the bid failure this was
47
+ * written for had to be reported from a screenshot. Anything added here must
48
+ * keep recording; silence is the bug.
39
49
  */
40
50
  export declare function wrapAction<T>(fn: () => Promise<T>): Promise<ActionResult<T>>;
41
51
  /** Type guard for ActionResult success arm. */
@@ -1,5 +1,7 @@
1
1
  import { normalizeError } from "../../../errors/normalize";
2
2
  import { mapToHttpError } from "../../../errors/error-mapping";
3
+ import { describeCauseChain } from "../../../errors/describe-cause";
4
+ import { reportActionError } from "./action-error-reporter";
3
5
  /**
4
6
  * Wrap a server-action body so any thrown error is converted into the
5
7
  * standard `{ ok: false, code, error, issues? }` envelope via `mapToHttpError`.
@@ -16,6 +18,16 @@ import { mapToHttpError } from "../../../errors/error-mapping";
16
18
  * ```
17
19
  *
18
20
  * `wrapAction` itself never throws. Callers can rely on the returned envelope.
21
+ *
22
+ * 🛑 BECAUSE it never throws, it is also the ONLY thing that will ever see the
23
+ * failure. Next.js calls `onRequestError` only for errors that ESCAPE a server
24
+ * action; converting the throw into a return value means that hook never fires.
25
+ * This catch used to be `void normalizeError(err)` — a pure classifier whose
26
+ * result is discarded — and then a return: no log, no store, no rethrow. All
27
+ * 222 server actions in this codebase (68 in appkit, 154 in the consumer) were
28
+ * therefore completely unobserved, which is why the bid failure this was
29
+ * written for had to be reported from a screenshot. Anything added here must
30
+ * keep recording; silence is the bug.
19
31
  */
20
32
  export async function wrapAction(fn) {
21
33
  try {
@@ -27,6 +39,20 @@ export async function wrapAction(fn) {
27
39
  const mapped = mapToHttpError(err, {
28
40
  isProduction: process.env.NODE_ENV === "production",
29
41
  });
42
+ // Record 5xx and datastore-level failures. 4xx business outcomes ("bid too
43
+ // low", "coupon expired") are the action working correctly and would drown
44
+ // the store — this is the same threshold createRouteHandler applies.
45
+ if (mapped.status >= 500 || mapped.infrastructure) {
46
+ // internalMessage ?? message — `message` is scrubbed for 5xx in
47
+ // production, and recording the scrubbed copy would defeat the point.
48
+ const cause = describeCauseChain(err);
49
+ reportActionError({
50
+ code: mapped.code,
51
+ message: mapped.internalMessage ?? mapped.message,
52
+ stack: err instanceof Error ? err.stack : undefined,
53
+ ...(cause ? { cause } : {}),
54
+ });
55
+ }
30
56
  return {
31
57
  ok: false,
32
58
  code: mapped.code,
@@ -16,12 +16,14 @@
16
16
  export interface SurfaceErrorOptions {
17
17
  showToast: (message: string, variant?: "error" | "warning" | "info" | "success" | "loading") => void;
18
18
  setFieldError?: (field: string, message: string) => void;
19
- /** next-intl style translator. Falls back to the raw error message when undefined. */
19
+ /**
20
+ * next-intl style translator. When undefined the chain degrades to
21
+ * `fallbackMessage` and then to generic copy — NEVER to `err.message`.
22
+ */
20
23
  translate?: (key: string) => string;
21
24
  /**
22
- * Curated copy preferred over `err.message` when the error carries no
23
- * mapped display message. Lets a caller keep wording better than the raw
24
- * server string WITHOUT adding a second toast of its own.
25
+ * Curated copy used when the code resolves to no translation. Must be
26
+ * authored text; never a value derived from an error object.
25
27
  *
26
28
  * Never overrides field-level `issues` — those are more specific than any
27
29
  * caller-supplied sentence.
@@ -2,7 +2,8 @@ import { isApiError } from "./ApiError";
2
2
  import { ApiClientError } from "../../http/ApiClient";
3
3
  import { applyZodIssues, hasAttachableIssue } from "../../ui/forms/apply-zod-issues";
4
4
  import { normalizeError } from "../../errors/normalize";
5
- import { getErrorDisplay } from "../../errors/error-display-map";
5
+ import { getErrorDisplay, toUserMessage } from "../../errors/error-display-map";
6
+ import { HTTP_ERROR_CODES } from "../../errors/error-mapping";
6
7
  /** True for `ApiError` instances and for `ApiClientError` (the lower-level
7
8
  * error `ApiClient.request()` throws) when it carries a stable `code` —
8
9
  * both shapes route through the same toast-vs-inline-field-error logic
@@ -39,7 +40,13 @@ export function surfaceError(err, opts) {
39
40
  }
40
41
  if (hasStableErrorCode(err)) {
41
42
  const display = getErrorDisplay(err.code);
42
- const message = translate?.(display.messageKey) ?? fallbackMessage ?? err.message;
43
+ // `?? err.message` used to terminate this chain. With no `translate` and no
44
+ // `fallbackMessage` — the shape most call sites use — that surfaced the
45
+ // SERVER's own message, which for a 5xx is its internals. `toUserMessage`
46
+ // ends at `errors.codes.UNKNOWN` instead.
47
+ const message = toUserMessage(err.code, translate, {
48
+ fallback: fallbackMessage,
49
+ });
43
50
  if (display.field && setFieldError) {
44
51
  setFieldError(display.field, message);
45
52
  return;
@@ -47,16 +54,20 @@ export function surfaceError(err, opts) {
47
54
  showToast(message, "error");
48
55
  return;
49
56
  }
50
- // Non-ApiError — this is a programming error or unhandled exception.
51
- // Report it (workstream 13) so clientErrors picks it up.
52
- const message = err instanceof Error ? err.message : "Something went wrong. Please try again.";
53
- const fallback = fallbackMessage ?? translate?.("errors.codes.CLIENT_UNHANDLED") ?? message;
54
- showToast(fallback, "error");
57
+ // Non-ApiError — a programming error or unhandled exception.
58
+ //
59
+ // The raw text is REPORTED (below) but never SHOWN. That split is the point:
60
+ // this branch is where a raw Node/DOM string arrives, and it used to be
61
+ // toasted verbatim whenever no translator was supplied.
62
+ const internalMessage = err instanceof Error ? err.message : String(err ?? "unknown error");
63
+ showToast(toUserMessage(HTTP_ERROR_CODES.CLIENT_UNHANDLED, translate, {
64
+ fallback: fallbackMessage,
65
+ }), "error");
55
66
  if (report) {
56
67
  try {
57
68
  report({
58
69
  code: "CLIENT_UNHANDLED",
59
- message,
70
+ message: internalMessage,
60
71
  stack: err instanceof Error ? err.stack : undefined,
61
72
  });
62
73
  }
@@ -0,0 +1,27 @@
1
+ export interface RealtimeToken {
2
+ customToken: string;
3
+ expiresAt: number;
4
+ }
5
+ /**
6
+ * Ensure the shared realtime app is signed in for `scope`, and take a lease.
7
+ *
8
+ * @param scope an identifier for the claim set (e.g. `"messages"`,
9
+ * `"chat:<id>"`). Signing in again for the SAME scope while a
10
+ * valid token is held is a no-op.
11
+ * @param getToken fetches a fresh custom token. Called only when needed.
12
+ * @returns a release function. **Always call it** — the session is only torn
13
+ * down when every lease is released.
14
+ */
15
+ export declare function acquireRealtimeSession(scope: string, getToken: () => Promise<RealtimeToken>): Promise<() => void>;
16
+ /**
17
+ * Take a lease WITHOUT signing in — for a consumer that manages its own
18
+ * sign-in but must still participate in the reference count.
19
+ *
20
+ * `useRealtimeEvent` is the case: its token carries a job-scoped `bulkJobId`
21
+ * claim it receives from the caller, so it cannot use `acquireRealtimeSession`'s
22
+ * fetcher. It still must not be able to sign the shared app out from under
23
+ * another channel, which is precisely what its `cleanup()` used to do.
24
+ */
25
+ export declare function acquireExternalRealtimeLease(): () => void;
26
+ /** Test/diagnostic only. */
27
+ export declare function activeRealtimeLeases(): number;
@@ -0,0 +1,118 @@
1
+ import { normalizeError } from "../errors/normalize";
2
+ import { getClientRealtimeProvider } from "./client-realtime";
3
+ /**
4
+ * Shared, REFERENCE-COUNTED sign-in for the one client realtime app.
5
+ *
6
+ * WHY THIS EXISTS — two distinct bugs, one cause.
7
+ *
8
+ * 1. **Nobody signed in for messaging.** `useConversation` and
9
+ * `useConversations` subscribed to `chats/...` without ever calling
10
+ * `signInWithToken`, so `auth == null` and both RTDB rules evaluated false.
11
+ * Every buyer↔seller live update was permission-denied, and both hooks
12
+ * swallowed it (one into `setIsConnected(false)`, one into a literal
13
+ * `// ignore`), so the channel looked implemented and was inert. Only
14
+ * `useChat` and `useRealtimeEvent` ever authenticated.
15
+ *
16
+ * 2. **Signing out is a SHARED action.** `FirebaseClientRealtimeProvider` is
17
+ * registered once, as a singleton on the `"letitrip-realtime"` app. So
18
+ * `useRealtimeEvent.cleanup()` calling `provider.signOut()` when a bulk job
19
+ * finished tore the session out from under an admin chat that was still
20
+ * open — and, in reverse, `useChat`'s token (carrying `chatIds`) overwrote a
21
+ * bulk token (carrying `bulkJobId`), killing an in-flight job subscription.
22
+ * Whoever finished last broke whoever was still working.
23
+ *
24
+ * The fix for (2) is not "sign out more carefully" — it is that **no individual
25
+ * consumer may decide to sign out at all**. Each acquires a lease and releases
26
+ * it; the session ends only when the last lease is gone.
27
+ *
28
+ * Tokens differ per channel (different claims), so `acquire` takes a fetcher and
29
+ * re-signs when the token it holds is for a different scope or has expired.
30
+ */
31
+ /** Refresh a little early — a token that expires mid-subscription is a silent drop. */
32
+ const EXPIRY_SKEW_MS = 60000;
33
+ let leases = 0;
34
+ let currentScope = null;
35
+ let expiresAt = 0;
36
+ let inFlight = null;
37
+ /**
38
+ * Ensure the shared realtime app is signed in for `scope`, and take a lease.
39
+ *
40
+ * @param scope an identifier for the claim set (e.g. `"messages"`,
41
+ * `"chat:<id>"`). Signing in again for the SAME scope while a
42
+ * valid token is held is a no-op.
43
+ * @param getToken fetches a fresh custom token. Called only when needed.
44
+ * @returns a release function. **Always call it** — the session is only torn
45
+ * down when every lease is released.
46
+ */
47
+ export async function acquireRealtimeSession(scope, getToken) {
48
+ const lease = { scope };
49
+ leases++;
50
+ const needsSignIn = currentScope !== lease.scope || Date.now() > expiresAt - EXPIRY_SKEW_MS;
51
+ if (needsSignIn) {
52
+ // Collapse concurrent acquires — several hooks mount in the same tick and
53
+ // would otherwise each fetch a token and race to sign in.
54
+ inFlight ?? (inFlight = (async () => {
55
+ try {
56
+ const token = await getToken();
57
+ await getClientRealtimeProvider().signInWithToken(token.customToken);
58
+ currentScope = lease.scope;
59
+ expiresAt = token.expiresAt;
60
+ }
61
+ finally {
62
+ inFlight = null;
63
+ }
64
+ })());
65
+ await inFlight;
66
+ }
67
+ let released = false;
68
+ return () => {
69
+ if (released)
70
+ return; // React StrictMode double-invokes cleanups
71
+ released = true;
72
+ leases = Math.max(0, leases - 1);
73
+ if (leases > 0)
74
+ return;
75
+ currentScope = null;
76
+ expiresAt = 0;
77
+ getClientRealtimeProvider()
78
+ .signOut()
79
+ .catch((err) => {
80
+ void normalizeError(err);
81
+ });
82
+ };
83
+ }
84
+ /**
85
+ * Take a lease WITHOUT signing in — for a consumer that manages its own
86
+ * sign-in but must still participate in the reference count.
87
+ *
88
+ * `useRealtimeEvent` is the case: its token carries a job-scoped `bulkJobId`
89
+ * claim it receives from the caller, so it cannot use `acquireRealtimeSession`'s
90
+ * fetcher. It still must not be able to sign the shared app out from under
91
+ * another channel, which is precisely what its `cleanup()` used to do.
92
+ */
93
+ export function acquireExternalRealtimeLease() {
94
+ leases++;
95
+ // Its own sign-in has replaced whatever scope was active; forget the cached
96
+ // one so the next acquire() re-authenticates rather than trusting a token
97
+ // that is no longer installed.
98
+ currentScope = null;
99
+ expiresAt = 0;
100
+ let released = false;
101
+ return () => {
102
+ if (released)
103
+ return;
104
+ released = true;
105
+ leases = Math.max(0, leases - 1);
106
+ if (leases > 0)
107
+ return;
108
+ getClientRealtimeProvider()
109
+ .signOut()
110
+ .catch((err) => {
111
+ void normalizeError(err);
112
+ });
113
+ };
114
+ }
115
+ /** Test/diagnostic only. */
116
+ export function activeRealtimeLeases() {
117
+ return leases;
118
+ }
@@ -1,7 +1,19 @@
1
1
  import { normalizeError } from "../errors/normalize";
2
- const getAdminDb = () => module.require("../providers/db-firebase").getAdminDb();
2
+ // Static import, deliberately. This used to be
3
+ // `(module as any).require("../providers/db-firebase")` — the bundler-evasion
4
+ // idiom used correctly elsewhere in appkit (admin.ts's firebase-admin loaders),
5
+ // but every OTHER use passes a BARE specifier, which Node resolves from
6
+ // node_modules at any depth. A RELATIVE one is resolved against the emitted
7
+ // chunk, not this source file, so in a bundled Lambda it became
8
+ // `.next/server/chunks/providers/db-firebase` → MODULE_NOT_FOUND, and every
9
+ // runBatch/runTransaction threw before touching Firestore.
10
+ //
11
+ // The lazy form also bought nothing: the 19 repository imports below already
12
+ // pull this module into the graph statically (bid.repository.ts et al), and
13
+ // db-firebase reaches firebase-admin only through `import type` plus its own
14
+ // bare-specifier runtime requires. See CLAUDE.md Root Cause #24.
15
+ import { getAdminDb } from "../providers/db-firebase";
3
16
  import { DatabaseError } from "../errors";
4
- import { serverLogger } from "../monitoring";
5
17
  import { userRepository, tokenRepository, sessionRepository, } from "../features/auth/repository";
6
18
  import { addressesRepository } from "../features/addresses/repository/addresses.repository";
7
19
  import { cartRepository } from "../features/cart/repository/cart.repository";
@@ -81,32 +93,28 @@ export class UnitOfWork {
81
93
  return notificationRepository;
82
94
  }
83
95
  async runTransaction(fn) {
84
- serverLogger.debug("[UnitOfWork] Starting transaction");
85
96
  try {
86
- const result = await this.db.runTransaction(fn);
87
- serverLogger.debug("[UnitOfWork] Transaction committed successfully");
88
- return result;
97
+ return await this.db.runTransaction(fn);
89
98
  }
90
99
  catch (error) {
91
100
  void normalizeError(error);
92
- serverLogger.error("[UnitOfWork] Transaction failed", {
93
- error: error instanceof Error ? error.message : String(error),
94
- });
95
- throw new DatabaseError("Transaction failed", error);
101
+ // Interpolate the underlying message. Without it the persisted
102
+ // serverErrors row reads only "Transaction failed" and the real cause
103
+ // survives nowhere the store can see — `cause` carries the stack, but
104
+ // the message is what the admin list actually renders.
105
+ const detail = error instanceof Error ? error.message : String(error);
106
+ throw new DatabaseError(`Transaction failed: ${detail}`, error);
96
107
  }
97
108
  }
98
109
  async runBatch(fn) {
99
- serverLogger.debug("[UnitOfWork] Starting batch write");
100
110
  try {
101
111
  const batch = this.db.batch();
102
112
  await fn(batch);
103
113
  await batch.commit();
104
- serverLogger.debug("[UnitOfWork] Batch write committed successfully");
105
114
  }
106
115
  catch (error) {
107
116
  void normalizeError(error);
108
117
  const detail = error instanceof Error ? error.message : String(error);
109
- serverLogger.error("[UnitOfWork] Batch write failed", { error: detail });
110
118
  throw new DatabaseError(`Batch write failed: ${detail}`, error);
111
119
  }
112
120
  }
@@ -1,6 +1,19 @@
1
1
  export class AppError extends Error {
2
2
  constructor(statusCode, message, code, data) {
3
- super(message);
3
+ // Forward `data` as the standard `cause` when it is itself an Error.
4
+ //
5
+ // Every AppError in this codebase is a WRAP — `new DatabaseError("Batch
6
+ // write failed: …", originalError)` — and until 2026-08-28 the original was
7
+ // bound only to `data`, never to `cause`. Nothing that walks the cause chain
8
+ // (util.inspect, the Next error overlay, any APM, our own serverErrors
9
+ // recorder) could reach it, so the underlying fault was retained in memory
10
+ // and dropped by every consumer.
11
+ //
12
+ // `captureStackTrace` below additionally rebases `.stack` onto this
13
+ // constructor, so the wrapper's own stack starts at the catch block that
14
+ // wrapped it — the frames that named the real fault live ONLY on the cause.
15
+ // That is why preserving it is load-bearing rather than cosmetic.
16
+ super(message, data instanceof Error ? { cause: data } : undefined);
4
17
  this.statusCode = statusCode;
5
18
  this.message = message;
6
19
  this.code = code;
@@ -1,6 +1,16 @@
1
1
  import { AppError } from "./base-error";
2
+ import { ERROR_CODES } from "./error-codes";
2
3
  export class DatabaseError extends AppError {
3
4
  constructor(message, data) {
4
- super(500, message, "DATABASE_ERROR", data);
5
+ // `ERROR_CODES.DB_OPERATION_FAILED` ("DB_001"), NOT the literal
6
+ // "DATABASE_ERROR" this used to emit.
7
+ //
8
+ // That literal had exactly one reference in the entire repo — this line.
9
+ // It was absent from ERROR_CODES, HTTP_ERROR_CODES, ERROR_DISPLAY_MAP and
10
+ // messages/en.json, so all ~193 DatabaseError throw sites produced a code
11
+ // no lookup could resolve and every one of them fell through to the raw
12
+ // internal message. "DB_001" already has both a display-map entry and the
13
+ // translation "A database error occurred. Please try again".
14
+ super(500, message, ERROR_CODES.DB_OPERATION_FAILED, data);
5
15
  }
6
16
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @returns the flattened chain, or `undefined` when there is no cause — so the
3
+ * caller can omit the Firestore field entirely rather than writing `""`.
4
+ */
5
+ export declare function describeCauseChain(err: unknown): string | undefined;
@@ -0,0 +1,76 @@
1
+ import { normalizeError } from "./normalize";
2
+ /**
3
+ * Flatten an `Error.cause` chain into a single persistable string.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * Every `AppError` in this codebase is a WRAP — `new DatabaseError("Batch
7
+ * write failed: …", originalError)` — and `AppError` calls
8
+ * `Error.captureStackTrace(this, this.constructor)`, which rebases `.stack`
9
+ * onto the wrapping constructor. So the wrapper's own stack starts at the
10
+ * catch block that wrapped it, and the frames that actually name the fault
11
+ * live only on the cause.
12
+ *
13
+ * Nothing used to read them. `JSON.stringify(new Error("boom"))` is `"{}"`,
14
+ * so the one logger path that tried (`buildServerMeta`) serialised the cause
15
+ * to an empty object, and the serverErrors schema had no field for it at all.
16
+ * The only reason the original message was ever readable was one call site
17
+ * interpolating it into its own message by hand.
18
+ *
19
+ * Depth-capped and byte-capped: a cause chain is untrusted in length, and this
20
+ * value is persisted into a document that must stay inside the 4 KB stack
21
+ * budget documented on `ServerErrorDocument`.
22
+ */
23
+ const MAX_DEPTH = 5;
24
+ const MAX_BYTES = 4096;
25
+ /** `cause` is only present on the ES2022 Error shape; read it structurally. */
26
+ function causeOf(value) {
27
+ return value.cause;
28
+ }
29
+ function messageOf(err) {
30
+ if (err instanceof Error)
31
+ return `${err.name}: ${err.message}`;
32
+ if (typeof err === "string")
33
+ return err;
34
+ try {
35
+ return JSON.stringify(err) ?? String(err);
36
+ // JSON.stringify throws only on a circular/BigInt value; String() always
37
+ // succeeds. This runs inside the error path, where throwing would lose the
38
+ // original error we were trying to describe.
39
+ }
40
+ catch (stringifyErr) {
41
+ void normalizeError(stringifyErr);
42
+ return String(err);
43
+ }
44
+ }
45
+ /**
46
+ * @returns the flattened chain, or `undefined` when there is no cause — so the
47
+ * caller can omit the Firestore field entirely rather than writing `""`.
48
+ */
49
+ export function describeCauseChain(err) {
50
+ if (!(err instanceof Error))
51
+ return undefined;
52
+ const parts = [];
53
+ const seen = new Set([err]);
54
+ let current = causeOf(err);
55
+ let depth = 0;
56
+ while (current !== undefined && current !== null && depth < MAX_DEPTH) {
57
+ // A cause cycle is rare but cheap to guard, and an infinite loop here would
58
+ // hang the error path — the one place that must never make things worse.
59
+ if (seen.has(current)) {
60
+ parts.push("caused by: <circular>");
61
+ break;
62
+ }
63
+ seen.add(current);
64
+ const head = messageOf(current);
65
+ const stack = current instanceof Error ? current.stack : undefined;
66
+ // Prefer the stack (it contains the message as its first line); fall back
67
+ // to the message alone for non-Error causes.
68
+ parts.push(`caused by: ${stack ?? head}`);
69
+ current = typeof current === "object" ? causeOf(current) : undefined;
70
+ depth++;
71
+ }
72
+ if (parts.length === 0)
73
+ return undefined;
74
+ const joined = parts.join("\n\n");
75
+ return joined.length <= MAX_BYTES ? joined : joined.slice(0, MAX_BYTES);
76
+ }
@@ -3,7 +3,12 @@
3
3
  * - `field`: when present + a FormShellContext is in scope, the client wrapper
4
4
  * routes the error inline as a field error instead of a toast.
5
5
  * - `messageKey`: a `next-intl` key under `errors.codes.*` resolved at render time.
6
- * Falls back to `errors.codes.UNKNOWN` then to the raw `Error.message` if absent.
6
+ * Falls back to `errors.codes.UNKNOWN`, and then to `GENERIC_USER_MESSAGE`.
7
+ *
8
+ * 🛑 The chain terminates in a CONSTANT. It used to end "…then to the raw
9
+ * `Error.message` if absent", and that tail is what printed a Node require
10
+ * stack into the bid modal. Resolve codes through `toUserMessage` below; never
11
+ * re-add a raw-message fallback.
7
12
  */
8
13
  export interface ErrorDisplayEntry {
9
14
  field?: string;
@@ -12,3 +17,29 @@ export interface ErrorDisplayEntry {
12
17
  export declare const ERROR_DISPLAY_MAP: Record<string, ErrorDisplayEntry>;
13
18
  /** Look up display metadata; returns a generic entry when the code is unknown. */
14
19
  export declare function getErrorDisplay(code: string): ErrorDisplayEntry;
20
+ /** Last-resort copy when no translator is available. Never a server string. */
21
+ export declare const GENERIC_USER_MESSAGE = "Something went wrong. Please try again.";
22
+ /**
23
+ * The user-facing message for an error code. **Never returns server text.**
24
+ *
25
+ * 🛑 This is the whole point: every previous way of doing this ended in a raw
26
+ * fallback. `ERROR_DISPLAY_MAP[code] ?? error` — the pattern CLAUDE.md Rule #9
27
+ * §6 itself documented — falls through to the server's own message, and that
28
+ * is how a Node `MODULE_NOT_FOUND`, complete with
29
+ * `/var/task/.next/server/chunks/...` and its full require stack, was rendered
30
+ * as copy inside the "Place your bid" modal. `surfaceError` had the same tail.
31
+ * There was no shared helper that did this correctly, which is why ~60 call
32
+ * sites each invented `err.message`.
33
+ *
34
+ * The chain is: code -> messageKey -> translation -> `errors.codes.UNKNOWN` ->
35
+ * `GENERIC_USER_MESSAGE`. It terminates in a constant, deliberately.
36
+ *
37
+ * @param code stable error code from an `ActionResult` / error envelope
38
+ * @param t a next-intl translator; omitted, the generic string is used
39
+ * @param options `fallback` overrides the generic string with FEATURE-SPECIFIC
40
+ * copy you have authored ("Could not place your bid"). It must
41
+ * never be a value derived from an error object.
42
+ */
43
+ export declare function toUserMessage(code: string | undefined, t?: (key: string) => string, options?: {
44
+ fallback?: string;
45
+ }): string;