@mohasinac/appkit 4.0.2 → 4.1.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 (48) hide show
  1. package/dist/_internal/client/features/layout/useSidebarSearch.d.ts +19 -0
  2. package/dist/_internal/client/features/layout/useSidebarSearch.js +21 -0
  3. package/dist/_internal/server/features/auth/permissions.d.ts +2 -0
  4. package/dist/_internal/server/features/auth/permissions.js +4 -2
  5. package/dist/_internal/server/features/seo/sitemap.js +3 -2
  6. package/dist/client.d.ts +1 -1
  7. package/dist/client.js +1 -1
  8. package/dist/features/about/components/FAQPageView.d.ts +4 -1
  9. package/dist/features/about/components/FAQPageView.js +17 -7
  10. package/dist/features/account/components/UserSidebar.js +15 -9
  11. package/dist/features/admin/components/AdminSidebar.js +15 -9
  12. package/dist/features/admin/components/AdminTesterChecklistItemEditorView.js +4 -1
  13. package/dist/features/admin/components/AdminUserEditorView.d.ts +3 -1
  14. package/dist/features/admin/components/AdminUserEditorView.js +10 -2
  15. package/dist/features/admin/components/AdminUsersView.js +1 -1
  16. package/dist/features/auth/role-predicates.d.ts +14 -0
  17. package/dist/features/auth/role-predicates.js +1 -0
  18. package/dist/features/auth/schemas/firestore.d.ts +2 -0
  19. package/dist/features/auth/schemas/firestore.js +1 -0
  20. package/dist/features/events/actions/event-actions.js +15 -8
  21. package/dist/features/events/repository/events.repository.d.ts +2 -0
  22. package/dist/features/events/repository/events.repository.js +9 -0
  23. package/dist/features/events/schemas/firestore.d.ts +1 -0
  24. package/dist/features/events/schemas/firestore.js +1 -0
  25. package/dist/features/faq/components/FAQSearchableList.d.ts +11 -0
  26. package/dist/features/faq/components/FAQSearchableList.js +21 -0
  27. package/dist/features/homepage/components/BeforeAfterCard.js +2 -1
  28. package/dist/features/homepage/components/BrandsSection.js +2 -1
  29. package/dist/features/homepage/components/CharacterHotspot.js +2 -1
  30. package/dist/features/homepage/components/CharacterHotspotForm.js +4 -3
  31. package/dist/features/homepage/components/HeroBanner.js +2 -1
  32. package/dist/features/homepage/components/PromoGrid.js +2 -1
  33. package/dist/features/homepage/components/ShopByCategorySection.js +2 -1
  34. package/dist/features/seller/components/SellerSidebar.js +15 -9
  35. package/dist/features/tester/actions/checklist-item-actions.d.ts +6 -0
  36. package/dist/features/tester/actions/checklist-item-actions.js +2 -0
  37. package/dist/features/tester/components/TesterHubView.js +5 -4
  38. package/dist/features/tester/schemas/firestore.d.ts +3 -1
  39. package/dist/features/tester/schemas/firestore.js +2 -0
  40. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +159 -6
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/next/api/routeHandler.js +25 -3
  44. package/dist/react/contexts/SessionContext.d.ts +2 -0
  45. package/dist/react/contexts/SessionContext.js +2 -0
  46. package/dist/styles.css +1 -1
  47. package/dist/tailwind-utilities.css +1 -1
  48. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  /*
2
2
  * WHY: Seeds the initial tester QA checklist catalog — DB-backed, admin-manageable (mirrors faq-seed-data.ts).
3
- * WHAT: ~55 default test cases across 6 groups (Account & Auth, Buying, Selling, Content & Discovery,
3
+ * WHAT: ~65 default test cases across 6 groups (Account & Auth, Buying, Selling, Content & Discovery,
4
4
  * Community & Support, Design & UX). Admins add/edit further cases via /admin/tester-checklist —
5
- * this array is a starting point, not the source of truth.
5
+ * this array is a starting point, not the source of truth. Every case is searchable by title or
6
+ * route in the Tester Hub (/user/tester), which any isTester account OR admin can open.
6
7
  *
7
8
  * EXPORTS:
8
9
  * testerChecklistSeedData — Array of Partial<TesterChecklistItemDocument> for seed runner
@@ -14,7 +15,7 @@
14
15
  * @tag consumers:seed/index.ts,seed/runner.ts
15
16
  * @tag sideEffects:none
16
17
  */
