@open-mercato/onboarding 0.6.7-develop.6860.1.f796b22e80 → 0.6.7

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.
Files changed (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +5 -8
  3. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +2 -25
  4. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +2 -2
  5. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +2 -3
  6. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +2 -2
  7. package/dist/modules/onboarding/api/post/onboarding.js +1 -9
  8. package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
  9. package/dist/modules/onboarding/data/entities.js +0 -4
  10. package/dist/modules/onboarding/data/entities.js.map +2 -2
  11. package/dist/modules/onboarding/encryption.js +2 -4
  12. package/dist/modules/onboarding/encryption.js.map +2 -2
  13. package/dist/modules/onboarding/lib/service.js +6 -18
  14. package/dist/modules/onboarding/lib/service.js.map +2 -2
  15. package/generated/entities/onboarding_request/index.ts +0 -1
  16. package/generated/entity-fields-registry.ts +0 -1
  17. package/package.json +5 -6
  18. package/src/__tests__/service.test.ts +0 -16
  19. package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +2 -37
  20. package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +2 -3
  21. package/src/modules/onboarding/api/post/onboarding.ts +1 -9
  22. package/src/modules/onboarding/data/entities.ts +0 -4
  23. package/src/modules/onboarding/encryption.ts +1 -3
  24. package/src/modules/onboarding/lib/service.ts +5 -17
  25. package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +0 -19
  26. package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js +0 -15
  27. package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js.map +0 -7
  28. package/src/__tests__/backfill-legacy-request.test.ts +0 -213
  29. package/src/__tests__/encryption.test.ts +0 -19
  30. package/src/modules/onboarding/i18n/ko.json +0 -100
  31. package/src/modules/onboarding/migrations/Migration20260710133207_onboarding.ts +0 -15
@@ -4,7 +4,6 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
4
4
  "onboarding_request": {
5
5
  "id": "id",
6
6
  "email": "email",
7
- "email_hash": "email_hash",
8
7
  "token_hash": "token_hash",
9
8
  "status": "status",
10
9
  "first_name": "first_name",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/onboarding",
3
- "version": "0.6.7-develop.6860.1.f796b22e80",
3
+ "version": "0.6.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -70,13 +70,13 @@
70
70
  }
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80"
73
+ "@open-mercato/shared": "0.6.7"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80",
76
+ "@open-mercato/shared": "0.6.7",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
- "ts-jest": "^29.4.12",
79
+ "ts-jest": "^29.4.11",
80
80
  "typescript": "7.0.2"
81
81
  },
82
82
  "publishConfig": {
@@ -86,6 +86,5 @@
86
86
  "type": "git",
87
87
  "url": "https://github.com/open-mercato/open-mercato",
88
88
  "directory": "packages/onboarding"
89
- },
90
- "stableVersion": "0.6.6"
89
+ }
91
90
  }
@@ -2,7 +2,6 @@ import { createHash } from 'node:crypto'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { OnboardingRequest } from '../modules/onboarding/data/entities'
4
4
  import { OnboardingService } from '../modules/onboarding/lib/service'
