@open-mercato/core 0.10.1-develop.7796.1.f2868e5c62 → 0.10.1-develop.7798.1.dddc3c71f6
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/autologin.js +7 -1
- package/dist/modules/auth/api/autologin.js.map +2 -2
- package/dist/modules/auth/api/login.js +10 -1
- package/dist/modules/auth/api/login.js.map +2 -2
- package/dist/modules/auth/lib/sessionEligibility.js +17 -0
- package/dist/modules/auth/lib/sessionEligibility.js.map +7 -0
- package/dist/modules/auth/services/authService.js +7 -4
- package/dist/modules/auth/services/authService.js.map +2 -2
- package/dist/modules/forms/api/attachment-helpers.js +24 -0
- package/dist/modules/forms/api/attachment-helpers.js.map +2 -2
- package/dist/modules/forms/api/public/submissions/[id]/attachments/[attachmentId]/route.js +26 -1
- package/dist/modules/forms/api/public/submissions/[id]/attachments/[attachmentId]/route.js.map +2 -2
- package/dist/modules/forms/api/public/submissions/[id]/attachments/route.js +17 -1
- package/dist/modules/forms/api/public/submissions/[id]/attachments/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/autologin.ts +7 -1
- package/src/modules/auth/api/login.ts +10 -1
- package/src/modules/auth/lib/sessionEligibility.ts +15 -0
- package/src/modules/auth/services/authService.ts +13 -5
- package/src/modules/forms/api/attachment-helpers.ts +34 -0
- package/src/modules/forms/api/public/submissions/[id]/attachments/[attachmentId]/route.ts +26 -1
- package/src/modules/forms/api/public/submissions/[id]/attachments/route.ts +16 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
await
|
|
79
|
-
return { session
|
|
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(
|
|
5
|
-
"mappings": "AACA,SAAS,SAAS,YAAY;AAC9B,SAAS,MAAY,UAAU,SAAS,qBAAqB;AAC7D,SAAS,6BAA6B;AACtC,SAAS,mBAAmB,qBAAqB;AACjD,SAAS,oBAAoB,6BAA6B;AAC1D,SAAS,2BAA2B;AAOpC,MAAM,iCAAiC;AAEhC,MAAM,YAAY;AAAA,EACvB,YAAoB,IAAmB;AAAnB;AAAA,EAAoB;AAAA,EAExC,MAAM,gBAAgB,OAAe;AACnC,UAAM,cAAc,sBAAsB,KAAK;AAC/C,WAAO,sBAAsB,KAAK,IAAI,MAAM;AAAA,MAC1C,WAAW;AAAA,MACX,KAAK;AAAA,QACH,EAAE,MAAM;AAAA,QACR,EAAE,WAAW,EAAE,KAAK,YAAY,EAAE;AAAA,MACpC;AAAA,IACF,CAAQ;AAAA,EACV;AAAA,EAEA,MAAM,iBAAiB,OAAe;AACpC,UAAM,cAAc,sBAAsB,KAAK;AAC/C,WAAO,mBAAmB,KAAK,IAAI,MAAM;AAAA,MACvC,WAAW;AAAA,MACX,KAAK;AAAA,QACH,EAAE,MAAM;AAAA,QACR,EAAE,WAAW,EAAE,KAAK,YAAY,EAAE;AAAA,MACpC;AAAA,IACF,CAAQ;AAAA,EACV;AAAA,EAEA,MAAM,yBAAyB,OAAe,UAAkB;AAC9D,UAAM,cAAc,sBAAsB,KAAK;AAC/C,WAAO;AAAA,MACL,KAAK;AAAA,MACL;AAAA,MACA;AAAA,QACE;AAAA,QACA,WAAW;AAAA,QACX,KAAK;AAAA,UACH,EAAE,MAAM;AAAA,UACR,EAAE,WAAW,EAAE,KAAK,YAAY,EAAE;AAAA,QACpC;AAAA,MACF;AAAA,MACA;AAAA,MACA,EAAE,SAAS;AAAA,IACb;AAAA,EACF;AAAA,EAEA,MAAM,eAAe,MAAmB,UAAkB;AACxD,UAAM,aAAa,MAAM,gBAAgB;AAIzC,UAAM,UAAU,MAAM,QAAQ,UAAU,cAAc,8BAA8B;AACpF,WAAO,eAAe,QAAQ;AAAA,EAChC;AAAA,EAEA,MAAM,kBAAkB,MAAY;AAClC,UAAM,MAAM,oBAAI,KAAK;AAErB,UAAM,KAAK,GAAG,aAAa,MAAM,EAAE,IAAI,KAAK,GAAG,GAAG,EAAE,aAAa,IAAI,CAAC;AACtE,SAAK,cAAc;AAAA,EACrB;AAAA,EAEA,MAAM,aAAa,MAAY,UAA6C;AAC1E,UAAM,mBAAmB,YAAY,KAAK,YAAY;AACtD,QAAI,CAAC,iBAAkB,QAAO,CAAC;AAC/B,UAAM,QAAQ,MAAM;AAAA,MAClB,KAAK;AAAA,MACL;AAAA,MACA,EAAE,MAAM,WAAW,MAAM,MAAM,EAAE,UAAU,kBAAkB,WAAW,KAAK,EAAS;AAAA,MACtF,EAAE,UAAU,CAAC,MAAM,EAAE;AAAA,MACrB,EAAE,UAAU,kBAAkB,gBAAgB,KAAK,kBAAkB,KAAK;AAAA,IAC5E;AAMA,WAAO,MACJ,IAAI,CAAC,MAAM,EAAE,IAAI,EACjB,OAAO,CAAC,SAAuB,CAAC,CAAC,IAAI,EACrC,IAAI,CAAC,SAAS,KAAK,IAAI,EACvB,OAAO,CAAC,SAAyB,OAAO,SAAS,YAAY,KAAK,KAAK,EAAE,SAAS,CAAC;AAAA,EACxF;AAAA,EAGA,MAAM,cAAc,MAAY,WAA+D;AAC7F,QAAI,CAAE,MAAM,oBAAoB,
|
|
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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
2
3
|
import { FormSubmission, FormVersion } from "../data/entities.js";
|
|
3
4
|
import { isAttachmentServiceError } from "../services/attachment-service.js";
|
|
4
5
|
import { resolveMaxUploadBytes } from "../services/upload-validation.js";
|
|
@@ -14,6 +15,28 @@ function mapAttachmentError(error) {
|
|
|
14
15
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
15
16
|
return NextResponse.json({ error: "INTERNAL_ERROR", message }, { status: 500 });
|
|
16
17
|
}
|
|
18
|
+
async function resolveAttachmentRolePolicy(em, args, compiler, rolePolicy) {
|
|
19
|
+
if (!args.role) return null;
|
|
20
|
+
const scope = { organizationId: args.organizationId, tenantId: args.tenantId };
|
|
21
|
+
const submission = await findOneWithDecryption(em, FormSubmission, {
|
|
22
|
+
id: args.submissionId,
|
|
23
|
+
...scope,
|
|
24
|
+
deletedAt: null
|
|
25
|
+
}, void 0, scope);
|
|
26
|
+
if (!submission) return null;
|
|
27
|
+
const version = await findOneWithDecryption(em, FormVersion, {
|
|
28
|
+
id: submission.formVersionId,
|
|
29
|
+
...scope
|
|
30
|
+
}, void 0, scope);
|
|
31
|
+
if (!version) return null;
|
|
32
|
+
const compiled = compiler.compile({
|
|
33
|
+
id: version.id,
|
|
34
|
+
updatedAt: version.updatedAt,
|
|
35
|
+
schema: version.schema,
|
|
36
|
+
uiSchema: version.uiSchema
|
|
37
|
+
});
|
|
38
|
+
return rolePolicy.resolve(compiled, args.role);
|
|
39
|
+
}
|
|
17
40
|
async function resolveFieldUploadConfig(em, args) {
|
|
18
41
|
const submission = await em.findOne(FormSubmission, {
|
|
19
42
|
id: args.submissionId,
|
|
@@ -106,6 +129,7 @@ export {
|
|
|
106
129
|
SANDBOXED_DOWNLOAD_HEADERS,
|
|
107
130
|
mapAttachmentError,
|
|
108
131
|
parseUploadBody,
|
|
132
|
+
resolveAttachmentRolePolicy,
|
|
109
133
|
resolveFieldUploadConfig
|
|
110
134
|
};
|
|
111
135
|
//# sourceMappingURL=attachment-helpers.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/forms/api/attachment-helpers.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Shared helpers for the W4 attachment upload / download routes.\n *\n * Resolves the per-field upload config (`x-om-accept`, `x-om-max-size-bytes`,\n * `x-om-multiple`) from the submission's pinned form version \u2014 the server is\n * authoritative; the client never supplies these constraints.\n */\n\nimport { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { FormSubmission, FormVersion } from '../data/entities'\nimport { AttachmentServiceError, isAttachmentServiceError } from '../services/attachment-service'\nimport { resolveMaxUploadBytes } from '../services/upload-validation'\n\nconst MULTIPART_OVERHEAD_BYTES = 64 * 1024\n\nexport const SANDBOXED_DOWNLOAD_HEADERS = {\n 'content-security-policy': \"default-src 'none'; sandbox\",\n 'x-content-type-options': 'nosniff',\n} as const\n\nexport type FieldUploadConfig = {\n fieldKey: string\n accept: string[] | null\n maxSizeBytes: number | null\n multiple: boolean\n}\n\nexport function mapAttachmentError(error: unknown): NextResponse {\n if (isAttachmentServiceError(error)) {\n return NextResponse.json({ error: error.code, message: error.message }, { status: error.httpStatus })\n }\n const message = error instanceof Error ? error.message : 'Unknown error'\n return NextResponse.json({ error: 'INTERNAL_ERROR', message }, { status: 500 })\n}\n\n/**\n * Looks up the `file`-typed field node on the submission's pinned form version\n * and reads its upload config. Returns `null` when the submission, version, or\n * field is missing, or when the field is not a `file` type. Scope is enforced\n * by org+tenant on both reads.\n */\nexport async function resolveFieldUploadConfig(\n em: EntityManager,\n args: { organizationId: string; tenantId: string; submissionId: string; fieldKey: string },\n): Promise<FieldUploadConfig | null> {\n const submission = await em.findOne(FormSubmission, {\n id: args.submissionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n deletedAt: null,\n })\n if (!submission) return null\n\n const version = await em.findOne(FormVersion, {\n id: submission.formVersionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n })\n if (!version) return null\n\n const properties = (version.schema as Record<string, unknown>).properties\n if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return null\n const node = (properties as Record<string, unknown>)[args.fieldKey]\n if (!node || typeof node !== 'object' || Array.isArray(node)) return null\n\n const record = node as Record<string, unknown>\n if (record['x-om-type'] !== 'file') return null\n\n const acceptRaw = record['x-om-accept']\n const accept = Array.isArray(acceptRaw)\n ? acceptRaw.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)\n : null\n const maxRaw = record['x-om-max-size-bytes']\n const maxSizeBytes = typeof maxRaw === 'number' && Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : null\n\n return {\n fieldKey: args.fieldKey,\n accept: accept && accept.length > 0 ? accept : null,\n maxSizeBytes,\n multiple: record['x-om-multiple'] === true,\n }\n}\n\nexport type ParsedUpload = {\n fieldKey: string\n filename: string\n contentType: string\n bytes: Buffer\n}\n\n/**\n * Parses a multipart/form-data upload body. Expects a `file` part and a\n * `field_key` text part. Returns a 422 response describing the failure when\n * the body is malformed.\n */\nexport async function parseUploadBody(\n req: Request,\n maxFileBytes: number = resolveMaxUploadBytes(process.env),\n): Promise<ParsedUpload | NextResponse> {\n const maxBodyBytes = maxFileBytes + MULTIPART_OVERHEAD_BYTES\n const declaredLength = Number(req.headers.get('content-length'))\n if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n\n const boundedBody = await readBoundedBody(req, maxBodyBytes)\n if (boundedBody instanceof NextResponse) return boundedBody\n\n let form: FormData\n try {\n const boundedRequest = new Request(req.url, {\n method: req.method,\n headers: req.headers,\n body: boundedBody as unknown as BodyInit,\n })\n form = await boundedRequest.formData()\n } catch {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Expected multipart/form-data.' }, { status: 422 })\n }\n const fieldKeyRaw = form.get('field_key')\n if (typeof fieldKeyRaw !== 'string' || fieldKeyRaw.length === 0) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Missing field_key.' }, { status: 422 })\n }\n const filePart = form.get('file')\n if (!(filePart instanceof File)) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Missing file part.' }, { status: 422 })\n }\n if (filePart.size > maxFileBytes) {\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n const arrayBuffer = await filePart.arrayBuffer()\n return {\n fieldKey: fieldKeyRaw,\n filename: filePart.name || 'upload',\n contentType: filePart.type || 'application/octet-stream',\n bytes: Buffer.from(arrayBuffer),\n }\n}\n\nasync function readBoundedBody(req: Request, maxBodyBytes: number): Promise<Buffer | NextResponse> {\n if (!req.body) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Expected multipart/form-data.' }, { status: 422 })\n }\n\n const reader = req.body.getReader()\n const chunks: Buffer[] = []\n let totalBytes = 0\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBodyBytes) {\n await reader.cancel()\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n chunks.push(Buffer.from(value))\n }\n return Buffer.concat(chunks, totalBytes)\n}\n"],
|
|
5
|
-
"mappings": "AAQA,SAAS,oBAAoB;AAE7B,SAAS,gBAAgB,mBAAmB;
|
|
4
|
+
"sourcesContent": ["/**\n * Shared helpers for the W4 attachment upload / download routes.\n *\n * Resolves the per-field upload config (`x-om-accept`, `x-om-max-size-bytes`,\n * `x-om-multiple`) from the submission's pinned form version \u2014 the server is\n * authoritative; the client never supplies these constraints.\n */\n\nimport { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { FormSubmission, FormVersion } from '../data/entities'\nimport type { FormVersionCompiler } from '../services/form-version-compiler'\nimport type { RolePolicyResolution, RolePolicyService } from '../services/role-policy-service'\nimport { AttachmentServiceError, isAttachmentServiceError } from '../services/attachment-service'\nimport { resolveMaxUploadBytes } from '../services/upload-validation'\n\nconst MULTIPART_OVERHEAD_BYTES = 64 * 1024\n\nexport const SANDBOXED_DOWNLOAD_HEADERS = {\n 'content-security-policy': \"default-src 'none'; sandbox\",\n 'x-content-type-options': 'nosniff',\n} as const\n\nexport type FieldUploadConfig = {\n fieldKey: string\n accept: string[] | null\n maxSizeBytes: number | null\n multiple: boolean\n}\n\nexport function mapAttachmentError(error: unknown): NextResponse {\n if (isAttachmentServiceError(error)) {\n return NextResponse.json({ error: error.code, message: error.message }, { status: error.httpStatus })\n }\n const message = error instanceof Error ? error.message : 'Unknown error'\n return NextResponse.json({ error: 'INTERNAL_ERROR', message }, { status: 500 })\n}\n\nexport async function resolveAttachmentRolePolicy(\n em: EntityManager,\n args: { organizationId: string; tenantId: string; submissionId: string; role: string | null },\n compiler: FormVersionCompiler,\n rolePolicy: RolePolicyService,\n): Promise<RolePolicyResolution | null> {\n if (!args.role) return null\n\n const scope = { organizationId: args.organizationId, tenantId: args.tenantId }\n const submission = await findOneWithDecryption(em, FormSubmission, {\n id: args.submissionId,\n ...scope,\n deletedAt: null,\n }, undefined, scope)\n if (!submission) return null\n\n const version = await findOneWithDecryption(em, FormVersion, {\n id: submission.formVersionId,\n ...scope,\n }, undefined, scope)\n if (!version) return null\n\n const compiled = compiler.compile({\n id: version.id,\n updatedAt: version.updatedAt,\n schema: version.schema,\n uiSchema: version.uiSchema,\n })\n return rolePolicy.resolve(compiled, args.role)\n}\n\n/**\n * Looks up the `file`-typed field node on the submission's pinned form version\n * and reads its upload config. Returns `null` when the submission, version, or\n * field is missing, or when the field is not a `file` type. Scope is enforced\n * by org+tenant on both reads.\n */\nexport async function resolveFieldUploadConfig(\n em: EntityManager,\n args: { organizationId: string; tenantId: string; submissionId: string; fieldKey: string },\n): Promise<FieldUploadConfig | null> {\n const submission = await em.findOne(FormSubmission, {\n id: args.submissionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n deletedAt: null,\n })\n if (!submission) return null\n\n const version = await em.findOne(FormVersion, {\n id: submission.formVersionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n })\n if (!version) return null\n\n const properties = (version.schema as Record<string, unknown>).properties\n if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return null\n const node = (properties as Record<string, unknown>)[args.fieldKey]\n if (!node || typeof node !== 'object' || Array.isArray(node)) return null\n\n const record = node as Record<string, unknown>\n if (record['x-om-type'] !== 'file') return null\n\n const acceptRaw = record['x-om-accept']\n const accept = Array.isArray(acceptRaw)\n ? acceptRaw.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)\n : null\n const maxRaw = record['x-om-max-size-bytes']\n const maxSizeBytes = typeof maxRaw === 'number' && Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : null\n\n return {\n fieldKey: args.fieldKey,\n accept: accept && accept.length > 0 ? accept : null,\n maxSizeBytes,\n multiple: record['x-om-multiple'] === true,\n }\n}\n\nexport type ParsedUpload = {\n fieldKey: string\n filename: string\n contentType: string\n bytes: Buffer\n}\n\n/**\n * Parses a multipart/form-data upload body. Expects a `file` part and a\n * `field_key` text part. Returns a 422 response describing the failure when\n * the body is malformed.\n */\nexport async function parseUploadBody(\n req: Request,\n maxFileBytes: number = resolveMaxUploadBytes(process.env),\n): Promise<ParsedUpload | NextResponse> {\n const maxBodyBytes = maxFileBytes + MULTIPART_OVERHEAD_BYTES\n const declaredLength = Number(req.headers.get('content-length'))\n if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n\n const boundedBody = await readBoundedBody(req, maxBodyBytes)\n if (boundedBody instanceof NextResponse) return boundedBody\n\n let form: FormData\n try {\n const boundedRequest = new Request(req.url, {\n method: req.method,\n headers: req.headers,\n body: boundedBody as unknown as BodyInit,\n })\n form = await boundedRequest.formData()\n } catch {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Expected multipart/form-data.' }, { status: 422 })\n }\n const fieldKeyRaw = form.get('field_key')\n if (typeof fieldKeyRaw !== 'string' || fieldKeyRaw.length === 0) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Missing field_key.' }, { status: 422 })\n }\n const filePart = form.get('file')\n if (!(filePart instanceof File)) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Missing file part.' }, { status: 422 })\n }\n if (filePart.size > maxFileBytes) {\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n const arrayBuffer = await filePart.arrayBuffer()\n return {\n fieldKey: fieldKeyRaw,\n filename: filePart.name || 'upload',\n contentType: filePart.type || 'application/octet-stream',\n bytes: Buffer.from(arrayBuffer),\n }\n}\n\nasync function readBoundedBody(req: Request, maxBodyBytes: number): Promise<Buffer | NextResponse> {\n if (!req.body) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Expected multipart/form-data.' }, { status: 422 })\n }\n\n const reader = req.body.getReader()\n const chunks: Buffer[] = []\n let totalBytes = 0\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBodyBytes) {\n await reader.cancel()\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n chunks.push(Buffer.from(value))\n }\n return Buffer.concat(chunks, totalBytes)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
3
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
4
|
+
import { FormAttachment } from "../../../../../../data/entities.js";
|
|
3
5
|
import { resolveRuntimePrincipal } from "../../../../../../lib/runtime-principal.js";
|
|
4
6
|
import {
|
|
5
7
|
mapAttachmentError,
|
|
8
|
+
resolveAttachmentRolePolicy,
|
|
6
9
|
SANDBOXED_DOWNLOAD_HEADERS
|
|
7
10
|
} from "../../../../../attachment-helpers.js";
|
|
8
11
|
import { buildPublicRateLimitKey, enforcePublicRateLimit } from "../../../../rate-limit.js";
|
|
@@ -24,6 +27,28 @@ async function GET(req, context) {
|
|
|
24
27
|
const principal = await resolveRuntimePrincipal({ req, submissionId, em });
|
|
25
28
|
if (!principal) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
|
26
29
|
try {
|
|
30
|
+
const scope = { organizationId: principal.organizationId, tenantId: principal.tenantId };
|
|
31
|
+
const policy = await resolveAttachmentRolePolicy(
|
|
32
|
+
em,
|
|
33
|
+
{
|
|
34
|
+
...scope,
|
|
35
|
+
submissionId,
|
|
36
|
+
role: principal.role
|
|
37
|
+
},
|
|
38
|
+
container.resolve("formVersionCompiler"),
|
|
39
|
+
container.resolve("formsRolePolicyService")
|
|
40
|
+
);
|
|
41
|
+
if (!policy) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
|
42
|
+
const attachment = await findOneWithDecryption(em, FormAttachment, {
|
|
43
|
+
id: attachmentId,
|
|
44
|
+
submissionId,
|
|
45
|
+
organizationId: principal.organizationId,
|
|
46
|
+
kind: "user_upload",
|
|
47
|
+
removedAt: null
|
|
48
|
+
}, { fields: ["fieldKey"] }, scope);
|
|
49
|
+
if (!attachment || !policy.canRead(attachment.fieldKey)) {
|
|
50
|
+
return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
|
51
|
+
}
|
|
27
52
|
const result = await service.readUpload({
|
|
28
53
|
organizationId: principal.organizationId,
|
|
29
54
|
tenantId: principal.tenantId,
|
|
@@ -45,7 +70,7 @@ async function GET(req, context) {
|
|
|
45
70
|
}
|
|
46
71
|
const getMethodDoc = {
|
|
47
72
|
summary: "Download a submission attachment (public/anonymous)",
|
|
48
|
-
description: "Streams
|
|
73
|
+
description: "Streams decrypted user-upload bytes only when the participant role can read the attachment field in the submission\u2019s pinned version. Authorized via access token or portal session.",
|
|
49
74
|
tags: ["Forms Public Runtime"],
|
|
50
75
|
responses: [{ status: 200, description: "Attachment stream", mediaType: "application/octet-stream" }],
|
|
51
76
|
errors: [
|
package/dist/modules/forms/api/public/submissions/[id]/attachments/[attachmentId]/route.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../../src/modules/forms/api/public/submissions/%5Bid%5D/attachments/%5BattachmentId%5D/route.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Public/anonymous attachment download \u2014\n * GET /api/forms/public/submissions/:id/attachments/:attachmentId\n *\n * Streams a decrypted user-upload attachment back to the owning participant so\n * they can preview / confirm what they uploaded. Authorized via\n * `resolveRuntimePrincipal` (bearer access token OR portal session); org/tenant\n * are re-derived from the persisted submission (R-2d-4). Cross-tenant ids\n * return 404 (no enumeration).\n */\n\nimport { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AttachmentService } from '../../../../../../services/attachment-service'\nimport { resolveRuntimePrincipal } from '../../../../../../lib/runtime-principal'\nimport {\n mapAttachmentError,\n SANDBOXED_DOWNLOAD_HEADERS,\n} from '../../../../../attachment-helpers'\nimport { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../../rate-limit'\n\nexport const metadata = {\n GET: { requireAuth: false },\n}\n\nexport async function GET(\n req: NextRequest,\n context: { params: { id: string; attachmentId: string } | Promise<{ id: string; attachmentId: string }> },\n) {\n const params = await Promise.resolve(context.params)\n const submissionId = String(params.id)\n const attachmentId = String(params.attachmentId)\n\n const limited = await enforcePublicRateLimit(\n req,\n buildPublicRateLimitKey('submission-attachment', submissionId, attachmentId),\n )\n if (limited) return limited\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = container.resolve('formsAttachmentService') as AttachmentService\n\n const principal = await resolveRuntimePrincipal({ req, submissionId, em })\n if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })\n\n try {\n const result = await service.readUpload({\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n attachmentId,\n })\n const safeName = result.filename.replace(/[\"\\\\\\r\\n]/g, '_') || 'attachment'\n return new NextResponse(result.bytes as unknown as BodyInit, {\n status: 200,\n headers: {\n 'content-type': result.contentType || 'application/octet-stream',\n 'content-disposition': `attachment; filename=\"${safeName}\"`,\n ...SANDBOXED_DOWNLOAD_HEADERS,\n },\n })\n } catch (error) {\n return mapAttachmentError(error)\n }\n}\n\nconst getMethodDoc: OpenApiMethodDoc = {\n summary: 'Download a submission attachment (public/anonymous)',\n description: 'Streams
|
|
5
|
-
"mappings": "AAWA,SAAS,oBAAsC;AAG/C,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["/**\n * Public/anonymous attachment download \u2014\n * GET /api/forms/public/submissions/:id/attachments/:attachmentId\n *\n * Streams a decrypted user-upload attachment back to the owning participant so\n * they can preview / confirm what they uploaded. Authorized via\n * `resolveRuntimePrincipal` (bearer access token OR portal session); org/tenant\n * are re-derived from the persisted submission (R-2d-4). Cross-tenant ids\n * return 404 (no enumeration).\n */\n\nimport { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { FormAttachment } from '../../../../../../data/entities'\nimport type { AttachmentService } from '../../../../../../services/attachment-service'\nimport type { FormVersionCompiler } from '../../../../../../services/form-version-compiler'\nimport type { RolePolicyService } from '../../../../../../services/role-policy-service'\nimport { resolveRuntimePrincipal } from '../../../../../../lib/runtime-principal'\nimport {\n mapAttachmentError,\n resolveAttachmentRolePolicy,\n SANDBOXED_DOWNLOAD_HEADERS,\n} from '../../../../../attachment-helpers'\nimport { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../../rate-limit'\n\nexport const metadata = {\n GET: { requireAuth: false },\n}\n\nexport async function GET(\n req: NextRequest,\n context: { params: { id: string; attachmentId: string } | Promise<{ id: string; attachmentId: string }> },\n) {\n const params = await Promise.resolve(context.params)\n const submissionId = String(params.id)\n const attachmentId = String(params.attachmentId)\n\n const limited = await enforcePublicRateLimit(\n req,\n buildPublicRateLimitKey('submission-attachment', submissionId, attachmentId),\n )\n if (limited) return limited\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = container.resolve('formsAttachmentService') as AttachmentService\n\n const principal = await resolveRuntimePrincipal({ req, submissionId, em })\n if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })\n\n try {\n const scope = { organizationId: principal.organizationId, tenantId: principal.tenantId }\n const policy = await resolveAttachmentRolePolicy(em, {\n ...scope,\n submissionId,\n role: principal.role,\n }, container.resolve('formVersionCompiler') as FormVersionCompiler,\n container.resolve('formsRolePolicyService') as RolePolicyService)\n if (!policy) return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })\n\n const attachment = await findOneWithDecryption(em, FormAttachment, {\n id: attachmentId,\n submissionId,\n organizationId: principal.organizationId,\n kind: 'user_upload',\n removedAt: null,\n }, { fields: ['fieldKey'] }, scope)\n if (!attachment || !policy.canRead(attachment.fieldKey)) {\n return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })\n }\n\n const result = await service.readUpload({\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n attachmentId,\n })\n const safeName = result.filename.replace(/[\"\\\\\\r\\n]/g, '_') || 'attachment'\n return new NextResponse(result.bytes as unknown as BodyInit, {\n status: 200,\n headers: {\n 'content-type': result.contentType || 'application/octet-stream',\n 'content-disposition': `attachment; filename=\"${safeName}\"`,\n ...SANDBOXED_DOWNLOAD_HEADERS,\n },\n })\n } catch (error) {\n return mapAttachmentError(error)\n }\n}\n\nconst getMethodDoc: OpenApiMethodDoc = {\n summary: 'Download a submission attachment (public/anonymous)',\n description: 'Streams decrypted user-upload bytes only when the participant role can read the attachment field in the submission\u2019s pinned version. Authorized via access token or portal session.',\n tags: ['Forms Public Runtime'],\n responses: [{ status: 200, description: 'Attachment stream', mediaType: 'application/octet-stream' }],\n errors: [\n { status: 401, description: 'Missing or invalid access token / session' },\n { status: 404, description: 'Attachment not found' },\n { status: 429, description: 'Rate limit exceeded' },\n { status: 503, description: 'Rate limiting unavailable' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Public attachment download',\n methods: { GET: getMethodDoc },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,6 +5,7 @@ import { resolveRuntimePrincipal } from "../../../../../lib/runtime-principal.js
|
|
|
5
5
|
import {
|
|
6
6
|
mapAttachmentError,
|
|
7
7
|
parseUploadBody,
|
|
8
|
+
resolveAttachmentRolePolicy,
|
|
8
9
|
resolveFieldUploadConfig
|
|
9
10
|
} from "../../../../attachment-helpers.js";
|
|
10
11
|
import { buildPublicRateLimitKey, enforcePublicRateLimit } from "../../../rate-limit.js";
|
|
@@ -26,6 +27,20 @@ async function POST(req, context) {
|
|
|
26
27
|
if (!principal) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
|
27
28
|
const parsed = await parseUploadBody(req);
|
|
28
29
|
if (parsed instanceof NextResponse) return parsed;
|
|
30
|
+
const policy = await resolveAttachmentRolePolicy(
|
|
31
|
+
em,
|
|
32
|
+
{
|
|
33
|
+
organizationId: principal.organizationId,
|
|
34
|
+
tenantId: principal.tenantId,
|
|
35
|
+
submissionId,
|
|
36
|
+
role: principal.role
|
|
37
|
+
},
|
|
38
|
+
container.resolve("formVersionCompiler"),
|
|
39
|
+
container.resolve("formsRolePolicyService")
|
|
40
|
+
);
|
|
41
|
+
if (!policy?.canWrite(parsed.fieldKey)) {
|
|
42
|
+
return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
|
43
|
+
}
|
|
29
44
|
const config = await resolveFieldUploadConfig(em, {
|
|
30
45
|
organizationId: principal.organizationId,
|
|
31
46
|
tenantId: principal.tenantId,
|
|
@@ -62,11 +77,12 @@ const uploadResponseSchema = z.object({
|
|
|
62
77
|
});
|
|
63
78
|
const postMethodDoc = {
|
|
64
79
|
summary: "Upload an attachment (public/anonymous)",
|
|
65
|
-
description: "Stores an encrypted file for a file-typed field. Authorized via access token or portal session.",
|
|
80
|
+
description: "Stores an encrypted file for a file-typed field the participant role can edit in the submission\u2019s pinned version. Authorized via access token or portal session.",
|
|
66
81
|
tags: ["Forms Public Runtime"],
|
|
67
82
|
responses: [{ status: 201, description: "Attachment stored", schema: uploadResponseSchema }],
|
|
68
83
|
errors: [
|
|
69
84
|
{ status: 401, description: "Missing or invalid access token / session", schema: errorSchema },
|
|
85
|
+
{ status: 404, description: "Field not found or not editable by the participant role", schema: errorSchema },
|
|
70
86
|
{ status: 413, description: "File empty or oversize", schema: errorSchema },
|
|
71
87
|
{ status: 422, description: "Disallowed type / invalid field / scan rejected", schema: errorSchema },
|
|
72
88
|
{ status: 429, description: "Rate limit exceeded", schema: errorSchema },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/forms/api/public/submissions/%5Bid%5D/attachments/route.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Public/anonymous attachment upload \u2014\n * POST /api/forms/public/submissions/:id/attachments\n *\n * Token- or portal-authenticated participants upload a file for a `file`-typed\n * field. Authorization goes through `resolveRuntimePrincipal` (bearer access\n * token OR customer session). Org/tenant are ALWAYS re-derived from the\n * persisted submission row (R-2d-4) \u2014 never trusted from the client. Bytes are\n * encrypted at rest and gated by MIME allowlist, size ceiling, and the\n * pluggable virus scanner (W4 / SEC-4).\n */\n\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AttachmentService } from '../../../../../services/attachment-service'\nimport { resolveRuntimePrincipal } from '../../../../../lib/runtime-principal'\nimport {\n mapAttachmentError,\n parseUploadBody,\n resolveFieldUploadConfig,\n} from '../../../../attachment-helpers'\nimport { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../rate-limit'\n\nexport const metadata = {\n POST: { requireAuth: false },\n}\n\nexport async function POST(\n req: NextRequest,\n context: { params: { id: string } | Promise<{ id: string }> },\n) {\n const params = await Promise.resolve(context.params)\n const submissionId = String(params.id)\n\n const limited = await enforcePublicRateLimit(\n req,\n buildPublicRateLimitKey('submission-upload', submissionId),\n )\n if (limited) return limited\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = container.resolve('formsAttachmentService') as AttachmentService\n\n const principal = await resolveRuntimePrincipal({ req, submissionId, em })\n if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })\n\n const parsed = await parseUploadBody(req)\n if (parsed instanceof NextResponse) return parsed\n\n const config = await resolveFieldUploadConfig(em, {\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n fieldKey: parsed.fieldKey,\n })\n if (!config) {\n return NextResponse.json({ error: 'INVALID_FIELD' }, { status: 422 })\n }\n\n try {\n const stored = await service.storeUpload({\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n fieldKey: parsed.fieldKey,\n filename: parsed.filename,\n contentType: parsed.contentType,\n bytes: parsed.bytes,\n uploadedBy: principal.source === 'customer' ? principal.principal : null,\n accept: config.accept,\n fieldMaxSizeBytes: config.maxSizeBytes,\n })\n return NextResponse.json(stored, { status: 201 })\n } catch (error) {\n return mapAttachmentError(error)\n }\n}\n\nconst errorSchema = z.object({ error: z.string(), message: z.string().optional() })\nconst uploadResponseSchema = z.object({\n id: z.string(),\n filename: z.string(),\n contentType: z.string(),\n sizeBytes: z.number(),\n})\n\nconst postMethodDoc: OpenApiMethodDoc = {\n summary: 'Upload an attachment (public/anonymous)',\n description: 'Stores an encrypted file for a file-typed field. Authorized via access token or portal session.',\n tags: ['Forms Public Runtime'],\n responses: [{ status: 201, description: 'Attachment stored', schema: uploadResponseSchema }],\n errors: [\n { status: 401, description: 'Missing or invalid access token / session', schema: errorSchema },\n { status: 413, description: 'File empty or oversize', schema: errorSchema },\n { status: 422, description: 'Disallowed type / invalid field / scan rejected', schema: errorSchema },\n { status: 429, description: 'Rate limit exceeded', schema: errorSchema },\n { status: 503, description: 'Rate limiting unavailable', schema: errorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Public attachment upload',\n methods: { POST: postMethodDoc },\n}\n"],
|
|
5
|
-
"mappings": "AAYA,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAGlB,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["/**\n * Public/anonymous attachment upload \u2014\n * POST /api/forms/public/submissions/:id/attachments\n *\n * Token- or portal-authenticated participants upload a file for a `file`-typed\n * field. Authorization goes through `resolveRuntimePrincipal` (bearer access\n * token OR customer session). Org/tenant are ALWAYS re-derived from the\n * persisted submission row (R-2d-4) \u2014 never trusted from the client. Bytes are\n * encrypted at rest and gated by MIME allowlist, size ceiling, and the\n * pluggable virus scanner (W4 / SEC-4).\n */\n\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AttachmentService } from '../../../../../services/attachment-service'\nimport type { FormVersionCompiler } from '../../../../../services/form-version-compiler'\nimport type { RolePolicyService } from '../../../../../services/role-policy-service'\nimport { resolveRuntimePrincipal } from '../../../../../lib/runtime-principal'\nimport {\n mapAttachmentError,\n parseUploadBody,\n resolveAttachmentRolePolicy,\n resolveFieldUploadConfig,\n} from '../../../../attachment-helpers'\nimport { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../rate-limit'\n\nexport const metadata = {\n POST: { requireAuth: false },\n}\n\nexport async function POST(\n req: NextRequest,\n context: { params: { id: string } | Promise<{ id: string }> },\n) {\n const params = await Promise.resolve(context.params)\n const submissionId = String(params.id)\n\n const limited = await enforcePublicRateLimit(\n req,\n buildPublicRateLimitKey('submission-upload', submissionId),\n )\n if (limited) return limited\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = container.resolve('formsAttachmentService') as AttachmentService\n\n const principal = await resolveRuntimePrincipal({ req, submissionId, em })\n if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })\n\n const parsed = await parseUploadBody(req)\n if (parsed instanceof NextResponse) return parsed\n\n const policy = await resolveAttachmentRolePolicy(em, {\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n role: principal.role,\n }, container.resolve('formVersionCompiler') as FormVersionCompiler,\n container.resolve('formsRolePolicyService') as RolePolicyService)\n if (!policy?.canWrite(parsed.fieldKey)) {\n return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })\n }\n\n const config = await resolveFieldUploadConfig(em, {\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n fieldKey: parsed.fieldKey,\n })\n if (!config) {\n return NextResponse.json({ error: 'INVALID_FIELD' }, { status: 422 })\n }\n\n try {\n const stored = await service.storeUpload({\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n fieldKey: parsed.fieldKey,\n filename: parsed.filename,\n contentType: parsed.contentType,\n bytes: parsed.bytes,\n uploadedBy: principal.source === 'customer' ? principal.principal : null,\n accept: config.accept,\n fieldMaxSizeBytes: config.maxSizeBytes,\n })\n return NextResponse.json(stored, { status: 201 })\n } catch (error) {\n return mapAttachmentError(error)\n }\n}\n\nconst errorSchema = z.object({ error: z.string(), message: z.string().optional() })\nconst uploadResponseSchema = z.object({\n id: z.string(),\n filename: z.string(),\n contentType: z.string(),\n sizeBytes: z.number(),\n})\n\nconst postMethodDoc: OpenApiMethodDoc = {\n summary: 'Upload an attachment (public/anonymous)',\n description: 'Stores an encrypted file for a file-typed field the participant role can edit in the submission\u2019s pinned version. Authorized via access token or portal session.',\n tags: ['Forms Public Runtime'],\n responses: [{ status: 201, description: 'Attachment stored', schema: uploadResponseSchema }],\n errors: [\n { status: 401, description: 'Missing or invalid access token / session', schema: errorSchema },\n { status: 404, description: 'Field not found or not editable by the participant role', schema: errorSchema },\n { status: 413, description: 'File empty or oversize', schema: errorSchema },\n { status: 422, description: 'Disallowed type / invalid field / scan rejected', schema: errorSchema },\n { status: 429, description: 'Rate limit exceeded', schema: errorSchema },\n { status: 503, description: 'Rate limiting unavailable', schema: errorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Public attachment upload',\n methods: { POST: postMethodDoc },\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.
|
|
3
|
+
"version": "0.10.1-develop.7798.1.dddc3c71f6",
|
|
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.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7798.1.dddc3c71f6",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7798.1.dddc3c71f6",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7798.1.dddc3c71f6",
|
|
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.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7798.1.dddc3c71f6",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7798.1.dddc3c71f6",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7798.1.dddc3c71f6",
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
await
|
|
106
|
-
return { session
|
|
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) {
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { NextResponse } from 'next/server'
|
|
10
10
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
11
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
11
12
|
import { FormSubmission, FormVersion } from '../data/entities'
|
|
13
|
+
import type { FormVersionCompiler } from '../services/form-version-compiler'
|
|
14
|
+
import type { RolePolicyResolution, RolePolicyService } from '../services/role-policy-service'
|
|
12
15
|
import { AttachmentServiceError, isAttachmentServiceError } from '../services/attachment-service'
|
|
13
16
|
import { resolveMaxUploadBytes } from '../services/upload-validation'
|
|
14
17
|
|
|
@@ -34,6 +37,37 @@ export function mapAttachmentError(error: unknown): NextResponse {
|
|
|
34
37
|
return NextResponse.json({ error: 'INTERNAL_ERROR', message }, { status: 500 })
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
export async function resolveAttachmentRolePolicy(
|
|
41
|
+
em: EntityManager,
|
|
42
|
+
args: { organizationId: string; tenantId: string; submissionId: string; role: string | null },
|
|
43
|
+
compiler: FormVersionCompiler,
|
|
44
|
+
rolePolicy: RolePolicyService,
|
|
45
|
+
): Promise<RolePolicyResolution | null> {
|
|
46
|
+
if (!args.role) return null
|
|
47
|
+
|
|
48
|
+
const scope = { organizationId: args.organizationId, tenantId: args.tenantId }
|
|
49
|
+
const submission = await findOneWithDecryption(em, FormSubmission, {
|
|
50
|
+
id: args.submissionId,
|
|
51
|
+
...scope,
|
|
52
|
+
deletedAt: null,
|
|
53
|
+
}, undefined, scope)
|
|
54
|
+
if (!submission) return null
|
|
55
|
+
|
|
56
|
+
const version = await findOneWithDecryption(em, FormVersion, {
|
|
57
|
+
id: submission.formVersionId,
|
|
58
|
+
...scope,
|
|
59
|
+
}, undefined, scope)
|
|
60
|
+
if (!version) return null
|
|
61
|
+
|
|
62
|
+
const compiled = compiler.compile({
|
|
63
|
+
id: version.id,
|
|
64
|
+
updatedAt: version.updatedAt,
|
|
65
|
+
schema: version.schema,
|
|
66
|
+
uiSchema: version.uiSchema,
|
|
67
|
+
})
|
|
68
|
+
return rolePolicy.resolve(compiled, args.role)
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
/**
|
|
38
72
|
* Looks up the `file`-typed field node on the submission's pinned form version
|
|
39
73
|
* and reads its upload config. Returns `null` when the submission, version, or
|
|
@@ -13,10 +13,15 @@ import { NextResponse, type NextRequest } from 'next/server'
|
|
|
13
13
|
import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'
|
|
14
14
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
15
15
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
16
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
17
|
+
import { FormAttachment } from '../../../../../../data/entities'
|
|
16
18
|
import type { AttachmentService } from '../../../../../../services/attachment-service'
|
|
19
|
+
import type { FormVersionCompiler } from '../../../../../../services/form-version-compiler'
|
|
20
|
+
import type { RolePolicyService } from '../../../../../../services/role-policy-service'
|
|
17
21
|
import { resolveRuntimePrincipal } from '../../../../../../lib/runtime-principal'
|
|
18
22
|
import {
|
|
19
23
|
mapAttachmentError,
|
|
24
|
+
resolveAttachmentRolePolicy,
|
|
20
25
|
SANDBOXED_DOWNLOAD_HEADERS,
|
|
21
26
|
} from '../../../../../attachment-helpers'
|
|
22
27
|
import { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../../rate-limit'
|
|
@@ -47,6 +52,26 @@ export async function GET(
|
|
|
47
52
|
if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })
|
|
48
53
|
|
|
49
54
|
try {
|
|
55
|
+
const scope = { organizationId: principal.organizationId, tenantId: principal.tenantId }
|
|
56
|
+
const policy = await resolveAttachmentRolePolicy(em, {
|
|
57
|
+
...scope,
|
|
58
|
+
submissionId,
|
|
59
|
+
role: principal.role,
|
|
60
|
+
}, container.resolve('formVersionCompiler') as FormVersionCompiler,
|
|
61
|
+
container.resolve('formsRolePolicyService') as RolePolicyService)
|
|
62
|
+
if (!policy) return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })
|
|
63
|
+
|
|
64
|
+
const attachment = await findOneWithDecryption(em, FormAttachment, {
|
|
65
|
+
id: attachmentId,
|
|
66
|
+
submissionId,
|
|
67
|
+
organizationId: principal.organizationId,
|
|
68
|
+
kind: 'user_upload',
|
|
69
|
+
removedAt: null,
|
|
70
|
+
}, { fields: ['fieldKey'] }, scope)
|
|
71
|
+
if (!attachment || !policy.canRead(attachment.fieldKey)) {
|
|
72
|
+
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
const result = await service.readUpload({
|
|
51
76
|
organizationId: principal.organizationId,
|
|
52
77
|
tenantId: principal.tenantId,
|
|
@@ -69,7 +94,7 @@ export async function GET(
|
|
|
69
94
|
|
|
70
95
|
const getMethodDoc: OpenApiMethodDoc = {
|
|
71
96
|
summary: 'Download a submission attachment (public/anonymous)',
|
|
72
|
-
description: 'Streams
|
|
97
|
+
description: 'Streams decrypted user-upload bytes only when the participant role can read the attachment field in the submission’s pinned version. Authorized via access token or portal session.',
|
|
73
98
|
tags: ['Forms Public Runtime'],
|
|
74
99
|
responses: [{ status: 200, description: 'Attachment stream', mediaType: 'application/octet-stream' }],
|
|
75
100
|
errors: [
|
|
@@ -16,10 +16,13 @@ import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib
|
|
|
16
16
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
17
17
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
18
18
|
import type { AttachmentService } from '../../../../../services/attachment-service'
|
|
19
|
+
import type { FormVersionCompiler } from '../../../../../services/form-version-compiler'
|
|
20
|
+
import type { RolePolicyService } from '../../../../../services/role-policy-service'
|
|
19
21
|
import { resolveRuntimePrincipal } from '../../../../../lib/runtime-principal'
|
|
20
22
|
import {
|
|
21
23
|
mapAttachmentError,
|
|
22
24
|
parseUploadBody,
|
|
25
|
+
resolveAttachmentRolePolicy,
|
|
23
26
|
resolveFieldUploadConfig,
|
|
24
27
|
} from '../../../../attachment-helpers'
|
|
25
28
|
import { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../rate-limit'
|
|
@@ -51,6 +54,17 @@ export async function POST(
|
|
|
51
54
|
const parsed = await parseUploadBody(req)
|
|
52
55
|
if (parsed instanceof NextResponse) return parsed
|
|
53
56
|
|
|
57
|
+
const policy = await resolveAttachmentRolePolicy(em, {
|
|
58
|
+
organizationId: principal.organizationId,
|
|
59
|
+
tenantId: principal.tenantId,
|
|
60
|
+
submissionId,
|
|
61
|
+
role: principal.role,
|
|
62
|
+
}, container.resolve('formVersionCompiler') as FormVersionCompiler,
|
|
63
|
+
container.resolve('formsRolePolicyService') as RolePolicyService)
|
|
64
|
+
if (!policy?.canWrite(parsed.fieldKey)) {
|
|
65
|
+
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
const config = await resolveFieldUploadConfig(em, {
|
|
55
69
|
organizationId: principal.organizationId,
|
|
56
70
|
tenantId: principal.tenantId,
|
|
@@ -90,11 +104,12 @@ const uploadResponseSchema = z.object({
|
|
|
90
104
|
|
|
91
105
|
const postMethodDoc: OpenApiMethodDoc = {
|
|
92
106
|
summary: 'Upload an attachment (public/anonymous)',
|
|
93
|
-
description: 'Stores an encrypted file for a file-typed field. Authorized via access token or portal session.',
|
|
107
|
+
description: 'Stores an encrypted file for a file-typed field the participant role can edit in the submission’s pinned version. Authorized via access token or portal session.',
|
|
94
108
|
tags: ['Forms Public Runtime'],
|
|
95
109
|
responses: [{ status: 201, description: 'Attachment stored', schema: uploadResponseSchema }],
|
|
96
110
|
errors: [
|
|
97
111
|
{ status: 401, description: 'Missing or invalid access token / session', schema: errorSchema },
|
|
112
|
+
{ status: 404, description: 'Field not found or not editable by the participant role', schema: errorSchema },
|
|
98
113
|
{ status: 413, description: 'File empty or oversize', schema: errorSchema },
|
|
99
114
|
{ status: 422, description: 'Disallowed type / invalid field / scan rejected', schema: errorSchema },
|
|
100
115
|
{ status: 429, description: 'Rate limit exceeded', schema: errorSchema },
|