17
- function group(groupKey, groupLabel, pages) {
18
+ function group(groupKey, groupLabel, pages, opts) {
18
19
  const items = [];
19
20
  pages.forEach((page, pageIdx) => {
20
21
  page.cases.forEach((c, caseIdx) => {
@@ -27,8 +28,9 @@ function group(groupKey, groupLabel, pages) {
27
28
  label: c.label,
28
29
  description: c.description,
29
30
  href: c.href,
30
- order: pageIdx * 10 + caseIdx,
31
+ order: (opts?.orderOffset ?? 0) + pageIdx * 10 + caseIdx,
31
32
  isActive: true,
33
+ adminOnly: opts?.adminOnly ?? false,
32
34
  });
33
35
  });
34
36
  });
@@ -42,6 +44,8 @@ export const testerChecklistSeedData = [
42
44
  cases: [
43
45
  { key: "email-signup", label: "Sign up with email works", href: "/register" },
44
46
  { key: "google-oauth", label: "Sign up / log in with Google works", href: "/login" },
47
+ { key: "google-link-existing", label: "Linking Google sign-in to an existing email/password account works" },
48
+ { key: "google-popup-blocked-fallback", label: "If the Google sign-in popup is blocked, the fallback (RTDB signal + postMessage) still completes sign-in" },
45
49
  { key: "login", label: "Log in with email + password works", href: "/login" },
46
50
  { key: "logout", label: "Log out works and clears the session" },
47
51
  { key: "email-verify", label: "Email verification link works" },
@@ -58,6 +62,17 @@ export const testerChecklistSeedData = [
58
62
  { key: "public-profile-toggle", label: "Public profile visibility toggle works" },
59
63
  ],
60
64
  },
65
+ {
66
+ pageKey: "testing-program",
67
+ pageLabel: "Tester Hub",
68
+ cases: [
69
+ { key: "tester-hub-loads", label: "Tester Hub loads the full checklist grouped by section", href: "/user/tester" },
70
+ { key: "tester-hub-search", label: "Tester Hub search finds test cases by typing part of the title or the route (e.g. \"/store/payouts\")", href: "/user/tester" },
71
+ { key: "tester-hub-answer-saves", label: "Answering Yes/No and adding a comment on a test case saves without reloading" },
72
+ { key: "admin-tester-access", label: "Signed-in admin accounts can open the Tester Hub and see the same checklist as testers", href: "/user/tester" },
73
+ { key: "admin-testing-section", label: "Admin dashboard's Testing section shows both the Tester Checklist and Tester Feedback (results) links", href: "/admin/tester-feedback" },
74
+ ],
75
+ },
61
76
  ]),
