@open-mercato/core 0.10.1-develop.7796.1.f2868e5c62 → 0.10.1-develop.7797.1.f0365b4031

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.
@@ -1,4 +1,4 @@
1
- [build:core] found 6214 entry points
1
+ [build:core] found 6215 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 271 entry points
4
4
  [build:core:generated] built successfully
@@ -6,6 +6,7 @@ import { buildSafeRedirectResponse, resolveTrustedRedirectBase } from "@open-mer
6
6
  import { sanitizeRedirectPath } from "@open-mercato/core/modules/auth/lib/safeRedirect";
7
7
  import { resolveAutoLoginCredentials } from "@open-mercato/core/modules/auth/lib/autologin";
8
8
  import { emitAuthEvent } from "@open-mercato/core/modules/auth/events";
9
+ import { isAuthSessionRejectedError } from "@open-mercato/core/modules/auth/lib/sessionEligibility";
9
10
  import { isStaffTenantActive } from "@open-mercato/core/modules/auth/lib/tenantEligibility";
10
11
  const metadata = { requireAuth: false };
11
12
  const accessTokenMaxAgeSeconds = 60 * 60 * 8;
@@ -41,7 +42,12 @@ async function GET(req) {
41
42
  const roles = await auth.getUserRoles(user, resolvedTenantId);
42
43
  await auth.updateLastLoginAt(user);
43
44
  const expiresAt = new Date(Date.now() + accessTokenMaxAgeSeconds * 1e3);
44
- const { session } = await auth.createSession(user, expiresAt);
45
+ const createdSession = await auth.createSession(user, expiresAt).catch((error) => {
46
+ if (!isAuthSessionRejectedError(error)) throw error;
47
+ return null;
48
+ });
49
+ if (!createdSession) return buildSafeRedirectResponse(req, "/login");
50
+ const { session } = createdSession;
45
51
  const token = signJwt({
46
52
  sub: String(user.id),
47
53
  sid: String(session.id),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/auth/api/autologin.ts"],
4
- "sourcesContent": ["import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { buildSafeRedirectResponse, resolveTrustedRedirectBase } from '@open-mercato/core/modules/auth/lib/requestRedirect'\nimport { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'\nimport { resolveAutoLoginCredentials } from '@open-mercato/core/modules/auth/lib/autologin'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\nimport { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'\n\nexport const metadata = { requireAuth: false }\n\nconst accessTokenMaxAgeSeconds = 60 * 60 * 8\nconst logger = createLogger('auth').child({ component: 'autologin' })\n\n// Demo-only convenience: when OM_AUTOLOGIN_* credentials are configured, sign the\n// visitor in with those credentials and redirect into the app. Gated entirely\n// behind env vars \u2014 the homepage only routes here when they are set. On any\n// failure (feature off, bad credentials) it falls back to the manual login form\n// so a misconfigured demo can never loop.\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const baseUrl = resolveTrustedRedirectBase(req) ?? url.origin\n const redirectTo = sanitizeRedirectPath(url.searchParams.get('redirect'), baseUrl, '/backend')\n\n const credentials = resolveAutoLoginCredentials()\n if (!credentials) {\n return buildSafeRedirectResponse(req, '/login')\n }\n\n // Already signed in \u2014 go straight to the app instead of re-issuing a session.\n const existing = await getAuthFromRequest(req)\n if (existing) {\n return buildSafeRedirectResponse(req, redirectTo)\n }\n\n const container = await createRequestContainer()\n const auth = container.resolve<AuthService>('authService')\n\n let user = null\n if (credentials.tenantId) {\n user = await auth.findUserByEmailAndTenant(credentials.email, credentials.tenantId)\n } else {\n const users = await auth.findUsersByEmail(credentials.email)\n user = users.length === 1 ? users[0] : null\n }\n\n const ok = await auth.verifyPassword(user, credentials.password)\n const tenantActive = user ? await isStaffTenantActive(container.resolve('em'), user.tenantId) : false\n if (!user || !ok || !tenantActive) {\n // Misconfigured demo credentials \u2014 never loop; drop to the manual form.\n logger.warn('OM_AUTOLOGIN credentials did not resolve to a single valid user; falling back to login')\n return buildSafeRedirectResponse(req, '/login')\n }\n\n const resolvedTenantId = credentials.tenantId ?? (user.tenantId ? String(user.tenantId) : null)\n const roles = await auth.getUserRoles(user, resolvedTenantId)\n await auth.updateLastLoginAt(user)\n const expiresAt = new Date(Date.now() + accessTokenMaxAgeSeconds * 1000)\n const { session } = await auth.createSession(user, expiresAt)\n const token = signJwt({\n sub: String(user.id),\n sid: String(session.id),\n tenantId: resolvedTenantId,\n orgId: user.organizationId ? String(user.organizationId) : null,\n email: user.email,\n roles,\n })\n void emitAuthEvent('auth.login.success', {\n id: String(user.id),\n email: user.email,\n tenantId: resolvedTenantId,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n }).catch(() => undefined)\n\n const res = buildSafeRedirectResponse(req, redirectTo)\n res.cookies.set('auth_token', token, {\n httpOnly: true,\n path: '/',\n sameSite: 'lax',\n secure: process.env.NODE_ENV === 'production',\n maxAge: accessTokenMaxAgeSeconds,\n })\n return res\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Demo autologin',\n methods: {\n GET: {\n summary: 'Auto sign-in using env-configured demo credentials',\n description:\n 'When OM_AUTOLOGIN_EMAIL / OM_AUTOLOGIN_PASSWORD are configured, signs the visitor in with those credentials and redirects into the app. Intended for single-tenant demo instances only. Falls back to the login page when disabled or misconfigured.',\n responses: [\n { status: 307, description: 'Redirect into the app (or to /login on failure)', mediaType: 'text/html' },\n ],\n },\n },\n}\n"],
5
- "mappings": "AACA,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAE7B,SAAS,eAAe;AACxB,SAAS,0BAA0B;AACnC,SAAS,2BAA2B,kCAAkC;AACtE,SAAS,4BAA4B;AACrC,SAAS,mCAAmC;AAC5C,SAAS,qBAAqB;AAC9B,SAAS,2BAA2B;AAE7B,MAAM,WAAW,EAAE,aAAa,MAAM;AAE7C,MAAM,2BAA2B,KAAK,KAAK;AAC3C,MAAM,SAAS,aAAa,MAAM,EAAE,MAAM,EAAE,WAAW,YAAY,CAAC;AAOpE,eAAsB,IAAI,KAAc;AACtC,QAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,QAAM,UAAU,2BAA2B,GAAG,KAAK,IAAI;AACvD,QAAM,aAAa,qBAAqB,IAAI,aAAa,IAAI,UAAU,GAAG,SAAS,UAAU;AAE7F,QAAM,cAAc,4BAA4B;AAChD,MAAI,CAAC,aAAa;AAChB,WAAO,0BAA0B,KAAK,QAAQ;AAAA,EAChD;AAGA,QAAM,WAAW,MAAM,mBAAmB,GAAG;AAC7C,MAAI,UAAU;AACZ,WAAO,0BAA0B,KAAK,UAAU;AAAA,EAClD;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,OAAO,UAAU,QAAqB,aAAa;AAEzD,MAAI,OAAO;AACX,MAAI,YAAY,UAAU;AACxB,WAAO,MAAM,KAAK,yBAAyB,YAAY,OAAO,YAAY,QAAQ;AAAA,EACpF,OAAO;AACL,UAAM,QAAQ,MAAM,KAAK,iBAAiB,YAAY,KAAK;AAC3D,WAAO,MAAM,WAAW,IAAI,MAAM,CAAC,IAAI;AAAA,EACzC;AAEA,QAAM,KAAK,MAAM,KAAK,eAAe,MAAM,YAAY,QAAQ;AAC/D,QAAM,eAAe,OAAO,MAAM,oBAAoB,UAAU,QAAQ,IAAI,GAAG,KAAK,QAAQ,IAAI;AAChG,MAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,cAAc;AAEjC,WAAO,KAAK,wFAAwF;AACpG,WAAO,0BAA0B,KAAK,QAAQ;AAAA,EAChD;AAEA,QAAM,mBAAmB,YAAY,aAAa,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI;AAC1F,QAAM,QAAQ,MAAM,KAAK,aAAa,MAAM,gBAAgB;AAC5D,QAAM,KAAK,kBAAkB,IAAI;AACjC,QAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,2BAA2B,GAAI;AACvE,QAAM,EAAE,QAAQ,IAAI,MAAM,KAAK,cAAc,MAAM,SAAS;AAC5D,QAAM,QAAQ,QAAQ;AAAA,IACpB,KAAK,OAAO,KAAK,EAAE;AAAA,IACnB,KAAK,OAAO,QAAQ,EAAE;AAAA,IACtB,UAAU;AAAA,IACV,OAAO,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,IAC3D,OAAO,KAAK;AAAA,IACZ;AAAA,EACF,CAAC;AACD,OAAK,cAAc,sBAAsB;AAAA,IACvC,IAAI,OAAO,KAAK,EAAE;AAAA,IAClB,OAAO,KAAK;AAAA,IACZ,UAAU;AAAA,IACV,gBAAgB,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,EACtE,CAAC,EAAE,MAAM,MAAM,MAAS;AAExB,QAAM,MAAM,0BAA0B,KAAK,UAAU;AACrD,MAAI,QAAQ,IAAI,cAAc,OAAO;AAAA,IACnC,UAAU;AAAA,IACV,MAAM;AAAA,IACN,UAAU;AAAA,IACV,QAAQ,QAAQ,IAAI,aAAa;AAAA,IACjC,QAAQ;AAAA,EACV,CAAC;AACD,SAAO;AACT;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aACE;AAAA,MACF,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,mDAAmD,WAAW,YAAY;AAAA,MACxG;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { buildSafeRedirectResponse, resolveTrustedRedirectBase } from '@open-mercato/core/modules/auth/lib/requestRedirect'\nimport { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'\nimport { resolveAutoLoginCredentials } from '@open-mercato/core/modules/auth/lib/autologin'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\nimport { isAuthSessionRejectedError } from '@open-mercato/core/modules/auth/lib/sessionEligibility'\nimport { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'\n\nexport const metadata = { requireAuth: false }\n\nconst accessTokenMaxAgeSeconds = 60 * 60 * 8\nconst logger = createLogger('auth').child({ component: 'autologin' })\n\n// Demo-only convenience: when OM_AUTOLOGIN_* credentials are configured, sign the\n// visitor in with those credentials and redirect into the app. Gated entirely\n// behind env vars \u2014 the homepage only routes here when they are set. On any\n// failure (feature off, bad credentials) it falls back to the manual login form\n// so a misconfigured demo can never loop.\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const baseUrl = resolveTrustedRedirectBase(req) ?? url.origin\n const redirectTo = sanitizeRedirectPath(url.searchParams.get('redirect'), baseUrl, '/backend')\n\n const credentials = resolveAutoLoginCredentials()\n if (!credentials) {\n return buildSafeRedirectResponse(req, '/login')\n }\n\n // Already signed in \u2014 go straight to the app instead of re-issuing a session.\n const existing = await getAuthFromRequest(req)\n if (existing) {\n return buildSafeRedirectResponse(req, redirectTo)\n }\n\n const container = await createRequestContainer()\n const auth = container.resolve<AuthService>('authService')\n\n let user = null\n if (credentials.tenantId) {\n user = await auth.findUserByEmailAndTenant(credentials.email, credentials.tenantId)\n } else {\n const users = await auth.findUsersByEmail(credentials.email)\n user = users.length === 1 ? users[0] : null\n }\n\n const ok = await auth.verifyPassword(user, credentials.password)\n const tenantActive = user ? await isStaffTenantActive(container.resolve('em'), user.tenantId) : false\n if (!user || !ok || !tenantActive) {\n // Misconfigured demo credentials \u2014 never loop; drop to the manual form.\n logger.warn('OM_AUTOLOGIN credentials did not resolve to a single valid user; falling back to login')\n return buildSafeRedirectResponse(req, '/login')\n }\n\n const resolvedTenantId = credentials.tenantId ?? (user.tenantId ? String(user.tenantId) : null)\n const roles = await auth.getUserRoles(user, resolvedTenantId)\n await auth.updateLastLoginAt(user)\n const expiresAt = new Date(Date.now() + accessTokenMaxAgeSeconds * 1000)\n const createdSession = await auth.createSession(user, expiresAt).catch((error: unknown) => {\n if (!isAuthSessionRejectedError(error)) throw error\n return null\n })\n if (!createdSession) return buildSafeRedirectResponse(req, '/login')\n const { session } = createdSession\n const token = signJwt({\n sub: String(user.id),\n sid: String(session.id),\n tenantId: resolvedTenantId,\n orgId: user.organizationId ? String(user.organizationId) : null,\n email: user.email,\n roles,\n })\n void emitAuthEvent('auth.login.success', {\n id: String(user.id),\n email: user.email,\n tenantId: resolvedTenantId,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n }).catch(() => undefined)\n\n const res = buildSafeRedirectResponse(req, redirectTo)\n res.cookies.set('auth_token', token, {\n httpOnly: true,\n path: '/',\n sameSite: 'lax',\n secure: process.env.NODE_ENV === 'production',\n maxAge: accessTokenMaxAgeSeconds,\n })\n return res\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Demo autologin',\n methods: {\n GET: {\n summary: 'Auto sign-in using env-configured demo credentials',\n description:\n 'When OM_AUTOLOGIN_EMAIL / OM_AUTOLOGIN_PASSWORD are configured, signs the visitor in with those credentials and redirects into the app. Intended for single-tenant demo instances only. Falls back to the login page when disabled or misconfigured.',\n responses: [\n { status: 307, description: 'Redirect into the app (or to /login on failure)', mediaType: 'text/html' },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -13,6 +13,7 @@ import { runCustomRouteAfterInterceptors } from "@open-mercato/shared/lib/crud/c
13
13
  import { sanitizeRedirectPath } from "@open-mercato/core/modules/auth/lib/safeRedirect";
14
14
  import { getAppBaseUrl, isCrossSiteBrowserRequest } from "@open-mercato/shared/lib/url";
15
15
  import { handleAuthRouteError } from "@open-mercato/core/modules/auth/api/routeError";
16
+ import { isAuthSessionRejectedError } from "@open-mercato/core/modules/auth/lib/sessionEligibility";
16
17
  import { isStaffTenantActive } from "@open-mercato/core/modules/auth/lib/tenantEligibility";
17
18
  const loginRateLimitConfig = readEndpointRateLimitConfig("LOGIN", {
18
19
  points: 5,
@@ -151,7 +152,15 @@ async function handleLoginRequest(req) {
151
152
  const rememberMeDays = Number(process.env.REMEMBER_ME_DAYS || "30");
152
153
  const accessTokenMaxAgeSeconds = 60 * 60 * 8;
153
154
  const sessionExpiresAt = remember ? new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1e3) : new Date(Date.now() + accessTokenMaxAgeSeconds * 1e3);
154
- const { session: loginSession, token: sessionRefreshToken } = await auth.createSession(user, sessionExpiresAt);
155
+ let createdSession;
156
+ try {
157
+ createdSession = await auth.createSession(user, sessionExpiresAt);
158
+ } catch (error) {
159
+ if (!isAuthSessionRejectedError(error)) throw error;
160
+ void emitAuthEvent("auth.login.failed", { email: parsed.data.email, reason: "account_deactivated" }).catch(() => void 0);
161
+ return NextResponse.json({ ok: false, error: translate("auth.login.errors.invalidCredentials", "Invalid email or password") }, { status: 401 });
162
+ }
163
+ const { session: loginSession, token: sessionRefreshToken } = createdSession;
155
164
  const token = signJwt({
156
165
  sub: String(user.id),
157
166
  sid: String(loginSession.id),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/auth/api/login.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { userLoginSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { EventBus } from '@open-mercato/events/types'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit, resetAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { runCustomRouteAfterInterceptors } from '@open-mercato/shared/lib/crud/custom-route-interceptor'\nimport { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'\nimport { getAppBaseUrl, isCrossSiteBrowserRequest } from '@open-mercato/shared/lib/url'\nimport { handleAuthRouteError } from '@open-mercato/core/modules/auth/api/routeError'\nimport { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'\n\nconst loginRateLimitConfig = readEndpointRateLimitConfig('LOGIN', {\n points: 5, duration: 60, blockDuration: 60, keyPrefix: 'login',\n})\nconst loginIpRateLimitConfig = readEndpointRateLimitConfig('LOGIN_IP', {\n points: 20, duration: 60, blockDuration: 60, keyPrefix: 'login-ip',\n})\n\nexport const metadata = { requireAuth: false }\n\n// validation comes from userLoginSchema\n\ntype ParsedLoginForm = {\n email: string\n password: string\n remember: boolean\n tenantIdRaw: string\n requiredRoles: string[]\n redirectTo: string\n}\n\nfunction parseRequiredRoles(rawValue: string): string[] {\n return rawValue\n .split(',')\n .map((value) => value.trim())\n .filter(Boolean)\n}\n\nasync function parseLoginForm(req: Request): Promise<ParsedLoginForm> {\n const rawContentType = req.headers.get('content-type') ?? ''\n const contentType = rawContentType.split(';')[0].trim().toLowerCase()\n\n try {\n if (contentType === 'application/x-www-form-urlencoded') {\n const body = await req.text()\n const params = new URLSearchParams(body)\n const requireRoleRaw = String(params.get('requireRole') ?? params.get('role') ?? '').trim()\n return {\n email: String(params.get('email') ?? ''),\n password: String(params.get('password') ?? ''),\n remember: parseBooleanToken(params.get('remember')) === true,\n tenantIdRaw: String(params.get('tenantId') ?? params.get('tenant') ?? '').trim(),\n requiredRoles: requireRoleRaw ? parseRequiredRoles(requireRoleRaw) : [],\n redirectTo: String(params.get('redirect') ?? ''),\n }\n }\n\n const form = await req.formData()\n const requireRoleRaw = String(form.get('requireRole') ?? form.get('role') ?? '').trim()\n return {\n email: String(form.get('email') ?? ''),\n password: String(form.get('password') ?? ''),\n remember: parseBooleanToken(form.get('remember')?.toString()) === true,\n tenantIdRaw: String(form.get('tenantId') ?? form.get('tenant') ?? '').trim(),\n requiredRoles: requireRoleRaw ? parseRequiredRoles(requireRoleRaw) : [],\n redirectTo: String(form.get('redirect') ?? ''),\n }\n } catch {\n return {\n email: '',\n password: '',\n remember: false,\n tenantIdRaw: '',\n requiredRoles: [],\n redirectTo: '',\n }\n }\n}\n\n// Resolving translations can itself be what failed, so the generic message is\n// recovered defensively \u2014 the error path must never throw a second time.\nasync function resolveGenericLoginError(): Promise<string> {\n try {\n const { translate } = await resolveTranslations()\n return translate('auth.login.errors.generic', 'An error occurred. Please try again.')\n } catch {\n return 'An error occurred. Please try again.'\n }\n}\n\nexport async function POST(req: Request) {\n try {\n return await handleLoginRequest(req)\n } catch (error) {\n return handleAuthRouteError(error, {\n scope: 'auth.login',\n message: await resolveGenericLoginError(),\n })\n }\n}\n\nasync function handleLoginRequest(req: Request) {\n const { translate } = await resolveTranslations()\n if (isCrossSiteBrowserRequest(req)) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.crossSiteRequest', 'This sign-in request did not come from this application. Open the sign-in page and try again.') }, { status: 403 })\n }\n const { email, password, remember, tenantIdRaw, requiredRoles, redirectTo } = await parseLoginForm(req)\n // Rate limit \u2014 two layers, both checked before validation and DB work\n const { error: rateLimitError, compoundKey: rateLimitCompoundKey } = await checkAuthRateLimit({\n req, ipConfig: loginIpRateLimitConfig, compoundConfig: loginRateLimitConfig, compoundIdentifier: email,\n })\n if (rateLimitError) return rateLimitError\n const parsed = userLoginSchema.pick({ email: true, password: true, tenantId: true }).safeParse({\n email,\n password,\n tenantId: tenantIdRaw || undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid credentials') }, { status: 400 })\n }\n const container = await createRequestContainer()\n const auth = (container.resolve('authService') as AuthService)\n const tenantId = parsed.data.tenantId ?? null\n let user = null\n if (tenantId) {\n user = await auth.findUserByEmailAndTenant(parsed.data.email, tenantId)\n } else {\n const users = await auth.findUsersByEmail(parsed.data.email)\n // Never disclose that an email is registered across multiple tenants \u2014 a\n // password-independent 400-vs-401 response is an account/topology oracle\n // (issue #2242). Treat an ambiguous match as no resolvable user and fall\n // through to the uniform invalid-credentials path; tenant-selection\n // guidance is delivered out-of-band via the activation/login link.\n user = users.length === 1 ? users[0] : null\n }\n // Always verify the password \u2014 verifyPassword runs a constant-time bcrypt\n // comparison even when the user is missing or has no hash \u2014 so unknown-email,\n // wrong-password, and multi-tenant cases return an identical 401 with\n // identical latency.\n const ok = await auth.verifyPassword(user, parsed.data.password)\n const tenantActive = user ? await isStaffTenantActive(container.resolve('em'), user.tenantId) : false\n if (!user || !ok || user.isConfirmed === false || !tenantActive) {\n // The 401 body stays identical for every branch so the response never reveals\n // which one fired. `reason` goes to the audit stream instead, where separating a\n // deactivated account from a mistyped password is the whole point \u2014 otherwise\n // repeated attempts against a disabled account look like ordinary fat-fingering.\n let reason: string\n if (user && user.isConfirmed === false) reason = 'account_deactivated'\n else if (user && !tenantActive) reason = 'tenant_unavailable'\n else if (user?.passwordHash) reason = 'invalid_password'\n else reason = 'invalid_credentials'\n void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason }).catch(() => undefined)\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })\n }\n // Optional role requirement\n if (requiredRoles.length) {\n const userRoleNames = await auth.getUserRoles(user, tenantId ?? (user.tenantId ? String(user.tenantId) : null))\n const authorized = requiredRoles.some(r => userRoleNames.includes(r))\n if (!authorized) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.permissionDenied', 'Not authorized for this area') }, { status: 403 })\n }\n }\n await auth.updateLastLoginAt(user)\n // Reset rate limit counter on successful login so legitimate users aren't penalized for prior typos\n if (rateLimitCompoundKey) {\n await resetAuthRateLimit(rateLimitCompoundKey, loginRateLimitConfig)\n }\n const resolvedTenantId = tenantId ?? (user.tenantId ? String(user.tenantId) : null)\n const userRoleNames = await auth.getUserRoles(user, resolvedTenantId)\n try {\n const eventBus = (container.resolve('eventBus') as EventBus)\n void eventBus.emitEvent('query_index.coverage.warmup', {\n tenantId: resolvedTenantId,\n }).catch(() => undefined)\n } catch {\n // optional warmup\n }\n const rememberMeDays = Number(process.env.REMEMBER_ME_DAYS || '30')\n const accessTokenMaxAgeSeconds = 60 * 60 * 8\n const sessionExpiresAt = remember\n ? new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1000)\n : new Date(Date.now() + accessTokenMaxAgeSeconds * 1000)\n const { session: loginSession, token: sessionRefreshToken } = await auth.createSession(user, sessionExpiresAt)\n const token = signJwt({\n sub: String(user.id),\n sid: String(loginSession.id),\n tenantId: resolvedTenantId,\n orgId: user.organizationId ? String(user.organizationId) : null,\n email: user.email,\n roles: userRoleNames\n })\n void emitAuthEvent('auth.login.success', { id: String(user.id), email: user.email, tenantId: resolvedTenantId, organizationId: user.organizationId ? String(user.organizationId) : null }).catch(() => undefined)\n const responseData: { ok: true; token: string; redirect: string; refreshToken?: string } = {\n ok: true,\n token,\n redirect: sanitizeRedirectPath(redirectTo, getAppBaseUrl(req), '/backend'),\n }\n if (remember) {\n responseData.refreshToken = sessionRefreshToken\n }\n const em = container.resolve('em')\n const interceptedResponse = await runCustomRouteAfterInterceptors({\n routePath: 'auth/login',\n method: 'POST',\n request: {\n method: 'POST',\n url: req.url,\n body: {\n email: parsed.data.email,\n tenantId: parsed.data.tenantId ?? undefined,\n remember,\n requireRole: requiredRoles.length > 0 ? requiredRoles : undefined,\n },\n headers: Object.fromEntries(req.headers.entries()),\n },\n response: {\n statusCode: 200,\n body: responseData,\n headers: {},\n },\n context: {\n em,\n container,\n },\n })\n if (!interceptedResponse.ok) {\n return NextResponse.json(interceptedResponse.body, { status: interceptedResponse.statusCode })\n }\n\n const interceptedBody = interceptedResponse.body\n const authTokenForCookie = typeof interceptedBody.token === 'string' && interceptedBody.token.length > 0\n ? interceptedBody.token\n : token\n const refreshTokenForCookie = typeof interceptedBody.refreshToken === 'string'\n ? interceptedBody.refreshToken\n : undefined\n\n // An interceptor that swaps the issued token (the MFA challenge hands back a provisional\n // `mfa_pending` token) has not completed authentication. Any `session_token` still in the\n // browser from an earlier login would let `GET /api/auth/session/refresh` mint a full staff\n // token and skip the outstanding second factor, so it is cleared alongside the swap.\n const authTokenReplacedByInterceptor = authTokenForCookie !== token\n\n const res = NextResponse.json(interceptedBody, { status: interceptedResponse.statusCode })\n res.cookies.set('auth_token', authTokenForCookie, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: accessTokenMaxAgeSeconds })\n if (remember && refreshTokenForCookie) {\n const expiresAt = new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1000)\n res.cookies.set('session_token', refreshTokenForCookie, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', expires: expiresAt })\n } else if (!remember && !authTokenReplacedByInterceptor) {\n res.cookies.set('session_token', sessionRefreshToken, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: accessTokenMaxAgeSeconds })\n } else if (authTokenReplacedByInterceptor) {\n res.cookies.set('session_token', '', { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 0 })\n }\n return res\n}\n\nconst loginRequestSchema = userLoginSchema.extend({\n password: z.string().min(6).describe('User password'),\n remember: z.enum(['on', '1', 'true']).optional().describe('Persist the session (submit `on`, `1`, or `true`).'),\n}).describe('Login form payload')\n\nconst loginSuccessSchema = z.object({\n ok: z.literal(true),\n token: z.string().describe('JWT token issued for subsequent API calls'),\n redirect: z.string().nullable().describe('Next location the client should navigate to'),\n refreshToken: z.string().optional().describe('Long-lived refresh token for obtaining new access tokens (only present when remember=true)'),\n})\n\nconst loginErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n})\n\nconst loginMethodDoc: OpenApiMethodDoc = {\n summary: 'Authenticate user credentials',\n description: 'Validates the submitted credentials and issues a bearer token cookie for subsequent API calls.',\n tags: ['Authentication & Accounts'],\n requestBody: {\n contentType: 'application/x-www-form-urlencoded',\n schema: loginRequestSchema,\n description: 'Form-encoded payload captured from the login form.',\n },\n responses: [\n {\n status: 200,\n description: 'Authentication succeeded',\n schema: loginSuccessSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: loginErrorSchema },\n { status: 401, description: 'Invalid credentials', schema: loginErrorSchema },\n { status: 403, description: 'User lacks required role, or the request was submitted cross-site', schema: loginErrorSchema },\n { status: 429, description: 'Too many login attempts', schema: rateLimitErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Authenticate user credentials',\n description: 'Accepts login form submissions and manages cookie/session issuance.',\n methods: {\n POST: loginMethodDoc,\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { userLoginSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { EventBus } from '@open-mercato/events/types'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit, resetAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { runCustomRouteAfterInterceptors } from '@open-mercato/shared/lib/crud/custom-route-interceptor'\nimport { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'\nimport { getAppBaseUrl, isCrossSiteBrowserRequest } from '@open-mercato/shared/lib/url'\nimport { handleAuthRouteError } from '@open-mercato/core/modules/auth/api/routeError'\nimport { isAuthSessionRejectedError } from '@open-mercato/core/modules/auth/lib/sessionEligibility'\nimport { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'\n\nconst loginRateLimitConfig = readEndpointRateLimitConfig('LOGIN', {\n points: 5, duration: 60, blockDuration: 60, keyPrefix: 'login',\n})\nconst loginIpRateLimitConfig = readEndpointRateLimitConfig('LOGIN_IP', {\n points: 20, duration: 60, blockDuration: 60, keyPrefix: 'login-ip',\n})\n\nexport const metadata = { requireAuth: false }\n\n// validation comes from userLoginSchema\n\ntype ParsedLoginForm = {\n email: string\n password: string\n remember: boolean\n tenantIdRaw: string\n requiredRoles: string[]\n redirectTo: string\n}\n\nfunction parseRequiredRoles(rawValue: string): string[] {\n return rawValue\n .split(',')\n .map((value) => value.trim())\n .filter(Boolean)\n}\n\nasync function parseLoginForm(req: Request): Promise<ParsedLoginForm> {\n const rawContentType = req.headers.get('content-type') ?? ''\n const contentType = rawContentType.split(';')[0].trim().toLowerCase()\n\n try {\n if (contentType === 'application/x-www-form-urlencoded') {\n const body = await req.text()\n const params = new URLSearchParams(body)\n const requireRoleRaw = String(params.get('requireRole') ?? params.get('role') ?? '').trim()\n return {\n email: String(params.get('email') ?? ''),\n password: String(params.get('password') ?? ''),\n remember: parseBooleanToken(params.get('remember')) === true,\n tenantIdRaw: String(params.get('tenantId') ?? params.get('tenant') ?? '').trim(),\n requiredRoles: requireRoleRaw ? parseRequiredRoles(requireRoleRaw) : [],\n redirectTo: String(params.get('redirect') ?? ''),\n }\n }\n\n const form = await req.formData()\n const requireRoleRaw = String(form.get('requireRole') ?? form.get('role') ?? '').trim()\n return {\n email: String(form.get('email') ?? ''),\n password: String(form.get('password') ?? ''),\n remember: parseBooleanToken(form.get('remember')?.toString()) === true,\n tenantIdRaw: String(form.get('tenantId') ?? form.get('tenant') ?? '').trim(),\n requiredRoles: requireRoleRaw ? parseRequiredRoles(requireRoleRaw) : [],\n redirectTo: String(form.get('redirect') ?? ''),\n }\n } catch {\n return {\n email: '',\n password: '',\n remember: false,\n tenantIdRaw: '',\n requiredRoles: [],\n redirectTo: '',\n }\n }\n}\n\n// Resolving translations can itself be what failed, so the generic message is\n// recovered defensively \u2014 the error path must never throw a second time.\nasync function resolveGenericLoginError(): Promise<string> {\n try {\n const { translate } = await resolveTranslations()\n return translate('auth.login.errors.generic', 'An error occurred. Please try again.')\n } catch {\n return 'An error occurred. Please try again.'\n }\n}\n\nexport async function POST(req: Request) {\n try {\n return await handleLoginRequest(req)\n } catch (error) {\n return handleAuthRouteError(error, {\n scope: 'auth.login',\n message: await resolveGenericLoginError(),\n })\n }\n}\n\nasync function handleLoginRequest(req: Request) {\n const { translate } = await resolveTranslations()\n if (isCrossSiteBrowserRequest(req)) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.crossSiteRequest', 'This sign-in request did not come from this application. Open the sign-in page and try again.') }, { status: 403 })\n }\n const { email, password, remember, tenantIdRaw, requiredRoles, redirectTo } = await parseLoginForm(req)\n // Rate limit \u2014 two layers, both checked before validation and DB work\n const { error: rateLimitError, compoundKey: rateLimitCompoundKey } = await checkAuthRateLimit({\n req, ipConfig: loginIpRateLimitConfig, compoundConfig: loginRateLimitConfig, compoundIdentifier: email,\n })\n if (rateLimitError) return rateLimitError\n const parsed = userLoginSchema.pick({ email: true, password: true, tenantId: true }).safeParse({\n email,\n password,\n tenantId: tenantIdRaw || undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid credentials') }, { status: 400 })\n }\n const container = await createRequestContainer()\n const auth = (container.resolve('authService') as AuthService)\n const tenantId = parsed.data.tenantId ?? null\n let user = null\n if (tenantId) {\n user = await auth.findUserByEmailAndTenant(parsed.data.email, tenantId)\n } else {\n const users = await auth.findUsersByEmail(parsed.data.email)\n // Never disclose that an email is registered across multiple tenants \u2014 a\n // password-independent 400-vs-401 response is an account/topology oracle\n // (issue #2242). Treat an ambiguous match as no resolvable user and fall\n // through to the uniform invalid-credentials path; tenant-selection\n // guidance is delivered out-of-band via the activation/login link.\n user = users.length === 1 ? users[0] : null\n }\n // Always verify the password \u2014 verifyPassword runs a constant-time bcrypt\n // comparison even when the user is missing or has no hash \u2014 so unknown-email,\n // wrong-password, and multi-tenant cases return an identical 401 with\n // identical latency.\n const ok = await auth.verifyPassword(user, parsed.data.password)\n const tenantActive = user ? await isStaffTenantActive(container.resolve('em'), user.tenantId) : false\n if (!user || !ok || user.isConfirmed === false || !tenantActive) {\n // The 401 body stays identical for every branch so the response never reveals\n // which one fired. `reason` goes to the audit stream instead, where separating a\n // deactivated account from a mistyped password is the whole point \u2014 otherwise\n // repeated attempts against a disabled account look like ordinary fat-fingering.\n let reason: string\n if (user && user.isConfirmed === false) reason = 'account_deactivated'\n else if (user && !tenantActive) reason = 'tenant_unavailable'\n else if (user?.passwordHash) reason = 'invalid_password'\n else reason = 'invalid_credentials'\n void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason }).catch(() => undefined)\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })\n }\n // Optional role requirement\n if (requiredRoles.length) {\n const userRoleNames = await auth.getUserRoles(user, tenantId ?? (user.tenantId ? String(user.tenantId) : null))\n const authorized = requiredRoles.some(r => userRoleNames.includes(r))\n if (!authorized) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.permissionDenied', 'Not authorized for this area') }, { status: 403 })\n }\n }\n await auth.updateLastLoginAt(user)\n // Reset rate limit counter on successful login so legitimate users aren't penalized for prior typos\n if (rateLimitCompoundKey) {\n await resetAuthRateLimit(rateLimitCompoundKey, loginRateLimitConfig)\n }\n const resolvedTenantId = tenantId ?? (user.tenantId ? String(user.tenantId) : null)\n const userRoleNames = await auth.getUserRoles(user, resolvedTenantId)\n try {\n const eventBus = (container.resolve('eventBus') as EventBus)\n void eventBus.emitEvent('query_index.coverage.warmup', {\n tenantId: resolvedTenantId,\n }).catch(() => undefined)\n } catch {\n // optional warmup\n }\n const rememberMeDays = Number(process.env.REMEMBER_ME_DAYS || '30')\n const accessTokenMaxAgeSeconds = 60 * 60 * 8\n const sessionExpiresAt = remember\n ? new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1000)\n : new Date(Date.now() + accessTokenMaxAgeSeconds * 1000)\n let createdSession: Awaited<ReturnType<AuthService['createSession']>>\n try {\n createdSession = await auth.createSession(user, sessionExpiresAt)\n } catch (error) {\n if (!isAuthSessionRejectedError(error)) throw error\n void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason: 'account_deactivated' }).catch(() => undefined)\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })\n }\n const { session: loginSession, token: sessionRefreshToken } = createdSession\n const token = signJwt({\n sub: String(user.id),\n sid: String(loginSession.id),\n tenantId: resolvedTenantId,\n orgId: user.organizationId ? String(user.organizationId) : null,\n email: user.email,\n roles: userRoleNames\n })\n void emitAuthEvent('auth.login.success', { id: String(user.id), email: user.email, tenantId: resolvedTenantId, organizationId: user.organizationId ? String(user.organizationId) : null }).catch(() => undefined)\n const responseData: { ok: true; token: string; redirect: string; refreshToken?: string } = {\n ok: true,\n token,\n redirect: sanitizeRedirectPath(redirectTo, getAppBaseUrl(req), '/backend'),\n }\n if (remember) {\n responseData.refreshToken = sessionRefreshToken\n }\n const em = container.resolve('em')\n const interceptedResponse = await runCustomRouteAfterInterceptors({\n routePath: 'auth/login',\n method: 'POST',\n request: {\n method: 'POST',\n url: req.url,\n body: {\n email: parsed.data.email,\n tenantId: parsed.data.tenantId ?? undefined,\n remember,\n requireRole: requiredRoles.length > 0 ? requiredRoles : undefined,\n },\n headers: Object.fromEntries(req.headers.entries()),\n },\n response: {\n statusCode: 200,\n body: responseData,\n headers: {},\n },\n context: {\n em,\n container,\n },\n })\n if (!interceptedResponse.ok) {\n return NextResponse.json(interceptedResponse.body, { status: interceptedResponse.statusCode })\n }\n\n const interceptedBody = interceptedResponse.body\n const authTokenForCookie = typeof interceptedBody.token === 'string' && interceptedBody.token.length > 0\n ? interceptedBody.token\n : token\n const refreshTokenForCookie = typeof interceptedBody.refreshToken === 'string'\n ? interceptedBody.refreshToken\n : undefined\n\n // An interceptor that swaps the issued token (the MFA challenge hands back a provisional\n // `mfa_pending` token) has not completed authentication. Any `session_token` still in the\n // browser from an earlier login would let `GET /api/auth/session/refresh` mint a full staff\n // token and skip the outstanding second factor, so it is cleared alongside the swap.\n const authTokenReplacedByInterceptor = authTokenForCookie !== token\n\n const res = NextResponse.json(interceptedBody, { status: interceptedResponse.statusCode })\n res.cookies.set('auth_token', authTokenForCookie, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: accessTokenMaxAgeSeconds })\n if (remember && refreshTokenForCookie) {\n const expiresAt = new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1000)\n res.cookies.set('session_token', refreshTokenForCookie, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', expires: expiresAt })\n } else if (!remember && !authTokenReplacedByInterceptor) {\n res.cookies.set('session_token', sessionRefreshToken, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: accessTokenMaxAgeSeconds })\n } else if (authTokenReplacedByInterceptor) {\n res.cookies.set('session_token', '', { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 0 })\n }\n return res\n}\n\nconst loginRequestSchema = userLoginSchema.extend({\n password: z.string().min(6).describe('User password'),\n remember: z.enum(['on', '1', 'true']).optional().describe('Persist the session (submit `on`, `1`, or `true`).'),\n}).describe('Login form payload')\n\nconst loginSuccessSchema = z.object({\n ok: z.literal(true),\n token: z.string().describe('JWT token issued for subsequent API calls'),\n redirect: z.string().nullable().describe('Next location the client should navigate to'),\n refreshToken: z.string().optional().describe('Long-lived refresh token for obtaining new access tokens (only present when remember=true)'),\n})\n\nconst loginErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n})\n\nconst loginMethodDoc: OpenApiMethodDoc = {\n summary: 'Authenticate user credentials',\n description: 'Validates the submitted credentials and issues a bearer token cookie for subsequent API calls.',\n tags: ['Authentication & Accounts'],\n requestBody: {\n contentType: 'application/x-www-form-urlencoded',\n schema: loginRequestSchema,\n description: 'Form-encoded payload captured from the login form.',\n },\n responses: [\n {\n status: 200,\n description: 'Authentication succeeded',\n schema: loginSuccessSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: loginErrorSchema },\n { status: 401, description: 'Invalid credentials', schema: loginErrorSchema },\n { status: 403, description: 'User lacks required role, or the request was submitted cross-site', schema: loginErrorSchema },\n { status: 429, description: 'Too many login attempts', schema: rateLimitErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Authenticate user credentials',\n description: 'Accepts login form submissions and manages cookie/session issuance.',\n methods: {\n POST: loginMethodDoc,\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["requireRoleRaw", "userRoleNames"]
7
7
  }
@@ -0,0 +1,17 @@
1
+ var _a, _b;
2
+ const SESSION_REJECTED_MARKER = /* @__PURE__ */ Symbol.for("@open-mercato/auth/AuthSessionRejectedError");
3
+ class AuthSessionRejectedError extends (_b = Error, _a = SESSION_REJECTED_MARKER, _b) {
4
+ constructor() {
5
+ super("[internal] Session eligibility rejected");
6
+ this[_a] = true;
7
+ this.name = "AuthSessionRejectedError";
8
+ }
9
+ }
10
+ function isAuthSessionRejectedError(error) {
11
+ return error !== null && typeof error === "object" && error[SESSION_REJECTED_MARKER] === true;
12
+ }
13
+ export {
14
+ AuthSessionRejectedError,
15
+ isAuthSessionRejectedError
16
+ };
17
+ //# sourceMappingURL=sessionEligibility.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/auth/lib/sessionEligibility.ts"],
4
+ "sourcesContent": ["const SESSION_REJECTED_MARKER = Symbol.for('@open-mercato/auth/AuthSessionRejectedError')\n\nexport class AuthSessionRejectedError extends Error {\n readonly [SESSION_REJECTED_MARKER] = true\n\n constructor() {\n super('[internal] Session eligibility rejected')\n this.name = 'AuthSessionRejectedError'\n }\n}\n\nexport function isAuthSessionRejectedError(error: unknown): error is AuthSessionRejectedError {\n return error !== null && typeof error === 'object'\n && (error as Record<symbol, unknown>)[SESSION_REJECTED_MARKER] === true\n}\n"],
5
+ "mappings": "AAAA;AAAA,MAAM,0BAA0B,uBAAO,IAAI,6CAA6C;AAEjF,MAAM,kCAAiC,YAClC,8BADkC,IAAM;AAAA,EAGlD,cAAc;AACZ,UAAM,yCAAyC;AAHjD,SAAU,MAA2B;AAInC,SAAK,OAAO;AAAA,EACd;AACF;AAEO,SAAS,2BAA2B,OAAmD;AAC5F,SAAO,UAAU,QAAQ,OAAO,UAAU,YACpC,MAAkC,uBAAuB,MAAM;AACvE;",
6
+ "names": []
7
+ }
@@ -69,14 +69,17 @@ class AuthService {
69
69
  return links.map((l) => l.role).filter((role) => !!role).map((role) => role.name).filter((name) => typeof name === "string" && name.trim().length > 0);
70
70
  }
71
71
  async createSession(user, expiresAt) {
72
- if (!await isStaffTenantActive(this.em, user.tenantId)) {
72
+ return this.createSessionWithEntityManager(this.em, user, expiresAt);
73
+ }
74
+ async createSessionWithEntityManager(em, user, expiresAt) {
75
+ if (!await isStaffTenantActive(em, user.tenantId)) {
73
76
  throw new Error("[internal] Tenant is not available for authentication");
74
77
  }
75
78
  const rawToken = generateAuthToken();
76
79
  const tokenHash = hashAuthToken(rawToken);
77
- const sess = this.em.create(Session, { user, token: tokenHash, expiresAt, createdAt: /* @__PURE__ */ new Date() });
78
- await this.em.persist(sess).flush();
79
- return { session: sess, token: rawToken };
80
+ const session = em.create(Session, { user, token: tokenHash, expiresAt, createdAt: /* @__PURE__ */ new Date() });
81
+ await em.persist(session).flush();
82
+ return { session, token: rawToken };
80
83
  }
81
84
  async deleteSessionByToken(token) {
82
85
  const hashedToken = hashAuthToken(token);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/auth/services/authService.ts"],
4
- "sourcesContent": ["import { EntityManager } from '@mikro-orm/postgresql'\nimport { compare, hash } from 'bcryptjs'\nimport { User, Role, UserRole, Session, PasswordReset } from '@open-mercato/core/modules/auth/data/entities'\nimport { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport { generateAuthToken, hashAuthToken } from '@open-mercato/core/modules/auth/lib/tokenHash'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'\n\n// A fixed, valid bcrypt hash (cost 10) of a throwaway value no real password\n// can match. verifyPassword compares against it whenever the user is missing or\n// has no password hash, so a failed login spends the same bcrypt CPU time\n// regardless of whether the account exists \u2014 closing the timing side channel\n// for account enumeration (issue #2242).\nconst TIMING_EQUALIZER_PASSWORD_HASH = '$2b$10$OcZrhmZpIzJOjkfwUrk7d.Nl0eHNzOvalBcBlt5Ran.4lj8R3HZg6'\n\nexport class AuthService {\n constructor(private em: EntityManager) {}\n\n async findUserByEmail(email: string) {\n const emailHashes = emailHashLookupValues(email)\n return findOneWithDecryption(this.em, User, {\n deletedAt: null,\n $or: [\n { email },\n { emailHash: { $in: emailHashes } },\n ],\n } as any)\n }\n\n async findUsersByEmail(email: string) {\n const emailHashes = emailHashLookupValues(email)\n return findWithDecryption(this.em, User, {\n deletedAt: null,\n $or: [\n { email },\n { emailHash: { $in: emailHashes } },\n ],\n } as any)\n }\n\n async findUserByEmailAndTenant(email: string, tenantId: string) {\n const emailHashes = emailHashLookupValues(email)\n return findOneWithDecryption(\n this.em,\n User,\n {\n tenantId,\n deletedAt: null,\n $or: [\n { email },\n { emailHash: { $in: emailHashes } },\n ],\n } as any,\n undefined,\n { tenantId },\n )\n }\n\n async verifyPassword(user: User | null, password: string) {\n const storedHash = user?.passwordHash ?? null\n // Always run a bcrypt comparison \u2014 against a fixed dummy hash when the user\n // is absent or has no password \u2014 so login latency does not reveal whether\n // the account exists (timing-based enumeration, issue #2242).\n const matched = await compare(password, storedHash ?? TIMING_EQUALIZER_PASSWORD_HASH)\n return storedHash !== null && matched\n }\n\n async updateLastLoginAt(user: User) {\n const now = new Date()\n // Use native update to avoid flushing unrelated entities that might be pending in this EM\n await this.em.nativeUpdate(User, { id: user.id }, { lastLoginAt: now })\n user.lastLoginAt = now\n }\n\n async getUserRoles(user: User, tenantId?: string | null): Promise<string[]> {\n const resolvedTenantId = tenantId ?? user.tenantId ?? null\n if (!resolvedTenantId) return []\n const links = await findWithDecryption(\n this.em,\n UserRole,\n { user, deletedAt: null, role: { tenantId: resolvedTenantId, deletedAt: null } as any },\n { populate: ['role'] },\n { tenantId: resolvedTenantId, organizationId: user.organizationId ?? null },\n )\n // A populated `role` can still be null when the link points at a soft-deleted\n // role (the Role soft-delete filter suppresses hydration), e.g. an admin link\n // orphaned by a re-seed during interrupted-provisioning recovery. Dropping such\n // links keeps role resolution from throwing on the login / session-refresh hot\n // path, mirroring resolveCanonicalStaffAuthContext in lib/sessionIntegrity.ts.\n return links\n .map((l) => l.role)\n .filter((role): role is Role => !!role)\n .map((role) => role.name)\n .filter((name): name is string => typeof name === 'string' && name.trim().length > 0)\n }\n\n\n async createSession(user: User, expiresAt: Date): Promise<{ session: Session; token: string }> {\n if (!(await isStaffTenantActive(this.em, user.tenantId))) {\n throw new Error('[internal] Tenant is not available for authentication')\n }\n const rawToken = generateAuthToken()\n const tokenHash = hashAuthToken(rawToken)\n const sess = this.em.create(Session as any, { user, token: tokenHash, expiresAt, createdAt: new Date() } as any)\n await this.em.persist(sess).flush()\n return { session: sess as Session, token: rawToken }\n }\n\n async deleteSessionByToken(token: string) {\n const hashedToken = hashAuthToken(token)\n await this.em.nativeDelete(Session, { token: hashedToken })\n }\n\n async deleteSessionById(sessionId: string) {\n await this.em.nativeDelete(Session, { id: sessionId })\n }\n\n async findActiveSessionById(sessionId: string): Promise<Session | null> {\n const session = await this.em.findOne(Session, { id: sessionId, deletedAt: null })\n if (!session) return null\n if (session.expiresAt.getTime() < Date.now()) return null\n return session\n }\n\n async deleteAllUserSessions(userId: string) {\n await this.em.nativeDelete(Session, { user: userId })\n }\n\n async refreshFromSessionToken(token: string) {\n const now = new Date()\n const hashedToken = hashAuthToken(token)\n const sess = await this.em.findOne(Session, { token: hashedToken })\n if (!sess || sess.expiresAt <= now) return null\n const user = await findOneWithDecryption(this.em, User, { id: sess.user.id, deletedAt: null })\n if (!user) return null\n if (!(await isStaffTenantActive(this.em, user.tenantId))) return null\n const roles = await this.getUserRoles(user, user.tenantId ?? null)\n return { user, roles, session: sess }\n }\n\n async requestPasswordReset(email: string) {\n const user = await this.findUserByEmail(email)\n if (!user) return null\n const rawToken = generateAuthToken()\n const tokenHash = hashAuthToken(rawToken)\n const expiresAt = new Date(Date.now() + 60 * 60 * 1000)\n const row = this.em.create(PasswordReset as any, { user, token: tokenHash, expiresAt, createdAt: new Date() } as any)\n await this.em.persist(row).flush()\n return { user, token: rawToken }\n }\n\n private async findUnconsumedPasswordReset(token: string): Promise<PasswordReset | null> {\n const now = new Date()\n const hashedToken = hashAuthToken(token)\n const row = await this.em.findOne(PasswordReset, { token: hashedToken })\n if (!row) return null\n if (row.usedAt && row.usedAt <= now) return null\n if (row.expiresAt <= now) return null\n return row\n }\n\n async isPasswordResetTokenValid(token: string): Promise<boolean> {\n const row = await this.findUnconsumedPasswordReset(token)\n if (!row) return false\n const user = await findOneWithDecryption(this.em, User, { id: row.user.id, deletedAt: null })\n return user != null\n }\n\n async confirmPasswordReset(token: string, newPassword: string): Promise<User | null> {\n const now = new Date()\n const row = await this.findUnconsumedPasswordReset(token)\n if (!row) return null\n\n // Atomic compare-and-set: only mark used if still unused \u2014 prevents token replay under concurrency\n const affected = await this.em.nativeUpdate(\n PasswordReset,\n { id: row.id, usedAt: null },\n { usedAt: now },\n )\n if (affected === 0) return null\n\n const user = await findOneWithDecryption(this.em, User, { id: row.user.id, deletedAt: null })\n if (!user) return null\n user.passwordHash = await hash(newPassword, 10)\n await this.em.flush()\n await this.deleteAllUserSessions(String(user.id))\n return user\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { EntityManager, type RequiredEntityData } from '@mikro-orm/postgresql'\nimport { compare, hash } from 'bcryptjs'\nimport { User, Role, UserRole, Session, PasswordReset } from '@open-mercato/core/modules/auth/data/entities'\nimport { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport { generateAuthToken, hashAuthToken } from '@open-mercato/core/modules/auth/lib/tokenHash'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'\n\n// A fixed, valid bcrypt hash (cost 10) of a throwaway value no real password\n// can match. verifyPassword compares against it whenever the user is missing or\n// has no password hash, so a failed login spends the same bcrypt CPU time\n// regardless of whether the account exists \u2014 closing the timing side channel\n// for account enumeration (issue #2242).\nconst TIMING_EQUALIZER_PASSWORD_HASH = '$2b$10$OcZrhmZpIzJOjkfwUrk7d.Nl0eHNzOvalBcBlt5Ran.4lj8R3HZg6'\n\nexport class AuthService {\n constructor(private em: EntityManager) {}\n\n async findUserByEmail(email: string) {\n const emailHashes = emailHashLookupValues(email)\n return findOneWithDecryption(this.em, User, {\n deletedAt: null,\n $or: [\n { email },\n { emailHash: { $in: emailHashes } },\n ],\n } as any)\n }\n\n async findUsersByEmail(email: string) {\n const emailHashes = emailHashLookupValues(email)\n return findWithDecryption(this.em, User, {\n deletedAt: null,\n $or: [\n { email },\n { emailHash: { $in: emailHashes } },\n ],\n } as any)\n }\n\n async findUserByEmailAndTenant(email: string, tenantId: string) {\n const emailHashes = emailHashLookupValues(email)\n return findOneWithDecryption(\n this.em,\n User,\n {\n tenantId,\n deletedAt: null,\n $or: [\n { email },\n { emailHash: { $in: emailHashes } },\n ],\n } as any,\n undefined,\n { tenantId },\n )\n }\n\n async verifyPassword(user: User | null, password: string) {\n const storedHash = user?.passwordHash ?? null\n // Always run a bcrypt comparison \u2014 against a fixed dummy hash when the user\n // is absent or has no password \u2014 so login latency does not reveal whether\n // the account exists (timing-based enumeration, issue #2242).\n const matched = await compare(password, storedHash ?? TIMING_EQUALIZER_PASSWORD_HASH)\n return storedHash !== null && matched\n }\n\n async updateLastLoginAt(user: User) {\n const now = new Date()\n // Use native update to avoid flushing unrelated entities that might be pending in this EM\n await this.em.nativeUpdate(User, { id: user.id }, { lastLoginAt: now })\n user.lastLoginAt = now\n }\n\n async getUserRoles(user: User, tenantId?: string | null): Promise<string[]> {\n const resolvedTenantId = tenantId ?? user.tenantId ?? null\n if (!resolvedTenantId) return []\n const links = await findWithDecryption(\n this.em,\n UserRole,\n { user, deletedAt: null, role: { tenantId: resolvedTenantId, deletedAt: null } as any },\n { populate: ['role'] },\n { tenantId: resolvedTenantId, organizationId: user.organizationId ?? null },\n )\n // A populated `role` can still be null when the link points at a soft-deleted\n // role (the Role soft-delete filter suppresses hydration), e.g. an admin link\n // orphaned by a re-seed during interrupted-provisioning recovery. Dropping such\n // links keeps role resolution from throwing on the login / session-refresh hot\n // path, mirroring resolveCanonicalStaffAuthContext in lib/sessionIntegrity.ts.\n return links\n .map((l) => l.role)\n .filter((role): role is Role => !!role)\n .map((role) => role.name)\n .filter((name): name is string => typeof name === 'string' && name.trim().length > 0)\n }\n\n\n async createSession(user: User, expiresAt: Date): Promise<{ session: Session; token: string }> {\n return this.createSessionWithEntityManager(this.em, user, expiresAt)\n }\n\n protected async createSessionWithEntityManager(\n em: EntityManager,\n user: User,\n expiresAt: Date,\n ): Promise<{ session: Session; token: string }> {\n if (!(await isStaffTenantActive(em, user.tenantId))) {\n throw new Error('[internal] Tenant is not available for authentication')\n }\n const rawToken = generateAuthToken()\n const tokenHash = hashAuthToken(rawToken)\n const session = em.create(Session, { user, token: tokenHash, expiresAt, createdAt: new Date() } as RequiredEntityData<Session>)\n await em.persist(session).flush()\n return { session, token: rawToken }\n }\n\n async deleteSessionByToken(token: string) {\n const hashedToken = hashAuthToken(token)\n await this.em.nativeDelete(Session, { token: hashedToken })\n }\n\n async deleteSessionById(sessionId: string) {\n await this.em.nativeDelete(Session, { id: sessionId })\n }\n\n async findActiveSessionById(sessionId: string): Promise<Session | null> {\n const session = await this.em.findOne(Session, { id: sessionId, deletedAt: null })\n if (!session) return null\n if (session.expiresAt.getTime() < Date.now()) return null\n return session\n }\n\n async deleteAllUserSessions(userId: string) {\n await this.em.nativeDelete(Session, { user: userId })\n }\n\n async refreshFromSessionToken(token: string) {\n const now = new Date()\n const hashedToken = hashAuthToken(token)\n const sess = await this.em.findOne(Session, { token: hashedToken })\n if (!sess || sess.expiresAt <= now) return null\n const user = await findOneWithDecryption(this.em, User, { id: sess.user.id, deletedAt: null })\n if (!user) return null\n if (!(await isStaffTenantActive(this.em, user.tenantId))) return null\n const roles = await this.getUserRoles(user, user.tenantId ?? null)\n return { user, roles, session: sess }\n }\n\n async requestPasswordReset(email: string) {\n const user = await this.findUserByEmail(email)\n if (!user) return null\n const rawToken = generateAuthToken()\n const tokenHash = hashAuthToken(rawToken)\n const expiresAt = new Date(Date.now() + 60 * 60 * 1000)\n const row = this.em.create(PasswordReset as any, { user, token: tokenHash, expiresAt, createdAt: new Date() } as any)\n await this.em.persist(row).flush()\n return { user, token: rawToken }\n }\n\n private async findUnconsumedPasswordReset(token: string): Promise<PasswordReset | null> {\n const now = new Date()\n const hashedToken = hashAuthToken(token)\n const row = await this.em.findOne(PasswordReset, { token: hashedToken })\n if (!row) return null\n if (row.usedAt && row.usedAt <= now) return null\n if (row.expiresAt <= now) return null\n return row\n }\n\n async isPasswordResetTokenValid(token: string): Promise<boolean> {\n const row = await this.findUnconsumedPasswordReset(token)\n if (!row) return false\n const user = await findOneWithDecryption(this.em, User, { id: row.user.id, deletedAt: null })\n return user != null\n }\n\n async confirmPasswordReset(token: string, newPassword: string): Promise<User | null> {\n const now = new Date()\n const row = await this.findUnconsumedPasswordReset(token)\n if (!row) return null\n\n // Atomic compare-and-set: only mark used if still unused \u2014 prevents token replay under concurrency\n const affected = await this.em.nativeUpdate(\n PasswordReset,\n { id: row.id, usedAt: null },\n { usedAt: now },\n )\n if (affected === 0) return null\n\n const user = await findOneWithDecryption(this.em, User, { id: row.user.id, deletedAt: null })\n if (!user) return null\n user.passwordHash = await hash(newPassword, 10)\n await this.em.flush()\n await this.deleteAllUserSessions(String(user.id))\n return user\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7796.1.f2868e5c62",
3
+ "version": "0.10.1-develop.7797.1.f0365b4031",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.4.3"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7796.1.f2868e5c62",
270
- "@open-mercato/shared": "0.10.1-develop.7796.1.f2868e5c62",
271
- "@open-mercato/ui": "0.10.1-develop.7796.1.f2868e5c62",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7797.1.f0365b4031",
270
+ "@open-mercato/shared": "0.10.1-develop.7797.1.f0365b4031",
271
+ "@open-mercato/ui": "0.10.1-develop.7797.1.f0365b4031",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7796.1.f2868e5c62",
277
- "@open-mercato/shared": "0.10.1-develop.7796.1.f2868e5c62",
278
- "@open-mercato/ui": "0.10.1-develop.7796.1.f2868e5c62",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7797.1.f0365b4031",
277
+ "@open-mercato/shared": "0.10.1-develop.7797.1.f0365b4031",
278
+ "@open-mercato/ui": "0.10.1-develop.7797.1.f0365b4031",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -8,6 +8,7 @@ import { buildSafeRedirectResponse, resolveTrustedRedirectBase } from '@open-mer
8
8
  import { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'
9
9
  import { resolveAutoLoginCredentials } from '@open-mercato/core/modules/auth/lib/autologin'
10
10
  import { emitAuthEvent } from '@open-mercato/core/modules/auth/events'
11
+ import { isAuthSessionRejectedError } from '@open-mercato/core/modules/auth/lib/sessionEligibility'
11
12
  import { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'
12
13
 
13
14
  export const metadata = { requireAuth: false }
@@ -59,7 +60,12 @@ export async function GET(req: Request) {
59
60
  const roles = await auth.getUserRoles(user, resolvedTenantId)
60
61
  await auth.updateLastLoginAt(user)
61
62
  const expiresAt = new Date(Date.now() + accessTokenMaxAgeSeconds * 1000)
62
- const { session } = await auth.createSession(user, expiresAt)
63
+ const createdSession = await auth.createSession(user, expiresAt).catch((error: unknown) => {
64
+ if (!isAuthSessionRejectedError(error)) throw error
65
+ return null
66
+ })
67
+ if (!createdSession) return buildSafeRedirectResponse(req, '/login')
68
+ const { session } = createdSession
63
69
  const token = signJwt({
64
70
  sub: String(user.id),
65
71
  sid: String(session.id),
@@ -16,6 +16,7 @@ import { runCustomRouteAfterInterceptors } from '@open-mercato/shared/lib/crud/c
16
16
  import { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'
17
17
  import { getAppBaseUrl, isCrossSiteBrowserRequest } from '@open-mercato/shared/lib/url'
18
18
  import { handleAuthRouteError } from '@open-mercato/core/modules/auth/api/routeError'
19
+ import { isAuthSessionRejectedError } from '@open-mercato/core/modules/auth/lib/sessionEligibility'
19
20
  import { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'
20
21
 
21
22
  const loginRateLimitConfig = readEndpointRateLimitConfig('LOGIN', {
@@ -189,7 +190,15 @@ async function handleLoginRequest(req: Request) {
189
190
  const sessionExpiresAt = remember
190
191
  ? new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1000)
191
192
  : new Date(Date.now() + accessTokenMaxAgeSeconds * 1000)
192
- const { session: loginSession, token: sessionRefreshToken } = await auth.createSession(user, sessionExpiresAt)
193
+ let createdSession: Awaited<ReturnType<AuthService['createSession']>>
194
+ try {
195
+ createdSession = await auth.createSession(user, sessionExpiresAt)
196
+ } catch (error) {
197
+ if (!isAuthSessionRejectedError(error)) throw error
198
+ void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason: 'account_deactivated' }).catch(() => undefined)
199
+ return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })
200
+ }
201
+ const { session: loginSession, token: sessionRefreshToken } = createdSession
193
202
  const token = signJwt({
194
203
  sub: String(user.id),
195
204
  sid: String(loginSession.id),
@@ -0,0 +1,15 @@
1
+ const SESSION_REJECTED_MARKER = Symbol.for('@open-mercato/auth/AuthSessionRejectedError')
2
+
3
+ export class AuthSessionRejectedError extends Error {
4
+ readonly [SESSION_REJECTED_MARKER] = true
5
+
6
+ constructor() {
7
+ super('[internal] Session eligibility rejected')
8
+ this.name = 'AuthSessionRejectedError'
9
+ }
10
+ }
11
+
12
+ export function isAuthSessionRejectedError(error: unknown): error is AuthSessionRejectedError {
13
+ return error !== null && typeof error === 'object'
14
+ && (error as Record<symbol, unknown>)[SESSION_REJECTED_MARKER] === true
15
+ }
@@ -1,4 +1,4 @@
1
- import { EntityManager } from '@mikro-orm/postgresql'
1
+ import { EntityManager, type RequiredEntityData } from '@mikro-orm/postgresql'
2
2
  import { compare, hash } from 'bcryptjs'
3
3
  import { User, Role, UserRole, Session, PasswordReset } from '@open-mercato/core/modules/auth/data/entities'
4
4
  import { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'
@@ -96,14 +96,22 @@ export class AuthService {
96
96
 
97
97
 
98
98
  async createSession(user: User, expiresAt: Date): Promise<{ session: Session; token: string }> {
99
- if (!(await isStaffTenantActive(this.em, user.tenantId))) {
99
+ return this.createSessionWithEntityManager(this.em, user, expiresAt)
100
+ }
101
+
102
+ protected async createSessionWithEntityManager(
103
+ em: EntityManager,
104
+ user: User,
105
+ expiresAt: Date,
106
+ ): Promise<{ session: Session; token: string }> {
107
+ if (!(await isStaffTenantActive(em, user.tenantId))) {
100
108
  throw new Error('[internal] Tenant is not available for authentication')
101
109
  }
102
110
  const rawToken = generateAuthToken()
103
111
  const tokenHash = hashAuthToken(rawToken)
104
- const sess = this.em.create(Session as any, { user, token: tokenHash, expiresAt, createdAt: new Date() } as any)
105
- await this.em.persist(sess).flush()
106
- return { session: sess as Session, token: rawToken }
112
+ const session = em.create(Session, { user, token: tokenHash, expiresAt, createdAt: new Date() } as RequiredEntityData<Session>)
113
+ await em.persist(session).flush()
114
+ return { session, token: rawToken }
107
115
  }
108
116
 
109
117
  async deleteSessionByToken(token: string) {