@open-mercato/onboarding 0.6.7 → 0.6.8-develop.6875.1.871a4afc94

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 +8 -5
  3. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +25 -2
  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 +3 -2
  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 +9 -1
  8. package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
  9. package/dist/modules/onboarding/data/entities.js +4 -0
  10. package/dist/modules/onboarding/data/entities.js.map +2 -2
  11. package/dist/modules/onboarding/encryption.js +4 -2
  12. package/dist/modules/onboarding/encryption.js.map +2 -2
  13. package/dist/modules/onboarding/lib/service.js +18 -6
  14. package/dist/modules/onboarding/lib/service.js.map +2 -2
  15. package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js +15 -0
  16. package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js.map +7 -0
  17. package/generated/entities/onboarding_request/index.ts +1 -0
  18. package/generated/entity-fields-registry.ts +1 -0
  19. package/package.json +6 -5
  20. package/src/__tests__/backfill-legacy-request.test.ts +213 -0
  21. package/src/__tests__/encryption.test.ts +19 -0
  22. package/src/__tests__/service.test.ts +16 -0
  23. package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +37 -2
  24. package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +3 -2
  25. package/src/modules/onboarding/api/post/onboarding.ts +9 -1
  26. package/src/modules/onboarding/data/entities.ts +4 -0
  27. package/src/modules/onboarding/encryption.ts +3 -1
  28. package/src/modules/onboarding/i18n/ko.json +100 -0
  29. package/src/modules/onboarding/lib/service.ts +17 -5
  30. package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +19 -0
  31. package/src/modules/onboarding/migrations/Migration20260710133207_onboarding.ts +15 -0
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,7 @@
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,5 +1,6 @@
1
1
  export const id = "id";
2
2
  export const email = "email";
3
+ export const email_hash = "email_hash";
3
4
  export const token_hash = "token_hash";
4
5
  export const status = "status";
5
6
  export const first_name = "first_name";
@@ -4,6 +4,7 @@ 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",
7
8
  "token_hash": "token_hash",
8
9
  "status": "status",
9
10
  "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",
3
+ "version": "0.6.8-develop.6875.1.871a4afc94",
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"
73
+ "@open-mercato/shared": "0.6.8-develop.6875.1.871a4afc94"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.7",
76
+ "@open-mercato/shared": "0.6.8-develop.6875.1.871a4afc94",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
- "ts-jest": "^29.4.11",
79
+ "ts-jest": "^29.4.12",
80
80
  "typescript": "7.0.2"
81
81
  },
82
82
  "publishConfig": {
@@ -86,5 +86,6 @@
86
86
  "type": "git",
87
87
  "url": "https://github.com/open-mercato/open-mercato",
88
88
  "directory": "packages/onboarding"
89
- }
89
+ },
90
+ "stableVersion": "0.6.7"
90
91
  }
@@ -0,0 +1,213 @@
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
+ })
@@ -0,0 +1,19 @@
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
+ })
@@ -2,6 +2,7 @@ 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'
5
6
 