62
77
  ...group("buying", "Buying", [
63
78
  {
@@ -79,6 +94,11 @@ export const testerChecklistSeedData = [
79
94
  { key: "preorder-detail", label: "Pre-order detail page shows expected ship date correctly" },
80
95
  { key: "image-gallery", label: "Product image gallery / zoom works" },
81
96
  { key: "video-playback", label: "Product video (YouTube embed) plays correctly" },
97
+ { key: "prizedraw-buy-reveal", label: "Buying a prize-draw entry, then the reveal, correctly shows win/lose and auto-refunds non-winners" },
98
+ { key: "bundle-purchase", label: "Purchasing a bundle/grouped-listing works and shows all included items in the order" },
99
+ { key: "classified-contact-flow", label: "A classified listing shows a contact-seller flow with deliberately no checkout/buy button" },
100
+ { key: "digitalcode-delivery", label: "Purchasing a digital-code listing delivers the code to the buyer post-purchase" },
101
+ { key: "live-item-detail", label: "A live-item listing's detail page shows the livestream link correctly" },
82
102
  ],
83
103
  },
84
104
  {
@@ -139,6 +159,22 @@ export const testerChecklistSeedData = [
139
159
  cases: [
140
160
  { key: "start-conversation", label: "Starting a conversation with a seller works" },
141
161
  { key: "receive-reply", label: "Receiving and reading a seller's reply works" },
162
+ { key: "message-realtime-update", label: "A new message appears in the recipient's thread without a manual page refresh (realtime ping-channel)", href: "/user/messages" },
163
+ { key: "messages-list-thread", label: "The messages list and individual thread view both load correctly", href: "/user/messages" },
164
+ ],
165
+ },
166
+ {
167
+ pageKey: "user-dashboard-extras",
168
+ pageLabel: "User Dashboard — Addresses, Catalogue, Settings, My Orders-by-Type",
169
+ cases: [
170
+ { key: "addresses-crud", label: "Adding, editing, and listing delivery addresses works", href: "/user/addresses" },
171
+ { key: "catalogue-crud", label: "Adding, editing, and deleting a personal catalogue item works", href: "/user/catalogue" },
172
+ { key: "settings-page", label: "The user settings page loads and saves correctly", href: "/user/settings" },
173
+ { key: "my-prize-draws", label: "\"My Prize Draw Entries\" shows accurate entries and outcomes", href: "/user/prize-draws" },
174
+ { key: "my-digital-codes", label: "\"My Digital Codes\" shows purchased codes correctly", href: "/user/digital-codes" },
175
+ { key: "my-offers", label: "\"My Offers\" list shows accurate offer history", href: "/user/offers" },
176
+ { key: "my-returns", label: "Requesting and viewing a return works", href: "/user/returns" },
177
+ { key: "my-reviews", label: "\"My Reviews\" shows reviews the user has left", href: "/user/reviews" },
142
178
  ],
143
179
  },
144
180
  ]),
@@ -182,6 +218,70 @@ export const testerChecklistSeedData = [
182
218
  cases: [
183
219
  { key: "view-analytics", label: "Seller analytics dashboard shows accurate sales data", href: "/store/analytics" },
184
220
  { key: "view-payouts", label: "Seller payouts list shows accurate payout history", href: "/store/payouts" },
221
+ { key: "payouts-checkbox-select", label: "Selecting payouts with the row checkboxes shows a working bulk action bar (Export Selected)", href: "/store/payouts" },
222
+ { key: "payouts-detail-panel", label: "Opening \"View Details\" on a payout shows a side panel with status progress, transaction ID, and expected payout date" },
223
+ { key: "payouts-reminder-toggle", label: "Toggling the payout reminder flag in the detail panel saves correctly" },
224
+ ],
225
+ },
226
+ {
227
+ pageKey: "seller-shipping-payouts-setup",
228
+ pageLabel: "Seller Shipping & Payout Setup",
229
+ cases: [
230
+ { key: "shipping-page", label: "Store shipping settings page saves correctly", href: "/store/shipping" },
231
+ { key: "shipping-configs-crud", label: "Creating, editing, and listing shipping configs works", href: "/store/shipping-configs" },
232
+ { key: "payout-methods-crud", label: "Adding, editing, and listing payout methods works", href: "/store/payout-methods" },
233
+ { key: "payout-settings-page", label: "Payout settings page saves correctly", href: "/store/payout-settings" },
234
+ ],
235
+ },
236
+ {
237
+ pageKey: "seller-listing-types",
238
+ pageLabel: "Seller — All Listing Types (Coupons, Bundles, Classifieds, Digital Codes, Live, Prize Draws, Art, Stickers)",
239
+ cases: [
240
+ { key: "seller-coupons-crud", label: "Seller can create, edit, and list their own coupons", href: "/store/coupons" },
241
+ { key: "seller-bundles-crud", label: "Seller can create, edit, and list bundles/grouped listings", href: "/store/bundles" },
242
+ { key: "seller-classified-crud", label: "Seller can create, edit, and list classified listings", href: "/store/classified" },
243
+ { key: "seller-digitalcodes-crud", label: "Seller can create, edit, and list digital-code listings", href: "/store/digital-codes" },
244
+ { key: "seller-live-crud", label: "Seller can create, edit, and list live-item listings", href: "/store/live" },
245
+ { key: "seller-prizedraws-crud", label: "Seller can create, edit, and list prize-draw listings, and view entries", href: "/store/prize-draws" },
246
+ { key: "seller-art-stickers-crud", label: "Seller can create, edit, and list art and sticker listings", href: "/store/art" },
247
+ ],
248
+ },
249
+ {
250
+ pageKey: "seller-catalog-org",
251
+ pageLabel: "Seller Categories, Sublisting Categories & Listing Templates",
252
+ cases: [
253
+ { key: "seller-categories-crud", label: "Seller can create and edit their own categories", href: "/store/categories" },
254
+ { key: "seller-sublisting-categories-crud", label: "Seller can create and edit sublisting categories", href: "/store/sublisting-categories" },
255
+ { key: "seller-listing-templates-crud", label: "Seller can create, edit, and reuse listing templates when creating a new product", href: "/store/listing-templates" },
256
+ ],
257
+ },
258
+ {
259
+ pageKey: "seller-ops-comms",
260
+ pageLabel: "Seller Addresses, Messages, Fulfillment & Print",
261
+ cases: [
262
+ { key: "seller-addresses-crud", label: "Seller can add and edit store addresses", href: "/store/addresses" },
263
+ { key: "seller-messages", label: "Seller messages list and thread view both work", href: "/store/messages" },
264
+ { key: "seller-fulfillment-queue", label: "Seller fulfillment queue shows accurate pending items", href: "/store/fulfillment" },
265
+ { key: "seller-print-center", label: "Seller print-center generates labels/invoices correctly", href: "/store/print-center" },
266
+ { key: "seller-inventory-print", label: "Seller inventory print view works", href: "/store/inventory/print" },
267
+ ],
268
+ },
269
+ {
270
+ pageKey: "seller-marketing-extras",
271
+ pageLabel: "Seller Offers, Features, Google Reviews & WhatsApp Catalog",
272
+ cases: [
273
+ { key: "seller-offers-list", label: "Seller offers list shows accurate buyer offers", href: "/store/offers" },
274
+ { key: "seller-features-crud", label: "Seller can create and edit product features", href: "/store/features" },
275
+ { key: "seller-google-reviews-sync", label: "Google Reviews sync pulls in real reviews correctly", href: "/store/google-reviews" },
276
+ { key: "seller-whatsapp-catalog", label: "WhatsApp catalog import/push works", href: "/store/whatsapp" },
277
+ { key: "seller-reviews-response", label: "Seller can view and respond to product reviews", href: "/store/reviews" },
278
+ ],
279
+ },
280
+ {
281
+ pageKey: "seller-guide",
282
+ pageLabel: "Seller Guide Pages",
283
+ cases: [
284
+ { key: "seller-guide-pages", label: "The 5 seller guide pages (overview, capabilities, finance, listings, orders, settings) all load correctly", href: "/store/guide" },
185
285
  ],
186
286
  },
187
287
  ]),
