@mohasinac/appkit 4.29.1 → 4.30.1

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 (165) hide show
  1. package/dist/_internal/client/features/layout/SidebarCollapseToggle.js +1 -1
  2. package/dist/_internal/shared/features/blog/schema.d.ts +2 -2
  3. package/dist/_internal/shared/features/blog/schema.js +2 -1
  4. package/dist/_internal/shared/features/brands/schema.d.ts +2 -2
  5. package/dist/_internal/shared/features/brands/schema.js +3 -1
  6. package/dist/_internal/shared/features/categories/bundle-schemas.d.ts +3 -3
  7. package/dist/_internal/shared/features/categories/bundle-schemas.js +2 -1
  8. package/dist/_internal/shared/features/events/schema.d.ts +2 -2
  9. package/dist/_internal/shared/features/events/schema.js +2 -1
  10. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  11. package/dist/_internal/shared/features/products/schema.js +3 -2
  12. package/dist/_internal/shared/features/reviews/schema.d.ts +1 -1
  13. package/dist/_internal/shared/features/reviews/schema.js +2 -1
  14. package/dist/_internal/shared/types/action-result.d.ts +0 -2
  15. package/dist/client/api/surface-error.d.ts +9 -0
  16. package/dist/client/api/surface-error.js +29 -3
  17. package/dist/client/api/useApiMutation.d.ts +10 -0
  18. package/dist/client/api/useApiMutation.js +2 -1
  19. package/dist/core/server-action.d.ts +3 -14
  20. package/dist/core/server-action.js +11 -2
  21. package/dist/errors/error-envelope.d.ts +73 -0
  22. package/dist/errors/error-envelope.js +96 -0
  23. package/dist/errors/error-handler.js +14 -7
  24. package/dist/features/account/components/NotificationBell.js +1 -1
  25. package/dist/features/account/components/UserSidebar.js +2 -2
  26. package/dist/features/account/components/UserSupportView.js +2 -6
  27. package/dist/features/account/hooks/useProfile.d.ts +6 -0
  28. package/dist/features/account/hooks/useProfile.js +1 -0
  29. package/dist/features/admin/components/AdminAdEditorView.js +1 -3
  30. package/dist/features/admin/components/AdminAddressClustersView.js +1 -3
  31. package/dist/features/admin/components/AdminAddressEditorView.js +2 -4
  32. package/dist/features/admin/components/AdminAddressesView.js +1 -3
  33. package/dist/features/admin/components/AdminAllEventEntriesView.js +1 -3
  34. package/dist/features/admin/components/AdminBidsView.js +1 -3
  35. package/dist/features/admin/components/AdminBlogEditorView.js +2 -2
  36. package/dist/features/admin/components/AdminBrandEditorView.js +4 -5
  37. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +1 -3
  38. package/dist/features/admin/components/AdminCategoryEditorView.js +2 -4
  39. package/dist/features/admin/components/AdminContactEditorView.js +1 -3
  40. package/dist/features/admin/components/AdminContactView.js +2 -6
  41. package/dist/features/admin/components/AdminCouponEditorView.js +2 -4
  42. package/dist/features/admin/components/AdminEmployeeEditorView.js +3 -9
  43. package/dist/features/admin/components/AdminFaqEditorView.js +2 -4
  44. package/dist/features/admin/components/AdminFeatureEditorView.js +0 -9
  45. package/dist/features/admin/components/AdminFeatureFlagsView.js +2 -4
  46. package/dist/features/admin/components/AdminNavEditorView.js +1 -3
  47. package/dist/features/admin/components/AdminNavigationView.js +3 -3
  48. package/dist/features/admin/components/AdminNewsletterView.js +1 -3
  49. package/dist/features/admin/components/AdminNotificationsView.js +2 -6
  50. package/dist/features/admin/components/AdminOffersView.js +1 -3
  51. package/dist/features/admin/components/AdminOrderEditorView.js +1 -3
  52. package/dist/features/admin/components/AdminPaymentClustersView.js +1 -3
  53. package/dist/features/admin/components/AdminPaymentMethodsView.js +1 -3
  54. package/dist/features/admin/components/AdminPayoutMarkPaidModal.js +2 -4
  55. package/dist/features/admin/components/AdminProductEditorView.js +2 -4
  56. package/dist/features/admin/components/AdminReturnRequestsView.js +2 -6
  57. package/dist/features/admin/components/AdminReviewsView.js +2 -6
  58. package/dist/features/admin/components/AdminScammerEditorView.js +2 -6
  59. package/dist/features/admin/components/AdminSectionsView.js +2 -6
  60. package/dist/features/admin/components/AdminSessionsView.js +1 -3
  61. package/dist/features/admin/components/AdminSidebar.js +2 -2
  62. package/dist/features/admin/components/AdminSiteSettingsView.js +1 -1
  63. package/dist/features/admin/components/AdminStoreEditorView.js +1 -3
  64. package/dist/features/admin/components/AdminStoresView.js +2 -6
  65. package/dist/features/admin/components/AdminSublistingCategoryEditorView.js +2 -6
  66. package/dist/features/admin/components/AdminSupportTicketDetailView.js +7 -21
  67. package/dist/features/admin/components/AdminTesterChecklistItemEditorView.js +2 -4
  68. package/dist/features/admin/components/AdminUserEditorView.js +6 -18
  69. package/dist/features/admin/components/AdminUsersView.js +2 -6
  70. package/dist/features/admin/components/BrandQuickCreateForm.js +2 -4
  71. package/dist/features/admin/components/CategoryQuickCreateForm.js +2 -4
  72. package/dist/features/auctions/components/PlaceBidFormClient.js +1 -1
  73. package/dist/features/auth/components/ForgotPasswordView.js +1 -1
  74. package/dist/features/auth/components/LoginForm.js +1 -1
  75. package/dist/features/auth/components/RegisterForm.js +1 -1
  76. package/dist/features/auth/components/ResetPasswordView.js +1 -1
  77. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  78. package/dist/features/auth/repository/session.repository.js +13 -1
  79. package/dist/features/auth/repository/token.repository.d.ts +25 -2
  80. package/dist/features/auth/repository/token.repository.js +26 -18
  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 -4
  120. package/dist/index.js +4 -7
  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 +48 -4
  131. package/dist/security/browser.d.ts +1 -1
  132. package/dist/security/browser.js +1 -1
  133. package/dist/security/index.d.ts +2 -2
  134. package/dist/security/index.js +2 -2
  135. package/dist/security/pii-encrypt.d.ts +12 -10
  136. package/dist/security/pii-encrypt.js +91 -29
  137. package/dist/security/pii-mask.d.ts +39 -1
  138. package/dist/security/pii-mask.js +33 -4
  139. package/dist/security/pii-redact.d.ts +0 -13
  140. package/dist/security/pii-redact.js +3 -1
  141. package/dist/security/pii-schemas.d.ts +0 -6
  142. package/dist/security/pii-schemas.js +29 -6
  143. package/dist/security/settings-encryption.js +31 -3
  144. package/dist/seed/test-utils.js +6 -2
  145. package/dist/server.d.ts +1 -1
  146. package/dist/server.js +1 -1
  147. package/dist/ui/DataTable.js +2 -2
  148. package/dist/ui/components/CollapsibleSection.js +1 -1
  149. package/dist/ui/components/ImageGallery.js +1 -1
  150. package/dist/ui/components/ListingLayout.js +1 -1
  151. package/dist/ui/forms/FormShell.d.ts +0 -19
  152. package/dist/ui/forms/FormShell.js +4 -23
  153. package/dist/ui/forms/apply-zod-issues.d.ts +34 -0
  154. package/dist/ui/forms/apply-zod-issues.js +38 -0
  155. package/dist/ui/forms/index.d.ts +2 -1
  156. package/dist/ui/forms/index.js +2 -1
  157. package/dist/ui/icons/icon-registry.d.ts +8 -0
  158. package/dist/ui/icons/icon-registry.js +13 -1
  159. package/dist/utils/action-response.d.ts +1 -1
  160. package/dist/utils/media-url.d.ts +49 -0
  161. package/dist/utils/media-url.js +72 -1
  162. package/dist/validation/schemas.d.ts +15 -2
  163. package/dist/validation/schemas.js +18 -22
  164. package/package.json +1 -1
  165. package/scripts/seed-cli.mjs +21 -6
