@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.
@@ -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
- if (input.q)
279
- parts.push(sieveFilter(PRODUCT_FIELDS.TITLE, SIEVE_OP.CONTAINS_CI, input.q));
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, addPiiIndices, decryptPiiFields, encryptPiiFields, piiBlindIndex, } from "../../../security";
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
- return this.sieveQuery(model, ProductRepository.SIEVE_FIELDS, {
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
- return processor.apply(effective, baseQuery, {
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, NEWSLETTER_PII_FIELDS, NEWSLETTER_PII_INDEX_MAP, 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
+ 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";
@@ -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, NEWSLETTER_PII_FIELDS, NEWSLETTER_PII_INDEX_MAP, 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";
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";
@@ -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, addPiiIndices, piiIndicesFor, getPiiConfigError, encryptShippingAddress, decryptShippingAddress, encryptPayoutDetails, decryptPayoutDetails, encryptShippingConfig, decryptShippingConfig, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "./pii-encrypt";
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, NEWSLETTER_PII_FIELDS, NEWSLETTER_PII_INDEX_MAP, 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";
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";
@@ -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, addPiiIndices, piiIndicesFor, getPiiConfigError, encryptShippingAddress, decryptShippingAddress, encryptPayoutDetails, decryptPayoutDetails, encryptShippingConfig, decryptShippingConfig, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "./pii-encrypt";
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, NEWSLETTER_PII_FIELDS, NEWSLETTER_PII_INDEX_MAP, 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
+ 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
- * Use this, never `addPiiIndices`, when merging indices onto an already-encrypted
42
- * object. `addPiiIndices` returns `{...source, ...indices}`, so spreading its
43
- * result over ciphertext silently restores the plaintext — which is exactly how
44
- * `payouts.sellerEmail`, `payouts.upiId` and `reviews.userName` ended up stored
45
- * in cleartext beside a valid index.
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
- * Use this, never `addPiiIndices`, when merging indices onto an already-encrypted
329
- * object. `addPiiIndices` returns `{...source, ...indices}`, so spreading its
330
- * result over ciphertext silently restores the plaintext — which is exactly how
331
- * `payouts.sellerEmail`, `payouts.upiId` and `reviews.userName` ended up stored
332
- * in cleartext beside a valid index.
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
- /** PII fields in newsletter subscribers */
44
- export const NEWSLETTER_PII_FIELDS = [];
45
- /** Blind-index mapping for newsletter subscribers */
46
- export const NEWSLETTER_PII_INDEX_MAP = {};
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 { addPiiIndices } from "./security/index";
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 { addPiiIndices } from "./security/index";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.30.0",
3
+ "version": "4.30.2",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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, addPiiIndices, encryptPayoutDetails, encryptPayoutBankAccount, encryptShippingConfig } = appkitServer;
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
- let docData = stripUndefined({ ...u });
492
- docData = addPiiIndices(docData, USER_PII_INDEX_MAP);
493
- docData = encryptPiiFields(docData, [...USER_PII_FIELDS]);
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 });