@@ -199,8 +299,11 @@ export const testerChecklistSeedData = [
199
299
  pageLabel: "Events, Raffles & Spin Wheel",
200
300
  cases: [
201
301
  { key: "view-event", label: "Viewing an event detail page works", href: "/user/events" },
202
- { key: "raffle-entry", label: "Entering a raffle event works" },
203
- { key: "spin-wheel", label: "Spinning the spin-wheel event works and shows the prize" },
302
+ { key: "raffle-entry", label: "Entering an open_raffle event works" },
303
+ { key: "raffle-entry-top-n-scorers", label: "Entering a top_n_scorers raffle event works and the leaderboard reflects entries" },
304
+ { key: "raffle-entry-top-n-participants", label: "Entering a top_n_participants raffle event works" },
305
+ { key: "spin-wheel", label: "Spinning the spin-wheel event works and shows the prize immediately (realtime)" },
306
+ { key: "spin-wheel-window-blocked", label: "A second spin attempt within the same spinWindow is blocked with a clear message" },
204
307
  ],
205
308
  },
206
309
  {
@@ -209,6 +312,10 @@ export const testerChecklistSeedData = [
209
312
  cases: [
210
313
  { key: "view-claimed-coupons", label: "Claimed coupons list shows accurate coupons", href: "/user/coupons" },
211
314
  { key: "coupon-discount-applied", label: "Coupon discount is correctly reflected in the order total" },
315
+ { key: "coupon-expired-rejected", label: "Applying an expired coupon at checkout shows a specific expiry rejection message, not a generic error" },
316
+ { key: "coupon-below-min-purchase", label: "Applying a coupon below its minPurchase threshold is rejected with a clear message" },
317
+ { key: "coupon-not-combinable", label: "A seller-scoped coupon that isn't combinable with another seller's coupon is rejected when both are applied" },
318
+ { key: "coupon-per-user-limit", label: "Re-applying a coupon after hitting its perUserLimit is rejected" },
212
319
  ],
213
320
  },
214
321
  {
@@ -217,6 +324,18 @@ export const testerChecklistSeedData = [
217
324
  cases: [
218
325
  { key: "receive-notification", label: "In-app notifications appear for order/bid/message events", href: "/user/notifications" },
219
326
  { key: "mark-read", label: "Marking a notification as read works" },
327
+ { key: "notification-channel-prefs", label: "Per-channel notification preferences (in-app / email / WhatsApp) save and are respected" },
328
+ { key: "notification-type-sample", label: "A sample of notification types (order status change, bid outbid, message received, payout) each trigger correctly end-to-end" },
329
+ { key: "notification-tab-filters", label: "Notification tab filters correctly narrow the list", href: "/user/notifications" },
330
+ ],
331
+ },
332
+ {
333
+ pageKey: "faq-help",
334
+ pageLabel: "FAQ & Help",
335
+ cases: [
336
+ { key: "faq-bottom-borders", label: "FAQ question rows show a clear bottom-border divider on the homepage and the FAQs page", href: "/faqs" },
337
+ { key: "faq-mobile-count", label: "Homepage FAQ section shows a good number of questions on mobile, not just 1-2", href: "/" },
338
+ { key: "tabs-mobile-dropdown", label: "Tabs on category/brand/product/event detail pages collapse into a colored dropdown on mobile once there are more than 5 tabs" },
220
339
  ],
221
340
  },
222
341
  ]),
