@mohasinac/appkit 4.29.0 → 4.30.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 (160) hide show
  1. package/dist/_internal/client/features/layout/SidebarCollapseToggle.js +1 -1
  2. package/dist/_internal/server/jobs/handlers/_helpers.d.ts +22 -0
  3. package/dist/_internal/server/jobs/handlers/_helpers.js +25 -1
  4. package/dist/_internal/shared/features/blog/schema.d.ts +2 -2
  5. package/dist/_internal/shared/features/blog/schema.js +2 -1
  6. package/dist/_internal/shared/features/brands/schema.d.ts +2 -2
  7. package/dist/_internal/shared/features/brands/schema.js +3 -1
  8. package/dist/_internal/shared/features/categories/bundle-schemas.d.ts +3 -3
  9. package/dist/_internal/shared/features/categories/bundle-schemas.js +2 -1
  10. package/dist/_internal/shared/features/events/schema.d.ts +2 -2
  11. package/dist/_internal/shared/features/events/schema.js +2 -1
  12. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  13. package/dist/_internal/shared/features/products/schema.js +3 -2
  14. package/dist/_internal/shared/features/reviews/schema.d.ts +1 -1
  15. package/dist/_internal/shared/features/reviews/schema.js +2 -1
  16. package/dist/_internal/shared/types/action-result.d.ts +0 -2
  17. package/dist/client/api/surface-error.d.ts +9 -0
  18. package/dist/client/api/surface-error.js +29 -3
  19. package/dist/client/api/useApiMutation.d.ts +10 -0
  20. package/dist/client/api/useApiMutation.js +2 -1
  21. package/dist/core/server-action.d.ts +3 -14
  22. package/dist/core/server-action.js +11 -2
  23. package/dist/errors/error-envelope.d.ts +73 -0
  24. package/dist/errors/error-envelope.js +96 -0
  25. package/dist/errors/error-handler.js +14 -7
  26. package/dist/features/account/components/NotificationBell.js +1 -1
  27. package/dist/features/account/components/UserSidebar.js +2 -2
  28. package/dist/features/account/components/UserSupportView.js +2 -6
  29. package/dist/features/account/hooks/useProfile.d.ts +6 -0
  30. package/dist/features/account/hooks/useProfile.js +1 -0
  31. package/dist/features/admin/components/AdminAdEditorView.js +1 -3
  32. package/dist/features/admin/components/AdminAddressClustersView.js +1 -3
  33. package/dist/features/admin/components/AdminAddressEditorView.js +2 -4
  34. package/dist/features/admin/components/AdminAddressesView.js +1 -3
  35. package/dist/features/admin/components/AdminAllEventEntriesView.js +1 -3
  36. package/dist/features/admin/components/AdminBidsView.js +1 -3
  37. package/dist/features/admin/components/AdminBlogEditorView.js +2 -2
  38. package/dist/features/admin/components/AdminBrandEditorView.js +4 -5
  39. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +1 -3
  40. package/dist/features/admin/components/AdminCategoryEditorView.js +2 -4
  41. package/dist/features/admin/components/AdminContactEditorView.js +1 -3
  42. package/dist/features/admin/components/AdminContactView.js +2 -6
  43. package/dist/features/admin/components/AdminCouponEditorView.js +2 -4
  44. package/dist/features/admin/components/AdminEmployeeEditorView.js +3 -9
  45. package/dist/features/admin/components/AdminFaqEditorView.js +2 -4
  46. package/dist/features/admin/components/AdminFeatureEditorView.js +0 -9
  47. package/dist/features/admin/components/AdminFeatureFlagsView.js +2 -4
  48. package/dist/features/admin/components/AdminNavEditorView.js +1 -3
  49. package/dist/features/admin/components/AdminNavigationView.js +3 -3
  50. package/dist/features/admin/components/AdminNewsletterView.js +1 -3
  51. package/dist/features/admin/components/AdminNotificationsView.js +2 -6
  52. package/dist/features/admin/components/AdminOffersView.js +1 -3
  53. package/dist/features/admin/components/AdminOrderEditorView.js +1 -3
  54. package/dist/features/admin/components/AdminPaymentClustersView.js +1 -3
  55. package/dist/features/admin/components/AdminPaymentMethodsView.js +1 -3
  56. package/dist/features/admin/components/AdminPayoutMarkPaidModal.js +2 -4
  57. package/dist/features/admin/components/AdminProductEditorView.js +2 -4
  58. package/dist/features/admin/components/AdminReturnRequestsView.js +2 -6
  59. package/dist/features/admin/components/AdminReviewsView.js +2 -6
  60. package/dist/features/admin/components/AdminScammerEditorView.js +2 -6
  61. package/dist/features/admin/components/AdminSectionsView.js +2 -6
  62. package/dist/features/admin/components/AdminSessionsView.js +1 -3
  63. package/dist/features/admin/components/AdminSidebar.js +2 -2
  64. package/dist/features/admin/components/AdminSiteSettingsView.js +1 -1
  65. package/dist/features/admin/components/AdminStoreEditorView.js +1 -3
  66. package/dist/features/admin/components/AdminStoresView.js +2 -6
  67. package/dist/features/admin/components/AdminSublistingCategoryEditorView.js +2 -6
  68. package/dist/features/admin/components/AdminSupportTicketDetailView.js +7 -21
  69. package/dist/features/admin/components/AdminTesterChecklistItemEditorView.js +2 -4
  70. package/dist/features/admin/components/AdminUserEditorView.js +6 -18
  71. package/dist/features/admin/components/AdminUsersView.js +2 -6
  72. package/dist/features/admin/components/BrandQuickCreateForm.js +2 -4
  73. package/dist/features/admin/components/CategoryQuickCreateForm.js +2 -4
  74. package/dist/features/auctions/components/PlaceBidFormClient.js +1 -1
  75. package/dist/features/auth/components/ForgotPasswordView.js +1 -1
  76. package/dist/features/auth/components/LoginForm.js +1 -1
  77. package/dist/features/auth/components/RegisterForm.js +1 -1
  78. package/dist/features/auth/components/ResetPasswordView.js +1 -1
  79. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  80. package/dist/features/auth/repository/session.repository.js +13 -1
  81. package/dist/features/blog/actions/blog-actions.d.ts +14 -14
  82. package/dist/features/blog/actions/blog-actions.js +6 -8
  83. package/dist/features/blog/schemas/index.d.ts +1 -1
  84. package/dist/features/blog/schemas/index.js +2 -4
  85. package/dist/features/categories/api/[id]/route.js +2 -1
  86. package/dist/features/categories/api/route.js +2 -1
  87. package/dist/features/events/schemas/index.d.ts +1 -1
  88. package/dist/features/events/schemas/index.js +2 -4
  89. package/dist/features/faq/components/ContactCTA.js +1 -1
  90. package/dist/features/filters/AsyncFacetSection.js +1 -1
  91. package/dist/features/filters/FilterFacetSection.js +1 -1
  92. package/dist/features/filters/RangeFilter.js +1 -1
  93. package/dist/features/filters/SwitchFilter.js +1 -1
  94. package/dist/features/homepage/api/carousel/[id]/route.js +4 -3
  95. package/dist/features/homepage/api/carousel/route.js +4 -3
  96. package/dist/features/homepage/api/route.js +3 -2
  97. package/dist/features/homepage/components/GoogleReviewsSection.js +1 -1
  98. package/dist/features/homepage/components/HeroBanner.js +1 -1
  99. package/dist/features/homepage/components/HeroCarousel.js +1 -1
  100. package/dist/features/homepage/components/SocialPostCard.js +1 -1
  101. package/dist/features/homepage/components/WhatsAppCommunitySection.js +1 -1
  102. package/dist/features/layout/AppLayoutShell.js +4 -4
  103. package/dist/features/layout/BottomNavbar.js +2 -2
  104. package/dist/features/layout/MainNavbar.js +1 -1
  105. package/dist/features/layout/TitleBarLayout.js +24 -12
  106. package/dist/features/media/AvatarUpload.js +28 -10
  107. package/dist/features/media/modals/ImageCropModal.js +1 -1
  108. package/dist/features/media/modals/ImageEditor.js +1 -1
  109. package/dist/features/products/components/MakeOfferButton.js +1 -1
  110. package/dist/features/reviews/api/[id]/route.js +3 -2
  111. package/dist/features/reviews/api/route.js +3 -2
  112. package/dist/features/search/components/Search.js +2 -2
  113. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +3 -3
  114. package/dist/features/seller/components/SellerSidebar.js +2 -2
  115. package/dist/features/store-extensions/components/ReviewDecisionModal.js +1 -1
  116. package/dist/features/stores/repository/store.repository.d.ts +21 -0
  117. package/dist/features/stores/repository/store.repository.js +32 -0
  118. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -2
  119. package/dist/index.d.ts +3 -2
  120. package/dist/index.js +4 -1
  121. package/dist/next/api/api-response.d.ts +29 -12
  122. package/dist/next/api/api-response.js +9 -8
  123. package/dist/next/api/apiHandler.d.ts +6 -1
  124. package/dist/next/api/apiHandler.js +13 -1
  125. package/dist/next/api/routeHandler.js +6 -6
  126. package/dist/next/middleware/pii-scrubber.js +5 -2
  127. package/dist/providers/db-firebase/base.repository.d.ts +14 -0
  128. package/dist/providers/db-firebase/base.repository.js +22 -0
  129. package/dist/providers/db-firebase/sieve.d.ts +14 -1
  130. package/dist/providers/db-firebase/sieve.js +3 -1
  131. package/dist/security/pii-encrypt.d.ts +0 -4
  132. package/dist/security/pii-encrypt.js +79 -13
  133. package/dist/security/pii-mask.d.ts +39 -1
  134. package/dist/security/pii-mask.js +33 -4
  135. package/dist/security/pii-redact.d.ts +0 -13
  136. package/dist/security/pii-redact.js +3 -1
  137. package/dist/security/pii-schemas.d.ts +0 -2
  138. package/dist/security/pii-schemas.js +16 -2
  139. package/dist/security/settings-encryption.js +31 -3
  140. package/dist/seed/stores-seed-data.js +18 -0
  141. package/dist/seed/test-utils.js +6 -2
  142. package/dist/ui/DataTable.js +2 -2
  143. package/dist/ui/components/CollapsibleSection.js +1 -1
  144. package/dist/ui/components/ImageGallery.js +1 -1
  145. package/dist/ui/components/ListingLayout.js +1 -1
  146. package/dist/ui/forms/FormShell.d.ts +0 -19
  147. package/dist/ui/forms/FormShell.js +4 -23
  148. package/dist/ui/forms/apply-zod-issues.d.ts +34 -0
  149. package/dist/ui/forms/apply-zod-issues.js +38 -0
  150. package/dist/ui/forms/index.d.ts +2 -1
  151. package/dist/ui/forms/index.js +2 -1
  152. package/dist/ui/icons/icon-registry.d.ts +8 -0
  153. package/dist/ui/icons/icon-registry.js +13 -1
  154. package/dist/utils/action-response.d.ts +1 -1
  155. package/dist/utils/media-url.d.ts +49 -0
  156. package/dist/utils/media-url.js +72 -1
  157. package/dist/validation/schemas.d.ts +15 -2
  158. package/dist/validation/schemas.js +18 -22
  159. package/package.json +1 -1
  160. package/scripts/seed-cli.mjs +10 -2