6
7
  jest.mock('bcryptjs', () => ({
7
8
  hash: jest.fn().mockResolvedValue('hashed_password'),
@@ -127,6 +128,21 @@ describe('OnboardingService', () => {
127
128
  const result = await service.createOrUpdateRequest(makeStartInput())
128
129
  const createArgs = em.create.mock.calls[0][1]
129
130
  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)
130
146
  })
131
147
 
132
148
  it('throws PENDING_REQUEST when within cooldown', async () => {
@@ -1,6 +1,7 @@
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';
4
5
 
5
6
  export const integrationMeta = {
6
7
  dependsOnModules: ['onboarding'],
@@ -26,6 +27,15 @@ type UserConsentRow = {
26
27
  granted_at: Date | null;
27
28
  };
28
29
 
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
+
29
39
  const ONBOARDING_PASSWORD = 'IntegrationPass123!';
30
40
  const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
31
41
 
@@ -40,15 +50,32 @@ async function replaceVerificationToken(email: string, token: string): Promise<s
40
50
  set token_hash = $2,
41
51
  expires_at = now() + interval '24 hours',
42
52
  updated_at = now()
43
- where email = $1
53
+ where email_hash = any($1::text[])
44
54
  returning id`,
45
- [email, hashToken(token)],
55
+ [lookupHashCandidates(email), hashToken(token)],
46
56
  );
47
57
  expect(result.rowCount, 'onboarding request should exist after submitting the form').toBe(1);
48
58
  return result.rows[0].id;
49
59
  });
50
60
  }
51
61
 
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
+
52
79
  async function readCompletedRequest(requestId: string): Promise<OnboardingRequestRow> {
53
80
  return withClient(async (client) => {
54
81
  const result = await client.query<OnboardingRequestRow>(
@@ -142,6 +169,14 @@ test.describe('TC-ONB-001: self-service onboarding with marketing consent', () =
142
169
  await expect(page.getByRole('status')).toContainText('Check your inbox');
143
170
  await expect(page.getByRole('status')).toContainText(email);
144
171
 
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
+
145
180
  const requestId = await replaceVerificationToken(email, token);
146
181
  const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;
147
182
 
@@ -4,6 +4,7 @@
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';
7
8
 
8
9
  export const integrationMeta = {
9
10
  dependsOnModules: ['onboarding'],
@@ -39,9 +40,9 @@ async function replaceVerificationToken(email: string, token: string): Promise<s
39
40
  set token_hash = $2,
40
41
  expires_at = now() + interval '24 hours',
41
42
  updated_at = now()
42
- where email = $1
43
+ where email_hash = any($1::text[])
43
44
  returning id`,
44
- [email, hashToken(token)],
45
+ [lookupHashCandidates(email), hashToken(token)],
45
46
  );
46
47
  expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);
47
48
  return result.rows[0].id;
@@ -3,6 +3,8 @@ 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'
6
8
  import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
7
9
  import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
8
10
  import { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
@@ -107,7 +109,13 @@ export async function POST(req: Request) {
107
109
  const container = await createRequestContainer()
108
110
  const em = (container.resolve('em') as EntityManager)
109
111
 
110
- const existingUser = await em.findOne(User, { email: parsed.data.email })
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
+ })
111
119
  if (existingUser) {
112
120
  const message = translate('onboarding.errors.emailExists', 'We already have an account with this email. Try signing in or resetting your password.')
113
121
  return NextResponse.json({
@@ -4,6 +4,7 @@ type OnboardingStatus = 'pending' | 'processing' | 'completed' | 'expired'
4
4
 
5
5
  @Entity({ tableName: 'onboarding_requests' })
6
6
  @Unique({ properties: ['email'] })
7
+ @Unique({ properties: ['emailHash'] })
7
8
  @Unique({ properties: ['tokenHash'] })
8
9
  export class OnboardingRequest {
9
10
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
@@ -12,6 +13,9 @@ export class OnboardingRequest {
12
13
  @Property({ type: 'text' })
13
14
  email!: string
14
15
 
16
+ @Property({ name: 'email_hash', type: 'text', nullable: true })
17
+ emailHash?: string | null
18
+
15
19
  @Property({ name: 'token_hash', type: 'text' })
16
20
  tokenHash!: string
17
21
 
@@ -3,11 +3,13 @@ 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',
6
7
  fields: [
7
- { field: 'email' },
8
+ { field: 'email', hashField: 'email_hash' },
8
9
  { field: 'first_name' },
9
10
  { field: 'last_name' },
10
11
  { field: 'organization_name' },
12
+ { field: 'password_hash' },
11
13
  ],
12
14
  },
13
15
  ]
@@ -0,0 +1,100 @@
1
+ {
2
+ "demoFeedback.button.ariaLabel": "피드백 양식 열기",
3
+ "demoFeedback.button.askQuestion": "질문하기",
4
+ "demoFeedback.button.contactUs": "문의하기",
5
+ "demoFeedback.button.feedback": "피드백",
6
+ "demoFeedback.dialog.description": "방금 보신 것은 실제 시스템의 약 80%입니다.\n몇 달이 아닌 몇 주 안에 이를 프로덕션 준비가 된 솔루션으로 전환하는 방법에 대해 이야기해 봅시다.",
7
+ "demoFeedback.dialog.successBody": "곧 연락드리겠습니다.",
8
+ "demoFeedback.dialog.successTitle": "감사합니다!",
9
+ "demoFeedback.dialog.title": "Open Mercato 팀과 상담하기",
10
+ "demoFeedback.errors.emailInvalid": "유효한 이메일 주소를 입력하세요.",
11
+ "demoFeedback.errors.generic": "문제가 발생했습니다. 다시 시도해 주세요.",
12
+ "demoFeedback.errors.termsRequired": "계속하려면 약관에 동의해 주세요.",
13
+ "demoFeedback.form.email": "이메일 주소",
14
+ "demoFeedback.form.marketingLabel": "CT Tornado로부터 이메일을 통한 직접 마케팅 수신에 동의합니다. 언제든지 동의를 철회할 수 있습니다. {termsLink} 및 {privacyLink}를 참고하세요.",
15
+ "demoFeedback.form.message": "메시지 (선택 사항)",
16
+ "demoFeedback.form.privacyLink": "개인정보 처리방침",
17
+ "demoFeedback.form.submit": "연락 받기",
18
+ "demoFeedback.form.suppressPopup": "이 팝업을 자동으로 표시하지 않기",
19
+ "demoFeedback.form.termsAnd": " 및 ",
20
+ "demoFeedback.form.termsLabel": "다음을 읽고 동의합니다: ",
21
+ "demoFeedback.form.termsLink": "이용 약관",
22
+ "onboarding.disabled.cta": "로그인으로 이동",
23
+ "onboarding.disabled.description": "현재 워크스페이스 생성은 수동으로 처리되고 있습니다. 계속하기 전에 Open Mercato 팀 또는 관리자에게 접근 권한을 요청하세요.",
24
+ "onboarding.disabled.title": "셀프 서비스 온보딩을 현재 사용할 수 없습니다",
25
+ "onboarding.email.adminBody": "{firstName} {lastName} ({email}) 님이 {organizationName}에 대한 온보딩 요청을 제출했습니다.",
26
+ "onboarding.email.adminFooter": "검증이 완료된 후 테넌트를 검토할 수 있습니다.",
27
+ "onboarding.email.adminHeading": "새 온보딩 요청",
28
+ "onboarding.email.adminPreview": "새 온보딩 요청이 제출되었습니다",
29
+ "onboarding.email.adminSubject": "새로운 셀프 서비스 온보딩 요청",
30
+ "onboarding.email.body": "{organizationName} 조직 설정을 마치려면 이메일 주소 확인이 필요합니다.",
31
+ "onboarding.email.cta": "이메일 확인 및 워크스페이스 활성화",
32
+ "onboarding.email.expiry": "이 링크는 24시간 후 만료됩니다. 요청하지 않으셨다면 이 메시지를 무시하셔도 됩니다.",
33
+ "onboarding.email.footer": "Open Mercato · 온보딩 서비스",
34
+ "onboarding.email.greeting": "안녕하세요 {firstName}님,",
35
+ "onboarding.email.heading": "Open Mercato에 오신 것을 환영합니다",
36
+ "onboarding.email.marketingConsentNo": "마케팅 동의: 아니오",
37
+ "onboarding.email.marketingConsentYes": "마케팅 동의: 예",
38
+ "onboarding.email.preview": "Open Mercato 워크스페이스를 활성화하려면 이메일을 확인하세요",
39
+ "onboarding.email.subject": "온보딩을 완료하려면 이메일을 확인하세요",
40
+ "onboarding.errors.emailExists": "이미 이 이메일로 등록된 계정이 있습니다. 로그인하거나 비밀번호를 재설정해 보세요.",
41
+ "onboarding.errors.emailInvalid": "유효한 업무용 이메일을 입력하세요.",
42
+ "onboarding.errors.emailSendFailed": "확인 이메일을 보내지 못했습니다. 다시 시도하거나 지원팀에 문의하세요.",
43
+ "onboarding.errors.firstNameRequired": "이름은 필수입니다.",
44
+ "onboarding.errors.lastNameRequired": "성은 필수입니다.",
45
+ "onboarding.errors.organizationNameRequired": "조직 이름은 필수입니다.",
46
+ "onboarding.errors.passwordMismatch": "비밀번호가 일치해야 합니다.",
47
+ "onboarding.errors.passwordRequired": "비밀번호는 다음 요구 사항을 충족해야 합니다: {requirements}.",
48
+ "onboarding.errors.pendingRequest": "이미 대기 중인 검증이 있습니다. 약 {minutes}분 후에 다시 시도하거나 관리자에게 문의하세요.",
49
+ "onboarding.errors.termsRequired": "계속하려면 약관에 동의해 주세요.",
50
+ "onboarding.form.confirmPassword": "비밀번호 확인",
51
+ "onboarding.form.email": "업무용 이메일",
52
+ "onboarding.form.emailExists": "이미 이 이메일로 등록된 계정이 있습니다. 로그인하거나 비밀번호를 재설정해 보세요.",
53
+ "onboarding.form.firstName": "이름",
54
+ "onboarding.form.genericError": "문제가 발생했습니다. 다시 시도하거나 지원팀에 문의하세요.",
55
+ "onboarding.form.lastName": "성",
56
+ "onboarding.form.legalEntity": "오픈 메르카토 sp. z o.o., 등록 사무소는 ul. Wyspa Słodowa 7, 50-266 Wrocław, 폴란드는 브로츠와프의 브로츠와프-파브리치나 지방 법원이 관리하는 국가 법원 등록부(KRS)의 기업가 등록부, 국가 법원 등록부 제6상업부에 KRS 번호 0001253104, VAT 번호(NIP)로 등록되었습니다. PL8982336029, REGON 545230330, 자본금 PLN 80,000.00, 전액 지불됨.",
57
+ "onboarding.form.loading": "전송 중...",
58
+ "onboarding.form.marketingLabel": "제공한 이메일 주소로 CT Tornado로부터 직접 마케팅을 수신하는 데 동의합니다. 언제든지 동의를 철회할 수 있으며(예: 수신 거부 링크를 통해), CT Tornado가 정당한 이익에 따라 메일링 리스트 관리, 동의 증빙 보관, 수신 거부 기록을 위해 제 이메일 주소를 처리할 수 있다는 점을 알고 있습니다. {termsLink} 및 {privacyLink}를 참고하세요.",
59
+ "onboarding.form.organizationName": "조직 이름",
60
+ "onboarding.form.password": "비밀번호",
61
+ "onboarding.form.privacyLink": "개인정보 처리방침",
62
+ "onboarding.form.submit": "확인 이메일 보내기",
63
+ "onboarding.form.successBody": "{email}로 확인 링크를 보냈습니다. 워크스페이스를 활성화하려면 24시간 이내에 확인해 주세요.",
64
+ "onboarding.form.successTitle": "받은편지함을 확인하세요",
65
+ "onboarding.form.termsAnd": " 및 ",
66
+ "onboarding.form.termsLabel": "다음을 읽고 동의합니다: ",
67
+ "onboarding.form.termsLink": "이용 약관",
68
+ "onboarding.form.termsRequired": "계속하려면 약관에 동의해 주세요.",
69
+ "onboarding.password.requirements.digit": "숫자 1개",
70
+ "onboarding.password.requirements.help": "비밀번호 요구 사항: {requirements}",
71
+ "onboarding.password.requirements.minLength": "최소 {min}자",
72
+ "onboarding.password.requirements.separator": ", ",
73
+ "onboarding.password.requirements.special": "특수 문자 1개",
74
+ "onboarding.password.requirements.uppercase": "대문자 1개",
75
+ "onboarding.preparing.backCta": "온보딩으로 돌아가기",
76
+ "onboarding.preparing.description": "데모 환경을 준비하고 있습니다. 준비가 완료되는 대로 올바른 테넌트 로그인 링크가 담긴 이메일을 보내드리겠습니다.",
77
+ "onboarding.preparing.descriptionWithTenant": "{tenant}의 데모 환경을 준비하고 있습니다. 준비가 완료되는 대로 올바른 테넌트 로그인 링크가 담긴 이메일을 보내드리겠습니다.",
78
+ "onboarding.preparing.emailNotice": "이 페이지를 계속 열어둘 필요는 없습니다. 모든 준비가 완료되면 이메일로 알려드리겠습니다.",
79
+ "onboarding.preparing.homeCta": "홈페이지로 이동",
80
+ "onboarding.preparing.loginCta": "테넌트 로그인 열기",
81
+ "onboarding.preparing.redirecting": "워크스페이스가 준비되었습니다. 지금 테넌트 로그인 페이지로 이동합니다.",
82
+ "onboarding.preparing.statusErrorBody": "워크스페이스 상태를 확인하지 못했습니다. 워크스페이스가 아직 준비 중일 수 있으며, 이 페이지는 계속 재시도합니다.",
83
+ "onboarding.preparing.statusErrorTitle": "워크스페이스 상태 확인 실패",
84
+ "onboarding.preparing.title": "워크스페이스를 준비하고 있습니다",
85
+ "onboarding.readyEmail.body": "{organizationName}의 Open Mercato 워크스페이스 준비가 완료되었습니다. 아래의 보안 링크를 사용해 로그인하세요.",
86
+ "onboarding.readyEmail.cta": "로그인 열기",
87
+ "onboarding.readyEmail.footer": "Open Mercato · 온보딩 서비스",
88
+ "onboarding.readyEmail.greeting": "안녕하세요 {firstName}님,",
89
+ "onboarding.readyEmail.heading": "워크스페이스가 준비되었습니다",
90
+ "onboarding.readyEmail.preview": "워크스페이스가 준비되었습니다. 보안 로그인 링크로 로그인하세요.",
91
+ "onboarding.readyEmail.subject": "Open Mercato 워크스페이스가 준비되었습니다",
92
+ "onboarding.subtitle": "간단한 정보를 알려주시면 모든 것을 설정해 드리겠습니다.",
93
+ "onboarding.title": "Open Mercato 워크스페이스 만들기",
94
+ "onboarding.verifyStatus.alreadyExists": "이미 이 이메일로 등록된 계정이 있습니다. 로그인하거나 비밀번호를 재설정해 보세요.",
95
+ "onboarding.verifyStatus.error": "검증을 완료하지 못했습니다. 다시 시도하거나 지원팀에 문의하세요.",
96
+ "onboarding.verifyStatus.invalid": "확인 링크가 유효하지 않거나 만료되었습니다. 새 링크를 받으려면 온보딩 양식을 다시 제출하세요.",
97
+ "onboarding.verifyStatus.originNotAllowed": "허용되지 않은 출처에서 확인 링크가 열렸습니다. APP_URL과 APP_ALLOWED_ORIGINS를 확인한 후 확인 링크를 다시 여세요.",
98
+ "onboarding.verifyStatus.redirectMisconfigured": "APP_URL이 요청을 처리한 URL과 일치하지 않아 확인 링크가 안전하게 리디렉션될 수 없습니다. APP_URL과 APP_ALLOWED_ORIGINS를 확인한 후 확인 링크를 다시 여세요.",
99
+ "onboarding.verifyStatus.urlNotConfigured": "온보딩 검증이 구성되지 않았습니다. 확인 링크를 사용하려면 APP_URL을 설정해야 합니다."
100
+ }