@mohasinac/appkit 4.30.0 → 4.30.2
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/features/products/list-public.d.ts +11 -0
- package/dist/_internal/server/features/products/list-public.js +20 -3
- package/dist/features/auth/repository/token.repository.d.ts +25 -2
- package/dist/features/auth/repository/token.repository.js +26 -18
- package/dist/features/products/repository/products.repository.js +26 -2
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -6
- package/dist/providers/db-firebase/sieve.js +45 -3
- package/dist/security/browser.d.ts +1 -1
- package/dist/security/browser.js +1 -1
- package/dist/security/index.d.ts +2 -2
- package/dist/security/index.js +2 -2
- package/dist/security/pii-encrypt.d.ts +12 -6
- package/dist/security/pii-encrypt.js +12 -16
- package/dist/security/pii-schemas.d.ts +0 -4
- package/dist/security/pii-schemas.js +13 -4
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +11 -4
|
@@ -116,6 +116,17 @@ export interface PublicProductQuery {
|
|
|
116
116
|
page: number;
|
|
117
117
|
pageSize: number;
|
|
118
118
|
cursor?: string | null;
|
|
119
|
+
/**
|
|
120
|
+
* Free-text search, carried OUTSIDE `filters` on purpose.
|
|
121
|
+
*
|
|
122
|
+
* `searchTxt` matching is `array-contains`, which Sieve cannot express — so
|
|
123
|
+
* it travels as an opt to `productRepository.list` (and as `baseOpts.search`
|
|
124
|
+
* to the listingProcessor Function, whose products lister forwards opts
|
|
125
|
+
* wholesale). Every executor must pass it on; dropping it is silent, because
|
|
126
|
+
* a query with no search term is still a perfectly valid query that returns
|
|
127
|
+
* rows.
|
|
128
|
+
*/
|
|
129
|
+
search?: string;
|
|
119
130
|
}
|
|
120
131
|
export interface ExecutorResult {
|
|
121
132
|
items: unknown[];
|
|
@@ -275,8 +275,18 @@ function buildFirestoreSafeFilters(input) {
|
|
|
275
275
|
if (input.prizeRevealStatus) {
|
|
276
276
|
parts.push(sieveFilter(PRODUCT_FIELDS.PRIZE_REVEAL_STATUS, SIEVE_OP.EQ, input.prizeRevealStatus));
|
|
277
277
|
}
|
|
278
|
-
|
|
279
|
-
|
|
278
|
+
// `input.q` is deliberately NOT a filter clause.
|
|
279
|
+
//
|
|
280
|
+
// It used to be `title@=*<q>` pushed HERE — before the features, tags,
|
|
281
|
+
// sublisting and typeFacets clauses below. The Firebase adapter throws on
|
|
282
|
+
// case-insensitive operators and `throwExceptions: false` swallowed it, so
|
|
283
|
+
// the processor returned the query as of the throw: with this clause first,
|
|
284
|
+
// NOTHING was applied. Typing a search term discarded every facet the user
|
|
285
|
+
// had selected AND the sort, and answered 200 with the whole catalogue.
|
|
286
|
+
//
|
|
287
|
+
// Search now travels as `query.search` (see PublicProductQuery) and becomes an
|
|
288
|
+
// `array-contains` on `searchTxt` inside the repository, which is the only
|
|
289
|
+
// shape Firestore can serve for token matching.
|
|
280
290
|
// `array-contains-any` isn't supported by the Sieve Firebase adapter, so only a
|
|
281
291
|
// single feature can be pushed down; multi-select falls through to the caller.
|
|
282
292
|
if (input.features?.length === 1) {
|
|
@@ -329,7 +339,11 @@ async function defaultExecutor(query) {
|
|
|
329
339
|
sorts: query.sorts,
|
|
330
340
|
page: query.page,
|
|
331
341
|
pageSize: query.pageSize,
|
|
332
|
-
}
|
|
342
|
+
},
|
|
343
|
+
// Without this second argument the repository's searchTxt read path is
|
|
344
|
+
// unreachable on the default executor, which is what every SSR listing view
|
|
345
|
+
// uses. It was implemented and simply never called.
|
|
346
|
+
query.search ? { search: query.search } : undefined);
|
|
333
347
|
return {
|
|
334
348
|
items: result.items,
|
|
335
349
|
total: result.total,
|
|
@@ -405,6 +419,7 @@ export async function listPublicProducts(input, opts) {
|
|
|
405
419
|
page,
|
|
406
420
|
pageSize,
|
|
407
421
|
cursor: input.cursor ?? null,
|
|
422
|
+
search: input.q,
|
|
408
423
|
});
|
|
409
424
|
if (!result)
|
|
410
425
|
return null;
|
|
@@ -440,6 +455,7 @@ export async function listPublicProducts(input, opts) {
|
|
|
440
455
|
page: 1,
|
|
441
456
|
pageSize: PUBLIC_PRODUCT_MAX_PAGE_SIZE,
|
|
442
457
|
cursor: null,
|
|
458
|
+
search: input.q,
|
|
443
459
|
});
|
|
444
460
|
}));
|
|
445
461
|
// One failed branch must not blank the whole scope — but it is already
|
|
@@ -505,6 +521,7 @@ export async function listPublicProducts(input, opts) {
|
|
|
505
521
|
page: inMemory ? 1 : page,
|
|
506
522
|
pageSize: inMemory ? PUBLIC_PRODUCT_MAX_PAGE_SIZE : pageSize,
|
|
507
523
|
cursor: inMemory ? null : (input.cursor ?? null),
|
|
524
|
+
search: input.q,
|
|
508
525
|
});
|
|
509
526
|
if (!result)
|
|
510
527
|
return null;
|
|
@@ -3,8 +3,23 @@ import { BaseRepository } from "../../../providers/db-firebase";
|
|
|
3
3
|
import { type EmailVerificationTokenDocument, type PasswordResetTokenDocument } from "../schemas";
|
|
4
4
|
export declare class EmailVerificationTokenRepository extends BaseRepository<EmailVerificationTokenDocument> {
|
|
5
5
|
constructor();
|
|
6
|
+
/**
|
|
7
|
+
* Declared, not hand-rolled in a `create` override.
|
|
8
|
+
*
|
|
9
|
+
* The override this replaces computed `encryptPiiFields(...)` and then
|
|
10
|
+
* OVERWROTE the result with `addPiiIndices(data, MAP)` — which returns
|
|
11
|
+
* `{...plaintext, ...indices}` — so the ciphertext was discarded and every
|
|
12
|
+
* verification token's email was written to Firestore in CLEARTEXT. It was
|
|
13
|
+
* invisible because `mapDoc` decrypts on read and `decryptPiiFields` passes
|
|
14
|
+
* plaintext straight through, so reads looked identical either way.
|
|
15
|
+
*
|
|
16
|
+
* `applyWriteHooks` covers create/createWithId/update and the `*InTx` /
|
|
17
|
+
* `*InBatch` family, so no write path can skip encryption the way an
|
|
18
|
+
* override of `create` alone did.
|
|
19
|
+
*/
|
|
20
|
+
protected piiFields: readonly ["email"];
|
|
21
|
+
protected piiIndexMap: Record<string, string>;
|
|
6
22
|
protected mapDoc<D = EmailVerificationTokenDocument>(snap: DocumentSnapshot): D;
|
|
7
|
-
create(data: Partial<EmailVerificationTokenDocument>): Promise<EmailVerificationTokenDocument>;
|
|
8
23
|
findByToken(token: string): Promise<EmailVerificationTokenDocument | null>;
|
|
9
24
|
findByUserId(userId: string): Promise<EmailVerificationTokenDocument[]>;
|
|
10
25
|
findByEmail(email: string): Promise<EmailVerificationTokenDocument[]>;
|
|
@@ -15,8 +30,16 @@ export declare class EmailVerificationTokenRepository extends BaseRepository<Ema
|
|
|
15
30
|
}
|
|
16
31
|
export declare class PasswordResetTokenRepository extends BaseRepository<PasswordResetTokenDocument> {
|
|
17
32
|
constructor();
|
|
33
|
+
/**
|
|
34
|
+
* Same defect as its sibling above, one spread further along: the override
|
|
35
|
+
* this replaces built `{...encrypted, ...addPiiIndices(data, MAP)}`, and
|
|
36
|
+
* because `addPiiIndices` returns `{...plaintext, ...indices}` the plaintext
|
|
37
|
+
* `email` was spread back OVER the ciphertext. Reset-token emails were
|
|
38
|
+
* written in cleartext too.
|
|
39
|
+
*/
|
|
40
|
+
protected piiFields: readonly ["email"];
|
|
41
|
+
protected piiIndexMap: Record<string, string>;
|
|
18
42
|
protected mapDoc<D = PasswordResetTokenDocument>(snap: DocumentSnapshot): D;
|
|
19
|
-
create(data: Partial<PasswordResetTokenDocument>): Promise<PasswordResetTokenDocument>;
|
|
20
43
|
findByToken(token: string): Promise<PasswordResetTokenDocument | null>;
|
|
21
44
|
findByUserId(userId: string): Promise<PasswordResetTokenDocument[]>;
|
|
22
45
|
findByEmail(email: string): Promise<PasswordResetTokenDocument[]>;
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import { normalizeError } from "../../../errors/normalize";
|
|
2
2
|
import { DatabaseError } from "../../../errors";
|
|
3
3
|
import { BaseRepository } from "../../../providers/db-firebase";
|
|
4
|
-
import { TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP,
|
|
4
|
+
import { TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, decryptPiiFields, piiBlindIndex, } from "../../../security";
|
|
5
5
|
import { resolveDate } from "../../../utils";
|
|
6
6
|
import { EMAIL_VERIFICATION_COLLECTION, PASSWORD_RESET_COLLECTION, TOKEN_FIELDS, } from "../schemas";
|
|
7
7
|
export class EmailVerificationTokenRepository extends BaseRepository {
|
|
8
8
|
constructor() {
|
|
9
9
|
super(EMAIL_VERIFICATION_COLLECTION);
|
|
10
|
+
/**
|
|
11
|
+
* Declared, not hand-rolled in a `create` override.
|
|
12
|
+
*
|
|
13
|
+
* The override this replaces computed `encryptPiiFields(...)` and then
|
|
14
|
+
* OVERWROTE the result with `addPiiIndices(data, MAP)` — which returns
|
|
15
|
+
* `{...plaintext, ...indices}` — so the ciphertext was discarded and every
|
|
16
|
+
* verification token's email was written to Firestore in CLEARTEXT. It was
|
|
17
|
+
* invisible because `mapDoc` decrypts on read and `decryptPiiFields` passes
|
|
18
|
+
* plaintext straight through, so reads looked identical either way.
|
|
19
|
+
*
|
|
20
|
+
* `applyWriteHooks` covers create/createWithId/update and the `*InTx` /
|
|
21
|
+
* `*InBatch` family, so no write path can skip encryption the way an
|
|
22
|
+
* override of `create` alone did.
|
|
23
|
+
*/
|
|
24
|
+
this.piiFields = TOKEN_PII_FIELDS;
|
|
25
|
+
this.piiIndexMap = TOKEN_PII_INDEX_MAP;
|
|
10
26
|
}
|
|
11
27
|
mapDoc(snap) {
|
|
12
28
|
const raw = super.mapDoc(snap);
|
|
@@ -14,15 +30,6 @@ export class EmailVerificationTokenRepository extends BaseRepository {
|
|
|
14
30
|
...TOKEN_PII_FIELDS,
|
|
15
31
|
]);
|
|
16
32
|
}
|
|
17
|
-
async create(data) {
|
|
18
|
-
let encrypted = encryptPiiFields(data, [...TOKEN_PII_FIELDS]);
|
|
19
|
-
encrypted = addPiiIndices(data, TOKEN_PII_INDEX_MAP);
|
|
20
|
-
const merged = {
|
|
21
|
-
...encrypted,
|
|
22
|
-
...addPiiIndices(data, TOKEN_PII_INDEX_MAP),
|
|
23
|
-
};
|
|
24
|
-
return super.create(merged);
|
|
25
|
-
}
|
|
26
33
|
async findByToken(token) {
|
|
27
34
|
return this.findOneBy(TOKEN_FIELDS.TOKEN, token);
|
|
28
35
|
}
|
|
@@ -80,6 +87,15 @@ export class EmailVerificationTokenRepository extends BaseRepository {
|
|
|
80
87
|
export class PasswordResetTokenRepository extends BaseRepository {
|
|
81
88
|
constructor() {
|
|
82
89
|
super(PASSWORD_RESET_COLLECTION);
|
|
90
|
+
/**
|
|
91
|
+
* Same defect as its sibling above, one spread further along: the override
|
|
92
|
+
* this replaces built `{...encrypted, ...addPiiIndices(data, MAP)}`, and
|
|
93
|
+
* because `addPiiIndices` returns `{...plaintext, ...indices}` the plaintext
|
|
94
|
+
* `email` was spread back OVER the ciphertext. Reset-token emails were
|
|
95
|
+
* written in cleartext too.
|
|
96
|
+
*/
|
|
97
|
+
this.piiFields = TOKEN_PII_FIELDS;
|
|
98
|
+
this.piiIndexMap = TOKEN_PII_INDEX_MAP;
|
|
83
99
|
}
|
|
84
100
|
mapDoc(snap) {
|
|
85
101
|
const raw = super.mapDoc(snap);
|
|
@@ -87,14 +103,6 @@ export class PasswordResetTokenRepository extends BaseRepository {
|
|
|
87
103
|
...TOKEN_PII_FIELDS,
|
|
88
104
|
]);
|
|
89
105
|
}
|
|
90
|
-
async create(data) {
|
|
91
|
-
let encrypted = encryptPiiFields(data, [...TOKEN_PII_FIELDS]);
|
|
92
|
-
encrypted = {
|
|
93
|
-
...encrypted,
|
|
94
|
-
...addPiiIndices(data, TOKEN_PII_INDEX_MAP),
|
|
95
|
-
};
|
|
96
|
-
return super.create(encrypted);
|
|
97
|
-
}
|
|
98
106
|
async findByToken(token) {
|
|
99
107
|
return this.findOneBy(TOKEN_FIELDS.TOKEN, token);
|
|
100
108
|
}
|
|
@@ -5,7 +5,7 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
|
|
|
5
5
|
import { cacheManager } from "../../../core";
|
|
6
6
|
import { serverLogger } from "../../../monitoring";
|
|
7
7
|
import { generateUniqueId, slugify, generateBarcodeId } from "../../../utils";
|
|
8
|
-
import { buildSearchTxt, parseSearchTxtQuery } from "../../../utils/search-txt";
|
|
8
|
+
import { buildSearchTxt, matchesAllSearchTerms, parseSearchTxtQuery, } from "../../../utils/search-txt";
|
|
9
9
|
import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas";
|
|
10
10
|
import { PRODUCT_FIELDS } from "../../../constants/field-names";
|
|
11
11
|
/**
|
|
@@ -408,12 +408,36 @@ export class ProductRepository extends BaseRepository {
|
|
|
408
408
|
return baseQuery;
|
|
409
409
|
}
|
|
410
410
|
async list(model, opts) {
|
|
411
|
-
|
|
411
|
+
const result = await this.sieveQuery(model, ProductRepository.SIEVE_FIELDS, {
|
|
412
412
|
baseQuery: this.buildScopedQuery(opts),
|
|
413
413
|
defaultPageSize: 20,
|
|
414
414
|
maxPageSize: 100,
|
|
415
415
|
aliases: ProductRepository.FILTER_ALIASES,
|
|
416
416
|
});
|
|
417
|
+
// Only the most selective term became the `array-contains` clause, because
|
|
418
|
+
// Firestore permits one array operator per query. The REST of the terms
|
|
419
|
+
// were described as "AND-refined" in buildScopedQuery for a long time while
|
|
420
|
+
// nothing actually refined them — so "red dranzer" returned everything
|
|
421
|
+
// matching "dranzer" and the second word did nothing at all.
|
|
422
|
+
//
|
|
423
|
+
// Same shape as faqsRepository.list, deliberately: one implementation of
|
|
424
|
+
// "AND the remaining terms" that both collections read the same way.
|
|
425
|
+
const terms = parseSearchTxtQuery(opts?.search ?? "");
|
|
426
|
+
const extraTerms = terms.slice(1);
|
|
427
|
+
if (extraTerms.length === 0)
|
|
428
|
+
return result;
|
|
429
|
+
const items = result.items.filter((p) => matchesAllSearchTerms(p.searchTxt, extraTerms));
|
|
430
|
+
// `total` becomes a FLOOR once rows are dropped after the page was cut —
|
|
431
|
+
// it counts this page only. That is the known post-pagination-filter debt
|
|
432
|
+
// (Phase 5), not a new invention: reporting the pre-filter total here would
|
|
433
|
+
// promise pages that render empty.
|
|
434
|
+
return {
|
|
435
|
+
...result,
|
|
436
|
+
items,
|
|
437
|
+
total: items.length,
|
|
438
|
+
totalPages: items.length === 0 ? 0 : 1,
|
|
439
|
+
hasMore: false,
|
|
440
|
+
};
|
|
417
441
|
}
|
|
418
442
|
/**
|
|
419
443
|
* How many products of each listing type exist within a scope — the numbers
|
package/dist/index.d.ts
CHANGED
|
@@ -444,8 +444,6 @@ export { Can } from "./security/index";
|
|
|
444
444
|
export { DEFAULT_ROLES } from "./security/index";
|
|
445
445
|
export { EVENT_ENTRY_PII_FIELDS } from "./security/index";
|
|
446
446
|
export { LOTTERY_ENTRY_PII_FIELDS } from "./security/index";
|
|
447
|
-
export { NEWSLETTER_PII_FIELDS } from "./security/index";
|
|
448
|
-
export { NEWSLETTER_PII_INDEX_MAP } from "./security/index";
|
|
449
447
|
export { OFFER_PII_FIELDS } from "./security/index";
|
|
450
448
|
export { ORDER_PII_FIELDS } from "./security/index";
|
|
451
449
|
export { PAYOUT_PII_FIELDS } from "./security/index";
|
package/dist/index.js
CHANGED
|
@@ -787,12 +787,6 @@ export { EVENT_ENTRY_PII_FIELDS } from "./security/index";
|
|
|
787
787
|
// LOTTERY_ENTRY_PII_FIELDS - PII fields for lottery entries.
|
|
788
788
|
export { LOTTERY_ENTRY_PII_FIELDS } from "./security/index";
|
|
789
789
|
// [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
|
|
790
|
-
// NEWSLETTER_PII_FIELDS - Constant used across modules.
|
|
791
|
-
export { NEWSLETTER_PII_FIELDS } from "./security/index";
|
|
792
|
-
// [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
|
|
793
|
-
// NEWSLETTER_PII_INDEX_MAP - Constant used across modules.
|
|
794
|
-
export { NEWSLETTER_PII_INDEX_MAP } from "./security/index";
|
|
795
|
-
// [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
|
|
796
790
|
// OFFER_PII_FIELDS - Constant used across modules.
|
|
797
791
|
export { OFFER_PII_FIELDS } from "./security/index";
|
|
798
792
|
// [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
|
|
@@ -76,12 +76,24 @@ import { createFirebaseAdapter } from "@mohasinac/sievejs/adapters/firebase";
|
|
|
76
76
|
* (OR semantics). The stock adapter tries `whereOr` which Firestore Admin SDK
|
|
77
77
|
* doesn't support. Firestore's `.where(field, "in", [v1, v2])` is the correct
|
|
78
78
|
* equivalent and supports up to 30 values.
|
|
79
|
+
*
|
|
80
|
+
* It also REPORTS every case-insensitive condition it sees, because those are
|
|
81
|
+
* the ones the stock adapter throws on — see `buildFilteredSieveQuery`.
|
|
82
|
+
* Reading `operatorIsCaseInsensitive` off the parsed condition is deliberate:
|
|
83
|
+
* re-scanning the raw filter string for a trailing `*` cannot tell an operator
|
|
84
|
+
* suffix from a `*` inside a value (`title==foo*bar`).
|
|
79
85
|
*/
|
|
80
|
-
function createEnhancedFirebaseAdapter() {
|
|
86
|
+
function createEnhancedFirebaseAdapter(onCaseInsensitive) {
|
|
81
87
|
const base = createFirebaseAdapter();
|
|
82
88
|
return {
|
|
83
89
|
...base,
|
|
84
90
|
applyFilterGroup(query, group) {
|
|
91
|
+
if (onCaseInsensitive) {
|
|
92
|
+
for (const c of group) {
|
|
93
|
+
if (c.operatorIsCaseInsensitive)
|
|
94
|
+
onCaseInsensitive(c);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
85
97
|
if (group.length > 1 &&
|
|
86
98
|
group.every((c) => c.parsedOperator === "equals" &&
|
|
87
99
|
!c.operatorIsNegated &&
|
|
@@ -94,6 +106,9 @@ function createEnhancedFirebaseAdapter() {
|
|
|
94
106
|
};
|
|
95
107
|
}
|
|
96
108
|
import { FirebaseRepository } from "./base";
|
|
109
|
+
// Defining module, not the barrel — `errors/index` re-exports a lot, and this
|
|
110
|
+
// file is imported by every repository (Root Cause #18).
|
|
111
|
+
import { ValidationError } from "../../errors/validation-error";
|
|
97
112
|
import { deserializeTimestamps, getFirestoreCount } from "./helpers";
|
|
98
113
|
import { expandFilterAliases as _expandFilterAliases, } from "./filter-aliases";
|
|
99
114
|
// Re-export the pure alias helper + types so consumers that already imported
|
|
@@ -138,6 +153,22 @@ function withAliasesExpanded(model, aliases) {
|
|
|
138
153
|
*/
|
|
139
154
|
function buildFilteredSieveQuery(baseQuery, model, fields, merged, aliases) {
|
|
140
155
|
const effective = withAliasesExpanded(model, aliases);
|
|
156
|
+
// Case-insensitive operators (`@=*`, `_=*`, `==*`, `!=*`, `_-=*`) are the one
|
|
157
|
+
// shape the Firebase adapter throws on, and `throwExceptions: false` turns
|
|
158
|
+
// that throw into SILENCE — measured, not assumed:
|
|
159
|
+
//
|
|
160
|
+
// filters="status==published,title==foo" → where, where, orderBy ✅
|
|
161
|
+
// filters="title@=*dranzer,status==published" → NOTHING APPLIED ✗
|
|
162
|
+
// filters="status==published,title@=*dranzer" → where(status) only, no sort
|
|
163
|
+
//
|
|
164
|
+
// The processor catches the adapter's throw and returns the query as of that
|
|
165
|
+
// point, so the remaining clauses AND the entire sort phase are discarded.
|
|
166
|
+
// With the CI clause first that is the bare collection — every facet the user
|
|
167
|
+
// picked, gone, and a 200 carrying the whole table as "search results".
|
|
168
|
+
// `ignoreUnsupported: true` does NOT help; it drops the clause just as
|
|
169
|
+
// quietly. So the only honest options are "throw" or "keep lying", and a
|
|
170
|
+
// caller asking for an operator this backend cannot express is a 400.
|
|
171
|
+
const caseInsensitive = [];
|
|
141
172
|
const processor = new SieveProcessorBase({
|
|
142
173
|
// MUST be the enhanced adapter, not the stock one. This is the path
|
|
143
174
|
// `BaseRepository.sieveQuery` delegates to, so it is what ProductRepository
|
|
@@ -148,14 +179,25 @@ function buildFilteredSieveQuery(baseQuery, model, fields, merged, aliases) {
|
|
|
148
179
|
// not implement; `throwExceptions: false` then swallowed it and the clause
|
|
149
180
|
// vanished, returning the ENTIRE collection instead of the selected subset.
|
|
150
181
|
// See __tests__/sieve-or-group.test.ts.
|
|
151
|
-
adapter: createEnhancedFirebaseAdapter(),
|
|
182
|
+
adapter: createEnhancedFirebaseAdapter((c) => caseInsensitive.push(c)),
|
|
152
183
|
autoLoadConfig: false,
|
|
153
184
|
options: merged,
|
|
154
185
|
fields,
|
|
155
186
|
});
|
|
156
|
-
|
|
187
|
+
const query = processor.apply(effective, baseQuery, {
|
|
157
188
|
applyPagination: false,
|
|
158
189
|
});
|
|
190
|
+
// Thrown AFTER apply so the message can name every offending clause, not just
|
|
191
|
+
// the first one the adapter happened to reach.
|
|
192
|
+
if (caseInsensitive.length > 0) {
|
|
193
|
+
const clauses = caseInsensitive
|
|
194
|
+
.map((c) => `${c.field} (${c.parsedOperator})`)
|
|
195
|
+
.join(", ");
|
|
196
|
+
throw new ValidationError(`Case-insensitive filter operators are not supported by Firestore. ` +
|
|
197
|
+
`Offending clause(s): ${clauses}. Use searchTxt token search instead ` +
|
|
198
|
+
`— see appkit/src/utils/search-txt.ts.`, { fields: caseInsensitive.map((c) => c.field) });
|
|
199
|
+
}
|
|
200
|
+
return query;
|
|
159
201
|
}
|
|
160
202
|
/**
|
|
161
203
|
* Count matching documents WITHOUT reading any of them.
|
|
@@ -2,6 +2,6 @@ export { generateNonce, buildCSP } from "./csp";
|
|
|
2
2
|
export { requireAuth, requireRole, requireOwnership, requireEmailVerified, requireActiveAccount, canChangeRole, getRoleLevel, } from "./authorization";
|
|
3
3
|
export type { UserRole } from "./authorization";
|
|
4
4
|
export { redactPii } from "./pii-redact";
|
|
5
|
-
export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS,
|
|
5
|
+
export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS, TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, OFFER_PII_FIELDS, CHAT_PII_FIELDS, EVENT_ENTRY_PII_FIELDS, } from "./pii-schemas";
|
|
6
6
|
export type { Permission, RoleDefinition, RbacConfig, ResolvedUser, RbacHookReturn, } from "./rbac";
|
|
7
7
|
export { DEFAULT_ROLES, resolvePermissions, hasPermission, hasAllPermissions, hasAnyPermission, createRbacHook, Can, createRbacMiddleware, } from "./rbac";
|
package/dist/security/browser.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { generateNonce, buildCSP } from "./csp";
|
|
2
2
|
export { requireAuth, requireRole, requireOwnership, requireEmailVerified, requireActiveAccount, canChangeRole, getRoleLevel, } from "./authorization";
|
|
3
3
|
export { redactPii } from "./pii-redact";
|
|
4
|
-
export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS,
|
|
4
|
+
export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS, TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, OFFER_PII_FIELDS, CHAT_PII_FIELDS, EVENT_ENTRY_PII_FIELDS, } from "./pii-schemas";
|
|
5
5
|
export { DEFAULT_ROLES, resolvePermissions, hasPermission, hasAllPermissions, hasAnyPermission, createRbacHook, Can, createRbacMiddleware, } from "./rbac";
|
package/dist/security/index.d.ts
CHANGED
|
@@ -4,9 +4,9 @@ export type { RateLimitConfig, RateLimitResult } from "./rate-limit";
|
|
|
4
4
|
export { requireAuth, requireRole, requireOwnership, requireEmailVerified, requireActiveAccount, canChangeRole, getRoleLevel, } from "./authorization";
|
|
5
5
|
export type { UserRole } from "./authorization";
|
|
6
6
|
export { redactPii, safeDisplayName, safeDisplayEmail, maskIp } from "./pii-redact";
|
|
7
|
-
export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPiiFields, encryptPii, decryptPii, piiBlindIndex,
|
|
7
|
+
export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPiiFields, encryptPii, decryptPii, piiBlindIndex, piiIndicesFor, getPiiConfigError, encryptShippingAddress, decryptShippingAddress, encryptPayoutDetails, decryptPayoutDetails, encryptShippingConfig, decryptShippingConfig, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "./pii-encrypt";
|
|
8
8
|
export { isPiiEncrypted, maskName, maskEmail, maskPublicReview, maskPublicBid, maskPublicEventEntry, maskOfferForSeller, ENC_PREFIX, HMAC_PREFIX, } from "./pii-mask";
|
|
9
|
-
export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS,
|
|
9
|
+
export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS, TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, OFFER_PII_FIELDS, CHAT_PII_FIELDS, EVENT_ENTRY_PII_FIELDS, LOTTERY_ENTRY_PII_FIELDS, PAYMENT_METHOD_PII_FIELDS, } from "./pii-schemas";
|
|
10
10
|
export { encryptSecret, decryptSecret, isSecretEncrypted, maskSecret, } from "./settings-encryption";
|
|
11
11
|
export type { Permission, RoleDefinition, RbacConfig, ResolvedUser, RbacHookReturn, } from "./rbac";
|
|
12
12
|
export { DEFAULT_ROLES, resolvePermissions, hasPermission, hasAllPermissions, hasAnyPermission, createRbacHook, Can, createRequirePermission, createRequirePermissionSync, createRbacMiddleware, } from "./rbac";
|
package/dist/security/index.js
CHANGED
|
@@ -2,11 +2,11 @@ export { generateNonce, buildCSP } from "./csp";
|
|
|
2
2
|
export { rateLimit, applyRateLimit, rateLimitByIdentifier, RateLimitPresets, clearRateLimitStore, getClientIP, hashGuestIdentity, } from "./rate-limit";
|
|
3
3
|
export { requireAuth, requireRole, requireOwnership, requireEmailVerified, requireActiveAccount, canChangeRole, getRoleLevel, } from "./authorization";
|
|
4
4
|
export { redactPii, safeDisplayName, safeDisplayEmail, maskIp } from "./pii-redact";
|
|
5
|
-
export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPiiFields, encryptPii, decryptPii, piiBlindIndex,
|
|
5
|
+
export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPiiFields, encryptPii, decryptPii, piiBlindIndex, piiIndicesFor, getPiiConfigError, encryptShippingAddress, decryptShippingAddress, encryptPayoutDetails, decryptPayoutDetails, encryptShippingConfig, decryptShippingConfig, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "./pii-encrypt";
|
|
6
6
|
// Crypto-free display-masking helpers -- sourced from pii-mask.ts (not
|
|
7
7
|
// pii-encrypt.ts) so this barrel stays safe to re-export from the
|
|
8
8
|
// universal client+server index.ts. See pii-mask.ts's header comment.
|
|
9
9
|
export { isPiiEncrypted, maskName, maskEmail, maskPublicReview, maskPublicBid, maskPublicEventEntry, maskOfferForSeller, ENC_PREFIX, HMAC_PREFIX, } from "./pii-mask";
|
|
10
|
-
export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS,
|
|
10
|
+
export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS, TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, OFFER_PII_FIELDS, CHAT_PII_FIELDS, EVENT_ENTRY_PII_FIELDS, LOTTERY_ENTRY_PII_FIELDS, PAYMENT_METHOD_PII_FIELDS, } from "./pii-schemas";
|
|
11
11
|
export { encryptSecret, decryptSecret, isSecretEncrypted, maskSecret, } from "./settings-encryption";
|
|
12
12
|
export { DEFAULT_ROLES, resolvePermissions, hasPermission, hasAllPermissions, hasAnyPermission, createRbacHook, Can, createRequirePermission, createRequirePermissionSync, createRbacMiddleware, } from "./rbac";
|
|
@@ -38,16 +38,22 @@ export declare function piiBlindIndex(plaintext: string): string;
|
|
|
38
38
|
* Blind-index fields ONLY — derived from `source` (which must be plaintext),
|
|
39
39
|
* with none of `source`'s own keys carried along.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
41
|
+
* This is the ONLY index helper. Its predecessor, `addPiiIndices`, returned
|
|
42
|
+
* `{...source, ...indices}`, so spreading its result over ciphertext silently
|
|
43
|
+
* restored the plaintext — which is how `payouts.sellerEmail`, `payouts.upiId`
|
|
44
|
+
* and `reviews.userName` ended up stored in cleartext beside a valid index, and
|
|
45
|
+
* how both token repositories wrote every verification and password-reset email
|
|
46
|
+
* in cleartext until it was deleted. Returning indices only makes that class of
|
|
47
|
+
* bug unrepresentable rather than merely documented.
|
|
48
|
+
*
|
|
49
|
+
* Merge order is still the caller's responsibility:
|
|
50
|
+
* `{ ...encryptPiiFields(data, FIELDS), ...piiIndicesFor(data, MAP) }`
|
|
51
|
+
* — both arguments read the PLAINTEXT `data`; hashing ciphertext yields an
|
|
52
|
+
* index nothing can look up.
|
|
46
53
|
*
|
|
47
54
|
* @param source plaintext document — hashing ciphertext produces a useless index
|
|
48
55
|
*/
|
|
49
56
|
export declare function piiIndicesFor(source: object, mapping: Record<string, string>): Record<string, string>;
|
|
50
|
-
export declare function addPiiIndices<T extends object>(obj: T, mapping: Record<string, string>): T;
|
|
51
57
|
/**
|
|
52
58
|
* Encrypt PII inside a shipping address stored in an order.
|
|
53
59
|
* Supports string and object address shapes.
|
|
@@ -325,11 +325,18 @@ export function piiBlindIndex(plaintext) {
|
|
|
325
325
|
* Blind-index fields ONLY — derived from `source` (which must be plaintext),
|
|
326
326
|
* with none of `source`'s own keys carried along.
|
|
327
327
|
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
328
|
+
* This is the ONLY index helper. Its predecessor, `addPiiIndices`, returned
|
|
329
|
+
* `{...source, ...indices}`, so spreading its result over ciphertext silently
|
|
330
|
+
* restored the plaintext — which is how `payouts.sellerEmail`, `payouts.upiId`
|
|
331
|
+
* and `reviews.userName` ended up stored in cleartext beside a valid index, and
|
|
332
|
+
* how both token repositories wrote every verification and password-reset email
|
|
333
|
+
* in cleartext until it was deleted. Returning indices only makes that class of
|
|
334
|
+
* bug unrepresentable rather than merely documented.
|
|
335
|
+
*
|
|
336
|
+
* Merge order is still the caller's responsibility:
|
|
337
|
+
* `{ ...encryptPiiFields(data, FIELDS), ...piiIndicesFor(data, MAP) }`
|
|
338
|
+
* — both arguments read the PLAINTEXT `data`; hashing ciphertext yields an
|
|
339
|
+
* index nothing can look up.
|
|
333
340
|
*
|
|
334
341
|
* @param source plaintext document — hashing ciphertext produces a useless index
|
|
335
342
|
*/
|
|
@@ -344,17 +351,6 @@ export function piiIndicesFor(source, mapping) {
|
|
|
344
351
|
}
|
|
345
352
|
return indices;
|
|
346
353
|
}
|
|
347
|
-
export function addPiiIndices(obj, mapping) {
|
|
348
|
-
const result = { ...obj };
|
|
349
|
-
const access = result;
|
|
350
|
-
for (const [sourceField, indexField] of Object.entries(mapping)) {
|
|
351
|
-
const val = access[sourceField];
|
|
352
|
-
if (typeof val === "string" && val) {
|
|
353
|
-
access[indexField] = piiBlindIndex(val);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
return result;
|
|
357
|
-
}
|
|
358
354
|
// --- Nested-object PII helpers ----------------------------------------------
|
|
359
355
|
/**
|
|
360
356
|
* Encrypt PII inside a shipping address stored in an order.
|
|
@@ -26,10 +26,6 @@ export declare const PAYOUT_PII_INDEX_MAP: Record<string, string>;
|
|
|
26
26
|
* Bidder names are already masked on the public side by `maskPublicBid`.
|
|
27
27
|
*/
|
|
28
28
|
export declare const BID_PII_FIELDS: readonly ["userEmail"];
|
|
29
|
-
/** PII fields in newsletter subscribers */
|
|
30
|
-
export declare const NEWSLETTER_PII_FIELDS: readonly [];
|
|
31
|
-
/** Blind-index mapping for newsletter subscribers */
|
|
32
|
-
export declare const NEWSLETTER_PII_INDEX_MAP: Record<string, string>;
|
|
33
29
|
/**
|
|
34
30
|
* PII fields in tokens (email verification / password reset).
|
|
35
31
|
*
|
|
@@ -40,10 +40,19 @@ export const PAYOUT_PII_INDEX_MAP = {
|
|
|
40
40
|
* Bidder names are already masked on the public side by `maskPublicBid`.
|
|
41
41
|
*/
|
|
42
42
|
export const BID_PII_FIELDS = ["userEmail"];
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
/*
|
|
44
|
+
* NEWSLETTER_PII_FIELDS / NEWSLETTER_PII_INDEX_MAP were DELETED here.
|
|
45
|
+
*
|
|
46
|
+
* They were an empty `[]` and `{}` with no reader anywhere — only three barrel
|
|
47
|
+
* re-exports. `NewsletterRepository` does not extend `BaseRepository`; it holds
|
|
48
|
+
* its OWN module-local `NEWSLETTER_PII_FIELDS = ["email"]` and encrypts through
|
|
49
|
+
* that, so newsletter emails ARE encrypted at rest.
|
|
50
|
+
*
|
|
51
|
+
* The empty pair therefore did nothing except read, convincingly, as proof that
|
|
52
|
+
* newsletter PII was unencrypted — a shadow constant of exactly the shape in
|
|
53
|
+
* Root Cause #53, where the symbol a grep finds is not the one doing the work.
|
|
54
|
+
* Deleting it is the fix; populating it would have been a no-op.
|
|
55
|
+
*/
|
|
47
56
|
/**
|
|
48
57
|
* PII fields in tokens (email verification / password reset).
|
|
49
58
|
*
|
package/dist/server.d.ts
CHANGED
|
@@ -462,7 +462,7 @@ export { encryptPii } from "./security/index";
|
|
|
462
462
|
export { decryptPii } from "./security/index";
|
|
463
463
|
export { isPiiEncrypted } from "./security/index";
|
|
464
464
|
export { piiBlindIndex } from "./security/index";
|
|
465
|
-
export {
|
|
465
|
+
export { piiIndicesFor } from "./security/index";
|
|
466
466
|
export { getPiiConfigError } from "./security/index";
|
|
467
467
|
export { encryptShippingAddress } from "./security/index";
|
|
468
468
|
export { decryptShippingAddress } from "./security/index";
|
package/dist/server.js
CHANGED
|
@@ -1294,7 +1294,7 @@ export { encryptPii } from "./security/index";
|
|
|
1294
1294
|
export { decryptPii } from "./security/index";
|
|
1295
1295
|
export { isPiiEncrypted } from "./security/index";
|
|
1296
1296
|
export { piiBlindIndex } from "./security/index";
|
|
1297
|
-
export {
|
|
1297
|
+
export { piiIndicesFor } from "./security/index";
|
|
1298
1298
|
export { getPiiConfigError } from "./security/index";
|
|
1299
1299
|
export { encryptShippingAddress } from "./security/index";
|
|
1300
1300
|
export { decryptShippingAddress } from "./security/index";
|
package/package.json
CHANGED
package/scripts/seed-cli.mjs
CHANGED
|
@@ -174,7 +174,7 @@ const appkit = await import("@mohasinac/appkit");
|
|
|
174
174
|
// client-safe barrel, only from "@mohasinac/appkit/server". The *_PII_FIELDS
|
|
175
175
|
// / *_PII_INDEX_MAP constants are plain data and stay in the main barrel.
|
|
176
176
|
const appkitServer = await import("@mohasinac/appkit/server");
|
|
177
|
-
const { encryptPiiFields,
|
|
177
|
+
const { encryptPiiFields, piiIndicesFor, encryptPayoutDetails, encryptPayoutBankAccount, encryptShippingConfig } = appkitServer;
|
|
178
178
|
|
|
179
179
|
const {
|
|
180
180
|
// seed data
|
|
@@ -488,9 +488,16 @@ async function loadUsers(seed, stats) {
|
|
|
488
488
|
await auth.setCustomUserClaims(uid, { role: u.role });
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
491
|
+
// Both arguments read the PLAINTEXT `plain`: indices must be hashed from
|
|
492
|
+
// the source text, and encryption must not see an already-hashed value.
|
|
493
|
+
// The previous form relied on ordering alone (indices first, THEN
|
|
494
|
+
// encrypt) — correct, but one line-swap away from spreading plaintext
|
|
495
|
+
// back over ciphertext, which is why `addPiiIndices` no longer exists.
|
|
496
|
+
const plain = stripUndefined({ ...u });
|
|
497
|
+
let docData = {
|
|
498
|
+
...encryptPiiFields(plain, [...USER_PII_FIELDS]),
|
|
499
|
+
...piiIndicesFor(plain, USER_PII_INDEX_MAP),
|
|
500
|
+
};
|
|
494
501
|
if (docData.payoutDetails) docData.payoutDetails = encryptPayoutDetails(docData.payoutDetails);
|
|
495
502
|
if (docData.shippingConfig) docData.shippingConfig = encryptShippingConfig(docData.shippingConfig);
|
|
496
503
|
await docRef.set(docData, { merge: true });
|