@@ -9,7 +9,9 @@
9
9
  * Ciphertext format: "enc:v1:<iv_b64>:<ciphertext_b64>:<authtag_b64>"
10
10
  * Blind index format: "hmac-sha256:<sha256_hex>"
11
11
  */
12
- import { ENC_PREFIX, HMAC_PREFIX, isPiiEncrypted } from "./pii-mask";
12
+ import { ENC_PREFIX, HMAC_PREFIX, isPiiEncrypted, encEnvelopeKind } from "./pii-mask";
13
+ import { serverLogger } from "../monitoring/server-logger";
14
+ import { normalizeError } from "../errors/normalize";
13
15
  // Re-exported for backward-compat with existing direct imports of these
14
16
  // symbols from this file — the actual crypto-free definitions live in
15
17
  // pii-mask.ts (see that file's header comment for why the split exists).
@@ -50,10 +52,32 @@ function getEncKey() {
50
52
  const hex = normalizePiiSecretValue(process.env.PII_ENCRYPTION_KEY);
51
53
  return Buffer.from(hex, "hex");
52
54
  }
55
+ /**
56
+ * Blind-index key. Normalised and validated exactly like `getEncKey`.
57
+ *
58
+ * It previously read the env var RAW while `getEncKey` normalised the same
59
+ * value — so the two keys were derived from one variable by two different code
60
+ * paths. `Buffer.from(x, "hex")` does not throw on bad input; it truncates
61
+ * silently at the first non-hex byte. A BOM or trailing CR (a problem this repo
62
+ * has hit before — see scripts/_fix-vercel-env-bom.mjs) would therefore give
63
+ * the encryptor a 32-byte key and the indexer a 0-byte one, and every blind
64
+ * index written from that moment would be unmatchable against the existing
65
+ * ones. `findByEmail` would simply stop finding people — indistinguishable
66
+ * from "no such user", with nothing logged.
67
+ *
68
+ * Verified before changing: on the current environment the raw and normalised
69
+ * reads produce byte-identical keys, so this is a no-op for every index
70
+ * already in Firestore.
71
+ */
53
72
  function getHmacKey() {
54
- const raw = process.env["PII_HMAC_KEY"] ?? process.env["PII_ENCRYPTION_KEY"] ?? "";
73
+ const raw = normalizePiiSecretValue(process.env["PII_HMAC_KEY"] ?? process.env["PII_ENCRYPTION_KEY"] ?? "");
55
74
  if (!raw)
56
75
  throw new Error("PII_HMAC_KEY env var is not set — cannot generate blind PII index");
76
+ if (!/^[0-9a-fA-F]{64}$/.test(raw))
77
+ // Fail loudly at the boundary. A startup error is recoverable; a silently
78
+ // truncated key is not.
79
+ throw new Error("PII_HMAC_KEY (or its PII_ENCRYPTION_KEY fallback) must be a 64-character hex string — " +
80
+ `got ${raw.length} chars. Buffer.from would truncate this silently and desync every blind index.`);
57
81
  return Buffer.from(raw, "hex");
58
82
  }