@@ -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) {
@@ -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`;
package/dist/server.d.ts CHANGED
@@ -462,7 +462,7 @@ export { encryptPii } from "./security/index";
462
462
  export { decryptPii } from "./security/index";
463
463
  export { isPiiEncrypted } from "./security/index";
464
464
  export { piiBlindIndex } from "./security/index";
465
- export { addPiiIndices } from "./security/index";
465
+ export { piiIndicesFor } from "./security/index";
466
466
  export { getPiiConfigError } from "./security/index";
467
467
  export { encryptShippingAddress } from "./security/index";
468
468
  export { decryptShippingAddress } from "./security/index";
package/dist/server.js CHANGED
@@ -1294,7 +1294,7 @@ export { encryptPii } from "./security/index";
1294
1294
  export { decryptPii } from "./security/index";
1295
1295
  export { isPiiEncrypted } from "./security/index";
1296
1296
  export { piiBlindIndex } from "./security/index";
1297
- export { addPiiIndices } from "./security/index";
1297
+ export { piiIndicesFor } from "./security/index";
1298
1298
  export { getPiiConfigError } from "./security/index";
1299
1299
  export { encryptShippingAddress } from "./security/index";
1300
1300
  export { decryptShippingAddress } from "./security/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;
@@ -0,0 +1,38 @@
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 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
+ }
35
+ /** True when at least one issue carries a field path we can attach it to. */
36
+ export function hasAttachableIssue(issues) {
37
+ return Boolean(issues?.some((i) => i.path && i.path.length > 0));
38
+ }
@@ -1,5 +1,6 @@
1
1
  export type { FormShellProviderProps, FormShellStep, FormShellNav, FormShellContextValue, UseFormShellStateResult, } from "./FormShell";
2
- export { FormShellProvider, FormShellContext, useFormShell, useFormShellState, applyZodIssues } from "./FormShell";
2
+ export { FormShellProvider, FormShellContext, useFormShell, useFormShellState } from "./FormShell";
3
+ export { applyZodIssues, hasAttachableIssue } from "./apply-zod-issues";
3
4
  export type { FieldInputProps } from "./FieldInput";
4
5
  export { FieldInput } from "./FieldInput";
5
6
  export type { FieldSelectProps } from "./FieldSelect";
@@ -1,4 +1,5 @@
1
- export { FormShellProvider, FormShellContext, useFormShell, useFormShellState, applyZodIssues } from "./FormShell";
1
+ export { FormShellProvider, FormShellContext, useFormShell, useFormShellState } from "./FormShell";
2
+ export { applyZodIssues, hasAttachableIssue } from "./apply-zod-issues";
2
3
  export { FieldInput } from "./FieldInput";
3
4
  export { FieldSelect } from "./FieldSelect";
4
5
  export { FieldCheckbox } from "./FieldCheckbox";
@@ -50,6 +50,14 @@ export declare const ICONS: {
50
50
  readonly settings: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
51
51
  readonly expand: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
52
52
  readonly user: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
53
+ readonly notification: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
54
+ readonly compare: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
55
+ readonly help: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
56
+ readonly menu: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
57
+ readonly close: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
58
+ readonly themeLight: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
59
+ readonly themeDark: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
60
+ readonly deals: import("react").ForwardRefExoticComponent<Omit<import("lucide-react").LucideProps, "ref"> & import("react").RefAttributes<SVGSVGElement>>;
53
61
  };
54
62
  /** Every valid `iconKey`. A typo is a compile error, not a silent blank. */
55
63
  export type IconKey = keyof typeof ICONS;
@@ -39,7 +39,7 @@
39
39
  * @tag consumers:Button,RowActionMenu,BulkActionBar,action-registry
40
40
  * @tag sideEffects:none
41
41
  */
42
- import { Archive, ArrowLeft, Ban, Check, CheckCircle, ChevronDown, Copy, Download, Edit, Eye, EyeOff, FileDown, Filter, Flag, Gavel, Heart, Link as LinkIcon, Lock, Mail, MessageSquare, Package, Pause, Play, Plus, RefreshCw, Reply, RotateCcw, Save, Search, Send, Settings, Share2, ShieldCheck, ShoppingCart, Star, Trash2, TrendingUp, Truck, Upload, User, X, XCircle, } from "lucide-react";
42
+ import { Archive, ArrowLeft, Ban, Bell, Check, CheckCircle, ChevronDown, Copy, Download, Edit, Eye, EyeOff, FileDown, Filter, Flag, Gavel, HelpCircle, Heart, Link as LinkIcon, Lock, Mail, Menu, MessageSquare, Moon, Package, Pause, Play, Plus, RefreshCw, Reply, RotateCcw, Save, Search, Send, Settings, Share2, ShieldCheck, ShoppingCart, Star, Sun, Scale, Tag, Trash2, TrendingUp, Truck, Upload, User, X, XCircle, } from "lucide-react";
43
43
  /**
44
44
  * Semantic name → component.
45
45
  *
@@ -98,6 +98,18 @@ export const ICONS = {
98
98
  settings: Settings,
99
99
  expand: ChevronDown,
100
100
  user: User,
101
+ // Header chrome. Added 2026-08-27 when TitleBarLayout stopped hand-inlining
102
+ // ten raw <svg> elements — a raw <svg> carries no `shrink-0`, so inside a
103
+ // padded flex control it silently shrank rather than rendering at its stated
104
+ // size, and no audit could see a size it never declared.
105
+ notification: Bell,
106
+ compare: Scale,
107
+ help: HelpCircle,
108
+ menu: Menu,
109
+ close: X,
110
+ themeLight: Sun,
111
+ themeDark: Moon,
112
+ deals: Tag,
101
113
  };
102
114
  /**
103
115
  * The ONLY sanctioned glyph sizes.
@@ -1,4 +1,4 @@
1
- import type { ActionResult } from "../core/server-action";
1
+ import type { ActionResult } from "../_internal/shared/types/action-result";
2
2
  export type { ActionResult };
3
3
  type ActionErrorResult = Extract<ActionResult<never>, {
4
4
  ok: false;
@@ -7,6 +7,55 @@
7
7
  export declare const FIREBASE_STORAGE_HOST = "firebasestorage.googleapis.com";
8
8
  /** Google Cloud Storage host (used by public-asset URLs). */
9
9
  export declare const GCS_HOST = "storage.googleapis.com";
10
+ /**
11
+ * The canonical stored form of every media reference: `/media/<slug>`.
12
+ *
13
+ * `POST /api/media/finalize` mints exactly this, `next.config.js` rewrites
14
+ * `/media/:path*` → `/api/media/:path*`, and CLAUDE.md § Media Architecture
15
+ * makes it mandatory ("never write raw firebasestorage.googleapis.com URLs
16
+ * into Firestore"). Exported so validation can name the same prefix the
17
+ * resolver below branches on, instead of re-typing the literal.
18
+ */
19
+ export declare const MEDIA_PROXY_PREFIX = "/media/";
20
+ /** Cap for any stored media reference. Matches the previous schema bound. */
21
+ export declare const MEDIA_URL_MAX_LENGTH = 2048;
22
+ /**
23
+ * Absolute hosts a stored media reference may point at.
24
+ *
25
+ * These exist for BACK-COMPAT only — rows written before the `/media/` proxy,
26
+ * and `seedExtMedia()` fixtures pointing at third-party sample assets. New
27
+ * writes should always be producing `/media/<slug>`. This list was previously
28
+ * duplicated in `appkit/src/validation/schemas.ts` and
29
+ * `src/validation/request-schemas.ts`; it lives here now so there is one copy.
30
+ */
31
+ export declare const APPROVED_MEDIA_DOMAINS: readonly string[];
32
+ export declare const MEDIA_URL_MESSAGE = "Must be a stored media reference (/media/<slug>) or a URL on an approved CDN domain";
33
+ /**
34
+ * Is `value` something we are willing to PERSIST as a media reference?
35
+ *
36
+ * The single definition of that rule — `mediaUrlSchema` in both validation
37
+ * modules refines on it, and everything that stores an image/video URL should
38
+ * go through one of those.
39
+ *
40
+ * Accepts:
41
+ * - `/media/<slug>` — the canonical form (`/api/media/finalize` output);
42
+ * - an absolute URL on an approved host — see `APPROVED_MEDIA_DOMAINS`.
43
+ *
44
+ * Rejects:
45
+ * - `blob:` / `data:` — valid only in the tab that created them. Persisting
46
+ * one stores a reference that is already dead by the time anyone reads it;
47
+ * - `/api/media/ext?url=…` — a RENDER-time transform produced by
48
+ * `resolveMediaUrl` below. Storing it double-wraps on the next render and
49
+ * pins the value to a proxy route rather than to the asset;
50
+ * - anything else, including bare relative paths that are not `/media/`.
51
+ *
52
+ * NOTE this is deliberately a strict SUPERSET of the rule it replaced (which
53
+ * was `.url()` + an approved-host check). That old rule rejected the canonical
54
+ * `/media/<slug>` form outright — which is what made every avatar save fail —
55
+ * while happily accepting the raw Storage URLs CLAUDE.md bans. No value that
56
+ * validated before stops validating now.
57
+ */
58
+ export declare function isStoredMediaRef(value: string): boolean;
10
59
  export interface ResolveMediaUrlOptions {
11
60
  /**
12
61
  * When false, a URL we do not own is returned UNCHANGED instead of being sent
@@ -9,7 +9,78 @@ import { normalizeError } from "../errors/normalize";
9
9
  export const FIREBASE_STORAGE_HOST = "firebasestorage.googleapis.com";
10
10
  /** Google Cloud Storage host (used by public-asset URLs). */
11
11
  export const GCS_HOST = "storage.googleapis.com";
12
- const PROXY_PREFIX = "/media/";
12
+ /**
13
+ * The canonical stored form of every media reference: `/media/<slug>`.
14
+ *
15
+ * `POST /api/media/finalize` mints exactly this, `next.config.js` rewrites
16
+ * `/media/:path*` → `/api/media/:path*`, and CLAUDE.md § Media Architecture
17
+ * makes it mandatory ("never write raw firebasestorage.googleapis.com URLs
18
+ * into Firestore"). Exported so validation can name the same prefix the
19
+ * resolver below branches on, instead of re-typing the literal.
20
+ */
21
+ export const MEDIA_PROXY_PREFIX = "/media/";
22
+ const PROXY_PREFIX = MEDIA_PROXY_PREFIX;
23
+ /** Cap for any stored media reference. Matches the previous schema bound. */
24
+ export const MEDIA_URL_MAX_LENGTH = 2048;
25
+ /**
26
+ * Absolute hosts a stored media reference may point at.
27
+ *
28
+ * These exist for BACK-COMPAT only — rows written before the `/media/` proxy,
29
+ * and `seedExtMedia()` fixtures pointing at third-party sample assets. New
30
+ * writes should always be producing `/media/<slug>`. This list was previously
31
+ * duplicated in `appkit/src/validation/schemas.ts` and
32
+ * `src/validation/request-schemas.ts`; it lives here now so there is one copy.
33
+ */
34
+ export const APPROVED_MEDIA_DOMAINS = [
35
+ FIREBASE_STORAGE_HOST,
36
+ GCS_HOST,
37
+ "res.cloudinary.com",
38
+ "images.unsplash.com",
39
+ ];
40
+ export const MEDIA_URL_MESSAGE = "Must be a stored media reference (/media/<slug>) or a URL on an approved CDN domain";
41
+ /**
42
+ * Is `value` something we are willing to PERSIST as a media reference?
43
+ *
44
+ * The single definition of that rule — `mediaUrlSchema` in both validation
45
+ * modules refines on it, and everything that stores an image/video URL should
46
+ * go through one of those.
47
+ *
48
+ * Accepts:
49
+ * - `/media/<slug>` — the canonical form (`/api/media/finalize` output);
50
+ * - an absolute URL on an approved host — see `APPROVED_MEDIA_DOMAINS`.
51
+ *
52
+ * Rejects:
53
+ * - `blob:` / `data:` — valid only in the tab that created them. Persisting
54
+ * one stores a reference that is already dead by the time anyone reads it;
55
+ * - `/api/media/ext?url=…` — a RENDER-time transform produced by
56
+ * `resolveMediaUrl` below. Storing it double-wraps on the next render and
57
+ * pins the value to a proxy route rather than to the asset;
58
+ * - anything else, including bare relative paths that are not `/media/`.
59
+ *
60
+ * NOTE this is deliberately a strict SUPERSET of the rule it replaced (which
61
+ * was `.url()` + an approved-host check). That old rule rejected the canonical
62
+ * `/media/<slug>` form outright — which is what made every avatar save fail —
63
+ * while happily accepting the raw Storage URLs CLAUDE.md bans. No value that
64
+ * validated before stops validating now.
65
+ */
66
+ export function isStoredMediaRef(value) {
67
+ if (!value || value.length > MEDIA_URL_MAX_LENGTH)
68
+ return false;
69
+ if (value.startsWith(MEDIA_PROXY_PREFIX))
70
+ return true;
71
+ if (value.startsWith("blob:") || value.startsWith("data:"))
72
+ return false;
73
+ if (value.startsWith(MEDIA_ENDPOINTS.EXT))
74
+ return false;
75
+ try {
76
+ const { hostname } = new URL(value);
77
+ return APPROVED_MEDIA_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
78
+ }
79
+ catch (_err) {
80
+ void normalizeError(_err); // not an absolute URL, and not /media/ — reject
81
+ return false;
82
+ }
83
+ }
13
84
  /**
14
85
  * Our own Storage bucket, as it appears as the first path segment of a
15
86
  * `storage.googleapis.com` URL. Only URLs pointing at THIS bucket may have that
@@ -20,8 +20,21 @@ export declare const paginationQuerySchema: z.ZodObject<{
20
20
  }>;
21
21
  export declare const objectIdSchema: z.ZodString;
22
22
  export declare const urlSchema: z.ZodString;
23
- /** Default approved media domains. Extend via `createMediaUrlSchema`. */
24
- export declare const APPROVED_MEDIA_DOMAINS: string[];
23
+ /**
24
+ * A media reference we are willing to PERSIST.
25
+ *
26
+ * The rule lives in `isStoredMediaRef` (`utils/media-url.ts`) so this schema
27
+ * and its zod-4 twin in `src/validation/request-schemas.ts` cannot drift —
28
+ * the two Zod major versions mean the schema object can't be shared, but the
29
+ * predicate can.
30
+ *
31
+ * This used to be `.url()` + an approved-host refine, which rejected the
32
+ * canonical `/media/<slug>` form that `/api/media/finalize` actually mints.
33
+ * That is what made every avatar save fail with a bare "Validation failed".
34
+ *
35
+ * For a genuine EXTERNAL link (a brand website, a social handle, a tracking
36
+ * URL) use `urlSchema` above — not this.
37
+ */
25
38
  export declare const mediaUrlSchema: z.ZodEffects<z.ZodString, string, string>;
26
39
  export declare const dateStringSchema: z.ZodString;
27
40
  /** Discount percentage — 0–100. */
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
- import { normalizeError } from "../errors/normalize";
3
2
  // ============================================
4
3
  // COMMON SCHEMAS
5
4
  // ============================================
@@ -12,29 +11,26 @@ export const paginationQuerySchema = z.object({
12
11
  });
13
12
  export const objectIdSchema = z.string().regex(/^[a-z0-9-]+$/);
14
13
  export const urlSchema = z.string().url().max(2048);
15
- import { FIREBASE_STORAGE_HOST, GCS_HOST } from "../utils/media-url";
16
- /** Default approved media domains. Extend via `createMediaUrlSchema`. */
17
- export const APPROVED_MEDIA_DOMAINS = [
18
- FIREBASE_STORAGE_HOST,
19
- GCS_HOST,
20
- "res.cloudinary.com",
21
- "images.unsplash.com",
22
- // Add site-specific CDN domains by calling createMediaUrlSchema(extraDomains)
23
- ];
14
+ import { isStoredMediaRef, MEDIA_URL_MAX_LENGTH, MEDIA_URL_MESSAGE, } from "../utils/media-url";
15
+ /**
16
+ * A media reference we are willing to PERSIST.
17
+ *
18
+ * The rule lives in `isStoredMediaRef` (`utils/media-url.ts`) so this schema
19
+ * and its zod-4 twin in `src/validation/request-schemas.ts` cannot drift —
20
+ * the two Zod major versions mean the schema object can't be shared, but the
21
+ * predicate can.
22
+ *
23
+ * This used to be `.url()` + an approved-host refine, which rejected the
24
+ * canonical `/media/<slug>` form that `/api/media/finalize` actually mints.
25
+ * That is what made every avatar save fail with a bare "Validation failed".
26
+ *
27
+ * For a genuine EXTERNAL link (a brand website, a social handle, a tracking
28
+ * URL) use `urlSchema` above — not this.
29
+ */
24
30
  export const mediaUrlSchema = z
25
31
  .string()
26
- .url()
27
- .max(2048)
28
- .refine((url) => {
29
- try {
30
- const { hostname } = new URL(url);
31
- return APPROVED_MEDIA_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
32
- }
33
- catch (_err) {
34
- void normalizeError(_err);
35
- return false;
36
- }
37
- }, { message: "Image or video URL must be hosted on an approved CDN domain" });
32
+ .max(MEDIA_URL_MAX_LENGTH)
33
+ .refine(isStoredMediaRef, { message: MEDIA_URL_MESSAGE });
38
34
  export const dateStringSchema = z.string().datetime();
39
35
  // ============================================
40
36
  // SHARED PRIMITIVES (W2-4 — 2026-05-23)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.29.1",
3
+ "version": "4.30.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"