mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(auth): dynamic signup/login ban lists via AWS AppConfig (#4911)
* feat(auth): dynamic signup/login ban lists via AWS AppConfig - Move blocked-domain/allowlist/MX gating from env vars into AWS AppConfig (queried at runtime via the AppConfig Data SDK with a 30s in-process cache); env vars remain a fallback for self-hosted/OSS. - Add a new bannedEmails denylist that blocks a specific address at both sign-in and sign-up. - Generic, profile-agnostic AppConfig client so future config (feature flags) reuses the same plumbing; AppConfig client shares the same credential resolution as the S3 client. - Defense in depth: authenticateApiKeyFromHeader now rejects keys belonging to banned users. * fix(auth): scope bannedEmails to signup only; harden AppConfig cache - Remove bannedEmails sign-in check: better-auth's admin plugin already blocks banned users at sign-in (session.create.before, all providers). bannedEmails is now a signup-only denylist via user.create.before, which also closed the OAuth/email-OTP sign-in bypass the bots flagged. - AppConfig cache: track a 'loaded' flag so an empty/unseeded profile warms the cache instead of re-polling on every request; honor NextPollIntervalInSeconds to avoid throttling; dedupe concurrent cold fetches behind one in-flight poll to avoid racing the rotating session token. * refactor(auth): drop bannedEmails; gate AppConfig on isHosted - Remove the bannedEmails denylist entirely (better-auth banning + blockedSignupDomains cover the cases). - Move isAppConfigEnabled into feature-flags.ts and gate it on isHosted, so AppConfig is hosted-only; self-hosted/OSS always uses the env-var fallback and never constructs the AWS client. * fix(appconfig): preserve session token on parse error Narrow the token-resetting catch to only the network calls. A JSON/parse failure no longer discards the already-rotated session token (the round trip succeeded), so the next poll reuses it instead of opening a new StartConfigurationSession.
This commit is contained in:
@@ -108,6 +108,18 @@ describe('authenticateApiKeyFromHeader', () => {
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns invalid when the key belongs to a banned user', async () => {
|
||||
const record = personalKeyRecord({ userBanned: true })
|
||||
dbChainMockFns.where.mockResolvedValueOnce([record])
|
||||
|
||||
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Invalid API key' })
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns invalid when the hash lookup finds no row', async () => {
|
||||
dbChainMockFns.where.mockResolvedValueOnce([])
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey as apiKeyTable } from '@sim/db/schema'
|
||||
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { hashApiKey } from '@/lib/api-key/crypto'
|
||||
@@ -47,6 +47,7 @@ interface HashCandidate {
|
||||
workspaceId: string | null
|
||||
type: string
|
||||
expiresAt: Date | null
|
||||
userBanned: boolean | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,8 +83,10 @@ export async function authenticateApiKeyFromHeader(
|
||||
workspaceId: apiKeyTable.workspaceId,
|
||||
type: apiKeyTable.type,
|
||||
expiresAt: apiKeyTable.expiresAt,
|
||||
userBanned: userTable.banned,
|
||||
})
|
||||
.from(apiKeyTable)
|
||||
.leftJoin(userTable, eq(apiKeyTable.userId, userTable.id))
|
||||
.where(eq(apiKeyTable.keyHash, keyHash))
|
||||
|
||||
if (rows.length === 0) return INVALID
|
||||
@@ -91,6 +94,9 @@ export async function authenticateApiKeyFromHeader(
|
||||
const record = rows[0]
|
||||
const keyType = record.type as 'personal' | 'workspace'
|
||||
|
||||
// Defense in depth: banning deletes a user's keys, but reject any survivor too.
|
||||
if (record.userBanned) return INVALID
|
||||
|
||||
if (options.userId && record.userId !== options.userId) return INVALID
|
||||
if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID
|
||||
if (record.expiresAt && record.expiresAt < new Date()) return INVALID
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AccessControlConfig } from '@/lib/auth/access-control'
|
||||
|
||||
const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({
|
||||
mockFetch: vi.fn(),
|
||||
envRef: {
|
||||
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
|
||||
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
|
||||
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
|
||||
ALLOWED_LOGIN_EMAILS: undefined as string | undefined,
|
||||
ALLOWED_LOGIN_DOMAINS: undefined as string | undefined,
|
||||
BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined,
|
||||
},
|
||||
flagRef: { isAppConfigEnabled: false },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/appconfig', () => ({
|
||||
fetchAppConfigProfile: mockFetch,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
get env() {
|
||||
return envRef
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
get isAppConfigEnabled() {
|
||||
return flagRef.isAppConfigEnabled
|
||||
},
|
||||
}))
|
||||
|
||||
import { getAccessControlConfig } from '@/lib/auth/access-control'
|
||||
|
||||
const empty: AccessControlConfig = {
|
||||
blockedSignupDomains: [],
|
||||
allowedLoginEmails: [],
|
||||
allowedLoginDomains: [],
|
||||
blockedEmailMxHosts: [],
|
||||
}
|
||||
|
||||
describe('getAccessControlConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
flagRef.isAppConfigEnabled = false
|
||||
Object.assign(envRef, {
|
||||
BLOCKED_SIGNUP_DOMAINS: undefined,
|
||||
ALLOWED_LOGIN_EMAILS: undefined,
|
||||
ALLOWED_LOGIN_DOMAINS: undefined,
|
||||
BLOCKED_EMAIL_MX_HOSTS: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
describe('env fallback (AppConfig disabled)', () => {
|
||||
it('returns empty lists when nothing is set', async () => {
|
||||
expect(await getAccessControlConfig()).toEqual(empty)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('parses, trims, lowercases, and dedupes csv env vars', async () => {
|
||||
envRef.BLOCKED_SIGNUP_DOMAINS = 'Gmail.com, yahoo.com ,gmail.com,'
|
||||
envRef.ALLOWED_LOGIN_DOMAINS = 'Sim.ai'
|
||||
const result = await getAccessControlConfig()
|
||||
expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com'])
|
||||
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppConfig source (enabled)', () => {
|
||||
beforeEach(() => {
|
||||
flagRef.isAppConfigEnabled = true
|
||||
})
|
||||
|
||||
it('reads the access-control profile and normalizes the payload', async () => {
|
||||
mockFetch.mockImplementation((_ids, parse) =>
|
||||
Promise.resolve(
|
||||
parse({
|
||||
blockedSignupDomains: ['X.com'],
|
||||
allowedLoginDomains: ['sim.ai'],
|
||||
blockedEmailMxHosts: 'not-an-array',
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const result = await getAccessControlConfig()
|
||||
expect(result.blockedSignupDomains).toEqual(['x.com'])
|
||||
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
|
||||
expect(result.blockedEmailMxHosts).toEqual([])
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
{ application: 'sim-staging', environment: 'staging', profile: 'access-control' },
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to env vars when the fetch yields null', async () => {
|
||||
envRef.BLOCKED_SIGNUP_DOMAINS = 'spam.example'
|
||||
mockFetch.mockResolvedValue(null)
|
||||
const result = await getAccessControlConfig()
|
||||
expect(result.blockedSignupDomains).toEqual(['spam.example'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isAppConfigEnabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
/**
|
||||
* Name of the AppConfig configuration profile holding the signup/login gating
|
||||
* lists. This is a cross-repo contract: it must match the `CfnConfigurationProfile`
|
||||
* name created by the infra stack.
|
||||
*/
|
||||
const ACCESS_CONTROL_PROFILE = 'access-control'
|
||||
|
||||
/**
|
||||
* Normalized signup/login gating lists. All entries are trimmed, lowercased, and
|
||||
* de-duplicated. Domains are bare hostnames; MX hosts are substrings matched
|
||||
* against resolved MX exchanges; emails are full addresses.
|
||||
*/
|
||||
export interface AccessControlConfig {
|
||||
blockedSignupDomains: string[]
|
||||
allowedLoginEmails: string[]
|
||||
allowedLoginDomains: string[]
|
||||
blockedEmailMxHosts: string[]
|
||||
}
|
||||
|
||||
function normalizeList(values: unknown): string[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
return Array.from(new Set(values.map((v) => String(v).trim().toLowerCase()).filter(Boolean)))
|
||||
}
|
||||
|
||||
function parseCsv(value: string | undefined): string[] {
|
||||
return normalizeList(value?.split(','))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback source for self-hosted/OSS/local deployments that have no AppConfig.
|
||||
* Reads the same env vars the app used before AppConfig.
|
||||
*/
|
||||
function fromEnv(): AccessControlConfig {
|
||||
return {
|
||||
blockedSignupDomains: parseCsv(env.BLOCKED_SIGNUP_DOMAINS),
|
||||
allowedLoginEmails: parseCsv(env.ALLOWED_LOGIN_EMAILS),
|
||||
allowedLoginDomains: parseCsv(env.ALLOWED_LOGIN_DOMAINS),
|
||||
blockedEmailMxHosts: parseCsv(env.BLOCKED_EMAIL_MX_HOSTS),
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfig(json: unknown): AccessControlConfig {
|
||||
const obj = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
|
||||
return {
|
||||
blockedSignupDomains: normalizeList(obj.blockedSignupDomains),
|
||||
allowedLoginEmails: normalizeList(obj.allowedLoginEmails),
|
||||
allowedLoginDomains: normalizeList(obj.allowedLoginDomains),
|
||||
blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current signup/login gating lists. Reads from AWS AppConfig on
|
||||
* hosted deployments (cached, ~30s TTL, never blocks after the first fetch),
|
||||
* otherwise falls back to env vars so self-hosted/OSS works with no AWS.
|
||||
*/
|
||||
export async function getAccessControlConfig(): Promise<AccessControlConfig> {
|
||||
if (!isAppConfigEnabled) return fromEnv()
|
||||
|
||||
const value = await fetchAppConfigProfile(
|
||||
{
|
||||
application: env.APPCONFIG_APPLICATION as string,
|
||||
environment: env.APPCONFIG_ENVIRONMENT as string,
|
||||
profile: ACCESS_CONTROL_PROFILE,
|
||||
},
|
||||
parseConfig
|
||||
)
|
||||
|
||||
return value ?? fromEnv()
|
||||
}
|
||||
+36
-47
@@ -30,6 +30,7 @@ import {
|
||||
renderPasswordResetEmail,
|
||||
renderWelcomeEmail,
|
||||
} from '@/components/emails'
|
||||
import { getAccessControlConfig } from '@/lib/auth/access-control'
|
||||
import { sendPlanWelcomeEmail } from '@/lib/billing'
|
||||
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
|
||||
import {
|
||||
@@ -137,16 +138,6 @@ function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, provi
|
||||
}
|
||||
}
|
||||
|
||||
const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS
|
||||
? Array.from(
|
||||
new Set(
|
||||
env.BLOCKED_SIGNUP_DOMAINS.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
: null
|
||||
|
||||
export function isEmailInDenylist(
|
||||
email: string | undefined | null,
|
||||
denylist: readonly string[] | null
|
||||
@@ -157,10 +148,6 @@ export function isEmailInDenylist(
|
||||
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
|
||||
}
|
||||
|
||||
function isSignupEmailBlocked(email: string | undefined | null): boolean {
|
||||
return isEmailInDenylist(email, blockedSignupDomains)
|
||||
}
|
||||
|
||||
const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
|
||||
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
|
||||
)
|
||||
@@ -246,7 +233,8 @@ export const auth = betterAuth({
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
if (isSignupEmailBlocked(user.email)) {
|
||||
const accessControl = await getAccessControlConfig()
|
||||
if (isEmailInDenylist(user.email, accessControl.blockedSignupDomains)) {
|
||||
throw new Error('Sign-ups from this email domain are not allowed.')
|
||||
}
|
||||
return { data: user }
|
||||
@@ -813,51 +801,52 @@ export const auth = betterAuth({
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
(ctx.path.startsWith('/sign-in') || ctx.path.startsWith('/sign-up')) &&
|
||||
(env.ALLOWED_LOGIN_EMAILS || env.ALLOWED_LOGIN_DOMAINS)
|
||||
) {
|
||||
const isSignIn = ctx.path.startsWith('/sign-in')
|
||||
const isSignUp = ctx.path.startsWith('/sign-up')
|
||||
|
||||
if (isSignIn || isSignUp) {
|
||||
const accessControl = await getAccessControlConfig()
|
||||
const requestEmail = ctx.body?.email?.toLowerCase()
|
||||
|
||||
if (requestEmail) {
|
||||
let isAllowed = false
|
||||
|
||||
if (env.ALLOWED_LOGIN_EMAILS) {
|
||||
const allowedEmails = env.ALLOWED_LOGIN_EMAILS.split(',').map((email) =>
|
||||
email.trim().toLowerCase()
|
||||
)
|
||||
isAllowed = allowedEmails.includes(requestEmail)
|
||||
}
|
||||
|
||||
if (!isAllowed && env.ALLOWED_LOGIN_DOMAINS) {
|
||||
const allowedDomains = env.ALLOWED_LOGIN_DOMAINS.split(',').map((domain) =>
|
||||
domain.trim().toLowerCase()
|
||||
)
|
||||
const emailDomain = requestEmail.split('@')[1]
|
||||
isAllowed = emailDomain && allowedDomains.includes(emailDomain)
|
||||
}
|
||||
|
||||
// Banning an existing account is owned by better-auth's admin plugin (a
|
||||
// `session.create.before` hook that blocks banned users at sign-in across
|
||||
// all providers), so it is not re-checked here.
|
||||
const hasAllowlist =
|
||||
accessControl.allowedLoginEmails.length > 0 ||
|
||||
accessControl.allowedLoginDomains.length > 0
|
||||
if (hasAllowlist && requestEmail) {
|
||||
const emailDomain = requestEmail.split('@')[1]
|
||||
const isAllowed =
|
||||
accessControl.allowedLoginEmails.includes(requestEmail) ||
|
||||
(!!emailDomain && accessControl.allowedLoginDomains.includes(emailDomain))
|
||||
if (!isAllowed) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'Access restricted. Please contact your administrator.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.path.startsWith('/sign-up') && isSignupEmailBlocked(ctx.body?.email)) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'Sign-ups from this email domain are not allowed.',
|
||||
})
|
||||
}
|
||||
|
||||
if (isSignupMxValidationEnabled && ctx.path.startsWith('/sign-up/email') && ctx.body?.email) {
|
||||
const mxCheck = await validateSignupEmailMx(ctx.body.email)
|
||||
if (!mxCheck.allowed) {
|
||||
if (isSignUp && isEmailInDenylist(ctx.body?.email, accessControl.blockedSignupDomains)) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'Sign-ups from this email domain are not allowed.',
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
isSignupMxValidationEnabled &&
|
||||
ctx.path.startsWith('/sign-up/email') &&
|
||||
ctx.body?.email
|
||||
) {
|
||||
const mxCheck = await validateSignupEmailMx(
|
||||
ctx.body.email,
|
||||
accessControl.blockedEmailMxHosts
|
||||
)
|
||||
if (!mxCheck.allowed) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'Sign-ups from this email domain are not allowed.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.path === '/sign-up/email' && ctx.body?.email) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockSend } = vi.hoisted(() => ({
|
||||
mockSend: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@aws-sdk/client-appconfigdata', () => ({
|
||||
AppConfigDataClient: class {
|
||||
send = mockSend
|
||||
},
|
||||
StartConfigurationSessionCommand: class {
|
||||
__type = 'start'
|
||||
constructor(public input: unknown) {}
|
||||
},
|
||||
GetLatestConfigurationCommand: class {
|
||||
__type = 'get'
|
||||
constructor(public input: unknown) {}
|
||||
},
|
||||
}))
|
||||
|
||||
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
|
||||
|
||||
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value))
|
||||
|
||||
let counter = 0
|
||||
/** Unique identifiers per test so the module-level cache never bleeds across tests. */
|
||||
function uniqueIds() {
|
||||
counter += 1
|
||||
return { application: `app-${counter}`, environment: `env-${counter}`, profile: 'access-control' }
|
||||
}
|
||||
|
||||
describe('fetchAppConfigProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts a session then returns the parsed configuration', async () => {
|
||||
mockSend.mockImplementation((command: { __type: string }) => {
|
||||
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
|
||||
return Promise.resolve({
|
||||
Configuration: encode({ blockedSignupDomains: ['spam.example'] }),
|
||||
NextPollConfigurationToken: 'tok-2',
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fetchAppConfigProfile(
|
||||
uniqueIds(),
|
||||
(json) => json as Record<string, unknown>
|
||||
)
|
||||
expect(result).toEqual({ blockedSignupDomains: ['spam.example'] })
|
||||
|
||||
const sentTypes = mockSend.mock.calls.map(([c]) => c.__type)
|
||||
expect(sentTypes).toEqual(['start', 'get'])
|
||||
})
|
||||
|
||||
it('returns null when the cold fetch fails (never throws)', async () => {
|
||||
mockSend.mockRejectedValue(new Error('appconfig down'))
|
||||
const result = await fetchAppConfigProfile(uniqueIds(), (json) => json)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('applies the parse function to the decoded JSON', async () => {
|
||||
mockSend.mockImplementation((command: { __type: string }) => {
|
||||
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
|
||||
return Promise.resolve({
|
||||
Configuration: encode({ count: 2 }),
|
||||
NextPollConfigurationToken: 'tok-2',
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fetchAppConfigProfile(
|
||||
uniqueIds(),
|
||||
(json) => (json as { count: number }).count * 10
|
||||
)
|
||||
expect(result).toBe(20)
|
||||
})
|
||||
|
||||
it('warms the cache on an empty payload and does not re-poll (unseeded profile)', async () => {
|
||||
mockSend.mockImplementation((command: { __type: string }) => {
|
||||
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
|
||||
return Promise.resolve({
|
||||
Configuration: new Uint8Array(),
|
||||
NextPollConfigurationToken: 'tok-2',
|
||||
NextPollIntervalInSeconds: 60,
|
||||
})
|
||||
})
|
||||
|
||||
const ids = uniqueIds()
|
||||
expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull()
|
||||
const callsAfterFirst = mockSend.mock.calls.length
|
||||
|
||||
expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull()
|
||||
expect(mockSend.mock.calls.length).toBe(callsAfterFirst)
|
||||
})
|
||||
|
||||
it('keeps the session on a parse error (no re-StartConfigurationSession, no throw)', async () => {
|
||||
mockSend.mockImplementation((command: { __type: string }) => {
|
||||
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
|
||||
return Promise.resolve({
|
||||
Configuration: new TextEncoder().encode('not json{'),
|
||||
NextPollConfigurationToken: 'tok-2',
|
||||
NextPollIntervalInSeconds: 60,
|
||||
})
|
||||
})
|
||||
|
||||
const ids = uniqueIds()
|
||||
expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull()
|
||||
|
||||
// Network round trip succeeded, so exactly one session was started despite the
|
||||
// parse failure — the rotated token was preserved, not discarded.
|
||||
expect(mockSend.mock.calls.filter(([c]) => c.__type === 'start')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('dedupes concurrent cold fetches into a single poll', async () => {
|
||||
mockSend.mockImplementation((command: { __type: string }) => {
|
||||
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
|
||||
return Promise.resolve({
|
||||
Configuration: encode({ x: 1 }),
|
||||
NextPollConfigurationToken: 'tok-2',
|
||||
})
|
||||
})
|
||||
|
||||
const ids = uniqueIds()
|
||||
const [a, b] = await Promise.all([
|
||||
fetchAppConfigProfile(ids, (json) => json),
|
||||
fetchAppConfigProfile(ids, (json) => json),
|
||||
])
|
||||
|
||||
expect(a).toEqual({ x: 1 })
|
||||
expect(b).toEqual({ x: 1 })
|
||||
expect(mockSend.mock.calls.map(([c]) => c.__type)).toEqual(['start', 'get'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
AppConfigDataClient,
|
||||
GetLatestConfigurationCommand,
|
||||
type GetLatestConfigurationCommandOutput,
|
||||
StartConfigurationSessionCommand,
|
||||
} from '@aws-sdk/client-appconfigdata'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('AppConfig')
|
||||
|
||||
const DEFAULT_TTL_MS = 30_000
|
||||
|
||||
export interface AppConfigProfileIdentifiers {
|
||||
application: string
|
||||
environment: string
|
||||
profile: string
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
/** Last successfully parsed value, or `null` if the config is empty/unseeded. */
|
||||
value: T | null
|
||||
/** True once any poll has completed (success, empty payload, or error). */
|
||||
loaded: boolean
|
||||
/** Token for the next `GetLatestConfiguration` poll, rotated on each call. */
|
||||
nextToken: string | undefined
|
||||
expiresAt: number
|
||||
/** In-flight poll, shared so concurrent callers don't each hit AppConfig. */
|
||||
inflight: Promise<T | null> | null
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry<unknown>>()
|
||||
|
||||
let client: AppConfigDataClient | null = null
|
||||
|
||||
/**
|
||||
* Lazily construct the AppConfig data-plane client. Never instantiated unless a
|
||||
* caller actually fetches a profile, so deployments without AppConfig configured
|
||||
* never reach for AWS credentials.
|
||||
*/
|
||||
function getClient(): AppConfigDataClient {
|
||||
if (!client) {
|
||||
client = new AppConfigDataClient({
|
||||
region: env.AWS_REGION,
|
||||
credentials: getAwsCredentialsFromEnv(),
|
||||
})
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
function cacheKey(ids: AppConfigProfileIdentifiers): string {
|
||||
return `${ids.application}/${ids.environment}/${ids.profile}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one AppConfig poll for `entry`: starts a session if no token is held, then
|
||||
* calls `GetLatestConfiguration`. An empty payload means "unchanged" (or an
|
||||
* unseeded profile) and the previous value is kept. Any error is logged and the
|
||||
* last good value is retained. Marks the entry `loaded` on any outcome so callers
|
||||
* never re-block on the cold path, and honors AppConfig's `NextPollInterval` so we
|
||||
* don't poll faster than the server allows (which would throttle).
|
||||
*/
|
||||
async function poll<T>(
|
||||
ids: AppConfigProfileIdentifiers,
|
||||
parse: (json: unknown) => T,
|
||||
entry: CacheEntry<T>
|
||||
): Promise<T | null> {
|
||||
let response: GetLatestConfigurationCommandOutput
|
||||
try {
|
||||
const dataClient = getClient()
|
||||
|
||||
if (!entry.nextToken) {
|
||||
const session = await dataClient.send(
|
||||
new StartConfigurationSessionCommand({
|
||||
ApplicationIdentifier: ids.application,
|
||||
EnvironmentIdentifier: ids.environment,
|
||||
ConfigurationProfileIdentifier: ids.profile,
|
||||
})
|
||||
)
|
||||
entry.nextToken = session.InitialConfigurationToken
|
||||
}
|
||||
|
||||
response = await dataClient.send(
|
||||
new GetLatestConfigurationCommand({ ConfigurationToken: entry.nextToken })
|
||||
)
|
||||
entry.nextToken = response.NextPollConfigurationToken ?? entry.nextToken
|
||||
} catch (error) {
|
||||
// Network/session failure: drop the token so the next attempt starts a fresh
|
||||
// session (handles expired or invalid tokens). Mark loaded + back off so we
|
||||
// serve the fallback and retry in the background rather than blocking every
|
||||
// request during an outage.
|
||||
entry.nextToken = undefined
|
||||
entry.expiresAt = Date.now() + DEFAULT_TTL_MS
|
||||
entry.loaded = true
|
||||
logger.error('AppConfig fetch failed; serving last known value', {
|
||||
profile: cacheKey(ids),
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return entry.value
|
||||
}
|
||||
|
||||
// Parse outside the network try: a decode/parse error must NOT discard the
|
||||
// already-rotated session token — the round trip succeeded, so the next poll
|
||||
// can reuse it instead of opening a new session. Keep the last good value.
|
||||
try {
|
||||
if (response.Configuration && response.Configuration.length > 0) {
|
||||
const text = new TextDecoder().decode(response.Configuration)
|
||||
entry.value = parse(JSON.parse(text))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('AppConfig response parse failed; serving last known value', {
|
||||
profile: cacheKey(ids),
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
const intervalMs = (response.NextPollIntervalInSeconds ?? 60) * 1000
|
||||
entry.expiresAt = Date.now() + Math.max(DEFAULT_TTL_MS, intervalMs)
|
||||
entry.loaded = true
|
||||
return entry.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache a single AppConfig configuration profile as JSON.
|
||||
*
|
||||
* Profile-agnostic: pass the `application`/`environment` (from env) and a
|
||||
* `profile` constant owned by the calling feature. Uses an in-process TTL cache
|
||||
* with stale-while-revalidate — a warm cache returns immediately and refreshes
|
||||
* in the background once the TTL lapses, so no request blocks on the AppConfig
|
||||
* round trip after the first (cold) fetch. Concurrent callers share one in-flight
|
||||
* poll (avoids racing the rotating session token). Returns `null` when the config
|
||||
* is empty/unseeded or the first fetch fails.
|
||||
*/
|
||||
export async function fetchAppConfigProfile<T>(
|
||||
ids: AppConfigProfileIdentifiers,
|
||||
parse: (json: unknown) => T
|
||||
): Promise<T | null> {
|
||||
const key = cacheKey(ids)
|
||||
const entry = (cache.get(key) as CacheEntry<T> | undefined) ?? {
|
||||
value: null,
|
||||
loaded: false,
|
||||
nextToken: undefined,
|
||||
expiresAt: 0,
|
||||
inflight: null,
|
||||
}
|
||||
cache.set(key, entry)
|
||||
|
||||
// Cold: never polled — await a single shared poll so concurrent callers don't
|
||||
// each hit AppConfig (and don't race the rotating session token).
|
||||
if (!entry.loaded) {
|
||||
entry.inflight ??= poll(ids, parse, entry).finally(() => {
|
||||
entry.inflight = null
|
||||
})
|
||||
return entry.inflight
|
||||
}
|
||||
|
||||
// Warm but stale: serve cached value, refresh once in the background.
|
||||
if (Date.now() >= entry.expiresAt && !entry.inflight) {
|
||||
entry.inflight = poll(ids, parse, entry).finally(() => {
|
||||
entry.inflight = null
|
||||
})
|
||||
}
|
||||
|
||||
return entry.value
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
interface AwsCredentials {
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit AWS credentials from the environment, or `undefined` to defer to the
|
||||
* default AWS provider chain (the ECS task role in our deployments).
|
||||
*
|
||||
* Shared by every AWS SDK client (S3, AppConfig, …) so credential resolution is
|
||||
* identical everywhere: explicit keys when both `AWS_ACCESS_KEY_ID` and
|
||||
* `AWS_SECRET_ACCESS_KEY` are set (self-hosted, trigger.dev workers), otherwise
|
||||
* the instance/task role.
|
||||
*/
|
||||
export function getAwsCredentialsFromEnv(): AwsCredentials | undefined {
|
||||
return env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
|
||||
? {
|
||||
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -222,6 +222,10 @@ export const env = createEnv({
|
||||
S3_ENDPOINT: z.string().optional(), // Custom endpoint for S3-compatible storage (Cloudflare R2, MinIO, Backblaze B2). Leave unset for AWS S3
|
||||
S3_FORCE_PATH_STYLE: z.string().optional(), // Force path-style addressing (MinIO/Ceph RGW). Defaults to false (AWS S3, R2). Coerced via envBoolean at the consumption site
|
||||
|
||||
// Dynamic config - AWS AppConfig (hosted source of truth for signup/login gating lists; unset => env-var fallback)
|
||||
APPCONFIG_APPLICATION: z.string().optional(), // AppConfig application id/name. On hosted deployments, when set with APPCONFIG_ENVIRONMENT, gating lists come from AppConfig instead of env vars
|
||||
APPCONFIG_ENVIRONMENT: z.string().optional(), // AppConfig environment id/name. Profile name is an app-side constant ('access-control'), not an env var
|
||||
|
||||
// Cloud Storage - Azure Blob
|
||||
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
|
||||
AZURE_ACCOUNT_KEY: z.string().optional(), // Azure storage account key
|
||||
|
||||
@@ -96,6 +96,15 @@ export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATI
|
||||
*/
|
||||
export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED)
|
||||
|
||||
/**
|
||||
* Is AWS AppConfig the source of truth for the signup/login gating lists.
|
||||
* Hosted-only and requires both AppConfig identifiers (injected by the infra
|
||||
* stack). Self-hosted/OSS deployments always use the env-var fallback, so the
|
||||
* AppConfig client is never reached off-hosted.
|
||||
*/
|
||||
export const isAppConfigEnabled =
|
||||
isHosted && Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT)
|
||||
|
||||
/**
|
||||
* Is Trigger.dev enabled for async job processing
|
||||
*/
|
||||
|
||||
@@ -3,23 +3,14 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockResolveMx, envRef } = vi.hoisted(() => ({
|
||||
const { mockResolveMx } = vi.hoisted(() => ({
|
||||
mockResolveMx: vi.fn(),
|
||||
envRef: {
|
||||
BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('dns/promises', () => ({
|
||||
default: { resolveMx: mockResolveMx },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
get env() {
|
||||
return envRef
|
||||
},
|
||||
}))
|
||||
|
||||
import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
|
||||
|
||||
const mx = (...hosts: string[]) =>
|
||||
@@ -28,29 +19,29 @@ const mx = (...hosts: string[]) =>
|
||||
describe('validateSignupEmailMx', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
envRef.BLOCKED_EMAIL_MX_HOSTS = undefined
|
||||
})
|
||||
|
||||
it('blocks a domain whose MX backend is on the configured denylist', async () => {
|
||||
envRef.BLOCKED_EMAIL_MX_HOSTS = 'blocked-backend.example'
|
||||
mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example'))
|
||||
const result = await validateSignupEmailMx('user@rotated-domain.test')
|
||||
const result = await validateSignupEmailMx('user@rotated-domain.test', [
|
||||
'blocked-backend.example',
|
||||
])
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.reason).toBe('blocked_mx_backend')
|
||||
})
|
||||
|
||||
it('matches the denylist as a case-insensitive substring of the MX exchange', async () => {
|
||||
envRef.BLOCKED_EMAIL_MX_HOSTS = 'Blocked-Backend.Example'
|
||||
mockResolveMx.mockResolvedValue(mx('mx1.blocked-backend.example'))
|
||||
const result = await validateSignupEmailMx('user@another-domain.test')
|
||||
mockResolveMx.mockResolvedValue(mx('MX1.Blocked-Backend.Example'))
|
||||
const result = await validateSignupEmailMx('user@another-domain.test', [
|
||||
'blocked-backend.example',
|
||||
])
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.reason).toBe('blocked_mx_backend')
|
||||
})
|
||||
|
||||
it('does not block any backend when the denylist is empty (no hardcoded defaults)', async () => {
|
||||
envRef.BLOCKED_EMAIL_MX_HOSTS = undefined
|
||||
mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example'))
|
||||
const result = await validateSignupEmailMx('user@rotated-domain.test')
|
||||
const result = await validateSignupEmailMx('user@rotated-domain.test', [])
|
||||
expect(result.allowed).toBe(true)
|
||||
})
|
||||
|
||||
@@ -58,32 +49,32 @@ describe('validateSignupEmailMx', () => {
|
||||
mockResolveMx.mockResolvedValue(
|
||||
mx('gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com')
|
||||
)
|
||||
const result = await validateSignupEmailMx('real.person@gmail.com')
|
||||
const result = await validateSignupEmailMx('real.person@gmail.com', ['blocked-backend.example'])
|
||||
expect(result.allowed).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks a domain with no MX records (ENOTFOUND)', async () => {
|
||||
mockResolveMx.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }))
|
||||
const result = await validateSignupEmailMx('x@no-such-domain.invalid')
|
||||
const result = await validateSignupEmailMx('x@no-such-domain.invalid', [])
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.reason).toBe('no_mx')
|
||||
})
|
||||
|
||||
it('blocks a domain that resolves to an empty MX set', async () => {
|
||||
mockResolveMx.mockResolvedValue([])
|
||||
const result = await validateSignupEmailMx('x@empty.example')
|
||||
const result = await validateSignupEmailMx('x@empty.example', [])
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.reason).toBe('no_mx')
|
||||
})
|
||||
|
||||
it('fails open on a transient DNS error (does not block legit users)', async () => {
|
||||
mockResolveMx.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' }))
|
||||
const result = await validateSignupEmailMx('user@some-real-domain.com')
|
||||
const result = await validateSignupEmailMx('user@some-real-domain.com', [])
|
||||
expect(result.allowed).toBe(true)
|
||||
})
|
||||
|
||||
it('allows when the email has no domain (defers to other validation)', async () => {
|
||||
const result = await validateSignupEmailMx('not-an-email')
|
||||
const result = await validateSignupEmailMx('not-an-email', [])
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(mockResolveMx).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -2,29 +2,11 @@ import type { MxRecord } from 'dns'
|
||||
import dns from 'dns/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('EmailValidationServer')
|
||||
|
||||
const MX_LOOKUP_TIMEOUT_MS = 3000
|
||||
|
||||
/**
|
||||
* MX-host substrings to block, supplied at runtime via `BLOCKED_EMAIL_MX_HOSTS`.
|
||||
*
|
||||
* Signup-spam botnets rotate throwaway domains rapidly but funnel them through a
|
||||
* small number of shared catch-all mail providers, so the resolved MX host is a
|
||||
* far more stable signal than the domain itself. Each entry is matched as a
|
||||
* case-insensitive substring against the domain's resolved MX exchanges. No
|
||||
* hosts are hardcoded — operators configure their own denylist out of band.
|
||||
*/
|
||||
function getBlockedMxHosts(): string[] {
|
||||
return (
|
||||
env.BLOCKED_EMAIL_MX_HOSTS?.split(',')
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export interface SignupEmailCheck {
|
||||
/** Whether the email may proceed to signup. */
|
||||
allowed: boolean
|
||||
@@ -41,10 +23,18 @@ export interface SignupEmailCheck {
|
||||
* users are never blocked by an infrastructure blip. Only a definitive
|
||||
* "domain has no MX" answer (`ENOTFOUND` / `ENODATA`) blocks.
|
||||
*
|
||||
* `blockedMxHosts` are case-insensitive substrings matched against each resolved
|
||||
* MX exchange — signup-spam botnets rotate throwaway domains but funnel them
|
||||
* through a few shared catch-all backends, so the MX host is a more stable signal
|
||||
* than the domain. Sourced from access-control config (AppConfig or env fallback).
|
||||
*
|
||||
* Server-only — imports `dns/promises`. Never import from client code. Gated by the caller
|
||||
* behind `isSignupMxValidationEnabled`; this function performs the check unconditionally.
|
||||
*/
|
||||
export async function validateSignupEmailMx(email: string): Promise<SignupEmailCheck> {
|
||||
export async function validateSignupEmailMx(
|
||||
email: string,
|
||||
blockedMxHosts: string[]
|
||||
): Promise<SignupEmailCheck> {
|
||||
const domain = email.split('@')[1]?.toLowerCase()
|
||||
if (!domain) return { allowed: true }
|
||||
|
||||
@@ -80,10 +70,9 @@ export async function validateSignupEmailMx(email: string): Promise<SignupEmailC
|
||||
return { allowed: false, reason: 'no_mx' }
|
||||
}
|
||||
|
||||
const blocked = getBlockedMxHosts()
|
||||
const match = records.find((record) => {
|
||||
const exchange = record.exchange.toLowerCase()
|
||||
return blocked.some((host) => exchange.includes(host))
|
||||
return blockedMxHosts.some((host) => exchange.includes(host))
|
||||
})
|
||||
|
||||
if (match) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
|
||||
import {
|
||||
assertKnownSizeWithinLimit,
|
||||
readNodeStreamToBufferWithLimit,
|
||||
@@ -57,13 +57,7 @@ export function getS3Client(): S3Client {
|
||||
region,
|
||||
endpoint: S3_CONFIG.endpoint,
|
||||
forcePathStyle: S3_CONFIG.forcePathStyle,
|
||||
credentials:
|
||||
env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
|
||||
? {
|
||||
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
||||
}
|
||||
: undefined,
|
||||
credentials: getAwsCredentialsFromEnv(),
|
||||
})
|
||||
|
||||
return _s3Client
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@1password/sdk": "0.3.1",
|
||||
"@a2a-js/sdk": "0.3.7",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@aws-sdk/client-appconfigdata": "3.1032.0",
|
||||
"@aws-sdk/client-athena": "3.1032.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1032.0",
|
||||
"@aws-sdk/client-cloudformation": "3.1032.0",
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"@1password/sdk": "0.3.1",
|
||||
"@a2a-js/sdk": "0.3.7",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@aws-sdk/client-appconfigdata": "3.1032.0",
|
||||
"@aws-sdk/client-athena": "3.1032.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1032.0",
|
||||
"@aws-sdk/client-cloudformation": "3.1032.0",
|
||||
@@ -560,6 +561,8 @@
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-appconfigdata": ["@aws-sdk/client-appconfigdata@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-gh/cmEFDN97XJBWRLT0usnWnTDEm+cqgOIffTJGP68xCgj28EkSHnN5vtdy2QaZjj7/n/sKOlqIKONZUeonRpA=="],
|
||||
|
||||
"@aws-sdk/client-athena": ["@aws-sdk/client-athena@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/3RrC4J644U1ZlqcGyGCRf2cyCH/xWs2B6PewlKWeyTq2uWSRtY+v5CkEQ51fRm2Y5wfhuxoU9FO1jKIKm9fSA=="],
|
||||
|
||||
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/token-providers": "3.1032.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fSRz/48As9c3DeS+9ZWd7kk9171pJntCCuehHBDeprD9CPF+C+ATaVNJ5SOLE5RIBR2IHOVTwjAgJt/nkS/6Yg=="],
|
||||
|
||||
Reference in New Issue
Block a user