59
83
  /** Encrypt a plaintext string with AES-256-GCM. */
@@ -70,10 +94,19 @@ export function encryptValue(plaintext) {
70
94
  export function decryptValue(ciphertext) {
71
95
  if (!ciphertext.startsWith(ENC_PREFIX))
72
96
  return ciphertext;
97
+ // Name the actual problem. A settings-encrypted value shares this prefix but
98
+ // uses dot separators and a different key; it used to fail here as a bare
99
+ // part-count error, which reads like corruption rather than "wrong system".
100
+ if (encEnvelopeKind(ciphertext) === "settings") {
101
+ throw new Error("Value is settings-encrypted (SETTINGS_ENCRYPTION_KEY, dot-separated), not PII-encrypted — decryptValue cannot read it");
102
+ }
73
103
  const inner = ciphertext.slice(ENC_PREFIX.length);
74
104
  const parts = inner.split(":");
75
105
  if (parts.length !== 3)
76
- throw new Error(`Invalid PII ciphertext: ${ciphertext}`);
106
+ // NEVER interpolate the value. This used to embed the whole ciphertext,
107
+ // which then flowed into DatabaseError and serverLogger.error — encrypted
108
+ // PII sitting in the logs on every malformed row. Shape only.
109
+ throw new Error(`Invalid PII ciphertext: expected 3 colon-separated parts after ${ENC_PREFIX}, got ${parts.length} (${ciphertext.length} chars)`);
77
110
  const [ivB64, encB64, tagB64] = parts;
78
111
  const { createDecipheriv } = nodeCrypto();
79
112
  const key = getEncKey();
@@ -124,14 +157,20 @@ function setPath(obj, path, value) {
124
157
  /**
125
158
  * Encrypt the named fields, adding a blind index alongside each top-level one.
126
159
  *
127
- * Supports dotted paths (`deviceInfo.ip`, `whatsappConfig.accessToken`). This
128
- * was a flat top-level loop that skipped anything non-string, which is why
129
- * nested PII stayed in plaintext — and why the exported
130
- * `STORE_SECRET_FIELDS = ["whatsappConfig.accessToken"]` was unusable by the
131
- * very function it was written for.
160
+ * Supports dotted paths (`deviceInfo.ip`, `location.city`). This was a flat
161
+ * top-level loop that skipped anything non-string, which is why nested PII
162
+ * stayed in plaintext.
163
+ *
164
+ * 🛑 That dotted-path support is also what made it possible to point this
165
+ * function at a field belonging to the OTHER crypto system. A store's
166
+ * `whatsappConfig.accessToken` is encrypted with SETTINGS_ENCRYPTION_KEY;
167
+ * passing it here would encrypt it with the PII key while
168
+ * `StoreRepository.decryptSecrets` kept decrypting with the settings one.
169
+ * Only ever pass fields from this file's own `*_PII_FIELDS` lists.
132
170
  *
133
- * Idempotent: a value already carrying `enc:v1:` is left alone, so re-running
134
- * over a partially-migrated document is safe.
171
+ * Idempotent: a value already carrying `enc:v1:` is left alone — whichever
172
+ * system wrote it — so re-running over a partially-migrated document is safe
173
+ * and a settings-encrypted value is never double-wrapped.
135
174
  */
136
175
  /**
137
176
  * Encrypt `itemPath` on every element of the array at `arrayPath`.
@@ -192,6 +231,33 @@ export function encryptPiiFields(doc, piiFields) {
192
231
  * Decrypt specified PII fields in a Firestore document.
193
232
  * Fields that are not encrypted (no `enc:v1:` prefix) are passed through.
194
233
  */
234
+ /**
235
+ * Decrypt one value, or return a sentinel and warn.
236
+ *
237
+ * A single corrupt or foreign-format row must not take out the page. Before
238
+ * the sieve read path started calling `mapDoc` (which is what decrypts),
239
+ * a bad value was merely rendered as ciphertext — cosmetic. Once decryption
240
+ * actually runs on list reads, an unguarded throw inside a 100-row admin page
241
+ * turns that cosmetic defect into a 500 for the whole list.
242
+ *
243
+ * Same fail-soft posture `SiteSettingsRepository.getDecryptedCredentials`
244
+ * already takes for the settings key.
245
+ *
246
+ * The warning names the FIELD, never the value.
247
+ */
248
+ function decryptOrWarn(value, field) {
249
+ try {
250
+ return decryptValue(value);
251
+ }
252
+ catch (err) {
253
+ const normalized = normalizeError(err);
254
+ serverLogger.warn("PII decrypt failed — field left empty", {
255
+ field,
256
+ reason: normalized.message,
257
+ });
258
+ return "";
259
+ }
260
+ }
195
261
  export function decryptPiiFields(doc, piiFields) {
196
262
  const result = { ...doc };
197
263
  for (const field of piiFields) {
@@ -211,7 +277,7 @@ export function decryptPiiFields(doc, piiFields) {
211
277
  const clone = { ...el };
212
278
  const value = itemPath ? getPath(clone, itemPath) : undefined;
213
279
  if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
214
- setPath(clone, itemPath, decryptValue(value));
280
+ setPath(clone, itemPath, decryptOrWarn(value, field));
215
281
  }
216
282
  return clone;
217
283
  }));
@@ -220,13 +286,13 @@ export function decryptPiiFields(doc, piiFields) {
220
286
  if (field.includes(".")) {
221
287
  const value = getPath(result, field);
222
288
  if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
223
- setPath(result, field, decryptValue(value));
289
+ setPath(result, field, decryptOrWarn(value, field));
224
290
  }
225
291
  continue;
226
292
  }
227
293
  const value = result[field];
228
294
  if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
229
- result[field] = decryptValue(value);
295
+ result[field] = decryptOrWarn(value, field);
230
296
  }
231
297
  }
