@mohasinac/appkit 4.30.9 → 4.30.11

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.
@@ -56,13 +56,21 @@ const LISTERS = {
56
56
  ? `${m.filters},categoryType==brand`
57
57
  : "categoryType==brand",
58
58
  }),
59
- orders: (m) => orderRepository.listAll(m),
60
- reviews: (m) => reviewRepository.listAll(m),
59
+ orders: (m, o) => orderRepository.listAll(m, { search: getStringOpt(o, "search") }),
60
+ reviews: (m, o) => reviewRepository.listAll(m, { search: getStringOpt(o, "search") }),
61
61
  coupons: (m) => couponsRepository.list(m),
62
62
  bids: (m) => bidRepository.list(m),
63
63
  payouts: (m) => payoutRepository.list(m),
64
- blogPosts: (m) => blogRepository.listAll(m),
65
- events: (m) => eventRepository.list(m),
64
+ blogPosts: (m, o) => blogRepository.listAll(m, { search: getStringOpt(o, "search") }),
65
+ events: (m, o) => eventRepository.list(m, { search: getStringOpt(o, "search") }),
66
+ // 🛑 EVERY lister whose repository accepts a `search` opt MUST forward it.
67
+ // `baseOpts.search` is how token search reaches the repository — Sieve cannot
68
+ // express array-contains, so it can never ride inside `filters`. A lister
69
+ // written `(m) => repo.list(m)` silently drops it and the Function returns an
70
+ // UNFILTERED page with a 200, while the same route's local fallback filters
71
+ // correctly — so it only reproduces in production, and only when the Function
72
+ // is reachable. That is how it survived here for orders, reviews, blogPosts,
73
+ // events and stores after their routes and repositories were both wired.
66
74
  // Forwards opts so `baseOpts.search` reaches the repository's token search.
67
75
  // This was `(m) => faqsRepository.list(m)` — the second argument was dropped,
68
76
  // which made FAQ token search structurally unreachable through the Function
@@ -82,7 +90,9 @@ const LISTERS = {
82
90
  productFeatures: (m) => productFeaturesRepository.list(m),
83
91
  homepageSections: (m) => homepageSectionsRepository.list(m),
84
92
  users: (m) => userRepository.list(m),
85
- stores: (m, o) => storeRepository.listStores(m, getBoolOpt(o, "activeOnly", true)),
93
+ stores: (m, o) => storeRepository.listStores(m, getBoolOpt(o, "activeOnly", true), {
94
+ search: getStringOpt(o, "search"),
95
+ }),
86
96
  eventEntries: (m, o) => {
87
97
  const eventId = requireOpt(o, "eventId", "eventEntries");
88
98
  return eventEntryRepository.listForEvent(eventId, m);
@@ -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
  /**
@@ -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.9",
3
+ "version": "4.30.11",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"