@@ -251,5 +370,39 @@ export const testerChecklistSeedData = [
251
370
  { key: "error-states", label: "Error states (404, form validation) look correct" },
252
371
  ],
253
372
  },
373
+ {
374
+ pageKey: "dashboard-layout",
375
+ pageLabel: "Dashboards — Collapsible Sections & Mobile Tables",
376
+ cases: [
377
+ { key: "collapsible-admin", label: "Admin dashboard sections expand/collapse and remember their state on reload", href: "/admin/dashboard" },
378
+ { key: "collapsible-store", label: "Store dashboard sections expand/collapse and remember their state on reload", href: "/store" },
379
+ { key: "collapsible-user", label: "User profile dashboard sections expand/collapse and remember their state on reload", href: "/user/profile" },
380
+ { key: "mobile-table-cards", label: "Admin/seller listing tables show full-row cards by default on mobile, with a working switch back to table view" },
381
+ { key: "view-mode-persist", label: "Switching the dashboard table/card view mode is remembered on your next visit (filters/search/sort are not)" },
382
+ ],
383
+ },
384
+ {
385
+ pageKey: "footer-theme",
386
+ pageLabel: "Footer & Dark Mode",
387
+ cases: [
388
+ { key: "footer-dark-mode", label: "Footer background and all link/text colors switch correctly between light and dark mode", href: "/" },
389
+ ],
390
+ },
391
+ {
392
+ pageKey: "homepage-carousels",
393
+ pageLabel: "Homepage Carousels",
394
+ cases: [
395
+ { key: "carousel-loops", label: "Homepage carousels (Shop by Category, Top Brands, Featured Products, Live Auctions, Reserve Before It Ships, Verified Stores, Tournaments & Events, Collector Reviews) loop back to the first item after reaching the last instead of getting stuck at the end", href: "/" },
396
+ { key: "carousel-no-flicker", label: "Homepage carousel auto-scroll does not flash/flicker when looping back to the first item" },
397
+ { key: "carousel-pause-on-interaction", label: "Homepage carousel auto-scroll pauses while hovering, touching, keyboard-focusing (Tab + arrow keys), or scrolling it, and resumes afterward" },
398
+ { key: "carousel-arrows-work", label: "Homepage carousel prev/next arrow buttons work and wrap around at both ends" },
399
+ { key: "hero-banner-loops", label: "Hero banner at the top of the homepage rotates through all slides and loops back to the first without getting stuck" },
400
+ { key: "sections-showcase-render", label: "Categories showcase and brands strip sections render real seeded data" },
401
+ { key: "sections-promo-render", label: "Deals/promotions, newsletter signup, and spotlight sections render correctly" },
402
+ { key: "sections-live-reserve-render", label: "Live auctions and \"Reserve Before It Ships\" (pre-orders) sections show real listings" },
403
+ { key: "sections-social-proof-render", label: "Verified stores, tournaments & events, and collector reviews sections render correctly" },
404
+ { key: "sections-social-feed-hidden", label: "The social-feed section type is correctly hidden on the homepage since it's disabled in Site Settings" },
405
+ ],
406
+ },
254
407
  ]),