232
298
  return result;
@@ -14,7 +14,45 @@
14
14
  */
15
15
  export declare const ENC_PREFIX = "enc:v1:";
16
16
  export declare const HMAC_PREFIX = "hmac-sha256:";
17
- /** Check if a value is encrypted by our PII system. */
17
+ /**
18
+ * Which crypto system produced an `enc:v1:` value.
19
+ *
20
+ * TWO systems share this prefix and always have:
21
+ *
22
+ * PII `enc:v1:` + iv `:` ciphertext `:` tag — PII_ENCRYPTION_KEY
23
+ * settings `enc:v1:` + iv `.` tag `.` ciphertext — SETTINGS_ENCRYPTION_KEY
24
+ *
25
+ * Different separator, different field order, different key. `isPiiEncrypted`
26
+ * checked only the prefix and so could not tell them apart, which made every
27
+ * "is this ours" test wrong for one of the two — feed a settings value to
28
+ * `decryptValue` and it throws on the part count; feed a PII value to
29
+ * `decryptSecret` and it throws on a different part count. Neither error names
30
+ * the actual problem.
31
+ *
32
+ * Changing either prefix is NOT an option: `enc:v1:` is stamped into every
33
+ * encrypted value across twelve collections, and a new prefix orphans all of
34
+ * it. It does not need changing. Base64's alphabet is `A-Za-z0-9+/=`, which
35
+ * contains neither `.` nor `:` — so the two formats are ALREADY unambiguous in
36
+ * the data, and this function simply reads what is there. No migration.
37
+ */
38
+ export type EncEnvelopeKind = "pii" | "settings" | null;
39
+ export declare function encEnvelopeKind(value: unknown): EncEnvelopeKind;
40
+ /**
41
+ * Does this value carry the `enc:v1:` prefix at all, whichever system wrote it
42
+ * and even if malformed?
43
+ *
44
+ * This is the check the DISPLAY masks want. `maskName`/`maskEmail` exist so
45
+ * ciphertext never reaches the UI, and for that purpose "settings-encrypted"
46
+ * and "malformed" are just as unshowable as "PII-encrypted". Narrowing them to
47
+ * `kind === "pii"` would let the other two render verbatim.
48
+ */
49
+ export declare function hasEncPrefix(value: unknown): boolean;
50
+ /**
51
+ * Check if a value is encrypted by our PII system SPECIFICALLY.
52
+ *
53
+ * Use for crypto routing — "may I hand this to decryptValue". For "is this
54
+ * unshowable", use `hasEncPrefix`.
55
+ */
18
56
  export declare function isPiiEncrypted(value: string): boolean;
19
57
  /**
20
58
  * Mask a person's name for public display.
@@ -14,10 +14,39 @@
14
14
  */
15
15
  export const ENC_PREFIX = "enc:v1:";
16
16
  export const HMAC_PREFIX = "hmac-sha256:";