5
- import { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
6
5
 
7
6
  jest.mock('bcryptjs', () => ({
8
7
  hash: jest.fn().mockResolvedValue('hashed_password'),
@@ -128,21 +127,6 @@ describe('OnboardingService', () => {
128
127
  const result = await service.createOrUpdateRequest(makeStartInput())
129
128
  const createArgs = em.create.mock.calls[0][1]
130
129
  expect(createArgs.status).toBe('pending')
131
- expect(createArgs.emailHash).toBe(hashForLookup('user@example.com'))
132
- })
133
-
134
- it('looks up encrypted and legacy plaintext emails without querying ciphertext as plaintext', async () => {
135
- const em = createMockEm()
136
- const service = new OnboardingService(em)
137
-
138
- await service.createOrUpdateRequest(makeStartInput())
139
-
140
- expect(em.findOne).toHaveBeenCalledWith(OnboardingRequest, {
141
- $or: [
142
- { emailHash: { $in: lookupHashCandidates('user@example.com') } },
143
- { email: 'user@example.com', emailHash: null },
144
- ],
145
- }, undefined)
146
130
  })
147
131
 
148
132
  it('throws PENDING_REQUEST when within cooldown', async () => {
@@ -1,7 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { expect, test } from '@playwright/test';
3
3
  import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
4
- import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';
5
4
 
6
5
  export const integrationMeta = {
7
6
  dependsOnModules: ['onboarding'],
@@ -27,15 +26,6 @@ type UserConsentRow = {
27
26
  granted_at: Date | null;
28
27
  };
29
28
 
30
- type PendingRequestAtRest = {
31
- email: string;
32
- email_hash: string | null;
33
- first_name: string;
34
- last_name: string;
35
- organization_name: string;
36
- password_hash: string;
37
- };
38
-
39
29
  const ONBOARDING_PASSWORD = 'IntegrationPass123!';
40
30
  const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
41
31
 
@@ -50,32 +40,15 @@ async function replaceVerificationToken(email: string, token: string): Promise<s
50
40
  set token_hash = $2,
51
41
  expires_at = now() + interval '24 hours',
52
42
  updated_at = now()
53
- where email_hash = any($1::text[])
43
+ where email = $1
54
44
  returning id`,
55
- [lookupHashCandidates(email), hashToken(token)],
45
+ [email, hashToken(token)],
56
46
  );
57
47
  expect(result.rowCount, 'onboarding request should exist after submitting the form').toBe(1);
58
48
  return result.rows[0].id;
59
49
  });
60
50
  }
61
51
 
62
- async function readPendingRequestAtRest(email: string): Promise<PendingRequestAtRest> {
63
- return withClient(async (client) => {
64
- const result = await client.query<PendingRequestAtRest>(
65
- `select email, email_hash, first_name, last_name, organization_name, password_hash
66
- from onboarding_requests
67
- where email_hash = any($1::text[])`,
68
- [lookupHashCandidates(email)],
69
- );
70
- expect(result.rowCount, 'onboarding request should be queryable by its lookup hash').toBe(1);
71
- return result.rows[0];
72
- });
73
- }
74
-
75
- function expectEncryptedPayload(value: string): void {
76
- expect(value).toMatch(/^[^:]+:[^:]+:[^:]+:v1$/);
77
- }
78
-
79
52
  async function readCompletedRequest(requestId: string): Promise<OnboardingRequestRow> {
80
53
  return withClient(async (client) => {
81
54
  const result = await client.query<OnboardingRequestRow>(
@@ -169,14 +142,6 @@ test.describe('TC-ONB-001: self-service onboarding with marketing consent', () =
169
142
  await expect(page.getByRole('status')).toContainText('Check your inbox');
170
143
  await expect(page.getByRole('status')).toContainText(email);
171
144
 
172
- const pendingAtRest = await readPendingRequestAtRest(email);
173
- expect(lookupHashCandidates(email)).toContain(pendingAtRest.email_hash);
174
- expectEncryptedPayload(pendingAtRest.email);
175
- expectEncryptedPayload(pendingAtRest.first_name);
176
- expectEncryptedPayload(pendingAtRest.last_name);
177
- expectEncryptedPayload(pendingAtRest.organization_name);
178
- expectEncryptedPayload(pendingAtRest.password_hash);
179
-
180
145
  const requestId = await replaceVerificationToken(email, token);
181
146
  const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;
182
147
 
@@ -4,7 +4,6 @@
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';
6
6
  import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
7
- import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';
8
7
 
9
8
  export const integrationMeta = {
10
9
  dependsOnModules: ['onboarding'],
@@ -40,9 +39,9 @@ async function replaceVerificationToken(email: string, token: string): Promise<s
40
39
  set token_hash = $2,
41
40
  expires_at = now() + interval '24 hours',
42
41
  updated_at = now()
43
- where email_hash = any($1::text[])
42
+ where email = $1
44
43
  returning id`,
45
- [lookupHashCandidates(email), hashToken(token)],
44
+ [email, hashToken(token)],
46
45
  );
47
46
  expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);
48
47
  return result.rows[0].id;
@@ -3,8 +3,6 @@ import { z } from 'zod'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { createLogger } from '@open-mercato/shared/lib/logger'
6
- import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
7
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
6
  import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
9
7
  import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
10
8
  import { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
@@ -109,13 +107,7 @@ export async function POST(req: Request) {
109
107
  const container = await createRequestContainer()
110
108
  const em = (container.resolve('em') as EntityManager)
111
109
 
112
- const existingUser = await findOneWithDecryption(em, User, {
113
- deletedAt: null,
114
- $or: [
115
- { email: parsed.data.email },
116
- { emailHash: { $in: lookupHashCandidates(parsed.data.email) } },
117
- ],
118
- })
110
+ const existingUser = await em.findOne(User, { email: parsed.data.email })
119
111
  if (existingUser) {
120
112
  const message = translate('onboarding.errors.emailExists', 'We already have an account with this email. Try signing in or resetting your password.')
121
113
  return NextResponse.json({
@@ -4,7 +4,6 @@ type OnboardingStatus = 'pending' | 'processing' | 'completed' | 'expired'
4
4
 
5
5
  @Entity({ tableName: 'onboarding_requests' })
6
6
  @Unique({ properties: ['email'] })
7
- @Unique({ properties: ['emailHash'] })
8
7
  @Unique({ properties: ['tokenHash'] })
9
8
  export class OnboardingRequest {
10
9
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
@@ -13,9 +12,6 @@ export class OnboardingRequest {
13
12
  @Property({ type: 'text' })
14
13
  email!: string
15
14
 
16
- @Property({ name: 'email_hash', type: 'text', nullable: true })
17
- emailHash?: string | null
18
-
19
15
  @Property({ name: 'token_hash', type: 'text' })
20
16
  tokenHash!: string
21
17
 
@@ -3,13 +3,11 @@ import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryptio
3
3
  export const defaultEncryptionMaps: ModuleEncryptionMap[] = [
4
4
  {
5
5
  entityId: 'onboarding:onboarding_request',
6
- keyScope: 'system',
7
6
  fields: [
8
- { field: 'email', hashField: 'email_hash' },
7
+ { field: 'email' },
9
8
  { field: 'first_name' },
10
9
  { field: 'last_name' },
11
10
  { field: 'organization_name' },
12
- { field: 'password_hash' },
13
11
  ],
14
12
  },
15
13
  ]
@@ -1,8 +1,6 @@
1
1
  import { randomBytes, createHash } from 'node:crypto'
2
2
  import { hash } from 'bcryptjs'
3
3
  import { EntityManager } from '@mikro-orm/postgresql'
4
- import { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
5
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
4
  import { OnboardingRequest } from '../data/entities'
7
5
  import type { OnboardingStartInput } from '../data/validators'
8
6
 
@@ -20,14 +18,8 @@ export class OnboardingService {
20
18
  const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)
21
19
  const now = new Date()
22
20
  const passwordHash = await hash(input.password, 10)
23
- const emailHash = hashForLookup(input.email)
24
21
 
25
- const existing = await findOneWithDecryption(this.em, OnboardingRequest, {
26
- $or: [
27
- { emailHash: { $in: lookupHashCandidates(input.email) } },
28
- { email: input.email, emailHash: null },
29
- ],
30
- })
22
+ const existing = await this.em.findOne(OnboardingRequest, { email: input.email })
31
23
  if (existing) {
32
24
  const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt
33
25
  if (['pending', 'processing'].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1000) {
@@ -44,7 +36,6 @@ export class OnboardingService {
44
36
  existing.termsAccepted = true
45
37
  existing.marketingConsent = input.marketingConsent ?? false
46
38
  existing.passwordHash = passwordHash
47
- existing.emailHash = emailHash
48
39
  existing.expiresAt = expiresAt
49
40
  existing.completedAt = null
50
41
  existing.processingStartedAt = null
@@ -61,7 +52,6 @@ export class OnboardingService {
61
52
 
62
53
  const request = this.em.create(OnboardingRequest, {
63
54
  email: input.email,
64
- emailHash,
65
55
  tokenHash,
66
56
  status: 'pending',
67
57
  firstName: input.firstName,
@@ -84,7 +74,7 @@ export class OnboardingService {
84
74
  async findPendingByToken(token: string) {
85
75
  const tokenHash = hashToken(token)
86
76
  const now = new Date()
87
- return findOneWithDecryption(this.em, OnboardingRequest, {
77
+ return this.em.findOne(OnboardingRequest, {
88
78
  tokenHash,
89
79
  status: 'pending',
90
80
  expiresAt: { $gt: now } as any,
@@ -93,20 +83,18 @@ export class OnboardingService {
93
83
 
94
84
  async findByToken(token: string) {
95
85
  const tokenHash = hashToken(token)
96
- return findOneWithDecryption(this.em, OnboardingRequest, { tokenHash })
86
+ return this.em.findOne(OnboardingRequest, { tokenHash })
97
87
  }
98
88
 
99
89
  async findById(id: string) {
100
- return findOneWithDecryption(this.em, OnboardingRequest, { id })
90
+ return this.em.findOne(OnboardingRequest, { id })
101
91
  }
102
92
 
103
93
  async findLatestByTenantId(tenantId: string) {
104
- return findOneWithDecryption(
105
- this.em,
94
+ return this.em.findOne(
106
95
  OnboardingRequest,
107
96
  { tenantId, deletedAt: null },
108
97
  { orderBy: { updatedAt: 'DESC', createdAt: 'DESC' } },
109
- { tenantId, organizationId: null },
110
98
  )
111
99
  }
112
100
 
@@ -25,15 +25,6 @@
25
25
  "nullable": false,
26
26
  "mappedType": "text"
27
27
  },
28
- "email_hash": {
29
- "name": "email_hash",
30
- "type": "text",
31
- "unsigned": false,
32
- "autoincrement": false,
33
- "primary": false,
34
- "nullable": true,
35
- "mappedType": "text"
36
- },
37
28
  "token_hash": {
38
29
  "name": "token_hash",
39
30
  "type": "text",
@@ -269,16 +260,6 @@
269
260
  "primary": false,
270
261
  "unique": true
271
262
  },
272
- {
273
- "keyName": "onboarding_requests_email_hash_unique",
274
- "columnNames": [
275
- "email_hash"
276
- ],
277
- "composite": false,
278
- "constraint": true,
279
- "primary": false,
280
- "unique": true
281
- },
282
263
  {
283
264
  "keyName": "onboarding_requests_pkey",
284
265
  "columnNames": [
@@ -1,15 +0,0 @@
1
- import { Migration } from "@mikro-orm/migrations";
2
- class Migration20260710133207_onboarding extends Migration {
3
- up() {
4
- this.addSql(`alter table "onboarding_requests" add "email_hash" text null;`);
5
- this.addSql(`alter table "onboarding_requests" add constraint "onboarding_requests_email_hash_unique" unique ("email_hash");`);
6
- }
7
- down() {
8
- this.addSql(`alter table "onboarding_requests" drop constraint if exists "onboarding_requests_email_hash_unique";`);
9
- this.addSql(`alter table "onboarding_requests" drop column "email_hash";`);
10
- }
11
- }
12
- export {
13
- Migration20260710133207_onboarding
14
- };
15
- //# sourceMappingURL=Migration20260710133207_onboarding.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/modules/onboarding/migrations/Migration20260710133207_onboarding.ts"],
4
- "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260710133207_onboarding extends Migration {\n\n override up(): void | Promise<void> {\n this.addSql(`alter table \"onboarding_requests\" add \"email_hash\" text null;`);\n this.addSql(`alter table \"onboarding_requests\" add constraint \"onboarding_requests_email_hash_unique\" unique (\"email_hash\");`);\n }\n\n override down(): void | Promise<void> {\n this.addSql(`alter table \"onboarding_requests\" drop constraint if exists \"onboarding_requests_email_hash_unique\";`);\n this.addSql(`alter table \"onboarding_requests\" drop column \"email_hash\";`);\n }\n\n}\n"],
5
- "mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,2CAA2C,UAAU;AAAA,EAEvD,KAA2B;AAClC,SAAK,OAAO,+DAA+D;AAC3E,SAAK,OAAO,iHAAiH;AAAA,EAC/H;AAAA,EAES,OAA6B;AACpC,SAAK,OAAO,sGAAsG;AAClH,SAAK,OAAO,6DAA6D;AAAA,EAC3E;AAEF;",
6
- "names": []
7
- }
@@ -1,213 +0,0 @@
1
- import { createHash, randomBytes } from 'node:crypto'
2
- import type { EntityManager } from '@mikro-orm/postgresql'
3
- import { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
4
- import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
5
- import type { TenantDek } from '@open-mercato/shared/lib/encryption/kms'
6
- import { OnboardingRequest } from '../modules/onboarding/data/entities'
7
- import { OnboardingService } from '../modules/onboarding/lib/service'
8
- import { defaultEncryptionMaps } from '../modules/onboarding/encryption'
9
-
10
- jest.mock('bcryptjs', () => ({
11
- hash: jest.fn().mockResolvedValue('freshly_hashed_password'),
12
- }))
13
-
14
- const LEGACY_EMAIL = 'legacy.signup@example.com'
15
- const SYSTEM_KEY = randomBytes(32).toString('base64')
16
-
17
- function hashToken(token: string) {
18
- return createHash('sha256').update(token).digest('hex')
19
- }
20
-
21
- function makeLegacyPlaintextRow(): OnboardingRequest {
22
- return Object.assign(new OnboardingRequest(), {
23
- id: 'legacy-row',
24
- email: LEGACY_EMAIL,
25
- emailHash: null,
26
- tokenHash: hashToken('legacy-token'),
27
- status: 'pending',
28
- firstName: 'Ada',
29
- lastName: 'Lovelace',
30
- organizationName: 'Analytical Engines',
31
- locale: 'en',
32
- termsAccepted: true,
33
- marketingConsent: false,
34
- passwordHash: 'legacy_bcrypt_hash',
35
- expiresAt: new Date(Date.now() + 86_400_000),
36
- completedAt: null,
37
- processingStartedAt: null,
38
- tenantId: null,
39
- organizationId: null,
40
- userId: null,
41
- lastEmailSentAt: new Date(Date.now() - 60 * 60 * 1000),
42
- preparationStartedAt: null,
43
- preparationCompletedAt: null,
44
- readyEmailSentAt: null,
45
- createdAt: new Date(Date.now() - 7 * 86_400_000),
46
- updatedAt: new Date(Date.now() - 7 * 86_400_000),
47
- deletedAt: null,
48
- })
49
- }
50
-
51
- function matchesCondition(actual: unknown, condition: unknown): boolean {
52
- if (condition !== null && typeof condition === 'object' && !(condition instanceof Date)) {
53
- const operators = condition as Record<string, unknown>
54
- if ('$in' in operators) return (operators.$in as unknown[]).includes(actual)
55
- if ('$gt' in operators) return (actual as Date) > (operators.$gt as Date)
56
- if ('$ne' in operators) return actual !== operators.$ne
57
- return false
58
- }
59
- return actual === condition
60
- }
61
-
62
- function matchesFilter(row: Record<string, unknown>, filter: Record<string, unknown>): boolean {
63
- return Object.entries(filter).every(([key, condition]) => {
64
- if (key === '$or') return (condition as Record<string, unknown>[]).some((branch) => matchesFilter(row, branch))
65
- return matchesCondition(row[key], condition)
66
- })
67
- }
68
-
69
- /**
70
- * Minimal filter-evaluating EntityManager stand-in. The point of this suite is that a
71
- * backfilled row is still *found* by the service's real lookup, so the fake has to
72
- * actually evaluate the `$or` the service builds rather than record the call.
73
- */
74
- function makeEm(rows: OnboardingRequest[]) {
75
- const created: OnboardingRequest[] = []
76
- return {
77
- findOne: jest.fn(async (_entity: unknown, where: Record<string, unknown>) =>
78
- rows.find((row) => matchesFilter(row as unknown as Record<string, unknown>, where)) ?? null),
79
- create: jest.fn((_entity: unknown, data: Record<string, unknown>) => {
80
- const row = Object.assign(new OnboardingRequest(), data)
81
- created.push(row)
82
- return row
83
- }),
84
- persist: jest.fn(() => ({ flush: jest.fn(async () => {}) })),
85
- flush: jest.fn(async () => {}),
86
- created,
87
- } as unknown as EntityManager & { created: OnboardingRequest[]; findOne: jest.Mock; create: jest.Mock }
88
- }
89
-
90
- function makeEncryptionService(em: EntityManager) {
91
- const kms = {
92
- isHealthy: () => true,
93
- async getTenantDek(tenantId: string): Promise<TenantDek | null> {
94
- return { tenantId, key: SYSTEM_KEY, fetchedAt: Date.now() }
95
- },
96
- async createTenantDek(tenantId: string): Promise<TenantDek | null> {
97
- return this.getTenantDek(tenantId)
98
- },
99
- }
100
- return new TenantDataEncryptionService(em, { kms, defaultEncryptionMaps })
101
- }
102
-
103
- /**
104
- * Applies exactly what `mercato entities backfill-system-encryption` applies to one row:
105
- * the mapped fields go through `encryptEntityPayload` under the system key, and the
106
- * resulting ciphertext plus lookup hash are written back to the record.
107
- */
108
- async function backfillRow(service: TenantDataEncryptionService, row: OnboardingRequest): Promise<void> {
109
- const encrypted = await service.encryptEntityPayload('onboarding:onboarding_request', {
110
- email: row.email,
111
- email_hash: row.emailHash ?? null,
112
- first_name: row.firstName,
113
- last_name: row.lastName,
114
- organization_name: row.organizationName,
115
- password_hash: row.passwordHash ?? null,
116
- }, null, null)
117
- row.email = encrypted.email as string
118
- row.emailHash = encrypted.email_hash as string
119
- row.firstName = encrypted.first_name as string
120
- row.lastName = encrypted.last_name as string
121
- row.organizationName = encrypted.organization_name as string
122
- row.passwordHash = encrypted.password_hash as string
123
- }
124
-
125
- describe('backfilled legacy onboarding request (#3876)', () => {
126
- const originalToggle = process.env.TENANT_DATA_ENCRYPTION
127
-
128
- beforeEach(() => {
129
- process.env.TENANT_DATA_ENCRYPTION = 'yes'
130
- })
131
-
132
- afterEach(() => {
133
- if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
134
- else process.env.TENANT_DATA_ENCRYPTION = originalToggle
135
- })
136
-
137
- it('encrypts every mapped field and populates the lookup hash', async () => {
138
- const row = makeLegacyPlaintextRow()
139
- const em = makeEm([row])
140
-
141
- await backfillRow(makeEncryptionService(em), row)
142
-
143
- for (const value of [row.email, row.firstName, row.lastName, row.organizationName, row.passwordHash]) {
144
- expect(value).toMatch(/^[^:]+:[^:]+:[^:]+:v1$/)
145
- }
146
- expect(lookupHashCandidates(LEGACY_EMAIL)).toContain(row.emailHash)
147
- })
148
-
149
- it('still resolves the row on resubmit instead of creating a duplicate', async () => {
150
- const row = makeLegacyPlaintextRow()
151
- const em = makeEm([row])
152
- await backfillRow(makeEncryptionService(em), row)
153
-
154
- const service = new OnboardingService(em)
155
- const { request } = await service.createOrUpdateRequest({
156
- email: LEGACY_EMAIL,
157
- firstName: 'Ada',
158
- lastName: 'Lovelace',
159
- organizationName: 'Analytical Engines',
160
- password: 'Secret1!',
161
- confirmPassword: 'Secret1!',
162
- termsAccepted: true,
163
- marketingConsent: false,
164
- } as never)
165
-
166
- expect(request.id).toBe('legacy-row')
167
- expect(em.created).toHaveLength(0)
168
- expect(em.create).not.toHaveBeenCalled()
169
- })
170
-
171
- it('still resolves the row by verification token after the backfill', async () => {
172
- const row = makeLegacyPlaintextRow()
173
- const em = makeEm([row])
174
- await backfillRow(makeEncryptionService(em), row)
175
-
176
- const found = await new OnboardingService(em).findPendingByToken('legacy-token')
177
-
178
- expect(found?.id).toBe('legacy-row')
179
- })
180
-
181
- it('leaves a row the backfill already processed unchanged on a second pass', async () => {
182
- const row = makeLegacyPlaintextRow()
183
- const em = makeEm([row])
184
- const service = makeEncryptionService(em)
185
-
186
- await backfillRow(service, row)
187
- const afterFirstPass = { email: row.email, emailHash: row.emailHash, passwordHash: row.passwordHash }
188
- await backfillRow(service, row)
189
-
190
- expect(row.email).toBe(afterFirstPass.email)
191
- expect(row.emailHash).toBe(afterFirstPass.emailHash)
192
- expect(row.passwordHash).toBe(afterFirstPass.passwordHash)
193
- })
194
-
195
- it('still finds a row that was never backfilled, through the legacy plaintext branch', async () => {
196
- const row = makeLegacyPlaintextRow()
197
- const em = makeEm([row])
198
-
199
- const found = await new OnboardingService(em).createOrUpdateRequest({
200
- email: LEGACY_EMAIL,
201
- firstName: 'Ada',
202
- lastName: 'Lovelace',
203
- organizationName: 'Analytical Engines',
204
- password: 'Secret1!',
205
- confirmPassword: 'Secret1!',
206
- termsAccepted: true,
207
- marketingConsent: false,
208
- } as never)
209
-
210
- expect(found.request.id).toBe('legacy-row')
211
- expect(em.create).not.toHaveBeenCalled()
212
- })
213
- })
@@ -1,19 +0,0 @@
1
- import { defaultEncryptionMaps } from '../modules/onboarding/encryption'
2
-
3
- describe('onboarding encryption map', () => {
4
- it('protects pre-tenant PII and the transient password hash with a system key', () => {
5
- expect(defaultEncryptionMaps).toEqual([
6
- {
7
- entityId: 'onboarding:onboarding_request',
8
- keyScope: 'system',
9
- fields: [
10
- { field: 'email', hashField: 'email_hash' },
11
- { field: 'first_name' },
12
- { field: 'last_name' },
13
- { field: 'organization_name' },
14
- { field: 'password_hash' },
15
- ],
16
- },
17
- ])
18
- })
19
- })