255
408
  ];
package/dist/index.d.ts CHANGED
@@ -2448,7 +2448,7 @@ export { computeCheckoutFees, computePayoutDeduction, computeCodHandlingFee, cal
2448
2448
  export type { FeeCommissionRates, CheckoutFees, PayoutDeduction, CodHandlingFeeRates, GstBreakdown, } from "./_internal/shared/fees/calculator";
2449
2449
  export { checkEmiEligibility, computeEmiSchedule } from "./_internal/shared/features/emi/schedule";
2450
2450
  export type { EmiSettings, EmiIneligibleReason, EmiEligibility, EmiInstallmentPlan, EmiScheduleResult, } from "./_internal/shared/features/emi/schedule";
2451
- export { isAdminUser, isSellerUser, isModeratorUser, isEmployeeUser, isBuyerUser, } from "./features/auth/role-predicates";
2451
+ export { isAdminUser, isSellerUser, isModeratorUser, isEmployeeUser, isBuyerUser, isEffectiveAdminUser, } from "./features/auth/role-predicates";
2452
2452
  export { sanitizeProductForPublic, sanitizeProductsForPublic } from "./features/products/index";
2453
2453
  export { productAdminColumns } from "./features/products/index";
2454
2454
  export { productImageSchema } from "./features/products/index";
package/dist/index.js CHANGED
@@ -4494,7 +4494,7 @@ export { computeCheckoutFees, computePayoutDeduction, computeCodHandlingFee, cal
4494
4494
  // EMI eligibility + schedule computation (pure, client-safe — used for checkout quotes).
4495
4495
  export { checkEmiEligibility, computeEmiSchedule } from "./_internal/shared/features/emi/schedule";
4496
4496
  // User-role predicates â€" SB-UNI-E 2026-05-13.
4497
- export { isAdminUser, isSellerUser, isModeratorUser, isEmployeeUser, isBuyerUser, } from "./features/auth/role-predicates";
4497
+ export { isAdminUser, isSellerUser, isModeratorUser, isEmployeeUser, isBuyerUser, isEffectiveAdminUser, } from "./features/auth/role-predicates";
4498
4498
  // [SERVER-ONLY]-Safe in browser but intended for server routes.
4499
4499
  // sanitizeProductForPublic - Strip sellerId/sellerName/sellerEmail/ownerId before returning a product in a public response.
4500
4500
  export { sanitizeProductForPublic, sanitizeProductsForPublic } from "./features/products/index";
@@ -30,9 +30,10 @@ import { normalizeError } from "../../errors/normalize";
30
30
  */
31
31
  import { NextResponse } from "next/server.js";
32
32
  import { getProviders } from "../../contracts";
33
- import { isAdminUser } from "../../features/auth/role-predicates";
33
+ import { isEffectiveAdminUser } from "../../features/auth/role-predicates";
34
34
  import { mapToHttpError, HTTP_ERROR_CODES } from "../../errors/error-mapping";
35
35
  import { serverErrorsRepository } from "../../features/server-errors/repository/server-errors.repository";
36
+ import { userRepository } from "../../repositories";
36
37
  import { SCHEMAS } from "../../schemas/registry";
37
38
  function buildPublicCacheControl(policy) {
38
39
  const maxAge = policy?.maxAge ?? 30;
@@ -138,6 +139,20 @@ function errorJson(status, code, message, requestId, issues) {
138
139
  headers: { "X-Request-Id": requestId, "Cache-Control": "no-store" },
139
140
  });
140
141
  }
142
+ /**
143
+ * A tester explicitly flagged for admin testing (isTester && canTestAdmin) gets the
144
+ * same access as "admin" on routes that allow it. isTester/canTestAdmin never appear
145
+ * in the session-cookie JWT claims (only `role` does), so this requires one live
146
+ * Firestore read — paid only on the role-mismatch rejection path, never on the common
147
+ * case (matching role, or a real admin whose JWT role already says "admin").
148
+ */
149
+ async function isTesterEligibleForAdminRoute(user, effectiveRoles) {
150
+ if (!user || !effectiveRoles.includes("admin"))
151
+ return false;
152
+ const profile = await userRepository.findById(user.uid);
153
+ return Boolean(profile?.isTester &&
154
+ profile?.canTestAdmin);
155
+ }
141
156
  export function createRouteHandler(options) {
142
157
  if (options.bodyOptional && options.bodyRequired) {
143
158
  throw new Error("createRouteHandler: bodyOptional and bodyRequired are mutually exclusive");
@@ -175,11 +190,18 @@ export function createRouteHandler(options) {
175
190
  : [];
176
191
  if (effectiveRoles.length > 0) {
177
192
  if (!user || !effectiveRoles.includes(user.role ?? "")) {
178
- return errorJson(403, HTTP_ERROR_CODES.FORBIDDEN, "Insufficient permissions", requestId);
193
+ const testerEligible = await isTesterEligibleForAdminRoute(user, effectiveRoles);
194
+ if (testerEligible && user) {
195
+ user.isTester = true;
196
+ user.canTestAdmin = true;
197
+ }
198
+ else {
199
+ return errorJson(403, HTTP_ERROR_CODES.FORBIDDEN, "Insufficient permissions", requestId);
200
+ }
179
201
  }
180
202
  }
181
203
  // -- Permission check (employee fine-grained) --------------------------
182
- if (options.permission && user && !isAdminUser(user)) {
204
+ if (options.permission && user && !isEffectiveAdminUser(user)) {
183
205
  const { rbac } = getProviders();
184
206
  if (!rbac) {
185
207
  return errorJson(503, HTTP_ERROR_CODES.UNAVAILABLE, "RBAC provider not configured", requestId);
@@ -31,6 +31,8 @@ export interface SessionUser {
31
31
  storeId?: string;
32
32
  /** Tester program flag — orthogonal to `role`. Unlocks the Tester Hub nav item/page. */
33
33
  isTester?: boolean;
34
+ /** Orthogonal to isTester — unlocks admin-only checklist items and real /admin/** RBAC access. */
35
+ canTestAdmin?: boolean;
34
36
  publicProfile?: FirestoreDocument;
35
37
  stats?: FirestoreDocument;
36
38
  metadata?: FirestoreDocument;
@@ -64,6 +64,7 @@ function buildSessionUser(authUser, serverData) {
64
64
  phoneVerified: serverData.phoneVerified,
65
65
  storeId: serverData.storeId,
66
66
  isTester: serverData.isTester,
67
+ canTestAdmin: serverData.canTestAdmin,
67
68
  avatarMetadata: serverData.avatarMetadata?.url
68
69
  ? {
69
70
  url: serverData.avatarMetadata.url,
@@ -134,6 +135,7 @@ export function SessionProvider({ children, initialUser, endpoints: endpointOver
134
135
  phoneVerified: data.phoneVerified,
135
136
  storeId: data.storeId,
136
137
  isTester: data.isTester,
138
+ canTestAdmin: data.canTestAdmin,
137
139
  avatarMetadata: data.avatarMetadata?.url
138
140
  ? {
139
141
  url: data.avatarMetadata.url,