17
- /** Check if a value is encrypted by our PII system. */
18
- export function isPiiEncrypted(value) {
17
+ export function encEnvelopeKind(value) {
18
+ if (typeof value !== "string" || !value.startsWith(ENC_PREFIX))
19
+ return null;
20
+ const inner = value.slice(ENC_PREFIX.length);
21
+ const colons = inner.split(":").length - 1;
22
+ const dots = inner.split(".").length - 1;
23
+ if (colons === 2 && dots === 0)
24
+ return "pii";
25
+ if (dots === 2 && colons === 0)
26
+ return "settings";
27
+ return null; // carries the prefix but neither shape — malformed
28
+ }
29
+ /**
30
+ * Does this value carry the `enc:v1:` prefix at all, whichever system wrote it
31
+ * and even if malformed?
32
+ *
33
+ * This is the check the DISPLAY masks want. `maskName`/`maskEmail` exist so
34
+ * ciphertext never reaches the UI, and for that purpose "settings-encrypted"
35
+ * and "malformed" are just as unshowable as "PII-encrypted". Narrowing them to
36
+ * `kind === "pii"` would let the other two render verbatim.
37
+ */
38
+ export function hasEncPrefix(value) {
19
39
  return typeof value === "string" && value.startsWith(ENC_PREFIX);
20
40
  }
41
+ /**
42
+ * Check if a value is encrypted by our PII system SPECIFICALLY.
43
+ *
44
+ * Use for crypto routing — "may I hand this to decryptValue". For "is this
45
+ * unshowable", use `hasEncPrefix`.
46
+ */
47
+ export function isPiiEncrypted(value) {
48
+ return encEnvelopeKind(value) === "pii";
49
+ }
21
50
  /**
22
51
  * Mask a person's name for public display.
23
52
  * Each word is reduced to its first letter followed by "***".
@@ -31,7 +60,7 @@ export function isPiiEncrypted(value) {
31
60
  export function maskName(name) {
32
61
  if (!name || typeof name !== "string")
33
62
  return "Anonymous";
34
- if (isPiiEncrypted(name))
63
+ if (hasEncPrefix(name))
35
64
  return "Anonymous";
36
65
  return name
37
66
  .trim()
@@ -47,7 +76,7 @@ export function maskName(name) {
47
76
  export function maskEmail(email) {
48
77
  if (!email || typeof email !== "string")
49
78
  return "***@***.***";
50
- if (isPiiEncrypted(email))
79
+ if (hasEncPrefix(email))
51
80
  return "***@***.***";
52
81
  const atIdx = email.indexOf("@");
53
82
  if (atIdx < 0)
@@ -1,16 +1,3 @@
1
- /**
2
- * PII Redaction Utility
3
- *
4
- * Recursively sanitizes objects before logging by masking or redacting
5
- * known PII (Personally Identifiable Information) field names.
6
- *
7
- * - Secrets (password, token, apiKey, etc.) → "[REDACTED]"
8
- * - Emails → partial mask: "mo***@gmail.com"
9
- * - Phones → last 4 digits: "***9876"
10
- * - Names → first char + asterisks: "J********"
11
- * - Addresses, financial IDs → "[REDACTED]"
12
- * - IPs → first octet only: "192.***.***.***"
13
- */
14
1
  /**
15
2
  * Mask an IPv4 address by zeroing the last three octets. Returns
16
3
  * `[REDACTED]` for IPv6 / malformed input.
@@ -1,3 +1,4 @@
1
+ import { ENC_PREFIX } from "./pii-mask";
1
2
  /**
2
3
  * PII Redaction Utility
3
4
  *
@@ -138,7 +139,8 @@ export function redactPii(data, depth = 0) {
138
139
  // ---------------------------------------------------------------------------
139
140
  // UI display helpers
140
141
  // ---------------------------------------------------------------------------
141
- const ENC_TOKEN_PREFIX = "enc:v1:";
142
+ // One declaration of the prefix, shared with the crypto that produces it.
143
+ const ENC_TOKEN_PREFIX = ENC_PREFIX;
142
144
  /**
143
145
  * Safe display name for untrusted / possibly-encrypted PII fields.
144
146
  *
@@ -66,7 +66,5 @@ export declare const CHAT_PII_FIELDS: readonly ["buyerName", "sellerName"];
66
66
  export declare const EVENT_ENTRY_PII_FIELDS: readonly ["userDisplayName", "userEmail", "ipAddress"];
67
67
  /** PII fields in lottery entries */
68
68
  export declare const LOTTERY_ENTRY_PII_FIELDS: readonly ["userPhone", "userEmail", "userDisplayName"];
69
- /** Store OAuth bearer tokens — encrypted via SETTINGS_ENCRYPTION_KEY */
70
- export declare const STORE_SECRET_FIELDS: readonly ["whatsappConfig.accessToken"];
71
69
  /** PII fields in savedPaymentMethods — full identifier (UPI VPA, card PAN, etc.) */
72
70
  export declare const PAYMENT_METHOD_PII_FIELDS: readonly ["identifier"];
@@ -99,7 +99,21 @@ export const LOTTERY_ENTRY_PII_FIELDS = [
99
99
  "userEmail",
100
100
  "userDisplayName",
101
101
  ];
102
- /** Store OAuth bearer tokens — encrypted via SETTINGS_ENCRYPTION_KEY */
103
- export const STORE_SECRET_FIELDS = ["whatsappConfig.accessToken"];
102
+ // `STORE_SECRET_FIELDS = ["whatsappConfig.accessToken"]` was deleted here.
103
+ //
104
+ // It had ZERO consumers, and its presence in this file was the whole hazard:
105
+ // it sat in the PII-field registry, formatted identically to the entries above
106
+ // that ARE assigned to a repository's `piiFields` — while its own doc-comment
107
+ // said it was encrypted with SETTINGS_ENCRYPTION_KEY, the other key entirely.
108
+ //
109
+ // Wiring it into a `piiFields` override would have encrypted every store's Meta
110
+ // bearer token with the PII key while `StoreRepository.decryptSecrets` kept
111
+ // decrypting with the settings key: a hard throw on every store read, and
112
+ // live seller tokens unrecoverable without a bespoke migration. Dotted-path
113
+ // support was added to `encryptPiiFields` in 2026-08, which is exactly what
114
+ // made that one-line mistake possible.
115
+ //
116
+ // The token is handled where it belongs — `encryptSecret`/`decryptSecret` in
117
+ // `StoreRepository`. A constant does not need to exist for that.
104
118
  /** PII fields in savedPaymentMethods — full identifier (UPI VPA, card PAN, etc.) */
105
119
  export const PAYMENT_METHOD_PII_FIELDS = ["identifier"];
@@ -1,11 +1,32 @@
1
1
  // crypto is a Node.js built-in. Use require() to keep it out of the static
2
2
  // import graph so Next.js Edge bundler does not warn about this file.
3
3
  function nodeCrypto() { return require("crypto"); }
4
+ import { ENC_PREFIX, encEnvelopeKind } from "./pii-mask";
4
5
  const ALGORITHM = "aes-256-gcm";
5
6
  const IV_BYTES = 12;
6
- const PREFIX = "enc:v1:";
7
+ // ONE declaration of the prefix, shared with the PII system that also uses it.
8
+ // Two independent `"enc:v1:"` literals is what let the two formats drift into
9
+ // looking identical to every "is this encrypted" check in the codebase.
10
+ const PREFIX = ENC_PREFIX;
11
+ /**
12
+ * Strip a trailing CR/LF (literal or escaped) and surrounding whitespace —
13
+ * including a leading BOM, which `String.prototype.trim` removes.
14
+ *
15
+ * Mirrors `normalizePiiSecretValue` in pii-encrypt.ts rather than importing
16
+ * it: that module is the Node-crypto half of the PII system and this file must
17
+ * not depend on it. The two are four lines and must stay identical; the
18
+ * UNNORMALISED_HEX_KEY rule in audit-pii-crypto is what keeps them honest.
19
+ */
20
+ function normalizeSecretEnvValue(raw) {
21
+ return (raw ?? "").replace(/(?:\\r|\\n|\r|\n)+$/g, "").trim();
22
+ }
7
23
  function getMasterKey() {
8
- const hex = process.env.SETTINGS_ENCRYPTION_KEY;
24
+ // Normalise before measuring AND before Buffer.from. A BOM or trailing CR
25
+ // makes `length !== 64` true, so this threw a confusing "must be 64
26
+ // characters" on a key that looks correct in the dashboard — and had the
27
+ // length check not been there, Buffer.from would have truncated at the first
28
+ // non-hex byte and produced a silently WRONG key instead.
29
+ const hex = normalizeSecretEnvValue(process.env.SETTINGS_ENCRYPTION_KEY);
9
30
  if (!hex || hex.length !== 64) {
10
31
  throw new Error("SETTINGS_ENCRYPTION_KEY must be a 64-character hex string (32 bytes).");
11
32
  }
@@ -27,6 +48,13 @@ export function decryptSecret(encrypted) {
27
48
  if (!encrypted.startsWith(PREFIX)) {
28
49
  return encrypted;
29
50
  }
51
+ // Name the actual problem. A PII-encrypted value reaching here used to throw
52
+ // "expected 3 parts", which describes the symptom and not the cause — it is
53
+ // the wrong KEY and the wrong FORMAT, not a corrupt value.
54
+ const kind = encEnvelopeKind(encrypted);
55
+ if (kind === "pii") {
56
+ throw new Error("Value is PII-encrypted (PII_ENCRYPTION_KEY, colon-separated), not settings-encrypted — decryptSecret cannot read it");
57
+ }
30
58
  const parts = encrypted.slice(PREFIX.length).split(".");
31
59
  if (parts.length !== 3) {
32
60
  throw new Error("Invalid encrypted value - expected 3 parts");
@@ -41,7 +69,7 @@ export function decryptSecret(encrypted) {
41
69
  ]).toString("utf8");
42
70
  }
43
71
  export function isSecretEncrypted(value) {
44
- return value.startsWith(PREFIX);
72
+ return encEnvelopeKind(value) === "settings";
45
73
  }
46
74
  export function maskSecret(value) {
47
75
  if (!value || value.length < 12) {
@@ -178,6 +178,24 @@ const _rawstoresSeedData = [
178
178
  },
179
179
  isPublic: true,
180
180
  isVacationMode: false,
181
+ /*
182
+ * Reachable, but never advertised to search engines.
183
+ *
184
+ * This is a QA store — real enough for a tester to exercise seller flows
185
+ * against, and not something that belongs in Google's index. `isTestData`
186
+ * is what the codebase already uses to express that: the sitemap's store
187
+ * fetcher filters it out (it has no viewer), public store listings hide
188
+ * it, and `canViewTestData()` keeps it visible to testers and admins — so
189
+ * the checklist case linking to `/stores/store-tester-qa-seller` still
190
+ * resolves.
191
+ *
192
+ * 🛑 NOTE the deliberate absence of `testDataExpiresAt`. Every sandbox
193
+ * FIXTURE carries one; this store is permanent. `getTestDataRefs` skips
194
+ * docs without an expiry on the force-purge path precisely so
195
+ * `tester:purge-sandbox --force` cannot delete it. Do not add an expiry
196
+ * here — that would put a permanent seed store in range of the sweep.
197
+ */
198
+ isTestData: true,
181
199
  stats: { totalProducts: 0, itemsSold: 0, totalReviews: 0, averageRating: 0 },
182
200
  capabilities: ["host_preorders", "verified_seller", "create_coupons"],
183
201
  createdAt: daysAgo(1),
@@ -1,5 +1,6 @@
1
1
  import { runSeed } from "./runner";
2
2
  import { encryptPiiFields, decryptPiiFields, ENC_PREFIX } from "../security/pii-encrypt";
3
+ import { hasEncPrefix } from "../security/pii-mask";
3
4
  import { DEFAULT_CATEGORIES } from "./defaults/categories";
4
5
  import { DEFAULT_FAQS } from "./defaults/faqs";
5
6
  import { makeProduct } from "./factories/product.factory";
@@ -24,8 +25,11 @@ export async function assertPiiRoundTrip(doc, piiFields) {
24
25
  const encrypted = encryptPiiFields(doc, piiFields);
25
26
  for (const field of piiFields) {
26
27
  const val = encrypted[field];
27
- if (!val.startsWith(ENC_PREFIX)) {
28
- throw new Error(`assertPiiRoundTrip: field "${String(field)}" was NOT encrypted. Got: ${val}`);
28
+ // `hasEncPrefix`, not a bare startsWith: the assertion is "this is not
29
+ // cleartext", which both crypto formats satisfy. And the value NEVER goes
30
+ // in the message — this helper runs over real fixture PII.
31
+ if (!hasEncPrefix(val)) {
32
+ throw new Error(`assertPiiRoundTrip: field "${String(field)}" was NOT encrypted (got ${val.length} chars, no ${ENC_PREFIX} prefix).`);
29
33
  }
30
34
  // Verify blind index was written
31
35
  const indexKey = `${String(field)}Index`;
@@ -108,7 +108,7 @@ pageSize: pageSizeProp, stickyHeader: stickyHeaderProp, striped: stripedProp, sh
108
108
  const renderViewToggle = () => {
109
109
  if (!showViewToggle)
110
110
  return null;
111
- return (_jsxs(Div, { className: "appkit-data-table__view-toggle", role: "toolbar", "aria-label": "View mode", children: [showTableView && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("table"), "aria-label": tableView, "aria-pressed": activeViewMode === "table", className: `appkit-data-table__view-btn ${activeViewMode === "table" ? "appkit-data-table__view-btn--active" : ""} hidden sm:flex`, children: _jsx("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 10h18M3 6h18M3 14h18M3 18h18" }) }) })), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("grid"), "aria-label": gridView, "aria-pressed": activeViewMode === "grid", className: `appkit-data-table__view-btn ${activeViewMode === "grid" ? "appkit-data-table__view-btn--active" : ""}`, children: _jsx("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" }) }) }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("list"), "aria-label": listView, "aria-pressed": activeViewMode === "list", className: `appkit-data-table__view-btn ${activeViewMode === "list" ? "appkit-data-table__view-btn--active" : ""}`, children: _jsx("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" }) }) })] }));
111
+ return (_jsxs(Div, { className: "appkit-data-table__view-toggle", role: "toolbar", "aria-label": "View mode", children: [showTableView && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("table"), "aria-label": tableView, "aria-pressed": activeViewMode === "table", className: `appkit-data-table__view-btn ${activeViewMode === "table" ? "appkit-data-table__view-btn--active" : ""} hidden sm:flex`, children: _jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 10h18M3 6h18M3 14h18M3 18h18" }) }) })), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("grid"), "aria-label": gridView, "aria-pressed": activeViewMode === "grid", className: `appkit-data-table__view-btn ${activeViewMode === "grid" ? "appkit-data-table__view-btn--active" : ""}`, children: _jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" }) }) }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("list"), "aria-label": listView, "aria-pressed": activeViewMode === "list", className: `appkit-data-table__view-btn ${activeViewMode === "list" ? "appkit-data-table__view-btn--active" : ""}`, children: _jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" }) }) })] }));
112
112
  };
