@mohasinac/appkit 4.30.15 → 4.30.16
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/_internal/server/functions/https.js +17 -2
- package/dist/_internal/server/jobs/core/listingProcessor.js +5 -1
- package/dist/features/addresses/repository/addresses.repository.d.ts +12 -1
- package/dist/features/addresses/repository/addresses.repository.js +16 -7
- package/dist/features/auth/repository/user.repository.d.ts +22 -1
- package/dist/features/auth/repository/user.repository.js +31 -28
- package/dist/features/cart/repository/cart.repository.d.ts +15 -2
- package/dist/features/cart/repository/cart.repository.js +15 -13
- package/dist/features/orders/repository/orders.repository.d.ts +12 -1
- package/dist/features/orders/repository/orders.repository.js +14 -5
- package/dist/features/payments/repository/payout.repository.d.ts +19 -9
- package/dist/features/payments/repository/payout.repository.js +26 -20
- package/dist/features/products/components/ProductFilters.d.ts +19 -1
- package/dist/features/products/components/ProductFilters.js +11 -3
- package/dist/features/products/components/ProductsIndexListing.d.ts +4 -2
- package/dist/features/products/components/ProductsIndexListing.js +40 -27
- package/dist/features/reviews/repository/reviews.repository.d.ts +10 -8
- package/dist/features/reviews/repository/reviews.repository.js +16 -19
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +161 -17
- package/dist/providers/db-firebase/base.repository.d.ts +12 -0
- package/dist/providers/db-firebase/base.repository.js +14 -2
- package/dist/react/hooks/usePendingFilters.d.ts +12 -2
- package/dist/react/hooks/usePendingFilters.js +2 -2
- package/dist/react/hooks/usePendingTable.d.ts +9 -2
- package/dist/security/pii-schemas.d.ts +19 -1
- package/dist/security/pii-schemas.js +19 -1
- package/package.json +1 -1
|
@@ -64,8 +64,23 @@ export const listingProcessor = defineFunction({
|
|
|
64
64
|
handler: listingProcessorHandler,
|
|
65
65
|
options: {
|
|
66
66
|
region: REGION,
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Matched to the `gateway` function's budget ON PURPOSE, and this is not
|
|
68
|
+
// over-provisioning.
|
|
69
|
+
//
|
|
70
|
+
// `listingProcessorHandler` is reachable two ways: this direct function,
|
|
71
|
+
// and `gateway` with `action: "listingProcessor"` (it is in
|
|
72
|
+
// GATEWAY_HANDLERS). `src/lib/listing-processor.ts` chooses between them on
|
|
73
|
+
// whether FIREBASE_FUNCTION_GATEWAY_URL is set — so an ENVIRONMENT VARIABLE
|
|
74
|
+
// decided which limits the same query ran under. At 30s/256MiB here against
|
|
75
|
+
// the gateway's 120s/512MiB, a heavy listing query could succeed through
|
|
76
|
+
// one path and time out through the other, with nothing at the call site
|
|
77
|
+
// saying which path served it. That is the shape of a production bug nobody
|
|
78
|
+
// can reproduce.
|
|
79
|
+
//
|
|
80
|
+
// Cloud Functions bill on use, not on the declared ceiling, so aligning the
|
|
81
|
+
// two costs nothing while idle and removes the non-determinism.
|
|
82
|
+
timeoutSeconds: 120,
|
|
83
|
+
memory: "512MiB",
|
|
69
84
|
maxInstances: 20,
|
|
70
85
|
minInstances: 0,
|
|
71
86
|
cors: false,
|
|
@@ -11,7 +11,11 @@ import { normalizeError } from "../../../../errors/normalize";
|
|
|
11
11
|
const DEFAULT_SORT = "-createdAt";
|
|
12
12
|
const DEFAULT_PAGE = 1;
|
|
13
13
|
const DEFAULT_PAGE_SIZE = 20;
|
|
14
|
-
|
|
14
|
+
// 50, matching the app's own ceiling. This was 100, so a caller reaching the
|
|
15
|
+
// Function could ask for twice what every route permits — and the routes are
|
|
16
|
+
// where Rule #6's `pageSize <= 50` is enforced. A limit that only one of two
|
|
17
|
+
// entry points applies is not a limit.
|
|
18
|
+
const MAX_PAGE_SIZE = 50;
|
|
15
19
|
class ListingError extends Error {
|
|
16
20
|
constructor(status, message) {
|
|
17
21
|
super(message);
|
|
@@ -3,7 +3,18 @@ import { type AddressBanStatus, type AddressCreateInput, type AddressDocument, t
|
|
|
3
3
|
export declare class AddressesRepository extends BaseRepository<AddressDocument> {
|
|
4
4
|
constructor();
|
|
5
5
|
private decryptAddress;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Declared, not hand-rolled.
|
|
8
|
+
*
|
|
9
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
10
|
+
* `encryptAddressData` did — encrypt the listed fields, then add the mapped blind
|
|
11
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
12
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
13
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
14
|
+
* encryption lived in a private method only three call sites remembered to
|
|
15
|
+
* call.
|
|
16
|
+
*/
|
|
17
|
+
protected piiFields: readonly ["fullName", "phone", "addressLine1", "addressLine2"];
|
|
7
18
|
protected mapDoc<D = AddressDocument>(snap: DocumentSnapshot): D;
|
|
8
19
|
createWithId(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
|
|
9
20
|
update(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
|
|
@@ -13,28 +13,37 @@ import { normalizeError } from "../../../errors/normalize";
|
|
|
13
13
|
import { DatabaseError } from "../../../errors";
|
|
14
14
|
import { serverLogger } from "../../../monitoring";
|
|
15
15
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, } from "../../../providers/db-firebase";
|
|
16
|
-
import { ADDRESS_PII_FIELDS, decryptPiiFields,
|
|
16
|
+
import { ADDRESS_PII_FIELDS, decryptPiiFields, } from "../../../security";
|
|
17
17
|
import { ADDRESS_FIELDS, ADDRESSES_COLLECTION, } from "../schemas";
|
|
18
18
|
export class AddressesRepository extends BaseRepository {
|
|
19
19
|
constructor() {
|
|
20
20
|
super(ADDRESSES_COLLECTION);
|
|
21
|
+
/**
|
|
22
|
+
* Declared, not hand-rolled.
|
|
23
|
+
*
|
|
24
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
25
|
+
* `encryptAddressData` did — encrypt the listed fields, then add the mapped blind
|
|
26
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
27
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
28
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
29
|
+
* encryption lived in a private method only three call sites remembered to
|
|
30
|
+
* call.
|
|
31
|
+
*/
|
|
32
|
+
this.piiFields = ADDRESS_PII_FIELDS;
|
|
21
33
|
}
|
|
22
34
|
decryptAddress(doc) {
|
|
23
35
|
return decryptPiiFields(doc, [...ADDRESS_PII_FIELDS]);
|
|
24
36
|
}
|
|
25
|
-
encryptAddressData(data) {
|
|
26
|
-
return encryptPiiFields(data, [...ADDRESS_PII_FIELDS]);
|
|
27
|
-
}
|
|
28
37
|
mapDoc(snap) {
|
|
29
38
|
const raw = super.mapDoc(snap);
|
|
30
39
|
return this.decryptAddress(raw);
|
|
31
40
|
}
|
|
32
41
|
async createWithId(id, data) {
|
|
33
|
-
const encrypted = this.
|
|
42
|
+
const encrypted = this.applyWriteHooks(data);
|
|
34
43
|
return super.createWithId(id, encrypted);
|
|
35
44
|
}
|
|
36
45
|
async update(id, data) {
|
|
37
|
-
const encrypted = this.
|
|
46
|
+
const encrypted = this.applyWriteHooks(data);
|
|
38
47
|
return super.update(id, encrypted);
|
|
39
48
|
}
|
|
40
49
|
async listByOwner(ownerType, ownerId) {
|
|
@@ -91,7 +100,7 @@ export class AddressesRepository extends BaseRepository {
|
|
|
91
100
|
createdAt: now,
|
|
92
101
|
updatedAt: now,
|
|
93
102
|
};
|
|
94
|
-
const encrypted = this.
|
|
103
|
+
const encrypted = this.applyWriteHooks(addressData);
|
|
95
104
|
await docRef.set(prepareForFirestore(encrypted));
|
|
96
105
|
serverLogger.info("Address created", {
|
|
97
106
|
ownerType,
|
|
@@ -6,7 +6,28 @@ export declare class UserRepository extends BaseRepository<UserDocument> {
|
|
|
6
6
|
static readonly SIEVE_FIELDS: FirebaseSieveFields;
|
|
7
7
|
constructor();
|
|
8
8
|
private decryptUser;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Declared, not hand-rolled.
|
|
11
|
+
*
|
|
12
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
13
|
+
* `encryptUserData` did — encrypt the listed fields, then add the mapped blind
|
|
14
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
15
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
16
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
17
|
+
* encryption lived in a private method only three call sites remembered to
|
|
18
|
+
* call.
|
|
19
|
+
*/
|
|
20
|
+
protected piiFields: readonly ["email", "phoneNumber", "googleLinkedEmail"];
|
|
21
|
+
protected piiIndexMap: Record<string, string>;
|
|
22
|
+
/**
|
|
23
|
+
* The base hook, plus the nested objects it cannot see.
|
|
24
|
+
*
|
|
25
|
+
* `piiFields` is a flat list of top-level names, so a nested blob has to be
|
|
26
|
+
* encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
|
|
27
|
+
* encryption path with one extra step on top, rather than two parallel paths
|
|
28
|
+
* that have to agree — which is what the hand-rolled override was.
|
|
29
|
+
*/
|
|
30
|
+
protected applyWriteHooks<D extends object>(data: D): D;
|
|
10
31
|
protected mapDoc<D = UserDocument>(snap: DocumentSnapshot): D;
|
|
11
32
|
createWithId(id: string, data: Partial<UserDocument>): Promise<UserDocument>;
|
|
12
33
|
create(input: Omit<UserDocument, "id" | "createdAt" | "updatedAt">): Promise<UserDocument>;
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { normalizeError } from "../../../errors/normalize";
|
|
2
2
|
import { DatabaseError } from "../../../errors";
|
|
3
3
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
4
|
-
import { USER_PII_FIELDS, USER_PII_INDEX_MAP,
|
|
4
|
+
import { USER_PII_FIELDS, USER_PII_INDEX_MAP, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptShippingConfig, piiBlindIndex, } from "../../../security";
|
|
5
5
|
import { USER_COLLECTION, USER_FIELDS, } from "../schemas";
|
|
6
6
|
export class UserRepository extends BaseRepository {
|
|
7
7
|
constructor() {
|
|
8
8
|
super(USER_COLLECTION);
|
|
9
|
+
/**
|
|
10
|
+
* Declared, not hand-rolled.
|
|
11
|
+
*
|
|
12
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
13
|
+
* `encryptUserData` did — encrypt the listed fields, then add the mapped blind
|
|
14
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
15
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
16
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
17
|
+
* encryption lived in a private method only three call sites remembered to
|
|
18
|
+
* call.
|
|
19
|
+
*/
|
|
20
|
+
this.piiFields = USER_PII_FIELDS;
|
|
21
|
+
this.piiIndexMap = USER_PII_INDEX_MAP;
|
|
9
22
|
}
|
|
10
23
|
decryptUser(doc) {
|
|
11
24
|
const decrypted = decryptPiiFields(doc, [...USER_PII_FIELDS]);
|
|
@@ -17,32 +30,22 @@ export class UserRepository extends BaseRepository {
|
|
|
17
30
|
}
|
|
18
31
|
return decrypted;
|
|
19
32
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// `piiIndicesFor` applies USER_PII_INDEX_MAP's real names and, unlike
|
|
33
|
-
// `addPiiIndices`, carries no plaintext back over the ciphertext.
|
|
34
|
-
const encrypted = {
|
|
35
|
-
...encryptPiiFields(data, [...USER_PII_FIELDS]),
|
|
36
|
-
...piiIndicesFor(data, USER_PII_INDEX_MAP),
|
|
37
|
-
};
|
|
38
|
-
const access = encrypted;
|
|
39
|
-
if (access.payoutDetails) {
|
|
33
|
+
/**
|
|
34
|
+
* The base hook, plus the nested objects it cannot see.
|
|
35
|
+
*
|
|
36
|
+
* `piiFields` is a flat list of top-level names, so a nested blob has to be
|
|
37
|
+
* encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
|
|
38
|
+
* encryption path with one extra step on top, rather than two parallel paths
|
|
39
|
+
* that have to agree — which is what the hand-rolled override was.
|
|
40
|
+
*/
|
|
41
|
+
applyWriteHooks(data) {
|
|
42
|
+
const out = super.applyWriteHooks(data);
|
|
43
|
+
const access = out;
|
|
44
|
+
if (access.payoutDetails)
|
|
40
45
|
access.payoutDetails = encryptPayoutDetails(access.payoutDetails);
|
|
41
|
-
|
|
42
|
-
if (access.shippingConfig) {
|
|
46
|
+
if (access.shippingConfig)
|
|
43
47
|
access.shippingConfig = encryptShippingConfig(access.shippingConfig);
|
|
44
|
-
|
|
45
|
-
return encrypted;
|
|
48
|
+
return out;
|
|
46
49
|
}
|
|
47
50
|
mapDoc(snap) {
|
|
48
51
|
const raw = super.mapDoc(snap);
|
|
@@ -57,7 +60,7 @@ export class UserRepository extends BaseRepository {
|
|
|
57
60
|
// check with no error anywhere (found via a live RBAC bug where an
|
|
58
61
|
// admin account created this way couldn't access /admin).
|
|
59
62
|
const withUid = { uid: id, ...data };
|
|
60
|
-
const encrypted = this.
|
|
63
|
+
const encrypted = this.applyWriteHooks(withUid);
|
|
61
64
|
return super.createWithId(id, encrypted);
|
|
62
65
|
}
|
|
63
66
|
async create(input) {
|
|
@@ -78,7 +81,7 @@ export class UserRepository extends BaseRepository {
|
|
|
78
81
|
createdAt: new Date(),
|
|
79
82
|
updatedAt: new Date(),
|
|
80
83
|
};
|
|
81
|
-
const encrypted = this.
|
|
84
|
+
const encrypted = this.applyWriteHooks(userData);
|
|
82
85
|
await this.db
|
|
83
86
|
.collection(this.collection)
|
|
84
87
|
.doc(id)
|
|
@@ -155,7 +158,7 @@ export class UserRepository extends BaseRepository {
|
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
async update(uid, data) {
|
|
158
|
-
const encrypted = this.
|
|
161
|
+
const encrypted = this.applyWriteHooks(data);
|
|
159
162
|
return super.update(uid, encrypted);
|
|
160
163
|
}
|
|
161
164
|
async markEmailAsVerified(uid) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type DocumentReference } from "firebase-admin/firestore";
|
|
2
|
-
import type { DocumentSnapshot } from "../../../providers/db-firebase";
|
|
3
2
|
import { BaseRepository, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
|
|
4
3
|
import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartStoreAddons, type CartItemDocument, type AddGroupLineInput, type UpdateCartItemInput, type UpdateCartGroupMembersInput } from "../schemas";
|
|
5
4
|
export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
@@ -7,7 +6,21 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
|
7
6
|
constructor();
|
|
8
7
|
/** Admin-facing paginated list of carts. */
|
|
9
8
|
list(model: SieveModel): Promise<FirebaseSieveResult<CartDocument>>;
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* `mapDoc` is deliberately NOT overridden.
|
|
11
|
+
*
|
|
12
|
+
* It used to decrypt `items[].storeName` on every read — and nothing has
|
|
13
|
+
* ever encrypted that field. `CartRepository` declares no `piiFields`, and
|
|
14
|
+
* `storeName` appears nowhere in pii-schemas.ts. `decryptPii` passes a
|
|
15
|
+
* non-`enc:v1:` string straight through, so the call was a no-op on every
|
|
16
|
+
* document while reading as protection — the most expensive kind of dead
|
|
17
|
+
* code, because it answers the question "is this handled?" wrongly.
|
|
18
|
+
*
|
|
19
|
+
* A store name is not PII: it is the seller's public shopfront name, shown
|
|
20
|
+
* on every listing. If a genuinely private field is added to a cart line,
|
|
21
|
+
* declare it in `piiFields` so `applyWriteHooks` encrypts it on all nine
|
|
22
|
+
* write paths — do not reintroduce a decrypt with no matching encrypt.
|
|
23
|
+
*/
|
|
11
24
|
findByUserId(userId: string): Promise<CartDocument | null>;
|
|
12
25
|
getOrCreate(userId: string): Promise<CartDocument>;
|
|
13
26
|
createEmpty(userId: string): Promise<CartDocument>;
|
|
@@ -6,7 +6,6 @@ import { normalizeError } from "../../../errors/normalize";
|
|
|
6
6
|
const ERR_CART_ITEM_NOT_FOUND = "Cart item not found";
|
|
7
7
|
const ERR_CART_ITEM_LOCKED = "This item requires payment and cannot be removed or modified.";
|
|
8
8
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
9
|
-
import { decryptPii } from "../../../security";
|
|
10
9
|
import { CART_COLLECTION, } from "../schemas";
|
|
11
10
|
export class CartRepository extends BaseRepository {
|
|
12
11
|
constructor() {
|
|
@@ -16,18 +15,21 @@ export class CartRepository extends BaseRepository {
|
|
|
16
15
|
async list(model) {
|
|
17
16
|
return this.sieveQuery(model, CartRepository.SIEVE_FIELDS);
|
|
18
17
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `mapDoc` is deliberately NOT overridden.
|
|
20
|
+
*
|
|
21
|
+
* It used to decrypt `items[].storeName` on every read — and nothing has
|
|
22
|
+
* ever encrypted that field. `CartRepository` declares no `piiFields`, and
|
|
23
|
+
* `storeName` appears nowhere in pii-schemas.ts. `decryptPii` passes a
|
|
24
|
+
* non-`enc:v1:` string straight through, so the call was a no-op on every
|
|
25
|
+
* document while reading as protection — the most expensive kind of dead
|
|
26
|
+
* code, because it answers the question "is this handled?" wrongly.
|
|
27
|
+
*
|
|
28
|
+
* A store name is not PII: it is the seller's public shopfront name, shown
|
|
29
|
+
* on every listing. If a genuinely private field is added to a cart line,
|
|
30
|
+
* declare it in `piiFields` so `applyWriteHooks` encrypts it on all nine
|
|
31
|
+
* write paths — do not reintroduce a decrypt with no matching encrypt.
|
|
32
|
+
*/
|
|
31
33
|
async findByUserId(userId) {
|
|
32
34
|
return this.findById(userId);
|
|
33
35
|
}
|
|
@@ -27,7 +27,18 @@ export interface OrderWriteContext {
|
|
|
27
27
|
declare class OrderRepository extends BaseRepository<OrderDocument> {
|
|
28
28
|
constructor();
|
|
29
29
|
private decryptOrder;
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Declared, not hand-rolled.
|
|
32
|
+
*
|
|
33
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
34
|
+
* `encryptOrderData` did — encrypt the listed fields, then add the mapped blind
|
|
35
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
36
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
37
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
38
|
+
* encryption lived in a private method only three call sites remembered to
|
|
39
|
+
* call.
|
|
40
|
+
*/
|
|
41
|
+
protected piiFields: readonly ["userName", "userEmail", "sellerEmail"];
|
|
31
42
|
protected mapDoc<D = OrderDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
|
|
32
43
|
create(input: OrderCreateInput): Promise<OrderDocument>;
|
|
33
44
|
findByUser(userId: string): Promise<OrderDocument[]>;
|
|
@@ -3,7 +3,7 @@ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../uti
|
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
4
|
import { DatabaseError, NotFoundError } from "../../../errors";
|
|
5
5
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
6
|
-
import { decryptPiiFields,
|
|
6
|
+
import { decryptPiiFields, ORDER_PII_FIELDS, } from "../../../security";
|
|
7
7
|
import { serverTimestamp, arrayUnion } from "../../../contracts/field-ops";
|
|
8
8
|
import { createOrderId, ORDER_COLLECTION, ORDER_TRACKED_FIELDS, OrderStatusValues, } from "../schemas";
|
|
9
9
|
import { withHistory } from "../../../_internal/shared/history/index";
|
|
@@ -24,15 +24,24 @@ const ACTIVE_ALLOWANCE_STATUSES = [
|
|
|
24
24
|
class OrderRepository extends BaseRepository {
|
|
25
25
|
constructor() {
|
|
26
26
|
super(ORDER_COLLECTION);
|
|
27
|
+
/**
|
|
28
|
+
* Declared, not hand-rolled.
|
|
29
|
+
*
|
|
30
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
31
|
+
* `encryptOrderData` did — encrypt the listed fields, then add the mapped blind
|
|
32
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
33
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
34
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
35
|
+
* encryption lived in a private method only three call sites remembered to
|
|
36
|
+
* call.
|
|
37
|
+
*/
|
|
38
|
+
this.piiFields = ORDER_PII_FIELDS;
|
|
27
39
|
}
|
|
28
40
|
decryptOrder(doc) {
|
|
29
41
|
return decryptPiiFields(doc, [
|
|
30
42
|
...ORDER_PII_FIELDS,
|
|
31
43
|
]);
|
|
32
44
|
}
|
|
33
|
-
encryptOrderData(data) {
|
|
34
|
-
return encryptPiiFields(data, [...ORDER_PII_FIELDS]);
|
|
35
|
-
}
|
|
36
45
|
mapDoc(snap) {
|
|
37
46
|
const raw = super.mapDoc(snap);
|
|
38
47
|
return this.decryptOrder(raw);
|
|
@@ -46,7 +55,7 @@ class OrderRepository extends BaseRepository {
|
|
|
46
55
|
createdAt: new Date(),
|
|
47
56
|
updatedAt: new Date(),
|
|
48
57
|
};
|
|
49
|
-
const encrypted = this.
|
|
58
|
+
const encrypted = this.applyWriteHooks(orderData);
|
|
50
59
|
await this.db
|
|
51
60
|
.collection(this.collection)
|
|
52
61
|
.doc(id)
|
|
@@ -20,17 +20,27 @@ export declare class PayoutRepository extends BaseRepository<PayoutDocument> {
|
|
|
20
20
|
/** Decrypt PII fields on a payout document after Firestore read */
|
|
21
21
|
private decryptPayout;
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
23
|
+
* Declared, not hand-rolled.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
*
|
|
31
|
-
*
|
|
25
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
26
|
+
* `encryptPayoutData` did — encrypt the listed fields, then add the mapped blind
|
|
27
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
28
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
29
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
30
|
+
* encryption lived in a private method only three call sites remembered to
|
|
31
|
+
* call.
|
|
32
32
|
*/
|
|
33
|
-
|
|
33
|
+
protected piiFields: readonly ["sellerEmail", "upiId"];
|
|
34
|
+
protected piiIndexMap: Record<string, string>;
|
|
35
|
+
/**
|
|
36
|
+
* The base hook, plus the nested objects it cannot see.
|
|
37
|
+
*
|
|
38
|
+
* `piiFields` is a flat list of top-level names, so a nested blob has to be
|
|
39
|
+
* encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
|
|
40
|
+
* encryption path with one extra step on top, rather than two parallel paths
|
|
41
|
+
* that have to agree — which is what the hand-rolled override was.
|
|
42
|
+
*/
|
|
43
|
+
protected applyWriteHooks<D extends object>(data: D): D;
|
|
34
44
|
/** Override mapDoc to auto-decrypt PII on every Firestore read */
|
|
35
45
|
protected mapDoc<D = PayoutDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
|
|
36
46
|
/**
|
|
@@ -7,10 +7,23 @@
|
|
|
7
7
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
8
8
|
import { PAYOUT_COLLECTION, PAYOUT_FIELDS, PAYOUT_TRACKED_FIELDS, createPayoutId } from "../schemas";
|
|
9
9
|
import { withHistory } from "../../../_internal/shared/history/index";
|
|
10
|
-
import {
|
|
10
|
+
import { decryptPiiFields, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "../../../security";
|
|
11
11
|
export class PayoutRepository extends BaseRepository {
|
|
12
12
|
constructor() {
|
|
13
13
|
super(PAYOUT_COLLECTION);
|
|
14
|
+
/**
|
|
15
|
+
* Declared, not hand-rolled.
|
|
16
|
+
*
|
|
17
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
18
|
+
* `encryptPayoutData` did — encrypt the listed fields, then add the mapped blind
|
|
19
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
20
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
21
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
22
|
+
* encryption lived in a private method only three call sites remembered to
|
|
23
|
+
* call.
|
|
24
|
+
*/
|
|
25
|
+
this.piiFields = PAYOUT_PII_FIELDS;
|
|
26
|
+
this.piiIndexMap = PAYOUT_PII_INDEX_MAP;
|
|
14
27
|
}
|
|
15
28
|
/** Decrypt PII fields on a payout document after Firestore read */
|
|
16
29
|
decryptPayout(doc) {
|
|
@@ -21,26 +34,19 @@ export class PayoutRepository extends BaseRepository {
|
|
|
21
34
|
return decrypted;
|
|
22
35
|
}
|
|
23
36
|
/**
|
|
24
|
-
*
|
|
37
|
+
* The base hook, plus the nested objects it cannot see.
|
|
25
38
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `piiIndicesFor` returns the index fields alone, so the ciphertext cannot be
|
|
31
|
-
* overwritten. The identical bug was fixed in UserRepository and never
|
|
32
|
-
* propagated here.
|
|
39
|
+
* `piiFields` is a flat list of top-level names, so a nested blob has to be
|
|
40
|
+
* encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
|
|
41
|
+
* encryption path with one extra step on top, rather than two parallel paths
|
|
42
|
+
* that have to agree — which is what the hand-rolled override was.
|
|
33
43
|
*/
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
};
|
|
39
|
-
const access = encrypted;
|
|
40
|
-
if (access.bankAccount) {
|
|
44
|
+
applyWriteHooks(data) {
|
|
45
|
+
const out = super.applyWriteHooks(data);
|
|
46
|
+
const access = out;
|
|
47
|
+
if (access.bankAccount)
|
|
41
48
|
access.bankAccount = encryptPayoutBankAccount(access.bankAccount);
|
|
42
|
-
|
|
43
|
-
return encrypted;
|
|
49
|
+
return out;
|
|
44
50
|
}
|
|
45
51
|
/** Override mapDoc to auto-decrypt PII on every Firestore read */
|
|
46
52
|
mapDoc(snap) {
|
|
@@ -61,7 +67,7 @@ export class PayoutRepository extends BaseRepository {
|
|
|
61
67
|
updatedAt: now,
|
|
62
68
|
};
|
|
63
69
|
// Encrypt PII before persisting
|
|
64
|
-
const encrypted = this.
|
|
70
|
+
const encrypted = this.applyWriteHooks(data);
|
|
65
71
|
await this.db
|
|
66
72
|
.collection(this.collection)
|
|
67
73
|
.doc(id)
|
|
@@ -70,7 +76,7 @@ export class PayoutRepository extends BaseRepository {
|
|
|
70
76
|
}
|
|
71
77
|
/** Override base update to preserve encryption/indexing for changed PII fields. */
|
|
72
78
|
async update(payoutId, data) {
|
|
73
|
-
const encrypted = this.
|
|
79
|
+
const encrypted = this.applyWriteHooks(data);
|
|
74
80
|
return super.update(payoutId, encrypted);
|
|
75
81
|
}
|
|
76
82
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import type { FacetOption } from "../../filters/FilterFacetSection";
|
|
2
3
|
import type { UrlTable } from "../../filters/FilterPanel";
|
|
3
4
|
import type { AsyncFacetSectionProps } from "../../filters/AsyncFacetSection";
|
|
@@ -115,6 +116,23 @@ export interface ProductFiltersProps {
|
|
|
115
116
|
sublistingCategoryOptions?: FacetOption[];
|
|
116
117
|
/** Feature badge options for filtering by product features */
|
|
117
118
|
featureOptions?: FacetOption[];
|
|
119
|
+
/**
|
|
120
|
+
* Listing-type facet options — `{ value: <canonical ListingType>, label }`.
|
|
121
|
+
*
|
|
122
|
+
* Opt-in: only the combined browse pages (`/products`, `/art`) span more than
|
|
123
|
+
* one listing type, so every other caller leaves this empty and the section
|
|
124
|
+
* does not render. Derive the options from `PRODUCT_TYPE_FILTER_TABS` /
|
|
125
|
+
* `ART_STICKERS_TYPE_FILTER_TABS` — never a hand-written list, which is what
|
|
126
|
+
* `audit-listing-type-tab-coverage.mjs` exists to prevent (Root Cause #61).
|
|
127
|
+
*/
|
|
128
|
+
listingTypeOptions?: FacetOption[];
|
|
129
|
+
/**
|
|
130
|
+
* Rendered directly beneath the listing-type section. The caller owns it so
|
|
131
|
+
* this shared component does not have to know about the listing-type plugin
|
|
132
|
+
* registry — `/products` uses it for the "Full <type> filters →" link to a
|
|
133
|
+
* single selected type's dedicated browse page.
|
|
134
|
+
*/
|
|
135
|
+
listingTypeFooter?: ReactNode;
|
|
118
136
|
showStatus?: boolean;
|
|
119
137
|
variant?: ProductFilterVariant;
|
|
120
138
|
statusOptions?: FacetOption[];
|
|
@@ -124,4 +142,4 @@ export interface ProductFiltersProps {
|
|
|
124
142
|
/** Show "part of a bundle" switch filter (default true) */
|
|
125
143
|
showBundleFilter?: boolean;
|
|
126
144
|
}
|
|
127
|
-
export declare function ProductFilters({ table, categoryOptions, brandOptions, storeOptions, sellerOptions, tagOptions, sublistingCategoryOptions, featureOptions, showStatus, variant, statusOptions, currencyPrefix, showShipping, showBundleFilter, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }: ProductFiltersProps): import("react").JSX.Element;
|
|
145
|
+
export declare function ProductFilters({ table, categoryOptions, brandOptions, storeOptions, sellerOptions, tagOptions, sublistingCategoryOptions, featureOptions, listingTypeOptions, listingTypeFooter, showStatus, variant, statusOptions, currencyPrefix, showShipping, showBundleFilter, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }: ProductFiltersProps): import("react").JSX.Element;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
4
|
import { FilterFacetSection } from "../../filters/FilterFacetSection";
|
|
5
5
|
import { AsyncFacetSection } from "../../filters/AsyncFacetSection";
|
|
@@ -66,7 +66,7 @@ export function getProductSortOptions(variant) {
|
|
|
66
66
|
return PRODUCT_PUBLIC_SORT_OPTIONS;
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
-
export function ProductFilters({ table, categoryOptions = [], brandOptions = [], storeOptions = [], sellerOptions, tagOptions = [], sublistingCategoryOptions = [], featureOptions = [], showStatus = false, variant, statusOptions, currencyPrefix = "", showShipping = true, showBundleFilter = true, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }) {
|
|
69
|
+
export function ProductFilters({ table, categoryOptions = [], brandOptions = [], storeOptions = [], sellerOptions, tagOptions = [], sublistingCategoryOptions = [], featureOptions = [], listingTypeOptions = [], listingTypeFooter, showStatus = false, variant, statusOptions, currencyPrefix = "", showShipping = true, showBundleFilter = true, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }) {
|
|
70
70
|
const resolvedStoreOptions = storeOptions.length > 0 ? storeOptions : (sellerOptions ?? []);
|
|
71
71
|
const t = useTranslations("filters");
|
|
72
72
|
const conditionOptions = [
|
|
@@ -99,5 +99,13 @@ export function ProductFilters({ table, categoryOptions = [], brandOptions = [],
|
|
|
99
99
|
const selectedStatuses = splitPipe(TABLE_KEYS.STATUS);
|
|
100
100
|
const selectedSublistings = splitPipe(TABLE_KEYS.SUBLISTING_CATEGORY);
|
|
101
101
|
const selectedFeatures = splitPipe(TABLE_KEYS.FEATURES);
|
|
102
|
-
|
|
102
|
+
const selectedListingTypes = splitPipe(TABLE_KEYS.LISTING_TYPE);
|
|
103
|
+
return (_jsxs(Div, { children: [listingTypeOptions.length > 0 && (_jsxs(_Fragment, { children: [_jsx(FilterFacetSection, { title: "Listing type", options: listingTypeOptions, selected: selectedListingTypes, onChange: (vals) =>
|
|
104
|
+
// Serialise in the OPTIONS' order, not the order the boxes were
|
|
105
|
+
// ticked — `auction|art` and `art|auction` are the same result
|
|
106
|
+
// set but two different React Query keys and two different URLs.
|
|
107
|
+
table.set(TABLE_KEYS.LISTING_TYPE, listingTypeOptions
|
|
108
|
+
.filter((o) => vals.includes(o.value))
|
|
109
|
+
.map((o) => o.value)
|
|
110
|
+
.join("|")), searchable: false, defaultCollapsed: false }), listingTypeFooter] })), loadCategoryOptions ? (_jsx(AsyncFacetSection, { title: t("category"), loadOptions: loadCategoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), defaultCollapsed: false })) : categoryOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("category"), options: categoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), searchable: true, defaultCollapsed: categoryOptions.length > 6 })) : null, _jsx(FilterFacetSection, { title: t("condition"), options: conditionOptions, selected: selectedConditions, onChange: (vals) => table.set(TABLE_KEYS.CONDITION, vals.join("|")), searchable: false, defaultCollapsed: false }), _jsx(RangeFilter, { title: t("priceRange"), minValue: table.get(TABLE_KEYS.MIN_PRICE), maxValue: table.get(TABLE_KEYS.MAX_PRICE), onMinChange: (v) => table.set(TABLE_KEYS.MIN_PRICE, v), onMaxChange: (v) => table.set(TABLE_KEYS.MAX_PRICE, v), prefix: currencyPrefix, showSlider: true, minBound: 0, maxBound: 500000, step: 500, minPlaceholder: t("minPrice"), maxPlaceholder: t("maxPrice"), defaultCollapsed: false }), loadBrandOptions ? (_jsx(AsyncFacetSection, { title: t("brand"), loadOptions: loadBrandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), defaultCollapsed: false })) : brandOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), searchable: brandOptions.length > 4, defaultCollapsed: brandOptions.length > 6 })) : null, loadStoreOptions ? (_jsx(AsyncFacetSection, { title: t("store"), loadOptions: loadStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), defaultCollapsed: false })) : resolvedStoreOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("store"), options: resolvedStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), searchable: resolvedStoreOptions.length > 4, defaultCollapsed: resolvedStoreOptions.length > 6 })) : null, showShipping && (_jsx(SwitchFilter, { title: t("shipping"), label: t("freeShippingOnly"), checked: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (v) => table.set(TABLE_KEYS.FREE_SHIPPING, v ? "true" : ""), defaultCollapsed: false })), tagOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("tags"), options: tagOptions, selected: selectedTags, onChange: (vals) => table.set(TABLE_KEYS.TAGS, vals.join("|")), searchable: tagOptions.length > 4, defaultCollapsed: tagOptions.length > 6 })), shouldShowStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions ?? defaultStatusOptions, selected: selectedStatuses, onChange: (vals) => table.set(TABLE_KEYS.STATUS, vals.join("|")), searchable: false, defaultCollapsed: false })), sublistingCategoryOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Sublisting Type", options: sublistingCategoryOptions, selected: selectedSublistings, onChange: (vals) => table.set(TABLE_KEYS.SUBLISTING_CATEGORY, vals.join("|")), searchable: sublistingCategoryOptions.length > 4, defaultCollapsed: false })), showBundleFilter && (_jsx(SwitchFilter, { title: "Bundles", label: "Part of a bundle only", checked: table.get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true", onChange: (v) => table.set(TABLE_KEYS.IS_PART_OF_BUNDLE, v ? "true" : ""), defaultCollapsed: false })), featureOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Features", options: featureOptions, selected: selectedFeatures, onChange: (vals) => table.set(TABLE_KEYS.FEATURES, vals.join("|")), searchable: featureOptions.length > 4, defaultCollapsed: featureOptions.length > 4 }))] }));
|
|
103
111
|
}
|
|
@@ -6,8 +6,10 @@ export interface ProductsIndexListingProps {
|
|
|
6
6
|
* (e.g. `["art", "stickers"]`) to reuse this component for a different
|
|
7
7
|
* combined browse page. */
|
|
8
8
|
listingTypes?: readonly string[];
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
9
|
+
/** Options for the "Listing type" checkbox facet in the filter drawer. `id`
|
|
10
|
+
* must be the canonical `ListingType` — it is passed straight into
|
|
11
|
+
* `sieveFilter("listingType", EQ, id)`. Defaults to the generic Products set;
|
|
12
|
+
* pass a matching set when overriding `listingTypes`. */
|
|
11
13
|
typeTabs?: readonly {
|
|
12
14
|
id: string;
|
|
13
15
|
label: string;
|