@mohasinac/appkit 4.30.10 → 4.30.12

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.
@@ -21,7 +21,9 @@ export function AdminBlogView({ children, ...props }) {
21
21
  const config = {
22
22
  portal: "admin",
23
23
  title: "Blog Posts",
24
- // Search intentionally absent: this endpoint does not read `q`, so the box accepted typing and changed nothing. Restore it when the collection gains searchTxt — audit-listing-search-capability tracks it.
24
+ // buildBlogSearchTxt indexes title + excerpt + category + tags — NOT the post
25
+ // body, which is not tokenised.
26
+ searchPlaceholder: "Search by title, excerpt, category or tag…",
25
27
  emptyLabel: "No blog posts found",
26
28
  filterKeys: ["status", "isFeatured"],
27
29
  defaultSort: sortBy("publishedAt", "DESC"),
@@ -52,7 +52,8 @@ export function AdminCouponsView({ children, onBulkArchive, onBulkDelete, ...pro
52
52
  const config = {
53
53
  portal: "admin",
54
54
  title: "Coupons",
55
- // Search intentionally absent: this endpoint does not read `q`, so the box accepted typing and changed nothing. Restore it when the collection gains searchTxt — audit-listing-search-capability tracks it.
55
+ // buildCouponSearchTxt indexes code + name + description — nothing else.
56
+ searchPlaceholder: "Search by code, name or description…",
56
57
  emptyLabel: "No coupons found",
57
58
  filterKeys: ["type", "showExpired"],
58
59
  defaultSort: sortBy("createdAt", "DESC"),
@@ -103,7 +103,10 @@ export function AdminOffersView({ children, ...props }) {
103
103
  const config = {
104
104
  portal: "admin",
105
105
  title: "Offers",
106
- // Search intentionally absent: this endpoint does not read `q`, so the box accepted typing and changed nothing. Restore it when the collection gains searchTxt — audit-listing-search-capability tracks it.
106
+ // buildOfferSearchTxt indexes productTitle + productSlug + storeName. It does
107
+ // NOT index buyerName or buyerEmail — those are PII, and the placeholder
108
+ // must not promise a match it will never make.
109
+ searchPlaceholder: "Search by product or store…",
107
110
  emptyLabel: "No offers found",
108
111
  filterKeys: ["status"],
109
112
  defaultSort: sortBy("createdAt", "DESC"),
@@ -17,6 +17,25 @@ export interface BidWriteContext {
17
17
  }
18
18
  export declare class BidRepository extends BaseRepository<BidDocument> {
19
19
  constructor();
20
+ /**
21
+ * Declared, not hand-rolled.
22
+ *
23
+ * `create` encrypted `userEmail` while nothing else did, and there was no
24
+ * `update` override — so `adminUpdateBid`, whose input type is
25
+ * `Partial<Omit<BidDocument,"id"|"createdAt">>` and therefore INCLUDES
26
+ * `userEmail`, routed to `BaseRepository.update` -> `applyWriteHooks` with
27
+ * `piiFields = []` and wrote plaintext back over the ciphertext.
28
+ *
29
+ * Declaring it covers all nine mutating methods, including the `*InTx` /
30
+ * `*InBatch` family that had no encryption at all.
31
+ *
32
+ * 🛑 It does NOT cover `setWinningBid`, `endAuction`, `cancelProductBids` or
33
+ * the `markWon`/`markLost`/`markOutbid`/`markWinning` cluster — those call
34
+ * `batch.update(ref, …)` DIRECTLY and always will. They do not touch
35
+ * `userEmail` today, so there is no leak; the next PII-shaped field added to
36
+ * one of those patches would inherit the bypass.
37
+ */
38
+ protected piiFields: readonly ["userEmail"];
20
39
  /** Override mapDoc to auto-decrypt PII on every bid read */
21
40
  protected mapDoc<D = BidDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
22
41
  /**
@@ -8,11 +8,30 @@ import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateV
8
8
  import { DatabaseError } from "../../../errors";
9
9
  import { BID_COLLECTION, BID_TRACKED_FIELDS, BID_HISTORY_PII_FIELDS, createBidId } from "../schemas";
10
10
  import { withHistory } from "../../../_internal/shared/history/index";
11
- import { encryptPiiFields, decryptPiiFields, BID_PII_FIELDS, } from "../../../security";
11
+ import { decryptPiiFields, BID_PII_FIELDS, } from "../../../security";
12
12
  import { BID_FIELDS } from "../../../constants/field-names";
13
13
  export class BidRepository extends BaseRepository {
14
14
  constructor() {
15
15
  super(BID_COLLECTION);
16
+ /**
17
+ * Declared, not hand-rolled.
18
+ *
19
+ * `create` encrypted `userEmail` while nothing else did, and there was no
20
+ * `update` override — so `adminUpdateBid`, whose input type is
21
+ * `Partial<Omit<BidDocument,"id"|"createdAt">>` and therefore INCLUDES
22
+ * `userEmail`, routed to `BaseRepository.update` -> `applyWriteHooks` with
23
+ * `piiFields = []` and wrote plaintext back over the ciphertext.
24
+ *
25
+ * Declaring it covers all nine mutating methods, including the `*InTx` /
26
+ * `*InBatch` family that had no encryption at all.
27
+ *
28
+ * 🛑 It does NOT cover `setWinningBid`, `endAuction`, `cancelProductBids` or
29
+ * the `markWon`/`markLost`/`markOutbid`/`markWinning` cluster — those call
30
+ * `batch.update(ref, …)` DIRECTLY and always will. They do not touch
31
+ * `userEmail` today, so there is no leak; the next PII-shaped field added to
32
+ * one of those patches would inherit the bypass.
33
+ */
34
+ this.piiFields = BID_PII_FIELDS;
16
35
  }
17
36
  /** Override mapDoc to auto-decrypt PII on every bid read */
18
37
  mapDoc(snap) {
@@ -38,8 +57,9 @@ export class BidRepository extends BaseRepository {
38
57
  createdAt: new Date(),
39
58
  updatedAt: new Date(),
40
59
  };
41
- // Encrypt PII before persisting
42
- const encrypted = encryptPiiFields(bidData, [...BID_PII_FIELDS]);
60
+ // The SAME hook every other write path uses, rather than a second
61
+ // hand-rolled call that has to stay in agreement with it.
62
+ const encrypted = this.applyWriteHooks(bidData);
43
63
  await this.db
44
64
  .collection(this.collection)
45
65
  .doc(id)
@@ -437,7 +457,12 @@ BidRepository.SIEVE_FIELDS = {
437
457
  productTitle: { canFilter: true, canSort: true },
438
458
  userId: { canFilter: true, canSort: false },
439
459
  userName: { canFilter: true, canSort: true },
440
- userEmail: { canFilter: true, canSort: true },
460
+ // Encrypted (BID_PII_FIELDS). Filtering compares plaintext against
461
+ // `enc:v1:…` and matches ZERO rows with a 200; sorting orders by base64
462
+ // noise. It appeared to work only because the column was still
463
+ // plaintext. There is no blind index for bids (`indexMap: {}`), so the
464
+ // fix is to close it, not to add one — same as payout.sellerEmail.
465
+ userEmail: { canFilter: false, canSort: false },
441
466
  bidAmount: { canFilter: true, canSort: true },
442
467
  status: { canFilter: true, canSort: true },
443
468
  isWinning: { canFilter: true, canSort: false },
@@ -33,7 +33,18 @@ export class CatalogueRepository extends BaseRepository {
33
33
  createdAt: now,
34
34
  updatedAt: now,
35
35
  };
36
- await this.getCollection().doc(id).set(prepareForFirestore(data));
36
+ // `applyWriteHooks`, NOT a bare `.set(data)`.
37
+ //
38
+ // This wrote the document directly, bypassing the hook entirely — so
39
+ // `ownerEmail` was persisted in CLEARTEXT on every create despite
40
+ // `piiFields` being declared above. `mapDoc` then calls `decryptPiiFields`,
41
+ // which passes a non-`enc:v1:` string straight through, so reads looked
42
+ // correct and nothing ever errored.
43
+ //
44
+ // Deliberately not `this.createWithId(id, data)`: that does a
45
+ // read-after-write, costing a Firestore read on every create (Rule #6) and
46
+ // changing the return from this plaintext echo to a `mapDoc` round-trip.
47
+ await this.getCollection().doc(id).set(prepareForFirestore(this.applyWriteHooks(data)));
37
48
  return { id, ...data };
38
49
  }
39
50
  /**
@@ -33,7 +33,8 @@ export function AdminEventsView({ children, ...props }) {
33
33
  const config = {
34
34
  portal: "admin",
35
35
  title: "Events",
36
- // Search intentionally absent: this endpoint does not read `q`, so the box accepted typing and changed nothing. Restore it when the collection gains searchTxt — audit-listing-search-capability tracks it.
36
+ // buildEventSearchTxt indexes title + description + type + tags.
37
+ searchPlaceholder: "Search by title, description, type or tag…",
37
38
  emptyLabel: "No events found",
38
39
  filterKeys: ["status", "type"],
39
40
  defaultSort: sortBy("startsAt", "DESC"),
@@ -151,7 +151,9 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
151
151
  canSort: boolean;
152
152
  };
153
153
  };
154
- listForSeller(productIds: string[], model: SieveModel): Promise<FirebaseSieveResult<OrderDocument>>;
154
+ listForSeller(productIds: string[], model: SieveModel, opts?: {
155
+ search?: string;
156
+ }): Promise<FirebaseSieveResult<OrderDocument>>;
155
157
  listForUser(userId: string, model: SieveModel): Promise<FirebaseSieveResult<OrderDocument>>;
156
158
  static readonly ADMIN_SIEVE_FIELDS: {
157
159
  searchTxt: {
@@ -260,7 +260,12 @@ class OrderRepository extends BaseRepository {
260
260
  throw new DatabaseError(`Failed to delete orders for user: ${userId}`, error);
261
261
  }
262
262
  }
263
- async listForSeller(productIds, model) {
263
+ async listForSeller(productIds, model, opts) {
264
+ const plan = planSearchTxt(opts?.search);
265
+ // A search that yielded no usable term must return NOTHING, not the
266
+ // seller's whole order list.
267
+ if (plan.empty)
268
+ return emptySearchResult();
264
269
  if (productIds.length === 0) {
265
270
  const page = Math.max(1, Number(model.page ?? 1));
266
271
  const pageSize = Math.max(1, Number(model.pageSize ?? 20));
@@ -273,12 +278,22 @@ class OrderRepository extends BaseRepository {
273
278
  hasMore: false,
274
279
  };
275
280
  }
276
- const baseQuery = this.getCollection().where(ORDER_FIELDS.PRODUCT_ID, "in", productIds);
277
- return this.sieveQuery(model, OrderRepository.SELLER_SIEVE_FIELDS, {
281
+ let baseQuery = this.getCollection().where(ORDER_FIELDS.PRODUCT_ID, "in", productIds);
282
+ if (plan.head) {
283
+ // `array-contains` alongside `in` is legal — Firestore's one-per-query
284
+ // limits are on `array-contains` and on the `in`/`not-in`/
285
+ // `array-contains-any` family separately, so one of each is fine.
286
+ // Verified against production data before this shipped, and the
287
+ // matching composite index is declared in firestore.indexes.json;
288
+ // the query happened to plan without one, which is not a guarantee.
289
+ baseQuery = baseQuery.where(ORDER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
290
+ }
291
+ const result = await this.sieveQuery(model, OrderRepository.SELLER_SIEVE_FIELDS, {
278
292
  baseQuery,
279
293
  defaultPageSize: 20,
280
294
  maxPageSize: 100,
281
295
  });
296
+ return refineSearchTxt(result, plan.rest);
282
297
  }
283
298
  async listForUser(userId, model) {
284
299
  const baseQuery = this.getCollection().where(ORDER_FIELDS.USER_ID, "==", userId);
@@ -49,7 +49,8 @@ export function SellerCouponsView({ onCreateClick, onEditClick, onToggle, onDele
49
49
  const config = {
50
50
  portal: "seller",
51
51
  title: "Coupons",
52
- // Search intentionally absent: this endpoint does not read `q`, so the box accepted typing and changed nothing. Restore it when the collection gains searchTxt — audit-listing-search-capability tracks it.
52
+ // buildCouponSearchTxt indexes code + name + description — nothing else.
53
+ searchPlaceholder: "Search by code, name or description…",
53
54
  emptyLabel: "No coupons found — create your first coupon",
54
55
  filterKeys: ["isActive"],
55
56
  defaultSort: sortBy("createdAt", "DESC"),
@@ -22,7 +22,9 @@ export function SellerOffersView({ children, onAcceptOffer, onRejectOffer, onCou
22
22
  const config = {
23
23
  portal: "seller",
24
24
  title: "Offers",
25
- // Search intentionally absent: this endpoint does not read `q`, so the box accepted typing and changed nothing. Restore it when the collection gains searchTxt — audit-listing-search-capability tracks it.
25
+ // buildOfferSearchTxt indexes productTitle + productSlug + storeName — never
26
+ // the buyer's name or email.
27
+ searchPlaceholder: "Search by product or store…",
26
28
  emptyLabel: "No offers received",
27
29
  filterKeys: ["status"],
28
30
  defaultSort: sortBy("createdAt", "DESC"),
@@ -294,7 +294,10 @@ export function SellerOrdersView({ orderDetailApiBase = SELLER_ENDPOINTS.ORDERS,
294
294
  const config = {
295
295
  portal: "seller",
296
296
  title: "Orders",
297
- // Search intentionally absent: this endpoint does not read `q`, so the box accepted typing and changed nothing. Restore it when the collection gains searchTxt — audit-listing-search-capability tracks it.
297
+ // buildOrderSearchTxt indexes productTitle, storeName and trackingNumber.
298
+ // It does NOT index the buyer's name, email or address — those are PII,
299
+ // and the placeholder must not promise a match it will never make.
300
+ searchPlaceholder: "Search by product, store or tracking number…",
298
301
  emptyLabel: "No orders yet",
299
302
  filterKeys: ["status"],
300
303
  defaultSort: DEFAULT_SORT,
@@ -112,7 +112,9 @@ declare class OfferRepository extends BaseRepository<OfferDocument> {
112
112
  }): Promise<FirebaseSieveResult<OfferDocument>>;
113
113
  create(input: OfferCreateInput): Promise<OfferDocument>;
114
114
  findByBuyer(buyerUid: string, model?: SieveModel): Promise<FirebaseSieveResult<OfferDocument>>;
115
- findByStore(storeId: string, model?: SieveModel): Promise<FirebaseSieveResult<OfferDocument>>;
115
+ findByStore(storeId: string, model?: SieveModel, opts?: {
116
+ search?: string;
117
+ }): Promise<FirebaseSieveResult<OfferDocument>>;
116
118
  findPendingByStore(storeId: string): Promise<OfferDocument[]>;
117
119
  /** Returns all pending or countered offers past their expiresAt — used by expiry sweep job */
118
120
  findExpired(): Promise<OfferDocument[]>;
@@ -102,11 +102,21 @@ class OfferRepository extends BaseRepository {
102
102
  hasMore: false,
103
103
  };
104
104
  }
105
- async findByStore(storeId, model) {
105
+ async findByStore(storeId, model, opts) {
106
106
  if (model) {
107
- return this.sieveQuery(model, OfferRepository.SIEVE_FIELDS, {
108
- baseQuery: this.getCollection().where(OFFER_FIELDS.STORE_ID, "==", storeId),
107
+ const plan = planSearchTxt(opts?.search);
108
+ // A search that yielded no usable term must return NOTHING, not the
109
+ // store's whole offer list.
110
+ if (plan.empty)
111
+ return emptySearchResult();
112
+ let baseQuery = this.getCollection().where(OFFER_FIELDS.STORE_ID, "==", storeId);
113
+ if (plan.head) {
114
+ baseQuery = baseQuery.where(OFFER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
115
+ }
116
+ const result = await this.sieveQuery(model, OfferRepository.SIEVE_FIELDS, {
117
+ baseQuery,
109
118
  });
119
+ return refineSearchTxt(result, plan.rest);
110
120
  }
111
121
  const snap = await this.getCollection()
112
122
  .where(OFFER_FIELDS.STORE_ID, "==", storeId)
@@ -123,6 +123,8 @@ export declare const OFFER_FIELDS: {
123
123
  readonly COUNTER_ROUND: "counterRound";
124
124
  readonly CANCELLED_BY_ADMIN_UID: "cancelledByAdminUid";
125
125
  readonly CANCEL_REASON: "cancelReason";
126
+ /** Word-prefix n-grams for `array-contains` search. Derived by `buildOfferSearchTxt`. */
127
+ readonly SEARCH_TXT: "searchTxt";
126
128
  };
127
129
  export type OfferCreateInput = Pick<OfferDocument, "productId" | "productTitle" | "productSlug" | "productImageUrl" | "buyerUid" | "buyerName" | "buyerEmail" | "storeId" | "storeName" | "offerAmount" | "listedPrice" | "currency" | "buyerNote" | "previousOfferId" | "chainRootOfferId" | "counterRound">;
128
130
  export type OfferUpdateInput = Partial<Pick<OfferDocument, "status" | "counterAmount" | "lockedPrice" | "sellerNote" | "acceptedAt" | "checkoutDeadline" | "respondedAt" | "paidOrderId" | "paidAt" | "updatedAt" | "statusHistory" | "statusHistoryTruncated" | "supersededByOfferId" | "cancelledByAdminUid" | "cancelReason">>;
@@ -75,6 +75,8 @@ export const OFFER_FIELDS = {
75
75
  COUNTER_ROUND: "counterRound",
76
76
  CANCELLED_BY_ADMIN_UID: "cancelledByAdminUid",
77
77
  CANCEL_REASON: "cancelReason",
78
+ /** Word-prefix n-grams for `array-contains` search. Derived by `buildOfferSearchTxt`. */
79
+ SEARCH_TXT: "searchTxt",
78
80
  };
79
81
  export const offerQueryHelpers = {
80
82
  byBuyer: (buyerUid) => ["buyerUid", "==", buyerUid],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.30.10",
3
+ "version": "4.30.12",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"