113
113
  // --- Card views (grid / list) ------------------------------------------------
114
114
  const renderCardGrid = (mode) => {
@@ -158,5 +158,5 @@ function SelectableCard({ id, selectable, selected, onToggle, children, listMode
158
158
  selected
159
159
  ? "border-2 border-primary bg-primary"
160
160
  : "border-2 border-zinc-500 dark:border-slate-400 bg-transparent group-hover:border-primary",
161
- ].join(" "), checked: selected, onChange: (e) => onToggle(id, e.target.checked), "aria-label": "Select item" }), selected && (_jsx("svg", { className: "absolute inset-0 m-auto h-3 w-3 text-white pointer-events-none", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 3, d: "M5 13l4 4L19 7" }) }))] }) }) }), selected && (_jsx(Div, { className: "absolute inset-0 z-[var(--appkit-z-raised)] ring-2 ring-primary ring-offset-0 pointer-events-none", rounded: "xl", "aria-hidden": "true" })), children] }));
161
+ ].join(" "), checked: selected, onChange: (e) => onToggle(id, e.target.checked), "aria-label": "Select item" }), selected && (_jsx("svg", { className: "absolute inset-0 m-auto h-3 w-3 text-white pointer-events-none shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 3, d: "M5 13l4 4L19 7" }) }))] }) }) }), selected && (_jsx(Div, { className: "absolute inset-0 z-[var(--appkit-z-raised)] ring-2 ring-primary ring-offset-0 pointer-events-none", rounded: "xl", "aria-hidden": "true" })), children] }));
162
162
  }
@@ -9,7 +9,7 @@ import { Collapse } from "./Motion";
9
9
  * server-side merge/default-collapsed semantics). Purely presentational
10
10
  * here: isCollapsed/onToggle are always controlled by the caller. */
11
11
  export function CollapsibleSection({ title, isCollapsed, onToggle, renderHeaderExtra, keepMounted = false, id, className = "", children, }) {
12
- return (_jsxs(Div, { id: id, className: className, rounded: "xl", border: "default", surface: "default", children: [_jsxs(Row, { align: "center", justify: "between", paddingX: "x-md", paddingY: "y-sm", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: onToggle, paddingX: "none", paddingY: "none", rounded: "none", className: "min-w-0 flex-1", "aria-expanded": !isCollapsed, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { weight: "semibold", size: "sm", children: title }), _jsx("svg", { className: `w-4 h-4 transition-transform duration-150 ${isCollapsed ? "" : "rotate-180"}`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M19 9l-7 7-7-7" }) })] }) }), renderHeaderExtra && _jsx(Div, { className: "ml-3 shrink-0", children: renderHeaderExtra() })] }), keepMounted ? (
12
+ return (_jsxs(Div, { id: id, className: className, rounded: "xl", border: "default", surface: "default", children: [_jsxs(Row, { align: "center", justify: "between", paddingX: "x-md", paddingY: "y-sm", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: onToggle, paddingX: "none", paddingY: "none", rounded: "none", className: "min-w-0 flex-1", "aria-expanded": !isCollapsed, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { weight: "semibold", size: "sm", children: title }), _jsx("svg", { className: `w-4 h-4 transition-transform duration-150 ${isCollapsed ? "" : "rotate-180"} shrink-0`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M19 9l-7 7-7-7" }) })] }) }), renderHeaderExtra && _jsx(Div, { className: "ml-3 shrink-0", children: renderHeaderExtra() })] }), keepMounted ? (
13
13
  // No <Collapse>: it unmounts on close, which would discard uncommitted
14
14
  // child state (see `keepMounted` docs). `hidden` keeps the subtree
15
15
  // mounted and removes it from the a11y tree at the same time.
@@ -107,7 +107,7 @@ export default function ImageGallery({ images, initialIndex = 0, showThumbnails
107
107
  ? "zoom-out"
108
108
  : "zoom-in"
109
109
  : "default",
110
- } }), images.length > 1 && !isZoomed && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", onClick: goToPrevious, disabled: currentIndex === 0, className: `appkit-image-gallery__nav-btn appkit-image-gallery__nav-btn--prev ${themed.bgSecondary} ${themed.textPrimary}`, "aria-label": "Previous image", children: _jsx("svg", { className: "w-5 h-5 md:w-6 md:h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }) }), _jsx(Button, { variant: "ghost", onClick: goToNext, disabled: currentIndex === images.length - 1, className: `appkit-image-gallery__nav-btn appkit-image-gallery__nav-btn--next ${themed.bgSecondary} ${themed.textPrimary}`, "aria-label": "Next image", children: _jsx("svg", { className: "w-5 h-5 md:w-6 md:h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 5l7 7-7 7" }) }) })] })), images.length > 1 && (_jsxs("div", { className: `appkit-image-gallery__counter ${themed.bgSecondary} ${themed.textPrimary}`, "data-section": "imagegallery-div-519", children: [currentIndex + 1, " / ", images.length] })), allowZoom && isZoomed && (_jsxs("div", { className: `appkit-image-gallery__zoom-indicator ${themed.bgSecondary} ${themed.textPrimary}`, "data-section": "imagegallery-div-520", children: [Math.round(scale * 100), "%"] }))] }), showCaptions && currentImage.caption && (_jsx("div", { className: `appkit-image-gallery__caption ${themed.border}`, "data-section": "imagegallery-div-521", children: _jsx(Text, { className: themed.textSecondary, children: currentImage.caption }) }))] }), showThumbnails && images.length > 1 && (_jsx(HorizontalScroller, { snapToItems: true, className: "mt-4 pb-2", children: images.map((image, index) => (_jsx(Button, { variant: "ghost", onClick: () => goToIndex(index), className: `appkit-image-gallery__thumbnail ${index === currentIndex
110
+ } }), images.length > 1 && !isZoomed && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", onClick: goToPrevious, disabled: currentIndex === 0, className: `appkit-image-gallery__nav-btn appkit-image-gallery__nav-btn--prev ${themed.bgSecondary} ${themed.textPrimary}`, "aria-label": "Previous image", children: _jsx("svg", { className: "w-5 h-5 md:w-6 md:h-6 shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }) }), _jsx(Button, { variant: "ghost", onClick: goToNext, disabled: currentIndex === images.length - 1, className: `appkit-image-gallery__nav-btn appkit-image-gallery__nav-btn--next ${themed.bgSecondary} ${themed.textPrimary}`, "aria-label": "Next image", children: _jsx("svg", { className: "w-5 h-5 md:w-6 md:h-6 shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 5l7 7-7 7" }) }) })] })), images.length > 1 && (_jsxs("div", { className: `appkit-image-gallery__counter ${themed.bgSecondary} ${themed.textPrimary}`, "data-section": "imagegallery-div-519", children: [currentIndex + 1, " / ", images.length] })), allowZoom && isZoomed && (_jsxs("div", { className: `appkit-image-gallery__zoom-indicator ${themed.bgSecondary} ${themed.textPrimary}`, "data-section": "imagegallery-div-520", children: [Math.round(scale * 100), "%"] }))] }), showCaptions && currentImage.caption && (_jsx("div", { className: `appkit-image-gallery__caption ${themed.border}`, "data-section": "imagegallery-div-521", children: _jsx(Text, { className: themed.textSecondary, children: currentImage.caption }) }))] }), showThumbnails && images.length > 1 && (_jsx(HorizontalScroller, { snapToItems: true, className: "mt-4 pb-2", children: images.map((image, index) => (_jsx(Button, { variant: "ghost", onClick: () => goToIndex(index), className: `appkit-image-gallery__thumbnail ${index === currentIndex
111
111
  ? "appkit-image-gallery__thumbnail--active"
112
112
  : themed.border}`, "aria-label": `View image ${index + 1}`, children: _jsx(MediaImage, { src: image.thumbnail || image.src, alt: image.alt, size: "thumbnail", className: "w-full h-full object-cover" }) }, index))) })), _jsx("div", { className: `appkit-image-gallery__help ${themed.textMuted}`, "data-section": "imagegallery-div-522", children: allowZoom
113
113
  ? "Swipe to navigate • Double tap to zoom"
@@ -106,5 +106,5 @@ export function ListingLayout({ headerSlot, statusTabsSlot, filterContent, filte
106
106
  : l.applyFilters })] }), children: filterContent })), overlays] }));
107
107
  }
108
108
  function FilterIcon() {
109
- return (_jsx("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" }) }));
109
+ return (_jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" }) }));
110
110
  }
@@ -94,25 +94,6 @@ export interface UseFormShellStateResult {
94
94
  */
95
95
  markSubmitAttempted: () => void;
96
96
  }
97
- /**
98
- * `applyZodIssues` — pipe every issue from a Zod safeParse failure into the
99
- * FormShellContext error map. Use after `schema.safeParse(values)` returns
100
- * `success: false` to surface inline errors on `<FieldInput>` etc.
101
- *
102
- * Keys are the full dotted/indexed path (`"video.duration"`, `"images.2"`),
103
- * not just the top-level segment — a plain `issue.path[0]` key would collapse
104
- * every nested issue under one object onto the same map entry, silently
105
- * dropping all but the last. Top-level-only fields (the common case) are
106
- * unaffected: a single-segment path joins to the same string as before.
107
- *
108
- * Root-level `.refine()` issues (empty `path: []`, no owning field) are still
109
- * skipped — there is no field to attach them to, and no schema in this
110
- * codebase currently relies on one being surfaced this way.
111
- */
112
- export declare function applyZodIssues(issues: ReadonlyArray<{
113
- path: ReadonlyArray<PropertyKey>;
114
- message: string;
115
- }>, setFieldError: (name: string, error: string | null) => void): void;
116
97
  /**
117
98
  * `useFormShellState(schema?, nav?)` — caller-owned form state with optional
118
99
  * Zod validation. When a schema is supplied, `validate(values)` runs it and
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
+ import { applyZodIssues } from "./apply-zod-issues";
4
5
  // ─── Context ──────────────────────────────────────────────────────────────────
5
6
  export const FormShellContext = createContext(null);
6
7
  export function useFormShell() {
@@ -9,29 +10,9 @@ export function useFormShell() {
9
10
  throw new Error("useFormShell must be used inside <FormShell>");
10
11
  return ctx;
11
12
  }
12
- /**
13
- * `applyZodIssues` — pipe every issue from a Zod safeParse failure into the
14
- * FormShellContext error map. Use after `schema.safeParse(values)` returns
15
- * `success: false` to surface inline errors on `<FieldInput>` etc.
16
- *
17
- * Keys are the full dotted/indexed path (`"video.duration"`, `"images.2"`),
18
- * not just the top-level segment — a plain `issue.path[0]` key would collapse
19
- * every nested issue under one object onto the same map entry, silently
20
- * dropping all but the last. Top-level-only fields (the common case) are
21
- * unaffected: a single-segment path joins to the same string as before.
22
- *
23
- * Root-level `.refine()` issues (empty `path: []`, no owning field) are still
24
- * skipped — there is no field to attach them to, and no schema in this
25
- * codebase currently relies on one being surfaced this way.
26
- */
27
- export function applyZodIssues(issues, setFieldError) {
28
- for (const issue of issues) {
29
- if (!issue.path || issue.path.length === 0)
30
- continue;
31
- const key = issue.path.map(String).join(".");
32
- setFieldError(key, issue.message);
33
- }
34
- }
13
+ // `applyZodIssues` lives in ./apply-zod-issues — `surface-error.ts`, which is
14
+ // deliberately React-free, needs it to route server-side validation failures
15
+ // to inline field errors and cannot import this "use client" module.
35
16
  /**
36
17
  * `useFormShellState(schema?, nav?)` — caller-owned form state with optional
37
18
  * Zod validation. When a schema is supplied, `validate(values)` runs it and
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `applyZodIssues` — pipe every issue from a validation failure into a form's
3
+ * error map, so they render inline on `<FieldInput>` etc. rather than as a
4
+ * toast.
5
+ *
6
+ * WHY THIS IS ITS OWN FILE
7
+ * ------------------------
8
+ * It used to live in `FormShell.tsx`, which is a `"use client"` React module.
9
+ * `client/api/surface-error.ts` needs this function to route server-side
10
+ * validation failures to inline field errors, and that file is deliberately
11
+ * hook-free and React-free (see its header) so it can be called from outside a
12
+ * hook boundary — e.g. inside Button's non-hook click wrapper. Importing
13
+ * FormShell there would have dragged React and the whole form runtime into it.
14
+ *
15
+ * This is a MOVE, not a barrel re-export: `FormShell.tsx` imports it from here.
16
+ *
17
+ * Keys are the full dotted/indexed path (`"video.duration"`, `"images.2"`),
18
+ * not just the top-level segment — a plain `issue.path[0]` key would collapse
19
+ * every nested issue under one object onto the same map entry, silently
20
+ * dropping all but the last. Top-level-only fields (the common case) are
21
+ * unaffected: a single-segment path joins to the same string as before.
22
+ *
23
+ * Root-level `.refine()` issues (empty `path: []`, no owning field) are still
24
+ * skipped — there is no field to attach them to, so the caller must surface
25
+ * those some other way (they fall through to a toast in `surfaceError`).
26
+ */
27
+ export declare function applyZodIssues(issues: ReadonlyArray<{
28
+ path?: ReadonlyArray<PropertyKey>;
29
+ message: string;
30
+ }>, setFieldError: (name: string, error: string | null) => void): void;
31
+ /** True when at least one issue carries a field path we can attach it to. */
32
+ export declare function hasAttachableIssue(issues: ReadonlyArray<{
33
+ path?: ReadonlyArray<PropertyKey>;
34
+ }> | undefined): boolean;