@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.
- package/dist/features/admin/components/AdminBlogView.js +3 -1
- package/dist/features/admin/components/AdminCouponsView.js +2 -1
- package/dist/features/admin/components/AdminOffersView.js +4 -1
- 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/events/components/AdminEventsView.js +2 -1
- package/dist/features/orders/repository/orders.repository.d.ts +3 -1
- package/dist/features/orders/repository/orders.repository.js +18 -3
- package/dist/features/seller/components/SellerCouponsView.js +2 -1
- package/dist/features/seller/components/SellerOffersView.js +3 -1
- package/dist/features/seller/components/SellerOrdersView.js +4 -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
|
@@ -21,7 +21,9 @@ export function AdminBlogView({ children, ...props }) {
|
|
|
21
21
|
const config = {
|
|
22
22
|
portal: "admin",
|
|
23
23
|
title: "Blog Posts",
|
|
24
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 {
|
|
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
|
/**
|
|
@@ -33,7 +33,8 @@ export function AdminEventsView({ children, ...props }) {
|
|
|
33
33
|
const config = {
|
|
34
34
|
portal: "admin",
|
|
35
35
|
title: "Events",
|
|
36
|
-
//
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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],
|