@mohasinac/appkit 4.30.10 → 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.
- package/dist/features/auctions/repository/bid.repository.d.ts +19 -0
- package/dist/features/auctions/repository/bid.repository.js +29 -4
- package/dist/features/catalogue/repository/catalogue.repository.js +12 -1
- package/dist/features/seller/repository/offer.repository.d.ts +3 -1
- package/dist/features/seller/repository/offer.repository.js +13 -3
- package/dist/features/seller/schemas/firestore.d.ts +2 -0
- package/dist/features/seller/schemas/firestore.js +2 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
//
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
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],
|