@open-mercato/core 0.8.1-develop.7240.1.g2661390300 → 0.8.1-develop.7254.1.ab24f26208

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 (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/lib/storage.js +15 -1
  3. package/dist/modules/attachments/lib/storage.js.map +2 -2
  4. package/dist/modules/auth/api/reset/confirm.js +3 -2
  5. package/dist/modules/auth/api/reset/confirm.js.map +2 -2
  6. package/dist/modules/auth/api/reset.js +2 -1
  7. package/dist/modules/auth/api/reset.js.map +2 -2
  8. package/dist/modules/auth/di.js +6 -6
  9. package/dist/modules/auth/di.js.map +2 -2
  10. package/dist/modules/auth/lib/tenantHint.js +29 -0
  11. package/dist/modules/auth/lib/tenantHint.js.map +7 -0
  12. package/dist/modules/customer_accounts/di.js +3 -3
  13. package/dist/modules/customer_accounts/di.js.map +2 -2
  14. package/dist/modules/customer_accounts/lib/customerAuth.js +18 -7
  15. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  16. package/dist/modules/customer_accounts/services/customerSessionService.js +23 -0
  17. package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
  18. package/dist/modules/customers/ai-tools/activities-tasks-pack.js +28 -24
  19. package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +2 -2
  20. package/dist/modules/customers/commands/companies.js +5 -0
  21. package/dist/modules/customers/commands/companies.js.map +2 -2
  22. package/dist/modules/customers/index.js +1 -0
  23. package/dist/modules/customers/index.js.map +2 -2
  24. package/dist/modules/data_sync/lib/sync-engine.js +3 -7
  25. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  26. package/dist/modules/data_sync/lib/sync-schedule-service.js +52 -3
  27. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
  28. package/dist/modules/progress/lib/progressServiceImpl.js +1 -1
  29. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  30. package/package.json +7 -7
  31. package/src/modules/attachments/lib/storage.ts +24 -1
  32. package/src/modules/auth/api/reset/confirm.ts +3 -2
  33. package/src/modules/auth/api/reset.ts +2 -1
  34. package/src/modules/auth/di.ts +6 -6
  35. package/src/modules/auth/lib/tenantHint.ts +49 -0
  36. package/src/modules/customer_accounts/di.ts +3 -3
  37. package/src/modules/customer_accounts/lib/customerAuth.ts +36 -7
  38. package/src/modules/customer_accounts/services/customerSessionService.ts +28 -0
  39. package/src/modules/customers/ai-tools/activities-tasks-pack.ts +49 -39
  40. package/src/modules/customers/commands/companies.ts +8 -0
  41. package/src/modules/customers/index.ts +1 -0
  42. package/src/modules/data_sync/AGENTS.md +1 -1
  43. package/src/modules/data_sync/lib/sync-engine.ts +23 -12
  44. package/src/modules/data_sync/lib/sync-schedule-service.ts +54 -3
  45. package/src/modules/progress/lib/progressServiceImpl.ts +7 -1
@@ -1,4 +1,4 @@
1
- [build:core] found 5051 entry points
1
+ [build:core] found 5052 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 233 entry points
4
4
  [build:core:generated] built successfully
@@ -3,13 +3,25 @@ import path from "path";
3
3
  import { randomUUID } from "crypto";
4
4
  import { resolvePartitionEnvKey } from "./partitionEnv.js";
5
5
  import { resolveContainedPath, resolveLegacyPublicRoot } from "./pathContainment.js";
6
+ const STORAGE_ROOT_ENV_KEY = "ATTACHMENTS_STORAGE_ROOT";
7
+ function resolveStorageRoot() {
8
+ const envPath = process.env[STORAGE_ROOT_ENV_KEY];
9
+ if (envPath && envPath.trim().length > 0) {
10
+ const trimmed = envPath.trim();
11
+ if (!path.isAbsolute(trimmed)) {
12
+ throw new Error(`[internal] ${STORAGE_ROOT_ENV_KEY} must be an absolute path, received: ${trimmed}`);
13
+ }
14
+ return path.resolve(trimmed);
15
+ }
16
+ return path.join(process.cwd(), "storage", "attachments");
17
+ }
6
18
  function resolvePartitionRoot(code) {
7
19
  const envKey = resolvePartitionEnvKey(code);
8
20
  const envPath = process.env[envKey];
9
21
  if (envPath && envPath.trim().length > 0) {
10
22
  return path.resolve(envPath);
11
23
  }
12
- return path.join(process.cwd(), "storage", "attachments", code);
24
+ return path.join(resolveStorageRoot(), code);
13
25
  }
14
26
  function sanitizeFileName(fileName) {
15
27
  if (!fileName) return "file";
@@ -55,9 +67,11 @@ async function deletePartitionFile(partitionCode, storagePath, storageDriver) {
55
67
  }
56
68
  }
57
69
  export {
70
+ STORAGE_ROOT_ENV_KEY,
58
71
  deletePartitionFile,
59
72
  resolveAttachmentAbsolutePath,
60
73
  resolvePartitionRoot,
74
+ resolveStorageRoot,
61
75
  storePartitionFile
62
76
  };
63
77
  //# sourceMappingURL=storage.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/attachments/lib/storage.ts"],
4
- "sourcesContent": ["import { promises as fs } from 'fs'\nimport path from 'path'\nimport { randomUUID } from 'crypto'\nimport { resolvePartitionEnvKey } from './partitionEnv'\nimport { resolveContainedPath, resolveLegacyPublicRoot } from './pathContainment'\n\nexport function resolvePartitionRoot(code: string): string {\n const envKey = resolvePartitionEnvKey(code)\n const envPath = process.env[envKey]\n if (envPath && envPath.trim().length > 0) {\n return path.resolve(envPath)\n }\n return path.join(process.cwd(), 'storage', 'attachments', code)\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\n/**\n * @deprecated Use `StorageDriverFactory.resolveForPartition()` + `driver.store()` instead.\n * Kept for backward compatibility with external callers.\n */\nexport type StorePartitionFilePayload = {\n partitionCode: string\n orgId: string | null | undefined\n tenantId: string | null | undefined\n fileName: string\n buffer: Buffer\n}\n\nexport type StoredPartitionFile = {\n storagePath: string\n absolutePath: string\n fileName: string\n}\n\n/**\n * @deprecated Use `StorageDriverFactory.resolveForPartition()` + `driver.store()` instead.\n */\nexport async function storePartitionFile(payload: StorePartitionFilePayload): Promise<StoredPartitionFile> {\n const root = resolvePartitionRoot(payload.partitionCode)\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const relativePath = path.join(orgSegment, tenantSegment, storedName)\n const absolutePath = path.join(root, relativePath)\n await fs.mkdir(path.dirname(absolutePath), { recursive: true })\n await fs.writeFile(absolutePath, payload.buffer)\n return {\n storagePath: relativePath.replace(/\\\\/g, '/'),\n absolutePath,\n fileName: storedName,\n }\n}\n\n/**\n * @deprecated Use `StorageDriverFactory.resolveForAttachment()` + `driver.read()` / `driver.toLocalPath()` instead.\n */\nexport function resolveAttachmentAbsolutePath(\n partitionCode: string,\n storagePath: string,\n storageDriver?: string | null\n): string {\n if (storageDriver === 'legacyPublic') {\n return resolveContainedPath(process.cwd(), storagePath, resolveLegacyPublicRoot())\n }\n const root = resolvePartitionRoot(partitionCode)\n return resolveContainedPath(root, storagePath)\n}\n\n/**\n * @deprecated Use `StorageDriverFactory.resolveForAttachment()` + `driver.delete()` instead.\n */\nexport async function deletePartitionFile(\n partitionCode: string,\n storagePath: string,\n storageDriver?: string | null\n): Promise<void> {\n const absolutePath = resolveAttachmentAbsolutePath(partitionCode, storagePath, storageDriver)\n try {\n await fs.unlink(absolutePath)\n } catch {\n // best-effort removal\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,YAAY,UAAU;AAC/B,OAAO,UAAU;AACjB,SAAS,kBAAkB;AAC3B,SAAS,8BAA8B;AACvC,SAAS,sBAAsB,+BAA+B;AAEvD,SAAS,qBAAqB,MAAsB;AACzD,QAAM,SAAS,uBAAuB,IAAI;AAC1C,QAAM,UAAU,QAAQ,IAAI,MAAM;AAClC,MAAI,WAAW,QAAQ,KAAK,EAAE,SAAS,GAAG;AACxC,WAAO,KAAK,QAAQ,OAAO;AAAA,EAC7B;AACA,SAAO,KAAK,KAAK,QAAQ,IAAI,GAAG,WAAW,eAAe,IAAI;AAChE;AAEA,SAAS,iBAAiB,UAA0B;AAClD,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,SAAS,QAAQ,oBAAoB,GAAG;AACjD;AAEA,SAAS,kBAAkB,OAA0C;AACnE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,OAAO,KAAK;AAC7E,SAAO;AACT;AAEA,SAAS,qBAAqB,UAA6C;AACzE,MAAI,OAAO,aAAa,YAAY,SAAS,KAAK,EAAE,SAAS,EAAG,QAAO,UAAU,QAAQ;AACzF,SAAO;AACT;AAuBA,eAAsB,mBAAmB,SAAkE;AACzG,QAAM,OAAO,qBAAqB,QAAQ,aAAa;AACvD,QAAM,aAAa,kBAAkB,QAAQ,SAAS,IAAI;AAC1D,QAAM,gBAAgB,qBAAqB,QAAQ,YAAY,IAAI;AACnE,QAAM,WAAW,iBAAiB,QAAQ,YAAY,MAAM;AAC5D,QAAM,eAAe,WAAW,EAAE,QAAQ,MAAM,EAAE,EAAE,MAAM,GAAG,EAAE;AAC/D,QAAM,aAAa,GAAG,KAAK,IAAI,CAAC,IAAI,YAAY,IAAI,QAAQ;AAC5D,QAAM,eAAe,KAAK,KAAK,YAAY,eAAe,UAAU;AACpE,QAAM,eAAe,KAAK,KAAK,MAAM,YAAY;AACjD,QAAM,GAAG,MAAM,KAAK,QAAQ,YAAY,GAAG,EAAE,WAAW,KAAK,CAAC;AAC9D,QAAM,GAAG,UAAU,cAAc,QAAQ,MAAM;AAC/C,SAAO;AAAA,IACL,aAAa,aAAa,QAAQ,OAAO,GAAG;AAAA,IAC5C;AAAA,IACA,UAAU;AAAA,EACZ;AACF;AAKO,SAAS,8BACd,eACA,aACA,eACQ;AACR,MAAI,kBAAkB,gBAAgB;AACpC,WAAO,qBAAqB,QAAQ,IAAI,GAAG,aAAa,wBAAwB,CAAC;AAAA,EACnF;AACA,QAAM,OAAO,qBAAqB,aAAa;AAC/C,SAAO,qBAAqB,MAAM,WAAW;AAC/C;AAKA,eAAsB,oBACpB,eACA,aACA,eACe;AACf,QAAM,eAAe,8BAA8B,eAAe,aAAa,aAAa;AAC5F,MAAI;AACF,UAAM,GAAG,OAAO,YAAY;AAAA,EAC9B,QAAQ;AAAA,EAER;AACF;",
4
+ "sourcesContent": ["import { promises as fs } from 'fs'\nimport path from 'path'\nimport { randomUUID } from 'crypto'\nimport { resolvePartitionEnvKey } from './partitionEnv'\nimport { resolveContainedPath, resolveLegacyPublicRoot } from './pathContainment'\n\nexport const STORAGE_ROOT_ENV_KEY = 'ATTACHMENTS_STORAGE_ROOT'\n\n/**\n * Resolves the base directory that holds every partition directory. Deployment\n * configuration (`ATTACHMENTS_STORAGE_ROOT`) wins over the historical\n * `process.cwd()`-relative default, so the same attachment row resolves to the\n * same file regardless of the directory the process was started from. A\n * relative value is rejected rather than resolved against `process.cwd()`,\n * because `LocalStorageDriver.store()` creates missing directories and a typo\n * would otherwise silently produce a second, empty store.\n */\nexport function resolveStorageRoot(): string {\n const envPath = process.env[STORAGE_ROOT_ENV_KEY]\n if (envPath && envPath.trim().length > 0) {\n const trimmed = envPath.trim()\n if (!path.isAbsolute(trimmed)) {\n throw new Error(`[internal] ${STORAGE_ROOT_ENV_KEY} must be an absolute path, received: ${trimmed}`)\n }\n return path.resolve(trimmed)\n }\n return path.join(process.cwd(), 'storage', 'attachments')\n}\n\nexport function resolvePartitionRoot(code: string): string {\n const envKey = resolvePartitionEnvKey(code)\n const envPath = process.env[envKey]\n if (envPath && envPath.trim().length > 0) {\n return path.resolve(envPath)\n }\n return path.join(resolveStorageRoot(), code)\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\n/**\n * @deprecated Use `StorageDriverFactory.resolveForPartition()` + `driver.store()` instead.\n * Kept for backward compatibility with external callers.\n */\nexport type StorePartitionFilePayload = {\n partitionCode: string\n orgId: string | null | undefined\n tenantId: string | null | undefined\n fileName: string\n buffer: Buffer\n}\n\nexport type StoredPartitionFile = {\n storagePath: string\n absolutePath: string\n fileName: string\n}\n\n/**\n * @deprecated Use `StorageDriverFactory.resolveForPartition()` + `driver.store()` instead.\n */\nexport async function storePartitionFile(payload: StorePartitionFilePayload): Promise<StoredPartitionFile> {\n const root = resolvePartitionRoot(payload.partitionCode)\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const relativePath = path.join(orgSegment, tenantSegment, storedName)\n const absolutePath = path.join(root, relativePath)\n await fs.mkdir(path.dirname(absolutePath), { recursive: true })\n await fs.writeFile(absolutePath, payload.buffer)\n return {\n storagePath: relativePath.replace(/\\\\/g, '/'),\n absolutePath,\n fileName: storedName,\n }\n}\n\n/**\n * @deprecated Use `StorageDriverFactory.resolveForAttachment()` + `driver.read()` / `driver.toLocalPath()` instead.\n */\nexport function resolveAttachmentAbsolutePath(\n partitionCode: string,\n storagePath: string,\n storageDriver?: string | null\n): string {\n if (storageDriver === 'legacyPublic') {\n return resolveContainedPath(process.cwd(), storagePath, resolveLegacyPublicRoot())\n }\n const root = resolvePartitionRoot(partitionCode)\n return resolveContainedPath(root, storagePath)\n}\n\n/**\n * @deprecated Use `StorageDriverFactory.resolveForAttachment()` + `driver.delete()` instead.\n */\nexport async function deletePartitionFile(\n partitionCode: string,\n storagePath: string,\n storageDriver?: string | null\n): Promise<void> {\n const absolutePath = resolveAttachmentAbsolutePath(partitionCode, storagePath, storageDriver)\n try {\n await fs.unlink(absolutePath)\n } catch {\n // best-effort removal\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,YAAY,UAAU;AAC/B,OAAO,UAAU;AACjB,SAAS,kBAAkB;AAC3B,SAAS,8BAA8B;AACvC,SAAS,sBAAsB,+BAA+B;AAEvD,MAAM,uBAAuB;AAW7B,SAAS,qBAA6B;AAC3C,QAAM,UAAU,QAAQ,IAAI,oBAAoB;AAChD,MAAI,WAAW,QAAQ,KAAK,EAAE,SAAS,GAAG;AACxC,UAAM,UAAU,QAAQ,KAAK;AAC7B,QAAI,CAAC,KAAK,WAAW,OAAO,GAAG;AAC7B,YAAM,IAAI,MAAM,cAAc,oBAAoB,wCAAwC,OAAO,EAAE;AAAA,IACrG;AACA,WAAO,KAAK,QAAQ,OAAO;AAAA,EAC7B;AACA,SAAO,KAAK,KAAK,QAAQ,IAAI,GAAG,WAAW,aAAa;AAC1D;AAEO,SAAS,qBAAqB,MAAsB;AACzD,QAAM,SAAS,uBAAuB,IAAI;AAC1C,QAAM,UAAU,QAAQ,IAAI,MAAM;AAClC,MAAI,WAAW,QAAQ,KAAK,EAAE,SAAS,GAAG;AACxC,WAAO,KAAK,QAAQ,OAAO;AAAA,EAC7B;AACA,SAAO,KAAK,KAAK,mBAAmB,GAAG,IAAI;AAC7C;AAEA,SAAS,iBAAiB,UAA0B;AAClD,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,SAAS,QAAQ,oBAAoB,GAAG;AACjD;AAEA,SAAS,kBAAkB,OAA0C;AACnE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,OAAO,KAAK;AAC7E,SAAO;AACT;AAEA,SAAS,qBAAqB,UAA6C;AACzE,MAAI,OAAO,aAAa,YAAY,SAAS,KAAK,EAAE,SAAS,EAAG,QAAO,UAAU,QAAQ;AACzF,SAAO;AACT;AAuBA,eAAsB,mBAAmB,SAAkE;AACzG,QAAM,OAAO,qBAAqB,QAAQ,aAAa;AACvD,QAAM,aAAa,kBAAkB,QAAQ,SAAS,IAAI;AAC1D,QAAM,gBAAgB,qBAAqB,QAAQ,YAAY,IAAI;AACnE,QAAM,WAAW,iBAAiB,QAAQ,YAAY,MAAM;AAC5D,QAAM,eAAe,WAAW,EAAE,QAAQ,MAAM,EAAE,EAAE,MAAM,GAAG,EAAE;AAC/D,QAAM,aAAa,GAAG,KAAK,IAAI,CAAC,IAAI,YAAY,IAAI,QAAQ;AAC5D,QAAM,eAAe,KAAK,KAAK,YAAY,eAAe,UAAU;AACpE,QAAM,eAAe,KAAK,KAAK,MAAM,YAAY;AACjD,QAAM,GAAG,MAAM,KAAK,QAAQ,YAAY,GAAG,EAAE,WAAW,KAAK,CAAC;AAC9D,QAAM,GAAG,UAAU,cAAc,QAAQ,MAAM;AAC/C,SAAO;AAAA,IACL,aAAa,aAAa,QAAQ,OAAO,GAAG;AAAA,IAC5C;AAAA,IACA,UAAU;AAAA,EACZ;AACF;AAKO,SAAS,8BACd,eACA,aACA,eACQ;AACR,MAAI,kBAAkB,gBAAgB;AACpC,WAAO,qBAAqB,QAAQ,IAAI,GAAG,aAAa,wBAAwB,CAAC;AAAA,EACnF;AACA,QAAM,OAAO,qBAAqB,aAAa;AAC/C,SAAO,qBAAqB,MAAM,WAAW;AAC/C;AAKA,eAAsB,oBACpB,eACA,aACA,eACe;AACf,QAAM,eAAe,8BAA8B,eAAe,aAAa,aAAa;AAC5F,MAAI;AACF,UAAM,GAAG,OAAO,YAAY;AAAA,EAC9B,QAAQ;AAAA,EAER;AACF;",
6
6
  "names": []
7
7
  }
@@ -10,6 +10,7 @@ import { readEndpointRateLimitConfig } from "@open-mercato/shared/lib/ratelimit/
10
10
  import { checkAuthRateLimit } from "@open-mercato/core/modules/auth/lib/rateLimitCheck";
11
11
  import { createLogger } from "@open-mercato/shared/lib/logger";
12
12
  import { emitAuthEvent } from "@open-mercato/core/modules/auth/events";
13
+ import { withTenantHintPath } from "@open-mercato/core/modules/auth/lib/tenantHint";
13
14
  const logger = createLogger("auth").child({ component: "reset-confirm" });
14
15
  const resetConfirmRateLimitConfig = readEndpointRateLimitConfig("RESET_CONFIRM", {
15
16
  points: 5,
@@ -54,7 +55,7 @@ async function POST(req) {
54
55
  } catch (err) {
55
56
  logger.error("Failed to create notification", { err });
56
57
  }
57
- return NextResponse.json({ ok: true, redirect: "/login" });
58
+ return NextResponse.json({ ok: true, redirect: withTenantHintPath("/login", user.tenantId) });
58
59
  }
59
60
  const metadata = { requireAuth: false };
60
61
  const passwordResetConfirmResponseSchema = z.object({
@@ -71,7 +72,7 @@ const openApi = {
71
72
  methods: {
72
73
  POST: {
73
74
  summary: "Complete password reset",
74
- description: "Validates the reset token and updates the user password.",
75
+ description: "Validates the reset token and updates the user password. The returned `redirect` carries the resolved user's tenant as a navigation hint (`/login?tenant=...`) so a tenant-bound user lands on the tenant login entry; tenantless users get `/login`.",
75
76
  requestBody: {
76
77
  contentType: "application/x-www-form-urlencoded",
77
78
  schema: confirmPasswordResetSchema
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/auth/api/reset/confirm.ts"],
4
- "sourcesContent": ["import { confirmPasswordResetSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { buildNotificationFromType } from '@open-mercato/core/modules/notifications/lib/notificationBuilder'\nimport { resolveNotificationService } from '@open-mercato/core/modules/notifications/lib/notificationService'\nimport notificationTypes from '@open-mercato/core/modules/auth/notifications'\nimport { z } from 'zod'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\n\nconst logger = createLogger('auth').child({ component: 'reset-confirm' })\n\nconst resetConfirmRateLimitConfig = readEndpointRateLimitConfig('RESET_CONFIRM', {\n points: 5, duration: 300, keyPrefix: 'reset-confirm',\n})\n\n// validation via confirmPasswordResetSchema\n\nexport async function POST(req: Request) {\n const form = await req.formData()\n const token = String(form.get('token') ?? '')\n const password = String(form.get('password') ?? '')\n // Rate limit by IP \u2014 checked before validation and DB work\n const { error: rateLimitError } = await checkAuthRateLimit({ req, ipConfig: resetConfirmRateLimitConfig })\n if (rateLimitError) return rateLimitError\n const parsed = confirmPasswordResetSchema.safeParse({ token, password })\n if (!parsed.success) return NextResponse.json({ ok: false, error: 'Invalid request' }, { status: 400 })\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n const user = await auth.confirmPasswordReset(parsed.data.token, parsed.data.password)\n if (!user) return NextResponse.json({ ok: false, error: 'Invalid or expired token' }, { status: 400 })\n void emitAuthEvent('auth.password.reset.completed', {\n id: String(user.id),\n tenantId: user.tenantId ? String(user.tenantId) : null,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n at: new Date().toISOString(),\n }, { persistent: true }).catch(() => undefined)\n try {\n const tenantId = user.tenantId ? String(user.tenantId) : null\n if (tenantId) {\n const notificationService = resolveNotificationService(c)\n const typeDef = notificationTypes.find((type) => type.type === 'auth.password_reset.completed')\n if (typeDef) {\n const notificationInput = buildNotificationFromType(typeDef, {\n recipientUserId: String(user.id),\n sourceEntityType: 'auth:user',\n sourceEntityId: String(user.id),\n })\n await notificationService.create(notificationInput, {\n tenantId,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n })\n }\n }\n } catch (err) {\n logger.error('Failed to create notification', { err })\n }\n return NextResponse.json({ ok: true, redirect: '/login' })\n}\n\nexport const metadata = { requireAuth: false }\n\nconst passwordResetConfirmResponseSchema = z.object({\n ok: z.literal(true),\n redirect: z.string(),\n})\n\nconst passwordResetErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Confirm password reset',\n methods: {\n POST: {\n summary: 'Complete password reset',\n description: 'Validates the reset token and updates the user password.',\n requestBody: {\n contentType: 'application/x-www-form-urlencoded',\n schema: confirmPasswordResetSchema,\n },\n responses: [\n { status: 200, description: 'Password reset succeeded', schema: passwordResetConfirmResponseSchema },\n { status: 400, description: 'Invalid token or payload', schema: passwordResetErrorSchema },\n ],\n errors: [\n { status: 429, description: 'Too many reset confirmation attempts', schema: rateLimitErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,kCAAkC;AAC3C,SAAS,oBAAoB;AAE7B,SAAS,8BAA8B;AAEvC,SAAS,iCAAiC;AAC1C,SAAS,kCAAkC;AAC3C,OAAO,uBAAuB;AAC9B,SAAS,SAAS;AAClB,SAAS,4BAA4B;AACrC,SAAS,mCAAmC;AAC5C,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB;AAE9B,MAAM,SAAS,aAAa,MAAM,EAAE,MAAM,EAAE,WAAW,gBAAgB,CAAC;AAExE,MAAM,8BAA8B,4BAA4B,iBAAiB;AAAA,EAC/E,QAAQ;AAAA,EAAG,UAAU;AAAA,EAAK,WAAW;AACvC,CAAC;AAID,eAAsB,KAAK,KAAc;AACvC,QAAM,OAAO,MAAM,IAAI,SAAS;AAChC,QAAM,QAAQ,OAAO,KAAK,IAAI,OAAO,KAAK,EAAE;AAC5C,QAAM,WAAW,OAAO,KAAK,IAAI,UAAU,KAAK,EAAE;AAElD,QAAM,EAAE,OAAO,eAAe,IAAI,MAAM,mBAAmB,EAAE,KAAK,UAAU,4BAA4B,CAAC;AACzG,MAAI,eAAgB,QAAO;AAC3B,QAAM,SAAS,2BAA2B,UAAU,EAAE,OAAO,SAAS,CAAC;AACvE,MAAI,CAAC,OAAO,QAAS,QAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AACtG,QAAM,IAAI,MAAM,uBAAuB;AACvC,QAAM,OAAO,EAAE,QAAqB,aAAa;AACjD,QAAM,OAAO,MAAM,KAAK,qBAAqB,OAAO,KAAK,OAAO,OAAO,KAAK,QAAQ;AACpF,MAAI,CAAC,KAAM,QAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,2BAA2B,GAAG,EAAE,QAAQ,IAAI,CAAC;AACrG,OAAK,cAAc,iCAAiC;AAAA,IAClD,IAAI,OAAO,KAAK,EAAE;AAAA,IAClB,UAAU,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI;AAAA,IAClD,gBAAgB,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,IACpE,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,EAC7B,GAAG,EAAE,YAAY,KAAK,CAAC,EAAE,MAAM,MAAM,MAAS;AAC9C,MAAI;AACF,UAAM,WAAW,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI;AACzD,QAAI,UAAU;AACZ,YAAM,sBAAsB,2BAA2B,CAAC;AACxD,YAAM,UAAU,kBAAkB,KAAK,CAAC,SAAS,KAAK,SAAS,+BAA+B;AAC9F,UAAI,SAAS;AACX,cAAM,oBAAoB,0BAA0B,SAAS;AAAA,UAC3D,iBAAiB,OAAO,KAAK,EAAE;AAAA,UAC/B,kBAAkB;AAAA,UAClB,gBAAgB,OAAO,KAAK,EAAE;AAAA,QAChC,CAAC;AACD,cAAM,oBAAoB,OAAO,mBAAmB;AAAA,UAClD;AAAA,UACA,gBAAgB,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,QACtE,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF,SAAS,KAAK;AACZ,WAAO,MAAM,iCAAiC,EAAE,IAAI,CAAC;AAAA,EACvD;AACA,SAAO,aAAa,KAAK,EAAE,IAAI,MAAM,UAAU,SAAS,CAAC;AAC3D;AAEO,MAAM,WAAW,EAAE,aAAa,MAAM;AAE7C,MAAM,qCAAqC,EAAE,OAAO;AAAA,EAClD,IAAI,EAAE,QAAQ,IAAI;AAAA,EAClB,UAAU,EAAE,OAAO;AACrB,CAAC;AAED,MAAM,2BAA2B,EAAE,OAAO;AAAA,EACxC,IAAI,EAAE,QAAQ,KAAK;AAAA,EACnB,OAAO,EAAE,OAAO;AAClB,CAAC;AAEM,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,MACb,aAAa;AAAA,QACX,aAAa;AAAA,QACb,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,4BAA4B,QAAQ,mCAAmC;AAAA,QACnG,EAAE,QAAQ,KAAK,aAAa,4BAA4B,QAAQ,yBAAyB;AAAA,MAC3F;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,wCAAwC,QAAQ,qBAAqB;AAAA,MACnG;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { confirmPasswordResetSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { buildNotificationFromType } from '@open-mercato/core/modules/notifications/lib/notificationBuilder'\nimport { resolveNotificationService } from '@open-mercato/core/modules/notifications/lib/notificationService'\nimport notificationTypes from '@open-mercato/core/modules/auth/notifications'\nimport { z } from 'zod'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\nimport { withTenantHintPath } from '@open-mercato/core/modules/auth/lib/tenantHint'\n\nconst logger = createLogger('auth').child({ component: 'reset-confirm' })\n\nconst resetConfirmRateLimitConfig = readEndpointRateLimitConfig('RESET_CONFIRM', {\n points: 5, duration: 300, keyPrefix: 'reset-confirm',\n})\n\n// validation via confirmPasswordResetSchema\n\nexport async function POST(req: Request) {\n const form = await req.formData()\n const token = String(form.get('token') ?? '')\n const password = String(form.get('password') ?? '')\n // Rate limit by IP \u2014 checked before validation and DB work\n const { error: rateLimitError } = await checkAuthRateLimit({ req, ipConfig: resetConfirmRateLimitConfig })\n if (rateLimitError) return rateLimitError\n const parsed = confirmPasswordResetSchema.safeParse({ token, password })\n if (!parsed.success) return NextResponse.json({ ok: false, error: 'Invalid request' }, { status: 400 })\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n const user = await auth.confirmPasswordReset(parsed.data.token, parsed.data.password)\n if (!user) return NextResponse.json({ ok: false, error: 'Invalid or expired token' }, { status: 400 })\n void emitAuthEvent('auth.password.reset.completed', {\n id: String(user.id),\n tenantId: user.tenantId ? String(user.tenantId) : null,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n at: new Date().toISOString(),\n }, { persistent: true }).catch(() => undefined)\n try {\n const tenantId = user.tenantId ? String(user.tenantId) : null\n if (tenantId) {\n const notificationService = resolveNotificationService(c)\n const typeDef = notificationTypes.find((type) => type.type === 'auth.password_reset.completed')\n if (typeDef) {\n const notificationInput = buildNotificationFromType(typeDef, {\n recipientUserId: String(user.id),\n sourceEntityType: 'auth:user',\n sourceEntityId: String(user.id),\n })\n await notificationService.create(notificationInput, {\n tenantId,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n })\n }\n }\n } catch (err) {\n logger.error('Failed to create notification', { err })\n }\n return NextResponse.json({ ok: true, redirect: withTenantHintPath('/login', user.tenantId) })\n}\n\nexport const metadata = { requireAuth: false }\n\nconst passwordResetConfirmResponseSchema = z.object({\n ok: z.literal(true),\n redirect: z.string(),\n})\n\nconst passwordResetErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Confirm password reset',\n methods: {\n POST: {\n summary: 'Complete password reset',\n description: 'Validates the reset token and updates the user password. The returned `redirect` carries the resolved user\\'s tenant as a navigation hint (`/login?tenant=...`) so a tenant-bound user lands on the tenant login entry; tenantless users get `/login`.',\n requestBody: {\n contentType: 'application/x-www-form-urlencoded',\n schema: confirmPasswordResetSchema,\n },\n responses: [\n { status: 200, description: 'Password reset succeeded', schema: passwordResetConfirmResponseSchema },\n { status: 400, description: 'Invalid token or payload', schema: passwordResetErrorSchema },\n ],\n errors: [\n { status: 429, description: 'Too many reset confirmation attempts', schema: rateLimitErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "AAAA,SAAS,kCAAkC;AAC3C,SAAS,oBAAoB;AAE7B,SAAS,8BAA8B;AAEvC,SAAS,iCAAiC;AAC1C,SAAS,kCAAkC;AAC3C,OAAO,uBAAuB;AAC9B,SAAS,SAAS;AAClB,SAAS,4BAA4B;AACrC,SAAS,mCAAmC;AAC5C,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB;AAC9B,SAAS,0BAA0B;AAEnC,MAAM,SAAS,aAAa,MAAM,EAAE,MAAM,EAAE,WAAW,gBAAgB,CAAC;AAExE,MAAM,8BAA8B,4BAA4B,iBAAiB;AAAA,EAC/E,QAAQ;AAAA,EAAG,UAAU;AAAA,EAAK,WAAW;AACvC,CAAC;AAID,eAAsB,KAAK,KAAc;AACvC,QAAM,OAAO,MAAM,IAAI,SAAS;AAChC,QAAM,QAAQ,OAAO,KAAK,IAAI,OAAO,KAAK,EAAE;AAC5C,QAAM,WAAW,OAAO,KAAK,IAAI,UAAU,KAAK,EAAE;AAElD,QAAM,EAAE,OAAO,eAAe,IAAI,MAAM,mBAAmB,EAAE,KAAK,UAAU,4BAA4B,CAAC;AACzG,MAAI,eAAgB,QAAO;AAC3B,QAAM,SAAS,2BAA2B,UAAU,EAAE,OAAO,SAAS,CAAC;AACvE,MAAI,CAAC,OAAO,QAAS,QAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AACtG,QAAM,IAAI,MAAM,uBAAuB;AACvC,QAAM,OAAO,EAAE,QAAqB,aAAa;AACjD,QAAM,OAAO,MAAM,KAAK,qBAAqB,OAAO,KAAK,OAAO,OAAO,KAAK,QAAQ;AACpF,MAAI,CAAC,KAAM,QAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,2BAA2B,GAAG,EAAE,QAAQ,IAAI,CAAC;AACrG,OAAK,cAAc,iCAAiC;AAAA,IAClD,IAAI,OAAO,KAAK,EAAE;AAAA,IAClB,UAAU,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI;AAAA,IAClD,gBAAgB,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,IACpE,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,EAC7B,GAAG,EAAE,YAAY,KAAK,CAAC,EAAE,MAAM,MAAM,MAAS;AAC9C,MAAI;AACF,UAAM,WAAW,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI;AACzD,QAAI,UAAU;AACZ,YAAM,sBAAsB,2BAA2B,CAAC;AACxD,YAAM,UAAU,kBAAkB,KAAK,CAAC,SAAS,KAAK,SAAS,+BAA+B;AAC9F,UAAI,SAAS;AACX,cAAM,oBAAoB,0BAA0B,SAAS;AAAA,UAC3D,iBAAiB,OAAO,KAAK,EAAE;AAAA,UAC/B,kBAAkB;AAAA,UAClB,gBAAgB,OAAO,KAAK,EAAE;AAAA,QAChC,CAAC;AACD,cAAM,oBAAoB,OAAO,mBAAmB;AAAA,UAClD;AAAA,UACA,gBAAgB,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,QACtE,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF,SAAS,KAAK;AACZ,WAAO,MAAM,iCAAiC,EAAE,IAAI,CAAC;AAAA,EACvD;AACA,SAAO,aAAa,KAAK,EAAE,IAAI,MAAM,UAAU,mBAAmB,UAAU,KAAK,QAAQ,EAAE,CAAC;AAC9F;AAEO,MAAM,WAAW,EAAE,aAAa,MAAM;AAE7C,MAAM,qCAAqC,EAAE,OAAO;AAAA,EAClD,IAAI,EAAE,QAAQ,IAAI;AAAA,EAClB,UAAU,EAAE,OAAO;AACrB,CAAC;AAED,MAAM,2BAA2B,EAAE,OAAO;AAAA,EACxC,IAAI,EAAE,QAAQ,KAAK;AAAA,EACnB,OAAO,EAAE,OAAO;AAClB,CAAC;AAEM,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,MACb,aAAa;AAAA,QACX,aAAa;AAAA,QACb,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,4BAA4B,QAAQ,mCAAmC;AAAA,QACnG,EAAE,QAAQ,KAAK,aAAa,4BAA4B,QAAQ,yBAAyB;AAAA,MAC3F;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,wCAAwC,QAAQ,qBAAqB;AAAA,MACnG;AAAA,IACF;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -12,6 +12,7 @@ import { rateLimitErrorSchema } from "@open-mercato/shared/lib/ratelimit/helpers
12
12
  import { readEndpointRateLimitConfig } from "@open-mercato/shared/lib/ratelimit/config";
13
13
  import { checkAuthRateLimit } from "@open-mercato/core/modules/auth/lib/rateLimitCheck";
14
14
  import { mapSecurityEmailUrlError, toSecurityEmailUrl } from "@open-mercato/shared/lib/url";
15
+ import { withTenantHintUrl } from "@open-mercato/core/modules/auth/lib/tenantHint";
15
16
  import { createLogger } from "@open-mercato/shared/lib/logger";
16
17
  import { emitAuthEvent } from "@open-mercato/core/modules/auth/events";
17
18
  const logger = createLogger("auth").child({ component: "reset" });
@@ -58,7 +59,7 @@ async function POST(req) {
58
59
  const resReq = await auth.requestPasswordReset(parsed.data.email);
59
60
  if (!resReq) return NextResponse.json({ ok: true });
60
61
  const { user, token } = resReq;
61
- const resetUrl = resetUrlTemplate.replace("__token__", token);
62
+ const resetUrl = withTenantHintUrl(resetUrlTemplate.replace("__token__", token), user.tenantId);
62
63
  void emitAuthEvent("auth.password.reset.requested", {
63
64
  id: String(user.id),
64
65
  tenantId: user.tenantId ? String(user.tenantId) : null,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/auth/api/reset.ts"],
4
- "sourcesContent": ["import { requestPasswordResetSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport ResetPasswordEmail from '@open-mercato/core/modules/auth/emails/ResetPasswordEmail'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { buildNotificationFromType } from '@open-mercato/core/modules/notifications/lib/notificationBuilder'\nimport { resolveNotificationService } from '@open-mercato/core/modules/notifications/lib/notificationService'\nimport notificationTypes from '@open-mercato/core/modules/auth/notifications'\nimport { z } from 'zod'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { mapSecurityEmailUrlError, toSecurityEmailUrl } from '@open-mercato/shared/lib/url'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\n\nconst logger = createLogger('auth').child({ component: 'reset' })\n\nconst resetRateLimitConfig = readEndpointRateLimitConfig('RESET', {\n points: 3, duration: 60, blockDuration: 60, keyPrefix: 'reset',\n})\nconst resetIpRateLimitConfig = readEndpointRateLimitConfig('RESET_IP', {\n points: 10, duration: 60, blockDuration: 60, keyPrefix: 'reset-ip',\n})\n\n// validation via requestPasswordResetSchema\n\nexport async function POST(req: Request) {\n const form = await req.formData()\n const email = String(form.get('email') ?? '')\n // Rate limit \u2014 two layers, both checked before validation and DB work\n const { error: rateLimitError } = await checkAuthRateLimit({\n req, ipConfig: resetIpRateLimitConfig, compoundConfig: resetRateLimitConfig, compoundIdentifier: email,\n })\n if (rateLimitError) return rateLimitError\n const parsed = requestPasswordResetSchema.safeParse({ email })\n if (!parsed.success) {\n const fieldErrors = parsed.error.flatten().fieldErrors\n return NextResponse.json({ error: 'Validation failed', fieldErrors }, { status: 422 })\n }\n let resetUrlTemplate: string\n try {\n resetUrlTemplate = toSecurityEmailUrl(req, '/reset/__token__')\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'auth.reset',\n configMessage: 'Password reset is not configured',\n })\n if (mapped) return NextResponse.json(mapped.body, { status: mapped.status })\n throw error\n }\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n const resReq = await auth.requestPasswordReset(parsed.data.email)\n if (!resReq) return NextResponse.json({ ok: true })\n const { user, token } = resReq\n const resetUrl = resetUrlTemplate.replace('__token__', token)\n void emitAuthEvent('auth.password.reset.requested', {\n id: String(user.id),\n tenantId: user.tenantId ? String(user.tenantId) : null,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n at: new Date().toISOString(),\n }, { persistent: true }).catch(() => undefined)\n\n const { translate } = await resolveTranslations()\n const subject = translate('auth.email.resetPassword.subject', 'Reset your password')\n const copy = {\n preview: translate('auth.email.resetPassword.preview', 'Reset your password'),\n title: translate('auth.email.resetPassword.title', 'Reset your password'),\n body: translate('auth.email.resetPassword.body', 'Click the link below to set a new password. This link will expire in 60 minutes.'),\n cta: translate('auth.email.resetPassword.cta', 'Set a new password'),\n hint: translate('auth.email.resetPassword.hint', \"If you didn't request this, you can safely ignore this email.\"),\n }\n\n try {\n await sendEmail({\n to: user.email,\n subject,\n react: ResetPasswordEmail({ resetUrl, copy }),\n tenantId: user.tenantId ? String(user.tenantId) : undefined,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n })\n } catch (err) {\n logger.error('Failed to send reset email', { err })\n }\n try {\n const tenantId = user.tenantId ? String(user.tenantId) : null\n if (tenantId) {\n const notificationService = resolveNotificationService(c)\n const typeDef = notificationTypes.find((type) => type.type === 'auth.password_reset.requested')\n if (typeDef) {\n const notificationInput = buildNotificationFromType(typeDef, {\n recipientUserId: String(user.id),\n sourceEntityType: 'auth:user',\n sourceEntityId: String(user.id),\n })\n await notificationService.create(notificationInput, {\n tenantId,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n })\n }\n }\n } catch (err) {\n logger.error('Failed to create notification', { err })\n }\n return NextResponse.json({ ok: true })\n}\n\nexport const metadata = { requireAuth: false }\n\nconst passwordResetRequestSchema = z.object({\n email: z.string().email(),\n})\n\nconst passwordResetResponseSchema = z.object({\n ok: z.literal(true),\n})\n\nconst passwordResetErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Request password reset',\n methods: {\n POST: {\n summary: 'Send reset email',\n description: 'Requests a password reset email for the given account. The endpoint always returns `ok: true` to avoid leaking account existence.',\n requestBody: {\n contentType: 'application/x-www-form-urlencoded',\n schema: passwordResetRequestSchema,\n },\n responses: [\n { status: 200, description: 'Reset email dispatched (or ignored for unknown accounts)', schema: passwordResetResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid request origin', schema: passwordResetErrorSchema },\n { status: 429, description: 'Too many password reset requests', schema: rateLimitErrorSchema },\n { status: 500, description: 'Password reset email origin is not configured', schema: passwordResetErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { requestPasswordResetSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport ResetPasswordEmail from '@open-mercato/core/modules/auth/emails/ResetPasswordEmail'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { buildNotificationFromType } from '@open-mercato/core/modules/notifications/lib/notificationBuilder'\nimport { resolveNotificationService } from '@open-mercato/core/modules/notifications/lib/notificationService'\nimport notificationTypes from '@open-mercato/core/modules/auth/notifications'\nimport { z } from 'zod'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { mapSecurityEmailUrlError, toSecurityEmailUrl } from '@open-mercato/shared/lib/url'\nimport { withTenantHintUrl } from '@open-mercato/core/modules/auth/lib/tenantHint'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\n\nconst logger = createLogger('auth').child({ component: 'reset' })\n\nconst resetRateLimitConfig = readEndpointRateLimitConfig('RESET', {\n points: 3, duration: 60, blockDuration: 60, keyPrefix: 'reset',\n})\nconst resetIpRateLimitConfig = readEndpointRateLimitConfig('RESET_IP', {\n points: 10, duration: 60, blockDuration: 60, keyPrefix: 'reset-ip',\n})\n\n// validation via requestPasswordResetSchema\n\nexport async function POST(req: Request) {\n const form = await req.formData()\n const email = String(form.get('email') ?? '')\n // Rate limit \u2014 two layers, both checked before validation and DB work\n const { error: rateLimitError } = await checkAuthRateLimit({\n req, ipConfig: resetIpRateLimitConfig, compoundConfig: resetRateLimitConfig, compoundIdentifier: email,\n })\n if (rateLimitError) return rateLimitError\n const parsed = requestPasswordResetSchema.safeParse({ email })\n if (!parsed.success) {\n const fieldErrors = parsed.error.flatten().fieldErrors\n return NextResponse.json({ error: 'Validation failed', fieldErrors }, { status: 422 })\n }\n let resetUrlTemplate: string\n try {\n resetUrlTemplate = toSecurityEmailUrl(req, '/reset/__token__')\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'auth.reset',\n configMessage: 'Password reset is not configured',\n })\n if (mapped) return NextResponse.json(mapped.body, { status: mapped.status })\n throw error\n }\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n const resReq = await auth.requestPasswordReset(parsed.data.email)\n if (!resReq) return NextResponse.json({ ok: true })\n const { user, token } = resReq\n const resetUrl = withTenantHintUrl(resetUrlTemplate.replace('__token__', token), user.tenantId)\n void emitAuthEvent('auth.password.reset.requested', {\n id: String(user.id),\n tenantId: user.tenantId ? String(user.tenantId) : null,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n at: new Date().toISOString(),\n }, { persistent: true }).catch(() => undefined)\n\n const { translate } = await resolveTranslations()\n const subject = translate('auth.email.resetPassword.subject', 'Reset your password')\n const copy = {\n preview: translate('auth.email.resetPassword.preview', 'Reset your password'),\n title: translate('auth.email.resetPassword.title', 'Reset your password'),\n body: translate('auth.email.resetPassword.body', 'Click the link below to set a new password. This link will expire in 60 minutes.'),\n cta: translate('auth.email.resetPassword.cta', 'Set a new password'),\n hint: translate('auth.email.resetPassword.hint', \"If you didn't request this, you can safely ignore this email.\"),\n }\n\n try {\n await sendEmail({\n to: user.email,\n subject,\n react: ResetPasswordEmail({ resetUrl, copy }),\n tenantId: user.tenantId ? String(user.tenantId) : undefined,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n })\n } catch (err) {\n logger.error('Failed to send reset email', { err })\n }\n try {\n const tenantId = user.tenantId ? String(user.tenantId) : null\n if (tenantId) {\n const notificationService = resolveNotificationService(c)\n const typeDef = notificationTypes.find((type) => type.type === 'auth.password_reset.requested')\n if (typeDef) {\n const notificationInput = buildNotificationFromType(typeDef, {\n recipientUserId: String(user.id),\n sourceEntityType: 'auth:user',\n sourceEntityId: String(user.id),\n })\n await notificationService.create(notificationInput, {\n tenantId,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n })\n }\n }\n } catch (err) {\n logger.error('Failed to create notification', { err })\n }\n return NextResponse.json({ ok: true })\n}\n\nexport const metadata = { requireAuth: false }\n\nconst passwordResetRequestSchema = z.object({\n email: z.string().email(),\n})\n\nconst passwordResetResponseSchema = z.object({\n ok: z.literal(true),\n})\n\nconst passwordResetErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Request password reset',\n methods: {\n POST: {\n summary: 'Send reset email',\n description: 'Requests a password reset email for the given account. The endpoint always returns `ok: true` to avoid leaking account existence.',\n requestBody: {\n contentType: 'application/x-www-form-urlencoded',\n schema: passwordResetRequestSchema,\n },\n responses: [\n { status: 200, description: 'Reset email dispatched (or ignored for unknown accounts)', schema: passwordResetResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid request origin', schema: passwordResetErrorSchema },\n { status: 429, description: 'Too many password reset requests', schema: rateLimitErrorSchema },\n { status: 500, description: 'Password reset email origin is not configured', schema: passwordResetErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -26,22 +26,22 @@ function resolveOptionalOrganizationHierarchyService(container) {
26
26
  function register(container) {
27
27
  container.register({ authService: asClass(AuthService).scoped() });
28
28
  container.register({
29
- authPrincipalService: asFunction(function authPrincipalServiceFactory(em) {
29
+ authPrincipalService: asFunction(function authPrincipalServiceFactory(cradle) {
30
30
  return new DefaultAuthPrincipalService(
31
- em,
31
+ cradle.em,
32
32
  resolveOptionalOrganizationHierarchyService(container)
33
33
  );
34
- }).scoped()
34
+ }).scoped().proxy()
35
35
  });
36
36
  container.register({
37
- rbacService: asFunction(function rbacServiceFactory(em) {
37
+ rbacService: asFunction(function rbacServiceFactory(cradle) {
38
38
  const configuredCache = resolveOptionalCache(container);
39
39
  return new RbacService(
40
- em,
40
+ cradle.em,
41
41
  configuredCache ?? (isRbacDefaultCacheEnabled() ? createRbacFallbackCache() : void 0),
42
42
  resolveOptionalOrganizationHierarchyService(container)
43
43
  );
44
- }).scoped()
44
+ }).scoped().proxy()
45
45
  });
46
46
  }
47
47
  export {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/auth/di.ts"],
4
- "sourcesContent": ["import { asClass, asFunction } from 'awilix'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport {\n createRbacFallbackCache,\n isRbacDefaultCacheEnabled,\n resetRbacFallbackCache,\n} from '@open-mercato/core/modules/auth/services/rbacDefaultCache'\nimport { DefaultAuthPrincipalService } from './services/principalService'\n\nexport { resetRbacFallbackCache }\n\nfunction resolveOptionalCache(container: AppContainer): CacheStrategy | undefined {\n try {\n return container.hasRegistration('cache')\n ? container.resolve<CacheStrategy>('cache')\n : undefined\n } catch {\n return undefined\n }\n}\n\nfunction resolveOptionalOrganizationHierarchyService(\n container: AppContainer,\n): OrganizationHierarchyService | undefined {\n try {\n if (!container.hasRegistration('organizationHierarchyService')) return undefined\n const candidate = container.resolve<OrganizationHierarchyService>('organizationHierarchyService')\n return typeof candidate?.resolveAncestorIds === 'function' ? candidate : undefined\n } catch {\n return undefined\n }\n}\n\nexport function register(container: AppContainer) {\n // Register or override core auth service\n container.register({ authService: asClass(AuthService).scoped() })\n container.register({\n authPrincipalService: asFunction(function authPrincipalServiceFactory(em: EntityManager) {\n return new DefaultAuthPrincipalService(\n em,\n resolveOptionalOrganizationHierarchyService(container),\n )\n }).scoped(),\n })\n // Resolve optional infrastructure lazily so Auth still works when Directory\n // is disabled and in lean CLI/test containers without a CacheStrategy.\n // Setting `OM_RBAC_DEFAULT_CACHE=on` opts into the in-process fallback;\n // an explicitly registered cache always wins.\n container.register({\n rbacService: asFunction(function rbacServiceFactory(em: EntityManager) {\n const configuredCache = resolveOptionalCache(container)\n return new RbacService(\n em,\n configuredCache ?? (isRbacDefaultCacheEnabled() ? createRbacFallbackCache() : undefined),\n resolveOptionalOrganizationHierarchyService(container),\n )\n }).scoped(),\n })\n}\n"],
5
- "mappings": "AAAA,SAAS,SAAS,kBAAkB;AAKpC,SAAS,mBAAmB;AAC5B,SAAS,mBAAmB;AAC5B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,mCAAmC;AAI5C,SAAS,qBAAqB,WAAoD;AAChF,MAAI;AACF,WAAO,UAAU,gBAAgB,OAAO,IACpC,UAAU,QAAuB,OAAO,IACxC;AAAA,EACN,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,4CACP,WAC0C;AAC1C,MAAI;AACF,QAAI,CAAC,UAAU,gBAAgB,8BAA8B,EAAG,QAAO;AACvE,UAAM,YAAY,UAAU,QAAsC,8BAA8B;AAChG,WAAO,OAAO,WAAW,uBAAuB,aAAa,YAAY;AAAA,EAC3E,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,SAAS,WAAyB;AAEhD,YAAU,SAAS,EAAE,aAAa,QAAQ,WAAW,EAAE,OAAO,EAAE,CAAC;AACjE,YAAU,SAAS;AAAA,IACjB,sBAAsB,WAAW,SAAS,4BAA4B,IAAmB;AACvF,aAAO,IAAI;AAAA,QACT;AAAA,QACA,4CAA4C,SAAS;AAAA,MACvD;AAAA,IACF,CAAC,EAAE,OAAO;AAAA,EACZ,CAAC;AAKD,YAAU,SAAS;AAAA,IACjB,aAAa,WAAW,SAAS,mBAAmB,IAAmB;AACrE,YAAM,kBAAkB,qBAAqB,SAAS;AACtD,aAAO,IAAI;AAAA,QACT;AAAA,QACA,oBAAoB,0BAA0B,IAAI,wBAAwB,IAAI;AAAA,QAC9E,4CAA4C,SAAS;AAAA,MACvD;AAAA,IACF,CAAC,EAAE,OAAO;AAAA,EACZ,CAAC;AACH;",
4
+ "sourcesContent": ["import { asClass, asFunction } from 'awilix'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport {\n createRbacFallbackCache,\n isRbacDefaultCacheEnabled,\n resetRbacFallbackCache,\n} from '@open-mercato/core/modules/auth/services/rbacDefaultCache'\nimport { DefaultAuthPrincipalService } from './services/principalService'\n\nexport { resetRbacFallbackCache }\n\nfunction resolveOptionalCache(container: AppContainer): CacheStrategy | undefined {\n try {\n return container.hasRegistration('cache')\n ? container.resolve<CacheStrategy>('cache')\n : undefined\n } catch {\n return undefined\n }\n}\n\nfunction resolveOptionalOrganizationHierarchyService(\n container: AppContainer,\n): OrganizationHierarchyService | undefined {\n try {\n if (!container.hasRegistration('organizationHierarchyService')) return undefined\n const candidate = container.resolve<OrganizationHierarchyService>('organizationHierarchyService')\n return typeof candidate?.resolveAncestorIds === 'function' ? candidate : undefined\n } catch {\n return undefined\n }\n}\n\nexport function register(container: AppContainer) {\n // Register or override core auth service\n container.register({ authService: asClass(AuthService).scoped() })\n container.register({\n authPrincipalService: asFunction(function authPrincipalServiceFactory(cradle: { em: EntityManager }) {\n return new DefaultAuthPrincipalService(\n cradle.em,\n resolveOptionalOrganizationHierarchyService(container),\n )\n }).scoped().proxy(),\n })\n // Resolve optional infrastructure lazily so Auth still works when Directory\n // is disabled and in lean CLI/test containers without a CacheStrategy.\n // Setting `OM_RBAC_DEFAULT_CACHE=on` opts into the in-process fallback;\n // an explicitly registered cache always wins.\n container.register({\n rbacService: asFunction(function rbacServiceFactory(cradle: { em: EntityManager }) {\n const configuredCache = resolveOptionalCache(container)\n return new RbacService(\n cradle.em,\n configuredCache ?? (isRbacDefaultCacheEnabled() ? createRbacFallbackCache() : undefined),\n resolveOptionalOrganizationHierarchyService(container),\n )\n }).scoped().proxy(),\n })\n}\n"],
5
+ "mappings": "AAAA,SAAS,SAAS,kBAAkB;AAKpC,SAAS,mBAAmB;AAC5B,SAAS,mBAAmB;AAC5B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,mCAAmC;AAI5C,SAAS,qBAAqB,WAAoD;AAChF,MAAI;AACF,WAAO,UAAU,gBAAgB,OAAO,IACpC,UAAU,QAAuB,OAAO,IACxC;AAAA,EACN,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,4CACP,WAC0C;AAC1C,MAAI;AACF,QAAI,CAAC,UAAU,gBAAgB,8BAA8B,EAAG,QAAO;AACvE,UAAM,YAAY,UAAU,QAAsC,8BAA8B;AAChG,WAAO,OAAO,WAAW,uBAAuB,aAAa,YAAY;AAAA,EAC3E,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,SAAS,WAAyB;AAEhD,YAAU,SAAS,EAAE,aAAa,QAAQ,WAAW,EAAE,OAAO,EAAE,CAAC;AACjE,YAAU,SAAS;AAAA,IACjB,sBAAsB,WAAW,SAAS,4BAA4B,QAA+B;AACnG,aAAO,IAAI;AAAA,QACT,OAAO;AAAA,QACP,4CAA4C,SAAS;AAAA,MACvD;AAAA,IACF,CAAC,EAAE,OAAO,EAAE,MAAM;AAAA,EACpB,CAAC;AAKD,YAAU,SAAS;AAAA,IACjB,aAAa,WAAW,SAAS,mBAAmB,QAA+B;AACjF,YAAM,kBAAkB,qBAAqB,SAAS;AACtD,aAAO,IAAI;AAAA,QACT,OAAO;AAAA,QACP,oBAAoB,0BAA0B,IAAI,wBAAwB,IAAI;AAAA,QAC9E,4CAA4C,SAAS;AAAA,MACvD;AAAA,IACF,CAAC,EAAE,OAAO,EAAE,MAAM;AAAA,EACpB,CAAC;AACH;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,29 @@
1
+ const TENANT_HINT_PARAM = "tenant";
2
+ function normalizeTenantHint(tenantId) {
3
+ const value = tenantId?.trim();
4
+ return value ? value : null;
5
+ }
6
+ function withTenantHintUrl(url, tenantId) {
7
+ const tenant = normalizeTenantHint(tenantId);
8
+ if (!tenant) return url;
9
+ try {
10
+ const parsed = new URL(url);
11
+ parsed.searchParams.set(TENANT_HINT_PARAM, tenant);
12
+ return parsed.toString();
13
+ } catch {
14
+ return url;
15
+ }
16
+ }
17
+ function withTenantHintPath(path, tenantId) {
18
+ const tenant = normalizeTenantHint(tenantId);
19
+ if (!tenant) return path;
20
+ const [pathname, existingQuery = ""] = path.split("?");
21
+ const params = new URLSearchParams(existingQuery);
22
+ params.set(TENANT_HINT_PARAM, tenant);
23
+ return `${pathname}?${params.toString()}`;
24
+ }
25
+ export {
26
+ withTenantHintPath,
27
+ withTenantHintUrl
28
+ };
29
+ //# sourceMappingURL=tenantHint.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/auth/lib/tenantHint.ts"],
4
+ "sourcesContent": ["/**\n * Tenant continuity hints for unauthenticated auth navigation.\n *\n * The `tenant` query parameter is a navigation hint only \u2014 it tells the login\n * entry which tenant the user was already resolved to so it can show tenant\n * branding instead of the generic form. It is never authorization, and it must\n * only ever be derived from a server-resolved user, never from request input.\n */\n\nconst TENANT_HINT_PARAM = 'tenant'\n\nexport type TenantHintSource = string | null | undefined\n\nfunction normalizeTenantHint(tenantId: TenantHintSource): string | null {\n const value = tenantId?.trim()\n return value ? value : null\n}\n\n/**\n * Adds the tenant hint to an absolute URL (password reset and invitation email\n * links). Returns the URL unchanged for tenantless users or an unparsable URL,\n * so a hint can never cost a user their working link.\n */\nexport function withTenantHintUrl(url: string, tenantId: TenantHintSource): string {\n const tenant = normalizeTenantHint(tenantId)\n if (!tenant) return url\n try {\n const parsed = new URL(url)\n parsed.searchParams.set(TENANT_HINT_PARAM, tenant)\n return parsed.toString()\n } catch {\n return url\n }\n}\n\n/**\n * Adds the tenant hint to an app-relative path (the redirect handed back to the\n * browser after a successful reset). Returns the path unchanged for tenantless\n * users. Encoding and replace-don't-duplicate semantics match\n * {@link withTenantHintUrl}.\n */\nexport function withTenantHintPath(path: string, tenantId: TenantHintSource): string {\n const tenant = normalizeTenantHint(tenantId)\n if (!tenant) return path\n const [pathname, existingQuery = ''] = path.split('?')\n const params = new URLSearchParams(existingQuery)\n params.set(TENANT_HINT_PARAM, tenant)\n return `${pathname}?${params.toString()}`\n}\n"],
5
+ "mappings": "AASA,MAAM,oBAAoB;AAI1B,SAAS,oBAAoB,UAA2C;AACtE,QAAM,QAAQ,UAAU,KAAK;AAC7B,SAAO,QAAQ,QAAQ;AACzB;AAOO,SAAS,kBAAkB,KAAa,UAAoC;AACjF,QAAM,SAAS,oBAAoB,QAAQ;AAC3C,MAAI,CAAC,OAAQ,QAAO;AACpB,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,WAAO,aAAa,IAAI,mBAAmB,MAAM;AACjD,WAAO,OAAO,SAAS;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAQO,SAAS,mBAAmB,MAAc,UAAoC;AACnF,QAAM,SAAS,oBAAoB,QAAQ;AAC3C,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,CAAC,UAAU,gBAAgB,EAAE,IAAI,KAAK,MAAM,GAAG;AACrD,QAAM,SAAS,IAAI,gBAAgB,aAAa;AAChD,SAAO,IAAI,mBAAmB,MAAM;AACpC,SAAO,GAAG,QAAQ,IAAI,OAAO,SAAS,CAAC;AACzC;",
6
+ "names": []
7
+ }
@@ -18,7 +18,7 @@ function register(container) {
18
18
  container.register({ customerInvitationService: asClass(CustomerInvitationService).scoped() });
19
19
  container.register({
20
20
  domainMappingService: asFunction(
21
- function domainMappingServiceFactory(em) {
21
+ function domainMappingServiceFactory(cradle) {
22
22
  let cacheService;
23
23
  try {
24
24
  if (container.hasRegistration("cache")) {
@@ -28,9 +28,9 @@ function register(container) {
28
28
  } catch {
29
29
  cacheService = void 0;
30
30
  }
31
- return new DomainMappingService(em, cacheService ? { cacheService } : void 0);
31
+ return new DomainMappingService(cradle.em, cacheService ? { cacheService } : void 0);
32
32
  }
33
- ).scoped()
33
+ ).scoped().proxy()
34
34
  });
35
35
  }
36
36
  export {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/customer_accounts/di.ts"],
4
- "sourcesContent": ["import { asClass, asFunction } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { CustomerUserService } from '@open-mercato/core/modules/customer_accounts/services/customerUserService'\nimport { CustomerSessionService } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'\nimport { CustomerTokenService } from '@open-mercato/core/modules/customer_accounts/services/customerTokenService'\nimport { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'\nimport { DomainMappingService } from '@open-mercato/core/modules/customer_accounts/services/domainMappingService'\n\ntype DomainCacheService = {\n get: (key: string, options?: unknown) => Promise<unknown>\n set: (key: string, value: unknown, options?: { ttl?: number; tags?: string[] }) => Promise<void>\n deleteByTags: (tags: string[]) => Promise<number>\n}\n\nfunction isDomainCacheService(candidate: unknown): candidate is DomainCacheService {\n if (!candidate || typeof candidate !== 'object') return false\n const c = candidate as Record<string, unknown>\n return typeof c.get === 'function' && typeof c.set === 'function' && typeof c.deleteByTags === 'function'\n}\n\nexport function register(container: AppContainer) {\n container.register({ customerUserService: asClass(CustomerUserService).scoped() })\n container.register({ customerSessionService: asClass(CustomerSessionService).scoped() })\n container.register({ customerTokenService: asClass(CustomerTokenService).scoped() })\n container.register({ customerRbacService: asClass(CustomerRbacService).scoped() })\n container.register({ customerInvitationService: asClass(CustomerInvitationService).scoped() })\n container.register({\n domainMappingService: asFunction(\n function domainMappingServiceFactory(em: EntityManager) {\n // Resolve the cache lazily so registrations from `bootstrap(container)` (which runs after\n // module DI registrars) are visible. Awilix CLASSIC mode would fail to resolve a\n // destructured `cache` param if the key is not yet registered; using `hasRegistration`\n // keeps construction safe for CLI/test contexts where `cache` may never be wired.\n let cacheService: DomainCacheService | undefined\n try {\n if (container.hasRegistration('cache')) {\n const candidate = container.resolve('cache')\n if (isDomainCacheService(candidate)) cacheService = candidate\n }\n } catch {\n cacheService = undefined\n }\n return new DomainMappingService(em, cacheService ? { cacheService } : undefined)\n },\n ).scoped(),\n })\n}\n"],
5
- "mappings": "AAAA,SAAS,SAAS,kBAAkB;AAGpC,SAAS,2BAA2B;AACpC,SAAS,8BAA8B;AACvC,SAAS,4BAA4B;AACrC,SAAS,2BAA2B;AACpC,SAAS,iCAAiC;AAC1C,SAAS,4BAA4B;AAQrC,SAAS,qBAAqB,WAAqD;AACjF,MAAI,CAAC,aAAa,OAAO,cAAc,SAAU,QAAO;AACxD,QAAM,IAAI;AACV,SAAO,OAAO,EAAE,QAAQ,cAAc,OAAO,EAAE,QAAQ,cAAc,OAAO,EAAE,iBAAiB;AACjG;AAEO,SAAS,SAAS,WAAyB;AAChD,YAAU,SAAS,EAAE,qBAAqB,QAAQ,mBAAmB,EAAE,OAAO,EAAE,CAAC;AACjF,YAAU,SAAS,EAAE,wBAAwB,QAAQ,sBAAsB,EAAE,OAAO,EAAE,CAAC;AACvF,YAAU,SAAS,EAAE,sBAAsB,QAAQ,oBAAoB,EAAE,OAAO,EAAE,CAAC;AACnF,YAAU,SAAS,EAAE,qBAAqB,QAAQ,mBAAmB,EAAE,OAAO,EAAE,CAAC;AACjF,YAAU,SAAS,EAAE,2BAA2B,QAAQ,yBAAyB,EAAE,OAAO,EAAE,CAAC;AAC7F,YAAU,SAAS;AAAA,IACjB,sBAAsB;AAAA,MACpB,SAAS,4BAA4B,IAAmB;AAKtD,YAAI;AACJ,YAAI;AACF,cAAI,UAAU,gBAAgB,OAAO,GAAG;AACtC,kBAAM,YAAY,UAAU,QAAQ,OAAO;AAC3C,gBAAI,qBAAqB,SAAS,EAAG,gBAAe;AAAA,UACtD;AAAA,QACF,QAAQ;AACN,yBAAe;AAAA,QACjB;AACA,eAAO,IAAI,qBAAqB,IAAI,eAAe,EAAE,aAAa,IAAI,MAAS;AAAA,MACjF;AAAA,IACF,EAAE,OAAO;AAAA,EACX,CAAC;AACH;",
4
+ "sourcesContent": ["import { asClass, asFunction } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { CustomerUserService } from '@open-mercato/core/modules/customer_accounts/services/customerUserService'\nimport { CustomerSessionService } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'\nimport { CustomerTokenService } from '@open-mercato/core/modules/customer_accounts/services/customerTokenService'\nimport { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'\nimport { DomainMappingService } from '@open-mercato/core/modules/customer_accounts/services/domainMappingService'\n\ntype DomainCacheService = {\n get: (key: string, options?: unknown) => Promise<unknown>\n set: (key: string, value: unknown, options?: { ttl?: number; tags?: string[] }) => Promise<void>\n deleteByTags: (tags: string[]) => Promise<number>\n}\n\nfunction isDomainCacheService(candidate: unknown): candidate is DomainCacheService {\n if (!candidate || typeof candidate !== 'object') return false\n const c = candidate as Record<string, unknown>\n return typeof c.get === 'function' && typeof c.set === 'function' && typeof c.deleteByTags === 'function'\n}\n\nexport function register(container: AppContainer) {\n container.register({ customerUserService: asClass(CustomerUserService).scoped() })\n container.register({ customerSessionService: asClass(CustomerSessionService).scoped() })\n container.register({ customerTokenService: asClass(CustomerTokenService).scoped() })\n container.register({ customerRbacService: asClass(CustomerRbacService).scoped() })\n container.register({ customerInvitationService: asClass(CustomerInvitationService).scoped() })\n container.register({\n domainMappingService: asFunction(\n function domainMappingServiceFactory(cradle: { em: EntityManager }) {\n // Resolve the cache lazily so registrations from `bootstrap(container)` (which runs after\n // module DI registrars) are visible. Awilix CLASSIC mode would fail to resolve a\n // destructured `cache` param if the key is not yet registered; using `hasRegistration`\n // keeps construction safe for CLI/test contexts where `cache` may never be wired.\n let cacheService: DomainCacheService | undefined\n try {\n if (container.hasRegistration('cache')) {\n const candidate = container.resolve('cache')\n if (isDomainCacheService(candidate)) cacheService = candidate\n }\n } catch {\n cacheService = undefined\n }\n return new DomainMappingService(cradle.em, cacheService ? { cacheService } : undefined)\n },\n ).scoped().proxy(),\n })\n}\n"],
5
+ "mappings": "AAAA,SAAS,SAAS,kBAAkB;AAGpC,SAAS,2BAA2B;AACpC,SAAS,8BAA8B;AACvC,SAAS,4BAA4B;AACrC,SAAS,2BAA2B;AACpC,SAAS,iCAAiC;AAC1C,SAAS,4BAA4B;AAQrC,SAAS,qBAAqB,WAAqD;AACjF,MAAI,CAAC,aAAa,OAAO,cAAc,SAAU,QAAO;AACxD,QAAM,IAAI;AACV,SAAO,OAAO,EAAE,QAAQ,cAAc,OAAO,EAAE,QAAQ,cAAc,OAAO,EAAE,iBAAiB;AACjG;AAEO,SAAS,SAAS,WAAyB;AAChD,YAAU,SAAS,EAAE,qBAAqB,QAAQ,mBAAmB,EAAE,OAAO,EAAE,CAAC;AACjF,YAAU,SAAS,EAAE,wBAAwB,QAAQ,sBAAsB,EAAE,OAAO,EAAE,CAAC;AACvF,YAAU,SAAS,EAAE,sBAAsB,QAAQ,oBAAoB,EAAE,OAAO,EAAE,CAAC;AACnF,YAAU,SAAS,EAAE,qBAAqB,QAAQ,mBAAmB,EAAE,OAAO,EAAE,CAAC;AACjF,YAAU,SAAS,EAAE,2BAA2B,QAAQ,yBAAyB,EAAE,OAAO,EAAE,CAAC;AAC7F,YAAU,SAAS;AAAA,IACjB,sBAAsB;AAAA,MACpB,SAAS,4BAA4B,QAA+B;AAKlE,YAAI;AACJ,YAAI;AACF,cAAI,UAAU,gBAAgB,OAAO,GAAG;AACtC,kBAAM,YAAY,UAAU,QAAQ,OAAO;AAC3C,gBAAI,qBAAqB,SAAS,EAAG,gBAAe;AAAA,UACtD;AAAA,QACF,QAAQ;AACN,yBAAe;AAAA,QACjB;AACA,eAAO,IAAI,qBAAqB,OAAO,IAAI,eAAe,EAAE,aAAa,IAAI,MAAS;AAAA,MACxF;AAAA,IACF,EAAE,OAAO,EAAE,MAAM;AAAA,EACnB,CAAC;AACH;",
6
6
  "names": []
7
7
  }
@@ -2,20 +2,31 @@ import { NextResponse } from "next/server";
2
2
  import { verifyAudienceJwt, verifyJwt } from "@open-mercato/shared/lib/auth/jwt";
3
3
  import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
4
4
  import { CUSTOMER_JWT_AUDIENCE } from "@open-mercato/core/modules/customer_accounts/services/customerSessionService";
5
+ async function resolveSessionService() {
6
+ const [{ createRequestContainer }, { CustomerSessionService }] = await Promise.all([
7
+ import("@open-mercato/shared/lib/di/container"),
8
+ import("@open-mercato/core/modules/customer_accounts/services/customerSessionService")
9
+ ]);
10
+ const container = await createRequestContainer();
11
+ return container.resolve("customerSessionService");
12
+ }
5
13
  async function assertSessionStillActive(input) {
6
14
  try {
7
- const [{ createRequestContainer }, { CustomerSessionService }] = await Promise.all([
8
- import("@open-mercato/shared/lib/di/container"),
9
- import("@open-mercato/core/modules/customer_accounts/services/customerSessionService")
10
- ]);
11
- const container = await createRequestContainer();
12
- const service = container.resolve("customerSessionService");
15
+ const service = await resolveSessionService();
13
16
  const session = await service.findActiveSessionForClaims(input);
14
17
  return session !== null;
15
18
  } catch {
16
19
  return false;
17
20
  }
18
21
  }
22
+ async function assertUserStillHasActiveSession(input) {
23
+ try {
24
+ const service = await resolveSessionService();
25
+ return await service.hasActiveSessionForUser(input);
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
19
30
  function readCookieFromHeader(header, name) {
20
31
  if (!header) return void 0;
21
32
  const parts = header.split(";");
@@ -88,7 +99,7 @@ async function getCustomerAuthFromRequest(req) {
88
99
  userId,
89
100
  tenantId,
90
101
  organizationId
91
- }) : true;
102
+ }) : await assertUserStillHasActiveSession({ userId, tenantId, organizationId });
92
103
  if (!stillActive) return null;
93
104
  const userState = await validateUserState(
94
105
  userId,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/customer_accounts/lib/customerAuth.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { verifyAudienceJwt, verifyJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport type { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CUSTOMER_JWT_AUDIENCE } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'\n\nexport interface CustomerAuthContext {\n sub: string\n sid: string\n type: 'customer'\n tenantId: string\n orgId: string\n email: string\n displayName: string\n customerEntityId?: string | null\n personEntityId?: string | null\n resolvedFeatures: string[]\n isPortalAdmin?: boolean\n}\n\nasync function assertSessionStillActive(input: {\n sessionId: string\n userId: string\n tenantId: string\n organizationId: string\n}): Promise<boolean> {\n try {\n const [{ createRequestContainer }, { CustomerSessionService }] = await Promise.all([\n import('@open-mercato/shared/lib/di/container'),\n import('@open-mercato/core/modules/customer_accounts/services/customerSessionService'),\n ])\n const container = await createRequestContainer()\n const service = container.resolve('customerSessionService') as InstanceType<typeof CustomerSessionService>\n const session = await service.findActiveSessionForClaims(input)\n return session !== null\n } catch {\n // Fail closed: if we cannot verify the session, treat the token as revoked to prevent\n // replay of leaked JWTs when the backend is partially degraded.\n return false\n }\n}\n\nexport function readCookieFromHeader(header: string | null | undefined, name: string): string | undefined {\n if (!header) return undefined\n const parts = header.split(';')\n for (const part of parts) {\n const trimmed = part.trim()\n if (trimmed.startsWith(`${name}=`)) {\n return trimmed.slice(name.length + 1)\n }\n }\n return undefined\n}\n\nexport type UserValidationResult =\n | { valid: false }\n | { valid: true; resolvedFeatures: string[]; isPortalAdmin: boolean }\n\nexport async function validateUserState(\n sub: string,\n tenantId: string,\n orgId: string,\n iat: unknown,\n): Promise<UserValidationResult> {\n const [{ createRequestContainer }, { CustomerUser }] = await Promise.all([\n import('@open-mercato/shared/lib/di/container'),\n import('@open-mercato/core/modules/customer_accounts/data/entities'),\n ])\n const container = await createRequestContainer()\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n const user = await findOneWithDecryption(em, CustomerUser, { id: sub }, {\n fields: ['sessionsRevokedAt', 'deletedAt', 'isActive'],\n })\n if (!user) return { valid: false }\n if (user.deletedAt) return { valid: false }\n if (!user.isActive) return { valid: false }\n if (user.sessionsRevokedAt && typeof iat === 'number' && iat * 1000 < user.sessionsRevokedAt.getTime()) {\n return { valid: false }\n }\n\n const { CustomerRbacService } = await import(\n '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\n )\n const rbac = container.resolve('customerRbacService') as InstanceType<typeof CustomerRbacService>\n const acl = await rbac.loadAcl(sub, { tenantId, organizationId: orgId })\n const resolvedFeatures = await rbac.getEffectiveFeatures(sub, {\n tenantId,\n organizationId: orgId,\n })\n return { valid: true, resolvedFeatures, isPortalAdmin: acl.isPortalAdmin }\n}\n\nexport async function getCustomerAuthFromRequest(req: Request): Promise<CustomerAuthContext | null> {\n const cookieHeader = req.headers.get('cookie') || ''\n const authHeader = (req.headers.get('authorization') || '').trim()\n\n let token: string | undefined\n if (authHeader.toLowerCase().startsWith('bearer ')) {\n token = authHeader.slice(7).trim()\n }\n if (!token) {\n const cookieValue = readCookieFromHeader(cookieHeader, 'customer_auth_token')\n if (cookieValue) {\n try {\n token = decodeURIComponent(cookieValue)\n } catch {\n // Malformed percent-encoding; use raw value\n token = cookieValue\n }\n }\n }\n if (!token) return null\n\n try {\n let payload = verifyAudienceJwt(CUSTOMER_JWT_AUDIENCE, token) as Record<string, unknown> | null\n // Legacy fallback: accept a pre-migration customer token signed with the raw JWT_SECRET, but\n // only while `verifyJwt` itself still considers it legacy \u2014 it owns the grace window (token\n // `iat` vs JWT_LEGACY_GRACE_MINUTES / JWT_LEGACY_CUTOVER_AT) and marks the payload. Trusting\n // the bare return value would also let a staff-audience token through this branch, because\n // the default `verifyJwt` path verifies against the staff-derived key.\n if (!payload) {\n const legacyPayload = verifyJwt(token) as Record<string, unknown> | null\n if (legacyPayload && legacyPayload._legacyToken === true) payload = legacyPayload\n }\n if (!payload) return null\n if (payload.type !== 'customer') return null\n const sid = typeof payload.sid === 'string' ? payload.sid : ''\n if (!sid && payload._legacyToken !== true) return null\n const userId = String(payload.sub)\n const tenantId = String(payload.tenantId)\n const organizationId = String(payload.orgId)\n const stillActive = sid\n ? await assertSessionStillActive({\n sessionId: sid,\n userId,\n tenantId,\n organizationId,\n })\n : true\n if (!stillActive) return null\n\n const userState = await validateUserState(\n userId,\n tenantId,\n organizationId,\n payload.iat,\n )\n if (!userState.valid) return null\n\n return {\n sub: userId,\n sid,\n type: 'customer',\n tenantId,\n orgId: organizationId,\n email: String(payload.email || ''),\n displayName: String(payload.displayName || ''),\n customerEntityId: payload.customerEntityId ? String(payload.customerEntityId) : null,\n personEntityId: payload.personEntityId ? String(payload.personEntityId) : null,\n resolvedFeatures: userState.resolvedFeatures,\n isPortalAdmin: userState.isPortalAdmin,\n }\n } catch {\n // Invalid or expired JWT \u2014 treat as unauthenticated\n return null\n }\n}\n\nexport async function requireCustomerAuth(req: Request): Promise<CustomerAuthContext> {\n const auth = await getCustomerAuthFromRequest(req)\n if (!auth) {\n throw NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n return auth\n}\n\nexport async function requireCustomerFeature(\n auth: CustomerAuthContext,\n features: string[],\n rbac: CustomerRbacService,\n): Promise<void> {\n if (!features.length) return\n const ok = await rbac.userHasAllFeatures(\n auth.sub,\n features,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n if (!ok) {\n throw NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { verifyAudienceJwt, verifyJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport type { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CUSTOMER_JWT_AUDIENCE } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'\n\nexport interface CustomerAuthContext {\n sub: string\n sid: string\n type: 'customer'\n tenantId: string\n orgId: string\n email: string\n displayName: string\n customerEntityId?: string | null\n personEntityId?: string | null\n resolvedFeatures: string[]\n isPortalAdmin?: boolean\n}\n\nasync function resolveSessionService(): Promise<\n InstanceType<typeof import('@open-mercato/core/modules/customer_accounts/services/customerSessionService').CustomerSessionService>\n> {\n const [{ createRequestContainer }, { CustomerSessionService }] = await Promise.all([\n import('@open-mercato/shared/lib/di/container'),\n import('@open-mercato/core/modules/customer_accounts/services/customerSessionService'),\n ])\n const container = await createRequestContainer()\n return container.resolve('customerSessionService') as InstanceType<typeof CustomerSessionService>\n}\n\nasync function assertSessionStillActive(input: {\n sessionId: string\n userId: string\n tenantId: string\n organizationId: string\n}): Promise<boolean> {\n try {\n const service = await resolveSessionService()\n const session = await service.findActiveSessionForClaims(input)\n return session !== null\n } catch {\n // Fail closed: if we cannot verify the session, treat the token as revoked to prevent\n // replay of leaked JWTs when the backend is partially degraded.\n return false\n }\n}\n\n/**\n * Liveness re-check for a legacy token that carries no `sid` claim. It cannot name the session it\n * was issued for, so the check falls back to the strongest available statement: the customer must\n * still be signed in somewhere. Once every session is revoked \u2014 logout, per-device revoke, admin\n * action \u2014 the token stops authenticating instead of surviving until its own expiry.\n */\nasync function assertUserStillHasActiveSession(input: {\n userId: string\n tenantId: string\n organizationId: string\n}): Promise<boolean> {\n try {\n const service = await resolveSessionService()\n return await service.hasActiveSessionForUser(input)\n } catch {\n // Same fail-closed contract as the sid-bound check above.\n return false\n }\n}\n\nexport function readCookieFromHeader(header: string | null | undefined, name: string): string | undefined {\n if (!header) return undefined\n const parts = header.split(';')\n for (const part of parts) {\n const trimmed = part.trim()\n if (trimmed.startsWith(`${name}=`)) {\n return trimmed.slice(name.length + 1)\n }\n }\n return undefined\n}\n\nexport type UserValidationResult =\n | { valid: false }\n | { valid: true; resolvedFeatures: string[]; isPortalAdmin: boolean }\n\nexport async function validateUserState(\n sub: string,\n tenantId: string,\n orgId: string,\n iat: unknown,\n): Promise<UserValidationResult> {\n const [{ createRequestContainer }, { CustomerUser }] = await Promise.all([\n import('@open-mercato/shared/lib/di/container'),\n import('@open-mercato/core/modules/customer_accounts/data/entities'),\n ])\n const container = await createRequestContainer()\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n const user = await findOneWithDecryption(em, CustomerUser, { id: sub }, {\n fields: ['sessionsRevokedAt', 'deletedAt', 'isActive'],\n })\n if (!user) return { valid: false }\n if (user.deletedAt) return { valid: false }\n if (!user.isActive) return { valid: false }\n if (user.sessionsRevokedAt && typeof iat === 'number' && iat * 1000 < user.sessionsRevokedAt.getTime()) {\n return { valid: false }\n }\n\n const { CustomerRbacService } = await import(\n '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\n )\n const rbac = container.resolve('customerRbacService') as InstanceType<typeof CustomerRbacService>\n const acl = await rbac.loadAcl(sub, { tenantId, organizationId: orgId })\n const resolvedFeatures = await rbac.getEffectiveFeatures(sub, {\n tenantId,\n organizationId: orgId,\n })\n return { valid: true, resolvedFeatures, isPortalAdmin: acl.isPortalAdmin }\n}\n\nexport async function getCustomerAuthFromRequest(req: Request): Promise<CustomerAuthContext | null> {\n const cookieHeader = req.headers.get('cookie') || ''\n const authHeader = (req.headers.get('authorization') || '').trim()\n\n let token: string | undefined\n if (authHeader.toLowerCase().startsWith('bearer ')) {\n token = authHeader.slice(7).trim()\n }\n if (!token) {\n const cookieValue = readCookieFromHeader(cookieHeader, 'customer_auth_token')\n if (cookieValue) {\n try {\n token = decodeURIComponent(cookieValue)\n } catch {\n // Malformed percent-encoding; use raw value\n token = cookieValue\n }\n }\n }\n if (!token) return null\n\n try {\n let payload = verifyAudienceJwt(CUSTOMER_JWT_AUDIENCE, token) as Record<string, unknown> | null\n // Legacy fallback: accept a pre-migration customer token signed with the raw JWT_SECRET, but\n // only while `verifyJwt` itself still considers it legacy \u2014 it owns the grace window (token\n // `iat` vs JWT_LEGACY_GRACE_MINUTES / JWT_LEGACY_CUTOVER_AT) and marks the payload. Trusting\n // the bare return value would also let a staff-audience token through this branch, because\n // the default `verifyJwt` path verifies against the staff-derived key.\n if (!payload) {\n const legacyPayload = verifyJwt(token) as Record<string, unknown> | null\n if (legacyPayload && legacyPayload._legacyToken === true) payload = legacyPayload\n }\n if (!payload) return null\n if (payload.type !== 'customer') return null\n const sid = typeof payload.sid === 'string' ? payload.sid : ''\n if (!sid && payload._legacyToken !== true) return null\n const userId = String(payload.sub)\n const tenantId = String(payload.tenantId)\n const organizationId = String(payload.orgId)\n // Every accepted token is re-checked for liveness, exactly like the SSR path does \u2014 a token\n // without a `sid` is checked against the customer's remaining sessions rather than skipped,\n // so session revocation is never structurally unreachable on the API path.\n const stillActive = sid\n ? await assertSessionStillActive({\n sessionId: sid,\n userId,\n tenantId,\n organizationId,\n })\n : await assertUserStillHasActiveSession({ userId, tenantId, organizationId })\n if (!stillActive) return null\n\n const userState = await validateUserState(\n userId,\n tenantId,\n organizationId,\n payload.iat,\n )\n if (!userState.valid) return null\n\n return {\n sub: userId,\n sid,\n type: 'customer',\n tenantId,\n orgId: organizationId,\n email: String(payload.email || ''),\n displayName: String(payload.displayName || ''),\n customerEntityId: payload.customerEntityId ? String(payload.customerEntityId) : null,\n personEntityId: payload.personEntityId ? String(payload.personEntityId) : null,\n resolvedFeatures: userState.resolvedFeatures,\n isPortalAdmin: userState.isPortalAdmin,\n }\n } catch {\n // Invalid or expired JWT \u2014 treat as unauthenticated\n return null\n }\n}\n\nexport async function requireCustomerAuth(req: Request): Promise<CustomerAuthContext> {\n const auth = await getCustomerAuthFromRequest(req)\n if (!auth) {\n throw NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n return auth\n}\n\nexport async function requireCustomerFeature(\n auth: CustomerAuthContext,\n features: string[],\n rbac: CustomerRbacService,\n): Promise<void> {\n if (!features.length) return\n const ok = await rbac.userHasAllFeatures(\n auth.sub,\n features,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n if (!ok) {\n throw NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -98,6 +98,29 @@ class CustomerSessionService {
98
98
  if (session.expiresAt.getTime() < Date.now()) return null;
99
99
  return session;
100
100
  }
101
+ /**
102
+ * Liveness probe for tokens that carry no `sid` claim — the pre-migration customer JWTs the
103
+ * legacy raw-secret fallback still admits during a key rotation. Such a token cannot name the
104
+ * session it was issued for, so the strongest statement we can make about it is whether the
105
+ * customer is still signed in anywhere. A customer whose sessions have all been revoked
106
+ * (logout, per-device revoke, admin action) has none, and the token must stop working.
107
+ *
108
+ * Existence is the whole answer, so this reads no encrypted column and needs no decryption
109
+ * round-trip on the authentication path — the same reason `findActiveSessionForClaims` next to
110
+ * it queries directly. Scope comes from the owning user, since sessions carry no scope columns.
111
+ */
112
+ async hasActiveSessionForUser(input) {
113
+ const session = await this.em.findOne(CustomerUserSession, {
114
+ user: {
115
+ id: input.userId,
116
+ tenantId: input.tenantId,
117
+ organizationId: input.organizationId
118
+ },
119
+ deletedAt: null,
120
+ expiresAt: { $gt: /* @__PURE__ */ new Date() }
121
+ });
122
+ return session !== null;
123
+ }
101
124
  async revokeSession(sessionId) {
102
125
  await this.em.nativeUpdate(CustomerUserSession, { id: sessionId }, { deletedAt: /* @__PURE__ */ new Date() });
103
126
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/customer_accounts/services/customerSessionService.ts"],
4
- "sourcesContent": ["import { EntityManager } from '@mikro-orm/postgresql'\nimport { CustomerUser, CustomerUserSession } from '@open-mercato/core/modules/customer_accounts/data/entities'\nimport { generateSecureToken, hashToken } from '@open-mercato/core/modules/customer_accounts/lib/tokenGenerator'\nimport { signAudienceJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\nexport const CUSTOMER_JWT_AUDIENCE = 'customer'\nconst CUSTOMER_JWT_TTL_SECONDS = 60 * 60 * 8\n\nconst DEFAULT_SESSION_TTL_DAYS = 30\nconst DEFAULT_MAX_SESSIONS_PER_USER = 5\n\nfunction resolveMaxSessionsPerUser(): number {\n const raw = process.env.MAX_CUSTOMER_SESSIONS_PER_USER\n if (!raw) return DEFAULT_MAX_SESSIONS_PER_USER\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_SESSIONS_PER_USER\n return Math.floor(parsed)\n}\n\nexport class CustomerSessionService {\n constructor(private em: EntityManager) {}\n\n async createSession(\n user: CustomerUser,\n resolvedFeatures: string[],\n ip?: string | null,\n userAgent?: string | null,\n ): Promise<{ rawToken: string; jwt: string; session: CustomerUserSession }> {\n const rawToken = generateSecureToken()\n const tokenHash = hashToken(rawToken)\n const days = Number(process.env.CUSTOMER_SESSION_TTL_DAYS || DEFAULT_SESSION_TTL_DAYS)\n const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)\n\n await this.enforceSessionCap(user.id, user.tenantId, user.organizationId)\n\n const session = this.em.create(CustomerUserSession, {\n user,\n tokenHash,\n ipAddress: ip || null,\n userAgent: userAgent || null,\n expiresAt,\n lastUsedAt: new Date(),\n createdAt: new Date(),\n } as any) as CustomerUserSession\n await this.em.persist(session).flush()\n\n const jwt = this.signCustomerJwt(user, resolvedFeatures, session.id)\n\n return { rawToken, jwt, session }\n }\n\n signCustomerJwt(user: CustomerUser, resolvedFeatures: string[], sessionId: string): string {\n return signAudienceJwt(\n CUSTOMER_JWT_AUDIENCE,\n {\n sub: user.id,\n sid: sessionId,\n type: 'customer',\n tenantId: user.tenantId,\n orgId: user.organizationId,\n email: user.email,\n displayName: user.displayName || '',\n customerEntityId: user.customerEntityId || null,\n personEntityId: user.personEntityId || null,\n resolvedFeatures,\n },\n CUSTOMER_JWT_TTL_SECONDS,\n )\n }\n\n async findByToken(rawToken: string, tenantId?: string): Promise<CustomerUserSession | null> {\n const tokenHash = hashToken(rawToken)\n const session = await this.em.findOne(CustomerUserSession, {\n tokenHash,\n deletedAt: null,\n }, { populate: ['user'] })\n if (!session) return null\n if (session.expiresAt.getTime() < Date.now()) return null\n const user = session.user as CustomerUser\n if (tenantId && user?.tenantId !== tenantId) return null\n return session\n }\n\n async refreshSession(\n rawToken: string,\n resolvedFeatures: string[],\n ): Promise<{ jwt: string; user: CustomerUser } | null> {\n const session = await this.findByToken(rawToken)\n if (!session) return null\n const user = session.user as CustomerUser\n if (!user || user.deletedAt || !user.isActive) return null\n\n await this.em.nativeUpdate(CustomerUserSession, { id: session.id }, { lastUsedAt: new Date() })\n const jwt = this.signCustomerJwt(user, resolvedFeatures, session.id)\n return { jwt, user }\n }\n\n async findActiveSessionById(sessionId: string): Promise<CustomerUserSession | null> {\n const session = await this.em.findOne(CustomerUserSession, {\n id: sessionId,\n deletedAt: null,\n })\n if (!session) return null\n if (session.expiresAt.getTime() < Date.now()) return null\n return session\n }\n\n async findActiveSessionForClaims(input: {\n sessionId: string\n userId: string\n tenantId: string\n organizationId: string\n }): Promise<CustomerUserSession | null> {\n const session = await this.em.findOne(CustomerUserSession, {\n id: input.sessionId,\n user: {\n id: input.userId,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n },\n deletedAt: null,\n })\n if (!session) return null\n if (session.expiresAt.getTime() < Date.now()) return null\n return session\n }\n\n async revokeSession(sessionId: string): Promise<void> {\n await this.em.nativeUpdate(CustomerUserSession, { id: sessionId }, { deletedAt: new Date() })\n }\n\n private async enforceSessionCap(\n userId: string,\n tenantId: string,\n organizationId: string,\n ): Promise<void> {\n const cap = resolveMaxSessionsPerUser()\n const existing = await findWithDecryption(\n this.em,\n CustomerUserSession,\n {\n user: userId as any,\n deletedAt: null,\n expiresAt: { $gt: new Date() },\n },\n { orderBy: { createdAt: 'asc' } },\n { tenantId, organizationId },\n )\n const toRevoke = existing.length - (cap - 1)\n if (toRevoke <= 0) return\n const oldestIds = existing.slice(0, toRevoke).map((s) => s.id)\n await this.em.nativeUpdate(\n CustomerUserSession,\n { id: { $in: oldestIds } },\n { deletedAt: new Date() },\n )\n }\n\n async revokeAllUserSessions(userId: string, em?: EntityManager): Promise<void> {\n const now = new Date()\n const target = em ?? this.em\n await target.nativeUpdate(\n CustomerUserSession,\n { user: userId as any, deletedAt: null },\n { deletedAt: now },\n )\n await target.nativeUpdate(\n CustomerUser,\n { id: userId },\n { sessionsRevokedAt: now },\n )\n }\n}\n"],
5
- "mappings": "AACA,SAAS,cAAc,2BAA2B;AAClD,SAAS,qBAAqB,iBAAiB;AAC/C,SAAS,uBAAuB;AAChC,SAAS,0BAA0B;AAE5B,MAAM,wBAAwB;AACrC,MAAM,2BAA2B,KAAK,KAAK;AAE3C,MAAM,2BAA2B;AACjC,MAAM,gCAAgC;AAEtC,SAAS,4BAAoC;AAC3C,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,SAAS,OAAO,GAAG;AACzB,MAAI,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,EAAG,QAAO;AACpD,SAAO,KAAK,MAAM,MAAM;AAC1B;AAEO,MAAM,uBAAuB;AAAA,EAClC,YAAoB,IAAmB;AAAnB;AAAA,EAAoB;AAAA,EAExC,MAAM,cACJ,MACA,kBACA,IACA,WAC0E;AAC1E,UAAM,WAAW,oBAAoB;AACrC,UAAM,YAAY,UAAU,QAAQ;AACpC,UAAM,OAAO,OAAO,QAAQ,IAAI,6BAA6B,wBAAwB;AACrF,UAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,KAAK,GAAI;AAElE,UAAM,KAAK,kBAAkB,KAAK,IAAI,KAAK,UAAU,KAAK,cAAc;AAExE,UAAM,UAAU,KAAK,GAAG,OAAO,qBAAqB;AAAA,MAClD;AAAA,MACA;AAAA,MACA,WAAW,MAAM;AAAA,MACjB,WAAW,aAAa;AAAA,MACxB;AAAA,MACA,YAAY,oBAAI,KAAK;AAAA,MACrB,WAAW,oBAAI,KAAK;AAAA,IACtB,CAAQ;AACR,UAAM,KAAK,GAAG,QAAQ,OAAO,EAAE,MAAM;AAErC,UAAM,MAAM,KAAK,gBAAgB,MAAM,kBAAkB,QAAQ,EAAE;AAEnE,WAAO,EAAE,UAAU,KAAK,QAAQ;AAAA,EAClC;AAAA,EAEA,gBAAgB,MAAoB,kBAA4B,WAA2B;AACzF,WAAO;AAAA,MACL;AAAA,MACA;AAAA,QACE,KAAK,KAAK;AAAA,QACV,KAAK;AAAA,QACL,MAAM;AAAA,QACN,UAAU,KAAK;AAAA,QACf,OAAO,KAAK;AAAA,QACZ,OAAO,KAAK;AAAA,QACZ,aAAa,KAAK,eAAe;AAAA,QACjC,kBAAkB,KAAK,oBAAoB;AAAA,QAC3C,gBAAgB,KAAK,kBAAkB;AAAA,QACvC;AAAA,MACF;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,YAAY,UAAkB,UAAwD;AAC1F,UAAM,YAAY,UAAU,QAAQ;AACpC,UAAM,UAAU,MAAM,KAAK,GAAG,QAAQ,qBAAqB;AAAA,MACzD;AAAA,MACA,WAAW;AAAA,IACb,GAAG,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC;AACzB,QAAI,CAAC,QAAS,QAAO;AACrB,QAAI,QAAQ,UAAU,QAAQ,IAAI,KAAK,IAAI,EAAG,QAAO;AACrD,UAAM,OAAO,QAAQ;AACrB,QAAI,YAAY,MAAM,aAAa,SAAU,QAAO;AACpD,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,eACJ,UACA,kBACqD;AACrD,UAAM,UAAU,MAAM,KAAK,YAAY,QAAQ;AAC/C,QAAI,CAAC,QAAS,QAAO;AACrB,UAAM,OAAO,QAAQ;AACrB,QAAI,CAAC,QAAQ,KAAK,aAAa,CAAC,KAAK,SAAU,QAAO;AAEtD,UAAM,KAAK,GAAG,aAAa,qBAAqB,EAAE,IAAI,QAAQ,GAAG,GAAG,EAAE,YAAY,oBAAI,KAAK,EAAE,CAAC;AAC9F,UAAM,MAAM,KAAK,gBAAgB,MAAM,kBAAkB,QAAQ,EAAE;AACnE,WAAO,EAAE,KAAK,KAAK;AAAA,EACrB;AAAA,EAEA,MAAM,sBAAsB,WAAwD;AAClF,UAAM,UAAU,MAAM,KAAK,GAAG,QAAQ,qBAAqB;AAAA,MACzD,IAAI;AAAA,MACJ,WAAW;AAAA,IACb,CAAC;AACD,QAAI,CAAC,QAAS,QAAO;AACrB,QAAI,QAAQ,UAAU,QAAQ,IAAI,KAAK,IAAI,EAAG,QAAO;AACrD,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,2BAA2B,OAKO;AACtC,UAAM,UAAU,MAAM,KAAK,GAAG,QAAQ,qBAAqB;AAAA,MACzD,IAAI,MAAM;AAAA,MACV,MAAM;AAAA,QACJ,IAAI,MAAM;AAAA,QACV,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,MACxB;AAAA,MACA,WAAW;AAAA,IACb,CAAC;AACD,QAAI,CAAC,QAAS,QAAO;AACrB,QAAI,QAAQ,UAAU,QAAQ,IAAI,KAAK,IAAI,EAAG,QAAO;AACrD,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,cAAc,WAAkC;AACpD,UAAM,KAAK,GAAG,aAAa,qBAAqB,EAAE,IAAI,UAAU,GAAG,EAAE,WAAW,oBAAI,KAAK,EAAE,CAAC;AAAA,EAC9F;AAAA,EAEA,MAAc,kBACZ,QACA,UACA,gBACe;AACf,UAAM,MAAM,0BAA0B;AACtC,UAAM,WAAW,MAAM;AAAA,MACrB,KAAK;AAAA,MACL;AAAA,MACA;AAAA,QACE,MAAM;AAAA,QACN,WAAW;AAAA,QACX,WAAW,EAAE,KAAK,oBAAI,KAAK,EAAE;AAAA,MAC/B;AAAA,MACA,EAAE,SAAS,EAAE,WAAW,MAAM,EAAE;AAAA,MAChC,EAAE,UAAU,eAAe;AAAA,IAC7B;AACA,UAAM,WAAW,SAAS,UAAU,MAAM;AAC1C,QAAI,YAAY,EAAG;AACnB,UAAM,YAAY,SAAS,MAAM,GAAG,QAAQ,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE;AAC7D,UAAM,KAAK,GAAG;AAAA,MACZ;AAAA,MACA,EAAE,IAAI,EAAE,KAAK,UAAU,EAAE;AAAA,MACzB,EAAE,WAAW,oBAAI,KAAK,EAAE;AAAA,IAC1B;AAAA,EACF;AAAA,EAEA,MAAM,sBAAsB,QAAgB,IAAmC;AAC7E,UAAM,MAAM,oBAAI,KAAK;AACrB,UAAM,SAAS,MAAM,KAAK;AAC1B,UAAM,OAAO;AAAA,MACX;AAAA,MACA,EAAE,MAAM,QAAe,WAAW,KAAK;AAAA,MACvC,EAAE,WAAW,IAAI;AAAA,IACnB;AACA,UAAM,OAAO;AAAA,MACX;AAAA,MACA,EAAE,IAAI,OAAO;AAAA,MACb,EAAE,mBAAmB,IAAI;AAAA,IAC3B;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { EntityManager } from '@mikro-orm/postgresql'\nimport { CustomerUser, CustomerUserSession } from '@open-mercato/core/modules/customer_accounts/data/entities'\nimport { generateSecureToken, hashToken } from '@open-mercato/core/modules/customer_accounts/lib/tokenGenerator'\nimport { signAudienceJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\nexport const CUSTOMER_JWT_AUDIENCE = 'customer'\nconst CUSTOMER_JWT_TTL_SECONDS = 60 * 60 * 8\n\nconst DEFAULT_SESSION_TTL_DAYS = 30\nconst DEFAULT_MAX_SESSIONS_PER_USER = 5\n\nfunction resolveMaxSessionsPerUser(): number {\n const raw = process.env.MAX_CUSTOMER_SESSIONS_PER_USER\n if (!raw) return DEFAULT_MAX_SESSIONS_PER_USER\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_SESSIONS_PER_USER\n return Math.floor(parsed)\n}\n\nexport class CustomerSessionService {\n constructor(private em: EntityManager) {}\n\n async createSession(\n user: CustomerUser,\n resolvedFeatures: string[],\n ip?: string | null,\n userAgent?: string | null,\n ): Promise<{ rawToken: string; jwt: string; session: CustomerUserSession }> {\n const rawToken = generateSecureToken()\n const tokenHash = hashToken(rawToken)\n const days = Number(process.env.CUSTOMER_SESSION_TTL_DAYS || DEFAULT_SESSION_TTL_DAYS)\n const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)\n\n await this.enforceSessionCap(user.id, user.tenantId, user.organizationId)\n\n const session = this.em.create(CustomerUserSession, {\n user,\n tokenHash,\n ipAddress: ip || null,\n userAgent: userAgent || null,\n expiresAt,\n lastUsedAt: new Date(),\n createdAt: new Date(),\n } as any) as CustomerUserSession\n await this.em.persist(session).flush()\n\n const jwt = this.signCustomerJwt(user, resolvedFeatures, session.id)\n\n return { rawToken, jwt, session }\n }\n\n signCustomerJwt(user: CustomerUser, resolvedFeatures: string[], sessionId: string): string {\n return signAudienceJwt(\n CUSTOMER_JWT_AUDIENCE,\n {\n sub: user.id,\n sid: sessionId,\n type: 'customer',\n tenantId: user.tenantId,\n orgId: user.organizationId,\n email: user.email,\n displayName: user.displayName || '',\n customerEntityId: user.customerEntityId || null,\n personEntityId: user.personEntityId || null,\n resolvedFeatures,\n },\n CUSTOMER_JWT_TTL_SECONDS,\n )\n }\n\n async findByToken(rawToken: string, tenantId?: string): Promise<CustomerUserSession | null> {\n const tokenHash = hashToken(rawToken)\n const session = await this.em.findOne(CustomerUserSession, {\n tokenHash,\n deletedAt: null,\n }, { populate: ['user'] })\n if (!session) return null\n if (session.expiresAt.getTime() < Date.now()) return null\n const user = session.user as CustomerUser\n if (tenantId && user?.tenantId !== tenantId) return null\n return session\n }\n\n async refreshSession(\n rawToken: string,\n resolvedFeatures: string[],\n ): Promise<{ jwt: string; user: CustomerUser } | null> {\n const session = await this.findByToken(rawToken)\n if (!session) return null\n const user = session.user as CustomerUser\n if (!user || user.deletedAt || !user.isActive) return null\n\n await this.em.nativeUpdate(CustomerUserSession, { id: session.id }, { lastUsedAt: new Date() })\n const jwt = this.signCustomerJwt(user, resolvedFeatures, session.id)\n return { jwt, user }\n }\n\n async findActiveSessionById(sessionId: string): Promise<CustomerUserSession | null> {\n const session = await this.em.findOne(CustomerUserSession, {\n id: sessionId,\n deletedAt: null,\n })\n if (!session) return null\n if (session.expiresAt.getTime() < Date.now()) return null\n return session\n }\n\n async findActiveSessionForClaims(input: {\n sessionId: string\n userId: string\n tenantId: string\n organizationId: string\n }): Promise<CustomerUserSession | null> {\n const session = await this.em.findOne(CustomerUserSession, {\n id: input.sessionId,\n user: {\n id: input.userId,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n },\n deletedAt: null,\n })\n if (!session) return null\n if (session.expiresAt.getTime() < Date.now()) return null\n return session\n }\n\n /**\n * Liveness probe for tokens that carry no `sid` claim \u2014 the pre-migration customer JWTs the\n * legacy raw-secret fallback still admits during a key rotation. Such a token cannot name the\n * session it was issued for, so the strongest statement we can make about it is whether the\n * customer is still signed in anywhere. A customer whose sessions have all been revoked\n * (logout, per-device revoke, admin action) has none, and the token must stop working.\n *\n * Existence is the whole answer, so this reads no encrypted column and needs no decryption\n * round-trip on the authentication path \u2014 the same reason `findActiveSessionForClaims` next to\n * it queries directly. Scope comes from the owning user, since sessions carry no scope columns.\n */\n async hasActiveSessionForUser(input: {\n userId: string\n tenantId: string\n organizationId: string\n }): Promise<boolean> {\n const session = await this.em.findOne(CustomerUserSession, {\n user: {\n id: input.userId,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n },\n deletedAt: null,\n expiresAt: { $gt: new Date() },\n })\n return session !== null\n }\n\n async revokeSession(sessionId: string): Promise<void> {\n await this.em.nativeUpdate(CustomerUserSession, { id: sessionId }, { deletedAt: new Date() })\n }\n\n private async enforceSessionCap(\n userId: string,\n tenantId: string,\n organizationId: string,\n ): Promise<void> {\n const cap = resolveMaxSessionsPerUser()\n const existing = await findWithDecryption(\n this.em,\n CustomerUserSession,\n {\n user: userId as any,\n deletedAt: null,\n expiresAt: { $gt: new Date() },\n },\n { orderBy: { createdAt: 'asc' } },\n { tenantId, organizationId },\n )\n const toRevoke = existing.length - (cap - 1)\n if (toRevoke <= 0) return\n const oldestIds = existing.slice(0, toRevoke).map((s) => s.id)\n await this.em.nativeUpdate(\n CustomerUserSession,\n { id: { $in: oldestIds } },\n { deletedAt: new Date() },\n )\n }\n\n async revokeAllUserSessions(userId: string, em?: EntityManager): Promise<void> {\n const now = new Date()\n const target = em ?? this.em\n await target.nativeUpdate(\n CustomerUserSession,\n { user: userId as any, deletedAt: null },\n { deletedAt: now },\n )\n await target.nativeUpdate(\n CustomerUser,\n { id: userId },\n { sessionsRevokedAt: now },\n )\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }